diff --git a/.gitignore b/.gitignore index e90c6ba..3c71615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -web/dist/ sidecar/claude-sdk/dist/ target/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 578af4b..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "vendor/eve"] - path = vendor/eve - url = https://github.com/GameCult/Eve.git diff --git a/AGENTS.md b/AGENTS.md index d258e9c..f0e4406 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ Ghostlight is a Rust world kernel and the play daemon built on it. Models propose; the kernel admits or refuses. The same kernel is meant to run the simulation behind Aetheria's public story, a persistent cast and storylets, as -well as Ghostlight Dungeon, the single-player DM-led client. `README.md` says +well as Ghostlight Dungeon, the single-player DM-led client (parked at +tag `parked/ghostlight-eve-surface`). `README.md` says what exists today and what does not. ## Where State Lives @@ -55,28 +56,21 @@ what exists today and what does not. - Kernel: `crates/ghostlight/src/`. Its vocabulary and current mechanism: `docs/architecture/ghostlight-world-ontology.md`. The consumer contract: `docs/architecture/ghostlight-world-consumer-api.md`. -- Dungeon daemon: `crates/ghostlight-dungeon/src/` (`runtime.rs` for routes and - commands, `play.rs` for the play table, `eve.rs` for the surface). - Interface authority: `docs/architecture/ghostlight-eve-native-interface.md`. +- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe): + tag `parked/ghostlight-eve-surface`; not on main. - Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`. -- Browser host: `web/`. Claude SDK sidecar: `sidecar/claude-sdk/`. -- Architecture rationale: `docs/architecture/ghostlight-dungeon-mvp.md`. +- Claude SDK sidecar: `sidecar/claude-sdk/`. +- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`. `notes/ghostlight-implementation-plan.md` is the historical plan of the 2026-09 rebuild, not the current plan. ## Build, Verify, Deploy -Ghostlight Dungeon ships to Linux on Yggdrasil. Idunn freezes an exact commit, -runs the locked native Linux tests, builds the daemon and web projection in -pinned containers, and seals the release. The steps are -`deployment/idunn/recipe.toml`; the owning runbook is -`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. - -You are probably reading this on a Windows workstation. That is where the shell -is, not where the artifact runs. A Windows compile is not evidence about the -Linux release; build for the target, or let the deploy path's builder do it, and -say which one you used. Heavy builds and test runs go to Yggdrasil, not the -workstation. Deploy only through Idunn (`idunn up ghostlight`). +Nothing deploys from this repo: the Idunn target `ghostlight` is braked and stopped (deployment and +lifecycle brakes engaged, unit stopped; retirement waits on +`ghostlight-verse:follow_up:idunn-v2-target-retirement`) and no recipe exists on main. Kernel and verse tests run on Linux on Yggdrasil (the +`ygg-verify` stopgap), not on the workstation; a Windows compile is not evidence +about Linux, so say which host you used. Do not run `idunn up ghostlight`. For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult `F:\Projects\gamecult-ops` before acting. diff --git a/Cargo.lock b/Cargo.lock index 892fe21..09a0b20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,15 +37,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - [[package]] name = "android_system_properties" version = "0.1.6" @@ -61,28 +52,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "async-trait" version = "0.1.92" @@ -106,70 +75,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "axum-macros", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "base64" version = "0.22.1" @@ -182,12 +87,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitflags" version = "2.13.1" @@ -288,12 +187,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -366,85 +259,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "cultmesh-rs" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191" -dependencies = [ - "anyhow", - "cultcache-rs", - "cultnet-rs", - "serde", -] - -[[package]] -name = "cultnet-rs" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191" -dependencies = [ - "aes-gcm", - "anyhow", - "base64 0.22.1", - "chrono", - "cultcache-rs", - "ed25519-dalek", - "hmac", - "rand 0.9.5", - "rmp-serde", - "rmpv", - "serde", - "serde_bytes", - "serde_json", - "sha2", - "socket2 0.5.10", - "uuid", - "windows-sys 0.61.2", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - [[package]] name = "digest" version = "0.10.7" @@ -453,7 +267,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", - "subtle", ] [[package]] @@ -467,30 +280,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - [[package]] name = "errno" version = "0.3.14" @@ -507,12 +296,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -621,18 +404,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -642,7 +413,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "rand_core 0.10.1", "wasm-bindgen", ] @@ -668,6 +439,7 @@ dependencies = [ "ghostlight-persona-projection", "reqwest", "rmp-serde", + "rmpv", "serde", "serde_bytes", "serde_json", @@ -676,44 +448,8 @@ dependencies = [ "thiserror", "tokio", "tracing", - "uuid", - "zeroize", -] - -[[package]] -name = "ghostlight-dungeon" -version = "0.1.0" -dependencies = [ - "aes-gcm", - "anyhow", - "async-stream", - "async-trait", - "axum", - "base64 0.22.1", - "chrono", - "codex-connector", - "cultcache-rs", - "cultmesh-rs", - "cultnet-rs", - "ghostlight", - "ghostlight-persona-projection", - "hmac", - "jsonwebtoken", - "libc", - "rand 0.9.5", - "reqwest", - "rmp-serde", - "serde", - "serde_bytes", - "serde_json", - "sha2", - "tempfile", - "thiserror", - "tokio", - "tower", - "tower-http", - "tracing", - "tracing-subscriber", + "unicode-normalization", + "unicode-properties", "uuid", "zeroize", ] @@ -728,12 +464,15 @@ dependencies = [ ] [[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +name = "ghostlight-verse" +version = "0.1.0" dependencies = [ - "digest", + "anyhow", + "chrono", + "ghostlight", + "serde_json", + "tempfile", + "tokio", ] [[package]] @@ -769,24 +508,12 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.11.0" @@ -800,7 +527,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -841,7 +567,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2", "tokio", "tower-service", "tracing", @@ -1007,27 +733,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.189" @@ -1067,43 +772,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "mio" version = "1.2.2" @@ -1115,40 +789,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1193,16 +833,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1215,16 +845,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "polyval" version = "0.6.2" @@ -1246,21 +866,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -1283,7 +888,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", + "socket2", "thiserror", "tokio", "tracing", @@ -1299,7 +904,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash", @@ -1321,7 +926,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2", "tracing", "windows-sys 0.61.2", ] @@ -1335,28 +940,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -1368,16 +957,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -1387,15 +966,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -1429,23 +999,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - [[package]] name = "reqwest" version = "0.12.28" @@ -1527,8 +1080,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" dependencies = [ "rmp", - "serde", - "serde_bytes", ] [[package]] @@ -1537,15 +1088,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1612,12 +1154,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.229" @@ -1671,17 +1207,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1705,15 +1230,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "2.0.1" @@ -1730,27 +1246,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -1763,16 +1258,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.5" @@ -1783,16 +1268,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1880,45 +1355,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.4" @@ -1956,7 +1392,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2008,7 +1444,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2019,23 +1454,13 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", - "futures-core", "futures-util", "http", "http-body", - "http-body-util", - "http-range-header", - "httpdate", - "mime", - "mime_guess", - "percent-encoding", "pin-project-lite", - "tokio", - "tokio-util", "tower", "tower-layer", "tower-service", - "tracing", "url", ] @@ -2057,7 +1482,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2081,36 +1505,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", ] [[package]] @@ -2125,18 +1519,27 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + [[package]] name = "unicode-segmentation" version = "1.13.3" @@ -2189,12 +1592,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "version_check" version = "0.9.5" @@ -2216,15 +1613,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -2494,12 +1882,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.4" @@ -2529,26 +1911,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 7bfeb7e..aa81258 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,8 +1,8 @@ [workspace] members = [ "crates/ghostlight", - "crates/ghostlight-dungeon", "crates/ghostlight-persona-projection", + "crates/ghostlight-verse", ] resolver = "2" @@ -13,19 +13,10 @@ version = "0.1.0" [workspace.dependencies] anyhow = "1" -aes-gcm = "0.10" async-trait = "0.1" -async-stream = "0.3" -axum = { version = "0.8", features = ["macros"] } chrono = { version = "0.4", features = ["serde"] } -base64 = "0.22" codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" } cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" } -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultnet-rs" } -cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultmesh-rs" } -jsonwebtoken = "9" -hmac = "0.12" -rand = "0.9" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } rmp-serde = "1.3" serde = { version = "1", features = ["derive"] } @@ -33,9 +24,7 @@ serde_json = "1" sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["full"] } -tower-http = { version = "0.6", features = ["fs", "trace"] } tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } unicode-segmentation = "1" uuid = { version = "1", features = ["v4", "serde"] } zeroize = "1.8" diff --git a/README.md b/README.md index ffd2855..ff4a893 100644 --- a/README.md +++ b/README.md @@ -1,149 +1,121 @@ # Ghostlight -Ghostlight is a Rust library that keeps a simulated world honest, and a play -daemon built on it. A language model proposes; the library's kernel checks each -proposal against typed world state and admits or refuses it. Places, people, -knowledge, commitments, and time live in the kernel's journal, not in a model's -context window, so they survive restarts and long stories. +**Ghostlight is a world kernel: typed world state that a language model can +propose changes to and can never write.** The valuable part is +`crates/ghostlight`. The Dungeon game, one client of it, is parked (below). -## What is here +**The thesis.** World state is typed documents. Every change to it, and every +narrative dependency on it, is an operation in a closed, predefined algebra. +Models propose operations; the kernel checks each against the state (references, +preconditions, authority, conservation, who knows what) and admits it or refuses +it with the complete list of mismatches. Narration describes only what was +committed. A model-as-game-master holds the world in a context window and +drifts; here the world is a digest-chained journal that survives restarts and +cannot be talked into contradicting itself. -| Part | Path | What it is | -|---|---|---| -| World kernel | `crates/ghostlight/` | The sealed `ghostlight` library: world state, the closed operation set and reducer, the CultCache journal, the mailbox that is its only door, controllers, elaboration, lenses, the consumer ingress, and the inference ports. | -| Persona projection | `crates/ghostlight-persona-projection/` | The membrane between structured state and a prose-only Persona: projection, Persona turn receipts, and the controller descriptors the kernel consumes. | -| Dungeon | `crates/ghostlight-dungeon/` | The `ghostlight-dungeon` daemon: single-player, DM-led play over one world. HTTP and Eve routes, Heimdall sign-in, app sessions, the play table, CultMesh publication, and Idunn health. | -| Browser | `web/` | A thin Eve browser host and the Heimdall access adapter. It renders the server's surface and owns no product state. | -| Claude SDK sidecar | `sidecar/claude-sdk/` | A Node process that serves `claude`-prefixed models to the kernel through the Claude Agent SDK. It is a stopgap for a direct Messages API port. | -| Deploy recipe | `deployment/idunn/recipe.toml` | The steps Idunn runs to test, build, and seal a release. | -| Eve | `vendor/eve/` | A git submodule (`.gitmodules`) supplying the Eve browser lowering and contracts. | +**Use it as a structured state backend.** Do not judge it by the parked Dungeon's +prose. The kernel is a library; prose comes from whichever model you plug into +its ports. -## How it works +- `crates/ghostlight`: the sealed library. A consumer gets create/open + (`WorldMailbox::open`, `create`), immutable snapshots (`snapshot`), command + submission through role-scoped ports (`PlayPort`, `ConsumerPort`, + `ControllerPort`, `SeedPort`), typed receipts, and the runner and inference-port + entry points (`ControllerRunner`, `ElaborationRunner`, `SeedRunner`, + `InferencePort`). Mutable state, ID issuers, the reducer and the journal + writer are not public. `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` + and `crates/ghostlight/tests/external_admission.rs` prove the seal. +- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary (subjects, + places, routes, holdings, knowledge, commitments, pressures), the operation + algebra, and what the reducer checks. +- `docs/architecture/ghostlight-world-consumer-api.md`: the contract for a + program that reads a world and proposes changes to the characters it controls + (`POST /cultnet/world-patch`; no process on main serves that door, a host serves + `ghostlight::admit_document`). -- The kernel is sealed. A consumer reaches it through create/open, immutable - snapshots, command submission, typed receipts, and the runner and port entry - points. There is no public mutable state, ID issuer, reducer, or journal - writer, and the crate declares no cargo features. The seal is proven by - `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` and by - `crates/ghostlight/tests/external_admission.rs`, which forges IDs, digests, - and opportunities and asserts that each is refused with nothing committed. -- Every change enters one mailbox (`crates/ghostlight/src/mailbox.rs`) as a - command, is decided by one reducer, and commits atomically to a - digest-chained journal (`crates/ghostlight/src/journal.rs`, CultCache on redb). -- Models author through typed tools and a repair loop - (`crates/ghostlight/src/elaboration.rs`, `tool_schema.rs`, `patch.rs`). A - refused proposal returns its mismatches to the model; it never edits state. -- A Persona speaks only prose. A Projector builds its view, and an Interpreter - lowers its words into typed proposals with exact source quotes - (`crates/ghostlight/src/controllers.rs`). -- Vault evidence for seeding comes from a read-only markdown directory reader - (`crates/ghostlight/src/vault.rs`). The Dungeon's seed lane reads the - directory named by `GHOSTLIGHT_SEED_VAULT_ROOT`, read once at startup, so - changing it needs a restart - (`crates/ghostlight-dungeon/src/runtime.rs`). -- Other programs can propose changes to the characters they control through - `POST /cultnet/world-patch` (`crates/ghostlight-dungeon/src/runtime.rs`, - `crates/ghostlight/src/consumer.rs`). The contract is - `docs/architecture/ghostlight-world-consumer-api.md`. +Start with those two documents, then `cargo doc -p ghostlight --open`. -## Ghostlight Dungeon +**Populate the algebra first.** The kernel owns the component operations; a world owns its +affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in +Speak, which records an utterance and changes nothing. Until your world declares affordances +(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on, +every intent a model voices lowers to speech or to a recorded gap, and the prose built on that +will be thin no matter how good the model is. The Aetheria verse genesis +(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still +only record the act; their effects are the next phase. -Dungeon is the single-player, DM-led client. A player signs in with Heimdall, -creates a world, seeds it from a vault, approves and activates it, and plays in -free text. The play table (`crates/ghostlight-dungeon/src/play.rs`) owns the -turn lifecycle; the interface is one Eve surface, `ghostlight.play` -(`crates/ghostlight-dungeon/src/eve.rs`, described in -`docs/architecture/ghostlight-eve-native-interface.md`). Because its player is -not a developer, Dungeon is judged by whether someone can sit down and play. +## Elaborators -Where it runs: +An elaborator is a model session, confined to one jurisdiction of the world, that +adds structure where the world is thin: it answers a derived causal boundary +(a destination nobody has described yet) or a scale deficit with typed +declarations, and the kernel verifies the result like any other patch. Sessions +are resumable and checkpointed against committed history. Eight stock lenses +(`crates/ghostlight/src/lens.rs`: patina, charter, ledger, hearth, tangle, veil, +ember, numen) give each session a role-specific emphasis, for example customs and +local jokes, or money and ledgers. A lens changes the prompt only, never what the +kernel admits. Because boundaries are derived from state, elaborators can be +dispatched as the player approaches a place or a social structure, instead of +authoring the world up front. -- **Yggdrasil**, under Idunn, built from `main` by `deployment/idunn/recipe.toml`. - Idunn owns the daemon's lifecycle and the write lease. Odin supplies discovery - and Heimdall's command-boundary record. The operating runbook is - `gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. -- **Inference** is routed by model name: a `local/` prefix goes to an - OpenAI-compatible loopback endpoint - (`GHOSTLIGHT_LOCAL_ENDPOINT`, `crates/ghostlight/src/local_inference.rs`), - which in production is Bonsai on Raven's GPU reached through a loopback - tunnel; a `claude` prefix goes to the SDK sidecar - (`GHOSTLIGHT_SDK_SIDECAR`, `crates/ghostlight/src/sdk_inference.rs`). - Ghostlight never reads, stores, or forwards a model credential. -- **Sign-in** is Heimdall's. Ghostlight keeps a hashed HttpOnly session and - derives authority from the world's owner and approvers. +## Direction (planned, not built) -## Direction +Use the kernel to simulate a whole world with many kinds of actor: Aetheria's +factions and a persistent cast whose members keep identity, memory and +commitments across sessions. Newspaper-staff simulations before the 2026-08 +teardown are the earlier proof of the idea; that code is in git history, not in +the tree. Running this at scale is gated by inference cost, not by the design. +`crates/ghostlight-verse` is the first slice: `ghostlight-verse genesis` creates +an Aetheria verse world from the Aetheria catalog and `ghostlight-verse run` +advances it a number of ticks under a token cap, reporting per tick what each +faction did and what it cost. Nothing loads AetheriaLore prose, models +storylets, or produces episodes yet. Targets: `notes/ghostlight-verse-target.md`. -The next work makes Ghostlight the simulation behind Aetheria's public story. -The operator's rulings (campaign `ghostlight-verse` in the Eureka mind) ask for: +## Ghostlight Dungeon (parked) -- a world loaded from the Aetheria verse, with factions and a cast; -- storylets for Aetheria; -- a persistent cast whose characters keep identity, memory, and commitments - across sessions, so serialized episodes can follow them; -- episodes a cheap pipeline can turn into a video stream, and channel - characters who later appear in the game. +The single-player, DM-led client and its daemon, Eve play surface and browser +host are parked at tag `parked/ghostlight-eve-surface`. Nothing is deployed and +nothing on main serves HTTP. The Dungeon returns when Thing 2 gives it a UI +(follow-up `dungeon-ui-on-thing2`); the rebuild starts from the tag. -None of this exists yet. Nothing under `crates/` loads Aetheria lore, models -storylets, or produces an episode. Every event the kernel admits is meant to -have exactly one render path: ship action filmed in Aetheria, or a social verb -staged as an Aetheria conversation. Today a world can declare any affordance -kind, and the kernel does not enforce that restriction. +## Repo map + +| Path | What it is | +|---|---| +| `crates/ghostlight/` | The sealed kernel library. | +| `crates/ghostlight-persona-projection/` | Membrane between structured state and a prose-only Persona. | +| `crates/ghostlight-verse/` | The `ghostlight-verse` operator command line (`genesis`, `run`). | +| `sidecar/claude-sdk/` | Node process serving `claude`-prefixed models to the kernel; a stopgap for a direct Messages API port. | +| `AGENTS.md` | Invariants and where things are. | +| `docs/architecture/ghostlight-dungeon-mvp.md` | The parked Dungeon's authority architecture (design record). | +| `notes/` | The verse campaign's target and map, the handoff pointer, local smoke notes. | + +`docs/public-architecture/`, `docs/articles/` and `docs/product/` are design +essays and proposals from before the rebuild. They describe organs (campaigns, +co-op, Nemesis, newspapers) that are not in `crates/`; they do not describe the +running machine. Pages ending in `-cut.md` or `-postmortem.md` are dated history. ## Build and test -The release target is Linux on Yggdrasil; a Windows build says nothing about -it. Idunn runs the steps in `deployment/idunn/recipe.toml`, which are the -verification: +Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a +workstation: ```sh -git submodule update --init vendor/eve +cargo build --locked --workspace cargo test --locked -p ghostlight-persona-projection cargo test --locked -p ghostlight -cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon -cargo build --locked --release -p ghostlight-dungeon --bin ghostlight-dungeon -npm --prefix web ci -npm --prefix web test -npm --prefix web run build +cargo test --locked -p ghostlight-verse ``` -The sidecar has its own `npm --prefix sidecar/claude-sdk run build` and `test` -(also the root scripts `sdk:build` and `sdk:test`). Four Dungeon tests are -`#[ignore]`d because they drive the vendored browser lowering through -`tools/eve_client_bridge.mjs` and need Node beside cargo. The release -acceptance step runs `controllers::tests::real_local_model_cognition_modes_commit_speech` -(ignored by default) against a real model. +The kernel alone needs only `cargo test -p ghostlight`. The sidecar has +`npm run sdk:build` and `npm run sdk:test`. The acceptance test +`controllers::tests::real_local_model_cognition_modes_commit_speech` is ignored +by default and needs a real model. -To run Dungeon locally: `npm run dungeon:run`, or -`cargo run -p ghostlight-dungeon -- --state-root PATH`. Configuration is by -`GHOSTLIGHT_*` environment variables; the recipe's `required_environment` -lists the ones a deployment sets. +Inference is routed by model name: `local/` goes to an OpenAI-compatible +loopback endpoint, `claude` to the SDK sidecar, and the verse runner's `hosted/` +to an https endpoint. Ghostlight never reads, stores or forwards a model +credential beyond a key file named at run time. -## Documents - -- `AGENTS.md`: repository doctrine for agents. -- `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture - of the world machine. -- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary and the - kernel's current mechanism. -- `docs/architecture/ghostlight-world-consumer-api.md`: the consumer contract. -- `docs/architecture/ghostlight-eve-native-interface.md`: the Eve surface. -- `docs/architecture/ghostlight-library-extraction.md`, - `ghostlight-stock-lenses.md`: closed records of landed work. Pages ending in - `-cut.md` and `-postmortem.md` are dated history. -- `docs/architecture/ghostlight-play-agent.md`: historical. Its status line - predates the play-agent cuts; read it with `-cut.md` beside it, not as the - current play surface (`crates/ghostlight-dungeon/src/play.rs` is). -- `docs/architecture/ghostlight-session-zero.md`: unbuilt design, superseded by - the play agent. Nothing in `crates/` implements it. -- `notes/fresh-workspace-handoff.md`: where things run and where open work is - recorded. -- `docs/public-architecture/`, `docs/articles/`: unbuilt design and essays - written before the rebuild. They describe campaigns, co-op, Nemesis - and strategic resolvers as organs; none of that exists in `crates/`. They do not - describe the running machine. -- `docs/product/lore-vault-entitlements.md`: a product proposal for hosted - vaults; nothing in `crates/` implements it. - -Runtime documents use MessagePack-backed CultCache. JSON appears only at schema -publication, browser, model-provider, and diagnostic boundaries. +Runtime documents are MessagePack-backed CultCache. JSON appears only at schema +publication, model-provider and diagnostic boundaries. diff --git a/crates/ghostlight-dungeon/Cargo.toml b/crates/ghostlight-dungeon/Cargo.toml deleted file mode 100644 index 032aecd..0000000 --- a/crates/ghostlight-dungeon/Cargo.toml +++ /dev/null @@ -1,48 +0,0 @@ -[package] -name = "ghostlight-dungeon" -version.workspace = true -edition.workspace = true -license.workspace = true -autobins = false - -[[bin]] -name = "ghostlight-dungeon" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -aes-gcm.workspace = true -async-trait.workspace = true -async-stream.workspace = true -axum.workspace = true -chrono.workspace = true -base64.workspace = true -codex-connector.workspace = true -cultcache-rs.workspace = true -cultnet-rs.workspace = true -cultmesh-rs.workspace = true -jsonwebtoken.workspace = true -libc = "0.2" -hmac.workspace = true -ghostlight = { path = "../ghostlight" } -ghostlight-persona-projection = { path = "../ghostlight-persona-projection" } -rand.workspace = true -reqwest.workspace = true -rmp-serde.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_bytes = "0.11" -sha2.workspace = true -thiserror.workspace = true -tokio.workspace = true -tower-http.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -uuid.workspace = true -zeroize.workspace = true - -[dev-dependencies] -ghostlight = { path = "../ghostlight", features = ["test-support"] } -tempfile = "3" -tokio = { workspace = true, features = ["test-util"] } -tower = { version = "0.5", features = ["util"] } diff --git a/crates/ghostlight-dungeon/build.rs b/crates/ghostlight-dungeon/build.rs deleted file mode 100644 index 9eaf054..0000000 --- a/crates/ghostlight-dungeon/build.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{env, path::Path, process::Command}; - -fn main() { - println!("cargo:rerun-if-env-changed=GHOSTLIGHT_BUILD_COMMIT"); - let manifest_dir = - env::var("CARGO_MANIFEST_DIR").expect("Cargo must supply CARGO_MANIFEST_DIR"); - let repo_root = Path::new(&manifest_dir).join("../.."); - emit_git_rerun_paths(&repo_root); - // Release tooling injects the clean-tree commit. Git discovery is only a - // convenience for local builds. The exact ref and reflog dependencies above - // keep that convenience from silently publishing stale provenance. - let commit = env::var("GHOSTLIGHT_BUILD_COMMIT") - .ok() - .filter(|value| is_commit(value)) - .or_else(|| git_commit(&repo_root)) - .expect("GhostlightDungeon builds require an exact 40-hex source commit"); - println!("cargo:rustc-env=GHOSTLIGHT_BUILD_COMMIT={commit}"); -} - -fn emit_git_rerun_paths(repo_root: &Path) { - for git_path in ["HEAD", "logs/HEAD", "packed-refs"] { - if let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", git_path]) { - emit_rerun_path(repo_root, &path); - } - } - - if let Some(reference) = git_output(repo_root, &["symbolic-ref", "-q", "HEAD"]) - && let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", &reference]) - { - emit_rerun_path(repo_root, &path); - } -} - -fn emit_rerun_path(repo_root: &Path, git_path: &str) { - let path = Path::new(git_path); - let resolved = if path.is_absolute() { - path.to_path_buf() - } else { - repo_root.join(path) - }; - println!("cargo:rerun-if-changed={}", resolved.display()); -} - -fn git_commit(repo_root: &Path) -> Option { - git_output(repo_root, &["rev-parse", "HEAD"]).filter(|value| is_commit(value)) -} - -fn git_output(repo_root: &Path, args: &[&str]) -> Option { - 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()) -} diff --git a/crates/ghostlight-dungeon/src/app_session.rs b/crates/ghostlight-dungeon/src/app_session.rs deleted file mode 100644 index f35a449..0000000 --- a/crates/ghostlight-dungeon/src/app_session.rs +++ /dev/null @@ -1,939 +0,0 @@ -//! Local custody for authenticated Heimdall sessions. -//! -//! This store owns only browser-session continuity. It does not own world -//! selection, commands, idempotency, exports, or fictional state. - -use crate::heimdall::{VerifiedSessionAdmission, VerifiedSessionRefresh}; -use ghostlight::VerifiedPrincipalEvidence; -use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; -use anyhow::{Context, bail}; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::{DateTime, Utc}; -use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{collections::BTreeMap, path::Path}; - -const STORE_TYPE: &str = "ghostlight.app_session_store.v2"; -/// `pub(crate)` so the recipe-drift test in `idunn_health.rs` can check the -/// `app-sessions` state slot's schema against this constant. -pub(crate) const STORE_SCHEMA: &str = "ghostlight.app_session_store.v2"; -const STORE_KEY: &str = "primary"; -const MAX_SESSION_RECORDS: usize = 1_024; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct AppSessionState { - schema: String, - sessions: BTreeMap, -} - -#[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, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - encrypted_refresh_claim: String, - created_at: DateTime, - last_refresh_at: DateTime, - revoked_at: Option>, -} - -/// 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, - wrapping_key_path: impl AsRef, - ) -> anyhow::Result { - 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 { - 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, - ) -> anyhow::Result> { - 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 { - 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 { - 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> { - 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, - refresh_before: chrono::Duration, - ) -> anyhow::Result> { - 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::>>(); - 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 { - 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(&mut self, error: anyhow::Error) -> anyhow::Result { - 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 { - 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) { - 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::>(); - 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 { - 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 { - 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 - /// `::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::(), - 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()); - } -} diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs deleted file mode 100644 index 4249b93..0000000 --- a/crates/ghostlight-dungeon/src/eve.rs +++ /dev/null @@ -1,1007 +0,0 @@ -//! Eve/CultUI projection for the one live world owner. - -use crate::mesh::{COMMAND_BOUNDARY, COMMAND_RECEIPT_SCHEMA, COMMAND_RESULT_SCHEMA, OWNER_REPO, PROVIDER_ID, SURFACE_ID}; -use crate::play::{PlayTurnState, PlayTurnView}; -use ghostlight::{JurisdictionKey, WorldPhase, WorldSnapshot}; -use anyhow::{Context, bail}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use uuid::Uuid; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct EveCommandInvocation { - pub(crate) schema: String, - #[serde(rename = "providerId")] - pub(crate) provider_id: String, - #[serde(rename = "surfaceId")] - pub(crate) surface_id: String, - pub(crate) operation: EveOperation, - pub(crate) payload: Value, - #[serde(rename = "issuedAt")] - pub(crate) issued_at: String, - #[serde(rename = "clientId")] - pub(crate) client_id: String, - #[serde(rename = "commandBoundary")] - pub(crate) command_boundary: String, - #[serde(rename = "receiptSchema")] - pub(crate) receipt_schema: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct EveOperation { - pub(crate) operation_id: String, - pub(crate) schema_id: Option, - pub(crate) idempotency_key: Option, - #[serde(default)] - pub(crate) route_hint: EveRouteHint, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct EveRouteHint { - pub(crate) source_version: Option, - pub(crate) transport: Option, -} - -/// The card's whole account of a turn closed by a fault: a fixed line, so -/// the row can carry no detail text at all. -const PLAY_FAULT_LINE: &str = - "The storyteller lost the thread and your turn ended. Write again to start a new turn."; - -/// The `world_create.v4` payload, in one place, so the button that captures it -/// and the descriptor that advertises it cannot drift. -const CREATE_BINDINGS: [&str; 8] = [ - "title", - "brief", - "subject_label", - "narrative_persona_label", - "operational_agent_label", - "targets", - "jurisdictions", - "lens_weights", -]; - -const SEED_BINDINGS: [&str; 2] = ["vault_scope", "brief"]; - -pub(crate) fn surface_version(snapshot: Option<&WorldSnapshot>) -> u64 { - snapshot - .map(|world| world.revision.saturating_add(1)) - .unwrap_or(0) -} - -/// The wake signal `publish_projection` sends over the `revisions` broadcast -/// channel and `/api/eve/events` relays — never a value a client feeds back -/// as `routeHint.sourceVersion`, and never the surface document's own -/// `version` field (PA.f148/PA.f160), which names `surface_version` alone so -/// the kernel's `expected_revision = source_version - 1` derivation in -/// `runtime.rs` stays sound. It folds `surface_version` with the play row's -/// own `revision`, when a play view is available, so a play-row-only change -/// (a fresh refusal, a newly open question, a closed turn's narration, none -/// of which necessarily commit anything to the world) still wakes a watching -/// client even though `surface_version` alone would not move. Additive, not -/// paired: both counters only ever grow, so the sum only ever grows. No play -/// view (no turn has opened yet, or the table is unavailable) contributes -/// nothing, matching `surface_version`'s own reach when no play card renders -/// at all. -pub(crate) fn authenticated_wake_version( - snapshot: Option<&WorldSnapshot>, - play: Option<&PlayTurnView>, -) -> u64 { - surface_version(snapshot).saturating_add(play.map_or(0, |view| view.revision)) -} - -pub(crate) fn world_state(snapshot: Option<&WorldSnapshot>) -> &'static str { - match snapshot.map(|world| world.phase) { - None => "empty", - Some(WorldPhase::Draft) => "draft", - Some(WorldPhase::Active) => "active", - } -} - -pub(crate) fn anonymous_surface() -> Value { - anonymous_surface_at(0) -} - -pub(crate) fn mesh_surface(snapshot: Option<&WorldSnapshot>) -> Value { - anonymous_surface_at(surface_version(snapshot)) -} - -fn anonymous_surface_at(version: u64) -> Value { - surface_document( - version, - "Ghostlight Dungeon", - vec![ - json!({ - "id":"ghostlight.access", - "kind":"heimdall.access_gate", - "props":{ - "state":"anonymous", - "title":"Enter Ghostlight", - "detail":"Sign in with Discord to reach the world owner." - }, - "children":[] - }), - command_button( - "ghostlight.auth.begin", - "Continue with Discord", - "heimdall.auth.begin", - json!({}), - &[], - ), - ], - vec![command_descriptor( - "heimdall.auth.begin", - "heimdall.auth_begin_command.v1", - &[], - "Heimdall", - )], - ) -} - -/// What the seed lane has done and what it will answer next. Everything here is -/// derived from `scale_deficit` and `revision`: in Draft every commit is a patch -/// admission, so `revision - 1` is exactly the seed patches committed since -/// genesis, and "next shortfall" calls the runner's own `select_row` so the card -/// and the runner cannot disagree about what happens next. -/// -/// It shows no in-flight session and no last outcome. Both would need a second -/// reader of the controller-work store from here and a status cache in -/// `AppState`; the last outcome is already in the invocation's own -/// `command_result` receipt, which is where a command's result belongs. -fn seed_card(world: &WorldSnapshot) -> Value { - let next = ghostlight::select_row(world).map_or_else( - || "Nothing short.".to_owned(), - |row| { - format!( - "{} · {:?} · short {}", - jurisdiction_label(world, row.jurisdiction), - row.kind, - row.deficit - ) - }, - ); - let rows = world - .scale_deficit - .iter() - .enumerate() - .map(|(index, row)| { - json!({ - "id":format!("world.seed.row.{index}"), - "kind":"text", - "props":{"value":format!( - "{} · {:?} · target {} · alive {} · short {}", - jurisdiction_label(world, row.jurisdiction), - row.kind, - row.target, - row.qualified, - row.deficit - )}, - "children":[] - }) - }) - .collect::>(); - json!({ - "id":"world.seed.card", - "kind":"card", - "props":{ - "title":"Seed", - "detail":format!("Draft patches committed: {}", world.revision.saturating_sub(1)), - "nextShortfall":next - }, - "children":rows - }) -} - -fn jurisdiction_label(world: &WorldSnapshot, jurisdiction: JurisdictionKey) -> String { - match jurisdiction { - JurisdictionKey::PlaceSubtree(root) => world - .places - .iter() - .find(|entry| entry.id == root) - .map_or_else( - || "an unnamed place subtree".to_owned(), - |entry| entry.label.clone(), - ), - JurisdictionKey::Uncovered => "everything no declared root covers".to_owned(), - } -} - -/// `play` arrives beside the snapshot rather than inside it: `WorldSnapshot` -/// carries no play-turn state of its own. The play card below reads `play` -/// alone, never the operator's own unscoped log of every committed event: -/// invariant 8's projection, question, and refusal, nothing else. -/// -/// `seed_available` is whether the seed lane's vault is configured on this -/// server (`AppState::seed_vault_root`, set once at open from the environment). -/// The seed controls and the `world.seed` descriptor are offered only where -/// pressing the button could do something. -pub(crate) fn authenticated_surface( - account: &str, - snapshot: Option<&WorldSnapshot>, - play: Option<&PlayTurnView>, - seed_available: bool, -) -> anyhow::Result { - // The document's own `version` names the world's `surface_version` - // alone — the one meaning `routeHint.sourceVersion` and the kernel's - // compare-and-swap require of it (PA.f148/PA.f160). The play row's own - // revision moves independently of it and is never folded in; it has no - // wire reader of its own (PA.f165) and stays internal to - // `eve::authenticated_wake_version`'s SSE wake computation. - let version = surface_version(snapshot); - let mut children = vec![json!({ - "id":"ghostlight.identity", - "kind":"heimdall.identity", - "props":{ - "state":"authenticated", - "title":"Authenticated", - "detail":"Heimdall identity is bound server-side.", - "displayName":short_principal(account) - }, - "children":[] - })]; - let mut commands = Vec::new(); - - match snapshot { - None => { - // Dungeon policy, editable before submission; the library holds no - // default weights. - let default_lens_weights = - serde_json::to_string(&crate::runtime::uniform_lens_weights()) - .context("the default lens weights encode")?; - children.extend([ - json!({ - "id":"world.create.title", - "kind":"control.input.text", - "props":{"label":"World title","placeholder":"A name for this world"}, - "stateBindings":[local_draft("title", "string")], - "children":[] - }), - // Cut 10 (PA.f149): `CREATE_BINDINGS` has always advertised - // `brief` — `world_create.v4`'s `CreatePayload.brief` is - // required, not `#[serde(default)]` — but no control ever - // captured it. Driving `world.create` with the real - // vendored lowering surfaced this: nothing the lowering - // could ever submit through this form would satisfy - // `world_create.v4`, hand-built test payloads notwithstanding. - json!({ - "id":"world.create.brief", - "kind":"control.input.textarea", - // PA.f164: `brief` is documented "required, may be empty" - // (`CreatePayload::brief`), the same deliberate-empty - // shape as `targets`/`jurisdictions` below — an authored - // `value` so a form left untouched here still submits. - "props":{"label":"Brief","rows":2,"value":"","placeholder":"One sentence of what this world is for"}, - "stateBindings":[local_draft("brief", "string")], - "children":[] - }), - json!({ - "id":"world.create.subject", - "kind":"control.input.text", - "props":{"label":"Your name","placeholder":"The first person in the world"}, - "stateBindings":[local_draft("subject_label", "string")], - "children":[] - }), - json!({ - "id":"world.create.narrative_persona", - "kind":"control.input.text", - "props":{"label":"Narrative persona (optional)","placeholder":"A person who lives the story in prose"}, - "stateBindings":[local_draft("narrative_persona_label", "string")], - "children":[] - }), - json!({ - "id":"world.create.operational_agent", - "kind":"control.input.text", - "props":{"label":"Operational agent (optional)","placeholder":"An institution or operator-shaped mind"}, - "stateBindings":[local_draft("operational_agent_label", "string")], - "children":[] - }), - json!({ - "id":"world.create.targets", - "kind":"control.input.textarea", - // PA.f164: an authored `value`, not only a `placeholder` — - // `findAuthoredBindingValue` (the vendored lowering) only - // ever captures a control's `value`, never its - // `placeholder`, so a form submitted without editing this - // field would otherwise be refused as missing. `{}` is a - // legitimate `targets` (a world with no scale target is a - // deliberate choice, `CreatePayload`'s own doc comment). - "props":{"label":"Scale target","rows":2,"value":"{}","placeholder":"{\"person\": 12, \"institution\": 3}"}, - "stateBindings":[local_draft("targets", "string")], - "children":[] - }), - json!({ - "id":"world.create.jurisdictions", - "kind":"control.input.textarea", - // PA.f164: same gap, `[]` is a legitimate empty root list. - "props":{"label":"Jurisdiction roots","rows":3,"value":"[]","placeholder":"[{\"handle\":\"low_sere\",\"label\":\"The Low Sere\",\"permille\":700}]"}, - "stateBindings":[local_draft("jurisdictions", "string")], - "children":[] - }), - json!({ - "id":"world.create.lens_weights", - "kind":"control.input.textarea", - "props":{"label":"Lens weights","rows":2,"value":default_lens_weights,"placeholder":default_lens_weights}, - "stateBindings":[local_draft("lens_weights", "string")], - "children":[] - }), - command_button( - "world.create", - "Create world", - "world.create", - json!({}), - &CREATE_BINDINGS, - ), - ]); - commands.push(command_descriptor( - "world.create", - "ghostlight.world_create.v4", - &CREATE_BINDINGS, - "WorldMailbox", - )); - } - Some(world) => { - // Cut 10 (PA.f159): phase and clock stay; the subject census is - // gone — nothing about who else is in the world is the player's - // own projection, invariant 8's own "nothing else." - children.push(json!({ - "id":"world.summary", - "kind":"card", - "props":{"title":world.title,"subtitle":format!("{:?} · revision {} · minute {}", world.phase, world.revision, world.now.0)}, - "children":[] - })); - match world.phase { - WorldPhase::Draft => { - if world - .required_approvers - .contains(&ghostlight::PrincipalId::new(account)) - && !world - .draft_approvals - .contains(&ghostlight::PrincipalId::new(account)) - { - children.push(command_button( - "world.approve", - "Approve draft", - "world.approve", - json!({}), - &[], - )); - commands.push(command_descriptor( - "world.approve", - "ghostlight.world_approve.v0", - &[], - "WorldMailbox", - )); - } - if seed_available && world.owner == ghostlight::PrincipalId::new(account) { - children.extend([ - json!({ - "id":"world.seed.vault_scope", - "kind":"control.input.text", - "props":{"label":"Vault scope (optional)","placeholder":"A subdirectory of the configured vault"}, - "stateBindings":[local_draft("vault_scope", "string")], - "children":[] - }), - json!({ - "id":"world.seed.brief", - "kind":"control.input.textarea", - "props":{"label":"Brief (optional)","rows":2,"placeholder":"One sentence of what this world is for"}, - "stateBindings":[local_draft("brief", "string")], - "children":[] - }), - command_button( - "world.seed", - "Seed the world", - "world.seed", - json!({}), - &SEED_BINDINGS, - ), - seed_card(world), - ]); - commands.push(command_descriptor( - "world.seed", - "ghostlight.world_seed.v1", - &SEED_BINDINGS, - "SeedPort", - )); - } - if world.owner == ghostlight::PrincipalId::new(account) - && world.draft_approvals == world.required_approvers - { - children.push(command_button( - "world.activate", - "Activate world", - "world.activate", - json!({}), - &[], - )); - commands.push(command_descriptor( - "world.activate", - "ghostlight.world_activate.v0", - &[], - "WorldMailbox", - )); - } - } - WorldPhase::Active => { - if world.owner == ghostlight::PrincipalId::new(account) { - children.extend([ - json!({ - "id":"world.advance_time.minutes", - "kind":"control.input.number", - "props":{"label":"Advance minutes","min":1,"max":525_600}, - "stateBindings":[local_draft("minutes", "number")], - "children":[] - }), - command_button( - "world.advance_time", - "Advance time", - "world.advance_time", - json!({}), - &["minutes"], - ), - ]); - commands.push(command_descriptor( - "world.advance_time", - "ghostlight.world_advance_time.v0", - &["minutes"], - "WorldMailbox", - )); - } - // Cut 10 (PA.f152): owner-gated, like `world.advance_time` - // and `world.seed` above — the play card, its controls, - // and the `world.play` descriptor are the owner's own - // play authority over their own world, not a public - // affordance. - if world.owner == ghostlight::PrincipalId::new(account) { - // Cut 9: the play card. Invariant 8 — the player sees - // a projection, the question, and the refusal of - // their own act, nothing else — so this reads `play` - // alone, never the operator's own unscoped event log: - // no id, no other subject's state, no speech the - // player did not perceive. Always rendered here, with - // empty rows before any turn has ever opened. - let mut play_rows = Vec::new(); - if let Some(narration) = play.and_then(|view| view.narration.as_deref()) { - play_rows.push(json!({ - "id":"world.play.narration", - "kind":"text", - "props":{"value":narration}, - "children":[] - })); - } - if let Some(question) = play.and_then(|view| view.question.as_ref()) { - play_rows.push(json!({ - "id":"world.play.question", - "kind":"text", - "props":{"value":question.text.as_str()}, - "children":[] - })); - } - if let Some(refusal) = play.and_then(|view| view.refusal.as_deref()) { - play_rows.push(json!({ - "id":"world.play.refusal", - "kind":"text", - "props":{"value":refusal}, - "children":[] - })); - } - // R1: a turn that died on its model says so. The line is - // fixed: the fault detail stays in the store and the log, - // never on the card (invariant 8, PA.f187). - if play.is_some_and(|view| view.fault) { - play_rows.push(json!({ - "id":"world.play.fault", - "kind":"text", - "props":{"value":PLAY_FAULT_LINE}, - "children":[] - })); - } - // PA.f193-D ("the interrupted turn"; renumbered from - // the PA.f188 duplicate this file's own comment used - // to carry — PA.f188 names only `f295a3a`'s - // construction-site coverage): a turn a process died - // mid-round leaves `Running` forever — nothing - // resumes it on its own, and the only way forward is - // submitting an empty message (`play.rs`'s own - // `Some(existing) if existing.state == - // PlayTurnState::Running` admit arm: empty text - // continues it, non-empty text is refused as - // `TurnStillRunning`). Before this the card showed - // nothing at all in that state — no narration, no - // question, no refusal, since none of those are set - // on a turn that never got to close, ask, or record a - // refused act since it last opened — so a player had - // no way to learn the empty-submit continue exists at - // all. This is a control hint about the player's own - // turn, not world truth, so it stays within - // invariant 8 the same way the narration/question/ - // refusal rows above already do. - // - // `&& !view.run_in_progress`: `state == Running` - // alone is not "nothing is happening" — it is also - // true for the seconds a healthy turn is genuinely - // running its own round loop. In that window `admit` - // fails `run_lock`'s own `try_lock_owned` before - // inspecting anything and refuses as `TurnBusy`, - // contradicting a hint that told the player an empty - // continue would be accepted. `run_in_progress` is - // `current_turn_view`'s own non-blocking peek at that - // same lock, so the hint now renders only for a - // `Running` turn nothing is actively working on. - if play.is_some_and(|view| view.state == PlayTurnState::Running && !view.run_in_progress) { - play_rows.push(json!({ - "id":"world.play.running", - "kind":"text", - "props":{"value":"Your last turn did not finish. If nothing arrives, \ - submit an empty message to continue it."}, - "children":[] - })); - } - // PA.f194-D: with the hint above correctly suppressed - // while a round is genuinely in flight - // (`run_in_progress`), the card had nothing left to - // render for the whole span between submitting a turn - // and its own narration/question/refusal landing — - // narration/question/refusal are all still whatever - // the *previous* turn left behind (or unset, on a - // fresh table), and the round in progress does not - // touch any of them until it closes or opens a - // question. A player who submits and sees an - // unchanged or empty card has no sign anything is - // happening. This row renders only while this - // player's own turn is actively being worked on — - // `run_in_progress`, not world truth, and stays - // within invariant 8 the same way the hint above - // does. - if play.is_some_and(|view| view.run_in_progress) { - play_rows.push(json!({ - "id":"world.play.resolving", - "kind":"text", - "props":{"value":"Resolving your turn…"}, - "children":[] - })); - } - children.push(json!({ - "id":"world.play.card", - "kind":"card", - "props":{"title":"Play"}, - "children":play_rows - })); - - // Cut 10 (PA.f151): no `answers`/question-id control - // any more. The lowering has no authored, non-editable - // binding value (`hidden` is not a prop any renderer - // reads — Soul's own probe found it rendered as a - // plain, editable, visible text box holding the raw - // id), so the server resolves the open question - // itself instead of trusting a client-supplied id at - // all (`runtime.rs::execute_world`'s `world.play` - // arm). PA.f170's guarantee now holds by an opaque - // per-question token rather than PA.f161's version - // comparison (see `play::PlayTurn::question_token`'s - // own doc comment for why a version alone cannot tell - // two questions in the same turn apart): the button's - // own `props.action` carries it DOM-invisibly, the - // way `props.action.command` already does — a real - // client spreads a button's own `action` fields - // straight into the payload beside `bindings` - // (`commandPayload`, - // `vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`), - // never through a renderer that could show or edit - // it. `None` while no question is open: the button's - // own action then carries no token at all, matching a - // plain continue/opening, which needs none. - // PA.f179: `question.token` is `Option` — a - // `None` here (a question open whose own token was, - // for whatever reason, never minted) serializes as a - // JSON `null`, which `runtime.rs`'s own - // `answer_token_admits` gate refuses unconditionally, - // the same as any other mismatch. It is never - // rendered as an empty string a forged answer could - // match. - let play_action = play - .and_then(|view| view.question.as_ref()) - .map(|question| json!({"answerToken": question.token})) - .unwrap_or_else(|| json!({})); - children.extend([ - json!({ - "id":"world.play.text", - "kind":"control.input.textarea", - // PA.f214: an authored `value`, as `world.create.brief` above: - // the lowering only captures a `value`, so a box left - // untouched must still submit `text: ""`, which continues - // an existing Running turn (the running hint tells players - // to click Play with the box empty). - "props":{"label":"What do you do?","rows":3,"value":"","placeholder":"Write freely"}, - "stateBindings":[local_draft("text", "string")], - "children":[] - }), - command_button( - "world.play", - "Play", - "world.play", - play_action, - &["text"], - ), - ]); - commands.push(command_descriptor( - "world.play", - "ghostlight.world_play.v0", - &["text"], - "PlayTable", - )); - } - } - } - } - } - - children.push(command_button( - "app.auth.logout", - "Sign out", - "app.auth.logout", - json!({}), - &[], - )); - commands.push(command_descriptor( - "app.auth.logout", - "ghostlight.app_logout.v2", - &[], - "AppSessionOwner", - )); - Ok(surface_document( - version, - snapshot - .map(|world| world.title.as_str()) - .unwrap_or("Ghostlight Dungeon"), - children, - commands, - )) -} - -pub(crate) fn validate_invocation( - invocation: &EveCommandInvocation, - expected_transport: &str, -) -> anyhow::Result<()> { - if invocation.schema != "gamecult.eve.command_invocation.v1" - || invocation.provider_id != PROVIDER_ID - || invocation.surface_id != SURFACE_ID - || invocation.command_boundary != COMMAND_BOUNDARY - || invocation.receipt_schema != COMMAND_RESULT_SCHEMA - { - bail!("invocation does not match the Ghostlight Eve boundary"); - } - if invocation.operation.operation_id.trim().is_empty() - || invocation - .operation - .idempotency_key - .as_deref() - .is_none_or(str::is_empty) - || invocation - .operation - .schema_id - .as_deref() - .is_none_or(str::is_empty) - || invocation.client_id.trim().is_empty() - || invocation.issued_at.parse::>().is_err() - || invocation.operation.route_hint.transport.as_deref() != Some(expected_transport) - { - bail!("invocation metadata is incomplete"); - } - let expected = operation_schema(&invocation.operation.operation_id) - .context("Eve operation is not advertised by Ghostlight")?; - if invocation.operation.schema_id.as_deref() != Some(expected) { - bail!("operation payload schema does not match its command descriptor"); - } - if invocation.payload.get("caller").is_some() - || invocation.payload.get("caller_id").is_some() - || invocation.payload.get("callerId").is_some() - { - bail!("payload may not supply caller authority"); - } - Ok(()) -} - -pub(crate) fn operation_schema(operation: &str) -> Option<&'static str> { - Some(match operation { - "heimdall.auth.begin" => "heimdall.auth_begin_command.v1", - "heimdall.auth.complete" => "heimdall.auth_complete_command.v1", - "app.auth.logout" => "ghostlight.app_logout.v2", - "world.create" => "ghostlight.world_create.v4", - "world.approve" => "ghostlight.world_approve.v0", - "world.activate" => "ghostlight.world_activate.v0", - "world.advance_time" => "ghostlight.world_advance_time.v0", - "world.seed" => "ghostlight.world_seed.v1", - "world.play" => "ghostlight.world_play.v0", - _ => return None, - }) -} - -/// One command result in the shape `gamecult.eve.command_result.v1` actually -/// admits: `additionalProperties: false` over exactly `schema`, `receipt`, -/// `transientProjection`, `pluginPayload` and `draftDirective`, with the -/// per-command detail (state, message, source version, ids) inside the -/// receipt, which is `gamecult.eve.command_receipt.v1` and does allow its own -/// extra fields. -/// -/// The browser validates this envelope before it looks at anything in it, so -/// a flat result is not a lenient dialect of the contract — it is rejected -/// whole, with the client reporting "invalid Eve command result: data must -/// not have additional properties" and no command of any kind completing. -pub(crate) fn command_result( - invocation: &EveCommandInvocation, - state: &str, - message: impl Into, - source_version: Option, - plugin_payload: Option, - receipt_extra: Option, -) -> Value { - let message = message.into(); - // A refusal is otherwise visible only to the browser that received it. - // The operator log gets the operation and the refusal's own message, - // never the payload. - if !matches!(state, "accepted" | "completed" | "pending") { - tracing::warn!( - operation = %invocation.operation.operation_id, - state, - message = %message, - "Eve command not accepted" - ); - } - let mut receipt = json!({ - "schema":COMMAND_RECEIPT_SCHEMA, - "receiptId":Uuid::new_v4().to_string(), - "commandId":invocation.operation.idempotency_key.clone().unwrap_or_default(), - "command":invocation.operation.operation_id, - "state":state, - "ownerRepo":OWNER_REPO, - "authority":PROVIDER_ID, - "providerId":PROVIDER_ID, - "surfaceId":SURFACE_ID, - "message":message, - // The contract requires a source version and refuses a null one. A - // command that reports no version reports the surface it was decided - // against, which is 0 before any world exists. - "sourceVersion":source_version.unwrap_or(0), - "issuedAtUtc":Utc::now().to_rfc3339() - }); - if let (Some(map), Some(extra)) = (receipt.as_object_mut(), receipt_extra) { - if let Some(extra) = extra.as_object() { - for (key, value) in extra { - map.insert(key.clone(), value.clone()); - } - } - } - let mut result = json!({ - "schema":COMMAND_RESULT_SCHEMA, - "receipt":receipt - }); - if let (Some(map), Some(payload)) = (result.as_object_mut(), plugin_payload) { - map.insert("pluginPayload".into(), payload); - } - result -} - -fn surface_document( - version: u64, - title: &str, - children: Vec, - commands: Vec, -) -> Value { - json!({ - "type":"surface-state", - "schema":"gamecult.eve.surface.v1", - "providerId":PROVIDER_ID, - "providerKind":"narrative.simulation", - "title":title, - // The one meaning `routeHint.sourceVersion` and the kernel's - // compare-and-swap require of it (PA.f148/PA.f160): `surface_version` - // alone. The play row's own revision has no wire reader (PA.f165) - // and is never carried here. - "version":version, - "updatedAtUtc":Utc::now().to_rfc3339(), - "surface":{ - "id":SURFACE_ID, - "root":{"id":"ghostlight.root","kind":"surface","props":{},"children":children}, - "styles":{"tokens":{ - "colorBackground":"#0c1110", - "colorPanel":"#17201d", - "colorText":"#e8e1cf", - "colorMuted":"#9aa69f", - "colorAccent":"#d49b58" - }} - }, - "commands":commands - }) -} - -fn command_button(id: &str, label: &str, command: &str, action: Value, bindings: &[&str]) -> Value { - let mut action = action.as_object().cloned().unwrap_or_default(); - action.insert("command".into(), Value::String(command.into())); - json!({ - "id":id, - "kind":"control.button", - "props":{"label":label,"command":command,"action":action,"captureBindings":bindings}, - "children":[] - }) -} - -fn command_descriptor(command: &str, schema: &str, bindings: &[&str], authority: &str) -> Value { - json!({ - "schema":"gamecult.eve.command.v1", - "command":command, - "payloadSchema":schema, - "captureBindings":bindings, - "transport":"https-json", - "authority":authority - }) -} - -/// A `captureBindings` entry the vendored browser lowering -/// (`@gamecult/eve-browser-lowering`, `EveStateBindingDescriptor`) actually -/// resolves, field-for-field the same shape its own test suite -/// (`test/host-isolation.test.mjs`) builds for its own `composer.message` -/// local-draft binding: `targetProp`, `pointerId`, `sourceId`, `schemaId`, -/// `routeKind`, `bindingName`, `valueKind`, `accessMode`, `authority`. -/// -/// Two fields are load-bearing. `bindingName` must equal `key`, since `key` -/// is also the exact string Dungeon's own `captureBindings` array and -/// `command_descriptor` advertise — `findAuthoredBindingValue` and -/// `editableBindingContext` both match a binding by this field (falling back -/// to `pointerId`, then the control's own node id, only when it is absent). -/// `accessMode` must stay `"local-draft"`: `editableBindingContext` -/// (`src/index.ts:2198`) defaults an absent `accessMode` to `"read"` whenever -/// a binding is present at all, and a `"read"` control is disabled -/// (`:2205`) — an editable field with no `accessMode` here would render -/// disabled, not merely miscaptured. The remaining fields -/// (`targetProp`, `pointerId`, `sourceId`, `schemaId`, `routeKind`, -/// `valueKind`, `authority`) carry no runtime behavior in the current -/// lowering (grep confirms `valueKind` is read nowhere), but are still worth -/// stating correctly rather than leaving absent. -/// -/// `value_kind` must be one of the lowering's own `valueKind` union -/// (`"string" | "number" | "boolean" | "choice" | "string-list"`); Dungeon's -/// own JSON-textarea fields (`targets`, `jurisdictions`, `lens_weights`) are, -/// client-side, plain text controls like any other — there is no JSON -/// `valueKind` — so callers pass `"string"` for those too, and the server -/// side parses the captured text as JSON (`deserialize_json_capture` in -/// `runtime.rs`). -fn local_draft(key: &str, value_kind: &str) -> Value { - json!({ - "targetProp":"value", - "pointerId":format!("ghostlight.local.{key}"), - "sourceId":"eve.browser.local", - "schemaId":"gamecult.eve.local_draft.v1", - "routeKind":"in-process", - "bindingName":key, - "valueKind":value_kind, - "accessMode":"local-draft", - "authority":"eve.browser" - }) -} - -fn short_principal(principal: &str) -> String { - principal.chars().take(20).collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn invocation(operation: &str, schema: &str, payload: Value) -> EveCommandInvocation { - EveCommandInvocation { - schema: "gamecult.eve.command_invocation.v1".into(), - provider_id: PROVIDER_ID.into(), - surface_id: SURFACE_ID.into(), - operation: EveOperation { - operation_id: operation.into(), - schema_id: Some(schema.into()), - idempotency_key: Some(uuid::Uuid::new_v4().to_string()), - route_hint: EveRouteHint { - source_version: Some(0), - transport: Some("https-json".into()), - }, - }, - payload, - issued_at: Utc::now().to_rfc3339(), - client_id: "fixture".into(), - command_boundary: COMMAND_BOUNDARY.into(), - receipt_schema: COMMAND_RESULT_SCHEMA.into(), - } - } - - #[test] - fn anonymous_surface_advertises_only_authentication() { - let surface = anonymous_surface(); - let commands = surface["commands"].as_array().unwrap(); - assert_eq!(commands.len(), 1); - assert_eq!(commands[0]["command"], "heimdall.auth.begin"); - assert_eq!(surface["version"], 0); - } - - #[test] - fn empty_authenticated_surface_has_create_without_session_zero() { - let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); - let encoded = serde_json::to_string(&surface).unwrap(); - assert!(encoded.contains("world.create")); - assert!(encoded.contains("narrative_persona_label")); - assert!(encoded.contains("operational_agent_label")); - assert!(!encoded.contains("session_zero")); - assert!(!encoded.contains("campaign")); - assert_eq!(surface["version"], 0); - - // The lens control's default is Dungeon's uniform policy, every stock - // lens named, and it decodes as the weights the payload will carry. - fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node["id"] == id { - return Some(node); - } - node.as_object()? - .values() - .flat_map(|value| match value { - Value::Array(items) => items.iter().collect::>(), - other => vec![other], - }) - .find_map(|child| find(child, id)) - } - let control = find(&surface, "world.create.lens_weights").expect("a lens weights control"); - let default = control["props"]["value"].as_str().expect("a default value"); - let decoded: ghostlight::LensWeights = serde_json::from_str(default).unwrap(); - assert_eq!(decoded, crate::runtime::uniform_lens_weights()); - assert_eq!(decoded.iter().count(), ghostlight::Lens::ALL.len()); - assert!(decoded.iter().all(|(_, weight)| weight == 1)); - } - - /// `world.play`'s own operation id resolves to its schema through - /// `operation_schema`, so an invocation whose payload matches it - /// validates, and a payload that tries to claim authority is refused — - /// the same claim this test once made for the now-deleted speak - /// operation, PA.f146's own precedent (Cut 8b's schema existed here - /// before Cut 9 published `world.play`'s own descriptor and button, - /// below). - #[test] - fn payload_cannot_claim_authority() { - assert_eq!(operation_schema("world.play"), Some("ghostlight.world_play.v0")); - - let bound = invocation( - "world.play", - "ghostlight.world_play.v0", - json!({"text":"I look around.", "answers": null}), - ); - assert!(validate_invocation(&bound, "https-json").is_ok()); - - let forged = invocation( - "world.play", - "ghostlight.world_play.v0", - json!({"text":"I look around.", "answers": null, "caller":"owner"}), - ); - assert!(validate_invocation(&forged, "https-json").is_err()); - } - - #[test] - fn invocation_metadata_rejects_legacy_authority_extensions() { - let exact = invocation( - "world.play", - "ghostlight.world_play.v0", - json!({"text":"I look around.", "answers": null}), - ); - let mut outer = serde_json::to_value(&exact).unwrap(); - outer["callerId"] = json!("legacy-owner"); - assert!(serde_json::from_value::(outer).is_err()); - - let mut route = serde_json::to_value(&exact).unwrap(); - route["operation"]["routeHint"]["caller"] = json!("legacy-owner"); - assert!(serde_json::from_value::(route).is_err()); - } - - #[test] - fn removed_operation_is_unknown() { - assert!(operation_schema("session_zero.begin").is_none()); - assert!(operation_schema("world.assess").is_none()); - assert!(operation_schema("governance.time.propose").is_none()); - assert!(operation_schema("world.speak").is_none()); - } -} diff --git a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json deleted file mode 100644 index 3907bbc..0000000 --- a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json +++ /dev/null @@ -1,107 +0,0 @@ -{ - "schema": "ghostlight.eve_client_bridge_fixture.v1", - "steps": [ - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:34.345Z", - "operation": { - "idempotencyKey": "00e4b100-7626-4c62-8f45-5c2cfd5b5528", - "operationId": "world.create", - "routeHint": { - "sourceVersion": 0, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_create.v4" - }, - "payload": { - "bindings": { - "brief": "A world captured for the committed Eve client bridge fixture.", - "jurisdictions": "[]", - "lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}", - "subject_label": "The Fixture Operator", - "targets": "{}", - "title": "Bridge Fixture World" - } - }, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.create" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:35.759Z", - "operation": { - "idempotencyKey": "10b5cf50-43c3-4735-b54c-3cec0c1e632b", - "operationId": "world.approve", - "routeHint": { - "sourceVersion": 1, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_approve.v0" - }, - "payload": {}, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.approve" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:37.240Z", - "operation": { - "idempotencyKey": "d89dea99-cfeb-4992-9d5d-33b3f7a1cce2", - "operationId": "world.activate", - "routeHint": { - "sourceVersion": 2, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_activate.v0" - }, - "payload": {}, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.activate" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:38.611Z", - "operation": { - "idempotencyKey": "d654e813-be7d-4818-9872-9aecd86c4e39", - "operationId": "world.play", - "routeHint": { - "sourceVersion": 3, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_play.v0" - }, - "payload": { - "bindings": { - "text": "The new owner looks around." - } - }, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.play" - } - ], - "vendorEveSubmoduleRevision": "672c0c1e9fc828b63752345385727b06c50491d0" -} diff --git a/crates/ghostlight-dungeon/src/heimdall.rs b/crates/ghostlight-dungeon/src/heimdall.rs deleted file mode 100644 index 9b9e27b..0000000 --- a/crates/ghostlight-dungeon/src/heimdall.rs +++ /dev/null @@ -1,1678 +0,0 @@ -use aes_gcm::{ - Aes256Gcm, KeyInit, - aead::{Aead, Payload}, -}; -use anyhow::{Context, bail}; -use base64::{ - Engine, - engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, -}; -use chrono::{DateTime, SecondsFormat, Utc}; -use cultcache_rs::DatabaseEntry; -use cultmesh_rs::{CultMesh, CultMeshNodeOptions, CultMeshRudpSnapshotOptions}; -use cultnet_rs::{ - CULTNET_OPERATION_CONNECTION_ID, CultNetMessage, CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, -}; -use hmac::{Hmac, Mac}; -use jsonwebtoken::{ - Algorithm, DecodingKey, Validation, decode, decode_header, - jwk::{Jwk, JwkSet}, -}; -use rand::RngCore; -use reqwest::Client; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use sha2::{Digest, Sha256}; -use std::{ - net::{SocketAddr, UdpSocket}, - path::PathBuf, - sync::{Arc, Mutex as StdMutex}, - time::{Duration, Instant}, -}; - -const APP_SLUG: &str = "ghostlight"; -const PRIVATE_SERVICE: &str = "heimdall.private.commands"; -const PRIVATE_ENVELOPE_SCHEMA: &str = "heimdall.private_command_envelope.v1"; - -/// Heimdall answered a private command with status denied. Distinct from a -/// transport failure so the refresh loop never reads a message string. -#[derive(Debug, thiserror::Error)] -#[error("Heimdall denied the private command: {diagnostics}")] -pub(crate) struct HeimdallDenied { - pub(crate) diagnostics: String, -} - -/// Heimdall's signing keys could not be fetched, so a receipt could not be -/// judged either way. Distinct from a receipt that positively fails -/// verification: the refresh loop keeps the receipt for this and revokes for -/// that. It carries an error class and never the fetch error itself, whose -/// text names the key-set URL: an error never echoes an input value. -#[derive(Debug, thiserror::Error)] -#[error("Heimdall signing keys unavailable: the key-set fetch {class}")] -pub(crate) struct VerificationUnavailable { - class: &'static str, -} - -impl VerificationUnavailable { - fn of_fetch(error: &reqwest::Error) -> Self { - let class = if error.is_timeout() { - "timed out" - } else if error.is_connect() { - "could not connect" - } else if error.is_status() { - "returned an error status" - } else if error.is_decode() { - "returned an unreadable key set" - } else { - "failed" - }; - Self { class } - } - - #[cfg(test)] - pub(crate) fn new(class: &'static str) -> Self { - Self { class } - } -} - -/// How long a fetched signing-key set is trusted before it is fetched again. -const JWKS_TTL: Duration = Duration::from_secs(300); - -/// The last fetched signing-key set and when it was fetched. -#[derive(Default)] -pub(crate) struct JwksCache { - fetched: Option<(Instant, JwkSet)>, -} - -impl JwksCache { - /// The key published under `kid`, if the cached set is still fresh and - /// holds it. A miss on a fresh set still refetches, so a rotated key is - /// found. - pub(crate) fn key(&self, kid: &str, now: Instant) -> Option { - let (fetched_at, set) = self.fetched.as_ref()?; - if now.saturating_duration_since(*fetched_at) >= JWKS_TTL { - return None; - } - set.find(kid).cloned() - } - - pub(crate) fn store(&mut self, set: JwkSet, now: Instant) { - self.fetched = Some((now, set)); - } -} - -/// The two Heimdall calls the refresh loop makes, as a port so a scripted -/// Heimdall can stand in for the real one. -#[async_trait::async_trait] -pub(crate) trait SessionRefresher { - async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result; - - async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result; -} - -#[async_trait::async_trait] -impl SessionRefresher for HeimdallClient { - async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - HeimdallClient::refresh(self, refresh_token, idempotency_key).await - } - - async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - HeimdallClient::verify_refresh(self, completion).await - } -} - -#[derive(Clone)] -pub struct HeimdallClient { - http: Client, - issuer: String, - public_app_url: String, - discord_guild_id: String, - discord_role_id: String, - boundary_locator: HeimdallBoundaryLocator, - runtime_id: String, - shared_secret: String, - jwks: Arc>, -} - -/// Idunn's projection of a managed Heimdall dependency, when Heimdall is a -/// v2-managed target (it is not, as of this cut: every v2 deploy of it has -/// failed and it still runs as the legacy `heimdall.service`, so -/// `runtime.rs` always passes `None` here). Not an Idunn dependency in -/// `deployment/idunn/recipe.toml` right now, but the type stays owned here -/// so `require_expected_heimdall_boundary` below can resume checking a -/// discovered boundary against it the moment Heimdall returns as a managed -/// dependency, without re-deriving this shape from scratch. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct HeimdallDependencyBinding { - pub(crate) provider_id: String, - pub(crate) endpoint: SocketAddr, -} - -#[derive(Clone)] -enum HeimdallBoundaryLocator { - Odin { - endpoint: SocketAddr, - expected: Option, - }, - #[cfg(test)] - Fixed(ResolvedHeimdallBoundary), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct ResolvedHeimdallBoundary { - endpoint: SocketAddr, - daemon_id: String, - operations: Vec, -} - -#[derive(Clone, Debug, DatabaseEntry)] -#[cultcache( - type = "heimdall.command_boundary", - schema = "heimdall.command_boundary.v1" -)] -struct HeimdallCommandBoundaryCatalogEntry { - #[cultcache(key = 0)] - value: serde_json::Value, -} - -/// Heimdall owns this document and the receipts below; Dungeon reads the -/// fields it needs and ignores the rest. Only the signed `PrivateEnvelope`, -/// whose shape both sides fix, rejects unknown fields. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HeimdallCommandBoundaryRecord { - schema: String, - boundary_id: String, - daemon_id: String, - provider_id: String, - updated_at: String, - commands: Vec, - private_route: HeimdallPrivateRoute, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HeimdallBoundaryCommand { - operation: String, - request_schema: String, - response_schema: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HeimdallPrivateRoute { - endpoint: String, - exposure: String, - authentication: String, - secret_bearing: bool, -} - -cultmesh_rs::cultmesh_documents!(HeimdallDiscoveryDocuments { - HeimdallCommandBoundaryCatalogEntry => "heimdall.command_boundary.v1", -}); - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateEnvelope { - schema: String, - app_slug: String, - operation: String, - content_schema: String, - issued_at: String, - expires_at: String, - nonce: String, - idempotency_key: String, - iv: String, - ciphertext: String, - auth_tag: String, - signature: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BeginCommand<'a> { - provider: &'a str, - mode: &'a str, - return_to: &'a str, - entitlement_policy: DiscordRolePolicy<'a>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RefreshCommand<'a> { - schema: &'static str, - refresh_token: &'a str, - entitlement_policy: DiscordRolePolicy<'a>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LogoutCommand<'a> { - schema: &'static str, - refresh_token: &'a str, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct DiscordRolePolicy<'a> { - kind: &'a str, - guild_id: &'a str, - allowed_role_ids: [&'a str; 1], -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthBeginReceipt { - pub status: String, - pub handle: String, - pub expires_at: String, - pub navigation: AuthNavigation, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthNavigation { - pub url: String, - pub allowed_origins: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthCompletionReceipt { - pub status: String, - pub handle: Option, - pub error: Option, - pub account: Option, - pub session: Option, - pub access_token: Option, - pub refresh_token: Option, - pub refresh: Option, - #[serde(default)] - pub shared_capabilities: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct AccountSummary { - pub id: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HeimdallSession { - pub account_id: String, - pub session_id: String, - pub app_slug: String, - pub access_revision: u64, - pub expires_at: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RefreshSummary { - pub expires_at: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthLogoutReceipt { - pub status: String, - pub session_id: String, - pub access_revision: u64, - pub revoked_at: String, -} - -#[derive(Debug, Deserialize)] -pub struct AccessClaims { - pub iss: String, - pub aud: String, - pub sub: String, - pub sid: String, - pub exp: u64, - pub typ: String, - pub account_id: String, - pub access_revision: u64, - pub capabilities: Vec, - pub app: AppClaim, -} - -#[derive(Debug, Deserialize)] -pub struct AppClaim { - pub slug: String, -} - -/// Opaque session material admitted only after a signed Heimdall completion -/// and its response envelope agree exactly. -pub(crate) struct VerifiedSessionAdmission(VerifiedSessionMaterial); - -/// Opaque refresh material admitted only after a signed Heimdall refresh and -/// its response envelope agree exactly. -pub(crate) struct VerifiedSessionRefresh(VerifiedSessionMaterial); - -struct VerifiedSessionMaterial { - account_id: String, - heimdall_session_id: String, - access_revision: u64, - capabilities: Vec, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - refresh_claim: String, -} - -macro_rules! verified_session_accessors { - ($name:ident) => { - impl $name { - pub(crate) fn account_id(&self) -> &str { - &self.0.account_id - } - - pub(crate) fn heimdall_session_id(&self) -> &str { - &self.0.heimdall_session_id - } - - pub(crate) fn access_revision(&self) -> u64 { - self.0.access_revision - } - - pub(crate) fn capabilities(&self) -> &[String] { - &self.0.capabilities - } - - pub(crate) fn access_expires_at(&self) -> DateTime { - self.0.access_expires_at.to_owned() - } - - pub(crate) fn refresh_expires_at(&self) -> DateTime { - self.0.refresh_expires_at.to_owned() - } - - pub(crate) fn refresh_claim(&self) -> &str { - &self.0.refresh_claim - } - } - }; -} - -verified_session_accessors!(VerifiedSessionAdmission); -verified_session_accessors!(VerifiedSessionRefresh); - -#[cfg(test)] -impl VerifiedSessionAdmission { - pub(crate) fn fixture( - account_id: impl Into, - heimdall_session_id: impl Into, - access_revision: u64, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - refresh_claim: impl Into, - ) -> Self { - Self(VerifiedSessionMaterial { - account_id: account_id.into(), - heimdall_session_id: heimdall_session_id.into(), - access_revision, - capabilities: vec!["app_access".into()], - access_expires_at, - refresh_expires_at, - refresh_claim: refresh_claim.into(), - }) - } -} - -#[cfg(test)] -impl VerifiedSessionRefresh { - pub(crate) fn fixture( - account_id: impl Into, - heimdall_session_id: impl Into, - access_revision: u64, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - refresh_claim: impl Into, - ) -> Self { - Self(VerifiedSessionMaterial { - account_id: account_id.into(), - heimdall_session_id: heimdall_session_id.into(), - access_revision, - capabilities: vec!["app_access".into()], - access_expires_at, - refresh_expires_at, - refresh_claim: refresh_claim.into(), - }) - } -} - -impl HeimdallClient { - pub fn from_env( - runtime_id: &str, - odin_endpoint: SocketAddr, - expected: Option, - ) -> anyhow::Result { - if runtime_id.is_empty() || runtime_id.trim() != runtime_id { - bail!("Ghostlight runtime identity is not canonical"); - } - let secret_path = std::env::var_os("GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE") - .map(PathBuf::from) - .context("GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE is required")?; - let shared_secret = std::fs::read_to_string(&secret_path) - .with_context(|| format!("failed to read {}", secret_path.display()))? - .trim() - .to_owned(); - if shared_secret.is_empty() { - bail!("Ghostlight's Heimdall app secret is empty"); - } - Ok(Self { - http: Client::builder().timeout(Duration::from_secs(10)).build()?, - issuer: std::env::var("GHOSTLIGHT_HEIMDALL_BASE_URL") - .unwrap_or_else(|_| "https://heimdall.gamecult.org".into()) - .trim_end_matches('/') - .into(), - public_app_url: std::env::var("GHOSTLIGHT_PUBLIC_APP_URL") - .unwrap_or_else(|_| "https://yggdrasil.gamecult.org/ghostlight/".into()), - discord_guild_id: required_env("GHOSTLIGHT_DISCORD_GUILD_ID")?, - discord_role_id: required_env("GHOSTLIGHT_DISCORD_ROLE_ID")?, - boundary_locator: HeimdallBoundaryLocator::Odin { - endpoint: odin_endpoint, - expected, - }, - runtime_id: runtime_id.into(), - shared_secret, - jwks: Arc::default(), - }) - } - - #[cfg(test)] - pub fn fixture() -> Self { - Self { - http: Client::new(), - issuer: "https://heimdall.invalid".into(), - public_app_url: "https://ghostlight.invalid/".into(), - discord_guild_id: "guild-kltst".into(), - discord_role_id: "role-kltst".into(), - boundary_locator: HeimdallBoundaryLocator::Fixed(ResolvedHeimdallBoundary { - endpoint: "127.0.0.1:9".parse().unwrap(), - daemon_id: "yggdrasil-heimdall".into(), - operations: vec![ - "heimdall.auth.begin".into(), - "heimdall.auth.complete".into(), - "heimdall.auth.refresh".into(), - "heimdall.auth.logout".into(), - ], - }), - runtime_id: "ghostlight-test-runtime".into(), - shared_secret: "fixture-secret".into(), - jwks: Arc::default(), - } - } - - /// The same client against another issuer, for tests that stand up their own. - #[cfg(test)] - pub(crate) fn with_issuer(mut self, issuer: impl Into) -> Self { - self.issuer = issuer.into(); - self - } - - pub async fn begin(&self, idempotency_key: &str) -> anyhow::Result { - let payload = BeginCommand { - provider: "discord", - mode: "sign_in", - return_to: &self.public_app_url, - entitlement_policy: DiscordRolePolicy { - kind: "discord_role_access", - guild_id: &self.discord_guild_id, - allowed_role_ids: [&self.discord_role_id], - }, - }; - self.invoke( - "heimdall.auth.begin", - "heimdall.auth_begin_command.v1", - idempotency_key, - &payload, - "heimdall.auth_begin_receipt.v1", - ) - .await - } - - pub async fn complete( - &self, - handle: &str, - idempotency_key: &str, - ) -> anyhow::Result { - self.invoke( - "heimdall.auth.complete", - "heimdall.auth_complete_command.v1", - idempotency_key, - &serde_json::json!({"handle": handle}), - "heimdall.auth_completion_receipt.v1", - ) - .await - } - - pub async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - let payload = RefreshCommand { - schema: "heimdall.auth_refresh_command.v1", - refresh_token, - entitlement_policy: DiscordRolePolicy { - kind: "discord_role_access", - guild_id: &self.discord_guild_id, - allowed_role_ids: [&self.discord_role_id], - }, - }; - self.invoke( - "heimdall.auth.refresh", - "heimdall.auth_refresh_command.v1", - idempotency_key, - &payload, - "heimdall.auth_refresh_receipt.v1", - ) - .await - } - - pub async fn logout( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - let payload = LogoutCommand { - schema: "heimdall.auth_logout_command.v1", - refresh_token, - }; - self.invoke( - "heimdall.auth.logout", - "heimdall.auth_logout_command.v1", - idempotency_key, - &payload, - "heimdall.auth_logout_receipt.v1", - ) - .await - } - - pub async fn verify_completion( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - if completion.status != "authenticated" { - bail!("Heimdall completion is not authenticated"); - } - let token = completion - .access_token - .as_deref() - .context("Heimdall omitted the access token")?; - let claims = self.verify_access(token).await?; - verify_claim_binding(&completion, &claims, true)?; - Ok(VerifiedSessionAdmission(verified_session_material( - completion, claims, - )?)) - } - - pub async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - if completion.status != "authenticated" { - bail!("Heimdall refresh is not authenticated"); - } - let token = completion - .access_token - .as_deref() - .context("Heimdall refresh omitted the access token")?; - let claims = self.verify_access(token).await?; - verify_claim_binding(&completion, &claims, false)?; - Ok(VerifiedSessionRefresh(verified_session_material( - completion, claims, - )?)) - } - - async fn fetch_jwks(&self) -> Result { - self.http - .get(format!("{}/.well-known/jwks.json", self.issuer)) - .send() - .await? - .error_for_status()? - .json() - .await - } - - async fn verify_access(&self, token: &str) -> anyhow::Result { - let header = decode_header(token)?; - if header.alg != Algorithm::EdDSA { - bail!("Heimdall used an unsupported signing algorithm"); - } - let kid = header.kid.context("Heimdall token omitted kid")?; - let cached = self.jwks.lock().expect("jwks cache").key(&kid, Instant::now()); - let jwk = match cached { - Some(jwk) => jwk, - None => { - let set = self - .fetch_jwks() - .await - .map_err(|error| VerificationUnavailable::of_fetch(&error))?; - let jwk = set.find(&kid).cloned(); - self.jwks - .lock() - .expect("jwks cache") - .store(set, Instant::now()); - jwk.context("Heimdall signing key was not published")? - } - }; - let mut validation = Validation::new(Algorithm::EdDSA); - validation.set_audience(&[APP_SLUG]); - validation.set_issuer(&[self.issuer.as_str()]); - let claims = - decode::(token, &DecodingKey::from_jwk(&jwk)?, &validation)?.claims; - if claims.typ != "heimdall_access" - || claims.aud != APP_SLUG - || claims.iss != self.issuer - || claims.sub != claims.account_id - || claims.app.slug != APP_SLUG - || !claims - .capabilities - .iter() - .any(|value| value == "app_access") - { - bail!("Heimdall claim did not grant Ghostlight app_access"); - } - Ok(claims) - } - - async fn invoke( - &self, - operation: &str, - content_schema: &str, - idempotency_key: &str, - payload: &impl Serialize, - expected_schema: &str, - ) -> anyhow::Result { - let envelope = seal_envelope( - operation, - content_schema, - idempotency_key, - &self.shared_secret, - payload, - )?; - let boundary_locator = self.boundary_locator.clone(); - let runtime_id = self.runtime_id.clone(); - let operation = operation.to_owned(); - let request_payload = STANDARD.encode(rmp_serde::to_vec_named(&envelope)?); - let transport_operation = operation.clone(); - let response = tokio::task::spawn_blocking(move || { - let boundary = boundary_locator.resolve(&runtime_id, &transport_operation)?; - invoke_operation( - boundary.endpoint, - &boundary.daemon_id, - &runtime_id, - &transport_operation, - request_payload, - ) - }) - .await??; - let CultNetMessage::OperationResponse { - status, - payload_schema, - payload_encoding, - payload, - diagnostics, - .. - } = response - else { - bail!("Heimdall returned a non-operation response"); - }; - refuse_denied(&status, &diagnostics)?; - if payload_schema != PRIVATE_ENVELOPE_SCHEMA || payload_encoding != "messagepack-base64" { - bail!("Heimdall returned the wrong private response contract"); - } - let sealed: PrivateEnvelope = rmp_serde::from_slice(&STANDARD.decode(payload)?)?; - validate_private_response_binding(&sealed, &operation, expected_schema, idempotency_key)?; - open_envelope(&sealed, &self.shared_secret) - } -} - -/// A response with status denied is Heimdall's answer, not a transport -/// failure: it becomes the typed `HeimdallDenied`. -fn refuse_denied(status: &str, diagnostics: &[String]) -> anyhow::Result<()> { - if status == "denied" { - return Err(HeimdallDenied { - diagnostics: diagnostics.join("; "), - } - .into()); - } - Ok(()) -} - -fn validate_private_response_binding( - envelope: &PrivateEnvelope, - operation: &str, - content_schema: &str, - idempotency_key: &str, -) -> anyhow::Result<()> { - if envelope.app_slug != APP_SLUG { - bail!("Heimdall private response belongs to another app"); - } - if envelope.operation != operation || envelope.content_schema != content_schema { - bail!("Heimdall private response disagrees with the requested operation"); - } - if envelope.idempotency_key != idempotency_key { - bail!("Heimdall private response belongs to another request"); - } - Ok(()) -} - -fn verify_claim_binding( - completion: &AuthCompletionReceipt, - claims: &AccessClaims, - require_account_summary: bool, -) -> anyhow::Result<()> { - if require_account_summary && completion.account.is_none() { - bail!("Heimdall omitted the account"); - } - if completion - .account - .as_ref() - .is_some_and(|account| account.id != claims.account_id) - { - bail!("Heimdall account summary disagrees with its signed claim"); - } - let session = completion - .session - .as_ref() - .context("Heimdall omitted the session")?; - if session.account_id != claims.account_id - || session.session_id != claims.sid - || session.access_revision != claims.access_revision - || session.app_slug != APP_SLUG - { - bail!("Heimdall session fields disagree with its signed claim"); - } - Ok(()) -} - -fn verified_session_material( - completion: AuthCompletionReceipt, - claims: AccessClaims, -) -> anyhow::Result { - if !completion - .shared_capabilities - .iter() - .any(|value| value == "app_access") - { - bail!("Heimdall response did not expose signed app_access"); - } - let session = completion - .session - .as_ref() - .context("Heimdall omitted the verified session")?; - let access_expiry = i64::try_from(claims.exp).context("Heimdall access expiry is too large")?; - let access_expires_at = - DateTime::from_timestamp(access_expiry, 0).context("Heimdall access expiry is invalid")?; - // Heimdall's session record lives as long as its refresh token, so the - // session expiry it reports is the refresh expiry (Heimdall - // `issueAccessClaim`: `session.expiresAt` and `refresh.expiresAt` are both - // the record's `expiresAt`); only the signed access claim carries the - // shorter access expiry. - let session_expires_at: DateTime = session - .expires_at - .parse() - .context("Heimdall session expiry is invalid")?; - let refresh_expires_at: DateTime = completion - .refresh - .as_ref() - .context("Heimdall omitted refresh expiry")? - .expires_at - .parse() - .context("Heimdall refresh expiry is invalid")?; - if session_expires_at != refresh_expires_at { - bail!("Heimdall session expiry disagrees with its refresh expiry"); - } - if refresh_expires_at <= access_expires_at { - bail!("Heimdall refresh expiry does not outlive access"); - } - let refresh_claim = completion - .refresh_token - .context("Heimdall omitted its refresh claim")?; - if refresh_claim.is_empty() || refresh_claim.trim() != refresh_claim { - bail!("Heimdall refresh claim is empty or noncanonical"); - } - Ok(VerifiedSessionMaterial { - account_id: claims.account_id, - heimdall_session_id: claims.sid, - access_revision: claims.access_revision, - capabilities: claims.capabilities, - access_expires_at, - refresh_expires_at, - refresh_claim, - }) -} - -fn invoke_operation( - endpoint: SocketAddr, - target_runtime_id: &str, - runtime_id: &str, - operation: &str, - payload: String, -) -> anyhow::Result { - let socket = UdpSocket::bind("127.0.0.1:0")?; - socket.set_read_timeout(Some(Duration::from_millis(25)))?; - let mut options = CultNetRudpSocketTransportOptions::client( - runtime_id.to_owned(), - socket, - endpoint, - CULTNET_OPERATION_CONNECTION_ID, - ); - options.max_fragment_bytes = Some(2_048); - let mut transport = CultNetRudpSocketTransportConnection::new(options)?; - transport.connect(Vec::new())?; - let deadline = Instant::now() + Duration::from_secs(10); - while !transport.connected() && Instant::now() < deadline { - let _ = transport.receive_once()?; - transport.poll_resends()?; - } - if !transport.connected() { - bail!("Heimdall private CultNet connection timed out"); - } - let message_id = uuid::Uuid::new_v4().to_string(); - transport.send_schema_message(&CultNetMessage::OperationRequest { - message_id: message_id.clone(), - service_id: PRIVATE_SERVICE.into(), - operation: operation.into(), - payload_schema: PRIVATE_ENVELOPE_SCHEMA.into(), - payload_encoding: "messagepack-base64".into(), - payload, - source_runtime_id: Some(runtime_id.into()), - target_runtime_id: Some(target_runtime_id.into()), - })?; - while Instant::now() < deadline { - if let Some(response) = transport.receive_schema_message_once()? { - if matches!(&response, CultNetMessage::OperationResponse { message_id: value, .. } if value == &message_id) - { - return Ok(response); - } - } - transport.poll_resends()?; - } - bail!("Heimdall private command timed out") -} - -impl HeimdallBoundaryLocator { - fn resolve( - &self, - runtime_id: &str, - operation: &str, - ) -> anyhow::Result { - let boundary = match self { - Self::Odin { endpoint, expected } => { - let boundary = discover_heimdall_boundary(*endpoint, runtime_id)?; - if let Some(expected) = expected { - require_expected_heimdall_boundary(&boundary, expected)?; - } - boundary - } - #[cfg(test)] - Self::Fixed(boundary) => boundary.clone(), - }; - if !boundary - .operations - .iter() - .any(|candidate| candidate == operation) - { - bail!("Odin's Heimdall boundary does not advertise {operation}"); - } - Ok(boundary) - } -} - -fn require_expected_heimdall_boundary( - boundary: &ResolvedHeimdallBoundary, - expected: &HeimdallDependencyBinding, -) -> anyhow::Result<()> { - if boundary.daemon_id != expected.provider_id || boundary.endpoint != expected.endpoint { - bail!("discovered Heimdall boundary differs from Expected"); - } - Ok(()) -} - -fn discover_heimdall_boundary( - odin_endpoint: SocketAddr, - runtime_id: &str, -) -> anyhow::Result { - let store_path = std::env::temp_dir().join(format!( - "ghostlight-heimdall-discovery-{}.cc", - uuid::Uuid::new_v4() - )); - let result = (|| { - let mut node = CultMesh::create_node( - &store_path, - HeimdallDiscoveryDocuments, - CultMeshNodeOptions { - runtime_id: format!("{runtime_id}:heimdall-discovery"), - pull_on_start: false, - }, - )?; - let mut options = CultMeshRudpSnapshotOptions::odin( - odin_endpoint, - format!("{runtime_id}:heimdall-discovery"), - ); - options.schema_ids = Some(vec!["heimdall.command_boundary.v1".into()]); - options.record_keys = Some(vec!["heimdall:command-boundary".into()]); - let applied = node.pull_rudp_catalog_snapshot(options)?; - if applied != 1 { - bail!("Odin did not return exactly one Heimdall command boundary"); - } - let envelope = node - .cache() - .snapshot() - .into_iter() - .find(|entry| { - entry.key == "heimdall:command-boundary" - && entry.r#type == "heimdall.command_boundary" - && entry.schema_id.as_deref() == Some("heimdall.command_boundary.v1") - }) - .context("Odin omitted the exact Heimdall command boundary envelope")?; - validate_heimdall_boundary(rmp_serde::from_slice(&envelope.payload)?) - })(); - let _ = std::fs::remove_file(&store_path); - result -} - -fn validate_heimdall_boundary( - boundary: HeimdallCommandBoundaryRecord, -) -> anyhow::Result { - if boundary.schema != "heimdall.command_boundary.v1" - || boundary.boundary_id != "heimdall" - || boundary.provider_id != "heimdall" - || boundary.daemon_id.trim().is_empty() - || boundary.updated_at.trim().is_empty() - { - bail!("Odin returned a foreign or malformed Heimdall command boundary"); - } - if boundary.private_route.exposure != "loopback-only" - || boundary.private_route.secret_bearing - || boundary.private_route.authentication != "app-bound HMAC + AES-256-GCM envelope" - { - bail!("Odin returned an unsafe Heimdall private route"); - } - let endpoint_text = boundary - .private_route - .endpoint - .strip_prefix("rudp://") - .context("Heimdall's discovered private route is not RUDP")?; - let endpoint: SocketAddr = endpoint_text - .parse() - .context("Heimdall's discovered private route is not a socket address")?; - if !endpoint.ip().is_loopback() { - bail!("Heimdall's discovered private route is not loopback-only"); - } - if boundary.commands.is_empty() - || boundary.commands.iter().any(|command| { - command.operation.trim().is_empty() - || command.request_schema.trim().is_empty() - || command.response_schema.trim().is_empty() - }) - { - bail!("Heimdall's discovered command catalog is malformed"); - } - let operations = boundary - .commands - .into_iter() - .map(|command| command.operation) - .collect(); - Ok(ResolvedHeimdallBoundary { - endpoint, - daemon_id: boundary.daemon_id, - operations, - }) -} - -fn seal_envelope( - operation: &str, - content_schema: &str, - idempotency_key: &str, - secret: &str, - payload: &impl Serialize, -) -> anyhow::Result { - let issued = Utc::now(); - let expires = issued + chrono::Duration::seconds(30); - let mut nonce_bytes = [0_u8; 16]; - let mut iv = [0_u8; 12]; - rand::rng().fill_bytes(&mut nonce_bytes); - rand::rng().fill_bytes(&mut iv); - let mut envelope = PrivateEnvelope { - schema: PRIVATE_ENVELOPE_SCHEMA.into(), - app_slug: APP_SLUG.into(), - operation: operation.into(), - content_schema: content_schema.into(), - issued_at: issued.to_rfc3339_opts(SecondsFormat::Millis, true), - expires_at: expires.to_rfc3339_opts(SecondsFormat::Millis, true), - nonce: URL_SAFE_NO_PAD.encode(nonce_bytes), - idempotency_key: idempotency_key.into(), - iv: URL_SAFE_NO_PAD.encode(iv), - ciphertext: String::new(), - auth_tag: String::new(), - signature: String::new(), - }; - let key = Sha256::digest( - [ - b"heimdall.private-command.v1\0".as_slice(), - secret.as_bytes(), - ] - .concat(), - ); - let ciphertext_and_tag = Aes256Gcm::new_from_slice(&key)? - .encrypt( - (&iv).into(), - Payload { - msg: &rmp_serde::to_vec_named(payload)?, - aad: envelope.aad().as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to encrypt Heimdall private command"))?; - let split = ciphertext_and_tag - .len() - .checked_sub(16) - .context("AES-GCM output omitted its tag")?; - envelope.ciphertext = URL_SAFE_NO_PAD.encode(&ciphertext_and_tag[..split]); - envelope.auth_tag = URL_SAFE_NO_PAD.encode(&ciphertext_and_tag[split..]); - envelope.signature = sign(&envelope, secret)?; - Ok(envelope) -} - -fn open_envelope( - envelope: &PrivateEnvelope, - secret: &str, -) -> anyhow::Result { - if envelope.schema != PRIVATE_ENVELOPE_SCHEMA { - bail!("unsupported Heimdall private envelope"); - } - let now = Utc::now(); - let issued: DateTime = envelope.issued_at.parse()?; - let expires: DateTime = envelope.expires_at.parse()?; - if issued > now + chrono::Duration::seconds(30) - || expires <= now - || expires <= issued - || expires - issued > chrono::Duration::seconds(120) - { - bail!("Heimdall private response is outside its accepted clock window"); - } - let mut mac = as Mac>::new_from_slice(secret.as_bytes())?; - mac.update(envelope.authenticated_fields().as_bytes()); - mac.verify_slice(&URL_SAFE_NO_PAD.decode(&envelope.signature)?) - .map_err(|_| anyhow::anyhow!("Heimdall private response signature is invalid"))?; - let key = Sha256::digest( - [ - b"heimdall.private-command.v1\0".as_slice(), - secret.as_bytes(), - ] - .concat(), - ); - let iv = URL_SAFE_NO_PAD.decode(&envelope.iv)?; - let mut ciphertext = URL_SAFE_NO_PAD.decode(&envelope.ciphertext)?; - ciphertext.extend(URL_SAFE_NO_PAD.decode(&envelope.auth_tag)?); - let plaintext = Aes256Gcm::new_from_slice(&key)? - .decrypt( - iv.as_slice().into(), - Payload { - msg: &ciphertext, - aad: envelope.aad().as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to decrypt Heimdall private response"))?; - Ok(rmp_serde::from_slice(&plaintext)?) -} - -fn sign(envelope: &PrivateEnvelope, secret: &str) -> anyhow::Result { - let mut mac = as Mac>::new_from_slice(secret.as_bytes())?; - mac.update(envelope.authenticated_fields().as_bytes()); - Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())) -} - -impl PrivateEnvelope { - fn aad(&self) -> String { - [ - self.schema.as_str(), - self.app_slug.as_str(), - self.operation.as_str(), - self.content_schema.as_str(), - self.issued_at.as_str(), - self.expires_at.as_str(), - self.nonce.as_str(), - self.idempotency_key.as_str(), - ] - .join("\n") - } - - fn authenticated_fields(&self) -> String { - [ - self.aad(), - self.iv.clone(), - self.ciphertext.clone(), - self.auth_tag.clone(), - ] - .join("\n") - } -} - -fn required_env(name: &str) -> anyhow::Result { - let value = std::env::var(name).with_context(|| format!("{name} is required"))?; - let value = value.trim(); - if value.is_empty() { - bail!("{name} must not be empty"); - } - Ok(value.to_owned()) -} - -/// A local stand-in for Heimdall's issuer: it serves the key set (or a 5xx) -/// on a loopback port and counts the requests it receives. The key is the -/// RFC 8037 appendix A.1 Ed25519 test key. -#[cfg(test)] -pub(crate) mod test_issuer { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, - }; - - pub(crate) const KID: &str = "k1"; - const PRIVATE_D: &str = "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A"; - const PUBLIC_X: &str = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"; - - #[derive(Clone, Copy)] - pub(crate) enum Answer { - KeySet, - ServerError, - } - - pub(crate) struct Issuer { - pub(crate) url: String, - fetches: Arc, - } - - impl Issuer { - pub(crate) async fn start(answer: Answer) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let fetches = Arc::new(AtomicUsize::new(0)); - let counted = fetches.clone(); - let key_set = serde_json::json!({ - "keys": [{"kty": "OKP", "crv": "Ed25519", "kid": KID, "x": PUBLIC_X}] - }) - .to_string(); - tokio::spawn(async move { - while let Ok((mut stream, _)) = listener.accept().await { - let mut request = Vec::new(); - let mut chunk = [0_u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - match stream.read(&mut chunk).await { - Ok(0) | Err(_) => break, - Ok(read) => request.extend_from_slice(&chunk[..read]), - } - } - counted.fetch_add(1, Ordering::SeqCst); - let (status, body) = match answer { - Answer::KeySet => ("200 OK", key_set.as_str()), - Answer::ServerError => ("503 Service Unavailable", ""), - }; - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\n\ - content-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - } - }); - Self { url, fetches } - } - - pub(crate) fn fetches(&self) -> usize { - self.fetches.load(Ordering::SeqCst) - } - - /// The URL of a loopback port nothing listens on. - pub(crate) fn closed_url() -> String { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - format!("http://{}", listener.local_addr().unwrap()) - } - } - - /// An access token Heimdall's key signs for `issuer`, with the claims - /// `verify_access` demands. - pub(crate) fn signed_token(issuer: &str) -> String { - let mut der = vec![ - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, - 0x04, 0x20, - ]; - der.extend(URL_SAFE_NO_PAD.decode(PRIVATE_D).unwrap()); - let pem = format!( - "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", - STANDARD.encode(der) - ); - let mut header = jsonwebtoken::Header::new(Algorithm::EdDSA); - header.kid = Some(KID.into()); - let claims = serde_json::json!({ - "iss": issuer, "aud": APP_SLUG, "sub": "account-1", "sid": "session-1", - "exp": Utc::now().timestamp() + 300, "typ": "heimdall_access", - "account_id": "account-1", "access_revision": 1, - "capabilities": ["app_access"], "app": {"slug": APP_SLUG}, - }); - jsonwebtoken::encode( - &header, - &claims, - &jsonwebtoken::EncodingKey::from_ed_pem(pem.as_bytes()).unwrap(), - ) - .unwrap() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use super::test_issuer::{Answer, Issuer, signed_token}; - - #[tokio::test] - async fn a_key_fetch_failure_is_typed_unavailable_and_names_no_url() { - let closed = Issuer::closed_url(); - let failing = Issuer::start(Answer::ServerError).await; - for (base, class) in [ - (closed, "could not connect"), - (failing.url.clone(), "returned an error status"), - ] { - let canary = format!("{base}/canary-q7x1"); - let client = HeimdallClient::fixture().with_issuer(canary.clone()); - let error = client - .verify_access(&signed_token(&canary)) - .await - .unwrap_err(); - assert!(error.downcast_ref::().is_some(), "{error:#}"); - for text in [format!("{error}"), format!("{error:#}"), format!("{error:?}")] { - assert!(text.contains("signing keys unavailable"), "{text}"); - assert!(text.contains(class), "{text}"); - for leaked in [canary.as_str(), base.as_str(), "canary-q7x1", "127.0.0.1"] { - assert!(!text.contains(leaked), "{leaked} leaked into {text}"); - } - } - } - } - - #[tokio::test] - async fn the_key_set_is_fetched_once_within_its_ttl_and_again_after_it() { - let issuer = Issuer::start(Answer::KeySet).await; - let client = HeimdallClient::fixture().with_issuer(issuer.url.clone()); - let token = signed_token(&issuer.url); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 1); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 1, "a second verify within the ttl reads the cache"); - let age = |seconds: u64| { - client.jwks.lock().unwrap().fetched.as_mut().unwrap().0 = - Instant::now().checked_sub(Duration::from_secs(seconds)).unwrap(); - }; - age(299); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 1, "299 s old is still fresh"); - age(301); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 2, "301 s old is fetched again"); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 2, "and the new fetch is cached"); - } - - #[tokio::test] - async fn a_token_signed_under_any_other_algorithm_is_refused_before_any_key_fetch() { - let issuer = Issuer::start(Answer::KeySet).await; - let client = HeimdallClient::fixture().with_issuer(issuer.url.clone()); - let part = |text: &str| URL_SAFE_NO_PAD.encode(text); - for alg in [ - "none", "HS256", "HS384", "HS512", "RS256", "RS384", "PS256", "ES256", "ES384", - ] { - let token = format!( - "{}.{}.{}", - part(&format!(r#"{{"alg":"{alg}","typ":"JWT","kid":"k1"}}"#)), - part(r#"{"sub":"account-1"}"#), - URL_SAFE_NO_PAD.encode([0xAB_u8; 32]), - ); - let error = client.verify_access(&token).await.unwrap_err(); - assert!(error.downcast_ref::().is_none(), "{alg}"); - if alg != "none" { - assert!( - error.to_string().contains("unsupported signing algorithm"), - "{alg}: {error:#}" - ); - } - assert_eq!(issuer.fetches(), 0, "{alg} reached the key fetch"); - } - } - - fn boundary(endpoint: &str) -> HeimdallCommandBoundaryRecord { - HeimdallCommandBoundaryRecord { - schema: "heimdall.command_boundary.v1".into(), - boundary_id: "heimdall".into(), - daemon_id: "yggdrasil-heimdall".into(), - provider_id: "heimdall".into(), - updated_at: "2026-08-22T12:00:00.000Z".into(), - commands: vec![HeimdallBoundaryCommand { - operation: "heimdall.auth.begin".into(), - request_schema: "heimdall.private_command_envelope.v1".into(), - response_schema: "heimdall.auth_begin_receipt.v1".into(), - }], - private_route: HeimdallPrivateRoute { - endpoint: endpoint.into(), - exposure: "loopback-only".into(), - authentication: "app-bound HMAC + AES-256-GCM envelope".into(), - secret_bearing: false, - }, - } - } - - fn key_set(kid: &str) -> JwkSet { - serde_json::from_value(serde_json::json!({ - "keys": [{"kty": "OKP", "crv": "Ed25519", "kid": kid, - "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}] - })) - .unwrap() - } - - #[test] - fn a_cached_key_set_serves_until_its_ttl_and_misses_an_unpublished_kid() { - let start = Instant::now(); - let mut cache = JwksCache::default(); - assert!(cache.key("k1", start).is_none(), "an empty cache has no key"); - cache.store(key_set("k1"), start); - assert!(cache.key("k1", start + Duration::from_secs(299)).is_some()); - assert!(cache.key("k2", start).is_none(), "a rotated-in kid refetches"); - assert!(cache.key("k1", start + JWKS_TTL).is_none(), "stale at the ttl"); - } - - #[test] - fn a_denied_status_is_the_typed_denial_and_any_other_status_passes() { - let diagnostics = vec!["Idempotency key was reused".to_owned()]; - let denial = refuse_denied("denied", &diagnostics).unwrap_err(); - assert!(denial.downcast_ref::().is_some()); - assert!(denial.to_string().contains("Idempotency key was reused")); - refuse_denied("accepted", &diagnostics).unwrap(); - } - - #[test] - fn private_responses_are_bound_to_the_exact_app_and_request() { - let secret = "fixture-shared-secret"; - let operation = "heimdall.auth.begin"; - let content_schema = "heimdall.auth_begin_receipt.v1"; - let exact = seal_envelope(operation, content_schema, "request-a", secret, &"receipt") - .expect("fixture response should seal"); - validate_private_response_binding(&exact, operation, content_schema, "request-a") - .expect("exact response binding should be accepted"); - - let replayed_other_request = - seal_envelope(operation, content_schema, "request-b", secret, &"receipt") - .expect("other request should seal validly"); - assert!( - validate_private_response_binding( - &replayed_other_request, - operation, - content_schema, - "request-a", - ) - .unwrap_err() - .to_string() - .contains("another request") - ); - - let mut other_app = exact; - other_app.app_slug = "another-app".into(); - other_app.signature = sign(&other_app, secret).expect("other app response should sign"); - assert!( - validate_private_response_binding(&other_app, operation, content_schema, "request-a",) - .unwrap_err() - .to_string() - .contains("another app") - ); - } - - #[test] - fn reads_heimdalls_published_documents_with_fields_dungeon_does_not_use() { - // Shapes as Heimdall emits them: buildCommandBoundary in - // Heimdall/src/verse-state.ts and the receipts in - // Heimdall/src/private-command-plane.ts. - let boundary = rmp_serde::to_vec_named(&serde_json::json!({ - "schema": "heimdall.command_boundary.v1", - "boundaryId": "heimdall", - "daemonId": "yggdrasil-heimdall", - "providerId": "heimdall", - "updatedAt": "2026-09-23T12:00:00.000Z", - "owner": "Heimdall auth runtime", - "lifecycleAuthority": "idunn.yggdrasil-source-app.deploy", - "healthPublication": {"contract": "x", "transport": "cultcache-store"}, - "commands": [{ - "operation": "heimdall.auth.begin", - "requestSchema": "heimdall.private_command_envelope.v1", - "responseSchema": "heimdall.auth_begin_receipt.v1" - }], - "privateRoute": { - "endpoint": "rudp://127.0.0.1:4101", - "exposure": "loopback-only", - "authentication": "app-bound HMAC + AES-256-GCM envelope", - "secretBearing": false - }, - "forbiddenWriters": ["Odin and Idunn may observe Heimdall boundary state"], - "compatibility": {"httpHealth": "/healthz"} - })) - .unwrap(); - let boundary: HeimdallCommandBoundaryRecord = rmp_serde::from_slice(&boundary) - .expect("Heimdall's published boundary should decode"); - validate_heimdall_boundary(boundary).expect("Heimdall's published boundary should resolve"); - - let begin: AuthBeginReceipt = serde_json::from_value(serde_json::json!({ - "schema": "heimdall.auth_begin_receipt.v1", - "status": "pending", - "handle": "h", - "expiresAt": "2026-09-23T12:10:00.000Z", - "navigation": {"url": "https://discord.com/x", "allowedOrigins": ["https://discord.com"]} - })) - .expect("Heimdall's begin receipt should decode"); - assert_eq!(begin.status, "pending"); - let completion: AuthCompletionReceipt = serde_json::from_value(serde_json::json!({ - "schema": "heimdall.auth_completion_receipt.v1", - "status": "pending", - "handle": "h" - })) - .expect("Heimdall's completion receipt should decode"); - assert_eq!(completion.status, "pending"); - let logout: AuthLogoutReceipt = serde_json::from_value(serde_json::json!({ - "schema": "heimdall.auth_logout_receipt.v1", - "status": "revoked", - "sessionId": "s", - "accessRevision": 2, - "revokedAt": "2026-09-23T12:20:00.000Z" - })) - .expect("Heimdall's logout receipt should decode"); - assert_eq!(logout.access_revision, 2); - } - - #[test] - fn accepts_only_the_discovered_loopback_private_boundary() { - let resolved = validate_heimdall_boundary(boundary("rudp://127.0.0.1:4101")) - .expect("canonical boundary should resolve"); - assert_eq!(resolved.endpoint, "127.0.0.1:4101".parse().unwrap()); - assert_eq!(resolved.daemon_id, "yggdrasil-heimdall"); - assert_eq!(resolved.operations, vec!["heimdall.auth.begin"]); - let expected = HeimdallDependencyBinding { - provider_id: "yggdrasil-heimdall".into(), - endpoint: "127.0.0.1:4101".parse().unwrap(), - }; - require_expected_heimdall_boundary(&resolved, &expected) - .expect("exact Expected Heimdall should match discovery"); - let substitute = HeimdallDependencyBinding { - provider_id: "another-heimdall".into(), - endpoint: expected.endpoint, - }; - assert!(require_expected_heimdall_boundary(&resolved, &substitute).is_err()); - } - - #[test] - fn rejects_non_loopback_or_secret_bearing_routes() { - assert!( - validate_heimdall_boundary(boundary("rudp://10.77.0.2:4101")) - .unwrap_err() - .to_string() - .contains("not loopback-only") - ); - let mut secret_bearing = boundary("rudp://127.0.0.1:4101"); - secret_bearing.private_route.secret_bearing = true; - assert!( - validate_heimdall_boundary(secret_bearing) - .unwrap_err() - .to_string() - .contains("unsafe") - ); - } - - #[test] - fn refuses_operations_not_advertised_by_odin() { - let locator = HeimdallBoundaryLocator::Fixed( - validate_heimdall_boundary(boundary("rudp://127.0.0.1:4101")).unwrap(), - ); - assert!( - locator - .resolve("ghostlight-test", "heimdall.auth.logout") - .unwrap_err() - .to_string() - .contains("does not advertise") - ); - } - - #[test] - fn refresh_binding_uses_signed_claim_and_exact_session_without_account_summary() { - let claims = AccessClaims { - iss: "https://heimdall.invalid".into(), - aud: APP_SLUG.into(), - sub: "account-1".into(), - sid: "session-1".into(), - exp: 1, - typ: "heimdall_access".into(), - account_id: "account-1".into(), - access_revision: 4, - capabilities: vec!["app_access".into()], - app: AppClaim { - slug: APP_SLUG.into(), - }, - }; - let receipt = AuthCompletionReceipt { - status: "authenticated".into(), - handle: None, - error: None, - account: None, - session: Some(HeimdallSession { - account_id: "account-1".into(), - session_id: "session-1".into(), - app_slug: APP_SLUG.into(), - access_revision: 4, - expires_at: "2026-09-22T00:00:00Z".into(), - }), - access_token: Some("private".into()), - refresh_token: Some("private".into()), - refresh: Some(RefreshSummary { - expires_at: "2026-09-22T00:00:00Z".into(), - }), - shared_capabilities: vec!["app_access".into()], - }; - - verify_claim_binding(&receipt, &claims, false).unwrap(); - assert!( - verify_claim_binding(&receipt, &claims, true) - .unwrap_err() - .to_string() - .contains("omitted the account") - ); - } - - /// The shape Heimdall's `issueAccessClaim` returns: the access claim - /// expires first; session and refresh share the longer record expiry. - fn issued_completion(session_expiry: &str, refresh_expiry: &str) -> AuthCompletionReceipt { - AuthCompletionReceipt { - status: "authenticated".into(), - handle: None, - error: None, - account: None, - session: Some(HeimdallSession { - account_id: "account-1".into(), - session_id: "session-1".into(), - app_slug: APP_SLUG.into(), - access_revision: 1, - expires_at: session_expiry.into(), - }), - access_token: Some("private".into()), - refresh_token: Some("refresh".into()), - refresh: Some(RefreshSummary { - expires_at: refresh_expiry.into(), - }), - shared_capabilities: vec!["app_access".into()], - } - } - - fn access_claims(exp: u64) -> AccessClaims { - AccessClaims { - iss: "https://heimdall.invalid".into(), - aud: APP_SLUG.into(), - sub: "account-1".into(), - sid: "session-1".into(), - exp, - typ: "heimdall_access".into(), - account_id: "account-1".into(), - access_revision: 1, - capabilities: vec!["app_access".into()], - app: AppClaim { - slug: APP_SLUG.into(), - }, - } - } - - #[test] - fn a_session_as_heimdall_issues_it_is_admitted() { - // Access expires at 2026-09-23T20:00:00Z; the session record and its - // refresh token last thirty days. - let access_exp = 1_790_193_600; - let material = verified_session_material( - issued_completion("2026-10-23T20:00:00.000Z", "2026-10-23T20:00:00.000Z"), - access_claims(access_exp), - ) - .expect("Heimdall's issued session should be admitted"); - assert_eq!(material.access_expires_at.timestamp(), access_exp as i64); - assert!(material.refresh_expires_at > material.access_expires_at); - - assert!( - verified_session_material( - issued_completion("2026-10-22T20:00:00.000Z", "2026-10-23T20:00:00.000Z"), - access_claims(access_exp), - ) - .err() - .expect("Heimdall shape should be refused") - .to_string() - .contains("disagrees with its refresh expiry") - ); - assert!( - verified_session_material( - issued_completion("2026-09-23T19:00:00.000Z", "2026-09-23T19:00:00.000Z"), - access_claims(access_exp), - ) - .err() - .expect("Heimdall shape should be refused") - .to_string() - .contains("does not outlive access") - ); - } -} diff --git a/crates/ghostlight-dungeon/src/idunn_health.rs b/crates/ghostlight-dungeon/src/idunn_health.rs deleted file mode 100644 index 8cb9f22..0000000 --- a/crates/ghostlight-dungeon/src/idunn_health.rs +++ /dev/null @@ -1,2001 +0,0 @@ -//! Service-owned runtime presence at the Idunn/Odin authority boundary. -//! -//! Idunn supplies one immutable Expected/activation bundle, an activation-only -//! signing credential, the candidate bind, and (after Odin observes Warming) -//! one process write lease. Ghostlight reports what this process actually is; -//! it never promotes its route or manufactures Ready. - -use ghostlight::{STATE_SCHEMA, state_schema_compatibility_tag}; -use anyhow::{Context, Result, anyhow, bail, ensure}; -#[cfg(test)] -use cultcache_rs::CacheBackingStore; -use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; -use cultnet_rs::{ - CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, - CultNetRudpReliableSendStatus, CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, CultNetWireContract, - GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, GameCultProviderHealthIdentity, - GameCultRuntimeCapability, GameCultRuntimePresenceHealthPurpose, - GameCultRuntimePresenceHealthRecord, IDUNN_EXPECTED_INCARNATION_SCHEMA, - IDUNN_PROCESS_WRITE_LEASE_SCHEMA, IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, - IDUNN_RUNTIME_ACTIVATION_SCHEMA, IdunnExpectedDependency, IdunnExpectedIncarnationRecord, - IdunnProcessWriteLeaseRecord, IdunnRuntimeActivationRecord, IdunnRuntimeActivationSigner, - ServiceIdentitySigner, encode_cultnet_message_to_vec, open_service_identity_credential_reader, -}; -use std::{ - collections::VecDeque, - fs::{self, File}, - net::{SocketAddr, UdpSocket}, - path::{Path, PathBuf}, - sync::mpsc, - thread, - time::{Duration, Instant}, -}; -#[cfg(target_os = "linux")] -use std::{ - os::fd::{AsRawFd, FromRawFd, RawFd}, - os::unix::fs::{MetadataExt, PermissionsExt}, -}; - -pub const GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT: &str = - "ghostlight.cultnet-rudp-service-health"; -pub const IDUNN_RUNTIME_BUNDLE_ENVIRONMENT: &str = "GAMECULT_IDUNN_RUNTIME_BUNDLE"; -pub const IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT: &str = "GAMECULT_IDUNN_CANDIDATE_BIND"; -pub const IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT: &str = "GAMECULT_IDUNN_PROCESS_WRITE_LEASE"; - -pub(crate) const TARGET: &str = "ghostlight"; -const STATE_SCHEMA_GENERATION: &str = "world-v3"; -const STATE_CONTRACT_SHA256: &str = - "sha256-4ac3d4ceabedc48b4fc5a3c143ece00ac495d8d756bdc24f10034bcc1231970f"; -/// The exact `[state]` + `[[state.slots]]` text in `deployment/idunn/recipe.toml` -/// that `STATE_CONTRACT_SHA256` above was computed from (Idunn's own -/// `canonical_state_contract_sha256`: msgpack-encode the recipe's own parsed -/// `[state]` struct, then sha256 it — run against the finalized recipe, not -/// hand-derived). These two constants change together: whoever edits the -/// recipe's `[state]` block must recompute `STATE_CONTRACT_SHA256` the same -/// way and update both, or `recipe_matches_the_daemon_it_deploys` (below) -/// fails on the text pin without saying the hash is wrong — which is the -/// point: the test cannot recompute the hash itself (no TOML parser or -/// msgpack encoder in this crate's dependency graph), so pinning the source -/// text is the cheapest honest proxy for "the hash still matches this text". -/// LF line endings: the committed git blob is LF-only; this constant matches -/// that, not whatever CRLF a Windows checkout's autocrlf produces locally. -#[cfg(test)] -const EXPECTED_STATE_AND_SLOTS_TEXT: &str = "[state]\nschema_generation = \"world-v3\"\n\n[[state.slots]]\nid = \"world\"\nrelative_path = \"world.cc\"\nkind = \"cultcache-file\"\nschema = \"ghostlight.world_state.consumer.v6\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"preserve\"\nstartup = \"create-or-open-after-write-lease\"\n\n[[state.slots]]\nid = \"app-sessions\"\nrelative_path = \"service/app-sessions-v2.cc\"\nkind = \"cultcache-file\"\nschema = \"ghostlight.app_session_store.v2\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"preserve\"\nstartup = \"create-or-open-after-write-lease\"\n\n[[state.slots]]\nid = \"controller-work\"\nrelative_path = \"service/controller-work.cc\"\nkind = \"cultcache-file\"\nschema = \"ghostlight.controller_work.v17\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"preserve\"\nstartup = \"create-or-open-after-write-lease\"\n\n[[state.slots]]\nid = \"mesh-projection\"\nrelative_path = \"service/mesh-v2.cc\"\nkind = \"cultcache-file\"\nschema = \"gamecult.eve.surface.v1\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"rebuildable\"\nstartup = \"create-or-open-after-write-lease\"\n\n"; -const CULTNET_RUDP_PROTOCOL_ID: &str = "cultnet.transport.rudp.v0"; -const ODIN_CULTMESH_CATALOG_CONNECTION_ID: u32 = 0x0d1d_0002; -const RUNTIME_PRESENCE_IDENTITY_FD_NAME: &str = "gamecult-runtime-presence-identity"; -const WARMING_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5); -const MAX_RECENT_WARMING_PROOFS: usize = 64; -const ODIN_CAPABILITY: (&str, &str, &str) = - ("odin.verse-rendezvous", "odin.verse-topology.v1", "v1"); -#[cfg(target_os = "linux")] -const SYSTEMD_LISTEN_FDS_START: RawFd = 3; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct RuntimeDependencyBindings { - pub(crate) odin_rudp: SocketAddr, -} - -pub(crate) struct PublishedRuntimePresence { - canonical_sha256: String, -} - -impl PublishedRuntimePresence { - pub(crate) fn canonical_sha256(&self) -> &str { - &self.canonical_sha256 - } -} - -pub(crate) struct RuntimePresencePublisher { - endpoint: SocketAddr, - bound_endpoint: String, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - provider_signer: ServiceIdentitySigner, - activation_signer: IdunnRuntimeActivationSigner, - capabilities: Vec, - write_lease_path: PathBuf, - dependency_bindings: RuntimeDependencyBindings, - publisher_sequence: u64, - active_write_lease_sha256: Option, - /// Whether `wait_for_write_lease` requires the lease store to sit under - /// a root-controlled directory chain (`acquire_recent`) or accepts a - /// test's own tempdir (`acquire_recent_fixture`). Every production - /// construction path (`from_environment`) sets this `true`; only the - /// test fixture constructor sets it `false`. This is the same choice - /// `ProcessWriteLeaseGuard::acquire_inner`'s own `enforce_custody` - /// parameter already makes — the publisher just carries which one its - /// caller needs instead of `wait_for_write_lease` always picking the - /// production path. - enforce_custody: bool, -} - -impl RuntimePresencePublisher { - pub(crate) fn from_environment(observed_bound_endpoint: SocketAddr) -> Result> { - let Some(bundle) = std::env::var_os(IDUNN_RUNTIME_BUNDLE_ENVIRONMENT) else { - #[cfg(target_os = "linux")] - bail!("{IDUNN_RUNTIME_BUNDLE_ENVIRONMENT} is mandatory on Linux"); - #[cfg(not(target_os = "linux"))] - return Ok(None); - }; - let bundle = PathBuf::from(bundle); - require_runtime_bundle(&bundle)?; - let expected = read_expected(&bundle.join("expected.cc"))?; - let activation = read_activation(&bundle.join("activation.cc"), &expected.target)?; - - let odin_endpoint: SocketAddr = std::env::var("GHOSTLIGHT_ODIN_RUDP") - .context("GHOSTLIGHT_ODIN_RUDP is required for runtime presence")? - .parse() - .context("GHOSTLIGHT_ODIN_RUDP is not a socket address")?; - let write_lease_path = PathBuf::from( - std::env::var_os(IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT) - .with_context(|| format!("{IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT} is required"))?, - ); - require_process_write_lease_parent(&write_lease_path)?; - let (activation_credential, provider_credential) = inherited_authority_files()?; - let provider_signer = open_service_identity_credential_reader::< - GameCultProviderHealthIdentity, - >(provider_credential)?; - let activation_signer = - IdunnRuntimeActivationSigner::from_credential_reader(activation_credential)?; - Ok(Some(Self::new( - odin_endpoint, - observed_bound_endpoint, - expected, - activation, - provider_signer, - activation_signer, - write_lease_path, - true, - )?)) - } - - #[allow(clippy::too_many_arguments)] - fn new( - odin_endpoint: SocketAddr, - observed_bound_endpoint: SocketAddr, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - provider_signer: ServiceIdentitySigner, - activation_signer: IdunnRuntimeActivationSigner, - write_lease_path: PathBuf, - enforce_custody: bool, - ) -> Result { - expected.validate()?; - activation.validate()?; - ensure!( - expected.target == TARGET, - "Expected belongs to another target" - ); - ensure!( - expected.health_contract == GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT, - "Expected names another Ghostlight health contract" - ); - ensure!( - expected.state_schema_generation.as_deref() == Some(STATE_SCHEMA_GENERATION) - && expected.state_contract_sha256.as_deref() == Some(STATE_CONTRACT_SHA256) - && expected.write_lease_required, - "Expected does not name Ghostlight's writable world-v3 state" - ); - ensure!( - activation.expected_projection_sha256 == expected.canonical_sha256()? - && activation.runtime_id == expected.runtime_id, - "activation does not bind the exact Expected incarnation" - ); - ensure!( - activation.activation_signer_identity_id == activation_signer.identity_id(), - "activation credential does not belong to this launch" - ); - ensure!( - expected.expected_signer_identity_id == provider_signer.entry().identity_id, - "provider credential is not Idunn's Expected signer" - ); - ensure!( - write_lease_path.is_absolute(), - "write-lease path is not absolute" - ); - - let route = expected - .route - .as_ref() - .context("Ghostlight Expected has no candidate route")?; - ensure!( - route.transport == "http", - "Ghostlight candidate route is not HTTP" - ); - let expected_bind: SocketAddr = route - .candidate_endpoint - .strip_prefix("http://") - .context("Ghostlight candidate endpoint is not plain HTTP")? - .parse() - .context("Ghostlight candidate endpoint is not a socket address")?; - ensure!( - observed_bound_endpoint == expected_bind - && route.candidate_endpoint == format!("http://{observed_bound_endpoint}"), - "observed candidate bind differs from canonical Expected" - ); - - let capabilities = actual_capabilities(); - for requirement in &expected.capabilities { - ensure!( - capabilities.iter().any(|actual| { - actual.capability == requirement.capability - && actual.schema == requirement.schema - && actual.compatibility == requirement.compatibility - && actual.capacity >= requirement.minimum_capacity - }), - "Expected requires a capability this Ghostlight binary does not provide" - ); - } - let dependency_bindings = - runtime_dependency_bindings(&expected, odin_endpoint)?; - - Ok(Self { - endpoint: odin_endpoint, - bound_endpoint: format!("http://{observed_bound_endpoint}"), - expected, - activation, - provider_signer, - activation_signer, - capabilities, - write_lease_path, - dependency_bindings, - publisher_sequence: 0, - active_write_lease_sha256: None, - enforce_custody, - }) - } - - pub(crate) fn runtime_id(&self) -> &str { - &self.expected.runtime_id - } - - pub(crate) fn dependency_bindings(&self) -> &RuntimeDependencyBindings { - &self.dependency_bindings - } - - pub(crate) fn publish_warming(&mut self) -> Result { - ensure!( - self.active_write_lease_sha256.is_none(), - "an active runtime cannot return to Warming" - ); - self.publish("warming", "process-write-lease-pending", None) - } - - pub(crate) async fn wait_for_write_lease( - &mut self, - warming: &PublishedRuntimePresence, - timeout: Duration, - ) -> Result { - let deadline = Instant::now() + timeout; - let mut next_heartbeat = Instant::now() + WARMING_HEARTBEAT_INTERVAL; - let mut recent_warming_proofs = VecDeque::new(); - remember_warming_proof(&mut recent_warming_proofs, warming.canonical_sha256.clone()); - let mut last_acquisition_error: Option = None; - loop { - // The store and its lock existing is necessary but not - // sufficient: Idunn's own revocation of a prior candidate's - // lease leaves a present, zero-record store (and its .lock) - // behind, which is indistinguishable from "not yet written" by - // existence alone. Attempt the exact-match acquisition — the - // authority for what counts as this incarnation's lease stays in - // `acquire_recent` unchanged — and keep polling past a failure - // exactly as past a plain absence; only the deadline below ends - // the wait. This covers empty, stale-incarnation, and - // not-yet-written stores alike without special-casing any of - // them by size or by matching the error text. - if self.write_lease_path.exists() && sibling_lock_path(&self.write_lease_path).exists() - { - let acquired = if self.enforce_custody { - ProcessWriteLeaseGuard::acquire_recent( - self.write_lease_path.clone(), - self.expected.clone(), - self.activation.clone(), - recent_warming_proofs.iter().cloned().collect(), - ) - } else { - ProcessWriteLeaseGuard::acquire_recent_fixture( - self.write_lease_path.clone(), - self.expected.clone(), - self.activation.clone(), - recent_warming_proofs.iter().cloned().collect(), - ) - }; - match acquired { - Ok(guard) => return Ok(guard), - Err(error) => last_acquisition_error = Some(error), - } - } - let now = Instant::now(); - if now >= deadline { - return Err(match last_acquisition_error { - Some(error) => error.context(format!( - "timed out waiting for process write lease {}", - self.write_lease_path.display() - )), - None => anyhow!( - "timed out waiting for process write lease {}", - self.write_lease_path.display() - ), - }); - } - if now >= next_heartbeat { - // A failed heartbeat is a warning, not a reason to abort the - // wait: Odin can refuse Warming for a while after Idunn - // starts the unit and before it publishes the activation - // (Idunn/src/control_plane.rs), the same gap the initial - // publish in `initialize_production_admission` retries - // through. The lease wait keeps polling either way; only the - // outer `deadline` above ends it. - match self.publish_warming() { - Ok(heartbeat) => { - remember_warming_proof(&mut recent_warming_proofs, heartbeat.canonical_sha256); - } - Err(error) => { - tracing::warn!( - %error, - "Warming heartbeat publish failed while awaiting process lease; still waiting" - ); - } - } - next_heartbeat = now + WARMING_HEARTBEAT_INTERVAL; - } - tokio::time::sleep(Duration::from_millis(100)).await; - } - } - - pub(crate) fn publish_active( - &mut self, - lease: &ProcessWriteLeaseGuard, - ) -> Result { - lease.require_current()?; - let lease_sha256 = lease.canonical_sha256().to_owned(); - match &self.active_write_lease_sha256 { - Some(current) => ensure!( - current == &lease_sha256, - "runtime attempted to replace its active write lease" - ), - None => self.active_write_lease_sha256 = Some(lease_sha256.clone()), - } - self.publish("active", "world-owner-serving", Some(lease_sha256)) - } - - pub(crate) fn republish_active(&mut self) -> Result { - let lease_sha256 = self - .active_write_lease_sha256 - .clone() - .context("runtime has not entered Active")?; - self.publish("active", "world-owner-serving", Some(lease_sha256)) - } - - pub(crate) fn route_observation( - &mut self, - message_id: &str, - ready: bool, - lease: &ProcessWriteLeaseGuard, - ) -> Result { - ensure!( - !message_id.is_empty(), - "route-observation message id is empty" - ); - lease.require_current()?; - let lease_sha256 = lease.canonical_sha256(); - ensure!( - self.active_write_lease_sha256.as_deref() == Some(lease_sha256), - "route observation does not carry the active process write lease" - ); - let state = if ready { "active" } else { "degraded" }; - let detail = format!("route-observation:{message_id}"); - let record = self.signed_record(state, &detail, Some(lease_sha256.into()))?; - let payload = canonical_presence_payload(&record)?; - Ok(CultNetMessage::SnapshotResponseRaw { - message_id: message_id.into(), - documents: vec![runtime_presence_document(&record, payload, None)?], - }) - } - - fn publish( - &mut self, - state: &str, - detail: &str, - write_lease_sha256: Option, - ) -> Result { - let record = self.signed_record(state, detail, write_lease_sha256)?; - let canonical_sha256 = publish_runtime_presence(self.endpoint, &record)?; - Ok(PublishedRuntimePresence { canonical_sha256 }) - } - - fn signed_record( - &mut self, - state: &str, - detail: &str, - write_lease_sha256: Option, - ) -> Result { - self.publisher_sequence = self - .publisher_sequence - .checked_add(1) - .ok_or_else(|| anyhow!("runtime presence sequence overflow"))?; - let observed_at_unix_millis = u64::try_from(chrono::Utc::now().timestamp_millis())?; - let mut record = GameCultRuntimePresenceHealthRecord { - schema_version: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), - target: self.expected.target.clone(), - expected_projection_sha256: self.expected.canonical_sha256()?, - plan_id: self.expected.plan_id.clone(), - incarnation_id: self.expected.incarnation_id.clone(), - sealed_release_id: self.expected.sealed_release_id.clone(), - activation_witness_sha256: self.activation.canonical_sha256()?, - state_schema_generation: self.expected.state_schema_generation.clone(), - state_contract_sha256: self.expected.state_contract_sha256.clone(), - runtime_id: self.expected.runtime_id.clone(), - runtime_instance_id: self.activation.runtime_instance_id.clone(), - bound_endpoint: Some(self.bound_endpoint.clone()), - capabilities: self.capabilities.clone(), - health_contract: GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT.into(), - state: state.into(), - detail: detail.into(), - write_lease_sha256, - signer_identity_id: self.provider_signer.entry().identity_id.clone(), - publisher_sequence: self.publisher_sequence, - observed_at_unix_millis, - signature_algorithm: "ed25519".into(), - signature: Vec::new(), - activation_signer_identity_id: self.activation_signer.identity_id(), - activation_signature: Vec::new(), - }; - let payload = record.canonical_proof_payload()?; - let provider_proof = self - .provider_signer - .sign::(&payload); - ensure!( - provider_proof.identity_id == record.signer_identity_id, - "provider signer identity changed while publishing" - ); - record.signature = provider_proof.signature; - record.activation_signature = self.activation_signer.sign_presence_proof(&record)?; - record.validate()?; - Ok(record) - } -} - -fn runtime_dependency_bindings( - expected: &IdunnExpectedIncarnationRecord, - configured_odin: SocketAddr, -) -> Result { - ensure!( - expected.dependencies.len() == 1, - "Ghostlight Expected does not carry its exact one dependency" - ); - let odin = required_managed_dependency(expected, "shared-infrastructure", ODIN_CAPABILITY)?; - - let odin_endpoint = parse_dependency_socket_endpoint( - odin.provider_endpoint - .as_deref() - .context("Expected Odin dependency has no endpoint")?, - &["rudp://", "udp://"], - "Odin dependency", - )?; - ensure!( - odin_endpoint == configured_odin, - "GHOSTLIGHT_ODIN_RUDP differs from Expected Odin" - ); - - Ok(RuntimeDependencyBindings { - odin_rudp: odin_endpoint, - }) -} - -fn required_managed_dependency<'a>( - expected: &'a IdunnExpectedIncarnationRecord, - kind: &str, - identity: (&str, &str, &str), -) -> Result<&'a IdunnExpectedDependency> { - let dependency = expected - .dependencies - .iter() - .find(|dependency| { - dependency.capability == identity.0 - && dependency.schema == identity.1 - && dependency.compatibility == identity.2 - }) - .with_context(|| format!("Expected omits dependency {}", identity.0))?; - ensure!( - dependency.kind == kind - && dependency.startup == "before-promotion" - && dependency.minimum_capacity > 0 - && dependency.provider_id.is_some() - && dependency.provider_authority.as_deref() == Some("managed-incarnation") - && dependency.provider_expected_projection_sha256.is_some(), - "Expected dependency {} is unresolved or authority-incoherent", - identity.0 - ); - Ok(dependency) -} - -fn parse_dependency_socket_endpoint( - endpoint: &str, - allowed_schemes: &[&str], - label: &str, -) -> Result { - let matched_scheme = allowed_schemes - .iter() - .copied() - .find(|scheme| endpoint.starts_with(scheme)); - let socket_text = match matched_scheme { - Some(scheme) => &endpoint[scheme.len()..], - None if !endpoint.contains("://") => endpoint, - None => bail!("{label} uses an unsupported endpoint scheme"), - }; - let socket: SocketAddr = socket_text - .parse() - .with_context(|| format!("{label} endpoint is not a socket address"))?; - let canonical = matched_scheme - .map(|scheme| format!("{scheme}{socket}")) - .unwrap_or_else(|| socket.to_string()); - ensure!(endpoint == canonical, "{label} endpoint is not canonical"); - Ok(socket) -} - -/// True when a directory is hardened exactly as Idunn's own -/// `harden_runtime_bundle`/`harden_installed_release` leave it: root-owned, -/// mode 0555 (unwritable by group or other; `& 0o022 == 0` does not care -/// about the owner-write bit, which root controls regardless). Pure over the -/// three raw fields so the rule is testable without a Linux filesystem. -/// -/// `gid` is accepted but never checked. Idunn creates the runtime bundle -/// inside `runtime_root`, which is itself `setgid` to the workload's own -/// group (e.g. `ghostlight-world-v2-state`) so the process write-lease record -/// living beside it can share that group (Idunn/src/drivers.rs, the setgid -/// runtime_root and its lease-record gid requirement). The bundle inherits -/// that same real group, never gid 0 — the live Odin bundle is `root:odin`, -/// and Odin accepts it. A `gid == 0` requirement here rejected every bundle -/// Idunn actually produces. -fn is_root_service_read_only_directory(uid: u32, _gid: u32, mode: u32) -> bool { - uid == 0 && mode & 0o022 == 0 -} - -/// True when a file is hardened exactly as Idunn's `harden_runtime_bundle` -/// leaves it: root-owned, mode 0444. `gid` is accepted but never checked, for -/// the same reason as [`is_root_service_read_only_directory`]. -fn is_root_read_only_file(uid: u32, _gid: u32, mode: u32) -> bool { - uid == 0 && mode & 0o222 == 0 -} - -fn require_runtime_bundle(bundle: &Path) -> Result<()> { - ensure!( - bundle.is_absolute(), - "Idunn runtime bundle path is not absolute" - ); - #[cfg(target_os = "linux")] - { - let metadata = fs::symlink_metadata(bundle) - .with_context(|| format!("inspecting Idunn runtime bundle {}", bundle.display()))?; - ensure!( - metadata.is_dir() - && !metadata.file_type().is_symlink() - && is_root_service_read_only_directory( - metadata.uid(), - metadata.gid(), - metadata.permissions().mode() - ), - "Idunn runtime bundle is not a root-owned service-read-only directory" - ); - require_root_controlled_directory_chain( - bundle - .parent() - .context("Idunn runtime bundle has no parent")?, - "Idunn runtime bundle parent", - )?; - for path in [ - bundle.join("expected.cc"), - sibling_lock_path(&bundle.join("expected.cc")), - bundle.join("activation.cc"), - sibling_lock_path(&bundle.join("activation.cc")), - ] { - require_root_read_only_file(&path, "Idunn runtime bundle document")?; - } - } - Ok(()) -} - -fn require_process_write_lease_parent(path: &Path) -> Result<()> { - ensure!(path.is_absolute(), "write-lease path is not absolute"); - #[cfg(target_os = "linux")] - { - let parent = path - .parent() - .context("process write-lease path has no parent")?; - let metadata = fs::symlink_metadata(parent).with_context(|| { - format!("inspecting process write-lease parent {}", parent.display()) - })?; - ensure!( - metadata.is_dir() - && !metadata.file_type().is_symlink() - && metadata.uid() == 0 - && metadata.permissions().mode() & 0o022 == 0, - "process write-lease parent is not root-owned and service-nonwritable" - ); - require_root_controlled_directory_chain( - parent - .parent() - .context("process write-lease parent has no parent")?, - "process write-lease ancestor", - )?; - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn require_process_write_lease_custody(path: &Path) -> Result<()> { - require_process_write_lease_parent(path)?; - let parent = path - .parent() - .context("process write-lease path has no parent")?; - let parent_metadata = fs::symlink_metadata(parent)?; - for authority_file in [path.to_owned(), sibling_lock_path(path)] { - let metadata = fs::symlink_metadata(&authority_file).with_context(|| { - format!( - "inspecting process write-lease authority {}", - authority_file.display() - ) - })?; - ensure!( - metadata.is_file() - && !metadata.file_type().is_symlink() - && metadata.uid() == 0 - && metadata.gid() == parent_metadata.gid() - && metadata.permissions().mode() & 0o022 == 0 - && metadata.nlink() == 1, - "process write-lease authority {} is not one root-owned service-nonwritable file", - authority_file.display() - ); - } - Ok(()) -} - -#[cfg(not(target_os = "linux"))] -fn require_process_write_lease_custody(_path: &Path) -> Result<()> { - Ok(()) -} - -#[cfg(target_os = "linux")] -fn require_root_read_only_file(path: &Path, label: &str) -> Result<()> { - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("inspecting {label} {}", path.display()))?; - ensure!( - metadata.is_file() - && !metadata.file_type().is_symlink() - && is_root_read_only_file( - metadata.uid(), - metadata.gid(), - metadata.permissions().mode() - ) - && metadata.nlink() == 1, - "{label} is not one root-owned read-only regular file" - ); - Ok(()) -} - -#[cfg(target_os = "linux")] -fn require_root_controlled_directory_chain(path: &Path, label: &str) -> Result<()> { - for directory in path.ancestors() { - let metadata = fs::symlink_metadata(directory) - .with_context(|| format!("inspecting {label} {}", directory.display()))?; - ensure!( - metadata.is_dir() - && !metadata.file_type().is_symlink() - && is_root_service_read_only_directory( - metadata.uid(), - metadata.gid(), - metadata.permissions().mode() - ), - "{label} contains a non-root-controlled directory" - ); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn inherited_authority_files() -> Result<(File, File)> { - let listen_pid = std::env::var("LISTEN_PID").context("systemd did not bind authority FDs")?; - let listen_fds = std::env::var("LISTEN_FDS").context("systemd did not bind authority FDs")?; - let listen_fd_names = - std::env::var("LISTEN_FDNAMES").context("systemd did not name authority FDs")?; - authority_fd_layout( - &listen_pid, - &listen_fds, - &listen_fd_names, - )?; - ensure_descriptor_is_open(SYSTEMD_LISTEN_FDS_START, "runtime activation signer")?; - ensure_descriptor_is_open( - SYSTEMD_LISTEN_FDS_START + 1, - "stable runtime-presence signer", - )?; - // SAFETY: the exact systemd LISTEN_* contract above assigns these two - // distinct, verified-open descriptors to this PID. This function is their - // first consumer and takes sole ownership of both. - let activation = unsafe { File::from_raw_fd(SYSTEMD_LISTEN_FDS_START) }; - // SAFETY: same ownership proof; fd4 is distinct from fd3. - let provider = unsafe { File::from_raw_fd(SYSTEMD_LISTEN_FDS_START + 1) }; - // OpenFile descriptors deliberately arrive without close-on-exec. Seal - // both before inspecting either so even a rejected first credential - // cannot leave the second signer inheritable on the error path. - mark_descriptor_close_on_exec(&activation, "runtime activation signer")?; - mark_descriptor_close_on_exec(&provider, "stable runtime-presence signer")?; - protect_inherited_authority_file(&activation, Some(32), "runtime activation signer")?; - protect_inherited_authority_file(&provider, None, "stable runtime-presence signer")?; - Ok((activation, provider)) -} - -#[cfg(not(target_os = "linux"))] -fn inherited_authority_files() -> Result<(File, File)> { - bail!("Idunn authority FDs require the managed Linux runtime") -} - -// LISTEN_PID is not compared to this process's pid. Idunn launches the -// daemon with PrivatePIDs=yes, and systemd writes the pid it sees from the -// outer namespace, which this process cannot observe (Odin `8e16f56`). The -// exact count and name order here, the root-owned read-only descriptor checks, -// and the signature verification of their contents are what bind these -// descriptors to Idunn; LISTEN_PID is only required to be a canonical pid. -#[cfg_attr(not(target_os = "linux"), allow(dead_code))] -fn authority_fd_layout(listen_pid: &str, listen_fds: &str, listen_fd_names: &str) -> Result<()> { - ensure!( - listen_pid - .parse::() - .is_ok_and(|pid| pid != 0 && listen_pid == pid.to_string()), - "systemd passed a noncanonical LISTEN_PID" - ); - ensure!( - listen_fds.parse::()? == 2 && listen_fds == "2", - "systemd authority FD count is not exact" - ); - let names = listen_fd_names.split(':').collect::>(); - ensure!( - names - == [ - IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, - RUNTIME_PRESENCE_IDENTITY_FD_NAME, - ], - "systemd authority FD names or order differ from the launch contract" - ); - Ok(()) -} - -#[cfg(target_os = "linux")] -fn ensure_descriptor_is_open(fd: RawFd, label: &str) -> Result<()> { - // SAFETY: F_GETFD observes one integer descriptor and transfers no - // ownership. Failure leaves the descriptor untouched. - if unsafe { libc::fcntl(fd, libc::F_GETFD) } == -1 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("opening inherited {label} descriptor")); - } - Ok(()) -} - -#[cfg(target_os = "linux")] -fn protect_inherited_authority_file( - file: &File, - exact_size: Option, - label: &str, -) -> Result<()> { - let metadata = file.metadata()?; - ensure!( - metadata.is_file() - && metadata.uid() == 0 - && metadata.gid() == 0 - && metadata.permissions().mode() & 0o777 == 0o400 - && metadata.nlink() == 1 - && exact_size.map_or(metadata.len() > 0, |size| metadata.len() == size), - "inherited {label} descriptor is not one exact root-owned 0400 file" - ); - // SAFETY: F_GETFL observes integer flags on the live descriptor owned by - // `file` and transfers no ownership. - let status_flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFL) }; - if status_flags == -1 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("reading inherited {label} access mode")); - } - ensure!( - status_flags & libc::O_ACCMODE == libc::O_RDONLY, - "inherited {label} descriptor is not read-only" - ); - Ok(()) -} - -#[cfg(target_os = "linux")] -fn mark_descriptor_close_on_exec(file: &File, label: &str) -> Result<()> { - let descriptor_flags = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_GETFD) }; - if descriptor_flags == -1 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("reading inherited {label} descriptor flags")); - } - // SAFETY: F_SETFD mutates flags on the descriptor owned by `file`; it - // transfers no ownership and dereferences no pointer. - if unsafe { - libc::fcntl( - file.as_raw_fd(), - libc::F_SETFD, - descriptor_flags | libc::FD_CLOEXEC, - ) - } == -1 - { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("protecting inherited {label} from child inheritance")); - } - Ok(()) -} - -pub(crate) struct ProcessWriteLeaseGuard { - path: PathBuf, - record: IdunnProcessWriteLeaseRecord, - canonical_sha256: String, - enforce_custody: bool, - release: Option>, - worker: Option>, -} - -impl ProcessWriteLeaseGuard { - fn acquire_recent( - path: PathBuf, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - warming_presence_sha256es: Vec, - ) -> Result { - Self::acquire_inner(path, expected, activation, warming_presence_sha256es, true) - } - - #[cfg(test)] - fn acquire_fixture( - path: PathBuf, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - warming_presence_sha256: String, - ) -> Result { - Self::acquire_inner( - path, - expected, - activation, - vec![warming_presence_sha256], - false, - ) - } - - /// Not test-gated: `wait_for_write_lease` calls this directly whenever - /// its publisher's own `enforce_custody` is `false` (the test fixture - /// constructor), the same way it calls `acquire_recent` when `true` (the - /// production constructor). A test lease deliberately lives under a - /// tempdir, which is never root-controlled, so custody enforcement must - /// be off for it — not skipped by `#[cfg(test)]`, which a Linux release - /// build does not carry. - fn acquire_recent_fixture( - path: PathBuf, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - warming_presence_sha256es: Vec, - ) -> Result { - Self::acquire_inner(path, expected, activation, warming_presence_sha256es, false) - } - - fn acquire_inner( - path: PathBuf, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - warming_presence_sha256es: Vec, - enforce_custody: bool, - ) -> Result { - ensure!( - !warming_presence_sha256es.is_empty(), - "process write lease has no provider-owned Warming proof" - ); - if enforce_custody { - require_process_write_lease_custody(&path)?; - } - let worker_path = path.clone(); - let (ready_tx, ready_rx) = mpsc::sync_channel(1); - let (release_tx, release_rx) = mpsc::channel(); - let worker = thread::Builder::new() - .name("ghostlight-write-lease".into()) - .spawn(move || { - let store = SingleFileMessagePackBackingStore::new(&worker_path); - let result = store.with_read_only_shared_snapshot(|entries| { - if enforce_custody { - require_process_write_lease_custody(&worker_path)?; - } - let record = decode_write_lease( - &entries, - &expected, - &activation, - &warming_presence_sha256es, - )?; - ready_tx - .send(Ok(record)) - .map_err(|_| anyhow!("write-lease receiver stopped"))?; - let _ = release_rx.recv(); - Ok(()) - }); - if let Err(error) = result { - let _ = ready_tx.send(Err(format!("{error:#}"))); - } - })?; - match ready_rx - .recv() - .context("write-lease custodian stopped before admission")? - { - Ok(record) => Ok(Self { - canonical_sha256: record.canonical_sha256()?, - path, - record, - enforce_custody, - release: Some(release_tx), - worker: Some(worker), - }), - Err(error) => { - let _ = worker.join(); - bail!("process write lease was not admitted: {error}") - } - } - } - - pub(crate) fn canonical_sha256(&self) -> &str { - &self.canonical_sha256 - } - - pub(crate) fn require_current(&self) -> Result<()> { - if self.enforce_custody { - require_process_write_lease_custody(&self.path)?; - } - let current = - SingleFileMessagePackBackingStore::new(&self.path).pull_all_read_only_snapshot()?; - let [envelope] = current.as_slice() else { - bail!("process write-lease store is absent or ambiguous"); - }; - ensure!( - envelope.key == self.record.target - && envelope.r#type == IdunnProcessWriteLeaseRecord::TYPE - && envelope.schema_id.as_deref() == Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA) - && IdunnProcessWriteLeaseRecord::decode_canonical(&envelope.payload)? - == self.record, - "process write lease changed after admission" - ); - Ok(()) - } -} - -impl Drop for ProcessWriteLeaseGuard { - fn drop(&mut self) { - if let Some(release) = self.release.take() { - let _ = release.send(()); - } - if let Some(worker) = self.worker.take() { - let _ = worker.join(); - } - } -} - -fn actual_capabilities() -> Vec { - vec![ - GameCultRuntimeCapability { - capability: "gamecult.eve.surface-provider".into(), - schema: "gamecult.eve.surface.v1".into(), - compatibility: "v1".into(), - capacity: 1, - }, - GameCultRuntimeCapability { - capability: "ghostlight.world-service".into(), - schema: STATE_SCHEMA.into(), - compatibility: state_schema_compatibility_tag(), - capacity: 1, - }, - ] -} - -fn read_expected(path: &Path) -> Result { - let envelope = read_single_envelope(path)?; - ensure!( - envelope.r#type == IdunnExpectedIncarnationRecord::TYPE - && envelope.schema_id.as_deref() == Some(IDUNN_EXPECTED_INCARNATION_SCHEMA), - "runtime bundle Expected envelope has the wrong schema" - ); - let expected = IdunnExpectedIncarnationRecord::decode_canonical(&envelope.payload)?; - ensure!( - envelope.key == expected.target, - "Expected envelope key differs from target" - ); - Ok(expected) -} - -fn read_activation(path: &Path, target: &str) -> Result { - let envelope = read_single_envelope(path)?; - ensure!( - envelope.key == target - && envelope.r#type == IdunnRuntimeActivationRecord::TYPE - && envelope.schema_id.as_deref() == Some(IDUNN_RUNTIME_ACTIVATION_SCHEMA), - "runtime bundle activation envelope has the wrong identity" - ); - IdunnRuntimeActivationRecord::decode_canonical(&envelope.payload) -} - -fn read_single_envelope(path: &Path) -> Result { - let entries = SingleFileMessagePackBackingStore::new(path) - .pull_all_read_only_snapshot() - .with_context(|| format!("reading immutable runtime document {}", path.display()))?; - let [envelope] = entries.as_slice() else { - bail!("runtime document {} is absent or ambiguous", path.display()); - }; - Ok(envelope.clone()) -} - -fn decode_write_lease( - entries: &[CultCacheEnvelope], - expected: &IdunnExpectedIncarnationRecord, - activation: &IdunnRuntimeActivationRecord, - warming_presence_sha256es: &[String], -) -> Result { - let [envelope] = entries else { - bail!("process write-lease store is absent or ambiguous"); - }; - ensure!( - envelope.key == expected.target - && envelope.r#type == IdunnProcessWriteLeaseRecord::TYPE - && envelope.schema_id.as_deref() == Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA), - "process write-lease envelope has the wrong identity" - ); - let lease = IdunnProcessWriteLeaseRecord::decode_canonical(&envelope.payload)?; - ensure!( - lease.target == expected.target - && lease.expected_projection_sha256 == expected.canonical_sha256()? - && lease.plan_id == expected.plan_id - && lease.incarnation_id == expected.incarnation_id - && lease.sealed_release_id == expected.sealed_release_id - && lease.activation_witness_sha256 == activation.canonical_sha256()? - && lease.state_schema_generation - == expected - .state_schema_generation - .as_deref() - .context("Expected has no state schema generation")? - && lease.state_contract_sha256 - == expected - .state_contract_sha256 - .as_deref() - .context("Expected has no state contract")? - && lease.runtime_id == expected.runtime_id - && lease.runtime_instance_id == activation.runtime_instance_id - && warming_presence_sha256es - .iter() - .any(|sha256| sha256 == &lease.warming_presence_sha256), - "process write lease does not bind a provider-owned Warming proof for this incarnation" - ); - Ok(lease) -} - -fn remember_warming_proof(proofs: &mut VecDeque, sha256: String) { - proofs.push_back(sha256); - while proofs.len() > MAX_RECENT_WARMING_PROOFS { - proofs.pop_front(); - } -} - -fn publish_runtime_presence( - endpoint: SocketAddr, - record: &GameCultRuntimePresenceHealthRecord, -) -> Result { - record.validate()?; - let payload = canonical_presence_payload(record)?; - let canonical_sha256 = record.canonical_sha256()?; - let message = CultNetMessage::DocumentPutRaw { - message_id: format!( - "runtime-presence:{}:{}:{}", - record.target, record.runtime_instance_id, record.publisher_sequence - ), - document: runtime_presence_document( - record, - payload, - Some(vec![ - CULTNET_RUDP_PROTOCOL_ID.into(), - "runtime-presence".into(), - ]), - )?, - }; - let bind = if endpoint.is_ipv4() { - "0.0.0.0:0" - } else { - "[::]:0" - }; - let socket = UdpSocket::bind(bind) - .with_context(|| format!("binding Ghostlight CultNet sender at {bind}"))?; - socket.set_read_timeout(Some(Duration::from_millis(100)))?; - let mut transport = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client( - &record.runtime_id, - socket, - endpoint, - ODIN_CULTMESH_CATALOG_CONNECTION_ID, - ))?; - transport.connect(Vec::new())?; - let deadline = Instant::now() + Duration::from_millis(500); - while !transport.connected() { - let _ = transport.receive_once()?; - transport.poll_resends()?; - if Instant::now() >= deadline { - return Err(anyhow!( - "timed out connecting runtime-presence publisher to {endpoint}" - )); - } - } - let receipt = transport.send_reliable( - "schema", - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)?, - )?; - let deadline = Instant::now() + Duration::from_secs(2); - loop { - match transport.reliable_send_status(&receipt) { - CultNetRudpReliableSendStatus::Acknowledged => break, - CultNetRudpReliableSendStatus::Invalidated => { - bail!("runtime-presence acknowledgement was invalidated") - } - CultNetRudpReliableSendStatus::Pending => {} - } - let _ = transport.receive_once()?; - transport.poll_resends()?; - if Instant::now() >= deadline { - bail!("timed out awaiting exact runtime-presence acknowledgement from {endpoint}"); - } - } - Ok(canonical_sha256) -} - -fn canonical_presence_payload(record: &GameCultRuntimePresenceHealthRecord) -> Result> { - let payload = rmp_serde::to_vec(record).context("encoding canonical runtime presence")?; - let decoded: GameCultRuntimePresenceHealthRecord = rmp_serde::from_slice(&payload)?; - ensure!( - decoded == *record && rmp_serde::to_vec(&decoded)? == payload, - "runtime presence encoding is noncanonical" - ); - Ok(payload) -} - -fn runtime_presence_document( - record: &GameCultRuntimePresenceHealthRecord, - payload: Vec, - tags: Option>, -) -> Result { - Ok(CultNetRawDocumentRecord { - schema_id: GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into(), - record_key: record.target.clone(), - stored_at: chrono::DateTime::from_timestamp_millis( - record.observed_at_unix_millis.try_into()?, - ) - .context("runtime presence timestamp is invalid")? - .to_rfc3339(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload, - source_runtime_id: Some(record.runtime_id.clone()), - source_agent_id: Some(record.signer_identity_id.clone()), - source_role: Some("runtime-presence-publisher".into()), - tags, - }) -} - -fn sibling_lock_path(path: &Path) -> PathBuf { - let mut name = path - .file_name() - .map(|value| value.to_os_string()) - .unwrap_or_else(|| "authority.cc".into()); - name.push(".lock"); - path.with_file_name(name) -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use cultnet_rs::{ - GameCultRuntimePresenceHealthPurpose, IdunnExpectedCapability, IdunnExpectedRoute, - IdunnRuntimeActivationLaunch, IdunnServiceIdentity, RuntimePresenceAuthenticationContext, - authenticate_runtime_presence_claim, enroll_service_identity_at, verify_runtime_authority, - }; - - fn digest(byte: char) -> String { - format!("sha256-{}", byte.to_string().repeat(64)) - } - - struct Fixture { - publisher: RuntimePresencePublisher, - authority: cultnet_rs::VerifiedRuntimeAuthority, - expected: IdunnExpectedIncarnationRecord, - activation: IdunnRuntimeActivationRecord, - } - - pub(crate) struct RouteObservationFixture { - pub(crate) publisher: RuntimePresencePublisher, - pub(crate) write_lease: ProcessWriteLeaseGuard, - pub(crate) authority: cultnet_rs::VerifiedRuntimeAuthority, - } - - fn managed_dependency( - kind: &str, - capability: &str, - schema: &str, - compatibility: &str, - provider_id: &str, - endpoint: &str, - ) -> IdunnExpectedDependency { - IdunnExpectedDependency { - kind: kind.into(), - capability: capability.into(), - schema: schema.into(), - compatibility: compatibility.into(), - minimum_capacity: 1, - startup: "before-promotion".into(), - provider_id: Some(provider_id.into()), - provider_authority: Some("managed-incarnation".into()), - provider_expected_projection_sha256: Some(digest('a')), - provider_endpoint: Some(endpoint.into()), - } - } - - fn fixture_with_state_contract(root: &Path, state_contract_sha256: &str) -> Result { - fixture_with_state_contract_and_custody(root, state_contract_sha256, false) - } - - fn fixture_with_state_contract_and_custody( - root: &Path, - state_contract_sha256: &str, - enforce_custody: bool, - ) -> Result { - let provider_path = root.join("provider.cc"); - let provider = - enroll_service_identity_at::(&provider_path)?; - let provider_public_key = provider.entry().public_key.clone(); - let idunn = enroll_service_identity_at::(&root.join("idunn.cc"))?; - let expected = IdunnExpectedIncarnationRecord { - schema_version: IDUNN_EXPECTED_INCARNATION_SCHEMA.into(), - target: TARGET.into(), - plan_id: digest('1'), - incarnation_id: "ghostlight-incarnation-1".into(), - sealed_release_id: digest('2'), - source_repository: "github.com/GameCult/Ghostlight".into(), - source_revision: "3".repeat(40), - recipe_sha256: digest('4'), - runtime_id: "ghostlight-yggdrasil".into(), - expected_signer_identity_id: provider.entry().identity_id.clone(), - health_contract: GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT.into(), - artifact_sha256: digest('5'), - state_schema_generation: Some(STATE_SCHEMA_GENERATION.into()), - state_contract_sha256: Some(state_contract_sha256.into()), - write_lease_required: true, - route: Some(IdunnExpectedRoute { - route_id: "ghostlight-http".into(), - transport: "http".into(), - stable_endpoint: "https://ghostlight.gamecult.org/".into(), - candidate_endpoint: "http://127.0.0.1:18831".into(), - }), - capabilities: vec![ - IdunnExpectedCapability { - capability: "gamecult.eve.surface-provider".into(), - schema: "gamecult.eve.surface.v1".into(), - compatibility: "v1".into(), - minimum_capacity: 1, - }, - IdunnExpectedCapability { - capability: "ghostlight.world-service".into(), - schema: STATE_SCHEMA.into(), - compatibility: state_schema_compatibility_tag(), - minimum_capacity: 1, - }, - ], - dependencies: vec![managed_dependency( - "shared-infrastructure", - ODIN_CAPABILITY.0, - ODIN_CAPABILITY.1, - ODIN_CAPABILITY.2, - "odin-yggdrasil", - "rudp://127.0.0.1:9", - )], - }; - let launch = IdunnRuntimeActivationLaunch::issue(&expected, digest('7'), 100, &idunn)?; - let activation = launch.activation().clone(); - let mut activation_credential = Vec::new(); - launch.write_credential(&mut activation_credential)?; - let activation_signer = - IdunnRuntimeActivationSigner::from_credential_reader(&activation_credential[..])?; - let provider_signer = open_service_identity_credential_reader::< - GameCultProviderHealthIdentity, - >(File::open(&provider_path)?)?; - let authority = verify_runtime_authority( - &expected, - &activation, - &idunn.trust_anchor()?, - &provider_public_key, - )?; - let publisher = RuntimePresencePublisher::new( - "127.0.0.1:9".parse()?, - "127.0.0.1:18831".parse()?, - expected.clone(), - activation.clone(), - provider_signer, - activation_signer, - root.join("write-lease.cc"), - enforce_custody, - )?; - Ok(Fixture { - publisher, - authority, - expected, - activation, - }) - } - - fn fixture(root: &Path) -> Result { - fixture_with_state_contract(root, STATE_CONTRACT_SHA256) - } - - pub(crate) fn route_observation_fixture(root: &Path) -> Result { - let mut fixture = fixture(root)?; - let warming = - fixture - .publisher - .signed_record("warming", "process-write-lease-pending", None)?; - let warming_sha256 = warming.canonical_sha256()?; - let lease = IdunnProcessWriteLeaseRecord { - schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), - target: fixture.expected.target.clone(), - expected_projection_sha256: fixture.expected.canonical_sha256()?, - plan_id: fixture.expected.plan_id.clone(), - incarnation_id: fixture.expected.incarnation_id.clone(), - sealed_release_id: fixture.expected.sealed_release_id.clone(), - activation_witness_sha256: fixture.activation.canonical_sha256()?, - state_schema_generation: STATE_SCHEMA_GENERATION.into(), - state_contract_sha256: fixture.expected.state_contract_sha256.clone().unwrap(), - runtime_id: fixture.expected.runtime_id.clone(), - runtime_instance_id: fixture.activation.runtime_instance_id.clone(), - warming_presence_sha256: warming_sha256.clone(), - lease_epoch: 1, - issued_at_unix_millis: 200, - }; - let path = root.join("write-lease.cc"); - SingleFileMessagePackBackingStore::new(&path).push(&CultCacheEnvelope { - key: lease.target.clone(), - r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), - payload: lease.canonical_bytes()?, - stored_at: "2026-09-03T00:00:00Z".into(), - schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), - })?; - let write_lease = ProcessWriteLeaseGuard::acquire_fixture( - path, - fixture.expected, - fixture.activation, - warming_sha256, - )?; - fixture.publisher.active_write_lease_sha256 = Some(write_lease.canonical_sha256().into()); - Ok(RouteObservationFixture { - publisher: fixture.publisher, - write_lease, - authority: fixture.authority, - }) - } - - #[test] - fn inherited_authority_fd_layout_rejects_substitution_and_extras() -> Result<()> { - let pid = std::process::id(); - let names = format!( - "{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}:{RUNTIME_PRESENCE_IDENTITY_FD_NAME}" - ); - authority_fd_layout(&pid.to_string(), "2", &names)?; - // Under PrivatePIDs the outer pid never equals ours; it must still pass. - authority_fd_layout(&(pid + 1).to_string(), "2", &names)?; - assert!(authority_fd_layout(&format!("0{pid}"), "2", &names).is_err()); - assert!(authority_fd_layout("0", "2", &names).is_err()); - assert!(authority_fd_layout("", "2", &names).is_err()); - assert!(authority_fd_layout(&pid.to_string(), "02", &names).is_err()); - assert!(authority_fd_layout(&pid.to_string(), "3", &names).is_err()); - assert!( - authority_fd_layout( - &pid.to_string(), - "2", - &format!( - "{RUNTIME_PRESENCE_IDENTITY_FD_NAME}:{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}" - ), - ) - .is_err() - ); - assert!( - authority_fd_layout( - &pid.to_string(), - "2", - &format!( - "{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}:{IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME}" - ), - ) - .is_err() - ); - Ok(()) - } - - #[cfg(target_os = "linux")] - #[test] - fn close_on_exec_authority_descriptor_is_not_visible_to_a_child() -> Result<()> { - use std::{os::fd::AsRawFd, process::Command}; - - let file = tempfile::tempfile()?; - mark_descriptor_close_on_exec(&file, "test signer")?; - let descriptor = file.as_raw_fd(); - let status = Command::new("/bin/sh") - .arg("-c") - .arg(format!("test ! -e /proc/self/fd/{descriptor}")) - .status()?; - ensure!(status.success(), "child inherited the signer descriptor"); - Ok(()) - } - - #[test] - fn runtime_rejects_an_expected_projection_for_another_state_contract() { - let root = tempfile::tempdir().unwrap(); - let error = match fixture_with_state_contract(root.path(), &digest('6')) { - Ok(_) => panic!("another state contract was admitted"), - Err(error) => error, - }; - assert!( - error - .to_string() - .contains("does not name Ghostlight's writable world-v3 state") - ); - } - - #[test] - fn warming_presence_is_dual_signed_and_reports_compiled_capabilities() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let record = - fixture - .publisher - .signed_record("warming", "process-write-lease-pending", None)?; - let bytes = rmp_serde::to_vec(&record)?; - let claim = authenticate_runtime_presence_claim( - &bytes, - &fixture.authority, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: record.observed_at_unix_millis, - maximum_age_millis: 1_000, - maximum_future_skew_millis: 10, - }, - )?; - assert_eq!(claim.record(), &record); - assert_eq!(record.capabilities, actual_capabilities()); - assert_eq!( - record.bound_endpoint.as_deref(), - Some("http://127.0.0.1:18831") - ); - assert!(record.write_lease_sha256.is_none()); - - let wrong_purpose = fixture - .publisher - .provider_signer - .sign::(b"another-record"); - assert_ne!(wrong_purpose.signature, record.signature); - Ok(()) - } - - #[test] - fn dependency_endpoints_fail_closed_without_scheme_coercion() -> Result<()> { - assert_eq!( - parse_dependency_socket_endpoint( - "rudp://127.0.0.1:4100", - &["rudp://", "udp://"], - "Odin dependency", - )?, - "127.0.0.1:4100".parse::()? - ); - assert!( - parse_dependency_socket_endpoint( - "tcp://127.0.0.1:4100", - &["rudp://", "udp://"], - "Odin dependency", - ) - .is_err() - ); - assert!( - parse_dependency_socket_endpoint( - "http://127.0.0.1:4101", - &["rudp://", "udp://"], - "candidate dependency", - ) - .is_err() - ); - Ok(()) - } - - #[test] - fn process_write_lease_guard_holds_and_rechecks_the_exact_incarnation() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let first_warming = - fixture - .publisher - .signed_record("warming", "process-write-lease-pending", None)?; - let first_warming_sha256 = first_warming.canonical_sha256()?; - let latest_warming = - fixture - .publisher - .signed_record("warming", "process-write-lease-pending", None)?; - let latest_warming_sha256 = latest_warming.canonical_sha256()?; - assert!(latest_warming.publisher_sequence > first_warming.publisher_sequence); - assert_ne!(latest_warming_sha256, first_warming_sha256); - let lease = IdunnProcessWriteLeaseRecord { - schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), - target: fixture.expected.target.clone(), - expected_projection_sha256: fixture.expected.canonical_sha256()?, - plan_id: fixture.expected.plan_id.clone(), - incarnation_id: fixture.expected.incarnation_id.clone(), - sealed_release_id: fixture.expected.sealed_release_id.clone(), - activation_witness_sha256: fixture.activation.canonical_sha256()?, - state_schema_generation: STATE_SCHEMA_GENERATION.into(), - state_contract_sha256: fixture.expected.state_contract_sha256.clone().unwrap(), - runtime_id: fixture.expected.runtime_id.clone(), - runtime_instance_id: fixture.activation.runtime_instance_id.clone(), - warming_presence_sha256: first_warming_sha256.clone(), - lease_epoch: 1, - issued_at_unix_millis: 200, - }; - let path = root.path().join("write-lease.cc"); - SingleFileMessagePackBackingStore::new(&path).push(&CultCacheEnvelope { - key: lease.target.clone(), - r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), - payload: lease.canonical_bytes()?, - stored_at: "2026-09-03T00:00:00Z".into(), - schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), - })?; - - let guard = ProcessWriteLeaseGuard::acquire_recent_fixture( - path.clone(), - fixture.expected.clone(), - fixture.activation.clone(), - vec![first_warming_sha256, latest_warming_sha256], - )?; - guard.require_current()?; - assert_eq!(guard.canonical_sha256(), lease.canonical_sha256()?); - - let lease_sha256 = lease.canonical_sha256()?; - fixture.publisher.active_write_lease_sha256 = Some(lease_sha256.clone()); - let response = fixture - .publisher - .route_observation("challenge-31", true, &guard)?; - let CultNetMessage::SnapshotResponseRaw { - message_id, - documents, - } = response - else { - panic!("route observation was not a raw snapshot response"); - }; - assert_eq!(message_id, "challenge-31"); - let [document] = documents.as_slice() else { - panic!("route observation did not contain exactly one document"); - }; - assert_eq!(document.schema_id, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA); - assert_eq!(document.record_key, TARGET); - assert_eq!(document.tags, None); - let presence: GameCultRuntimePresenceHealthRecord = - rmp_serde::from_slice(&document.payload)?; - assert_eq!(presence.state, "active"); - assert_eq!(presence.detail, "route-observation:challenge-31"); - assert_eq!( - presence.write_lease_sha256.as_deref(), - Some(lease_sha256.as_str()) - ); - let claim = authenticate_runtime_presence_claim( - &document.payload, - &fixture.authority, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: presence.observed_at_unix_millis, - maximum_age_millis: 1_000, - maximum_future_skew_millis: 10, - }, - )?; - assert_eq!(claim.record(), &presence); - - let degraded = fixture - .publisher - .route_observation("challenge-32", false, &guard)?; - let CultNetMessage::SnapshotResponseRaw { documents, .. } = degraded else { - panic!("degraded route observation was not a raw snapshot response"); - }; - let degraded: GameCultRuntimePresenceHealthRecord = - rmp_serde::from_slice(&documents[0].payload)?; - assert_eq!(degraded.state, "degraded"); - assert_eq!(degraded.detail, "route-observation:challenge-32"); - drop(guard); - - let mut wrong = lease; - wrong.runtime_instance_id = digest('8'); - let entries = SingleFileMessagePackBackingStore::new(&path).pull_all()?; - let store = SingleFileMessagePackBackingStore::new(&path); - ensure!( - store.replace_and_append_if_snapshot_unchanged( - &entries, - vec![CultCacheEnvelope { - key: wrong.target.clone(), - r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), - payload: wrong.canonical_bytes()?, - stored_at: "2026-09-03T00:00:01Z".into(), - schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), - }], - )?, - "test lease replacement lost CAS" - ); - assert!( - ProcessWriteLeaseGuard::acquire_fixture( - path, - fixture.expected, - fixture.activation, - digest('9'), - ) - .is_err() - ); - Ok(()) - } - - /// No TOML parser is in this workspace's dependency graph (checked - /// `Cargo.lock`), so this pins the exact strings the daemon's own - /// constants require rather than parsing the recipe into a struct. It - /// still catches the drift class Soul found: the recipe naming a state - /// generation, provided schema, slot schema, or dependency shape the - /// binary does not actually have (F2). Every field is anchored to its own - /// table block (Soul's second pass: a bare `.contains()` anywhere in the - /// file passed even when the matched value sat under the wrong table — - /// e.g. a schema correct in `[[provides]]` but wrong in its own state - /// slot). - const RECIPE_TOML: &str = include_str!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../deployment/idunn/recipe.toml" - )); - - /// Splits recipe text into blocks, each running from one line whose first - /// non-whitespace byte is `[` up to (not including) the next such line. - /// Assertions then search *within* the right block instead of the whole - /// file, so a value in the wrong table cannot satisfy them. - fn recipe_blocks(text: &str) -> Vec<&str> { - let mut starts = Vec::new(); - let mut offset = 0usize; - for line in text.split_inclusive('\n') { - if line.trim_start().starts_with('[') { - starts.push(offset); - } - offset += line.len(); - } - starts.push(text.len()); - starts.windows(2).map(|pair| &text[pair[0]..pair[1]]).collect() - } - - /// The one block whose header matches `header` and whose body contains - /// `anchor` (e.g. `id = "world"`, or `capability = "..."`) — the field - /// that names which real table this is, distinct from every other block - /// sharing the same header (there are four `[[state.slots]]` blocks, two - /// `[[provides]]` blocks, two `[[dependencies]]` blocks). - fn find_block<'a>(blocks: &[&'a str], header: &str, anchor: &str) -> &'a str { - blocks - .iter() - .copied() - .find(|block| block.trim_start().starts_with(header) && block.contains(anchor)) - .unwrap_or_else(|| panic!("recipe has no {header} block containing {anchor:?}")) - } - - #[test] - fn recipe_matches_the_daemon_it_deploys() { - let blocks = recipe_blocks(RECIPE_TOML); - - let world_service = find_block( - &blocks, - "[[provides]]", - "capability = \"ghostlight.world-service\"", - ); - assert!( - world_service.contains(&format!("schema = \"{STATE_SCHEMA}\"")), - "recipe [[provides]] ghostlight.world-service schema disagrees with STATE_SCHEMA" - ); - assert!( - world_service.contains(&format!( - "compatibility = \"{}\"", - state_schema_compatibility_tag() - )), - "recipe [[provides]] ghostlight.world-service compatibility disagrees with state_schema_compatibility_tag()" - ); - - let world_slot = find_block(&blocks, "[[state.slots]]", "id = \"world\""); - assert!( - world_slot.contains(&format!("schema = \"{STATE_SCHEMA}\"")), - "recipe world state slot schema disagrees with STATE_SCHEMA" - ); - - let app_sessions_slot = find_block(&blocks, "[[state.slots]]", "id = \"app-sessions\""); - assert!( - app_sessions_slot.contains(&format!( - "schema = \"{}\"", - crate::app_session::STORE_SCHEMA - )), - "recipe app-sessions slot schema disagrees with app_session::STORE_SCHEMA" - ); - - let controller_work_slot = - find_block(&blocks, "[[state.slots]]", "id = \"controller-work\""); - assert!( - controller_work_slot - .contains(&format!("schema = \"{}\"", ghostlight::CONTROLLER_WORK_SCHEMA)), - "recipe controller-work slot schema disagrees with ghostlight::CONTROLLER_WORK_SCHEMA" - ); - - let mesh_slot = find_block(&blocks, "[[state.slots]]", "id = \"mesh-projection\""); - assert!( - mesh_slot.contains("schema = \"gamecult.eve.surface.v1\""), - "recipe mesh-projection slot schema disagrees with the mesh module's eve surface schema" - ); - - let dependency_blocks: Vec<&&str> = blocks - .iter() - .filter(|block| block.trim_start().starts_with("[[dependencies]]")) - .collect(); - assert_eq!( - dependency_blocks.len(), - 1, - "recipe does not declare Ghostlight's exact one dependency \ - (Heimdall is discovered through Odin at use, not an Idunn \ - dependency, until Heimdall is a v2 target — see \ - runtime_dependency_bindings)" - ); - - let odin_dependency = find_block( - &blocks, - "[[dependencies]]", - &format!("capability = \"{}\"", ODIN_CAPABILITY.0), - ); - assert!( - odin_dependency.contains("kind = \"shared-infrastructure\""), - "recipe Odin dependency kind disagrees with idunn_health's managed-shared-infrastructure rule" - ); - - assert!( - !RECIPE_TOML.contains("gamecult.codex.subscription-inference"), - "recipe still declares the retired CodexConnector dependency" - ); - - // STATE_CONTRACT_SHA256 pin (S3): the test cannot recompute Idunn's - // hash itself (no TOML parser or msgpack encoder here), so it pins - // the exact [state]+[[state.slots]] source text the hash was - // computed from instead. A change to that text without recomputing - // and updating STATE_CONTRACT_SHA256 to match fails here. - let start = RECIPE_TOML - .find("[state]") - .expect("recipe has a [state] block"); - let end = RECIPE_TOML - .find("[[provides]]") - .expect("recipe [state] block is followed by [[provides]]"); - let state_and_slots_text = RECIPE_TOML[start..end].replace("\r\n", "\n"); - assert_eq!( - state_and_slots_text, EXPECTED_STATE_AND_SLOTS_TEXT, - "recipe [state]/[[state.slots]] text changed without updating \ - STATE_CONTRACT_SHA256 and EXPECTED_STATE_AND_SLOTS_TEXT together \ - (recompute the hash with Idunn's canonical_state_contract_sha256 \ - against the new recipe)" - ); - } - - /// A failed heartbeat publish does not abort `wait_for_write_lease`. The - /// fixture publisher's endpoint (`127.0.0.1:9`) is closed, so every - /// publish — including the heartbeat this loop fires after - /// `WARMING_HEARTBEAT_INTERVAL` — fails fast. Before the fix that - /// failure propagated through `?` and the wait ended within about one - /// heartbeat interval with a "publishing Warming heartbeat" error. After - /// the fix it logs and keeps waiting for the write-lease file (which - /// this test never creates), so the wait instead runs to its own outer - /// timeout and fails with "timed out waiting for process write lease". - #[tokio::test] - async fn a_failed_heartbeat_does_not_abort_the_wait_for_lease() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let warming = PublishedRuntimePresence { - canonical_sha256: digest('9'), - }; - let outer_timeout = WARMING_HEARTBEAT_INTERVAL + Duration::from_secs(1); - let error = match fixture - .publisher - .wait_for_write_lease(&warming, outer_timeout) - .await - { - Ok(_) => panic!("wait_for_write_lease unexpectedly acquired a lease"), - Err(error) => error, - }; - assert!( - format!("{error:#}").contains("timed out waiting for process write lease"), - "wait ended with `{error:#}`, not the outer timeout — a heartbeat failure aborted it early" - ); - Ok(()) - } - - /// Builds a lease store whose file and lock both exist but which holds - /// zero records — exactly what Idunn's own revocation of a prior - /// candidate's lease leaves behind. `push` then `delete` of a throwaway - /// envelope is the only way to reach that state through the crate's own - /// public store API without hand-encoding the on-disk format: it creates - /// both files, then empties the store while leaving them in place. - fn leave_an_empty_revoked_lease_store(path: &Path) -> Result<()> { - let mut store = SingleFileMessagePackBackingStore::new(path); - let placeholder = CultCacheEnvelope { - key: "placeholder".into(), - r#type: "placeholder.v1".into(), - payload: Vec::new(), - stored_at: "2026-09-03T00:00:00Z".into(), - schema_id: None, - }; - store.push(&placeholder)?; - store.delete(&placeholder)?; - Ok(()) - } - - /// A present, zero-record lease store is not "not yet admitted": before - /// this fix `wait_for_write_lease` bailed within one poll with "process - /// write lease was not admitted ... zero records", the same failure mode - /// as the live Idunn v2 deploy this fixes (a revoked prior candidate's - /// lease leaves exactly this file behind). After the fix the wait keeps - /// polling past it, exactly as past a plain absence, and only its own - /// outer deadline ends it — carrying the last acquisition error forward - /// for diagnosis. - #[tokio::test] - async fn an_empty_revoked_lease_store_times_out_not_not_admitted() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let path = root.path().join("write-lease.cc"); - leave_an_empty_revoked_lease_store(&path)?; - let warming = PublishedRuntimePresence { - canonical_sha256: digest('9'), - }; - let outer_timeout = Duration::from_millis(600); - let started = Instant::now(); - let error = match fixture - .publisher - .wait_for_write_lease(&warming, outer_timeout) - .await - { - Ok(_) => panic!("wait_for_write_lease unexpectedly acquired a lease from an empty store"), - Err(error) => error, - }; - let elapsed = started.elapsed(); - assert!( - elapsed >= outer_timeout, - "wait ended after {elapsed:?}, short of its own {outer_timeout:?} timeout" - ); - let chain = format!("{error:#}"); - assert!( - chain.contains("timed out waiting for process write lease"), - "wait ended with `{chain}`, not its own timeout — the empty store aborted it early" - ); - assert!( - chain.contains("not admitted"), - "wait's timeout error `{chain}` lost the last acquisition error for diagnosis" - ); - Ok(()) - } - - /// Same starting state as the test above — a present, zero-record lease - /// store — but this time a valid lease for the fixture's own incarnation - /// is written into it partway through the wait. The wait must still - /// return that lease, proving the retry actually re-attempts acquisition - /// on every poll rather than giving up on the store's first, empty read. - #[tokio::test] - async fn a_lease_written_after_an_empty_revoked_store_still_wins() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = fixture(root.path())?; - let path = root.path().join("write-lease.cc"); - leave_an_empty_revoked_lease_store(&path)?; - let warming_sha256 = digest('9'); - let warming = PublishedRuntimePresence { - canonical_sha256: warming_sha256.clone(), - }; - let lease = IdunnProcessWriteLeaseRecord { - schema_version: IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into(), - target: fixture.expected.target.clone(), - expected_projection_sha256: fixture.expected.canonical_sha256()?, - plan_id: fixture.expected.plan_id.clone(), - incarnation_id: fixture.expected.incarnation_id.clone(), - sealed_release_id: fixture.expected.sealed_release_id.clone(), - activation_witness_sha256: fixture.activation.canonical_sha256()?, - state_schema_generation: STATE_SCHEMA_GENERATION.into(), - state_contract_sha256: fixture.expected.state_contract_sha256.clone().unwrap(), - runtime_id: fixture.expected.runtime_id.clone(), - runtime_instance_id: fixture.activation.runtime_instance_id.clone(), - warming_presence_sha256: warming_sha256, - lease_epoch: 1, - issued_at_unix_millis: 200, - }; - let lease_sha256 = lease.canonical_sha256()?; - let write_path = path.clone(); - let writer = tokio::spawn(async move { - tokio::time::sleep(Duration::from_millis(300)).await; - SingleFileMessagePackBackingStore::new(&write_path) - .push(&CultCacheEnvelope { - key: lease.target.clone(), - r#type: IdunnProcessWriteLeaseRecord::TYPE.into(), - payload: lease.canonical_bytes().unwrap(), - stored_at: "2026-09-03T00:00:00Z".into(), - schema_id: Some(IDUNN_PROCESS_WRITE_LEASE_SCHEMA.into()), - }) - .unwrap(); - }); - let guard = fixture - .publisher - .wait_for_write_lease(&warming, Duration::from_secs(5)) - .await?; - writer.await?; - assert_eq!(guard.canonical_sha256(), lease_sha256); - Ok(()) - } - - /// Pins the choice `wait_for_write_lease` now makes between - /// `acquire_recent` and `acquire_recent_fixture`: a publisher built the - /// way `from_environment` (production) builds it — `enforce_custody: - /// true` — must still refuse a lease store that sits under a plain - /// tempdir, never root-controlled. Proven behaviorally through the real - /// `wait_for_write_lease` path, not by reading the private field, so a - /// future change that keeps the field but stops honoring it still fails - /// this test. Linux-only: custody enforcement is architecturally a - /// root-ownership concept (`require_process_write_lease_custody` is a - /// deliberate no-op on every other OS, `idunn_health.rs`'s own - /// `#[cfg(not(target_os = "linux"))]` fallback), so there is nothing for - /// this assertion to observe elsewhere. - #[cfg(target_os = "linux")] - #[tokio::test] - async fn production_custody_choice_rejects_a_lease_under_a_non_root_tempdir() -> Result<()> { - let root = tempfile::tempdir()?; - let mut fixture = - fixture_with_state_contract_and_custody(root.path(), STATE_CONTRACT_SHA256, true)?; - let path = root.path().join("write-lease.cc"); - leave_an_empty_revoked_lease_store(&path)?; - let warming = PublishedRuntimePresence { - canonical_sha256: digest('9'), - }; - let error = match fixture - .publisher - .wait_for_write_lease(&warming, Duration::from_millis(600)) - .await - { - Ok(_) => panic!( - "wait_for_write_lease acquired a lease under a tempdir with custody enforced" - ), - Err(error) => error, - }; - let chain = format!("{error:#}"); - assert!( - chain.contains("non-root-controlled") || chain.contains("root-owned"), - "expected a custody rejection, got `{chain}` — production custody enforcement regressed" - ); - Ok(()) - } - - /// Pure over (uid, gid, mode), so it runs on this Windows workstation too - /// rather than sitting unexercised behind `#[cfg(target_os = "linux")]` - /// the way `require_runtime_bundle`'s real-filesystem check has always - /// been (nothing in this crate constructed a real chowned/chmodded - /// fixture for it before this test). - #[test] - fn root_read_only_accepts_any_gid_and_refuses_group_or_other_write() { - // Exactly what Idunn's harden_runtime_bundle/harden_installed_release - // leave: root-owned, mode 0555, and — because the bundle lives inside - // a setgid runtime_root — a real workload gid, not 0. - assert!(is_root_service_read_only_directory(0, 0, 0o555)); - assert!(is_root_service_read_only_directory(0, 4_000, 0o555)); - // Owner-write is not part of the rule: 0o022 only forbids group/other. - assert!(is_root_service_read_only_directory(0, 4_000, 0o755)); - // Refused: not root-owned, or writable by group or other. - assert!(!is_root_service_read_only_directory(1_000, 0, 0o555)); - assert!(!is_root_service_read_only_directory(0, 0, 0o575)); - assert!(!is_root_service_read_only_directory(0, 0, 0o557)); - - // Exactly what harden_runtime_bundle leaves for a document: root - // owned, mode 0444, any gid. - assert!(is_root_read_only_file(0, 0, 0o444)); - assert!(is_root_read_only_file(0, 4_000, 0o444)); - // Refused: not root-owned, or writable by owner, group, or other. - assert!(!is_root_read_only_file(1_000, 0, 0o444)); - assert!(!is_root_read_only_file(0, 4_000, 0o644)); - assert!(!is_root_read_only_file(0, 4_000, 0o464)); - assert!(!is_root_read_only_file(0, 4_000, 0o446)); - } -} diff --git a/crates/ghostlight-dungeon/src/main.rs b/crates/ghostlight-dungeon/src/main.rs deleted file mode 100644 index 6f0faee..0000000 --- a/crates/ghostlight-dungeon/src/main.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod app_session; -mod eve; -mod heimdall; -mod idunn_health; -mod mesh; -mod play; -mod runtime; - -use anyhow::bail; -use std::path::PathBuf; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = std::env::args_os().skip(1).collect::>(); - 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 -} diff --git a/crates/ghostlight-dungeon/src/mesh.rs b/crates/ghostlight-dungeon/src/mesh.rs deleted file mode 100644 index e230aa1..0000000 --- a/crates/ghostlight-dungeon/src/mesh.rs +++ /dev/null @@ -1,464 +0,0 @@ -//! Derived CultMesh and Eve projection. -//! -//! The mesh publishes the world owner's current surface. It never pulls or -//! repairs world state and it has no command authority. - -use crate::eve; -use ghostlight::{CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, STATE_SCHEMA, WorldSnapshot}; -use anyhow::Result; -use chrono::{DateTime, Utc}; -use cultcache_rs::DatabaseEntry; -use cultmesh_rs::{ - CultMesh, CultMeshNode, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions, - publish_cultnet_messages_to_rudp_catalog, -}; -use serde_json::{Value, json}; -use std::{ - net::SocketAddr, - path::Path, - sync::{ - Arc, Condvar, Mutex, - atomic::{AtomicBool, Ordering}, - }, - thread, -}; - -pub(crate) const PROVIDER_ID: &str = "gamecult.ghostlight.dungeon"; -pub(crate) const SURFACE_ID: &str = "ghostlight.play"; -pub(crate) const COMMAND_BOUNDARY: &str = "ghostlight.eve.commands"; -pub(crate) const COMMAND_RESULT_SCHEMA: &str = "gamecult.eve.command_result.v1"; -pub(crate) const COMMAND_RECEIPT_SCHEMA: &str = "gamecult.eve.command_receipt.v1"; -/// The repository that owns this surface, as the command receipt contract -/// requires of every result. -pub(crate) const OWNER_REPO: &str = "GameCult/Ghostlight"; - -const HEALTH_KEY: &str = "ghostlight:dungeon:health"; -const ADVERTISEMENT_KEY: &str = "eve:provider:gamecult.ghostlight.dungeon"; -const SURFACE_KEY: &str = "eve:surface:ghostlight.play"; - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache(type = "gamecult.eve.surface", schema = "gamecult.eve.surface.v1")] -struct EveSurfaceRecord { - #[cultcache(key = 0)] - value: Value, -} - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache( - type = "gamecult.eve.provider_advertisement", - schema = "gamecult.eve.provider_advertisement.v1" -)] -struct EveProviderAdvertisementRecord { - #[cultcache(key = 0)] - value: Value, -} - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache( - type = "ghostlight.service_health", - schema = "ghostlight.service_health.v2" -)] -struct ServiceHealthRecord { - #[cultcache(key = 0)] - value: Value, -} - -cultmesh_rs::cultmesh_documents!(GhostlightDocuments { - EveSurfaceRecord => "gamecult.eve.surface.v1", - EveProviderAdvertisementRecord => "gamecult.eve.provider_advertisement.v1", - ServiceHealthRecord => "ghostlight.service_health.v2", -}); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct MeshRuntimeIdentity { - pub(crate) runtime_id: String, - pub(crate) service_id: String, - pub(crate) located_service: String, -} - -impl Default for MeshRuntimeIdentity { - fn default() -> Self { - Self { - runtime_id: "ghostlight-dungeon".into(), - service_id: "ghostlight-dungeon".into(), - located_service: "local".into(), - } - } -} - -#[derive(Clone)] -pub(crate) struct MeshPublisher { - node: Arc>, - remote: Option, - identity: MeshRuntimeIdentity, -} - -#[derive(Clone)] -struct RemoteReplication { - target: SocketAddr, - runtime_id: String, - pending: Arc<(Mutex>>, Condvar)>, - alive: Arc, -} - -impl MeshPublisher { - pub(crate) fn open( - store_path: impl AsRef, - rudp_target: Option, - identity: MeshRuntimeIdentity, - ) -> Result { - 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 { - 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 { - 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::(HEALTH_KEY) - .map(|record| record.value) - } - - fn put_and_stage( - &self, - node: &mut CultMeshNode, - key: impl Into, - value: &T, - remote_messages: &mut Vec, - ) -> 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 { - let pending = Arc::new(( - Mutex::new(None::>), - 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); - 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) { - 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::(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::>().is_ok()) - ); - assert_eq!(surface["surface"], eve::mesh_surface(None)["surface"]); - assert_eq!(surface["commands"], eve::mesh_surface(None)["commands"]); - assert!( - node.get_required::(ADVERTISEMENT_KEY) - .is_ok() - ); - assert!(node.get_required::(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); - } -} diff --git a/crates/ghostlight-dungeon/src/play.rs b/crates/ghostlight-dungeon/src/play.rs deleted file mode 100644 index 17cf76d..0000000 --- a/crates/ghostlight-dungeon/src/play.rs +++ /dev/null @@ -1,11900 +0,0 @@ -//! The play table's store and loop (Cut 8a). -//! -//! One play agent runs a role-playing game over a Ghostlight world: it reads -//! `table_view`, interprets the player's prose, dispatches Personas, commits -//! consequences through the kernel's own `table.rs` vocabulary, and ends the -//! turn or asks the player a question. The kernel is the only writer; this -//! table carries no world state between turns, only the turn's own inference -//! record. -//! -//! Cut 8b (`a99fdfe`) wired this into `AppState`, `GHOSTLIGHT_PLAY_MODEL`, -//! and `world.play` ingress: `runtime.rs` now holds one `Arc` per -//! world this process owns and routes `world.play` to it. The Eve schema — -//! a descriptor and a button for `world.play` — is not: `operation_schema` -//! carries the operation alone, so far advertised nowhere (Cut 9). - -use std::{ - collections::VecDeque, - path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; - -use anyhow::{Context, anyhow, bail}; -use base64::Engine; -use chrono::Utc; -use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; -use ghostlight::{ - CommandBody, CommandId, ControllerError, ControllerMode, ControllerPort, DecisionInvocation, - DecisionOpportunity, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceFaultDisposition, - InferenceOutput, InferencePort, InferenceRequest, KernelError, MailboxError, PersonaLane, - PlayPort, PrincipalCommandIntent, Statement, SubjectId, TickMinutes, VerifiedPrincipalEvidence, - WorldMailbox, WorldPatch, WorldSnapshot, actor_tools, authoring_tools, decode_actor_call, - decode_authoring_calls, describe_refusal, describe_refusal_to_actor, id_text, id_text_matches, - table_view, -}; -use ghostlight_persona_projection::{PersonaTurn, SourceSpan}; - -/// What closes a turn on a fault: the stored detail text and the typed class -/// that says what happened. Every non-inference failure is `Unclassified`; an -/// inference fault keeps the class its port gave it (`InferenceFaultClass`, -/// the HTTP status included), so the warn line in `close_with_fault` can name -/// it without the detail text carrying it. -struct TurnFault { - detail: String, - class: InferenceFaultClass, -} - -impl From for TurnFault { - fn from(detail: String) -> Self { - Self { detail, class: InferenceFaultClass::Unclassified } - } -} -use codex_connector::{CodexInputItem, CodexToolDefinition}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use tokio::sync::{Mutex, Semaphore}; - -const STORE_TYPE: &str = "ghostlight.play_turn_store.v1"; -/// PA.f172 bumped this from v1 to v2 when `question_surface_version` was -/// added to `PlayTurn` without `#[serde(default)]`. PA.f170 (Cut 13) bumps it -/// again, v2 to v3: `question_surface_version` is deleted outright (the -/// version-only staleness comparison it served is gone, replaced by the -/// opaque per-question `question_token`) and `question_token` carries -/// `#[serde(default)]` in its place, for exactly the same reason — a v1 or v2 -/// row's own bytes never had either field, so the same current struct decodes -/// either as `None`. `open` still gates strictly on the row's own declared -/// `schema_id` (a legacy branch recognizes `LEGACY_STORE_SCHEMA_V1` and -/// `LEGACY_STORE_SCHEMA_V2` by name, never silently), but once recognized, -/// upgrades the row to `STORE_SCHEMA` in place rather than discarding the -/// turn it names as unreadable. A row recovered this way whose question is -/// still open (`state == AwaitingPlayer`) mints a fresh token as part of the -/// upgrade — no earlier schema ever recorded one, and the card is re-rendered -/// with whatever the row holds, so a player returning to an upgraded session -/// can still answer. -const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v3"; -/// The schema id every row written before `question_surface_version` existed -/// carries. Recognized by `PlayTurnStore::open` as legacy, not corrupt, and -/// upgraded to `STORE_SCHEMA` in place (PA.f172, PA.f170). -const LEGACY_STORE_SCHEMA_V1: &str = "ghostlight.play_turn_store.v1"; -/// The schema id every row written between PA.f172 and PA.f170 carries: it -/// has `question_surface_version` (now deleted) but not `question_token`. -/// Recognized by `PlayTurnStore::open` as legacy, not corrupt, and upgraded -/// to `STORE_SCHEMA` in place the same way a v1 row is (PA.f170). -const LEGACY_STORE_SCHEMA_V2: &str = "ghostlight.play_turn_store.v2"; -const STORE_KEY: &str = "primary"; - -/// The one round budget: exhausting it closes the turn and narrates, rather -/// than leaving it `Running` with nothing said (PA-Q8, Cut 8, PA.f66a/PA.f95). -const ROUND_BUDGET: usize = 12; - -/// Bounded so a single round cannot spend the whole response budget one -/// grouped-agent tool spree; matches the grouped agent lane's own order of -/// magnitude (`controllers.rs`'s `OperationalAgent` request). -const PLAY_MAX_OUTPUT_TOKENS: u32 = 8_000; - -/// How many `Retryable` inference faults one round's own request may absorb -/// (PA.f94): only `InferenceFaultDisposition::Retryable` retries at all — a -/// `RecoveryRequired` or `IntegrityViolation` fault closes the turn on its -/// first occurrence, through `close_with_fault`, since retrying either kind -/// again cannot succeed differently. -const ROUND_RETRY_BUDGET: usize = ROUND_BUDGET; - -/// The key ledger's own retention window (PA.f83): a request key survives in -/// the ledger for the most recent 64 closed turns after the one that carried -/// it, then is forgotten — the store keeps only the current turn's own full -/// record, so an ancient replay past the window opens a fresh turn rather -/// than being recognized. -const KEY_LEDGER_WINDOW: usize = 64; - -/// Dungeon's own subset of the library's `PATCH_TOOLS`, named in Cut 8's -/// table (PA-Q7 B). The library hard-codes none of these; this is policy, and -/// policy belongs to the caller. -const PLAY_TOOLS: &[&str] = &[ - "relocate", - "transfer", - "consume", - "mint", - "bind", - "release", - "witness", - "acquire_knowledge", - "forget", - "create_commitment", - "discharge_commitment", - "advance_pressure", - "reduce_pressure", - "open_route", - "close_route", - "declare_place", - "declare_route", - "declare_resource", - "declare_fact", - "declare_subject", - "set_persona_material", - "retire", - "grant_affordance", - "revoke_affordance", -]; - -const DISPATCH_TOOL: &str = "dispatch"; -const ADVANCE_TIME_TOOL: &str = "advance_time"; -const ASK_PLAYER_TOOL: &str = "ask_player"; -const END_TURN_TOOL: &str = "end_turn"; - -/// A handle names the player or a subject dispatched this turn; nothing else -/// carries the `__` separator into a tool name a model may call. -const HANDLE_SEPARATOR: &str = "__"; - -/// The table rules, sent once per round as the request's instructions. The -/// turn's own conversation is rebuilt each round from the recorded -/// `opening_prompt` — `table_view` of the snapshot as it stood the moment the -/// turn opened, captured once, not re-rendered on a later round — plus the -/// recorded `rounds` and their recorded results (PA.f111, S6): a resumed -/// round replays this exact recorded opening, never a fresh `table_view` of -/// whatever the world looks like by the time it resumes. -const PLAY_INSTRUCTIONS: &str = "You are the table: the one player-facing role-playing agent for this Ghostlight world. \ -The player writes prose; you read the whole-world table view and decide what happens.\n\ -\n\ -Rules:\n\ -- Speech and display are quotes: a `__` call's `text` and `display` must be an exact, \ -verbatim quote copied from the player's own words or from a dispatched Persona's own turn prose. A \ -paraphrase is refused.\n\ -- An actor acts only through its own tools: use `__` for the player's handle or a \ -subject you dispatched this turn, never another subject's tool under a borrowed handle.\n\ -- Rulings are authoring tools: mint, transfer, relocate, and the rest of the authoring catalog are \ -your own hand as the table, not an actor's act.\n\ -- Ask the player with `ask_player` when an outcome needs the player's own choice or negotiation, and \ -stop the turn there.\n\ -- A fact you invent uses `declare_fact` with `ruled` standing; you reach a subject's knowledge of it \ -through `witness` or `acquire_knowledge`.\n\ -- You may `mint` quantity into existence; nothing else may.\n\ -- Call `dispatch` with the subjects who should act this round before calling their tools, naming each \ -subject by its full id exactly as table_view prints it in brackets — never the short __ prefix \ -an actor tool's own name carries. An id naming no dispatchable subject is refused, quoting the text \ -given, rather than silently doing nothing.\n\ -- Call `end_turn` when the round's consequences are settled."; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) enum PlayTurnState { - Running, - AwaitingPlayer, - Closed, -} - -/// One `run` request. `answers` names the question this request answers, by -/// the same `QuestionId` an `AwaitingPlayer` turn currently exposes; `None` -/// is the plain continue/opening shape (PA.f84). `&str`/`String` still -/// convert directly (`answers: None`), so most call sites are unaffected by -/// this replacing the bare `text` parameter `run` used to take. -#[derive(Clone, Debug)] -pub(crate) struct PlayRequest { - pub(crate) text: String, - pub(crate) answers: Option, -} - -impl From<&str> for PlayRequest { - fn from(text: &str) -> Self { - Self { - text: text.to_owned(), - answers: None, - } - } -} - -impl From for PlayRequest { - fn from(text: String) -> Self { - Self { text, answers: None } - } -} - -/// The small typed value one open question is identified by (PA.f84): a -/// `run` request whose `answers` does not match the turn's own currently -/// open `QuestionId` is refused as stale rather than being applied to -/// whatever question happens to be open when the request is finally -/// processed — a late retry naming an already-answered or already-superseded -/// question must never land on a different one. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct QuestionId { - turn_id: String, - round: usize, - slot: usize, -} - -/// One tool call's recorded body, persisted before submission (PA-Q8): a -/// resumed call resubmits this exact value, never a value rebuilt from a -/// fresh snapshot (mutation M8.3). -#[derive(Clone, Debug, Serialize, Deserialize)] -enum RecordedCall { - Authoring(WorldPatch), - PlayerAct(DecisionOpportunity, DecisionInvocation), - PersonaAct(SubjectId, DecisionOpportunity, DecisionInvocation), - AdvanceTime(TickMinutes), - Dispatch(Vec), - AskPlayer(String), - EndTurn, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct CallRecord { - call_id: String, - round: usize, - slot: usize, - /// Absent only for a call this table refused before it ever reached a - /// door: a decode failure, an unknown tool, a span that would not - /// locate. Nothing with a body here was ever silently dropped; its - /// `result` is always the reason. - body: Option, - result: Option, -} - -/// The play turn row (PA-Q8): one row, over `service/play-turn-v1.cc`, -/// following `app_session.rs`'s pattern. The agent's conversation is -/// re-derived each round from `opening_prompt`, `rounds`, and the recorded -/// `calls`/`persona_turns` results — the connector posture — so nothing -/// carries forward between turns except world state. -#[derive(Clone, Debug, Serialize, Deserialize)] -struct PlayTurn { - /// Minted fresh (`Uuid::new_v4`) when the turn opens (PA.f83): never - /// taken from any request key. A request key and a turn's own identity - /// are different things — see `applied_keys` below and the store's own - /// `KeyLedger` — so a stale retry of the key that opened this turn can - /// never reproduce this turn's own command ids by opening a second turn - /// under the same id. - turn_id: String, - /// Every request key this open turn has already applied (PA.f62/PA.f63): - /// a key already here is a replay — a no-op — for as long as this turn - /// stays the store's current one. Once the turn closes, this list is - /// archived into the store's own `KeyLedger` (PA.f83), which is where a - /// replay of one of these keys is recognized after a new turn has opened - /// in this one's place. `#[serde(default)]` (PA.f177): PA.f95's original - /// reasoning covered only the *current*-schema (v3) strict path, where it - /// still holds — `to_vec_named` always emits this field, so a v3 row that - /// omitted it re-encodes to different bytes and the canonical check still - /// bails. It did not cover the *legacy* v1/v2 branches: a row older than - /// this field's own introduction still carries `LEGACY_STORE_SCHEMA_V1`, - /// so without a default `upgrade_legacy_row`'s decode itself failed with - /// "missing field `applied_keys`", `PlayTurnStore::open` returned `Err`, - /// and `state.play` went `None` — silently, forever, on every restart. - #[serde(default)] - applied_keys: Vec, - opening_prompt: String, - player_prose: Vec, - rounds: Vec, - calls: Vec, - persona_turns: Vec<(SubjectId, PersonaTurn)>, - question: Option, - /// PA.f170 (Cut 13): an opaque random handle minted fresh whenever - /// `execute_round`'s `ASK_PLAYER_TOOL` arm opens a question, set and - /// cleared alongside `question`. It carries no turn id, round, slot, - /// subject, or any other world handle — it is an answer's handle and - /// nothing else (invariant 8: reading it in devtools teaches nothing - /// about the world). This replaces PA.f161's `question_surface_version` - /// and the version-only staleness comparison it served: two questions - /// opened in the same turn carry the same surface version (asking is - /// conversational and never moves it), so a version comparison alone - /// cannot tell them apart — the gap Soul proved for PA.f170. Runtime's - /// `world.play` arm refuses an answer whose own token does not name this - /// one. `#[serde(default)]`, matching the earlier `question_surface_version` - /// reasoning it replaces: a row written under `LEGACY_STORE_SCHEMA_V1` or - /// `LEGACY_STORE_SCHEMA_V2` never had this field, so the same current - /// struct decodes it to `None` for either — exactly what "no question - /// ever recorded a token" means for a turn that old. `PlayTurnStore::open` - /// mints a fresh one for a recovered row whose question is still open, so - /// an upgraded session stays answerable; the canonical re-encode check - /// below still holds current-schema (v3) rows to their exact bytes. - #[serde(default)] - question_token: Option, - refusal: Option, - narration: Option, - /// Set when the turn closed on a fault rather than the player's own - /// consequences settling: an inference fault that does not retry, an - /// inference exhausted its retry budget (PA.f65/PA.f94), a request-build, - /// snapshot, or narrate failure after the turn opened (PA.f85/PA.f93), or - /// a kernel `Invariant` error poisoned the table (PA.f66b). `None` for a - /// turn that closed through `end_turn` or the round budget. - /// `#[serde(default)]` (PA.f177): same reasoning as `applied_keys` above - /// — the current-schema canonical check still holds a v3 row to it, and a - /// legacy row older than this field's own introduction now decodes it as - /// `None` instead of failing `upgrade_legacy_row` outright. - #[serde(default)] - fault: Option, - state: PlayTurnState, -} - -impl PlayTurn { - /// Every subject a dispatch call this turn's own record names has - /// *successfully* acted (PA.f90): a dispatch whose Persona turn faulted, - /// or found no live opportunity, never produced prose this turn can - /// quote, so it does not count as dispatched — offering its actor tools - /// next round would let the agent call them with nothing behind them. - /// `persona_turns` is the one record of an actually-recorded turn; a - /// subject's own presence there, not merely its presence in a - /// `Dispatch` call's own subject list, is what this reads. - fn dispatched_subjects(&self) -> Vec { - let mut subjects = Vec::new(); - for call in &self.calls { - if let Some(RecordedCall::Dispatch(list)) = &call.body { - for subject in list { - if !subjects.contains(subject) && self.persona_turns.iter().any(|(id, _)| id == subject) { - subjects.push(*subject); - } - } - } - } - subjects - } - - /// The `(round, slot)` of the earliest `Dispatch` call in this turn's - /// own record that names `subject`, if any (PA.f125): call order is - /// total, so exactly one call can be credited with producing a - /// subject's own `persona_turns` entry — the first one, in call order, - /// that ever named it. `execute_round` records and persists a dispatch - /// call's own body before `execute_dispatch` runs any persona turn, so - /// a crash-resumed call already appears here, under its own - /// `(round, slot)`, before this is ever consulted — letting - /// `execute_dispatch` recognize its own not-yet-finished work instead - /// of refusing it as `already acted` against a different call. - fn dispatch_owner(&self, subject: SubjectId) -> Option<(usize, usize)> { - self.calls - .iter() - .filter_map(|call| match &call.body { - Some(RecordedCall::Dispatch(list)) if list.contains(&subject) => Some((call.round, call.slot)), - _ => None, - }) - .min() - } - - fn call_record(&self, round: usize, slot: usize) -> Option<&CallRecord> { - self.calls - .iter() - .find(|call| call.round == round && call.slot == slot) - } - - fn call_record_mut(&mut self, round: usize, slot: usize) -> Option<&mut CallRecord> { - self.calls - .iter_mut() - .find(|call| call.round == round && call.slot == slot) - } - - /// The currently open question's own `QuestionId`, when the turn is - /// `AwaitingPlayer` (PA.f84): derived from the last round's own - /// unresolved `ask_player` call, the same slot `find_ask_player_slot` - /// locates to record an answer's result. - fn open_question_id(&self) -> Option { - if self.state != PlayTurnState::AwaitingPlayer { - return None; - } - let round = self.rounds.len().checked_sub(1)?; - let slot = find_ask_player_slot(self, round)?; - Some(QuestionId { - turn_id: self.turn_id.clone(), - round, - slot, - }) - } -} - -/// `turn.open_question_id()` paired with the question's own text and its -/// opaque answer token (PA.f134/PA.f170): `None` whenever `open_question_id` -/// is, since `turn.question` and `turn.question_token` are only ever set -/// alongside it (`execute_round`'s `ASK_PLAYER_TOOL` arm sets all three in -/// the same round, and `answer_turn` and `close_with_fault` both clear -/// `question`/`question_token` on their own way out of `AwaitingPlayer`, -/// PA.f173). -fn open_question_of(turn: &PlayTurn) -> Option { - let id = turn.open_question_id()?; - Some(OpenQuestion { - id, - text: turn.question.clone().unwrap_or_default(), - // PA.f179: `unwrap_or_default()` used to fabricate `""` here whenever - // `question_token` was `None` — normally unreachable, since - // `execute_round`'s `ASK_PLAYER_TOOL` arm sets `question_token` - // alongside `question` in the same round (see this function's own - // doc comment), but a mutation or a future bug that defeats minting - // must not quietly degrade the security invariant along with it. - // Collapsing "no token was ever minted" into the same `""` an - // attacker can literally type made the gate this feeds - // (`runtime.rs`'s `world.play` arm) accept a forged empty - // `answerToken` whenever that happened. Carried as `Option` - // now, so the gate can require both sides to be `Some` and equal — - // absence refuses, it does not degrade to a comparable value. - token: turn.question_token.clone(), - }) -} - -#[derive(Clone, Serialize, Deserialize)] -struct PlayTurnStoreState { - schema: String, - turn: Option, - /// A closed turn's own applied request keys, kept for `KEY_LEDGER_WINDOW` - /// more closed turns after it (PA.f83). No `#[serde(default)]`, matching - /// `PlayTurn::applied_keys`/`fault` (PA.f95): `PlayTurnStore::open`'s own - /// canonical re-encode check refuses any row missing a declared field - /// regardless, so a default here could never actually apply either. - ledger: KeyLedger, - /// The row's own monotonic counter (Cut 9): incremented exactly once by - /// `swap_in`, the row's one write door, on every committed write — - /// `commit`'s own mid-turn updates and `open_new_turn`'s own turn - /// replacements alike. It never resets, not on a new turn and not on a - /// restart (the store reads it back off disk like every other field), so - /// a reconnecting SSE client can never see it go backwards. This is what - /// lets `eve::authenticated_surface`'s own `surface_version` move on a - /// refusal or a question that commits nothing to the world — the one gap - /// a version derived only from already-existing row fields could not - /// close, because a refusal updates `turn.refusal` without growing - /// `turn.calls` or `turn.rounds` (`execute_actor_call`'s `ActionRejected` - /// arm updates an already-recorded call's own result in place). No - /// `#[serde(default)]`, matching `ledger` above: the store has never been - /// deployed, so no row anywhere is missing it, and the canonical - /// re-encode check stays strict (PA.f95). - revision: u64, -} - -/// One closed turn's own applied request keys, oldest closed turn first. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -struct KeyLedger { - closed: VecDeque, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct ClosedTurnKeys { - turn_id: String, - keys: Vec, -} - -impl KeyLedger { - /// The `turn_id` of the closed turn `key` was applied to, if it is still - /// within the window (PA.f109): `ClosedTurnKeys::turn_id` is written on - /// every archive but was never read anywhere until this — a replay - /// recognized through the ledger can now say which turn it replayed. - fn turn_id_for(&self, key: &str) -> Option { - self.closed - .iter() - .find(|entry| entry.keys.iter().any(|existing| existing == key)) - .map(|entry| entry.turn_id.clone()) - } - - /// Archives one turn's own applied keys as it closes out of the store's - /// single current-turn row, then evicts down to `KEY_LEDGER_WINDOW` - /// closed turns, oldest first. - fn push_closed(&mut self, turn_id: String, keys: Vec) { - if keys.is_empty() { - return; - } - self.closed.push_back(ClosedTurnKeys { turn_id, keys }); - while self.closed.len() > KEY_LEDGER_WINDOW { - self.closed.pop_front(); - } - } -} - -/// `PlayTurnStore` over `service/play-turn-v1.cc`: one `OwnedRedbMessagePackBackingStore` -/// row, the same custody shape `app_session.rs`'s `AppSessionOwner` uses. -struct PlayTurnStore { - store: OwnedRedbMessagePackBackingStore, - row: CultCacheEnvelope, - state: PlayTurnStoreState, - healthy: bool, -} - -impl PlayTurnStore { - fn open(path: impl AsRef) -> anyhow::Result { - let store = OwnedRedbMessagePackBackingStore::new(path.as_ref())?; - store - .validate_path_identity() - .context("play-turn store path identity is invalid")?; - let rows = store.pull_all()?; - // PA.f193-E/PA.f194-E: the third element is the sidecar path a - // retirement wrote this call, `None` on every ordinary path (fresh - // init, a row that decoded/upgraded cleanly). Nothing on - // `PlayTurnStore` itself keeps it — `PlayTable::retired_row_sidecar` - // scans the store's own directory fresh on every `/health` read - // instead, so a retirement a *prior* process performed is still - // reported after a restart, not only by the process whose own `open` - // happened to perform it. - let (row, state, _retired_sidecar) = match rows.as_slice() { - [] => { - let state = PlayTurnStoreState { - schema: STORE_SCHEMA.into(), - turn: None, - ledger: KeyLedger::default(), - revision: 0, - }; - let row = envelope(&state)?; - if !store.compare_and_swap_batch(&[], vec![row.clone()])? { - bail!("play-turn store changed during initialization"); - } - (row, state, None) - } - // PA.f184: a row already at the current schema id can still fail - // to read — genuinely corrupt bytes, or (the realistic trigger) a - // future change to `PlayTurn`'s field set or order that lands - // without moving `STORE_SCHEMA`, which flips the canonical - // re-encode check below without touching `schema_id` at all. - // Before this fix every one of those three checks `bail!`ed - // unconditionally, so `open` failed the identical way on every - // future restart and the play card stayed gone until an operator - // deleted the store by hand — exactly the PA.f177 gap, but for a - // row this store itself is supposed to already understand. Now it - // retires through the same `retire_unreadable_row` path the - // legacy arms use, so a damaged or future-schema row loses its - // one turn instead of the whole play surface. - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(STORE_SCHEMA) => - { - match read_current_schema_row(row) { - Ok(state) => (row.clone(), state, None), - Err(CurrentSchemaRowError::Decode(error)) => { - let (row, state, sidecar) = - retire_unreadable_row(&store, path.as_ref(), row, STORE_SCHEMA, &error)?; - (row, state, Some(sidecar)) - } - // PA.f193-A/B: not a decode failure — the row's bytes - // parsed into a `PlayTurnStoreState` perfectly fine, and - // either the canonical re-encode check or the inner - // `schema` tag disagreed. The row is left untouched and - // `open` fails loudly, the same shape `upgrade_legacy_row` - // already uses for its own `Other` arm (PA.f185), instead - // of destroying a turn that decoded just fine. - Err(CurrentSchemaRowError::Other(error)) => { - return Err(error).context("reading a current-schema play-turn row failed"); - } - } - } - // PA.f172/PA.f170: a row written before `question_token` existed - // — named legacy, not corrupt, because it is neither: it is a - // real, previously-canonical shape this process itself wrote, - // under the schema id that was current when it did. - // `question_token` carries `#[serde(default)]`, so the very same - // `PlayTurnStoreState` decodes it correctly (the field reads back - // `None`, matching what "no question ever recorded a token" - // means for a turn this old). `upgrade_legacy_row` mints a fresh - // token when the recovered turn's own question is still open, - // upgrades the row to `STORE_SCHEMA` (v3) in place, and persists - // that upgrade immediately, so the legacy path is only ever taken - // once per row; the canonical re-encode check does not apply - // here — a legacy row's bytes were never canonical under v3 and - // were never meant to be — it resumes for every write this store - // makes from here on. - // PA.f177: `upgrade_legacy_row`'s own decode can still fail — a - // row older than `applied_keys`/`fault` themselves, or one that - // is genuinely damaged — even though the schema id names it - // recognizably legacy. That is no longer treated as "open must - // fail and every future open must fail on the exact same row"; - // the row is retired to a sidecar file and the store starts - // fresh instead (see `retire_unreadable_row`). - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V1) => - { - match upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V1) { - Ok((row, state)) => (row, state, None), - Err(LegacyRowError::Decode(error)) => { - let (row, state, sidecar) = retire_unreadable_row( - &store, - path.as_ref(), - row, - LEGACY_STORE_SCHEMA_V1, - &error, - )?; - (row, state, Some(sidecar)) - } - // PA.f185: not a decode failure — an inner `schema` tag - // that disagrees with the envelope, or a lost - // compare-and-swap race — so the row is left untouched - // and `open` fails loudly instead of destroying a turn - // that decoded just fine. - Err(LegacyRowError::Other(error)) => { - return Err(error).context("upgrading a legacy play-turn row failed"); - } - } - } - // PA.f170: a row written between PA.f172 and PA.f170 — it has - // `question_surface_version` (deleted, silently ignored on - // decode since nothing here reads it) but no `question_token`. - // Upgraded exactly like a v1 row above; PA.f177's own retirement - // fallback applies here too. - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V2) => - { - match upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V2) { - Ok((row, state)) => (row, state, None), - Err(LegacyRowError::Decode(error)) => { - let (row, state, sidecar) = retire_unreadable_row( - &store, - path.as_ref(), - row, - LEGACY_STORE_SCHEMA_V2, - &error, - )?; - (row, state, Some(sidecar)) - } - Err(LegacyRowError::Other(error)) => { - return Err(error).context("upgrading a legacy play-turn row failed"); - } - } - } - // PA.f184/PA.f194-A: our own key and type, but a schema id none - // of the arms above recognize — a future schema this build has - // never seen, not foreign data. That alone does not mean the row - // is unreadable: an Idunn rollback across a schema-id move can - // hand this build a row stamped with a schema id it has never - // seen whose bytes still decode cleanly under the current - // `PlayTurnStoreState` shape (nothing about the struct changed, - // only the id). Before this fix, every unrecognized schema id - // was retired on the id alone, with no decode attempted — a - // canonical, perfectly readable row lost its one open turn, and - // the sidecar's own `reason` claimed the row "could not be read" - // when nothing had tried. Attempt the decode first, exactly like - // the current- and legacy-schema arms above: retire only on a - // genuine decode failure; a row that decodes cleanly is left - // untouched and `open` fails loudly instead, so an operator sees - // the mismatch rather than losing the turn silently. Multiple - // rows, or a row under a different `type`/`key` entirely, still - // bail below: that is genuinely foreign content this store must - // not guess about destroying. - [row] if row.r#type == STORE_TYPE && row.key == STORE_KEY => { - match rmp_serde::from_slice::(&row.payload) { - Ok(_) => { - return Err(anyhow!( - "play-turn row declares unrecognized schema {:?} but its payload decodes \ - cleanly under the current PlayTurnStoreState shape; refusing to retire a \ - readable row (PA.f194-A) — open a build that recognizes this schema", - row.schema_id - )); - } - Err(decode_error) => { - let reason = anyhow::Error::new(decode_error).context(format!( - "play-turn row declares unrecognized schema {:?}", - row.schema_id - )); - let (row, state, sidecar) = retire_unreadable_row( - &store, - path.as_ref(), - row, - row.schema_id.as_deref().unwrap_or(""), - &reason, - )?; - (row, state, Some(sidecar)) - } - } - } - _ => bail!("play-turn store contains foreign or otherwise-unreadable records; start with a fresh store"), - }; - Ok(Self { - store, - row, - state, - healthy: true, - }) - } - - fn current(&self) -> Option<&PlayTurn> { - self.state.turn.as_ref() - } - - /// PA.f99's own test seam: re-reads the current turn through a fresh - /// `pull_all` against the *same already-owned* redb `Database` this - /// store already holds — never the cached `self.state` field a mutation - /// could leave stale, and never a second `OwnedRedbMessagePackBackingStore::new` - /// on the same path, which CultCache's own single-owner lock refuses - /// outright (`redb CultCache already has an active owner`): this - /// crate learned that the hard way building this very seam. `pull_all` - /// opens its own fresh redb read transaction against the one open - /// `Database`, so this reflects exactly what the last successful - /// `compare_and_swap_batch` durably committed, independent of whether - /// `self.state` was ever updated at all. - #[cfg(test)] - fn pull_current_from_disk_for_test(&self) -> Option { - let rows = self.store.pull_all().ok()?; - let row = rows.into_iter().find(|row| row.r#type == STORE_TYPE && row.key == STORE_KEY)?; - let state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload).ok()?; - state.turn - } - - /// The `turn_id` of the turn `key` was already applied to, if any - /// (PA.f109): the still-open current turn, when `key` is recorded on it, - /// or the ledger's own archived record of one of the most recent - /// `KEY_LEDGER_WINDOW` closed turns (PA.f83). Either way, `run` treats a - /// known key as a no-op — a `Replayed` outcome naming the turn it - /// replayed, not a fresh open. - fn turn_id_for_key(&self, key: &str) -> Option { - if let Some(turn) = &self.state.turn - && turn.applied_keys.iter().any(|existing| existing == key) - { - return Some(turn.turn_id.clone()); - } - self.state.ledger.turn_id_for(key) - } - - /// Persists `turn` as an update to the store's own still-open current - /// turn: the ledger is carried forward unchanged, because no turn is - /// closing out of the row here. - fn commit(&mut self, turn: PlayTurn) -> anyhow::Result<()> { - self.ensure_owned()?; - self.swap_in(Some(turn), self.state.ledger.clone()) - } - - /// Opens `turn` as the store's new current turn (PA.f83): whatever turn - /// currently occupies the row — always `Closed`, by `PlayTable::run`'s - /// own invariant that only a closed turn (or no turn) is ever replaced — - /// has its own applied keys archived into the ledger first, so a later - /// replay of one of them is still recognized after its full record is - /// gone. - fn open_new_turn(&mut self, turn: PlayTurn) -> anyhow::Result<()> { - self.ensure_owned()?; - let mut ledger = self.state.ledger.clone(); - if let Some(previous) = &self.state.turn { - ledger.push_closed(previous.turn_id.clone(), previous.applied_keys.clone()); - } - self.swap_in(Some(turn), ledger) - } - - /// The row's one write door (Cut 9): every committed write — `commit`'s - /// own mid-turn updates and `open_new_turn`'s own turn replacements alike - /// — passes through here, and `revision` moves exactly once per call, - /// here alone, so no call site can under- or double-count it. - fn swap_in(&mut self, turn: Option, ledger: KeyLedger) -> anyhow::Result<()> { - let next = PlayTurnStoreState { - schema: STORE_SCHEMA.into(), - turn, - ledger, - revision: self.state.revision + 1, - }; - 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)) { - self.healthy = false; - bail!("play-turn commit outcome is uncertain; sole ownership was lost"); - } - self.row = next_row; - self.state = next; - Ok(()) - } - - fn ensure_owned(&mut self) -> anyhow::Result<()> { - if !self.healthy { - bail!("play-turn custody is poisoned; reopen the owner"); - } - if self.store.validate_path_identity().is_err() { - self.healthy = false; - bail!("play-turn store ownership was lost"); - } - Ok(()) - } -} - -fn envelope(state: &PlayTurnStoreState) -> anyhow::Result { - 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()), - }) -} - -/// Mints a fresh opaque per-question answer token (PA.f170): a random handle -/// with no world meaning of its own — not a `QuestionId`, not a subject, -/// turn, round, or slot, and forging one only answers the forger's own -/// already-open question (invariant 8). `execute_round`'s `ASK_PLAYER_TOOL` -/// arm mints one every time a question opens; `upgrade_legacy_row` mints one -/// for a recovered legacy row whose question is still open, since no earlier -/// schema ever recorded one. -fn mint_question_token() -> String { - uuid::Uuid::new_v4().to_string() -} - -/// `upgrade_legacy_row`'s own error, split by whether the row was actually -/// unreadable (PA.f185). Soul's probe: a row whose envelope declares -/// `LEGACY_STORE_SCHEMA_V1` and whose inner `schema` field says -/// `LEGACY_STORE_SCHEMA_V2` decodes its turn perfectly — `opening_prompt`, -/// `question`, every field reads back intact — but the inner-schema -/// consistency check below, and a lost `compare_and_swap_batch` race while -/// persisting the upgrade, both used to be indistinguishable from "the bytes -/// would not parse at all" once `PlayTurnStore::open` caught the `Err`: every -/// exit landed in `retire_unreadable_row`, destroying a readable turn under a -/// sidecar reason ("did not decode even leniently") that was false for -/// either case. Only `Decode` is that genuine case; `Other` propagates as an -/// ordinary `open` failure instead, so a schema-tag mismatch is diagnosable -/// and a transient CAS race can be retried, rather than both being treated as -/// permanent corruption and quietly erased. -enum LegacyRowError { - Decode(anyhow::Error), - Other(anyhow::Error), -} - -/// Shared by `PlayTurnStore::open`'s `LEGACY_STORE_SCHEMA_V1` and -/// `LEGACY_STORE_SCHEMA_V2` branches (PA.f170): decodes `row` under the -/// current `PlayTurnStoreState`/`PlayTurn` structs — both legacy schemas -/// decode through exactly the same `#[serde(default)]` fields a current- -/// schema row would — mints a fresh `question_token` when the recovered -/// turn's own question is still open (no earlier schema ever recorded one), -/// then upgrades the row to `STORE_SCHEMA` in place and persists that -/// upgrade immediately, so the legacy path is only ever taken once per row. -fn upgrade_legacy_row( - store: &OwnedRedbMessagePackBackingStore, - row: &CultCacheEnvelope, - legacy_schema: &str, -) -> Result<(CultCacheEnvelope, PlayTurnStoreState), LegacyRowError> { - let mut state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload) - .with_context(|| format!("play-turn legacy {legacy_schema} state is corrupt")) - .map_err(LegacyRowError::Decode)?; - if state.schema != legacy_schema { - return Err(LegacyRowError::Other(anyhow!( - "play-turn legacy {legacy_schema} state schema is invalid: payload itself declares {:?}", - state.schema - ))); - } - if let Some(turn) = state.turn.as_mut() - && turn.state == PlayTurnState::AwaitingPlayer - && turn.question.is_some() - && turn.question_token.is_none() - { - turn.question_token = Some(mint_question_token()); - } - state.schema = STORE_SCHEMA.into(); - let upgraded_row = envelope(&state).map_err(LegacyRowError::Other)?; - match store.compare_and_swap_batch(std::slice::from_ref(row), vec![upgraded_row.clone()]) { - Ok(true) => Ok((upgraded_row, state)), - Ok(false) => Err(LegacyRowError::Other(anyhow!( - "play-turn store changed while upgrading a legacy {legacy_schema} row" - ))), - Err(error) => Err(LegacyRowError::Other(error)), - } -} - -/// `read_current_schema_row`'s own error, split the same way -/// `upgrade_legacy_row`'s is (PA.f185, here PA.f193-A/B): only a genuine -/// decode failure means the row is actually unreadable. A row whose bytes -/// parse into a `PlayTurnStoreState` but fail the canonical re-encode check, -/// or whose inner `schema` field disagrees with the envelope's own -/// `STORE_SCHEMA`, has a perfectly readable turn inside it — before this fix -/// both cases reached `PlayTurnStore::open` as a plain `anyhow::Error` and -/// were routed into `retire_unreadable_row` exactly like a genuine decode -/// failure, destroying a turn that decoded fine. Only `Decode` is that -/// genuine case; `Other` propagates as an ordinary `open` failure instead, -/// leaving the row untouched. -enum CurrentSchemaRowError { - Decode(anyhow::Error), - Other(anyhow::Error), -} - -/// `PlayTurnStore::open`'s current-schema arm (PA.f184): a row already -/// declaring `STORE_SCHEMA` can still fail to read — genuinely corrupt bytes, -/// or a future change to `PlayTurn`'s own field set or order that lands -/// without moving `STORE_SCHEMA`, which the canonical re-encode check below -/// exists to catch. Unlike `upgrade_legacy_row` (PA.f185), there is no -/// upward-migration path for a current-schema row that fails one of these — -/// nothing upgrades it in place — but that does not make every exit here -/// retirement-worthy: the canonical check and the inner-schema check both run -/// only after the decode has already succeeded, so a row that fails either -/// one is readable, not corrupt (PA.f193-A/B). -fn read_current_schema_row(row: &CultCacheEnvelope) -> Result { - let state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload) - .context("play-turn state is corrupt") - .map_err(CurrentSchemaRowError::Decode)?; - let reencoded = rmp_serde::to_vec_named(&state) - .context("re-encoding play-turn state failed") - .map_err(CurrentSchemaRowError::Other)?; - if reencoded != row.payload { - return Err(CurrentSchemaRowError::Other(anyhow!("play-turn state is not canonical"))); - } - if state.schema != STORE_SCHEMA { - return Err(CurrentSchemaRowError::Other(anyhow!("play-turn state schema is invalid"))); - } - Ok(state) -} - -/// A legacy row's payload, `type`, `key` and `schema_id`, preserved for -/// whoever the operator hands `retire_unreadable_row`'s sidecar file to -/// (Soul's owed item 5). This is the one deliberate exception to the GameCult -/// substrate doctrine's "no load-bearing JSON": the doctrine's own carve-out -/// is content that exists to communicate with "the Non-CultNet xenos", and an -/// operator opening a forensic dump by hand with a text editor is exactly -/// that boundary, not a CultCache consumer. The msgpack form this replaced -/// was preservation in name only — nothing in this Body could read it back, -/// an operator included, without writing a one-off decoder first. JSON here -/// is readable directly; `payload_base64` carries the row's original bytes -/// verbatim (standard, padded alphabet) so nothing is lost, proven by the -/// round-trip test below. -#[derive(Serialize)] -struct RetiredPlayTurnRow<'a> { - retired_at: String, - reason: String, - r#type: &'a str, - key: &'a str, - schema_id: Option<&'a str>, - stored_at: &'a str, - payload_base64: String, -} - -/// `PlayTurnStore::open`'s fallback when a row this build recognizes by its -/// `schema_id` (legacy or current) still cannot be turned into a usable -/// `PlayTurnStoreState` — a row older than `applied_keys`/`fault` themselves -/// (PA.f177), one genuinely damaged past what a schema-shape upgrade can -/// recover, a current-schema row whose bytes no longer round-trip -/// canonically (PA.f184), or a schema id no arm above recognizes at all. Before -/// PA.f177 this row made `open` return `Err` unconditionally: `open_play` -/// caught that, logged it, and left `state.play` `None` — every future `open` -/// hit the identical row and failed the identical way, so the play card -/// stayed gone until an operator deleted the store by hand. The invariant is -/// that a daemon never answers an unreadable row by quietly having no play -/// surface, so this writes the row, byte for byte, to a sidecar file beside -/// the store (named for what it is, so it reads as forensic evidence and not -/// as another store — PA.f192: the extension is `.json`, not `.cc`, since -/// this sidecar is a one-shot forensic dump, never a CultCache store some -/// future process might mistake it for and try to open; Soul's owed item 5: -/// the content really is JSON, an operator-readable text file with the row's -/// original bytes preserved verbatim as base64 — see `RetiredPlayTurnRow`'s -/// own doc comment for why JSON here does not violate the GameCult substrate -/// doctrine), clears the row -/// through the store's own compare-and-swap (the same guard `swap_in` uses -/// for every other write — this is not a second, unguarded writer), and -/// returns a fresh, empty state so `open` succeeds and play starts fresh. -/// Logged at `warn`, so the fact reaches the operator through the same log -/// stream `runtime.rs`'s own `open_play` failure warning already uses, next -/// to the `playStatus` the operator already checks (PA.f193-E: -/// `PlayTable::retired_row_sidecar` reports it structurally too, by scanning -/// the store's own directory fresh on every read rather than trusting a flag -/// cached at `open` — see that method's own doc comment for why a cached -/// per-process flag is not enough for `/health` to survive a restart). -/// -/// PA.f189: two orderings matter here and are both deliberate. -/// - The sidecar write happens *before* the destructive compare-and-swap. -/// If the write fails, this returns `Err` without ever touching the row — -/// `open` fails loudly and the original row is exactly where it was, so a -/// disk-full or permission failure never destroys evidence it could not -/// also preserve. -/// - If the write succeeds but the compare-and-swap below then fails — the -/// store changed identity mid-retirement, another writer raced this one, -/// or custody was lost — this still returns `Err`. The sidecar file it -/// already wrote is *not* cleaned up: nothing here can tell whether the -/// race means the original row is still there (safe to retry retirement, -/// which would then write a second, differently-timestamped sidecar) or -/// already gone some other way. An orphaned sidecar from a failed -/// retirement is therefore an expected, if rare, shape — a forensic -/// leftover with no corresponding successful retirement — and an operator -/// clearing them out should confirm the row it names is actually resolved -/// (upgraded, retired again, or the store rebuilt) before deleting one. -/// -/// **Precondition (PA.f194-A):** the caller must have already attempted to -/// decode `row`'s payload and that attempt must have failed — `decode_error` -/// is trusted verbatim into the sidecar's own `reason` field ("could not be -/// read: ..."), so a caller that has not actually tried to read the row makes -/// that claim false. This function performs no decode of its own to verify -/// it. An unrecognized `schema_id` alone is not evidence of unreadability: a -/// row can declare a schema this build has never seen and still decode -/// cleanly under the current `PlayTurnStoreState` shape (a future schema -/// built from an identical struct, surfaced by an Idunn rollback across a -/// schema-id move). `PlayTurnStore::open`'s own unrecognized-schema arm -/// attempts a decode first and only calls this on genuine decode failure, -/// exactly like its current- and legacy-schema siblings. -fn retire_unreadable_row( - store: &OwnedRedbMessagePackBackingStore, - store_path: &Path, - row: &CultCacheEnvelope, - declared_schema: &str, - decode_error: &anyhow::Error, -) -> anyhow::Result<(CultCacheEnvelope, PlayTurnStoreState, PathBuf)> { - let reason = format!("row declared schema {declared_schema} but could not be read: {decode_error:#}"); - let sidecar = retired_row_sidecar_path(store_path); - let record = RetiredPlayTurnRow { - retired_at: Utc::now().to_rfc3339(), - reason: reason.clone(), - r#type: &row.r#type, - key: &row.key, - schema_id: row.schema_id.as_deref(), - stored_at: &row.stored_at, - payload_base64: base64::engine::general_purpose::STANDARD.encode(&row.payload), - }; - // Pretty-printed (Soul's owed item 5): this file exists for an operator - // to open and read, not for a machine to round-trip efficiently, so - // legibility wins over byte count. - let bytes = serde_json::to_vec_pretty(&record).context("encoding the retired play-turn row failed")?; - std::fs::write(&sidecar, &bytes) - .with_context(|| format!("writing the retired play-turn row to {} failed", sidecar.display()))?; - - let fresh = PlayTurnStoreState { - schema: STORE_SCHEMA.into(), - turn: None, - ledger: KeyLedger::default(), - revision: 0, - }; - let fresh_row = envelope(&fresh)?; - if !store.compare_and_swap_batch(std::slice::from_ref(row), vec![fresh_row.clone()])? { - bail!("play-turn store changed while retiring an unreadable row (the sidecar at {} was already written and is left in place)", sidecar.display()); - } - - tracing::warn!( - sidecar = %sidecar.display(), - reason = %reason, - "a play-turn row could not be read; it was retired to a sidecar file and play starts fresh" - ); - - Ok((fresh_row, fresh, sidecar)) -} - -/// Beside the store, named for what it is: -/// `.retired-unreadable-row..json` (PA.f192, -/// extension renamed from `.msgpack` for Soul's owed item 5, since the -/// content is now genuinely JSON — see `RetiredPlayTurnRow`'s own doc -/// comment). Timestamped so a second unreadable row (a different corruption, -/// on a later run) never collides with or silently overwrites the first -/// one's evidence, and so `retired_row_sidecars_on_disk`'s own lexicographic -/// sort is also chronological. Not `.cc`: this file is a one-shot forensic -/// dump written through `std::fs::write`, never through a -/// `CacheBackingStore`, and never meant to be opened as a CultCache store. -fn retired_row_sidecar_path(store_path: &Path) -> PathBuf { - let stamp = Utc::now().format("%Y%m%dT%H%M%S%.6fZ"); - let stem = store_path - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or("play-turn"); - store_path.with_file_name(format!("{stem}.retired-unreadable-row.{stamp}.json")) -} - -/// PA.f193-E: every sidecar `retire_unreadable_row` has ever written beside -/// `store_path`, oldest first (the timestamp sorts lexicographically, since -/// `retired_row_sidecar_path`'s own stamp format is fixed-width and -/// zero-padded). A fresh directory scan on every call, not a fact cached once -/// at `open` — the whole point is that this survives a restart: a clean -/// reopen after a retirement finds no unreadable row of its own to retire, -/// but the *evidence* that a prior process retired one is still sitting on -/// disk until an operator clears it. Filters by this store's own filename -/// stem so a second, unrelated `.cc` store's sidecars in the same directory -/// are never mixed in. -fn retired_row_sidecars_on_disk(store_path: &Path) -> Vec { - let Some(directory) = store_path.parent() else { - return Vec::new(); - }; - let Some(stem) = store_path.file_stem().and_then(|stem| stem.to_str()) else { - return Vec::new(); - }; - let prefix = format!("{stem}.retired-unreadable-row."); - let Ok(entries) = std::fs::read_dir(directory) else { - return Vec::new(); - }; - let mut sidecars: Vec = entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".json")) - }) - .collect(); - sidecars.sort(); - sidecars -} - -#[derive(Debug, thiserror::Error)] -pub(crate) enum PlayError { - #[error("the play table is poisoned by a prior kernel invariant fault; restart to clear it")] - Poisoned, - #[error("world mailbox error: {0}")] - World(#[from] MailboxError), - #[error("controller error: {0}")] - Controller(#[from] ControllerError), - #[error("play store error: {0}")] - Store(#[from] anyhow::Error), - /// PA.f134: carries the turn's own still-open question — the same one - /// the caller was already answering, since this refusal never changes - /// it — so a consumer can retry without re-reading the store. - #[error("an empty or whitespace-only answer is refused")] - EmptyAnswer { turn_id: String, question: OpenQuestion }, - /// PA.f84: `request.answers` did not name the turn's own currently open - /// `QuestionId` — either it named a different, no-longer-open question - /// (a late retry), or no question is open at all while the turn is - /// `AwaitingPlayer`, which cannot actually happen but is refused the - /// same way rather than assumed away. - /// - /// PA.f134: carries the turn's own *actually* currently open question - /// (`None` only in the "cannot actually happen" case), so a caller who - /// named a stale question can retry against the real one without - /// re-reading the store. - #[error("the answer does not name the currently open question; it is refused as stale")] - StaleAnswer { - turn_id: String, - question: Option, - }, - /// PA.f84: `request.answers` was given while no question is open at all - /// — the turn is `Running`, or there is no open turn to answer. - #[error("an answer was given but no question is currently open")] - NoQuestionOpen, - /// PA.f193-D: `admit`'s own `run_lock` was already held by another call's - /// own round loop when this request arrived — refused before request - /// text or the turn's own state are even inspected. An empty continue - /// submitted into this exact window is refused the same way a non-empty - /// one is, which is what actually distinguishes this from - /// `TurnStillRunning` below: nothing about the request being empty makes - /// it succeed here. Before this split both cases shared `TurnStillRunning`'s - /// text, which told a player who had just submitted the empty continue - /// the card itself hinted at that only an empty continue is accepted — - /// while refusing that exact empty continue for an unrelated reason. - #[error("a prior request for this turn is still being processed; retry shortly")] - TurnBusy, - /// PA.f84: the turn is `Running`, `admit`'s own `run_lock` was free (this - /// call is the only one touching the turn), and `request.text` is - /// non-empty; only an empty continue is accepted while a prior request's - /// own rounds are still being resolved. PA.f193-D: this is the only - /// `PlayError` for which that sentence is actually true — a request that - /// instead finds `run_lock` itself held is refused as `TurnBusy` above, - /// before text is ever inspected. - #[error("the turn is still running its own prior request; only an empty continue is accepted")] - TurnStillRunning, - /// PA.f108: empty or whitespace-only text with no turn currently open to - /// continue (no turn at all, or the current one is `Closed`) used to - /// open a fresh turn anyway, with prose `[""]`, and run a full inference - /// over nothing the player actually said. A new turn needs the player's - /// own words. - #[error("a new turn needs non-empty text; an empty or whitespace-only opening is refused")] - EmptyOpening, -} - -/// A turn's own open question, and the identity a caller answers it by -/// (PA.f134): `id` is the exact `QuestionId` `PlayRequest::answers` expects -/// back; `text` is the question itself, so a consumer never has to read the -/// store to learn either. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct OpenQuestion { - pub(crate) id: QuestionId, - pub(crate) text: String, - /// PA.f170: the opaque per-question answer token minted when this - /// question opened — the value `runtime.rs`'s `world.play` arm compares - /// an answer's own `answerToken` against, refusing anything else as - /// stale. Replaces PA.f161's `surface_version`, which only ever told - /// apart questions that opened on different world revisions, not two - /// questions opened in the same turn. - /// - /// `Option`, not `String` (PA.f179): `None` means no token was - /// ever minted for this question — a state the gate must refuse - /// outright, never compare against an answer as if `""` were a real - /// token. Collapsing that case into `String::default()` is exactly what - /// let a defeated mint degrade the gate to accepting a forged empty - /// `answerToken`. - pub(crate) token: Option, -} - -/// One `run` call's own outcome (PA.f109): `Replayed` names the turn a -/// request key already known — recorded on the still-open current turn, or -/// archived in the store's own `KeyLedger` — replayed, so `ClosedTurnKeys`'s -/// own `turn_id` field, written on every archive, finally has a reader. -/// -/// PA.f134: both variants also carry the turn's own currently open question, -/// when one is open, so a caller who reaches `ask_player` can answer it -/// without a second, separate read of the store — `run`'s own return value -/// already names everything `PlayRequest::answers` needs. `world.play`'s own -/// route still cannot hand this back to its HTTP caller (it spawns `run` in -/// a task and answers before the turn even opens); `PlayTable::current_turn_view` -/// is what a later, separate read answers from instead. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum RunOutcome { - Ran { - turn_id: String, - question: Option, - }, - Replayed { - turn_id: String, - question: Option, - }, -} - -/// `PlayTable::admit`'s own outcome (Cut 10, PA.f153/PA.f154): a replay is -/// fully decided already — no round ever runs, so nothing is ever spawned -/// for it — while `Spawn` carries the built, already-persisted turn a caller -/// hands to `execute` in a background task. -pub(crate) enum Admission { - Replayed { - turn_id: String, - question: Option, - }, - Spawn(Admitted), -} - -/// An admitted turn, ready for `PlayTable::execute` (Cut 10). Carries the -/// table's own `run_lock` guard (owned, not borrowed — see `run_lock`'s own -/// doc comment) so the lock stays held from admission through the end of -/// execution even when the two happen in different tasks. -pub(crate) struct Admitted { - turn: PlayTurn, - _run_guard: tokio::sync::OwnedMutexGuard<()>, -} - -/// The current turn's own player-facing state (PA.f134), read through -/// `PlayTable::current_turn_view` — see its own doc comment for why this -/// exists beside `RunOutcome`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PlayTurnView { - pub(crate) turn_id: String, - pub(crate) state: PlayTurnState, - pub(crate) question: Option, - pub(crate) narration: Option, - pub(crate) refusal: Option, - /// The play row's own monotonic counter, read straight off the store. - /// `eve::authenticated_wake_version` folds it with `surface_version` to - /// wake the SSE path on a play-row-only change (a fresh refusal, a newly - /// open question, a closed turn's narration) that moves nothing on the - /// world itself. It is never folded into the surface document's own - /// `version` field, which names `surface_version` alone. - pub(crate) revision: u64, - /// PA.f193-D: whether `admit`'s own `run_lock` is held right now — a - /// non-blocking peek, not a fresh acquisition, taken by - /// `current_turn_view` alongside everything else it reads. Lets - /// `eve::authenticated_surface` show the "submit an empty message to - /// continue" hint only for a turn nothing is currently working on: - /// showing it while a round is genuinely in flight told a player to do - /// the exact thing `admit`'s own `try_lock_owned` was about to refuse as - /// `TurnBusy` anyway. - pub(crate) run_in_progress: bool, - /// Whether the turn closed on a fault (`PlayTurn::fault` is set). The - /// detail stays in the stored row and the log; the card shows a fixed - /// player-safe line, never the detail (invariant 8, PA.f187). - pub(crate) fault: bool, -} - -/// One outcome of executing a round's already-decided tool calls. -#[derive(Clone, Copy)] -enum RoundOutcome { - /// Every call in the round resolved; the loop should infer the next one. - Continue, - AwaitingPlayer, - Closed, - /// A kernel `Invariant` fault closed the turn and poisoned the table. - Poisoned, -} - -/// The play table (Cut 8): one operational model running a role-playing game -/// over a Ghostlight world. `play` is Dungeon's one `PlayPort`, minted here -/// and nowhere else in production (negative grep, matching `PlayPort`'s own -/// doc comment). -pub(crate) struct PlayTable { - play: PlayPort, - world: WorldMailbox, - /// `Arc`, not owned (PA.f13/P8.5): a round's own dispatched Personas run - /// concurrently, each in its own spawned task under a permit drawn from - /// `permits`, so every concurrent call needs its own handle to the same - /// lane rather than a borrow of one `self`. - personas: Arc, - inference: Arc, - model: String, - /// The shared controller/Persona concurrency pool (PA.f13, P8.5): - /// `execute_dispatch` and `close_turn`'s own narration draw from the - /// exact same `Arc` `runtime.rs` hands `AppState`'s - /// `controller_permits` — the one pool `runtime_readiness`'s - /// `controllerStatus` reads — never a second pool this table mints for - /// itself. - permits: Arc, - store: Mutex, - /// PA.f186/PA.f193-E: the store's own on-disk path, kept beside `store` - /// so `retired_row_sidecar` can scan for sidecars without taking - /// `store`'s lock. Not the fact of a retirement itself — that is derived - /// fresh from disk on every read, on purpose: a cached "did this - /// process's own `open` retire something" flag reported `ok` again after - /// any restart, even though the sidecar evidence a prior process wrote - /// was still sitting right there. - store_path: PathBuf, - /// One turn at a time for this table (PA.f68/PA.f112): held for the - /// whole body of `run`, so the key check and the turn open happen under - /// one acquisition and two concurrent `run` calls can never interleave. - /// This table is the one owner of one world's play authority, so this is - /// "one running turn per world" exactly as `AppState.play` holds one - /// `Arc` per world this process owns. - /// `Arc`-wrapped since Cut 10 (PA.f153/PA.f154): `admit` takes an owned - /// guard (`try_lock_owned`) so it can hand the guard into a spawned task - /// alongside the turn it built, rather than holding a borrowed guard for - /// a call that must return to its own caller before the turn's round loop - /// finishes. - run_lock: Arc>, - poisoned: AtomicBool, - /// `retry_delay`'s own base, in milliseconds (PA.f122): an ordinary - /// field, always compiled, defaulting to `RETRY_DELAY_BASE_MS` — the - /// same production base `backoff_delay` always scaled from before this - /// field existed. Only a test ever changes it, through - /// `set_retry_delay_base_ms`, so a test's own retries settle instantly - /// without a `#[cfg(test)]` swap over the delay formula itself. - retry_delay_base_ms: std::sync::atomic::AtomicU64, - /// PA.f99's own test seam: fired immediately before each of the four - /// doors that submit or persist a call's already-recorded state - /// (`commit_authoring_run`, `execute_advance_time`, `execute_actor_call`, - /// and, since PA.f123, `execute_dispatch` right after each dispatched - /// subject's own turn is persisted) moves on. It hands the hook the - /// current turn as a fresh `pull_all` against the store's own - /// already-owned redb `Database` finds it - /// (`PlayTurnStore::pull_current_from_disk_for_test`) — not the store's - /// cached `self.state`, and not a second, independently opened store, - /// which CultCache's own single-owner lock refuses outright. No - /// production call installs one. - #[cfg(test)] - before_submit_hook: std::sync::Mutex) + Send + Sync>>>, - /// PA.f127/PA.f133's own test seam: see `fetch_round_snapshot`. - #[cfg(test)] - forced_round_snapshot: std::sync::Mutex>, -} - -impl PlayTable { - pub(crate) fn new( - world: WorldMailbox, - personas: PersonaLane, - inference: Arc, - model: String, - permits: Arc, - store_path: impl AsRef, - ) -> anyhow::Result { - let play = PlayPort::new(world.clone()); - let store_path = store_path.as_ref().to_path_buf(); - let store = PlayTurnStore::open(&store_path)?; - Ok(Self { - play, - world, - personas: Arc::new(personas), - inference, - model, - permits, - store: Mutex::new(store), - store_path, - run_lock: Arc::new(Mutex::new(())), - poisoned: AtomicBool::new(false), - retry_delay_base_ms: std::sync::atomic::AtomicU64::new(RETRY_DELAY_BASE_MS), - #[cfg(test)] - before_submit_hook: std::sync::Mutex::new(None), - #[cfg(test)] - forced_round_snapshot: std::sync::Mutex::new(None), - }) - } - - /// PA.f186/PA.f193-E: the most recent sidecar this store's directory - /// carries, if any — not only one this process's own `open` wrote. - /// Scanned fresh from disk on every call (`retired_row_sidecars_on_disk`, - /// one directory listing), so `runtime.rs`'s `/health` surface reports a - /// retirement across a restart: a clean reopen after a retirement finds - /// no unreadable row of its own, but the sidecar a prior process wrote is - /// still on disk until an operator clears it, and this keeps naming it. - /// `None` once every sidecar for this store has actually been cleared — - /// a healthy daemon still reports plain `ok`. - pub(crate) fn retired_row_sidecar(&self) -> Option { - retired_row_sidecars_on_disk(&self.store_path).pop() - } - - /// Installs PA.f99's own before-submit test hook (see the field's own - /// doc comment). Test-only: no production call site exists. - #[cfg(test)] - fn set_before_submit_hook(&self, hook: impl Fn(Option) + Send + Sync + 'static) { - *self.before_submit_hook.lock().unwrap() = Some(Box::new(hook)); - } - - #[cfg(test)] - async fn fire_before_submit_hook(&self) { - let installed = self.before_submit_hook.lock().unwrap().is_some(); - if !installed { - return; - } - let durable_turn = self.store.lock().await.pull_current_from_disk_for_test(); - if let Some(hook) = self.before_submit_hook.lock().unwrap().as_ref() { - hook(durable_turn); - } - } - - #[cfg(not(test))] - async fn fire_before_submit_hook(&self) {} - - /// One `world.play` turn. `key` is the caller's own idempotency key - /// (PA-Q8, PA.f62/PA.f63/PA.f83): it is not the turn's identity — the - /// turn mints its own `turn_id` when it opens. A key already known — - /// recorded on the currently open turn, or archived in the store's own - /// `KeyLedger` from one of the most recent 64 closed turns — is a replay - /// and is a no-op, making zero inference calls. A fresh key reaching an - /// open turn continues it: as the player's own answer, when it is - /// `AwaitingPlayer` and `request.answers` names that turn's own currently - /// open question (PA.f84; any other `answers`, or none at all, is - /// refused as stale rather than being applied to whatever question - /// happens to be open), or as a plain continue when it is `Running` and - /// `request.text` is empty (non-empty text is refused: this turn is - /// still running its own prior request and drops nothing silently). A - /// fresh key reaching no turn, or a closed one, opens a new turn under - /// that key (Cut 8a's "one row": only the current turn's own record is - /// ever kept, so the closed turn's record is archived into the ledger - /// and then discarded). - pub(crate) async fn run( - &self, - principal: &VerifiedPrincipalEvidence, - key: String, - request: PlayRequest, - ) -> Result { - match self.admit(key, request).await? { - Admission::Replayed { turn_id, question } => Ok(RunOutcome::Replayed { turn_id, question }), - Admission::Spawn(admitted) => self.execute(admitted, principal).await, - } - } - - /// Cut 10 (PA.f153/PA.f154): the synchronous half of `run` — replay - /// detection, the stale-answer/empty-answer/no-question-open/ - /// turn-still-running/empty-opening refusals, and building (and - /// persisting) the turn a fresh request continues or opens. None of this - /// spends an inference budget, so `runtime.rs::execute_world`'s - /// `world.play` route now awaits this directly and answers `denied` with - /// the reason on `Err`, instead of discarding every refusal inside a - /// spawned task that had already answered `accepted`. Only - /// `Admission::Spawn`'s own turn actually runs a round loop, and only - /// that is spawned, via `execute`. - pub(crate) async fn admit(&self, key: String, request: PlayRequest) -> Result { - if self.poisoned.load(Ordering::SeqCst) { - return Err(PlayError::Poisoned); - } - // PA.f68/PA.f112: held from here through the end of `execute`, so - // the replay/key check below and the turn open happen under one - // acquisition and two concurrent `admit` calls for this table can - // never interleave — the second one always observes the first one's - // own already-open (or already-closed) turn, never a stale - // `None`/`Closed` read racing a still-in-flight `open_new_turn`. - // - // PA.f142: `try_lock_owned`, not `lock_owned().await` — a - // `world.play` route spawns the turn's own execution in a task and - // returns before it resolves, so a second request arriving while a - // turn's own round loop is still running (up to `ROUND_BUDGET` - // rounds of inference) must be refused at once, not queued behind it - // with no bound and no signal. A request that finds the lock free — - // including an answer to a question a prior call already left open - // and returned from — is never affected: nothing holds this lock - // between one turn's own execution finishing and the next admission - // starting. Owned (Cut 10), not borrowed, so the guard can move into - // the spawned task alongside the turn `admit` built. - let Ok(run_guard) = Arc::clone(&self.run_lock).try_lock_owned() else { - // PA.f193-D: `TurnBusy`, not `TurnStillRunning` — the lock itself - // is contended, before the turn's own state or the request's own - // text are inspected at all. An empty continue arriving in this - // exact window is refused too, which `TurnStillRunning`'s own - // text would misrepresent. - return Err(PlayError::TurnBusy); - }; - let PlayRequest { text, answers } = request; - let (stored, replayed_turn_id) = { - let store = self.store.lock().await; - (store.current().cloned(), store.turn_id_for_key(&key)) - }; - if let Some(turn_id) = replayed_turn_id { - // PA.f134: the replayed key's own turn, only if it is *still* - // the store's current one and still `AwaitingPlayer` — a replay - // of a key from an already-closed or already-superseded turn has - // no open question to report. - let question = stored - .as_ref() - .filter(|current| current.turn_id == turn_id && current.state == PlayTurnState::AwaitingPlayer) - .and_then(open_question_of); - return Ok(Admission::Replayed { turn_id, question }); - } - - let (turn, is_new_turn) = match stored { - Some(existing) if existing.state == PlayTurnState::AwaitingPlayer => { - let open_question = existing.open_question_id(); - // PA.f181 (no code change; narrowness recorded here, not - // fixed, because there is nothing to fix): in production, - // `answers` reaches here only from `runtime.rs`'s own - // `world.play` arm, which resolves it to `Some(question.id.clone())` - // *after* its own `answer_token_admits` gate already refused - // everything else — the server, not the caller, names the - // question being answered (PA.f151). So a real request can - // only ever land on the `(Some(given), Some(open)) if given - // == open` arm below, or on `None` (a plain continue/opening, - // no question open at all); the mismatch/stale case this - // match's own `_` arm refuses is, in production, reachable - // only through the race window between `runtime.rs`'s own - // `current_turn_view()` read (which built `question.id`) and - // this function's own fresh `stored` read a few lines above - // — the turn moved on in between, under a token that was - // genuinely valid for the question it was minted against a - // moment earlier. The one test that exercises a genuine - // mismatch here (`an_answer_naming_a_different_question_is_refused_as_stale`) - // reaches it by forging a `PlayRequest.answers` directly, at - // this layer, bypassing `runtime.rs`'s token gate entirely — - // there is no way to reach it end-to-end through the token - // gate on demand, only by winning the race. The check stays: - // it is still the one thing that closes that race window, - // and losing it silently would be exactly the kind of - // "it fixes itself after a reload" gap this project's own - // doctrine refuses to accept. - match (answers, open_question.clone()) { - (Some(given), Some(open)) if given == open => { - if text.trim().is_empty() { - // Refused before anything is recorded (PA.f65): - // the question stays open, the key is never - // marked applied, and nothing about the stored - // turn changes. PA.f134: the refusal carries the - // same still-open question the caller was - // already answering. - let question = open_question_of(&existing).expect( - "an EmptyAnswer refusal is only reached once open_question_id() already matched", - ); - return Err(PlayError::EmptyAnswer { - turn_id: existing.turn_id.clone(), - question, - }); - } - (self.answer_turn(existing, key, text), false) - } - // A missing or mismatched `answers` is refused as stale - // (PA.f84): a late retry naming an old question must - // never land on whatever question happens to be open now. - // PA.f134: carries the turn's own *actually* open - // question (from `open_question`, computed before the - // match consumed it), so the caller can retry against it. - _ => { - return Err(PlayError::StaleAnswer { - turn_id: existing.turn_id.clone(), - question: open_question.map(|id| OpenQuestion { - id, - text: existing.question.clone().unwrap_or_default(), - // PA.f179: kept as the real `Option`, - // not fabricated to `""` — see - // `OpenQuestion::token`'s own doc comment. - token: existing.question_token.clone(), - }), - }); - } - } - } - Some(existing) if existing.state == PlayTurnState::Running => { - if answers.is_some() { - return Err(PlayError::NoQuestionOpen); - } - if text.trim().is_empty() { - (self.continue_turn(existing, key), false) - } else { - return Err(PlayError::TurnStillRunning); - } - } - _ => { - if answers.is_some() { - return Err(PlayError::NoQuestionOpen); - } - // PA.f108: no turn is open to continue (none at all, or the - // current one is `Closed`), so this text opens a fresh one — - // and a fresh turn needs the player's own words, not an - // empty or whitespace-only prompt that would run a full - // inference over nothing said. - if text.trim().is_empty() { - return Err(PlayError::EmptyOpening); - } - (self.begin_turn(key, text).await?, true) - } - }; - if is_new_turn { - self.store.lock().await.open_new_turn(turn.clone())?; - } else { - self.persist(&turn).await?; - } - Ok(Admission::Spawn(Admitted { turn, _run_guard: run_guard })) - } - - /// Cut 10 (PA.f153/PA.f154): the round loop `run` used to run inline — - /// the part that can spend a real inference budget across up to - /// `ROUND_BUDGET` rounds. `runtime.rs::execute_world`'s `world.play` - /// route spawns this, never `admit`, so an admission refusal is never - /// silently swallowed behind an already-sent `accepted` response. - pub(crate) async fn execute( - &self, - admitted: Admitted, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let Admitted { mut turn, _run_guard } = admitted; - loop { - match turn.state { - PlayTurnState::AwaitingPlayer | PlayTurnState::Closed => break, - PlayTurnState::Running => {} - } - let round = turn.rounds.len().saturating_sub(1); - let round_is_open = !turn.rounds.is_empty() && round_incomplete(&turn, round); - if round_is_open { - match self.execute_round(&mut turn, round, principal).await? { - RoundOutcome::Continue => { - self.persist(&turn).await?; - continue; - } - RoundOutcome::AwaitingPlayer | RoundOutcome::Closed => { - self.persist(&turn).await?; - break; - } - RoundOutcome::Poisoned => { - // `execute_round` has already closed `turn` and - // recorded the fault (PA.f66b): this door only marks - // the table poisoned and persists that state. - self.poisoned.store(true, Ordering::SeqCst); - self.persist(&turn).await?; - return Err(PlayError::Poisoned); - } - } - } - if turn.rounds.len() >= ROUND_BUDGET { - // The round budget is exhausted (PA.f66a): the turn closes - // and narrates through the same door `end_turn` uses, rather - // than being left open with nothing said. A snapshot or - // narrate failure inside `close_turn` still closes the turn, - // with the fault recorded, through `close_with_fault` - // (PA.f85/PA.f93). - match self.close_turn(&turn, principal).await { - Ok(narration) => { - turn.narration = Some(narration); - turn.state = PlayTurnState::Closed; - self.persist(&turn).await?; - } - Err(error) => self.close_with_fault(&mut turn, error.to_string()).await?, - } - break; - } - let round = turn.rounds.len(); - match self.infer_round(&turn, round, principal).await { - Ok(output) => { - turn.rounds.push(output); - self.persist(&turn).await?; - } - Err(detail) => { - // PA.f85/PA.f93/PA.f94: a request-build failure, an - // inference fault whose own disposition never retries - // (`RecoveryRequired`/`IntegrityViolation`), or a - // `Retryable` fault that exhausted its retry budget, all - // close the turn with the fault recorded through the one - // shared door, rather than leaving it `Running`. - self.close_with_fault(&mut turn, detail).await?; - break; - } - } - } - // PA.f134: names the turn and, when the loop left it `AwaitingPlayer`, - // the question it is now waiting on — the same value `run`'s own - // `AwaitingPlayer` branches above already derive from `turn`, read - // once more here since `turn` was moved through the loop by value. - let question = open_question_of(&turn); - Ok(RunOutcome::Ran { - turn_id: turn.turn_id.clone(), - question, - }) - } - - /// The current turn's own player-facing state (PA.f134): its id, state, - /// open question (if any), last narration, and most recent refusal line - /// — everything a consumer needs to answer an open question or observe - /// what a turn did, without reading the store directly. `world.play`'s - /// own route spawns `run` in a task and answers `{"kind":"accepted"}` - /// before that call resolves, so nothing from a `run` call's own return - /// value ever reaches the caller that made it; this is the door a later, - /// separate read goes through instead. `None` when no turn has ever - /// opened on this table. - pub(crate) async fn current_turn_view(&self) -> Option { - let store = self.store.lock().await; - let turn = store.current()?; - // PA.f193-D: `try_lock`, not `try_lock_owned` — this never wants to - // hold the guard, only to observe whether one is currently held, so - // it is dropped immediately either way. This never contends with or - // delays a real `admit`: at worst it observes the lock a moment - // before or after `admit` itself would. - let run_in_progress = self.run_lock.try_lock().is_err(); - Some(PlayTurnView { - turn_id: turn.turn_id.clone(), - state: turn.state, - question: open_question_of(turn), - narration: turn.narration.clone(), - refusal: turn.refusal.clone(), - revision: store.state.revision, - run_in_progress, - fault: turn.fault.is_some(), - }) - } - - /// Opens a fresh turn. Takes no principal (PA.f95: the parameter was - /// never read) — the player's own subject is resolved per-round from the - /// live snapshot, not from the caller's own evidence. - async fn begin_turn(&self, key: String, text: String) -> Result { - let snapshot = self.play.snapshot().await?; - let opening_prompt = format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - text - ); - Ok(PlayTurn { - turn_id: uuid::Uuid::new_v4().to_string(), - applied_keys: vec![key], - opening_prompt, - player_prose: vec![text], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }) - } - - /// Applies the player's own answer to the currently open question, - /// already checked by `run` to name that question's own `QuestionId`. - fn answer_turn(&self, mut turn: PlayTurn, key: String, text: String) -> PlayTurn { - turn.applied_keys.push(key); - turn.player_prose.push(text.clone()); - let round = turn.rounds.len().saturating_sub(1); - if let Some(slot) = find_ask_player_slot(&turn, round) { - if let Some(record) = turn.call_record_mut(round, slot) { - record.result = Some(text); - } - } - turn.question = None; - turn.question_token = None; - turn.state = PlayTurnState::Running; - turn - } - - /// A plain continue of a `Running` turn under a fresh key: nothing about - /// the turn's own record changes beyond the key itself (PA.f84). - fn continue_turn(&self, mut turn: PlayTurn, key: String) -> PlayTurn { - turn.applied_keys.push(key); - turn - } - - /// Closes `turn` with `detail` as its recorded fault and persists it: - /// the one door every failure reachable only after a turn has already - /// opened goes through (PA.f85/PA.f93) — a request-build failure, a - /// snapshot or narrate failure, an inference fault whose disposition - /// does not retry or that exhausted its retry budget, or a kernel - /// `Invariant` fault. Distinct from poisoning the whole table: that - /// stays each caller's own decision (`RoundOutcome::Poisoned`), because - /// this helper only ever writes the one turn's own record. - async fn close_with_fault(&self, turn: &mut PlayTurn, fault: impl Into) -> Result<(), PlayError> { - let TurnFault { detail, class } = fault.into(); - turn.state = PlayTurnState::Closed; - // R2: the one place a fault closes a turn, so the one place it is - // logged, whole. The line names the typed class (for an HTTP reply, - // its status code, which is the server's answer and not an input - // value) beside the detail. An `InferenceFault`'s detail is a - // `&'static str`, which stops a port from handing it computed text by - // accident; it does not stop a deliberate `Box::leak` into the - // `pub` constructors, and a new port's canary test is what actually - // keeps endpoint, request and reply text out. The details of the - // other failures that reach here (kernel, request build, snapshot) - // are their callers' own error text, and nothing here constrains - // them. - tracing::warn!(turn_id = %turn.turn_id, class = ?class, detail = %detail, "play turn closed on a fault"); - turn.fault = Some(detail); - // PA.f173: a fault can close a turn that was `AwaitingPlayer`, and - // `answer_turn` is not the only door out of that state — clear the - // open question here too, the same way `answer_turn` does, so a - // closed turn never carries a question record alongside it. This was - // unobservable before this fix only because `open_question_id` gates - // on `state == AwaitingPlayer`, which a fault always leaves behind by - // setting `state = Closed` in the same write. - turn.question = None; - turn.question_token = None; - self.persist(turn).await - } - - /// Runs one round's own inference to completion, or returns the fault - /// detail the caller closes the turn with (PA.f85/PA.f93/PA.f94): a - /// request-build failure (the snapshot, `round_tools`, or - /// `InferenceRequest::play` itself), or an `InferenceFault` whose own - /// disposition does not retry, or that exhausted its bounded retry - /// budget. - async fn infer_round( - &self, - turn: &PlayTurn, - round: usize, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let snapshot = self.round_snapshot().await?; - let dispatched = turn.dispatched_subjects(); - let tools = self.round_tools(&snapshot, &dispatched, principal)?; - let conversation = rebuild_conversation(turn, round); - let command_id = CommandId::parse_uuid(&turn.turn_id).map_err(|error| error.to_string())?; - - let mut attempt = 0usize; - loop { - let request = InferenceRequest::play( - command_id, - round, - &self.model, - PLAY_INSTRUCTIONS, - conversation.clone(), - tools.clone(), - PLAY_MAX_OUTPUT_TOKENS, - ) - .map_err(|error| error.to_string())?; - let prepared = match self.inference.prepare(request) { - Ok(prepared) => prepared, - Err(fault) => { - self.absorb_or_close(fault, &mut attempt).await?; - continue; - } - }; - match self.inference.infer(prepared).await { - Ok(output) => return Ok(output), - Err(fault) => { - self.absorb_or_close(fault, &mut attempt).await?; - continue; - } - } - } - } - - /// One inference fault's own disposition decides its fate (PA.f94): only - /// `Retryable` retries at all, bounded by `ROUND_RETRY_BUDGET` and the - /// capped exponential backoff of `retry_delay` (100 ms doubling to a 20 s - /// cap, 105.5 s summed over the budget; zero under test); - /// `RecoveryRequired` and `IntegrityViolation` never retry, closing on - /// their first occurrence — retrying either again cannot succeed - /// differently. `Ok(())` means the caller's loop should try again; - /// `Err` carries the fault's own detail text and class, to close the turn with. - async fn absorb_or_close(&self, fault: InferenceFault, attempt: &mut usize) -> Result<(), TurnFault> { - match fault.disposition() { - InferenceFaultDisposition::Retryable if *attempt < ROUND_RETRY_BUDGET => { - let delay = self.retry_delay(*attempt); - *attempt += 1; - if !delay.is_zero() { - tokio::time::sleep(delay).await; - } - Ok(()) - } - InferenceFaultDisposition::Retryable - | InferenceFaultDisposition::RecoveryRequired - | InferenceFaultDisposition::IntegrityViolation => { - Err(TurnFault { detail: fault.to_string(), class: fault.class() }) - } - } - } - - /// The capped exponential backoff between retries of a `Retryable` - /// inference fault (PA.f94): 100 ms doubling to a 20 s cap, whose sum over - /// `ROUND_RETRY_BUDGET` retries must outlast a model server reload - /// (pinned by the summed-backoff test). PA.f122: the base is an ordinary field on - /// the table, not a `#[cfg(test)]` swap over the formula itself — - /// production and test both run `backoff_delay` (below, a free function, - /// compiled and directly unit-testable in every build), over the exact - /// same code path, differing only in the base a test explicitly sets - /// through `set_retry_delay_base_ms` (PA.f94's own zero delay, so a test - /// never actually sleeps). - fn retry_delay(&self, attempt: usize) -> Duration { - backoff_delay(self.retry_delay_base_ms.load(Ordering::Relaxed), attempt) - } - - /// Sets the base `backoff_delay` scales from. Test-only: no production - /// call site exists, matching `set_before_submit_hook`'s own shape — the - /// field itself stays ordinary and always compiled so production and - /// test run the identical `retry_delay`/`backoff_delay` code path. - #[cfg(test)] - pub(crate) fn set_retry_delay_base_ms(&self, base_ms: u64) { - self.retry_delay_base_ms.store(base_ms, Ordering::Relaxed); - } - - /// The one owner of round entry (PA.f127/PA.f133): fetches the live - /// snapshot and runs the handle-collision check exactly once, for - /// whichever caller is opening this round — `infer_round`, before - /// deciding the round's own tools, and `execute_round`, before resolving - /// a resumed round's already-decided calls. Both used to fetch their own - /// snapshot and run `collision_refusal` independently, which is two - /// owners for one rule; this is the one. - async fn round_snapshot(&self) -> Result { - let snapshot = self.fetch_round_snapshot().await?; - if let Some(refusal) = collision_refusal(&snapshot) { - return Err(refusal); - } - Ok(snapshot) - } - - /// The live fetch `round_snapshot` checks. Test-only: a forced snapshot, - /// when set through `force_round_snapshot`, stands in for the kernel's - /// own — the one seam this crate has for driving a *genuine*, real-id - /// handle collision through `round_snapshot` itself, matching - /// `before_submit_hook`'s and `set_retry_delay_base_ms`'s own shape. No - /// production call site ever sets it. - #[cfg(test)] - async fn fetch_round_snapshot(&self) -> Result { - if let Some(forced) = self.forced_round_snapshot.lock().unwrap().clone() { - return Ok(forced); - } - self.play.snapshot().await.map_err(|error| error.to_string()) - } - - #[cfg(not(test))] - async fn fetch_round_snapshot(&self) -> Result { - self.play.snapshot().await.map_err(|error| error.to_string()) - } - - /// Test-only: see `fetch_round_snapshot`. No production call site exists. - #[cfg(test)] - fn force_round_snapshot(&self, snapshot: WorldSnapshot) { - *self.forced_round_snapshot.lock().unwrap() = Some(snapshot); - } - - fn round_tools( - &self, - snapshot: &WorldSnapshot, - dispatched: &[SubjectId], - principal: &VerifiedPrincipalEvidence, - ) -> Result, String> { - let mut tools = authoring_tools(PLAY_TOOLS).map_err(|error| error.to_string())?; - tools.extend(control_tools()); - let Some(player) = player_subject(snapshot, principal) else { - return Ok(tools); - }; - let mut player_tools = actor_tools(&actor_prefix(player.id), snapshot, player.id); - annotate_actor_tools(&mut player_tools, snapshot, player.id); - tools.extend(player_tools); - for subject in dispatched { - let mut subject_tools = actor_tools(&actor_prefix(*subject), snapshot, *subject); - annotate_actor_tools(&mut subject_tools, snapshot, *subject); - tools.extend(subject_tools); - } - Ok(tools) - } - - /// Executes every call in `round` that has not yet resolved, in order. - async fn execute_round( - &self, - turn: &mut PlayTurn, - round: usize, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let output = turn.rounds[round].clone(); - // PA.f103, PA.f118, PA.f127/PA.f133: this door's own `close_with_fault` - // still calls `persist` under the hood, so a failure right here has no - // lower layer left to record it in — it propagates through the `?` on - // `close_with_fault`'s own `Result` instead of being written into the - // turn, which is exactly the failure that would need writing. - // `round_snapshot` failing here *is* covered, by - // `a_snapshot_failure_mid_round_closes_with_the_fault_recorded` below - // (a live fetch failure) and by the forced-collision tests below (the - // handle-collision refusal): a round resumed after a crash executes - // its already-decided calls through this door directly, never through - // `round_tools` at all, so the same one owner both a fresh round and a - // resumed one share must run here too, rather than resolving calls - // against a handle that no longer names one subject unambiguously. - let snapshot = match self.round_snapshot().await { - Ok(snapshot) => snapshot, - Err(detail) => { - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Closed); - } - }; - let player = player_subject(&snapshot, principal); - - // Slotted, unresolved tool calls only: `slot` must still count a - // call this loop skips as already resolved, so slot numbering never - // shifts between a round's first pass and its resumed one. - let mut calls: Vec<(usize, &str, &str, &str)> = Vec::new(); - let mut slot = 0usize; - for event in output.events() { - let InferenceEvent::ToolCall { - call_id, - name, - arguments, - } = event - else { - continue; - }; - let this_slot = slot; - slot += 1; - if turn - .call_record(round, this_slot) - .is_some_and(|call| call.result.is_some()) - { - continue; - } - calls.push((this_slot, call_id.as_str(), name.as_str(), arguments.as_str())); - } - - let mut index = 0usize; - while index < calls.len() { - let (this_slot, call_id, name, arguments) = calls[index]; - - // A maximal run of consecutive authoring calls commits together - // as one shared patch under one command id (PA.f54): actor calls - // and table tools break a run. - if PLAY_TOOLS.contains(&name) { - let mut run = Vec::new(); - while index < calls.len() && PLAY_TOOLS.contains(&calls[index].2) { - let (slot, call_id, name, arguments) = calls[index]; - run.push((slot, call_id.to_owned(), name.to_owned(), arguments.to_owned())); - index += 1; - } - let results = self.commit_authoring_run(turn, round, &run, &snapshot).await?; - let mut fault = None; - for (slot, call_id, outcome, result) in results { - if matches!(outcome, RoundOutcome::Poisoned) { - fault = Some(result.clone()); - } - record_call(turn, &call_id, round, slot, None, Some(result)); - } - self.persist(turn).await?; - if let Some(detail) = fault { - // PA.f66b: a kernel `Invariant` fault closes the turn - // with the fault recorded, not left `Running`. - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Poisoned); - } - continue; - } - index += 1; - - if name == END_TURN_TOOL { - record_call(turn, call_id, round, this_slot, Some(RecordedCall::EndTurn), None); - match self.close_turn(turn, principal).await { - Ok(narration) => { - if let Some(record) = turn.call_record_mut(round, this_slot) { - record.result = Some("the turn ended".to_owned()); - } - turn.narration = Some(narration); - turn.state = PlayTurnState::Closed; - } - Err(error) => { - // PA.f85/PA.f93: a narrate failure still closes the - // turn, with the fault recorded, rather than leaving - // it stuck `Running` behind a `?`. - self.close_with_fault(turn, error.to_string()).await?; - } - } - return Ok(RoundOutcome::Closed); - } - - if name == ASK_PLAYER_TOOL { - let question = parse_ask_player(arguments); - record_call( - turn, - call_id, - round, - this_slot, - Some(RecordedCall::AskPlayer(question.clone())), - None, - ); - turn.question = Some(question); - // PA.f170: a fresh opaque token, minted the instant this - // question opens, binds an answer to *this* question rather - // than to a world revision — PA.f161's `surface_version` - // told apart questions that opened on different revisions, - // but two questions opened in the same turn share one - // revision (asking is conversational and never moves it), so - // that comparison alone could not tell them apart. Unlike - // the version it replaces, the token needs no snapshot: it - // carries no world handle at all. - turn.question_token = Some(mint_question_token()); - turn.state = PlayTurnState::AwaitingPlayer; - return Ok(RoundOutcome::AwaitingPlayer); - } - - if name == DISPATCH_TOOL { - // PA.f136: the recorded body decides, exactly like every - // other door's own `recorded` match - // (`commit_authoring_run`/`execute_advance_time`/ - // `execute_actor_call`) — a resumed call resubmits its own - // already-recorded subjects, never a value `parse_dispatch` - // re-derives against whatever the live snapshot now says. - // Before this, `subjects`/`refusals` were re-parsed against - // the *current* snapshot on every pass, recorded body or not: - // if the world changed between the crash and the resume so a - // dispatched subject no longer resolved (a retirement), the - // resumed call ran a shorter list and the record below - // silently overwrote the original `RecordedCall::Dispatch` - // with that shorter one — losing the subject from - // `dispatch_owner`/`dispatched_subjects` for the rest of the - // turn even though `persona_turns` still carried her prose. - let recorded = turn.call_record(round, this_slot).and_then(|call| call.body.clone()); - let (subjects, refusals) = match recorded { - Some(RecordedCall::Dispatch(subjects)) => (subjects, Vec::new()), - _ => { - let (subjects, refusals) = parse_dispatch(arguments, &snapshot); - // PA.f125: this call's own body — which subjects it - // intends to dispatch — is recorded and persisted - // before `execute_dispatch` runs any persona turn, - // the same "body durable before effects" order every - // other door already uses (PA-Q8, `persist`'s own - // doc comment). Without this, a crash between - // `execute_dispatch`'s own per-subject persist and - // this call's own record here left `persona_turns` - // durable with no call record naming it; a resume - // then mistook the already-durable Persona turn for - // a *different* call's completed dispatch ("already - // acted"), and the Persona's own prose never reached - // the agent (PA.f64). - record_call( - turn, - call_id, - round, - this_slot, - Some(RecordedCall::Dispatch(subjects.clone())), - None, - ); - self.persist(turn).await?; - (subjects, refusals) - } - }; - self.fire_before_submit_hook().await; - let (outcome, dispatch_summary) = - self.execute_dispatch(turn, round, this_slot, &subjects).await?; - // PA.f98, PA.f114: bad input — an unknown id, a non-string - // entry, a malformed body, or a missing `subjects` key — is - // refused in the tool result, quoting what was given, never - // dropped silently. - let result = if refusals.is_empty() { - dispatch_summary - } else { - let refusals = refusals.join("; "); - if dispatch_summary.is_empty() { - refusals - } else { - format!("{dispatch_summary}; {refusals}") - } - }; - record_call( - turn, - call_id, - round, - this_slot, - Some(RecordedCall::Dispatch(subjects)), - Some(result), - ); - if matches!(outcome, RoundOutcome::Closed) { - // PA.f85/PA.f93: `execute_dispatch` already closed the - // turn with its fault recorded, mid-loop; nothing later - // in this round resolves. - return Ok(RoundOutcome::Closed); - } - continue; - } - - if name == ADVANCE_TIME_TOOL { - let (outcome, result) = self - .execute_advance_time(turn, call_id, round, this_slot, arguments) - .await?; - let fault = matches!(outcome, RoundOutcome::Poisoned).then(|| result.clone()); - record_call(turn, call_id, round, this_slot, None, Some(result)); - self.persist(turn).await?; - if let Some(detail) = fault { - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Poisoned); - } - continue; - } - - // `__` actor call: `handle` alone picks the actor - // (Dungeon's own vocabulary — the library owns no notion of - // which subject was dispatched this turn); the full `name` is - // then handed to `execute_actor_call` unstripped, so the - // library's own `decode_actor_call` owns the prefix strip. - if let Some((handle, _)) = name.split_once(HANDLE_SEPARATOR) { - // PA.f104: derived fresh from the turn's own record right - // here, on every actor call, rather than snapshotted once - // before this loop began — an earlier call in this same - // round's own call order (including a dispatch this round - // already executed) must make its subject's actor tools - // valid on the first pass exactly as it does on a resume, - // since a resume recomputes this same derivation from the - // same persisted record and would otherwise disagree. - let dispatched = turn.dispatched_subjects(); - let actor = resolve_handle(handle, player.as_ref(), &dispatched); - let Some(actor) = actor else { - record_call( - turn, - call_id, - round, - this_slot, - None, - Some(format!("`{handle}` is not the player or a subject dispatched this turn")), - ); - continue; - }; - let (outcome, result) = self - .execute_actor_call( - turn, call_id, round, this_slot, actor, name, arguments, &snapshot, principal, - ) - .await?; - let fault = matches!(outcome, RoundOutcome::Poisoned).then(|| result.clone()); - record_call(turn, call_id, round, this_slot, None, Some(result)); - self.persist(turn).await?; - if let Some(detail) = fault { - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Poisoned); - } - continue; - } - - record_call( - turn, - call_id, - round, - this_slot, - None, - Some(format!("`{name}` is not a tool this table offers")), - ); - } - - Ok(RoundOutcome::Continue) - } - - /// One maximal run of consecutive authoring calls (PA.f54): inside one - /// round, a run of authoring calls with no actor call or table tool - /// between them decodes as one shared patch, through the library's own - /// `decode_authoring_calls`, and commits atomically under one command id - /// derived from the run's own first (anchor) slot. A draft handle a - /// call in the run declares resolves against a later call in the same - /// run, because every call's item lands in that one shared `WorldPatch` - /// before any of it is submitted. - /// - /// PA.f61: a run whose shared patch would rewrite an existing subject's - /// persona material is refused whole — `WorldPatch::sets_persona_of_existing_subject` - /// is the one read-only query the library exposes for this, because - /// `WorldPatch`'s own fields are `pub(crate)` and Dungeon may not parse - /// its JSON to decide the question itself. `set_persona_material` still - /// stands for a subject this same run's own `declare_subject` call - /// declares (a `Ref::Draft`, not `Ref::Existing`). - async fn commit_authoring_run( - &self, - turn: &mut PlayTurn, - round: usize, - run: &[(usize, String, String, String)], - snapshot: &WorldSnapshot, - ) -> Result, PlayError> { - let anchor_slot = run[0].0; - let anchor_call_id = run[0].1.clone(); - let recorded = turn.call_record(round, anchor_slot).and_then(|call| call.body.clone()); - let (patch, batch) = match recorded { - Some(RecordedCall::Authoring(patch)) => (patch, None), - _ => { - let calls: Vec<(&str, &str)> = - run.iter().map(|(_, _, name, arguments)| (name.as_str(), arguments.as_str())).collect(); - let decoded = match decode_authoring_calls(&calls) { - Ok(decoded) => decoded, - Err(detail) => return Ok(refuse_run(run, format!("refused: {detail}"))), - }; - if decoded.patch.sets_persona_of_existing_subject() { - return Ok(refuse_run( - run, - "refused: set_persona_material may not target an existing subject; \ - only a subject this same run declares" - .to_owned(), - )); - } - record_call( - turn, - &anchor_call_id, - round, - anchor_slot, - Some(RecordedCall::Authoring(decoded.patch.clone())), - None, - ); - self.persist(turn).await?; - let patch = decoded.patch.clone(); - (patch, Some(decoded)) - } - }; - let command_id = derived_command_id(&turn.turn_id, round, anchor_slot); - self.fire_before_submit_hook().await; - let (outcome, result) = match self.play.submit_patch(command_id, patch).await { - Ok(_receipt) => (RoundOutcome::Continue, "applied".to_owned()), - Err(MailboxError::Kernel(KernelError::Invariant(detail))) => (RoundOutcome::Poisoned, detail), - Err(MailboxError::Kernel(kernel_error @ KernelError::PatchRejected(_))) => { - // PA.f54: the batch's own site map names the exact call a - // mismatch belongs to, when one decoded this call (a resumed - // resubmission has no fresh `DecodedBatch` and falls back to - // `describe_refusal`'s own raw-index wording). - let text = describe_refusal(snapshot, None, None, batch.as_ref(), &kernel_error); - (RoundOutcome::Continue, format!("refused: {text}")) - } - Err(error) => (RoundOutcome::Continue, format!("refused: {error}")), - }; - Ok(run - .iter() - .map(|(slot, call_id, _, _)| (*slot, call_id.clone(), outcome, result.clone())) - .collect()) - } - - /// Persists `turn` to the store: the one durable-write door every - /// `execute_*`/`commit_*` method uses to record a call's body *before* - /// attempting its submission (PA-Q8), so a crash between the two leaves - /// the exact submitted body on disk for `execute_round` to resubmit on - /// resume. - async fn persist(&self, turn: &PlayTurn) -> Result<(), PlayError> { - self.store.lock().await.commit(turn.clone())?; - Ok(()) - } - - async fn execute_advance_time( - &self, - turn: &mut PlayTurn, - call_id: &str, - round: usize, - slot: usize, - arguments: &str, - ) -> Result<(RoundOutcome, String), PlayError> { - let recorded = turn.call_record(round, slot).and_then(|call| call.body.clone()); - let minutes = match recorded { - Some(RecordedCall::AdvanceTime(minutes)) => minutes, - _ => match parse_minutes(arguments) { - Some(minutes) => { - record_call(turn, call_id, round, slot, Some(RecordedCall::AdvanceTime(minutes)), None); - self.persist(turn).await?; - minutes - } - None => { - return Ok(( - RoundOutcome::Continue, - "refused: minutes must be a positive integer within bounds".to_owned(), - )); - } - }, - }; - let command_id = derived_command_id(&turn.turn_id, round, slot); - self.fire_before_submit_hook().await; - match self.play.advance_time(command_id, minutes).await { - Ok(_) => Ok((RoundOutcome::Continue, "the clock advanced".to_owned())), - Err(MailboxError::Kernel(KernelError::Invariant(detail))) => Ok((RoundOutcome::Poisoned, detail)), - Err(error) => Ok((RoundOutcome::Continue, format!("refused: {error}"))), - } - } - - async fn execute_actor_call( - &self, - turn: &mut PlayTurn, - call_id: &str, - round: usize, - slot: usize, - actor: Actor, - name: &str, - arguments: &str, - snapshot: &WorldSnapshot, - principal: &VerifiedPrincipalEvidence, - ) -> Result<(RoundOutcome, String), PlayError> { - let recorded = turn.call_record(round, slot).and_then(|call| call.body.clone()); - let (opportunity, invocation) = match recorded { - Some(RecordedCall::PlayerAct(opportunity, invocation)) => (opportunity, invocation), - Some(RecordedCall::PersonaAct(_, opportunity, invocation)) => (opportunity, invocation), - _ => { - // The real handle prefix `round_tools` handed `actor_tools` - // when it generated this actor's own tools, paired with the - // full, unstripped call `name`, so `decode_actor_call`'s own - // prefix strip is the one and only place that decides what - // the prefix removes. - let prefix = actor_prefix(actor.subject); - let (opportunity, invocation) = - match decode_actor_call(snapshot, actor.subject, &prefix, name, arguments) { - Ok(decoded) => decoded, - Err(detail) => { - return Ok((RoundOutcome::Continue, format!("refused: {detail}"))); - } - }; - if !span_is_exact(&invocation, &actor, turn) { - return Ok(( - RoundOutcome::Continue, - "refused: the quoted text is not an exact quote from the acting subject's own prose" - .to_owned(), - )); - } - let body = if actor.is_player { - RecordedCall::PlayerAct(opportunity.clone(), invocation.clone()) - } else { - RecordedCall::PersonaAct(actor.subject, opportunity.clone(), invocation.clone()) - }; - record_call(turn, call_id, round, slot, Some(body), None); - self.persist(turn).await?; - (opportunity, invocation) - } - }; - - let command_id = derived_command_id(&turn.turn_id, round, slot); - self.fire_before_submit_hook().await; - let submission = if actor.is_player { - self.world - .submit_principal( - PrincipalCommandIntent { - id: command_id, - world_id: opportunity.world_id, - expected_revision: 0, - body: CommandBody::ExerciseDecision { - opportunity: opportunity.clone(), - invocation: invocation.clone(), - }, - }, - principal, - ) - .await - } else { - ControllerPort::new(self.world.clone()) - .submit_controller(command_id, &opportunity, invocation.clone()) - .await - }; - - match submission { - Ok(_) => { - // PA.f144: `turn.refusal` names the *most recent* player - // act's own refusal (invariant 8's actor-facing line) — a - // player act that now commits clears whatever an earlier - // round's refusal left standing, so a round-1 refusal is not - // still shown once a later act of the same player's own - // succeeds. A Persona's own act never set it, so this clears - // nothing a Persona commit should not touch either way. - if actor.is_player { - turn.refusal = None; - } - Ok((RoundOutcome::Continue, "applied".to_owned())) - } - Err(MailboxError::Kernel(KernelError::ActionRejected(mismatches))) => { - let error = KernelError::ActionRejected(mismatches); - // The entry the invocation actually named: found on the live - // snapshot alone, never from anything the caller supplied, - // matching `subject_opportunity`'s own rule that a consumer's - // claimed affordance is never trusted back. - let entry = snapshot - .affordances - .iter() - .find(|candidate| candidate.id == invocation.affordance); - // The agent's own surface: full detail, for the play agent to - // reason with. - let text = describe_refusal(snapshot, entry, Some(&invocation), None, &error); - if actor.is_player { - // Invariant 8: the player sees only the actor form — - // `describe_refusal_to_actor` names bare role names, never - // an id, a resolved label, or a fact's statement. A - // missing `entry` (which decode_actor_call's own success - // should make impossible) falls back to the same bare - // "refused" `describe_refusal_to_actor` itself uses for - // every non-`ActionRejected` error, rather than leaking - // the agent-facing text. - let actor_text = match entry { - Some(entry) => describe_refusal_to_actor(entry, &error), - None => "refused".to_owned(), - }; - turn.refusal = Some(actor_text); - } - Ok((RoundOutcome::Continue, format!("refused: {text}"))) - } - Err(MailboxError::Kernel(KernelError::Invariant(detail))) => Ok((RoundOutcome::Poisoned, detail)), - Err(error) => Ok((RoundOutcome::Continue, format!("refused: {error}"))), - } - } - - async fn execute_dispatch( - &self, - turn: &mut PlayTurn, - round: usize, - call_slot: usize, - subjects: &[SubjectId], - ) -> Result<(RoundOutcome, String), PlayError> { - // PA.f125: snapshotted before this call's own subjects run, so a - // subject *this* call adds to `persona_turns` later in this same - // loop (the same subject named twice in one `dispatch` call, - // PA.f124) is never mistaken for a subject this call is *resuming* - // after a crash — only a subject already durable *before* this - // invocation began can be that. - let already_durable: Vec = turn.persona_turns.iter().map(|(id, _)| *id).collect(); - // Indexed by the subject's own position in `subjects` (not the order - // any of this resolves in): every subject gets exactly one summary - // line, and the joined summary must read in the call's own JSON - // order whether that subject's line was decided synchronously below - // or by a concurrently dispatched Persona turn later. - let mut summary_slots: Vec> = vec![None; subjects.len()]; - let finished_summary = |slots: Vec>| slots.into_iter().flatten().collect::>().join("; "); - - /// One subject this call still needs a fresh Persona turn for, - /// carrying everything `personas.turn` and its own summary line - /// need — captured now, at the same point the old sequential loop - /// captured them, so concurrent dispatch changes nothing about - /// *which* snapshot or opportunity a subject's turn runs against. - struct Pending { - index: usize, - subject: SubjectId, - command_id: CommandId, - opportunity: DecisionOpportunity, - snapshot: WorldSnapshot, - } - let mut pending: Vec = Vec::new(); - // PA.f124: the same subject named twice in *this* call's own subject - // list is a collision even though neither occurrence has landed in - // `turn.persona_turns` yet — concurrent dispatch means both - // occurrences are seen before either one's Persona turn has run, so - // `turn.persona_turns` alone can no longer tell them apart the way - // the old strictly-sequential loop could (a first occurrence used to - // land in `turn.persona_turns` before the second was even read). - // This is the within-call twin of that same check. - let mut claimed_this_call: Vec = Vec::new(); - - for (index, subject) in subjects.iter().enumerate() { - if let Some(prose) = turn - .persona_turns - .iter() - .find(|(id, _)| id == subject) - .map(|(_, persona_turn)| persona_turn.source_prose().to_owned()) - { - let resuming_this_call = - already_durable.contains(subject) && turn.dispatch_owner(*subject) == Some((round, call_slot)); - if resuming_this_call { - // PA.f125: this call's own body is already durable - // before any persona turn ran (`execute_round` records - // and persists it before this door is entered), so a - // crash between one subject's own persist and this - // call's own final result finds its own prose here on - // resume, rather than refusing it as "already acted" - // against a call that never actually completed. - let snapshot = match self.play.snapshot().await { - Ok(snapshot) => snapshot, - Err(error) => { - self.close_with_fault(turn, error.to_string()).await?; - return Ok((RoundOutcome::Closed, finished_summary(summary_slots))); - } - }; - let label = subject_label(&snapshot, *subject); - summary_slots[index] = Some(format!("{label}: {prose}")); - } else { - // PA.f124: printed through the library's own `id_text`, - // the same printer `table_view` and `dispatch`'s own - // matching use — not a fifth hand-spelled debug-paren - // strip. - summary_slots[index] = Some(format!("{} already acted", id_text(*subject))); - } - continue; - } - if claimed_this_call.contains(subject) { - // PA.f124: a repeat within this same call's own list, ahead - // of its own first occurrence's Persona turn actually - // resolving — the same "already acted" text the sequential - // loop gave a repeat once its first occurrence had already - // landed in `turn.persona_turns`. - summary_slots[index] = Some(format!("{} already acted", id_text(*subject))); - continue; - } - let snapshot = match self.play.snapshot().await { - Ok(snapshot) => snapshot, - Err(error) => { - // PA.f85/PA.f93: a snapshot failure mid-dispatch still - // closes the turn with the fault recorded, rather than - // leaving it `Running` behind a `?`. - self.close_with_fault(turn, error.to_string()).await?; - return Ok((RoundOutcome::Closed, finished_summary(summary_slots))); - } - }; - let Some(opportunity) = snapshot - .opportunities - .iter() - .find(|opportunity| { - opportunity.scope.subject_id == *subject - && opportunity.controller_mode == ControllerMode::NarrativePersona - }) - .cloned() - else { - summary_slots[index] = Some(format!("{} holds no live Persona opportunity", id_text(*subject))); - continue; - }; - // Derived from the dispatch call's own slot and the subject, not - // from the subject's position in this call's own JSON array - // (PA.f66d): two subjects named by one dispatch call, or the - // same subject named by two dispatch calls in one round, must - // never collide, in whatever order either call lists them. - let command_id = dispatch_command_id(&turn.turn_id, round, call_slot, *subject); - claimed_this_call.push(*subject); - pending.push(Pending { - index, - subject: *subject, - command_id, - opportunity, - snapshot, - }); - } - - // The round's own freshly dispatched Personas run concurrently - // (PA.f13, P8.5): each spawned task draws its own permit from the - // shared `controller_permits` pool before it runs `personas.turn`, - // so the pool's own size bounds how many run cognition at once, not - // this call's own subject count. Spawned, not merely joined, so - // Tokio actually schedules every pending subject's turn as soon as - // its permit is free rather than only interleaving at await points - // inside one task. - let mut handles = Vec::with_capacity(pending.len()); - for entry in &pending { - let personas = self.personas.clone(); - let permits = self.permits.clone(); - let command_id = entry.command_id; - let opportunity = entry.opportunity.clone(); - handles.push(tokio::spawn(async move { - let permit = permits - .acquire_owned() - .await - .expect("permit semaphore is never closed"); - let result = personas.turn(command_id, &opportunity).await; - drop(permit); - result - })); - } - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push( - handle - .await - .expect("a dispatched Persona's own turn task never panics"), - ); - } - - for (entry, result) in pending.into_iter().zip(results) { - match result { - Ok(persona_turn) => { - let label = subject_label(&entry.snapshot, entry.subject); - // PA.f64: the dispatched Persona's own prose reaches the - // agent as this call's own tool result, not a bare - // "acted" acknowledgement — `rebuild_conversation` is - // already generic over every call's recorded `result`, - // so recording the prose here is the whole fix. - let prose = persona_turn.source_prose().to_owned(); - summary_slots[entry.index] = Some(format!("{label}: {prose}")); - turn.persona_turns.push((entry.subject, persona_turn)); - // PA.f123: `execute_dispatch` was the only call kind that - // did not persist at its own site — every other tool - // call's own door (`commit_authoring_run`, - // `execute_advance_time`, `execute_actor_call`) persists - // its own recorded body before submitting, and - // `execute_round`'s per-call loop persists after every - // *other* branch, but a dispatch call's own `continue` - // reached none of that until the round itself finished. - // A crash between two subjects in one dispatch call, or - // between this dispatch call and the round's own later - // persist, would re-infer this subject's Persona prose - // on resume, and an actor call already checked against - // the recorded prose would be checked against the new - // (possibly different) prose instead — persisting as - // soon as each subject's own prose is recorded closes - // that window. Persisted here, sequentially, after every - // concurrent turn has already resolved, so two subjects' - // own persists of the same turn row never race each - // other's `compare_and_swap_batch`. - self.persist(turn).await?; - self.fire_before_submit_hook().await; - } - Err(error) => { - let label = subject_label(&entry.snapshot, entry.subject); - summary_slots[entry.index] = Some(format!("{label} could not act: {error}")); - } - } - } - Ok((RoundOutcome::Continue, finished_summary(summary_slots))) - } - - /// Narrates the turn's own close to the player. Takes no snapshot of its - /// own (PA.f95: it was never read) — it always re-fetches its own fresh - /// snapshot below, since the round's own calls may have committed since - /// any snapshot a caller might have held. Cut 10 (PA.f159) added - /// `principal` back: `player_subject` now resolves the player by whose - /// principal is actually running this turn, not merely "the" human - /// subject in the snapshot. - async fn close_turn( - &self, - turn: &PlayTurn, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - // A fresh snapshot, not the round's own: this round's calls may have - // committed since it was taken, and `PersonaLane::narrate` refuses - // an opportunity that is not the kernel's own currently issued copy. - let snapshot = self.play.snapshot().await?; - let Some(player) = player_subject(&snapshot, principal) else { - return Ok(String::new()); - }; - let Some(opportunity) = snapshot - .opportunities - .iter() - .find(|opportunity| { - opportunity.scope.subject_id == player.id - && opportunity.controller_mode == ControllerMode::Human - }) - .cloned() - else { - return Ok(String::new()); - }; - let command_id = derived_command_id(&turn.turn_id, turn.rounds.len(), usize::MAX); - // Narration is cognition over the same shared pool a dispatched - // Persona's own turn draws from (PA.f13, P8.5): the turn's own close - // must queue behind an exhausted pool exactly like a dispatch would, - // rather than running unmetered because it happens to be the one - // Persona-lane call outside `execute_dispatch`. - let permit = self - .permits - .clone() - .acquire_owned() - .await - .expect("permit semaphore is never closed"); - let narration = self.personas.narrate(command_id, &opportunity).await; - drop(permit); - Ok(narration?) - } -} - -struct Actor { - subject: SubjectId, - is_player: bool, -} - -fn resolve_handle(handle: &str, player: Option<&SubjectRow>, dispatched: &[SubjectId]) -> Option { - if let Some(player) = player - && handle_for(player.id) == handle - { - return Some(Actor { - subject: player.id, - is_player: true, - }); - } - for subject in dispatched { - if handle_for(*subject) == handle { - return Some(Actor { - subject: *subject, - is_player: false, - }); - } - } - None -} - -struct SubjectRow { - id: SubjectId, -} - -/// Cut 10 (PA.f159): the subject *this principal's own turn* acts as, not -/// merely "the" human-controlled subject in the world. `WorldSnapshot` -/// already carries `human_controller: Option` on every -/// `SubjectSnapshot` (the library's own field, no library change needed); -/// this was the one production reader still ignoring it, matching by -/// `controller_mode` alone — correct only by accident, in the one-human- -/// subject worlds every fixture happens to build, and wrong the moment a -/// world has more than one. -fn player_subject(snapshot: &WorldSnapshot, principal: &VerifiedPrincipalEvidence) -> Option { - let owner = ghostlight::PrincipalId::new(principal.account_subject_hash()); - snapshot - .subjects - .iter() - .find(|subject| { - subject.controller_mode == Some(ControllerMode::Human) - && subject.human_controller.as_ref() == Some(&owner) - }) - .map(|subject| SubjectRow { id: subject.id }) -} - -fn subject_label(snapshot: &WorldSnapshot, subject: SubjectId) -> String { - snapshot - .subjects - .iter() - .find(|row| row.id == subject) - .map_or_else(|| "an unknown subject".to_owned(), |row| row.label.clone()) -} - -/// A subject's own short handle: the first 8 hex digits of its id -/// (PA.f97). Actor tool names are `__`, and a kind may run up -/// to 48 chars, so the full 36-char uuid text a handle used to carry could -/// reach 86 chars against a common 64-char function-name limit; 8 hex -/// digits plus the `__` separator keeps every name well under that limit -/// (`the_longest_possible_actor_tool_name_is_64_chars_or_fewer`) while still -/// reading as the same bracketed id prefix `table_view` prints. The id text -/// itself comes from the library's own `id_text` (PA.f113/PA.f129) — no -/// second, hand-written debug-paren strip here. -/// Collision between two subjects sharing a handle is astronomically -/// unlikely but not impossible; `round_tools` refuses the whole round as a -/// turn fault rather than silently addressing the wrong subject -/// (`find_handle_collision`). -fn handle_for(id: SubjectId) -> String { - id_text(id).chars().filter(char::is_ascii_hexdigit).take(8).collect() -} - -/// Appends one actor tool's own acting subject's label and full canonical id -/// to its description (PA.f98): `dispatch` takes the id exactly as -/// `table_view` prints it, never the short `__` prefix these tools' -/// own names carry, so every actor tool now names the id `dispatch` actually -/// expects, right where the agent reads it. `actor_tools` (`table.rs`) knows -/// nothing of a subject's label; annotating here, over the definitions it -/// already returned, is the smallest fix that does not teach the library -/// vocabulary a Dungeon-only presentation concern. The id itself comes from -/// the library's own `id_text` (PA.f113) — Dungeon no longer keeps its own -/// copy of the debug-paren strip `table_view` uses to print it. -fn annotate_actor_tools(tools: &mut [CodexToolDefinition], snapshot: &WorldSnapshot, subject: SubjectId) { - let label = subject_label(snapshot, subject); - let id = id_text(subject); - for tool in tools { - tool.description = format!("{} Acting subject: {label} [{id}].", tool.description); - } -} - -/// The first colliding handle among `handles`, if any (PA.f97): two entries -/// naming the *same* handle for the *same* subject is not a collision (a -/// subject may legitimately appear once as the player and, in principle, -/// once more via `dispatched`); two entries naming the same handle for -/// *different* subjects is. Pulled apart from `handle_for` itself so the -/// detection algorithm is directly testable without needing two real -/// subject ids that actually collide — vanishingly unlikely to construct -/// from genuine, randomly issued ids, and `SubjectId` mints nothing a -/// consumer crate can construct by hand. -fn find_handle_collision(handles: impl Iterator) -> Option { - let mut seen: std::collections::HashMap = std::collections::HashMap::new(); - for (handle, subject) in handles { - match seen.get(&handle) { - Some(existing) if *existing != subject => return Some(handle), - _ => { - seen.insert(handle, subject); - } - } - } - None -} - -/// The one collision-refusal rule `round_tools` (before inference offers -/// tools) and `execute_round` (resuming an already-decided round, which -/// never calls `round_tools` at all) both must enforce (PA.f97, PA.f105, -/// PA.f118): a handle collision anywhere in the snapshot's own subjects, -/// found by `find_handle_collision` over every subject the snapshot -/// carries, is a turn fault regardless of which subjects this round happens -/// to offer tools for. One shared owner so the two sites cannot drift the -/// way a verbatim copy invites. -fn collision_refusal(snapshot: &WorldSnapshot) -> Option { - find_handle_collision(snapshot.subjects.iter().map(|row| (handle_for(row.id), row.id))).map(|handle| { - format!("two subjects share the handle `{handle}`; the play table cannot address them safely") - }) -} - -/// The tool-name prefix one subject's own actor tools carry: exactly what -/// `round_tools` hands `actor_tools` to generate them, and what -/// `execute_actor_call` hands `decode_actor_call` to parse them back — one -/// prefix, shared, so generation and decode can never drift apart. -fn actor_prefix(subject: SubjectId) -> String { - format!("{}{HANDLE_SEPARATOR}", handle_for(subject)) -} - -fn control_tools() -> Vec { - vec![ - CodexToolDefinition { - name: DISPATCH_TOOL.into(), - description: "Dispatch one or more subjects to act this round, each named by its full id \ -exactly as table_view prints it in brackets — never the short __ prefix an actor tool's own \ -name carries. An id naming no dispatchable subject is refused, quoting the text given." - .into(), - parameters_json: r#"{"type":"object","properties":{"subjects":{"type":"array","items":{"type":"string"}}},"required":["subjects"]}"#.into(), - }, - CodexToolDefinition { - name: ADVANCE_TIME_TOOL.into(), - description: "Advance the world clock by a whole number of fictional minutes.".into(), - parameters_json: r#"{"type":"object","properties":{"minutes":{"type":"integer","minimum":1}},"required":["minutes"]}"#.into(), - }, - CodexToolDefinition { - name: ASK_PLAYER_TOOL.into(), - description: "Stop the turn and ask the player a question.".into(), - parameters_json: r#"{"type":"object","properties":{"question":{"type":"string"}},"required":["question"]}"#.into(), - }, - CodexToolDefinition { - name: END_TURN_TOOL.into(), - description: "End the turn: its consequences are settled.".into(), - parameters_json: r#"{"type":"object","properties":{},"required":[]}"#.into(), - }, - ] -} - -/// Resolves `dispatch`'s own `subjects` argument (PA.f98, PA.f114): each -/// entry is the *full* id `table_view` prints in brackets, matched through -/// the library's own `id_text_matches` — never the short `__` -/// actor-tool prefix, which exists only to keep a generated tool name under a -/// common function-name length limit and was never meant to be an id a caller -/// supplies back. -/// -/// `PLAY_INSTRUCTIONS` promises dispatch refuses bad input rather than -/// silently doing nothing with it, so every shape of bad input returns a -/// refusal in `refusals` that quotes what was given, instead of being -/// dropped: a body that is not valid JSON, a body that is not a JSON object, -/// a missing `subjects` key, a `subjects` that is not an array, a non-string -/// entry, and an entry naming no subject in `snapshot`. -fn parse_dispatch(arguments: &str, snapshot: &WorldSnapshot) -> (Vec, Vec) { - let value = match serde_json::from_str::(arguments) { - Ok(value) => value, - Err(_) => { - return ( - Vec::new(), - vec![format!("dispatch's arguments are not valid JSON: `{arguments}`")], - ); - } - }; - let Value::Object(fields) = value else { - return ( - Vec::new(), - vec![format!("dispatch's arguments are not a JSON object: `{arguments}`")], - ); - }; - let Some(subjects_value) = fields.get("subjects") else { - return ( - Vec::new(), - vec![format!("dispatch's arguments have no `subjects` key: `{arguments}`")], - ); - }; - let Value::Array(items) = subjects_value else { - return ( - Vec::new(), - vec![format!("dispatch's `subjects` is not an array: `{subjects_value}`")], - ); - }; - let mut subjects = Vec::new(); - let mut refusals = Vec::new(); - for item in items { - match item.as_str() { - Some(text) => match snapshot.subjects.iter().find(|subject| id_text_matches(subject.id, text)) { - Some(subject) => subjects.push(subject.id), - None => refusals.push(format!("`{text}` names no dispatchable subject")), - }, - None => refusals.push(format!("`{item}` in `subjects` is not a string")), - } - } - (subjects, refusals) -} - -fn parse_ask_player(arguments: &str) -> String { - serde_json::from_str::(arguments) - .ok() - .and_then(|value| value.get("question").and_then(Value::as_str).map(str::to_owned)) - .unwrap_or_default() -} - -fn parse_minutes(arguments: &str) -> Option { - let value = serde_json::from_str::(arguments).ok()?; - let minutes = value.get("minutes")?.as_u64()?; - TickMinutes::new(u32::try_from(minutes).ok()?) -} - -/// The derived command id for one dispatched Persona's own turn (PA.f66d): -/// hashed from the dispatch tool call's own round and slot plus the -/// subject's own handle, never from the subject's position in that call's -/// own JSON `subjects` array. Two subjects one dispatch call names, or the -/// same subject two dispatch calls in one round name, must never collide — -/// the old `dispatch_slot` collided exactly there, since it read only a -/// position within one call's own list and dropped the call's own identity -/// entirely. -fn dispatch_command_id(turn_id: &str, round: usize, call_slot: usize, subject: SubjectId) -> CommandId { - dispatch_command_id_for_handle(turn_id, round, call_slot, &handle_for(subject)) -} - -/// `dispatch_command_id`'s own hash, taking a bare handle so it is directly -/// testable without minting a `SubjectId` (Dungeon holds no public -/// constructor for one). -fn dispatch_command_id_for_handle(turn_id: &str, round: usize, call_slot: usize, handle: &str) -> CommandId { - let mut hasher = Sha256::new(); - hasher.update(b"ghostlight.dungeon.play.dispatch.v1\n"); - hasher.update(turn_id.as_bytes()); - hasher.update(b"\n"); - hasher.update(round.to_le_bytes()); - hasher.update(call_slot.to_le_bytes()); - hasher.update(b"\n"); - hasher.update(handle.as_bytes()); - let digest = hasher.finalize(); - let uuid = uuid::Uuid::from_slice(&digest[..16]).expect("sha256 digest is at least 16 bytes"); - CommandId::parse_uuid(&uuid.to_string()).expect("a formatted uuid always parses") -} - -/// `PlayTable::retry_delay`'s own production base, in milliseconds -/// (PA.f122): the default `retry_delay_base_ms` starts at, and the value -/// `backoff_delay`'s own doc-pinned formula (`backoff_delay_grows_then_caps`) -/// still assumes. -const RETRY_DELAY_BASE_MS: u64 = 100; - -/// `PlayTable::retry_delay`'s own capped exponential backoff formula -/// (PA.f102, PA.f122): `base_ms * 2^attempt` milliseconds, capped at `CAP`, -/// with `attempt` clamped before the shift so a very large attempt count can -/// never overflow or panic. A free function, always compiled — never hidden -/// behind `#[cfg(test)]` — so it is directly unit-testable in every build, -/// and `retry_delay` always calls exactly this, over `base_ms` alone, -/// whether that base is `RETRY_DELAY_BASE_MS` (production) or whatever a -/// test set it to. -fn backoff_delay(base_ms: u64, attempt: usize) -> Duration { - const CAP: u64 = 20_000; - Duration::from_millis(base_ms.saturating_mul(1u64 << attempt.min(8)).min(CAP)) -} - -/// A round is incomplete when it holds a tool call this table has not yet -/// resolved to a result. -fn round_incomplete(turn: &PlayTurn, round: usize) -> bool { - let Some(output) = turn.rounds.get(round) else { - return false; - }; - let mut slot = 0usize; - for event in output.events() { - if !matches!(event, InferenceEvent::ToolCall { .. }) { - continue; - } - let this_slot = slot; - slot += 1; - if !turn - .call_record(round, this_slot) - .is_some_and(|call| call.result.is_some()) - { - return true; - } - } - false -} - -fn find_ask_player_slot(turn: &PlayTurn, round: usize) -> Option { - turn.calls - .iter() - .find(|call| { - call.round == round && matches!(call.body, Some(RecordedCall::AskPlayer(_))) && call.result.is_none() - }) - .map(|call| call.slot) -} - -/// One refusal, attributed to every call in a run (PA.f54): the run commits -/// atomically, so a decode-time refusal — before any patch is even -/// assembled — is every one of the run's calls own result alike, not just -/// the anchor's. -fn refuse_run(run: &[(usize, String, String, String)], detail: String) -> Vec<(usize, String, RoundOutcome, String)> { - run.iter() - .map(|(slot, call_id, _, _)| (*slot, call_id.clone(), RoundOutcome::Continue, detail.clone())) - .collect() -} - -fn record_call( - turn: &mut PlayTurn, - call_id: &str, - round: usize, - slot: usize, - body: Option, - result: Option, -) { - if let Some(existing) = turn.call_record_mut(round, slot) { - if body.is_some() { - existing.body = body; - } - if result.is_some() { - existing.result = result; - } - return; - } - turn.calls.push(CallRecord { - call_id: call_id.to_owned(), - round, - slot, - body, - result, - }); -} - -/// The connector posture: `[UserText(opening_prompt), ...]` replaying every -/// fully-resolved round before `round` as `AssistantText`/`ToolCall`/`ToolResult` -/// items, exactly as `controllers.rs`'s own multi-round lanes rebuild their -/// conversation from `completed` outputs. -fn rebuild_conversation(turn: &PlayTurn, round: usize) -> Vec { - let mut conversation = vec![CodexInputItem::UserText { - text: turn.opening_prompt.clone(), - }]; - for (index, output) in turn.rounds.iter().enumerate() { - if index >= round { - break; - } - let mut slot = 0usize; - for event in output.events() { - match event { - InferenceEvent::Text(text) => { - if !text.is_empty() { - conversation.push(CodexInputItem::AssistantText { text: text.clone() }); - } - } - InferenceEvent::ToolCall { - call_id, - name, - arguments, - } => { - let this_slot = slot; - slot += 1; - conversation.push(CodexInputItem::ToolCall { - call_id: call_id.clone(), - name: name.clone(), - arguments: arguments.clone(), - }); - let output_text = turn - .call_record(index, this_slot) - .and_then(|call| call.result.clone()) - .unwrap_or_default(); - conversation.push(CodexInputItem::ToolResult { - call_id: call_id.clone(), - output: output_text, - }); - } - } - } - } - conversation -} - -/// Whether an actor call's `text`/`display` are exact quotes from the -/// acting subject's own preserved prose: the player's submitted texts, or a -/// dispatched Persona's own recorded turn (PA-Q8 invariant 4). A player's -/// display need not be spoken; an empty invocation (neither) is exact by -/// vacuity. -fn span_is_exact(invocation: &DecisionInvocation, actor: &Actor, turn: &PlayTurn) -> bool { - let sources: Vec<&str> = if actor.is_player { - turn.player_prose.iter().map(String::as_str).collect() - } else { - turn.persona_turns - .iter() - .filter(|(id, _)| *id == actor.subject) - .map(|(_, persona_turn)| persona_turn.source_prose()) - .collect() - }; - let quotes = [ - invocation.speech.as_ref().map(Statement::as_str), - invocation.display.as_ref().map(Statement::as_str), - ]; - for quote in quotes.into_iter().flatten() { - if !sources.iter().any(|source| SourceSpan::locate(source, quote).is_some()) { - return false; - } - } - true -} - -/// sha256 over the turn id, round, and slot; the first 16 bytes read back as -/// a UUID. Deterministic over `(turn_id, round, slot)`, so a resumed call -/// re-derives the identical command id the kernel's idempotency ledger -/// already knows (`AlreadyApplied`), never a fresh one. -fn derived_command_id(turn_id: &str, round: usize, slot: usize) -> CommandId { - let mut hasher = Sha256::new(); - hasher.update(b"ghostlight.dungeon.play.command.v1\n"); - hasher.update(turn_id.as_bytes()); - hasher.update(b"\n"); - hasher.update(round.to_le_bytes()); - hasher.update(slot.to_le_bytes()); - let digest = hasher.finalize(); - let uuid = uuid::Uuid::from_slice(&digest[..16]).expect("sha256 digest is at least 16 bytes"); - CommandId::parse_uuid(&uuid.to_string()).expect("a formatted uuid always parses") -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use ghostlight::{ - AffordanceId, CreateWorldIntent, InferenceFault, Lens, LensWeights, PreparedInference, - decode_authoring_call, - }; - use ghostlight_persona_projection::PersonaTurnBinding; - use std::{ - collections::{BTreeMap, VecDeque}, - sync::Mutex as StdMutex, - }; - use tokio::task::JoinHandle; - - /// PA.f186's own cross-module seam: `runtime.rs`'s readiness tests need - /// to prove a retirement during `PlayTable::new` reaches `/health` - /// end-to-end, but `STORE_TYPE`/`STORE_KEY`/`STORE_SCHEMA` and - /// `CultCacheEnvelope` are this module's own store internals. Rather than - /// re-importing `cultcache_rs` and duplicating this shape in - /// `runtime.rs`, this writes one row `PlayTurnStore::open` cannot read — - /// the exact same undecodable-bytes shape - /// `a_row_with_the_current_schema_id_and_undecodable_bytes_is_retired_to_a_sidecar_and_play_starts_fresh` - /// seeds below — at `store_path`, before a caller opens a `PlayTable` - /// over it. - pub(crate) fn seed_unreadable_row_for_test(store_path: &std::path::Path) { - let corrupt_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: vec![0xC1], // msgpack's own reserved "never used" marker byte - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }; - let raw_store = OwnedRedbMessagePackBackingStore::new(store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![corrupt_row]).unwrap(), - "seeding the corrupt row must succeed against an empty store" - ); - } - - /// Every `.rs` file under `src`, with everything a `#[cfg(test)]` item - /// removed, the same extent-by-indent scan `app_session.rs`'s own - /// forbidden-writer test uses. - 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; - 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 - } - - /// Pin: Dungeon never builds a `ComponentOp`, `Declaration`, `RoleBinding`, - /// or `ProposedEffect` by hand; everything goes through `table.rs`. - #[test] - fn soul_no_forbidden_writer_name_appears_in_dungeons_own_source() { - for forbidden in ["ComponentOp", "Declaration", "RoleBinding", "ProposedEffect"] { - for (path, source) in production_sources() { - assert!( - !source.contains(forbidden), - "{} names `{forbidden}` directly; Dungeon must compose through table.rs", - path.display() - ); - } - } - } - - /// Pin: `PlayPort`'s constructor is minted in exactly one production - /// site, matching the library's own doc comment on `PlayPort`. The - /// needle is assembled from halves so this assertion's own source, and - /// this file's doc comments about the rule, are never themselves a - /// match — the same precedent `app_session.rs`'s own single-minter test - /// uses for `VerifiedPrincipalEvidence`. - #[test] - fn soul_exactly_one_production_site_mints_play_port() { - let needle = format!("{}::{}(", "PlayPort", "new"); - let mut sites = Vec::new(); - for (path, source) in production_sources() { - for _ in source.match_indices(needle.as_str()) { - sites.push(path.clone()); - } - } - assert_eq!(sites.len(), 1, "the play port constructor appears in: {sites:?}"); - assert!(sites[0].ends_with("play.rs")); - } - - /// Rule: `PLAY_TOOLS` is a real subset of the library's own catalog. - #[test] - fn play_tools_are_all_real_authoring_tools() { - authoring_tools(PLAY_TOOLS).expect("every PLAY_TOOLS name is a known authoring tool"); - assert!(!PLAY_TOOLS.contains(&"admit"), "admit is not a play tool"); - } - - pub(crate) fn test_turn_id(n: u128) -> String { - uuid::Uuid::from_u128(n).to_string() - } - - fn far_future() -> chrono::DateTime { - Utc::now() + chrono::Duration::hours(4) - } - - /// PA.f161: widened to `pub(crate)` (test-only, behind `#[cfg(test)]`) - /// so `runtime.rs`'s own test module can script a deterministic - /// `ask_player` question through the real HTTP route — the only way to - /// exercise `world.play`'s own staleness comparison against a question - /// that actually opened, rather than one hand-built. - pub(crate) struct ScriptedPort { - queue: StdMutex>>, - seen: StdMutex>, - } - - impl ScriptedPort { - pub(crate) fn new(outputs: Vec) -> Arc { - Arc::new(Self { - queue: StdMutex::new(outputs.into_iter().map(Ok).collect()), - seen: StdMutex::new(Vec::new()), - }) - } - - fn seen_requests(&self) -> Vec { - self.seen.lock().unwrap().clone() - } - } - - #[async_trait::async_trait] - impl InferencePort for ScriptedPort { - fn prepare(&self, request: InferenceRequest) -> Result { - self.seen.lock().unwrap().push(request.clone()); - PreparedInference::prepare("play-test-runtime", far_future_unix_ms(), request) - } - - async fn infer(&self, _request: PreparedInference) -> Result { - match self.queue.lock().unwrap().pop_front() { - Some(Ok(output)) => Ok(output), - Some(Err(detail)) => Err(InferenceFault::recovery_required(detail)), - None => Err(InferenceFault::recovery_required("scripted port exhausted")), - } - } - } - - fn far_future_unix_ms() -> u64 { - u64::try_from(far_future().timestamp_millis()).unwrap_or(u64::MAX) - } - - /// PA.f142's own test seam: an `InferencePort` whose `infer` blocks until - /// the test explicitly releases it, so a test can drive a `run` call to - /// a *known* point deep inside `infer_round` — genuinely holding - /// `run_lock` — before firing a second, racing call. Deterministic, - /// unlike relying on `tokio::join!`'s own scheduling to make a race land - /// a particular way. - pub(crate) struct GatedPort { - ready: tokio::sync::Notify, - release: tokio::sync::Notify, - output: StdMutex>, - } - - impl GatedPort { - pub(crate) fn new(output: InferenceOutput) -> Arc { - Arc::new(Self { - ready: tokio::sync::Notify::new(), - release: tokio::sync::Notify::new(), - output: StdMutex::new(Some(output)), - }) - } - - /// Resolves once `infer` has been entered (and is itself now - /// waiting on `release`). - async fn wait_ready(&self) { - self.ready.notified().await; - } - - /// Lets a blocked `infer` call proceed. - fn release(&self) { - self.release.notify_one(); - } - } - - #[async_trait::async_trait] - impl InferencePort for GatedPort { - fn prepare(&self, request: InferenceRequest) -> Result { - PreparedInference::prepare("play-test-runtime", far_future_unix_ms(), request) - } - - async fn infer(&self, _request: PreparedInference) -> Result { - self.ready.notify_one(); - self.release.notified().await; - Ok(self - .output - .lock() - .unwrap() - .take() - .expect("GatedPort's own infer is called exactly once in these tests")) - } - } - - pub(crate) fn output(receipt: &str, events: Vec) -> InferenceOutput { - InferenceOutput::new(events, receipt.to_owned()) - } - - pub(crate) fn text_event(text: &str) -> InferenceEvent { - InferenceEvent::Text(text.to_owned()) - } - - pub(crate) fn call_event(call_id: &str, name: &str, arguments: serde_json::Value) -> InferenceEvent { - InferenceEvent::ToolCall { - call_id: call_id.to_owned(), - name: name.to_owned(), - arguments: arguments.to_string(), - } - } - - struct WorldFixture { - _directory: tempfile::TempDir, - world: WorldMailbox, - principal: VerifiedPrincipalEvidence, - /// The mailbox's own background owner task (PA.f118): kept, not - /// discarded, so a test that needs `self.play.snapshot()` (or any - /// other mailbox call) to fail deterministically can `.abort()` it — - /// aborting drops the task's own channel receiver, so every request - /// already queued or sent afterward resolves `MailboxError::Unavailable` - /// rather than hanging or silently succeeding. Unused by a test that - /// never aborts it, which is most of them. - owner: JoinHandle<()>, - } - - /// A genesis world, activated: one Human "first-person" subject and, - /// when `persona_label` is given, one NarrativePersona subject, both - /// standing in the commons with the kernel's own `speak` grant. Built - /// entirely through `WorldMailbox`'s public door — the same one Cut 8's - /// own play agent uses — so no test fixture reaches into the kernel's - /// private typed vocabulary. - async fn play_world(persona_label: Option<&str>, account_hash: &str) -> WorldFixture { - let directory = tempfile::tempdir().unwrap(); - let (world, owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); - let principal = VerifiedPrincipalEvidence::new(account_hash, far_future()); - let receipt = world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Fixture World".into(), - brief: "A small table for Cut 8a's own tests.".into(), - human_subject_label: "Player".into(), - narrative_persona_label: persona_label.map(str::to_owned), - operational_agent_label: None, - targets: BTreeMap::new(), - jurisdictions: Vec::new(), - lens_weights: LensWeights::new(BTreeMap::from([(Lens::Hearth, 1)])), - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ActivateWorld, - }, - &principal, - ) - .await - .unwrap(); - WorldFixture { - _directory: directory, - world, - principal, - owner, - } - } - - /// PA.f159: `player_subject` must resolve the acting subject by *whose* - /// principal is calling, not merely by finding a `ControllerMode::Human` - /// subject at all. A mutation reverting the added - /// `human_controller.as_ref() == Some(&owner)` clause — leaving only the - /// old `controller_mode == Some(Human)` check — would still find the - /// fixture's own human subject here even though the calling principal is - /// a stranger to this world entirely, so this fails under exactly that - /// mutation. - #[tokio::test] - async fn the_player_subject_follows_the_calling_principal() { - let fixture = play_world(None, "the-real-owner").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - assert!( - player_subject(&snapshot, &fixture.principal).is_some(), - "the world's own owner/player principal must resolve to the human subject" - ); - let stranger = VerifiedPrincipalEvidence::new("a-stranger-principal", far_future()); - assert!( - player_subject(&snapshot, &stranger).is_none(), - "a principal that does not control any subject here must resolve to none, even though a \ - ControllerMode::Human subject exists in this world" - ); - } - - /// Test fixtures build exactly one human-controlled subject, so this - /// stays the older, principal-blind lookup deliberately (Cut 10, - /// PA.f159) rather than threading a principal through the 20-odd call - /// sites below: it is testing "which subject is the fixture's own - /// player", not the production selection rule `player_subject` now - /// enforces (that rule has its own direct coverage in - /// `the_player_subject_follows_the_calling_principal` below). - fn player_id(snapshot: &WorldSnapshot) -> SubjectId { - snapshot - .subjects - .iter() - .find(|subject| subject.controller_mode == Some(ControllerMode::Human)) - .expect("a play fixture's own single human subject") - .id - } - - fn persona_id(snapshot: &WorldSnapshot) -> SubjectId { - snapshot - .subjects - .iter() - .find(|subject| subject.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap() - .id - } - - /// A subject's own full canonical id text, read out of a *real* - /// `table_view` render rather than a test-local copy of its printer - /// (PA.f113): a change to what `table_view` actually prints must break - /// every test that reads an id through this helper, which a fourth - /// hand-spelled debug-paren strip could not do — that copy passed every - /// dispatch test while `table_view`'s own print regressed and every real - /// dispatch an agent could make was refused. Distinct from `handle_for`'s - /// short 8-hex-digit actor handle (PA.f97). A `Ref::Existing` JSON value - /// needs the real id; only a `__` tool name needs the - /// truncated handle. - fn subject_id_text(snapshot: &WorldSnapshot, id: SubjectId) -> String { - let label = snapshot - .subjects - .iter() - .find(|subject| subject.id == id) - .map_or_else(|| "an unknown subject".to_owned(), |subject| subject.label.clone()); - let view = table_view(snapshot); - // The `Places:` section prints each place's occupants by the same - // "label [id]" shape (PA.f74) before the `Subjects:` section proper - // renders the subject's own canonical entry — the entry - // `PLAY_INSTRUCTIONS` and `dispatch` actually mean. Search only from - // the `Subjects:` header so a subject that also occupies a place - // does not read its id back out of the wrong section. - let subjects_section = view - .find("\n Subjects:") - .map_or(view.as_str(), |at| &view[at..]); - let marker = format!("{label} ["); - let start = subjects_section - .find(&marker) - .unwrap_or_else(|| panic!("{label} not found in table_view's Subjects section: {view}")) - + marker.len(); - let end = subjects_section[start..] - .find(']') - .unwrap_or_else(|| panic!("unterminated id bracket after {label} in table_view")); - subjects_section[start..start + end].to_owned() - } - - #[tokio::test] - async fn the_turn_closes_with_the_players_narration() { - let fixture = play_world(None, "player-closes").await; - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - // The player's own narration draws one Projector inference. - let projector = output("proj-0", vec![text_event("The room settles.")]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![projector]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(1), "I look around.".into()) - .await - .unwrap(); - - // PA.f69: the narration text itself, not merely its presence. - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.narration.as_deref(), Some("The room settles.")); - } - - /// Walks a rendered Eve surface for the first node whose own `id` matches - /// — the same small recursive search `eve.rs`'s own tests use, copied - /// rather than shared because it is test-local on both sides. - fn find_surface_node<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node["id"] == id { - return Some(node); - } - node.as_object()? - .values() - .flat_map(|value| match value { - Value::Array(items) => items.iter().collect::>(), - other => vec![other], - }) - .find_map(|child| find_surface_node(child, id)) - } - - /// Cut 9's own card test: the player's own already-committed speech is - /// durable in the world owner's own unscoped operator log — the - /// operator's surface, per that library method's own doc comment, which - /// "no subject-facing lane may reach" — while the turn's own narration is - /// a *different*, player-facing channel the play table produces. - /// `eve::authenticated_surface`'s play card must show the narration and - /// never the operator feed's own text, proving invariant 8's "nothing - /// else": the player sees a projection, the question, and the refusal of - /// their own act, never an unscoped event log. - /// - /// Mutation M9.1 named "render that feed into the card" as the mutant - /// this test kills. It is retired, not re-aimed (Cut 10, PA.f155): Cut 9 - /// had already deleted the operator-feed parameter `authenticated_surface` - /// once took, so a mutation that "renders the feed into the card" cannot - /// be expressed as a code change against the current signature — there is - /// no parameter left to wire in. The test's own assertions below (the - /// card renders narration/question/refusal and nothing else) remain a - /// live defense of invariant 8 against whatever *can* still leak — most - /// concretely, a future call site that starts threading operator-log text - /// through `narration`/`refusal` themselves. - #[tokio::test] - async fn the_play_surface_shows_only_the_projection() { - let fixture = play_world(None, "player-projection-only").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let opportunity = snapshot - .opportunities - .iter() - .find(|opportunity| opportunity.scope.subject_id == player) - .cloned() - .expect("the player's own speak opportunity"); - let affordance_id = snapshot - .affordances - .iter() - .find(|entry| { - entry.entry.kind.0 == "speak" - && snapshot - .subjects - .iter() - .any(|subject| subject.id == player && subject.affordances.contains(&entry.id)) - }) - .map(|entry| entry.id) - .expect("the player's own speak affordance"); - fixture - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: snapshot.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ExerciseDecision { - opportunity, - invocation: DecisionInvocation { - affordance: affordance_id, - bindings: Vec::new(), - proposed: Vec::new(), - speech: Some( - Statement::new("A voice the play table never narrated.".to_owned()) - .unwrap(), - ), - display: None, - }, - }, - }, - &fixture.principal, - ) - .await - .unwrap(); - let durable_log = fixture.world.operator_log().await.unwrap(); - assert_eq!(durable_log.len(), 1, "the committed speech must be durable in the world owner's own feed"); - - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The room settles into quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(90), "I look around.".into()) - .await - .unwrap(); - let view = table.current_turn_view().await.unwrap(); - assert_eq!(view.narration.as_deref(), Some("The room settles into quiet.")); - - let world = fixture.world.snapshot().await.unwrap(); - // `authenticated_surface` takes no operator feed at all any more - // (Cut 9's own deletion commit removed that parameter along with the - // card that once read it) — the leaking text above is durable and - // real, and this call has structurally no way to reach it. - let surface = - crate::eve::authenticated_surface("player-projection-only", Some(&world), Some(&view), true) - .unwrap(); - let play_card = find_surface_node(&surface, "world.play.card").expect("the play card"); - let play_card_encoded = serde_json::to_string(play_card).unwrap(); - assert!( - play_card_encoded.contains("The room settles into quiet."), - "the card must show the turn's own narration: {play_card_encoded}" - ); - assert!( - !play_card_encoded.contains("A voice the play table never narrated."), - "the play card must never render the operator log's own speech, even though it is durably \ - committed to the world owner's own unscoped operator log: {play_card_encoded}" - ); - // Every other `world.play.*` node too — the text control and the - // and the button — not only the card itself. - for id in ["world.play.answers", "world.play.text", "world.play"] { - if let Some(node) = find_surface_node(&surface, id) { - let encoded = serde_json::to_string(node).unwrap(); - assert!( - !encoded.contains("A voice the play table never narrated."), - "{id} must never carry the operator log's own speech: {encoded}" - ); - } - } - } - - /// PA.f193-D ("the interrupted turn"; renumbered from the PA.f188 - /// duplicate this comment used to carry — PA.f188 elsewhere in this file - /// names only `open_question_of`'s own construction-site token - /// coverage): a turn a process died mid-round leaves `Running` forever — - /// the store's own single-row shape never resumes it on its own, and the - /// admit arm at `run`'s own `Some(existing) if existing.state == - /// PlayTurnState::Running` match continues it only on an empty-text - /// submit (non-empty text is refused as `TurnStillRunning`). Before this - /// fix the card showed nothing at all in that state — no narration, no - /// question, no refusal, since none of those get set on a turn that - /// never reached a close, a question, or a refused act since it last - /// opened — so a player had no way to learn the empty-submit continue - /// exists. The card now names it explicitly while `Running` and nothing - /// is actively working on the turn (`run_in_progress` is false — see - /// `the_running_hint_does_not_appear_while_a_round_is_actually_in_flight`), - /// and says nothing extra once the turn is `AwaitingPlayer` or `Closed` - /// again. - /// - /// Mutation: delete the `if play.is_some_and(|view| view.state == - /// PlayTurnState::Running && !view.run_in_progress) { ... }` block from - /// `eve.rs`'s play-card construction — the `running_hint` lookup below - /// then finds nothing and the first assertion fails. - #[tokio::test] - async fn the_play_card_names_the_empty_submit_continue_while_the_turn_is_running() { - let fixture = play_world(None, "player-interrupted-turn").await; - let world = fixture.world.snapshot().await.unwrap(); - - let running_view = PlayTurnView { - turn_id: test_turn_id(9300), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-interrupted-turn", - Some(&world), - Some(&running_view), - true, - ) - .unwrap(); - let running_hint = find_surface_node(&surface, "world.play.running") - .expect("a Running turn nothing is working on must render a hint naming the empty-submit continue"); - let encoded = serde_json::to_string(running_hint).unwrap(); - assert!( - encoded.to_lowercase().contains("empty"), - "the hint must actually name the empty-submit continue, not just say something is \ - happening: {encoded}" - ); - - let awaiting_view = PlayTurnView { - turn_id: test_turn_id(9301), - state: PlayTurnState::AwaitingPlayer, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let awaiting_surface = crate::eve::authenticated_surface( - "player-interrupted-turn", - Some(&world), - Some(&awaiting_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&awaiting_surface, "world.play.running").is_none(), - "the running hint must not appear once the turn has left Running" - ); - } - - /// PA.f193-D: the hint the previous test proves for an idle `Running` - /// turn must not also render while something is actively working on that - /// turn — `admit`'s own `run_lock` is held, so `admit` refuses even an - /// empty continue with `TurnBusy` before it ever inspects the request, - /// directly contradicting a hint that told the player an empty submit - /// would be accepted. Soul measured this: the card rendered the hint for - /// every `Running` turn regardless of whether a round was genuinely in - /// flight. - /// - /// Mutation: change the play-card construction's gate back to `play - /// .is_some_and(|view| view.state == PlayTurnState::Running)` (drop the - /// `&& !view.run_in_progress`) — the assertion below then fails, because - /// the hint reappears for a turn something is actively running. - #[tokio::test] - async fn the_running_hint_does_not_appear_while_a_round_is_actually_in_flight() { - let fixture = play_world(None, "player-interrupted-turn-busy").await; - let world = fixture.world.snapshot().await.unwrap(); - - let busy_view = PlayTurnView { - turn_id: test_turn_id(9302), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: true, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-interrupted-turn-busy", - Some(&world), - Some(&busy_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&surface, "world.play.running").is_none(), - "the running hint must not appear while a round is actually in flight: it would tell \ - the player to submit the exact empty continue admit is about to refuse as TurnBusy" - ); - } - - /// PA.f194-D: with the running hint above correctly gated off while a - /// round is genuinely in flight, the card rendered nothing at all for the - /// whole span between the player submitting a turn and its own - /// narration/question/refusal landing — none of those are touched by the - /// round in progress until it closes or opens a question, so a player who - /// submits and watches an unchanged or empty card has no sign anything is - /// happening. This row renders for exactly the span the previous test - /// proves the running hint must not: whenever this player's own turn is - /// actively being worked on (`run_in_progress`), regardless of `state`. - /// - /// Mutation: delete the `if play.is_some_and(|view| view.run_in_progress) - /// { ... }` block that pushes `world.play.resolving` from `eve.rs`'s - /// play-card construction — the lookup below then finds nothing and the - /// first assertion fails. - #[tokio::test] - async fn the_card_shows_a_resolving_row_while_a_round_is_in_flight() { - let fixture = play_world(None, "player-round-in-flight").await; - let world = fixture.world.snapshot().await.unwrap(); - - let busy_view = PlayTurnView { - turn_id: test_turn_id(9303), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: true, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-round-in-flight", - Some(&world), - Some(&busy_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&surface, "world.play.resolving").is_some(), - "the card must render something while the player's own turn is actively being worked \ - on, not sit blank for the whole round" - ); - - let idle_view = PlayTurnView { - turn_id: test_turn_id(9304), - state: PlayTurnState::AwaitingPlayer, - question: None, - narration: Some("The hall falls quiet.".into()), - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let idle_surface = crate::eve::authenticated_surface( - "player-round-in-flight", - Some(&world), - Some(&idle_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&idle_surface, "world.play.resolving").is_none(), - "the resolving row must not linger once nothing is actively running" - ); - } - - /// PA.f194-D: the surviving running hint's own text used to claim "Your - /// last turn is still being resolved" — false in the only case it now - /// renders (`Running`, nothing actively working on it, PA.f193-D's own - /// `run_in_progress` gate). Its wording must describe reality: the turn - /// stalled, not that it is still in flight — "still being resolved" is - /// now what `world.play.resolving` above says instead. - /// - /// Mutation: revert the hint's text back to "Your last turn is still - /// being resolved..." — the assertion below fails. - #[tokio::test] - async fn the_running_hints_own_text_does_not_claim_the_turn_is_still_in_flight() { - let fixture = play_world(None, "player-stalled-turn-text").await; - let world = fixture.world.snapshot().await.unwrap(); - let running_view = PlayTurnView { - turn_id: test_turn_id(9305), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-stalled-turn-text", - Some(&world), - Some(&running_view), - true, - ) - .unwrap(); - let running_hint = find_surface_node(&surface, "world.play.running") - .expect("a Running turn nothing is working on must still render the hint"); - let encoded = serde_json::to_string(running_hint).unwrap(); - assert!( - !encoded.to_lowercase().contains("still being resolved"), - "the hint must not claim the turn is still being resolved when nothing is working on \ - it: {encoded}" - ); - } - - /// PA.f134's own end-to-end proof: answering *through the turn view's own - /// `question.id`* — the id `current_turn_view` actually exposes, never - /// one this test built — resumes the turn, and the closed turn's - /// narration is then visible through the same card. Cut 10 (PA.f151) - /// deleted the card's own rendered id control (the lowering had no way - /// to carry it without leaking it); `runtime.rs::execute_world`'s - /// `world.play` arm is the one production caller that resolves this id - /// now, the same way this test does. Also proves the play row's own - /// revision claim: the world commits nothing across this whole exchange - /// (`ask_player`/`end_turn` are conversational, not kernel patches), so - /// `world.revision` — and so the surface document's own `version` - /// (PA.f148) — never moves, yet `PlayTurnView::revision` still does, on - /// both the question opening and the turn closing. - #[tokio::test] - async fn a_question_is_answered_through_the_cards_own_binding_and_the_turn_closes_with_narration() { - let fixture = play_world(None, "player-card-question").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The hall falls quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask, end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) - .await - .unwrap(); - let asked_view = table.current_turn_view().await.unwrap(); - assert_eq!( - asked_view.question.as_ref().map(|question| question.text.as_str()), - Some("Which way?") - ); - - let world_before = fixture.world.snapshot().await.unwrap(); - let surface_before = crate::eve::authenticated_surface( - "player-card-question", - Some(&world_before), - Some(&asked_view), - true, - ) - .unwrap(); - - let question_row = find_surface_node(&surface_before, "world.play.question") - .expect("the question row"); - assert_eq!(question_row["props"]["value"], "Which way?"); - assert!( - find_surface_node(&surface_before, "world.play.answers").is_none(), - "Cut 10 deleted the id-carrying control; nothing on the surface names the question" - ); - // The exact id `current_turn_view` exposes — the one a production - // caller (`runtime.rs::execute_world`) resolves server-side now, - // never a value the player's own client supplies. - let answers: QuestionId = asked_view.question.as_ref().unwrap().id.clone(); - - table - .run( - &fixture.principal, - test_turn_id(92), - PlayRequest { - text: "I go left.".into(), - answers: Some(answers), - }, - ) - .await - .unwrap(); - - let closed_view = table.current_turn_view().await.unwrap(); - assert_eq!(closed_view.state, PlayTurnState::Closed); - assert_eq!(closed_view.question, None); - assert_eq!(closed_view.narration.as_deref(), Some("The hall falls quiet.")); - assert!( - closed_view.revision > asked_view.revision, - "the row's own revision must move again once the turn closes: {} -> {}", - asked_view.revision, - closed_view.revision - ); - - let world_after = fixture.world.snapshot().await.unwrap(); - assert_eq!( - world_after.revision, world_before.revision, - "asking and answering a question are conversational; neither commits to the world" - ); - - let surface_after = crate::eve::authenticated_surface( - "player-card-question", - Some(&world_after), - Some(&closed_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&surface_after, "world.play.question").is_none(), - "no question is open any more; the card must not still show one" - ); - // PA.f159: the subject census is gone from `world.summary` — phase - // and clock stay, but who else is in the world is not the player's - // own projection. - let summary = find_surface_node(&surface_after, "world.summary").expect("the summary card"); - assert_eq!(summary["children"], serde_json::json!([])); - let narration_row = find_surface_node(&surface_after, "world.play.narration") - .expect("the narration row"); - assert_eq!(narration_row["props"]["value"], "The hall falls quiet."); - assert_eq!( - surface_before["version"], surface_after["version"], - "the document's own version names world.revision alone, and it never moved" - ); - } - - /// PA.f170: `execute_round`'s `ASK_PLAYER_TOOL` arm mints a fresh - /// `question_token` every time a question opens, never reusing one - /// across two distinct questions in the same turn. The version it - /// replaces (PA.f161's `surface_version`) could not make this promise: - /// asking is conversational and never moves the world's own revision, so - /// two questions opened in the same turn — even in two different rounds, - /// with nothing committed between them — would have recorded the same - /// version. This is the token's own reason to exist: it, not a version, - /// is what tells the two questions apart at `runtime.rs`'s own boundary. - /// - /// Mutation: have `execute_round`'s `ASK_PLAYER_TOOL` arm reuse - /// `turn.question_token` when it is already `Some` instead of calling - /// `mint_question_token()` unconditionally — this test's two tokens - /// would then be equal. - #[tokio::test] - async fn two_questions_in_one_turn_mint_two_distinct_tokens() { - let fixture = play_world(None, "player-two-questions-distinct-tokens").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask_one = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let ask_two = output( - "r1", - vec![call_event( - "c1", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Are you sure?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask_one, ask_two]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) - .await - .unwrap(); - let first_view = table.current_turn_view().await.unwrap(); - let first_question = first_view.question.clone().expect("Q1 must be open"); - assert_eq!(first_question.text, "Which way?"); - - table - .run( - &fixture.principal, - test_turn_id(92), - PlayRequest { - text: "I go left.".into(), - answers: Some(first_question.id.clone()), - }, - ) - .await - .unwrap(); - let second_view = table.current_turn_view().await.unwrap(); - let second_question = second_view.question.clone().expect("Q2 must be open"); - assert_eq!(second_question.text, "Are you sure?"); - - assert_ne!( - first_question.token, second_question.token, - "two distinct questions in the same turn must never share an answer token" - ); - } - - /// PA.f173: `close_with_fault` used to leave `question` and - /// `question_token` set on the turn it closed, though a `Closed` turn - /// should never still carry an open question. This was unobservable - /// through `current_turn_view`/`open_question_id` only because both gate - /// on `state == AwaitingPlayer`, which closing always leaves behind in - /// the same write — so this test reaches past that gate, at the raw - /// persisted row. - /// - /// Mutation: remove the `turn.question = None; turn.question_token = - /// None;` lines from `close_with_fault` — the persisted row would then - /// still carry the question's own text and token after the turn closed, - /// and this test's assertions would fail. - #[tokio::test] - async fn close_with_fault_clears_the_open_question_off_the_persisted_row() { - let fixture = play_world(None, "player-fault-clears-question").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) - .await - .unwrap(); - let asked_view = table.current_turn_view().await.unwrap(); - assert_eq!(asked_view.state, PlayTurnState::AwaitingPlayer); - - let mut turn = table.store.lock().await.current().unwrap().clone(); - assert!(turn.question.is_some()); - assert!(turn.question_token.is_some()); - table - .close_with_fault(&mut turn, "manufactured fault for PA.f173".to_owned()) - .await - .unwrap(); - - let persisted = table.store.lock().await.current().unwrap().clone(); - assert_eq!(persisted.state, PlayTurnState::Closed); - assert_eq!( - persisted.question, None, - "a closed turn must not still carry the question it closed on top of" - ); - assert_eq!( - persisted.question_token, None, - "a closed turn must not still carry the question's own answer token" - ); - } - - /// A pre-Cut-11 process's own `PlayTurn`, replicated field-for-field - /// minus `question_surface_version`/`question_token` — the shape every - /// row actually written under `LEGACY_STORE_SCHEMA_V1` carries (PA.f172). - /// Test-only: no production writer ever mints this shape again. - #[derive(Clone, Serialize)] - struct LegacyPlayTurnV1 { - turn_id: String, - applied_keys: Vec, - opening_prompt: String, - player_prose: Vec, - rounds: Vec, - calls: Vec, - persona_turns: Vec<(SubjectId, PersonaTurn)>, - question: Option, - refusal: Option, - narration: Option, - fault: Option, - state: PlayTurnState, - } - - #[derive(Clone, Serialize)] - struct LegacyPlayTurnStoreStateV1 { - schema: String, - turn: Option, - ledger: KeyLedger, - revision: u64, - } - - /// A between-PA.f172-and-PA.f170 process's own `PlayTurn`: it has - /// `question_surface_version` (deleted by this cut) but not - /// `question_token` — the shape every row actually written under - /// `LEGACY_STORE_SCHEMA_V2` carries. Test-only: no production writer - /// ever mints this shape again. - #[derive(Clone, Serialize)] - struct LegacyPlayTurnV2 { - turn_id: String, - applied_keys: Vec, - opening_prompt: String, - player_prose: Vec, - rounds: Vec, - calls: Vec, - persona_turns: Vec<(SubjectId, PersonaTurn)>, - question: Option, - question_surface_version: Option, - refusal: Option, - narration: Option, - fault: Option, - state: PlayTurnState, - } - - #[derive(Clone, Serialize)] - struct LegacyPlayTurnStoreStateV2 { - schema: String, - turn: Option, - ledger: KeyLedger, - revision: u64, - } - - /// PA.f172: a row written before `question_token` existed — under - /// `LEGACY_STORE_SCHEMA_V1`, the schema id every pre-Cut-11 process - /// actually wrote (it is exactly what `STORE_SCHEMA` used to be) — is - /// recognized by `PlayTurnStore::open` as legacy, not corrupt, and its - /// turn recovered rather than discarded: the missing field decodes to - /// `None`, exactly what "no question ever recorded a token" means for a - /// turn this old. The row is also upgraded to `STORE_SCHEMA` in place, - /// durably, so the legacy path is only ever taken once. - /// - /// Mutation: remove the `schema_id.as_deref() == - /// Some(LEGACY_STORE_SCHEMA_V1)` arm from `PlayTurnStore::open` — this - /// row would then fall through to the catch-all and `open` would return - /// `Err`, exactly PA.f172's own bug: a workstation's pre-Cut-11 row - /// silently disables play with "the play table is unavailable". - #[tokio::test] - async fn a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV1 { - turn_id: "11111111-1111-1111-1111-111111111111".into(), - applied_keys: vec!["key-a".into()], - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let legacy_state = LegacyPlayTurnStoreStateV1 { - schema: LEGACY_STORE_SCHEMA_V1.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 3, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a genuine pre-Cut-11 row must open, not be refused as corrupt"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.turn_id, "11111111-1111-1111-1111-111111111111"); - assert_eq!(recovered.opening_prompt, "You stand at the gate."); - assert_eq!( - recovered.question_token, None, - "a pre-Cut-11 turn never recorded a token, and its own question is not open (Running); \ - the missing field must decode to None and no token must be minted for it" - ); - assert_eq!( - store.row.schema_id.as_deref(), - Some(STORE_SCHEMA), - "the legacy row must be upgraded to the current schema in place, not left at v1" - ); - - // Reopening must now take the strict current-schema path, proving - // the upgrade was actually durably persisted, not only held in - // memory on the first `open`. `store` must be dropped first: CultCache's - // own single-owner lock refuses a second `OwnedRedbMessagePackBackingStore::new` - // on the same path while the first is still held (the same custody - // rule `pull_current_from_disk_for_test`'s own doc comment names). - let recovered_turn_id = recovered.turn_id.clone(); - drop(store); - let reopened = PlayTurnStore::open(&store_path).unwrap(); - assert_eq!(reopened.current().unwrap().turn_id, recovered_turn_id); - } - - /// PA.f170: the same recognition and in-place upgrade `a_legacy_v1_row_*` - /// proves, for a row written between PA.f172 and PA.f170 instead — it - /// carries `question_surface_version` (now deleted, silently ignored on - /// decode) but not `question_token`. - /// - /// Mutation: remove the `schema_id.as_deref() == - /// Some(LEGACY_STORE_SCHEMA_V2)` arm from `PlayTurnStore::open` — this - /// row would then fall through to the catch-all and `open` would return - /// `Err`, silently disabling play on any workstation that ran Dungeon - /// between Cut 11 and Cut 13. - #[tokio::test] - async fn a_legacy_v2_row_is_recognized_and_upgraded_not_refused_as_corrupt() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV2 { - turn_id: "22222222-2222-2222-2222-222222222222".into(), - applied_keys: vec!["key-b".into()], - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_surface_version: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let legacy_state = LegacyPlayTurnStoreStateV2 { - schema: LEGACY_STORE_SCHEMA_V2.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 4, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V2.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a genuine v2 row must open, not be refused as corrupt"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.turn_id, "22222222-2222-2222-2222-222222222222"); - assert_eq!( - recovered.question_token, None, - "a v2 turn never recorded a token, and its own question is not open (Running); \ - the missing field must decode to None and no token must be minted for it" - ); - assert_eq!( - store.row.schema_id.as_deref(), - Some(STORE_SCHEMA), - "the legacy row must be upgraded to the current schema in place, not left at v2" - ); - } - - /// PA.f170: an upgraded row whose question is still open gets a freshly - /// minted token as part of the upgrade — no earlier schema ever recorded - /// one, and the card is re-rendered with whatever the row holds, so a - /// player returning to an upgraded session (Raven's workstation, named in - /// the cut's own spec) must still be able to answer. - /// - /// Mutation: remove the mint-if-open branch from `upgrade_legacy_row` — - /// the recovered turn's own `question_token` would then stay `None` while - /// `AwaitingPlayer`, and the answer this test goes on to submit would be - /// refused as stale by `runtime.rs`'s own boundary (no token to match) - /// even though the question is genuinely still open. - #[tokio::test] - async fn an_upgraded_legacy_row_whose_question_is_still_open_mints_a_fresh_token() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV1 { - turn_id: "33333333-3333-3333-3333-333333333333".into(), - applied_keys: vec!["key-c".into()], - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: vec![output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - let legacy_state = LegacyPlayTurnStoreStateV1 { - schema: LEGACY_STORE_SCHEMA_V1.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 5, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a genuine pre-Cut-11 row must open, not be refused as corrupt"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.state, PlayTurnState::AwaitingPlayer); - let token = recovered - .question_token - .clone() - .expect("an upgraded row whose question is still open must mint a fresh token"); - assert!(!token.is_empty(), "a minted token must not be the empty-string fallback"); - } - - /// PA.f184: a row that matches the *current* schema id but whose bytes do - /// not decode at all used to leave `open` refusing forever — `open_play` - /// caught the `Err`, logged it, and `state.play` stayed `None` across - /// every future restart, exactly the PA.f177 gap this fix closes for the - /// arm PA.f177 itself did not touch. It is now retired to a sidecar file - /// beside the store, the same path a damaged legacy row already took, so - /// the play card comes back (empty) instead of staying gone. - /// - /// Mutation: change `read_current_schema_row`'s decode line back to a - /// bare `rmp_serde::from_slice(&row.payload)?` (or otherwise make this - /// arm `bail!` instead of calling `retire_unreadable_row`) — `open` then - /// returns `Err` for this row and `store.expect(...)` below panics. - #[tokio::test] - async fn a_row_with_the_current_schema_id_and_undecodable_bytes_is_retired_to_a_sidecar_and_play_starts_fresh() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - seed_unreadable_row_for_test(&store_path); - - let store = PlayTurnStore::open(&store_path) - .expect("a damaged current-schema row must be retired, not fail open outright"); - assert!( - store.current().is_none(), - "play must start fresh with no current turn once the old row is retired" - ); - - let sidecar = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .find(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")) - .expect("the damaged row must be written to a sidecar file beside the store"); - assert!( - sidecar.file_name().to_string_lossy().ends_with(".json"), - "the sidecar must be named for what it is (PA.f192), not `.cc`: {:?}", - sidecar.file_name() - ); - let sidecar_bytes = std::fs::read(sidecar.path()).unwrap(); - assert!(!sidecar_bytes.is_empty(), "the sidecar must actually carry the retired row's bytes"); - - // Soul's owed item 5: the sidecar must actually be readable, and the - // original bytes must actually be recoverable from it — not merely - // "the file is non-empty". Parse it as JSON, decode `payload_base64`, - // and prove the result is byte-for-byte `seed_unreadable_row_for_test`'s - // own `vec![0xC1]`. - let decoded: serde_json::Value = serde_json::from_slice(&sidecar_bytes) - .expect("the sidecar must be readable JSON, not another CultCache-shaped blob"); - let payload_base64 = decoded["payload_base64"] - .as_str() - .expect("the sidecar must carry the original payload as a base64 string field"); - let recovered_payload = base64::engine::general_purpose::STANDARD - .decode(payload_base64) - .expect("payload_base64 must actually decode as base64"); - assert_eq!( - recovered_payload, - vec![0xC1], - "the sidecar's payload_base64 must decode to the exact original bytes, not a lossy copy" - ); - - // Reopening must not hit the same row again: it was cleared from the - // store as part of retiring it, not merely skipped this once. - drop(store); - let reopened = PlayTurnStore::open(&store_path) - .expect("reopening after retirement must not refuse the store again"); - assert!(reopened.current().is_none()); - let sidecars_after_reopen = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .filter(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")) - .count(); - assert_eq!( - sidecars_after_reopen, 1, - "a clean reopen after retirement must not itself perform a fresh retirement and write a \ - second sidecar" - ); - } - - /// PA.f184: a single row under this store's own `type`/`key` but a - /// schema id no arm recognizes — a future schema this build has never - /// seen — is retired exactly like a damaged row, not left bailing - /// forever with "foreign or otherwise-unreadable records". - /// - /// Mutation: delete the catch-all `[row] if row.r#type == STORE_TYPE && - /// row.key == STORE_KEY => { ... }` arm from `PlayTurnStore::open` — this - /// row then falls through to the final `_ => bail!(...)` and - /// `store.expect(...)` below panics. - #[tokio::test] - async fn a_row_declaring_an_unrecognized_future_schema_is_retired_not_refused_forever() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let future_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: vec![1, 2, 3], - stored_at: Utc::now().to_rfc3339(), - schema_id: Some("ghostlight.play_turn_store.v4".into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![future_row]).unwrap(), - "seeding the future-schema row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("an unrecognized future schema must be retired, not fail open outright"); - assert!(store.current().is_none()); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .any(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "the future-schema row must be written to a sidecar file beside the store" - ); - } - - /// PA.f194-A: Soul's own probe — a row declaring a schema id this build - /// has never seen (an Idunn rollback across a schema-id move landing - /// this build in front of a row a newer build wrote) whose payload still - /// decodes cleanly into the current `PlayTurnStoreState` shape, holding - /// an open question and its token. Before this fix the catch-all arm - /// retired every unrecognized schema id on the id alone, with no decode - /// attempted — this canonical, perfectly readable row lost its one open - /// turn, and the sidecar's own `reason` field falsely claimed the row - /// "could not be read". Now `open` attempts the decode first, the decode - /// succeeds, and `open` fails loudly instead of destroying a readable - /// turn — the same shape the current- and legacy-schema arms already use - /// for their own `Other` cases. - /// - /// Mutation: delete the `match rmp_serde::from_slice::(&row.payload)` - /// decode attempt from the catch-all arm (or its `Ok(_) => return Err(...)` - /// case) and route straight to `retire_unreadable_row` again — `open` then - /// succeeds with `current().is_none()`, a sidecar appears, and the - /// assertions below fail. - #[tokio::test] - async fn a_readable_row_declaring_an_unrecognized_future_schema_is_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let turn = PlayTurn { - turn_id: "44444444-4444-4444-4444-444444444444".into(), - applied_keys: vec!["key-d".into()], - opening_prompt: "You stand at a crossroads.".into(), - player_prose: vec!["I look around.".into()], - rounds: vec![output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - question_token: Some("future-schema-token".into()), - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - let future_state = PlayTurnStoreState { - schema: "ghostlight.play_turn_store.v4".into(), - turn: Some(turn), - ledger: KeyLedger::default(), - revision: 7, - }; - let future_payload = rmp_serde::to_vec_named(&future_state).unwrap(); - // Sanity: the fixture really is readable under the current shape, so - // this test exercises the "decodes cleanly" branch and not a decode - // failure in disguise. - rmp_serde::from_slice::(&future_payload) - .expect("the test fixture must actually decode as a valid PlayTurnStoreState"); - - let future_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: future_payload, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some("ghostlight.play_turn_store.v4".into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![future_row]).unwrap(), - "seeding the readable future-schema row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("a readable future-schema row must not silently succeed, and must not be retired"), - Err(error) => error, - }; - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("decodes cleanly") || full_chain.contains("v4"), - "the failure must name that the row is readable, not claim it could not be read: {full_chain}" - ); - - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable future-schema row must not be retired to a sidecar" - ); - - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original readable row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some("ghostlight.play_turn_store.v4")); - } - - /// PA.f193-A: Soul's own probe — a row declaring the *current* - /// `STORE_SCHEMA` whose bytes decode into a perfectly valid - /// `PlayTurnStoreState` (every field reads back, including the turn - /// itself) but whose on-disk field order is not what this build's own - /// `to_vec_named` would produce, so the canonical re-encode check - /// disagrees. Before this fix `read_current_schema_row` returned a plain - /// `anyhow::Error` for this exactly like a genuine decode failure, and - /// `open` retired the row, destroying a readable turn. Now `open` returns - /// `Err` and leaves the row untouched. - /// - /// Mutation: change `Err(CurrentSchemaRowError::Other(error)) => return - /// Err(error)...` back to routing through `retire_unreadable_row` (or - /// collapse `CurrentSchemaRowError` back into a single `anyhow::Result` - /// the way it was before PA.f193-A) — the row is then retired, a sidecar - /// appears, and the assertions below fail. - #[tokio::test] - async fn a_current_schema_rows_non_canonical_bytes_are_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - // Field order alphabetical (ledger, revision, schema, turn) rather - // than `PlayTurnStoreState`'s own declared order (schema, turn, - // ledger, revision): `rmp_serde` decodes a named-struct map by key - // regardless of wire order, so this still parses into a valid state, - // but re-encoding it through the real struct produces different - // bytes than what is on disk. - let mut state_fields = std::collections::BTreeMap::new(); - state_fields.insert("ledger", serde_json::to_value(KeyLedger::default()).unwrap()); - state_fields.insert("revision", Value::Number(5.into())); - state_fields.insert("schema", Value::String(STORE_SCHEMA.into())); - state_fields.insert("turn", Value::Null); - let state_json = Value::Object(state_fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); - let payload = rmp_serde::to_vec_named(&state_json).unwrap(); - - // Sanity: this payload really does decode into a valid state, so the - // test is actually exercising the canonical check and not a decode - // failure in disguise. - let decoded: PlayTurnStoreState = rmp_serde::from_slice(&payload) - .expect("the hand-ordered payload must still decode as a valid PlayTurnStoreState"); - assert!(rmp_serde::to_vec_named(&decoded).unwrap() != payload, "the test fixture must actually be non-canonical"); - - let row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![row]).unwrap(), - "seeding the non-canonical row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("a non-canonical current-schema row must not silently succeed either"), - Err(error) => error, - }; - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("canonical"), - "the failure must name the canonical mismatch: {full_chain}" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable turn must not be retired over a non-canonical re-encode alone" - ); - - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some(STORE_SCHEMA)); - } - - /// PA.f193-B: a row declaring the current `STORE_SCHEMA` in its envelope, - /// canonical on the wire, but whose inner `schema` field names something - /// else — the same class of disagreement PA.f185 already covers for the - /// legacy arms, here for the current-schema arm PA.f185 did not touch. - /// The row decodes fine; only the inner tag disagrees. Before this fix - /// that reached `retire_unreadable_row` exactly like a decode failure. - /// - /// Mutation: delete the `if state.schema != STORE_SCHEMA` check from - /// `read_current_schema_row` (or route its `Other` arm back through - /// `retire_unreadable_row`) — the assertions below fail: with the check - /// deleted `open` succeeds instead of refusing; with retirement restored - /// a sidecar appears. - #[tokio::test] - async fn a_current_schema_rows_inner_schema_disagreeing_with_its_envelope_is_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let state = PlayTurnStoreState { - schema: LEGACY_STORE_SCHEMA_V1.into(), - turn: None, - ledger: KeyLedger::default(), - revision: 7, - }; - let payload = rmp_serde::to_vec_named(&state).unwrap(); - let row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload, - stored_at: Utc::now().to_rfc3339(), - // Envelope says current schema; the payload's own inner `schema` - // field says otherwise (LEGACY_STORE_SCHEMA_V1 above) — the exact - // disagreement this arm exists to catch. - schema_id: Some(STORE_SCHEMA.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![row]).unwrap(), - "seeding the mismatched-inner-schema row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("an inner-schema mismatch must not silently succeed either"), - Err(error) => error, - }; - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("schema"), - "the failure must name the schema mismatch: {full_chain}" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable turn must not be retired over an inner-schema tag mismatch alone" - ); - - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some(STORE_SCHEMA)); - } - - /// PA.f185: Soul's own probe — a row whose envelope declares - /// `LEGACY_STORE_SCHEMA_V1` but whose inner `schema` field says - /// `LEGACY_STORE_SCHEMA_V2` decodes its turn perfectly - /// (`opening_prompt` reads back intact), yet before this fix `open` - /// routed the inner-schema mismatch into `retire_unreadable_row` anyway, - /// destroying a readable turn under a sidecar reason ("did not decode - /// even leniently") that was false. Now `open` returns `Err` and leaves - /// the row untouched: no sidecar, no destructive compare-and-swap, - /// because an inconsistent inner tag is not the same failure as bytes - /// that will not parse at all. - /// - /// Mutation: change `Err(LegacyRowError::Other(error)) => return - /// Err(error)...` (in either legacy arm) back to - /// `retire_unreadable_row(&store, path.as_ref(), row, - /// LEGACY_STORE_SCHEMA_V1, &error)?` — the row is then retired, a - /// sidecar appears, and the assertions below fail. - #[tokio::test] - async fn a_legacy_rows_inner_schema_disagreeing_with_its_envelope_is_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV1 { - turn_id: "55555555-5555-5555-5555-555555555555".into(), - applied_keys: Vec::new(), - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - // Envelope's own `schema_id` says v1 (handled by this arm); the - // payload's own inner `schema` field says v2 — the exact disagreement - // Soul's probe found, constructed by hand since no production writer - // ever mints this shape. - let legacy_state = LegacyPlayTurnStoreStateV1 { - schema: LEGACY_STORE_SCHEMA_V2.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 3, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![legacy_row]).unwrap(), - "seeding the mismatched-inner-schema row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("an inner-schema mismatch must not silently succeed either"), - Err(error) => error, - }; - // `{error:#}` (anyhow's alternate form) walks the whole `.context(...)` - // chain; `{error}` alone would only show `open`'s own outer wrapper - // ("upgrading a legacy play-turn row failed") and never actually - // check that the underlying cause names the schema mismatch at all. - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("schema"), - "the failure must name the schema mismatch, not read as a plain decode failure: {full_chain}" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable turn must not be retired over an inner-schema tag mismatch alone" - ); - - // The row itself is untouched: no destructive compare-and-swap ran. - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some(LEGACY_STORE_SCHEMA_V1)); - } - - /// PA.f177: a row older than `applied_keys`/`fault` themselves — written - /// before either field existed, still under `LEGACY_STORE_SCHEMA_V1`, the - /// schema id that never changed for them — must open and upgrade exactly - /// like any other legacy row, not fail `upgrade_legacy_row`'s decode with - /// "missing field `applied_keys`". Soul's probe row, named in PA.f177. - /// - /// Mutation: remove `#[serde(default)]` from `PlayTurn::applied_keys` (or - /// `fault`) — this row's msgpack bytes name neither field at all, so the - /// decode in `upgrade_legacy_row` fails, `open` falls into - /// `retire_unreadable_row` instead of recovering the turn, and the - /// `recovered.applied_keys`/`turn_id` assertions below fail because there - /// is no recovered turn to read. - #[tokio::test] - async fn a_legacy_v1_row_predating_applied_keys_and_fault_still_opens_and_upgrades() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - // Encoded by hand, not through `LegacyPlayTurnV1`: that struct itself - // already carries `applied_keys`/`fault`. This is the shape an even - // earlier process wrote, before either field was added to `PlayTurn` - // at all — the exact gap PA.f177 named. - let mut turn_fields = std::collections::BTreeMap::new(); - turn_fields.insert("turn_id", Value::String("44444444-4444-4444-4444-444444444444".into())); - turn_fields.insert("opening_prompt", Value::String("You stand at the gate.".into())); - turn_fields.insert("player_prose", Value::Array(vec![Value::String("I knock.".into())])); - turn_fields.insert("rounds", Value::Array(Vec::new())); - turn_fields.insert("calls", Value::Array(Vec::new())); - turn_fields.insert("persona_turns", Value::Array(Vec::new())); - turn_fields.insert("question", Value::Null); - turn_fields.insert("refusal", Value::Null); - turn_fields.insert("narration", Value::Null); - turn_fields.insert("state", Value::String("Running".into())); - let turn_json = Value::Object(turn_fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); - let mut state_fields = std::collections::BTreeMap::new(); - state_fields.insert("schema", Value::String(LEGACY_STORE_SCHEMA_V1.into())); - state_fields.insert("turn", turn_json); - state_fields.insert( - "ledger", - serde_json::to_value(KeyLedger::default()).unwrap(), - ); - state_fields.insert("revision", Value::Number(2.into())); - let state_json = Value::Object(state_fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); - // Round-trip through `serde_json::Value` into msgpack (`rmp_serde` - // encodes any `Serialize` type, including a dynamic `Value`), so the - // wire bytes genuinely lack `applied_keys`/`fault` rather than - // merely being constructed from a Rust struct that happens to. - let payload = rmp_serde::to_vec_named(&state_json).unwrap(); - - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the pre-applied_keys/fault row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a row predating applied_keys/fault must still open leniently, not be refused"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.turn_id, "44444444-4444-4444-4444-444444444444"); - assert!( - recovered.applied_keys.is_empty(), - "a field the row never recorded at all must default, not fail the decode" - ); - assert_eq!(recovered.fault, None); - assert_eq!( - store.row.schema_id.as_deref(), - Some(STORE_SCHEMA), - "the row must be upgraded to the current schema in place" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a row that decoded leniently must not also be retired to a sidecar" - ); - } - - /// PA.f177: a legacy-schema row that is genuinely damaged — not merely - /// missing a field the lenient reader now defaults — must not leave - /// `PlayTurnStore::open` returning `Err` forever. It is retired to a - /// sidecar file beside the store, and the store opens fresh with no - /// current turn, so `open_play` succeeds and the play card comes back - /// (empty) instead of staying gone across every future restart. - /// - /// Mutation: delete the `Err(error) => retire_unreadable_row(...)` arm - /// from `PlayTurnStore::open`'s `LEGACY_STORE_SCHEMA_V1` branch (leaving - /// only `upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V1)?`) — - /// `open` then returns `Err` for this row, exactly PA.f177's own bug, and - /// `store.expect(...)` below panics. - #[tokio::test] - async fn a_genuinely_undecodable_legacy_row_is_retired_to_a_sidecar_and_play_starts_fresh() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let damaged_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: vec![0xC1], // msgpack's own reserved "never used" marker byte - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![damaged_row]) - .unwrap(), - "seeding the damaged legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a damaged legacy row must be retired, not fail open outright"); - assert!( - store.current().is_none(), - "play must start fresh with no current turn once the old row is retired" - ); - - let sidecar = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .find(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")) - .expect("the damaged row must be written to a sidecar file beside the store"); - let sidecar_bytes = std::fs::read(sidecar.path()).unwrap(); - assert!(!sidecar_bytes.is_empty(), "the sidecar must actually carry the retired row's bytes"); - - // Reopening must not hit the same row again: it was cleared from the - // store as part of retiring it, not merely skipped this once. - drop(store); - let reopened = PlayTurnStore::open(&store_path) - .expect("reopening after retirement must not refuse the store again"); - assert!(reopened.current().is_none()); - } - - /// The fork Self ruled on, isolated: a commit that changes only - /// `turn.refusal` — exactly the shape `execute_actor_call`'s - /// `ActionRejected` arm leaves a row in (the call's own record is - /// already persisted before submission, PA-Q8's "body durable before - /// effects" order; this second commit carries only the refusal, growing - /// neither `calls` nor `rounds`) — must still move the row's own - /// `revision`. A counter derived from `calls.len()` or `rounds.len()` - /// cannot see this commit at all, which is the exact gap that made a - /// persisted counter, not a derived one, the ruled answer. - /// - /// Mutation: move `PlayTurnStore::swap_in`'s own `revision + 1` onto a - /// condition keyed off `turn.calls.len()` growing (or any other derived - /// field) instead of firing unconditionally on every commit — this - /// commit, which grows nothing, would then leave `revision` exactly - /// where it started, failing the assertion below. - #[tokio::test] - async fn a_commit_changing_only_the_refusal_still_moves_the_rows_own_revision() { - let directory = tempfile::tempdir().unwrap(); - let mut store = PlayTurnStore::open(directory.path().join("play-turn-v1.cc")).unwrap(); - let base = PlayTurn { - turn_id: test_turn_id(9001), - applied_keys: vec!["k0".into()], - opening_prompt: "opening".into(), - player_prose: vec!["I try the act.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - store.open_new_turn(base.clone()).unwrap(); - let revision_before = store.state.revision; - - let mut refused = base; - refused.refusal = Some("refused".into()); - store.commit(refused).unwrap(); - let revision_after = store.state.revision; - - assert!( - revision_after > revision_before, - "a commit that changes only the refusal line must still move the row's own revision: \ - {revision_before} -> {revision_after}" - ); - } - - #[tokio::test] - async fn a_raw_op_is_journaled_as_play_and_never_as_an_actor() { - let fixture = play_world(None, "player-raw-op").await; - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_resource", - serde_json::json!({"handle": "gold", "label": "Gold"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(2), "Declare a resource.".into()) - .await - .unwrap(); - - // In Active phase, only the `Play` capability may admit an - // unanswered declare with no `PatchAnswer` (`require_answer` in - // `lib.rs`); every other caller is refused `AnswerRequired`. This - // resource's presence in the next snapshot is therefore possible - // only if the commit's caller was `System(Play)`. - let snapshot = fixture.world.snapshot().await.unwrap(); - assert!( - table_view(&snapshot).contains("Gold"), - "the declared resource did not commit through the Play door" - ); - } - - #[tokio::test] - async fn an_act_for_an_undispatched_subject_is_refused() { - let fixture = play_world(Some("Mara"), "player-undispatched").await; - let before = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&before)); - let round = output( - "r0", - vec![ - call_event( - "c0", - &format!("{mara}__speak"), - serde_json::json!({"text": "never dispatched"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Silence.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(3), "Hello?".into()) - .await - .unwrap(); - - // PA.f69 S2: `resolve_handle` refuses the undispatched handle before - // any door is touched — asserted, not merely "no panic" — and - // nothing about the world committed from it. - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - result.contains("not the player or a subject dispatched this turn"), - "{result}" - ); - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "an undispatched act must commit nothing"); - } - - #[tokio::test] - async fn a_paraphrased_quote_is_refused() { - let fixture = play_world(None, "player-paraphrase").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = handle_for(player_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - &format!("{player}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "a whispered WARNING"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(100), "a whispered warning".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result.contains("not an exact quote"), "{result}"); - } - - /// Mutation M8.1: if `span_is_exact` stopped checking a Persona's own - /// quotes, this would no longer hold — the paraphrase would commit. - #[tokio::test] - async fn a_paraphrased_quote_is_refused_for_a_persona() { - let fixture = play_world(Some("Mara"), "player-persona-paraphrase").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&snapshot)); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id]}), - )], - ); - let speak_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "I nod and SAY nothing"}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("I nod and say nothing")]), - output("proj-narrate", vec![text_event("The room settles.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round, speak_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(120), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result.contains("not an exact quote"), "{result}"); - } - - /// PA.f115 (invariant 4): `span_is_exact`'s own - /// `.filter(|(id, _)| *id == actor.subject)` restricts a Persona's - /// sources to turns recorded *for that subject*; deleting it would let - /// one dispatched Persona speak another's prose verbatim, uncaught, by - /// pooling every dispatched Persona's recorded turns as a shared source - /// list. Mara and Borin are dispatched together; Mara's own `speak` call - /// quotes Borin's own recorded turn prose exactly and must still be - /// refused as not her own exact quote. Mutation: delete the filter in - /// `span_is_exact` (`play.rs`) and this must fail. - #[tokio::test] - async fn a_persona_cannot_speak_another_dispatched_personas_exact_quote() { - let fixture = play_world(Some("Mara"), "player-persona-cross-quote").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - // A second Persona subject, declared through the play table's own - // authoring door — the same mechanism - // `dispatch_order_never_changes_the_request_id_a_subject_gets` uses. - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("declare-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(760), "Introduce Borin.".into()) - .await - .unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - let mara_handle = handle_for(mara); - let mara_text = subject_id_text(&snapshot, mara); - let borin_text = subject_id_text(&snapshot, borin); - - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text, borin_text]}), - )], - ); - let speak_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{mara_handle}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "Borin says hello to the player."}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-borin", vec![text_event("Borin waits his turn.")]), - output("persona-borin", vec![text_event("Borin says hello to the player.")]), - output("proj-narrate", vec![text_event("The room settles.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round, speak_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(761), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - result.contains("not an exact quote"), - "Mara speaking Borin's own exact prose must still be refused: {result}" - ); - } - - /// A second world fixture, owed since 8a (PA-Q8): genesis's own - /// `WorldMailbox::create` grants only the kernel's own zero-role `speak` - /// affordance, whose sole precondition — `CanBroadcast(Colocated)` — is - /// satisfied even by a subject alone in a room, and `declare_affordance` - /// (the only door to a custom precondition) is not in `PLAY_TOOLS`, so - /// the play agent itself can never author one. This fixture instead - /// declares one directly on `fixture.world`, through - /// `decode_authoring_call` and `CommandBody::AdmitPatch`, while the world - /// is still in Draft — exactly the door genesis's own authoring root - /// uses, never anything `PLAY_TOOLS` gates. The affordance's one - /// precondition, `holds`, is bound to a resource role the player is - /// never granted any of, so any attempt against it is refused - /// deterministically, with no movement, authority, or standing setup - /// needed. - async fn play_world_with_precondition_affordance(account_hash: &str) -> (WorldFixture, AffordanceId) { - let directory = tempfile::tempdir().unwrap(); - let (world, owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); - let principal = VerifiedPrincipalEvidence::new(account_hash, far_future()); - let receipt = world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Fixture World".into(), - brief: "A small table with one guarded affordance.".into(), - human_subject_label: "Player".into(), - narrative_persona_label: None, - operational_agent_label: None, - targets: BTreeMap::new(), - jurisdictions: Vec::new(), - lens_weights: LensWeights::new(BTreeMap::from([(Lens::Hearth, 1)])), - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - - let resource_patch = decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "guarded_resource", "label": "Guarded Resource"}).to_string(), - ) - .unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::AdmitPatch { - answers: None, - patch: resource_patch, - }, - }, - &principal, - ) - .await - .unwrap(); - - let snapshot = world.snapshot().await.unwrap(); - let affordance_patch = decode_authoring_call( - "declare_affordance", - &serde_json::json!({ - "handle": "guarded_act", - "kind": "guarded_act", - "roles": [ - {"role": "target", "kind": {"namespace": "entity", "kind": "resource"}}, - ], - "preconditions": [ - {"precondition": "holds", "resource": "target", "at_least": 999_999}, - ], - "effect_slots": [ - { - "op_kind": {"op": "consume"}, - "roles": ["actor", "target"], - "bounds": {"bound": "quantity", "max": 1}, - }, - ], - "outcome_bands": [{"weight": 1, "effects": []}], - "carries_speech": false, - }) - .to_string(), - ) - .unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::AdmitPatch { - answers: None, - patch: affordance_patch, - }, - }, - &principal, - ) - .await - .unwrap(); - - let snapshot = world.snapshot().await.unwrap(); - let affordance_id = snapshot - .affordances - .iter() - .find(|entry| entry.entry.kind.0 == "guarded_act") - .unwrap() - .id; - let grant_patch = decode_authoring_call( - "grant_affordance", - &serde_json::json!({ - "subject": {"ref": "existing", "value": subject_id_text(&snapshot, player)}, - "affordance": {"ref": "existing", "value": affordance_id_text(&snapshot, affordance_id)}, - }) - .to_string(), - ) - .unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::AdmitPatch { - answers: None, - patch: grant_patch, - }, - }, - &principal, - ) - .await - .unwrap(); - - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ActivateWorld, - }, - &principal, - ) - .await - .unwrap(); - - ( - WorldFixture { - _directory: directory, - world, - principal, - owner, - }, - affordance_id, - ) - } - - /// The canonical id text for an affordance, read out of a real - /// `table_view` render, on the same precedent `subject_id_text` already - /// uses for a subject: never a test-local copy of the printer. - fn affordance_id_text(snapshot: &WorldSnapshot, id: AffordanceId) -> String { - let entry = snapshot.affordances.iter().find(|entry| entry.id == id).unwrap(); - let view = table_view(snapshot); - let marker = format!("{} [", entry.entry.kind.0); - let start = view - .find(&marker) - .unwrap_or_else(|| panic!("{} not found in table_view: {view}", entry.entry.kind.0)) - + marker.len(); - let end = view[start..] - .find(']') - .unwrap_or_else(|| panic!("unterminated id bracket after {} in table_view", entry.entry.kind.0)); - view[start..start + end].to_owned() - } - - /// The canonical id text for a declared resource, by its own label, read - /// out of `table_view`'s own `Resources:` section — `resources` itself is - /// `pub(crate)` inside `ghostlight`, so this crate has no field to read - /// it from directly. - fn resource_id_text(snapshot: &WorldSnapshot, label: &str) -> String { - let view = table_view(snapshot); - let section = view - .find("\n Resources:") - .map_or(view.as_str(), |at| &view[at..]); - let marker = format!(" {label} ["); - let start = section - .find(&marker) - .unwrap_or_else(|| panic!("{label} not found in table_view's Resources section: {view}")) - + marker.len(); - let end = section[start..] - .find(']') - .unwrap_or_else(|| panic!("unterminated id bracket after {label} in table_view")); - section[start..start + end].to_owned() - } - - /// PA-Q8, owed since 8a: `execute_actor_call`'s `ActionRejected` arm sets - /// `turn.refusal` to the *actor* form (invariant 8) — bare role names, - /// no id, no resolved label, no fact statement — never the agent-facing - /// `describe_refusal` text the tool call's own result still carries. - #[tokio::test] - async fn a_refused_player_act_sets_the_refusal_line() { - let (fixture, _affordance_id) = play_world_with_precondition_affordance("player-refusal-line").await; - let directory = tempfile::tempdir().unwrap(); - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let handle = handle_for(player); - let resource_id = resource_id_text(&snapshot, "Guarded Resource"); - - let round = output( - "r0", - vec![call_event( - "c0", - &format!("{handle}{HANDLE_SEPARATOR}guarded_act"), - serde_json::json!({ - "target": resource_id, - "slot_0_qty": 1, - }), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(881), "I try the guarded act.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let refusal = turn - .refusal - .as_deref() - .expect("a refused player act must set the actor-facing refusal line"); - assert!( - !refusal.contains(&id_text(player)), - "the actor-facing refusal must carry no id: {refusal}" - ); - assert!( - !refusal.to_lowercase().contains("guarded_resource") && !refusal.to_lowercase().contains("guarded resource"), - "the actor-facing refusal must not resolve or name the role's target: {refusal}" - ); - } - - /// PA.f144: `turn.refusal` names the *most recent* player act, not - /// whichever one last refused — round 0 refuses (setting the line, as - /// `a_refused_player_act_sets_the_refusal_line` above proves), round 1's - /// player act then commits, and the line must be gone, not still - /// showing round 0's now-stale refusal. Mutation: dropping the - /// `turn.refusal = None` this fix adds to `execute_actor_call`'s own - /// success arm. - #[tokio::test] - async fn a_later_successful_player_act_clears_an_earlier_refusal_line() { - let (fixture, _affordance_id) = play_world_with_precondition_affordance("player-f144-clears").await; - let directory = tempfile::tempdir().unwrap(); - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let handle = handle_for(player); - let resource_id = resource_id_text(&snapshot, "Guarded Resource"); - const OPENING: &str = "I try the guarded act."; - - let refused_round = output( - "r0", - vec![call_event( - "c0", - &format!("{handle}{HANDLE_SEPARATOR}guarded_act"), - serde_json::json!({ - "target": resource_id, - "slot_0_qty": 1, - }), - )], - ); - // Round 1's own speak call quotes the turn's own opening prose - // exactly (`span_is_exact`'s own player source, PA.f84: a `Running` - // continue never adds fresh `player_prose`, so this is the only text - // a player act can quote mid-turn without a fresh `ask_player` - // round). - let successful_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{handle}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": OPENING}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-narrate", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![refused_round, successful_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(968), OPENING.into()).await.unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.refusal.is_none(), - "a later successful player act must clear an earlier round's own refusal line: {:?}", - turn.refusal - ); - let speak_result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(speak_result, "applied", "the later act must itself have actually committed: {speak_result}"); - } - - #[tokio::test] - async fn a_mint_and_a_ruled_fact_commit_as_play() { - let fixture = play_world(None, "player-mint").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-a", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_resource", - serde_json::json!({"handle": "gold", "label": "Gold"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(90), "Declare gold.".into()) - .await - .unwrap(); - } - - let snapshot = fixture.world.snapshot().await.unwrap(); - let view = table_view(&snapshot); - let resource_id = view - .split("Gold [") - .nth(1) - .and_then(|rest| rest.split(']').next()) - .expect("Gold's bracketed id is printed in table_view") - .to_owned(); - let player = subject_id_text(&snapshot, player_id(&snapshot)); - - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-b", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let round = output( - "r0", - vec![ - call_event( - "c0", - "mint", - serde_json::json!({ - "holder": {"ref": "existing", "value": player}, - "resource": {"ref": "existing", "value": resource_id}, - "qty": 5, - }), - ), - call_event( - "c1", - "declare_fact", - serde_json::json!({ - "handle": "ruling", - "label": "The Ruling", - "statement": "Five bars of gold sit in the vault.", - "standing": {"standing": "ruled"}, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &fixture.principal, - test_turn_id(91), - "Mint gold and rule it so.".into(), - ) - .await - .unwrap(); - } - - let snapshot = fixture.world.snapshot().await.unwrap(); - let view = table_view(&snapshot); - assert!(view.contains("5 of Gold"), "the mint did not commit: {view}"); - // `table_view`'s Facts section lists only facts some subject knows - // (built from `subject.knowledge`, PA-Q7's own doc comment on - // `table_view`); a `declare_fact` with nobody granted knowledge of - // it is invisible there by design, so this checks the call's own - // recorded result instead of the rendered view. - let stored = PlayTurnStore::open(&store_path).unwrap(); - let turn = stored.current().unwrap(); - let fact_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(fact_result, "applied", "the ruled fact did not commit: {fact_result}"); - assert!(!PLAY_TOOLS.contains(&"admit"), "admit is not a play tool"); - } - - #[tokio::test] - async fn a_persona_fault_becomes_a_tool_result_and_the_turn_continues() { - let fixture = play_world(Some("Mara"), "player-persona-fault").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas_port = ScriptedPort::new(vec![]); - // Faults the dispatch's own Projector inference; one clean output is - // reserved for the player's own closing narration. - personas_port - .queue - .lock() - .unwrap() - .push_back(Err("provider unavailable".into())); - personas_port - .queue - .lock() - .unwrap() - .push_back(Ok(output("proj-narrate", vec![text_event("Quiet.")]))); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - personas_port, - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(80), "Hello.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(dispatch_result.contains("could not act"), "{dispatch_result}"); - } - - #[tokio::test] - async fn persona_dispatch_draws_only_from_the_permit_pool() { - let fixture = play_world(Some("Mara"), "player-permits").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara nods.")]), - output("persona-mara", vec![text_event("I nod back.")]), - output("proj-narrate", vec![text_event("The room is quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let permits = Arc::new(Semaphore::new(3)); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - permits.clone(), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(70), "Who's there?".into()) - .await - .unwrap(); - - assert_eq!( - permits.available_permits(), - 3, - "a dispatch must release every permit it acquires" - ); - } - - /// PA.f98: `dispatch` takes the subject id exactly as `table_view` prints - /// it in brackets (the full canonical id), not the short `__` - /// actor-tool prefix. Mutation: reverting `parse_dispatch` to match - /// `handle_for` instead of `full_id_text` fails this, because the id - /// given here is longer than 8 hex digits. - #[tokio::test] - async fn dispatch_by_the_printed_full_id_runs_the_persona() { - let fixture = play_world(Some("Mara"), "player-dispatch-full-id").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-narrate", vec![text_event("Quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(71), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(dispatch_result.contains("Mara nods once."), "{dispatch_result}"); - } - - /// PA.f124: `execute_dispatch`'s own summaries print a subject through - /// the library's `id_text` — the same printer `table_view` and - /// `dispatch`'s own matching use — never `{subject:?}`'s raw - /// `SubjectId(...)` debug wrapper, a fifth hand-spelled id spelling. - /// Dispatching the same subject twice in one call reaches the - /// `"already acted"` branch, the one call site printing a subject - /// without a `snapshot` (`subject_label`) to fall back on. Mutation: - /// reverting either print in `execute_dispatch` to `{subject:?}` fails - /// this. - #[tokio::test] - async fn dispatch_summaries_print_the_subject_through_id_text_not_debug() { - let fixture = play_world(Some("Mara"), "player-dispatch-id-text-summary").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text.clone(), mara_text.clone()]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-narrate", vec![text_event("Quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(922), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - dispatch_result.contains(&format!("{mara_text} already acted")), - "the repeated subject must be named by id_text's own spelling, not SubjectId(...) debug: {dispatch_result}" - ); - assert!( - !dispatch_result.contains("SubjectId("), - "the debug wrapper must never leak into a dispatch summary: {dispatch_result}" - ); - } - - /// PA.f98: an id naming no dispatchable subject is refused in the tool - /// result, quoting the text given, rather than being silently dropped. - #[tokio::test] - async fn an_unknown_dispatch_id_is_refused_naming_it() { - let fixture = play_world(Some("Mara"), "player-dispatch-unknown-id").await; - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": ["not-a-real-id"]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - // No queued output: if the unknown id were somehow dispatched - // anyway, the persona lane would fault trying to pop from an - // empty queue, and this test would fail loudly rather than - // silently accepting a dropped id. - ScriptedPort::new(vec![output("proj-narrate", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(72), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - dispatch_result.contains("`not-a-real-id` names no dispatchable subject"), - "{dispatch_result}" - ); - } - - /// PA.f98: a mixed dispatch list runs every valid id and names every - /// invalid one in the same call's result — neither is dropped, and the - /// valid one still actually acts. - #[tokio::test] - async fn a_mixed_dispatch_list_runs_the_valid_id_and_names_the_invalid_one() { - let fixture = play_world(Some("Mara"), "player-dispatch-mixed").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id, "not-a-real-id"]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-narrate", vec![text_event("Quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(73), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(dispatch_result.contains("Mara nods once."), "{dispatch_result}"); - assert!( - dispatch_result.contains("`not-a-real-id` names no dispatchable subject"), - "{dispatch_result}" - ); - } - - /// PA.f114: `PLAY_INSTRUCTIONS` promises an id naming no dispatchable - /// subject is refused, quoting what was given, rather than silently - /// doing nothing. `parse_dispatch` used to drop a non-string entry with - /// `Value::as_str` filtering it out unseen; it is now refused by name. - /// Tested directly against `parse_dispatch`, the exact function the - /// dispatch tool call handler runs the arguments through. - #[tokio::test] - async fn a_non_string_dispatch_entry_is_refused_quoting_it() { - let fixture = play_world(Some("Mara"), "player-dispatch-non-string").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let (subjects, refusals) = parse_dispatch(r#"{"subjects": [42, true]}"#, &snapshot); - assert!(subjects.is_empty()); - assert_eq!( - refusals, - vec![ - "`42` in `subjects` is not a string".to_owned(), - "`true` in `subjects` is not a string".to_owned(), - ] - ); - } - - /// PA.f114: a body that isn't valid JSON, and a body whose JSON isn't an - /// object, are each refused quoting the text given — not the silent - /// empty dispatch `parse_dispatch` used to return for both. - #[tokio::test] - async fn a_malformed_dispatch_body_is_refused_quoting_it() { - let fixture = play_world(Some("Mara"), "player-dispatch-malformed").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - - let (subjects, refusals) = parse_dispatch("not json at all", &snapshot); - assert!(subjects.is_empty()); - assert_eq!(refusals.len(), 1, "{refusals:?}"); - assert!(refusals[0].contains("not valid JSON"), "{refusals:?}"); - assert!(refusals[0].contains("not json at all"), "{refusals:?}"); - - let (subjects, refusals) = parse_dispatch(r#"["a", "b"]"#, &snapshot); - assert!(subjects.is_empty()); - assert_eq!(refusals.len(), 1, "{refusals:?}"); - assert!(refusals[0].contains("not a JSON object"), "{refusals:?}"); - } - - /// PA.f114: `dispatch`'s arguments missing the `subjects` key entirely - /// are refused rather than read as an empty dispatch list. - #[tokio::test] - async fn a_dispatch_body_missing_subjects_key_is_refused() { - let fixture = play_world(Some("Mara"), "player-dispatch-missing-key").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let (subjects, refusals) = parse_dispatch("{}", &snapshot); - assert!(subjects.is_empty()); - assert_eq!(refusals.len(), 1, "{refusals:?}"); - assert!(refusals[0].contains("no `subjects` key"), "{refusals:?}"); - } - - #[tokio::test] - async fn a_question_holds_the_turn_open_across_a_restart() { - let fixture = play_world(None, "player-question").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let opening_key = test_turn_id(60); - - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - // PA.f155/PA.f156: the row's own revision counter must survive the - // restart exactly, neither reset nor re-derived from anything but - // what was last durably committed — checked at three points below: - // right after the question opens, right after the fresh table - // reopens the same store (nothing committed yet, so unchanged), and - // once more after the answer closes the turn (moved again). - let revision_before_restart; - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &fixture.principal, - opening_key.clone(), - "I stand at a crossroads.".into(), - ) - .await - .unwrap(); - revision_before_restart = table.current_turn_view().await.unwrap().revision; - } - // "Restart": a fresh `PlayTable` reopening the same store path. - // `PlayTable::new` is the one production door that mints `PlayPort`; - // this fixture reuses this table's own `play` field for the - // world-drift commit below rather than minting a second one. - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-after", vec![text_event("You choose left.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - assert_eq!( - table.current_turn_view().await.unwrap().revision, revision_before_restart, - "reopening the store must read the same revision back, not reset or recompute it" - ); - - let (opening_prompt_before, turn_id_before, question_id) = { - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - (turn.opening_prompt.clone(), turn.turn_id.clone(), turn.open_question_id().unwrap()) - }; - - // World drift between the question and the answer: a real snapshot - // taken now would print differently. Mutation M8.2 (rebuilding - // `opening_prompt` on resume) is caught by the assertion below. - table - .play - .submit_patch( - CommandId::new(), - decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "silver", "label": "Silver"}).to_string(), - ) - .unwrap(), - ) - .await - .unwrap(); - - // The answer's own request key (PA.f62/PA.f63): a fresh key, not the - // turn's own identity — the whole point of separating the two is - // that a caller answering a question need not carry the turn's - // original opening key forward. - let answer_key = test_turn_id(61); - table - .run( - &fixture.principal, - answer_key, - PlayRequest { - text: "left".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.opening_prompt, opening_prompt_before, - "opening_prompt must never be rebuilt on resume (mutation M8.2)" - ); - assert_eq!( - turn.turn_id, turn_id_before, - "the turn's own identity, minted once when it opened, never moves" - ); - assert_ne!( - turn.turn_id, opening_key, - "turn_id is minted fresh (PA.f83), never taken from the opening request key" - ); - assert_eq!(turn.state, PlayTurnState::Closed); - assert_eq!( - turn.player_prose, - vec!["I stand at a crossroads.".to_owned(), "left".to_owned()] - ); - assert!(turn.narration.is_some()); - drop(stored); - let revision_after_close = table.current_turn_view().await.unwrap().revision; - assert!( - revision_after_close > revision_before_restart, - "the row's own revision must move again once the answer closes the turn: {revision_before_restart} -> {revision_after_close}" - ); - } - - #[tokio::test] - async fn a_resumed_call_resubmits_its_recorded_body() { - let fixture = play_world(None, "player-resume-body").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(50); - - // `PlayTable::new` is the one production door that mints `PlayPort` - // (pinned by `soul_exactly_one_production_site_mints_play_port`); - // this fixture reuses the table's own `play` field for the - // out-of-band pre-crash commit below rather than minting a second - // one. - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-resume", vec![text_event("Still here.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let patch = decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "gold", "label": "Gold"}).to_string(), - ) - .unwrap(); - let command_id = derived_command_id(&turn_id, 0, 0); - // The exact crash window this rule protects: the kernel already - // committed the call, but the store never recorded its result. - table.play.submit_patch(command_id, patch.clone()).await.unwrap(); - - let snapshot = fixture.world.snapshot().await.unwrap(); - let opening_prompt = format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Make gold." - ); - let call_id = "c0".to_owned(); - let round = output( - "r0", - vec![ - call_event(&call_id, "declare_resource", serde_json::json!({"handle": "gold", "label": "Gold"})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt, - player_prose: vec!["Make gold.".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id, - round: 0, - slot: 0, - body: Some(RecordedCall::Authoring(patch)), - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let snapshot = fixture.world.snapshot().await.unwrap(); - let view = table_view(&snapshot); - assert_eq!( - view.matches("Gold").count(), - 1, - "the resumed call must resubmit its recorded body idempotently, not double-commit: {view}" - ); - } - - /// Mutation M8.3: rebuilding the body from a fresh snapshot on resume, - /// instead of resubmitting the recorded `(opportunity, invocation)` - /// verbatim, fails the kernel's own opportunity-identity check — - /// `PA.f41`'s rule that a caller's opportunity must equal the kernel's - /// currently issued copy exactly — the moment a second `declare_resource` - /// between the crash and the resume has changed the world's revision. - #[tokio::test] - async fn a_resumed_actor_call_resubmits_its_recorded_body() { - let fixture = play_world(None, "player-resume-actor").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(51); - - // `PlayTable::new` is the one production door that mints `PlayPort`; - // this fixture reuses the table's own `play` field below instead of - // minting a second one. - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-resume", vec![text_event("Still here.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let (opportunity, invocation) = - decode_actor_call(&snapshot, player, "", "speak", &serde_json::json!({"text": "hold fast"}).to_string()) - .unwrap(); - let command_id = derived_command_id(&turn_id, 0, 0); - // The crash window: the kernel already committed the exercise, but - // the store never recorded its result. The world then drifts - // (a second, unrelated commit) before the resume runs. - fixture - .world - .submit_principal( - PrincipalCommandIntent { - id: command_id, - world_id: opportunity.world_id, - expected_revision: 0, - body: CommandBody::ExerciseDecision { - opportunity: opportunity.clone(), - invocation: invocation.clone(), - }, - }, - &fixture.principal, - ) - .await - .unwrap(); - table - .play - .submit_patch( - CommandId::new(), - decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "silver", "label": "Silver"}).to_string(), - ) - .unwrap(), - ) - .await - .unwrap(); - - let opening_prompt = format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "hold fast" - ); - let call_id = "c0".to_owned(); - let player_handle = handle_for(player); - let round = output( - "r0", - vec![ - call_event( - &call_id, - &format!("{player_handle}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "hold fast"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt, - player_prose: vec!["hold fast".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id, - round: 0, - slot: 0, - body: Some(RecordedCall::PlayerAct(opportunity, invocation)), - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(result, "applied", "a resubmitted actor call must resolve to AlreadyApplied, not a fresh refusal: {result}"); - } - - /// PA.f118: `execute_round`'s own `self.play.snapshot()` failing was - /// reported as untested because no fixture tore the world mailbox down - /// independently of the `WorldMailbox` the whole test depends on — - /// false: `WorldFixture` keeps its own mailbox owner task rather than - /// discarding it, and `.abort()`ing it drops the owner's own channel - /// receiver, so the very next mailbox call resolves - /// `MailboxError::Unavailable` deterministically. A round is left - /// unresolved as if the process had crashed mid-round; resuming it with - /// the mailbox owner aborted must close the turn with the fault - /// recorded rather than propagate the error out of `run` unhandled. - /// Mutation: turning the match arm back into a bare - /// `self.play.snapshot().await?` makes `run` return `Err` instead of - /// `Ok` with the fault recorded, failing this. - #[tokio::test] - async fn a_snapshot_failure_mid_round_closes_with_the_fault_recorded() { - let fixture = play_world(None, "player-round-snapshot-fault").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(910); - - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let call_id = "c0".to_owned(); - let round = output("r0", vec![call_event(&call_id, END_TURN_TOOL, serde_json::json!({}))]); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: "Opening.".into(), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id, - round: 0, - slot: 0, - body: None, - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - // The very next mailbox call — the `self.play.snapshot()` - // `execute_round` makes to resolve the unresolved round above — - // fails deterministically once the owner task is gone. - fixture.owner.abort(); - - let outcome = table.run(&fixture.principal, turn_id, String::new().into()).await.unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. })); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.fault.is_some(), - "a snapshot failure mid-round must close the turn with the fault recorded, not propagate: {:?}", - turn.fault - ); - assert_eq!(turn.state, PlayTurnState::Closed); - } - - /// A persona port wrapping `ScriptedPort` that aborts a world mailbox's - /// own owner task the moment its *first* `infer` call completes (PA.f118, - /// PA.f119): aborting only ever kills *future* mailbox calls — the - /// persona lane's own inference calls never touch `self.play` at all — - /// so a first dispatched subject's own Persona turn (two `infer` calls, - /// projector then persona) still finishes normally, and only a *second* - /// dispatched subject's own `execute_dispatch`-internal - /// `self.play.snapshot()` call, made after the first subject's turn - /// completes, is guaranteed to observe the aborted owner. Gives - /// deterministic, in-process control over exactly when the mailbox goes - /// down mid-dispatch, without timing races. - struct AbortAfterFirstPort { - inner: Arc, - owner: StdMutex>>, - } - - impl AbortAfterFirstPort { - fn new(inner: Arc, owner: JoinHandle<()>) -> Arc { - Arc::new(Self { - inner, - owner: StdMutex::new(Some(owner)), - }) - } - } - - #[async_trait::async_trait] - impl InferencePort for AbortAfterFirstPort { - fn prepare(&self, request: InferenceRequest) -> Result { - self.inner.prepare(request) - } - - async fn infer(&self, request: PreparedInference) -> Result { - let result = self.inner.infer(request).await; - if let Some(owner) = self.owner.lock().unwrap().take() { - owner.abort(); - } - result - } - } - - /// PA.f118, PA.f119: the same failure as - /// `a_snapshot_failure_mid_round_closes_with_the_fault_recorded`, at - /// `execute_dispatch`'s own separate `self.play.snapshot()` call site. - /// Mara and Borin are dispatched together in one call; the persona port - /// aborts the world mailbox's own owner task right after Mara's first - /// inference call, so her own dispatch still completes, and Borin's own - /// `execute_dispatch`-internal snapshot then fails deterministically. - /// Mutation: turning that match arm back into a bare - /// `self.play.snapshot().await?` makes `run` return `Err` instead of - /// `Ok` with the fault recorded and Mara's own successful dispatch - /// discarded, failing this. - #[tokio::test] - async fn a_snapshot_failure_mid_dispatch_closes_with_the_fault_recorded() { - let fixture = play_world(Some("Mara"), "player-dispatch-snapshot-fault").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - // A second Persona subject, declared through the play table's own - // authoring door — the same mechanism - // `a_persona_cannot_speak_another_dispatched_personas_exact_quote` uses. - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("declare-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(913), "Introduce Borin.".into()) - .await - .unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - let mara_text = subject_id_text(&snapshot, mara); - let borin_text = subject_id_text(&snapshot, borin); - - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text, borin_text]}), - )], - ); - let scripted = ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - ]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - AbortAfterFirstPort::new(scripted, fixture.owner), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - let outcome = table - .run(&fixture.principal, test_turn_id(914), "Who's there?".into()) - .await - .unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. })); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.fault.is_some(), - "a snapshot failure mid-dispatch must close the turn with the fault recorded, not propagate: {:?}", - turn.fault - ); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!( - turn.persona_turns.iter().any(|(id, _)| *id == mara), - "Mara's own dispatch, already snapshot-successful before the abort, must still be recorded" - ); - assert!( - !turn.persona_turns.iter().any(|(id, _)| *id == borin), - "Borin's own dispatch must never have started" - ); - } - - // The old `soul_execute_round_and_round_tools_share_the_collision_refusal_call` - // structural test (PA.f118/PA.f119) counted `collision_refusal(` call - // sites in `play.rs` and pinned it at 2, standing in for behaviour it had - // no seam to observe directly. PA.f127/PA.f133 collapsed the duplicate - // fetch-and-check `infer_round` and `execute_round` each ran into one - // shared owner, `round_snapshot` (which itself now calls - // `collision_refusal` exactly once), so that count is 1 by design and the - // structural test is gone. `fetch_round_snapshot`'s own test seam - // (`force_round_snapshot`) now drives a genuine, real-id collision - // through `round_snapshot` directly — see - // `a_forced_handle_collision_refuses_the_round_through_round_snapshot` - // below — which is real behavioural coverage neither `infer_round` nor - // `execute_round` could get independently before this cut, and it covers - // both callers at once because there is only one implementation left to - // call. - - #[tokio::test] - async fn restart_equivalence_of_the_agent_input() { - let directory = tempfile::tempdir().unwrap(); - let world_path = directory.path().join("world.cc"); - let store_path = directory.path().join("play-turn-v1.cc"); - let backup_path = directory.path().join("play-turn-v1.backup.cc"); - let principal = VerifiedPrincipalEvidence::new("player-restart-equiv", far_future()); - - let (world, owner) = WorldMailbox::open(&world_path).unwrap(); - let receipt = world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Restart Equivalence".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: LensWeights::new(BTreeMap::from([(Lens::Hearth, 1)])), - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ActivateWorld, - }, - &principal, - ) - .await - .unwrap(); - - { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output("proj-1", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&principal, test_turn_id(30), "Turn one.".into()) - .await - .unwrap(); - } - // Turn two opens and asks a question, fixing its own `turn_id` in - // the store (PA.f83: minted fresh, not derived from the key) before - // the backup is taken — a fresh `begin_turn` mints a new random - // `turn_id` every time it runs, so two *independent* openings from - // the same prior state are no longer expected to produce identical - // requests. Resuming one *already-open* turn across a restart still - // must, and that is what this test now checks. - let (question_id, turn2_id) = { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let ask = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which path?"}))], - ); - let table = PlayTable::new( - world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&principal, test_turn_id(31), "Turn two.".into()) - .await - .unwrap(); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - (turn.open_question_id().unwrap(), turn.turn_id.clone()) - }; - std::fs::copy(&store_path, &backup_path).unwrap(); - let answer_end = || output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - - // Path A: no restart, answer the already-open question directly. - let request_a = { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output("proj-2a", vec![text_event("Still fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = ScriptedPort::new(vec![answer_end()]); - let table = PlayTable::new( - world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &principal, - test_turn_id(32), - PlayRequest { - text: "left".into(), - answers: Some(question_id.clone()), - }, - ) - .await - .unwrap(); - port.seen_requests().into_iter().next().unwrap() - }; - - // Restart: drop every live clone of the world mailbox, join its - // owner task, restore the store to its post-question state, and - // reopen both. - std::fs::copy(&backup_path, &store_path).unwrap(); - drop(world); - owner.await.unwrap(); - let (world, _owner) = WorldMailbox::open(&world_path).unwrap(); - - let request_b = { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output("proj-2b", vec![text_event("Still fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = ScriptedPort::new(vec![answer_end()]); - let table = PlayTable::new( - world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &principal, - test_turn_id(32), - PlayRequest { - text: "left".into(), - answers: Some(question_id.clone()), - }, - ) - .await - .unwrap(); - let stored = table.store.lock().await; - assert_eq!(stored.current().unwrap().turn_id, turn2_id, "the resumed turn's own id must survive the restart unchanged"); - drop(stored); - port.seen_requests().into_iter().next().unwrap() - }; - - assert_eq!(request_a, request_b); - } - - // --- Fix batch 8a-fix ------------------------------------------------ - - /// The bracketed canonical id following one label in `table_view`'s own - /// rendering, the same parsing `a_mint_and_a_ruled_fact_commit_as_play` - /// already relies on for a declared resource's id. - fn bracketed_id_after(view: &str, needle: &str) -> String { - view.split(needle) - .nth(1) - .and_then(|rest| rest.split(']').next()) - .unwrap_or_else(|| panic!("`{needle}` not found in table_view: {view}")) - .to_owned() - } - - /// PA.f61: `set_persona_material` refuses an existing subject. - #[tokio::test] - async fn set_persona_material_on_an_existing_subject_is_refused() { - let fixture = play_world(None, "player-persona-existing").await; - let before = fixture.world.snapshot().await.unwrap(); - let player = subject_id_text(&before, player_id(&before)); - let round = output( - "r0", - vec![ - call_event( - "c0", - "set_persona_material", - serde_json::json!({ - "subject": {"ref": "existing", "value": player}, - "values": ["steadfast"], - "voice": "flat and even", - "memories": [], - "reads": [], - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(400), "Rewrite the player.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result.contains("existing subject"), "{result}"); - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "a refused run must commit nothing"); - } - - /// PA.f61 + PA.f54: `set_persona_material` stands for a subject the same - /// authoring run declares — the draft handle resolves only because the - /// whole run decodes as one shared patch. - #[tokio::test] - async fn set_persona_material_on_a_same_run_declared_subject_commits() { - let fixture = play_world(None, "player-persona-declared").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "npc", - "label": "A Stranger", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event( - "c1", - "set_persona_material", - serde_json::json!({ - "subject": {"ref": "draft", "value": "npc"}, - "values": ["steadfast"], - "voice": "flat and even", - "memories": ["the first watch"], - "reads": [], - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(401), "Introduce a stranger.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - let view = table_view(&after); - assert!(view.contains("A Stranger"), "{view}"); - assert!(view.contains("flat and even"), "the persona material did not commit: {view}"); - } - - /// PA.f54: a maximal run of authoring calls commits atomically, as one - /// kernel command — a later call's declaration resolving an earlier - /// call's draft handle in the same run is possible only because both - /// land in one shared patch. - #[tokio::test] - async fn an_authoring_run_commits_as_one_patch() { - let fixture = play_world(None, "player-run-atomic").await; - let before = fixture.world.snapshot().await.unwrap(); - let commons_id = bracketed_id_after(&table_view(&before), "The Commons ["); - - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "declare_route", - serde_json::json!({ - "handle": "hall", - "label": "The Hall", - "from": {"ref": "existing", "value": commons_id}, - "to": {"ref": "draft", "value": "cell"}, - "access": {"access": "public"}, - "cost": 1, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(402), "Build a hall to a cell.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!( - after.revision, - before.revision + 1, - "the whole run must commit as one kernel command, not two" - ); - let view = table_view(&after); - assert!(view.contains("A Narrow Cell"), "{view}"); - assert!(view.contains("The Hall"), "{view}"); - } - - /// PA.f54: a refusal inside a run names the exact call that caused it, - /// through the run's own `DecodedBatch` site map, and nothing in the run - /// commits — not even an earlier call's own declaration. - #[tokio::test] - async fn a_refusal_in_a_run_names_the_failing_call_and_nothing_commits() { - let fixture = play_world(None, "player-run-refusal").await; - let before = fixture.world.snapshot().await.unwrap(); - let player = handle_for(player_id(&before)); - - // `declare_place` (call #0) decodes cleanly; `relocate` (call #1) — - // an *operation*, not a declaration — references a route draft - // handle nothing in this run ever declares. An operation mismatch's - // site is attributed by exact call index through the run's own - // `DecodedBatch`, unlike a declaration's own (handle-named) site. - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "relocate", - serde_json::json!({ - "subject": {"ref": "existing", "value": player}, - "via": {"ref": "draft", "value": "no_such_route"}, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(403), "Build a hall to nowhere.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "a refused run must commit nothing"); - assert!( - !table_view(&after).contains("A Narrow Cell"), - "the first call's own declaration must not commit either" - ); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result0 = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - let result1 = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result1.contains("call #1"), "{result1}"); - assert_eq!(result0, result1, "every call in a run shares the run's own single outcome"); - } - - /// One half of PA.f87's replacement for the old source-text-position - /// pin: given a store that already holds a run's own recorded body from - /// before a crash (built directly here, the same "crash window" shape - /// `a_resumed_call_resubmits_its_recorded_body` uses for a single - /// call), resume must resubmit that exact recorded body idempotently — - /// one kernel commit, not two. This alone does not prove the record - /// happens *before* the submit in a first, non-resumed attempt; see - /// `commit_authoring_run_records_the_run_even_when_its_submit_is_refused` - /// for that half. - #[tokio::test] - async fn a_resumed_authoring_run_resubmits_its_recorded_shared_patch() { - let fixture = play_world(None, "player-resume-run").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(52); - - // `PlayTable::new` is the one production door that mints `PlayPort`; - // this fixture reuses the table's own `play` field for the - // out-of-band pre-crash commit below rather than minting a second - // one. - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-resume-run", vec![text_event("Still here.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let before = fixture.world.snapshot().await.unwrap(); - let commons_id = bracketed_id_after(&table_view(&before), "The Commons ["); - let declare_place_args = - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}).to_string(); - let declare_route_args = serde_json::json!({ - "handle": "hall", - "label": "The Hall", - "from": {"ref": "existing", "value": commons_id}, - "to": {"ref": "draft", "value": "cell"}, - "access": {"access": "public"}, - "cost": 1, - }) - .to_string(); - let decoded = decode_authoring_calls(&[ - ("declare_place", declare_place_args.as_str()), - ("declare_route", declare_route_args.as_str()), - ]) - .unwrap(); - let patch = decoded.patch.clone(); - let command_id = derived_command_id(&turn_id, 0, 0); - // The exact crash window this rule protects: the kernel already - // committed the run's own shared patch, but the store never - // recorded its result. - table.play.submit_patch(command_id, patch.clone()).await.unwrap(); - - let opening_prompt = format!("{}\n\nThe player writes:\n{}\n", table_view(&before), "Build a hall."); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "declare_route", - serde_json::json!({ - "handle": "hall", - "label": "The Hall", - "from": {"ref": "existing", "value": commons_id}, - "to": {"ref": "draft", "value": "cell"}, - "access": {"access": "public"}, - "cost": 1, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt, - player_prose: vec!["Build a hall.".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Authoring(patch)), - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - table.run(&fixture.principal, turn_id, String::new().into()).await.unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - // One commit, not two: the world's own revision counter is the - // unambiguous signal — `table_view` prints a place's own label a - // second time wherever a route mentions it as an endpoint, so a - // substring count is not (this mirrors `an_authoring_run_commits_as_one_patch`'s - // own revision-based check). - assert_eq!( - after.revision, - before.revision + 1, - "the resumed run must resubmit its recorded shared patch idempotently, not double-commit" - ); - let view = table_view(&after); - assert!(view.contains("A Narrow Cell"), "{view}"); - assert!(view.contains("The Hall"), "{view}"); - } - - /// The other half of PA.f87: `commit_authoring_run` must record the - /// run's own decoded patch *before* it ever finds out whether - /// `submit_patch` will succeed — not only after a success, and not only - /// when a caller happens to resume a pre-crashed turn. Proven with a - /// run whose kernel submission is genuinely refused (the same - /// `relocate`-to-a-nonexistent-route shape - /// `a_refusal_in_a_run_names_the_failing_call_and_nothing_commits` - /// uses): the decode itself succeeds, so a correct `commit_authoring_run` - /// records the run's own patch regardless of what the kernel later says - /// about it. Mutations: delete - /// `record_call(..., Some(RecordedCall::Authoring(...)), ...)`, or move - /// it inside the `Ok(_receipt) =>` success arm of the `submit_patch` - /// match (making the record conditional on success); both leave the - /// anchor slot's own `body` unset after this refusal, failing this test. - #[tokio::test] - async fn commit_authoring_run_records_the_run_even_when_its_submit_is_refused() { - let fixture = play_world(None, "player-record-before-refused-submit").await; - let before = fixture.world.snapshot().await.unwrap(); - let player = subject_id_text(&before, player_id(&before)); - - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "relocate", - serde_json::json!({ - "subject": {"ref": "existing", "value": player}, - "via": {"ref": "draft", "value": "no_such_route"}, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(53), "Build a hall to nowhere.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "a refused run must commit nothing to the world"); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let anchor = turn.calls.iter().find(|call| call.round == 0 && call.slot == 0).unwrap(); - assert!( - matches!(anchor.body, Some(RecordedCall::Authoring(_))), - "the run's own decoded patch must be recorded even though the kernel refused it: {:?}", - anchor.body - ); - assert!( - anchor.result.as_deref().unwrap_or_default().contains("refused"), - "{:?}", - anchor.result - ); - } - - /// PA.f64: a dispatched Persona's own prose reaches the agent as the - /// dispatch call's own tool result, verbatim, both on the round it - /// happened and after a restart resumes a later round. - #[tokio::test] - async fn a_dispatched_personas_prose_reaches_the_agents_next_request() { - let fixture = play_world(Some("Mara"), "player-dispatch-prose").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = subject_id_text(&snapshot, persona_id(&snapshot)); - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara.clone()]}), - )], - ); - let ask_round = output( - "r1", - vec![call_event( - "c1", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Well?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("The bolt is thrown; nobody enters here.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let port = ScriptedPort::new(vec![dispatch_round, ask_round]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(404), "Who's there?".into()) - .await - .unwrap(); - - let seen = port.seen_requests(); - let round1_request = format!("{:?}", seen.get(1).expect("round 1's own request was seen")); - assert!( - round1_request.contains("The bolt is thrown; nobody enters here."), - "round 1's request must carry Mara's own prose verbatim: {round1_request}" - ); - let question_id = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - // Release the store's exclusive custody before reopening it below — - // the same "restart" precedent `a_question_holds_the_turn_open_across_a_restart` - // relies on: a fresh `PlayTable` is the only production door onto a - // reopened store. - drop(table); - - // The same request, rebuilt after a restart reopening the same - // store, must carry the same prose (PA.f64's "including after a - // resume"). - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-after", vec![text_event("The hall goes still.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port2 = ScriptedPort::new(vec![ask_round_repeat()]); - let table2 = PlayTable::new( - fixture.world.clone(), - personas2, - port2.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - // Answering the still-open question forces a fresh round to be - // inferred, rebuilding the conversation from the stored record. - table2 - .run( - &fixture.principal, - test_turn_id(405), - PlayRequest { - text: "Answer.".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap(); - let seen2 = port2.seen_requests(); - let resumed_request = format!("{:?}", seen2.first().unwrap()); - assert!( - resumed_request.contains("The bolt is thrown; nobody enters here."), - "a resumed request must still carry the recorded Persona prose: {resumed_request}" - ); - } - - fn ask_round_repeat() -> InferenceOutput { - output( - "r2", - vec![call_event("c2", END_TURN_TOOL, serde_json::json!({}))], - ) - } - - /// PA.f69 S1: a Persona's own exact quote of its own prose is accepted — - /// mutating `span_is_exact` to check the *player's* prose instead of the - /// acting Persona's own recorded turn fails this, since the quote below - /// never appears in the player's own words. - #[tokio::test] - async fn a_persona_acting_on_its_own_exact_quote_is_accepted() { - let fixture = play_world(Some("Mara"), "player-persona-exact-quote").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&snapshot)); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id]}), - )], - ); - let speak_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "I nod and say nothing"}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("I nod and say nothing")]), - output("proj-narrate", vec![text_event("The room settles.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round, speak_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(406), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(result, "applied", "an exact quote of the Persona's own prose must be accepted: {result}"); - } - - /// PA.f65: an empty or whitespace-only answer is refused before it is - /// recorded; the turn stays exactly as it was. - #[tokio::test] - async fn an_empty_answer_is_refused_before_it_is_recorded() { - let fixture = play_world(None, "player-empty-answer").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(410); - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, turn_id.clone(), "I stand at a crossroads.".into()) - .await - .unwrap(); - let question_id = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - - let error = table - .run( - &fixture.principal, - test_turn_id(411), - PlayRequest { - text: " ".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::EmptyAnswer { .. }), "{error:?}"); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::AwaitingPlayer, "an empty answer must not resume the turn"); - assert_eq!(turn.question.as_deref(), Some("Which way?")); - } - - struct AlwaysFaultyPort { - calls: std::sync::atomic::AtomicUsize, - } - - impl AlwaysFaultyPort { - fn new() -> Self { - Self { - calls: std::sync::atomic::AtomicUsize::new(0), - } - } - - fn call_count(&self) -> usize { - self.calls.load(std::sync::atomic::Ordering::SeqCst) - } - } - - #[async_trait::async_trait] - impl InferencePort for AlwaysFaultyPort { - fn prepare(&self, _request: InferenceRequest) -> Result { - self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Err(InferenceFault::recovery_required("provider unavailable")) - } - - async fn infer(&self, _request: PreparedInference) -> Result { - Err(InferenceFault::recovery_required("provider unavailable")) - } - } - - /// PA.f102: `RecoveryRequired` is renamed honestly from the prior - /// "exhausted beyond retries" framing this test used to carry — it never - /// actually drove a `Retryable` fault's own retry-and-exhaust path at - /// all; every one of its faults was `RecoveryRequired`, which never - /// retries and closes on its first occurrence. Asserts the call count - /// directly: exactly one `prepare` call, not the whole `ROUND_RETRY_BUDGET` - /// a `Retryable` fault would have spent. Mutation: making - /// `RecoveryRequired` retry (see `a_retryable_fault_exhausts_its_budget_then_closes` - /// for the sibling budget-exhaustion mutation) fails this by driving the - /// call count above one. - #[tokio::test] - async fn a_recovery_required_fault_makes_one_call_then_closes_with_its_detail() { - let fixture = play_world(None, "player-recovery-required").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let port = Arc::new(AlwaysFaultyPort::new()); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(420), "Hello?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "a RecoveryRequired fault must close the turn, not leave it Running" - ); - assert_eq!( - turn.fault.as_deref(), - Some("provider unavailable"), - "the fault's own detail must be recorded" - ); - assert_eq!( - port.call_count(), - 1, - "RecoveryRequired must never retry: exactly one call, not the whole retry budget" - ); - } - - /// PA.f102: a persistent `Retryable` fault, unlike `RecoveryRequired`, - /// does retry — but only up to `ROUND_RETRY_BUDGET` times, then closes - /// with the fault's own detail. Total calls: the initial attempt plus - /// one retry per absorbed fault, `ROUND_RETRY_BUDGET + 1`. Mutations: - /// raising the budget or removing the cap both fail this by changing the - /// exact call count the test pins. - #[tokio::test] - async fn a_retryable_fault_exhausts_its_budget_then_closes() { - let fixture = play_world(None, "player-retryable-exhausted").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - // Pinned expectation, deliberately *not* expressed as - // `ROUND_RETRY_BUDGET + 1`: computing the expected count from the - // same symbol the production code reads would let a mutation that - // raises the budget silently drag this test's own expectation along - // with it. The queue below supplies comfortably more faults than - // today's real budget can consume, so a raised or uncapped budget - // shows up as a call count above the pinned `13`. - const PINNED_EXPECTED_CALLS: usize = ROUND_BUDGET + 1; - assert_eq!(ROUND_RETRY_BUDGET, ROUND_BUDGET, "this pin assumes today's retry budget; update it deliberately if that changes"); - let directory = tempfile::tempdir().unwrap(); - let port = DispositionPort::new( - std::iter::repeat_with(|| Err(InferenceFault::retryable("transient provider hiccup"))) - .take(PINNED_EXPECTED_CALLS + 10) - .collect(), - ); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - // PA.f122: production and test run the identical retry_delay code - // path — only the base a test explicitly sets differs, so this - // test's own dozen-plus retries settle instantly. Mutation: - // `retry_delay` ignoring `retry_delay_base_ms` and always scaling - // from the production `RETRY_DELAY_BASE_MS` instead pushes the - // dozen-plus real sleeps below past 30 real seconds; the bound here - // is generously above instant and nowhere near that. - table.set_retry_delay_base_ms(0); - let started = std::time::Instant::now(); - - table - .run(&fixture.principal, test_turn_id(421), "Hello?".into()) - .await - .unwrap(); - - assert!( - started.elapsed() < Duration::from_secs(2), - "a zero retry_delay_base_ms must make every retry settle instantly, not scale from the production base: {:?}", - started.elapsed() - ); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "a Retryable fault exhausting its budget must close the turn" - ); - assert_eq!(turn.fault.as_deref(), Some("transient provider hiccup")); - assert_eq!( - port.call_count(), - PINNED_EXPECTED_CALLS, - "exactly the initial attempt plus ROUND_RETRY_BUDGET retries, no more and no fewer" - ); - } - - /// PA.f102: the production backoff formula itself, never the - /// `#[cfg(test)]`-hidden zero delay: it grows, and it caps. - #[test] - fn backoff_delay_grows_then_caps() { - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 0), Duration::from_millis(100)); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 1), Duration::from_millis(200)); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 2), Duration::from_millis(400)); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 5), Duration::from_millis(3_200), "not yet capped"); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 7), Duration::from_millis(12_800), "not yet capped"); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 8), Duration::from_millis(20_000), "capped"); - assert_eq!( - backoff_delay(RETRY_DELAY_BASE_MS, 1_000_000), - Duration::from_millis(20_000), - "a very large attempt count must stay capped, never overflow or panic" - ); - assert_eq!( - backoff_delay(0, 6), - Duration::ZERO, - "a zero base (a test's own setting) must scale to zero at every attempt" - ); - } - - /// Captures what a tracing subscriber writes, for the test that reads the - /// warn line a fault closes a turn with. - #[derive(Clone, Default)] - struct LogCapture(Arc>>); - - impl std::io::Write for LogCapture { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture { - type Writer = LogCapture; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } - } - - impl LogCapture { - fn text(&self) -> String { - String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() - } - } - - fn capture_logs() -> (LogCapture, tracing::subscriber::DefaultGuard) { - let capture = LogCapture::default(); - let guard = tracing::subscriber::set_default( - tracing_subscriber::fmt() - .with_writer(capture.clone()) - .with_ansi(false) - .finish(), - ); - (capture, guard) - } - - /// R1 and R2. A turn closed by a fault logs its id and the fault's - /// detail at warn, and the owner's card shows the fixed fault row until - /// the view stops reporting a fault. Mutations: dropping the `warn!` in - /// `close_with_fault` fails the log assertions; `fault: false` in - /// `current_turn_view` fails the row assertion. - #[tokio::test] - async fn a_fault_closed_turn_is_logged_and_shown_on_the_card() { - let (logs, _guard) = capture_logs(); - let fixture = play_world(None, "player-fault-card").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - Arc::new(AlwaysFaultyPort::new()), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(430), "Hello?".into()).await.unwrap(); - let turn_id = table.current_turn_view().await.unwrap().turn_id; - - let line = logs - .text() - .lines() - .find(|line| line.contains("play turn closed on a fault")) - .map(str::to_owned) - .expect("closing a turn on a fault must log a line"); - assert!(line.contains("WARN"), "{line}"); - assert!(line.contains(&turn_id), "the line must name the turn: {line}"); - assert!(line.contains("provider unavailable"), "the line must carry the fault detail: {line}"); - - let world = fixture.world.snapshot().await.unwrap(); - let view = table.current_turn_view().await.unwrap(); - assert!(view.fault); - let surface = - crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&view), true).unwrap(); - let row = find_surface_node(&surface, "world.play.fault") - .expect("a fault-closed turn must show the fault row"); - let encoded = serde_json::to_string(row).unwrap(); - assert!(!encoded.contains("provider unavailable"), "the row must not carry the detail: {encoded}"); - - let healthy = PlayTurnView { fault: false, ..view }; - let surface = - crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&healthy), true).unwrap(); - assert!(find_surface_node(&surface, "world.play.fault").is_none()); - } - - /// A PlayTable over the real local port at `endpoint`, with the reply - /// timeout `timeout` (None is production's) and no retry sleep. - async fn local_play_table( - endpoint: std::net::SocketAddr, - timeout: Option, - account: &str, - directory: &std::path::Path, - ) -> (WorldFixture, PlayTable) { - let fixture = play_world(None, account).await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let binding = ghostlight::LocalBinding { - endpoint, - model_prefix: "local/".into(), - caller_runtime_id: "play-test-runtime".into(), - }; - let inference = match timeout { - Some(timeout) => ghostlight::open_local_port_with_timeout(binding, timeout).unwrap(), - None => ghostlight::open_inference(None, None, Some(binding), &["local/play"]).unwrap(), - }; - let table = PlayTable::new( - fixture.world.clone(), - personas, - inference, - "local/play".into(), - Arc::new(Semaphore::new(2)), - directory.join("play-turn-v1.cc"), - ) - .unwrap(); - table.set_retry_delay_base_ms(0); - (fixture, table) - } - - /// Runs one turn at `endpoint` through the real local port and asserts it - /// closed on a fault whose stored text contains `kind`, then that no byte - /// of the endpoint (the canary: reqwest's own error text carries the - /// request URL) reached the stored fault, the warn line, the card row, - /// or the Debug of the turn and its view. - async fn assert_faulted_turn_names_no_endpoint( - endpoint: std::net::SocketAddr, - timeout: Option, - account: &str, - turn_number: u128, - kind: &str, - class: &str, - reply_canaries: &[&str], - ) { - let (logs, _guard) = capture_logs(); - let directory = tempfile::tempdir().unwrap(); - let (fixture, table) = local_play_table(endpoint, timeout, account, directory.path()).await; - table.run(&fixture.principal, test_turn_id(turn_number), "Hello?".into()).await.unwrap(); - - let world = fixture.world.snapshot().await.unwrap(); - let view = table.current_turn_view().await.unwrap(); - assert!(view.fault, "the endpoint failure must close the turn on a fault"); - let surface = crate::eve::authenticated_surface(account, Some(&world), Some(&view), true).unwrap(); - let row = serde_json::to_string(find_surface_node(&surface, "world.play.fault").unwrap()).unwrap(); - let stored = table.store.lock().await.current().unwrap().fault.clone().unwrap(); - let log = logs.text(); - let debug = format!("{view:?} {:?}", table.store.lock().await.current().unwrap()); - assert!(stored.contains(kind), "the error kind is kept: {stored}"); - let warn = log - .lines() - .find(|line| line.contains("play turn closed on a fault")) - .expect("closing a turn on a fault must log a line"); - assert!(warn.contains(&format!("class={class}")), "the warn line must carry class {class}: {warn}"); - let canaries = [endpoint.to_string(), endpoint.ip().to_string(), endpoint.port().to_string(), "chat/completions".to_owned()] - .into_iter() - .chain(reply_canaries.iter().map(|canary| (*canary).to_owned())); - for canary in canaries { - for (where_, text) in [("stored fault", &stored), ("warn line", &log), ("card row", &row), ("debug", &debug)] { - assert!(!text.contains(&canary), "{canary} leaked into the {where_}: {text}"); - } - } - } - - /// The whole path with a dead endpoint (connect refused). - #[tokio::test] - async fn a_dead_local_endpoint_closes_the_turn_without_naming_the_endpoint() { - let endpoint = { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - listener.local_addr().unwrap() - }; - assert_faulted_turn_names_no_endpoint(endpoint, None, "player-fault-canary", 431, "refused the connection", "Connect", &[]).await; - } - - /// The whole path with a timeout, the leg a Bonsai stall takes: the - /// listener accepts and never answers, the client times out at 200 ms, - /// and the timeout is not retried. A timeout detail built from reqwest's - /// error text carries the request URL and fails the canary here. - #[tokio::test] - async fn a_timed_out_local_endpoint_closes_the_turn_without_naming_the_endpoint() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); - let endpoint = listener.local_addr().unwrap(); - tokio::spawn(async move { - let mut held = Vec::new(); - while let Ok((stream, _)) = listener.accept().await { - held.push(stream); - } - }); - assert_faulted_turn_names_no_endpoint( - endpoint, - Some(Duration::from_millis(200)), - "player-fault-timeout", - 432, - "timed out", - "Timeout", - &[], - ) - .await; - } - - /// Reads one HTTP request (headers and the declared body) off `stream`. - fn read_http_request(stream: &mut std::net::TcpStream) { - use std::io::Read; - let mut buf = Vec::new(); - let mut chunk = [0u8; 8192]; - loop { - let Ok(read) = stream.read(&mut chunk) else { break }; - if read == 0 { - break; - } - buf.extend_from_slice(&chunk[..read]); - let text = String::from_utf8_lossy(&buf).to_string(); - if let Some(split) = text.find("\r\n\r\n") { - let len = text[..split] - .to_ascii_lowercase() - .lines() - .find_map(|l| l.strip_prefix("content-length:").map(|v| v.trim().parse::().unwrap())) - .unwrap_or(0); - if buf.len() >= split + 4 + len { - break; - } - } - } - } - - /// Loopback server that answers every request with a `200` whose whole - /// body is `body`. - fn replying_with(body: &'static str) -> std::net::SocketAddr { - replying_with_status("200 OK", body) - } - - /// Loopback server that answers every request with `status` (a status - /// line tail such as `404 Not Found`) and `body`. - fn replying_with_status(status: &'static str, body: &'static str) -> std::net::SocketAddr { - use std::io::Write; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - std::thread::spawn(move || { - for stream in listener.incoming() { - let Ok(mut stream) = stream else { return }; - read_http_request(&mut stream); - let _ = write!( - stream, - "HTTP/1.1 {}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - status, - body.len(), - body - ); - } - }); - addr - } - - /// The status leg through the whole path: an endpoint that answers 404 - /// (model not loaded) or 502 (a proxy in front of a dead server) closes - /// the turn on a fault whose warn line carries that code as the typed - /// class, and the reply body, which here is a canary, reaches nothing. - /// Mutations: dropping `class` from the warn line, or logging a fixed - /// status, fails one of the two. - #[tokio::test] - async fn a_non_success_status_reaches_the_warn_line_as_its_code() { - for (number, status, code) in [(434u128, "404 Not Found", "404"), (435, "502 Bad Gateway", "502")] { - let endpoint = replying_with_status(status, "CANARY-status-body-7e41"); - let account = format!("player-fault-status-{code}"); - assert_faulted_turn_names_no_endpoint( - endpoint, - None, - &account, - number, - "non-success status", - &format!("Status({code})"), - &["CANARY-status-body-7e41"], - ) - .await; - } - } - - /// The malformed-reply leg through the whole path: a whole body that is - /// not the declared shape closes the turn on a fault, and serde's text - /// for it, which echoes the reply's own value, reaches the stored fault, - /// the warn line, the card row and Debug nowhere. - #[tokio::test] - async fn a_malformed_local_reply_closes_the_turn_without_echoing_it() { - let endpoint = replying_with("\"CANARY-reply-5d02c8\""); - assert_faulted_turn_names_no_endpoint( - endpoint, - None, - "player-fault-malformed", - 433, - "not the declared shape", - "BadReply", - &["CANARY-reply-5d02c8"], - ) - .await; - } - - /// Loopback server for the composed retry tests: closes the first - /// `drops` connections without a byte (a model server mid-reload), then - /// answers each later one with an `end_turn` tool call. Returns its - /// address and the count of connections seen. - fn closing_then_ending(drops: usize) -> (std::net::SocketAddr, Arc) { - use std::io::Write; - use std::sync::atomic::{AtomicUsize, Ordering}; - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - let addr = listener.local_addr().unwrap(); - let seen = Arc::new(AtomicUsize::new(0)); - let counter = seen.clone(); - std::thread::spawn(move || { - for stream in listener.incoming() { - let Ok(mut stream) = stream else { return }; - let n = counter.fetch_add(1, Ordering::SeqCst); - if n < drops { - drop(stream); - continue; - } - read_http_request(&mut stream); - let body = serde_json::json!({ - "id": format!("resp-{n}"), - "choices": [{"message": {"role": "assistant", "content": null, - "tool_calls": [{"id": format!("c{n}"), "type": "function", - "function": {"name": END_TURN_TOOL, "arguments": "{}"}}]}, - "finish_reason": "tool_calls"}], - "usage": {"prompt_tokens": 3, "completion_tokens": 2} - }) - .to_string(); - let _ = write!( - stream, - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}", - body.len(), - body - ); - let _ = stream.flush(); - } - }); - (addr, seen) - } - - /// R3 composed: a real closed send is `Retryable`, `absorb_or_close` - /// retries it, and the turn completes once the endpoint answers. It reads - /// the production budget, so the boundary moves with it: the turn - /// survives `ROUND_RETRY_BUDGET` empty replies and faults on one more. - #[tokio::test] - async fn a_turn_survives_empty_replies_up_to_the_retry_budget_and_faults_past_it() { - let (addr, seen) = closing_then_ending(3); - let directory = tempfile::tempdir().unwrap(); - let (fixture, table) = local_play_table(addr, None, "player-r3-three", directory.path()).await; - table.run(&fixture.principal, test_turn_id(900), "Hello?".into()).await.unwrap(); - let (state, fault) = { - let store = table.store.lock().await; - let turn = store.current().unwrap(); - (turn.state, turn.fault.clone()) - }; - assert_eq!(state, PlayTurnState::Closed); - assert!(fault.is_none(), "{fault:?}"); - assert_eq!(seen.load(std::sync::atomic::Ordering::SeqCst), 4, "three empty replies, then the answered call"); - - for (drops, account, id, survives) in [ - (ROUND_RETRY_BUDGET, "player-r3-edge", 901u128, true), - (ROUND_RETRY_BUDGET + 1, "player-r3-over", 902u128, false), - ] { - let (addr, _seen) = closing_then_ending(drops); - let directory = tempfile::tempdir().unwrap(); - let (fixture, table) = local_play_table(addr, None, account, directory.path()).await; - table.run(&fixture.principal, test_turn_id(id), "Hello?".into()).await.unwrap(); - let store = table.store.lock().await; - let turn = store.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - assert_eq!(turn.fault.is_none(), survives, "drops={drops} fault={:?}", turn.fault); - } - } - - /// R4: a model server reload takes about 30-40 s, so the production - /// backoff summed over the whole retry budget must outlast it with - /// margin, while the first retry stays quick. Reads the production base, - /// budget and curve; no copy of any of them. - #[test] - fn the_production_backoff_outlasts_a_model_reload() { - let total: Duration = (0..ROUND_RETRY_BUDGET) - .map(|attempt| backoff_delay(RETRY_DELAY_BASE_MS, attempt)) - .sum(); - assert!(total >= Duration::from_secs(90), "the retry window is only {total:?}"); - assert!(backoff_delay(RETRY_DELAY_BASE_MS, 0) <= Duration::from_millis(200)); - } - - /// PA.f66a: exhausting the round budget closes the turn and narrates, - /// rather than leaving it `Running` with nothing said. - #[tokio::test] - async fn exhausting_the_round_budget_closes_and_narrates() { - let fixture = play_world(None, "player-round-budget").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-budget", vec![text_event("The moment passes.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let rounds: Vec = (0..ROUND_BUDGET) - .map(|index| output(&format!("r{index}"), vec![text_event("Thinking.")])) - .collect(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(rounds), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(430), "Stall.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "the round budget must close the turn, not leave it Running" - ); - assert_eq!(turn.narration.as_deref(), Some("The moment passes.")); - } - - /// PA.f103: a narrate failure right after `end_turn` still closes the - /// turn with the fault recorded — Soul's own probe: `end_turn` with a - /// starved Persona script that yields no narration at all. Mutation: - /// turning `close_turn`'s own call site in the `end_turn` branch back - /// into a bare `self.close_turn(turn).await?` fails this, leaving the - /// turn `Running` behind the propagated error instead of `Closed` with - /// `fault` recorded. - #[tokio::test] - async fn a_narrate_failure_after_end_turn_closes_with_the_fault_recorded() { - let fixture = play_world(None, "player-narrate-fault-end-turn").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - // Starved: `end_turn`'s own narrate call finds nothing queued. - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let round = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(431), "Hello?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "a narrate failure after end_turn must still close the turn" - ); - assert!(turn.fault.is_some(), "the narrate failure's own fault must be recorded"); - assert!(turn.narration.is_none(), "no narration must be recorded when narrate itself faulted"); - } - - /// PA.f103: a narrate failure at the round budget, not only at - /// `end_turn`, still closes the turn with the fault recorded — the round - /// budget's own `close_turn` call site is a second, independent door - /// onto the same helper. Mutation: turning that call site back into a - /// bare `?` fails this the same way the sibling `end_turn` mutation does. - #[tokio::test] - async fn a_narrate_failure_at_the_round_budget_closes_with_the_fault_recorded() { - let fixture = play_world(None, "player-narrate-fault-round-budget").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - // Starved: the round budget's own narrate call finds nothing - // queued either. - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let rounds: Vec = (0..ROUND_BUDGET) - .map(|index| output(&format!("r{index}"), vec![text_event("Thinking.")])) - .collect(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(rounds), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(432), "Stall.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "the round budget must still close the turn when its own narrate faults" - ); - assert!(turn.fault.is_some(), "the narrate failure's own fault must be recorded"); - assert!(turn.narration.is_none(), "no narration must be recorded when narrate itself faulted"); - } - - /// PA.f66c: `dispatched_subjects` is derived from the whole record, in - /// call order, regardless of which round each dispatch call landed in — - /// the same value both `infer_round` (building next round's tools) and - /// `execute_round` (resolving a `__` call) read, on a - /// first pass and after a resume alike. - #[tokio::test] - async fn dispatched_subjects_reflects_the_whole_record_in_call_order() { - let fixture = play_world(Some("Mara"), "player-dispatch-order").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let a = player_id(&snapshot); - let b = persona_id(&snapshot); - let turn = PlayTurn { - turn_id: test_turn_id(440), - applied_keys: Vec::new(), - opening_prompt: String::new(), - player_prose: Vec::new(), - rounds: Vec::new(), - calls: vec![ - CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![a])), - result: Some("applied".into()), - }, - CallRecord { - call_id: "c1".into(), - round: 1, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![b])), - result: Some("applied".into()), - }, - ], - // PA.f90: a dispatch counts as dispatched only once its Persona - // turn actually produced and recorded prose, so both subjects - // need a `persona_turns` entry here. - persona_turns: vec![(a, fake_persona_turn("a acted")), (b, fake_persona_turn("b acted"))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - assert_eq!(turn.dispatched_subjects(), vec![a, b]); - } - - /// PA.f90: a dispatch whose Persona turn faulted, or found no live - /// opportunity, never produced prose — it must not count as dispatched, - /// so its actor tools are not offered next round and an actor call for - /// it is refused, even though the `Dispatch` call itself recorded - /// success. - #[tokio::test] - async fn a_dispatch_with_no_recorded_persona_turn_does_not_count_as_dispatched() { - let fixture = play_world(Some("Mara"), "player-dispatch-no-turn").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let subject = persona_id(&snapshot); - let turn = PlayTurn { - turn_id: test_turn_id(441), - applied_keys: Vec::new(), - opening_prompt: String::new(), - player_prose: Vec::new(), - rounds: Vec::new(), - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![subject])), - result: Some("holds no live Persona opportunity".into()), - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - assert_eq!(turn.dispatched_subjects(), Vec::new()); - } - - fn fake_persona_turn(prose: &str) -> PersonaTurn { - PersonaTurn::record( - PersonaTurnBinding { - world_id: "world".into(), - controller_id: "controller".into(), - opportunity_digest: "opportunity".into(), - world_revision: 0, - scope_digest: "scope".into(), - projector_receipt_digest: "projector".into(), - persona_inference_receipt_digest: "persona".into(), - interrupted_from: None, - }, - prose, - ) - } - - /// PA.f66d: the derived command id for one dispatched Persona's own turn - /// depends on the dispatch call's own slot and the subject, never on the - /// subject's position within that call's own JSON `subjects` array — - /// two subjects one call names, or the same subject two calls in one - /// round name, must never collide. - #[test] - fn dispatch_command_id_is_distinct_per_subject_and_call_and_stable_regardless_of_list_order() { - let turn_id = test_turn_id(999); - let a = dispatch_command_id_for_handle(&turn_id, 0, 5, "subject-a"); - let b = dispatch_command_id_for_handle(&turn_id, 0, 5, "subject-b"); - assert_ne!( - format!("{a:?}"), - format!("{b:?}"), - "two subjects named by the same dispatch call must not collide" - ); - let a_again = dispatch_command_id_for_handle(&turn_id, 0, 5, "subject-a"); - assert_eq!( - format!("{a:?}"), - format!("{a_again:?}"), - "the same subject, round, and call slot must derive the same id" - ); - let a_other_call = dispatch_command_id_for_handle(&turn_id, 0, 6, "subject-a"); - assert_ne!( - format!("{a:?}"), - format!("{a_other_call:?}"), - "two dispatch calls in one round naming the same subject must not collide" - ); - } - - /// PA.f104: `[dispatch(Mara), __speak, end_turn]` in one round must - /// resolve the speak call identically whether it is executed straight - /// through on a first pass, or resumed after a crash that landed only - /// the dispatch call's own result. `dispatched` used to be snapshotted - /// once before a round's own calls executed (before the dispatch call - /// even ran), refusing the speak call on the first pass while a resume's - /// fresh derivation — taken after the dispatch was already recorded — - /// applied it. Mutation: reintroducing that per-round snapshot fails - /// this by making the first-pass branch disagree with the resumed one. - #[tokio::test] - async fn a_same_round_dispatch_then_speak_agrees_on_first_pass_and_resume() { - const PROSE: &str = "Mara considers the player."; - const QUOTE: &str = "I nod once."; - - let first_pass = { - let fixture = play_world(Some("Mara"), "player-f104-first-pass").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&snapshot)); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_id]})), - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": QUOTE}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - // Two inference calls per dispatch: the Projector (`PROSE`) - // then the Persona itself, whose own output — `QUOTE` here — - // becomes `PersonaTurn::source_prose()`, the text the speak - // call's own quote is checked against. - ScriptedPort::new(vec![output("proj-mara", vec![text_event(PROSE)]), output("persona-mara", vec![text_event(QUOTE)])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(740), "Who's there?".into()) - .await - .unwrap(); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let speak_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - drop(stored); - let after = fixture.world.snapshot().await.unwrap(); - (speak_result, after.revision) - }; - - let resumed = { - let fixture = play_world(Some("Mara"), "player-f104-resume").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara_subject = persona_id(&snapshot); - let mara = handle_for(mara_subject); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [subject_id_text(&snapshot, mara_subject)]}), - ), - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": QUOTE}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let turn_id = test_turn_id(741); - // The state a crash right after the dispatch call resolved, but - // before the speak call did, would have left on disk: the - // dispatch call already carries its own recorded body and - // result, and its Persona turn is already recorded — exactly - // what `execute_dispatch`/`record_call`/`persist` would have - // written, never a value this test reconstructs by re-deriving - // anything `dispatched_subjects` itself computes. - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: vec![test_turn_id(742)], - opening_prompt: format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Who's there?" - ), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![mara_subject])), - result: Some(format!("Mara: {QUOTE}")), - }], - // The Persona's own recorded turn: its `source_prose()` is - // `QUOTE` (the actual Persona response), matching what a - // real `execute_dispatch` would have written, and what the - // speak call below quotes from. - persona_turns: vec![(mara_subject, fake_persona_turn(QUOTE))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(743), String::new().into()) - .await - .unwrap(); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let speak_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - drop(stored); - let after = fixture.world.snapshot().await.unwrap(); - (speak_result, after.revision) - }; - - assert_eq!(first_pass.0, "applied", "first pass: {}", first_pass.0); - assert_eq!( - first_pass, resumed, - "the same round script must resolve the speak call and the world revision identically \ - on a first pass and on resume" - ); - } - - // --- PA.f99: the persisted body is on disk before submission ---------- - // - // Each test below installs `PlayTable`'s own `#[cfg(test)]` - // `before_submit_hook` (see its doc comment): fired right before the one - // production call that submits a call's already-decoded body, it hands - // the hook a fresh `pull_all` against the store's own already-owned - // redb `Database` (`PlayTurnStore::pull_current_from_disk_for_test`) — - // never the store's cached `self.state`, which a mutation could leave - // stale relative to disk, and never a second, independently opened - // store: CultCache's own single-owner lock refuses a second - // `OwnedRedbMessagePackBackingStore::new` on the same path outright - // (discovered building this very seam — see the method's own doc - // comment). Each test then asserts the exact recorded body is already - // durably there. Deleting the `persist` call at any of the three record - // sites, or moving it to after its own submit call, must fail the - // matching test here. - - #[tokio::test] - async fn the_authoring_patch_is_persisted_before_it_is_submitted() { - let fixture = play_world(None, "player-f99-authoring").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_resource", - serde_json::json!({"handle": "silver", "label": "Silver"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.set_before_submit_hook(move |durable_turn| { - let turn = durable_turn.expect("a turn is durably on disk at submission time"); - let recorded = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - assert!( - matches!(recorded, Some(RecordedCall::Authoring(_))), - "the authoring patch must already be on disk when submission happens: {recorded:?}" - ); - }); - table - .run(&fixture.principal, test_turn_id(750), "Declare silver.".into()) - .await - .unwrap(); - } - - #[tokio::test] - async fn the_advance_time_minutes_are_persisted_before_they_are_submitted() { - let fixture = play_world(None, "player-f99-advance").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let round = output( - "r0", - vec![ - call_event("c0", ADVANCE_TIME_TOOL, serde_json::json!({"minutes": 5})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.set_before_submit_hook(move |durable_turn| { - let turn = durable_turn.expect("a turn is durably on disk at submission time"); - let recorded = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - assert!( - matches!(recorded, Some(RecordedCall::AdvanceTime(_))), - "the advance-time minutes must already be on disk when submission happens: {recorded:?}" - ); - }); - table - .run(&fixture.principal, test_turn_id(751), "Advance time.".into()) - .await - .unwrap(); - } - - #[tokio::test] - async fn the_actor_calls_invocation_is_persisted_before_it_is_submitted() { - let fixture = play_world(None, "player-f99-actor").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = handle_for(player_id(&snapshot)); - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let round = output( - "r0", - vec![ - call_event( - "c0", - &format!("{player}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "hold fast"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.set_before_submit_hook(move |durable_turn| { - let turn = durable_turn.expect("a turn is durably on disk at submission time"); - let recorded = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - assert!( - matches!(recorded, Some(RecordedCall::PlayerAct(_, _))), - "the actor call's own invocation must already be on disk when submission happens: {recorded:?}" - ); - }); - table - .run(&fixture.principal, test_turn_id(752), "hold fast".into()) - .await - .unwrap(); - } - - /// PA.f125. `execute_dispatch` persisted the dispatched Persona's own - /// `persona_turns` entry (its own `self.persist` right after each - /// subject completes, PA.f123) *before* the `Dispatch` call's own body - /// was ever recorded — `execute_round`'s `record_call` for the dispatch - /// call itself used to run only after `execute_dispatch` returned. A - /// crash in that window left `persona_turns` durable with no call - /// record naming it at all; on resume, the unfixed code read the - /// already-durable `persona_turns` entry as evidence some *other* call - /// already dispatched the subject, so the resumed dispatch call's own - /// tool result became ` already acted` instead of the Persona's own - /// prose — PA.f64's whole point defeated on exactly the path a crash - /// makes likely. - /// - /// The fix: `execute_round` now records and persists the dispatch - /// call's own body before `execute_dispatch` runs any persona turn, and - /// `execute_dispatch` tells its own not-yet-finished work apart from a - /// different call's completed dispatch through `PlayTurn::dispatch_owner` - /// (first call, in call order, to name a subject). - /// - /// First pass, live: `before_submit_hook` reads the store fresh off - /// disk right before the dispatched Persona's own turn is submitted - /// (subsuming PA.f123's own coverage), and the dispatch call's own body - /// must already be there. Mutation: reordering the record after the - /// persist, or deleting either, leaves the hook reading a turn whose - /// `calls` are still empty. - /// - /// Second pass, resumed: a hand-built crashed turn — no call record at - /// all for the dispatch call, but `persona_turns` already carrying the - /// Persona's own turn, exactly what the pre-fix crash window left on - /// disk (Soul's probe shape: built as the persist writes it, not - /// re-derived from anything `dispatch_owner`/`dispatched_subjects` - /// itself computes) — resumes into a tool result that still carries the - /// Persona's own prose, never "already acted", and the Personas port - /// sees no fresh inference request (the Persona is not re-inferred). - /// All three mutations above fail this half too: without the record, - /// `dispatch_owner` finds no call at all naming the subject and the - /// resumed call falls back to "already acted". - #[tokio::test] - async fn a_resumed_dispatch_call_recovers_its_own_persona_prose_not_already_acted() { - const PROSE: &str = "Mara nods once."; - - // First pass: live, hook-verified durability ordering. - { - let fixture = play_world(Some("Mara"), "player-f125-first-pass").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text]})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers.")]), - output("persona-mara", vec![text_event(PROSE)]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.set_before_submit_hook(move |durable_turn| { - let turn = durable_turn.expect("a turn is durably on disk at submission time"); - let recorded = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - assert!( - matches!(recorded, Some(RecordedCall::Dispatch(_))), - "the dispatch call's own body must already be on disk before its Persona turn submits: {recorded:?}" - ); - }); - table - .run(&fixture.principal, test_turn_id(920), "Who's there?".into()) - .await - .unwrap(); - } - - // Second pass: resumed from the exact crash window PA.f125 closes. - let fixture = play_world(Some("Mara"), "player-f125-resume").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text]})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let turn_id = test_turn_id(921); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Who's there?" - ), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - // No call record at all for the dispatch call: exactly what a - // crash between `execute_dispatch`'s own per-subject persist - // and `execute_round`'s own (pre-fix) later `record_call` left - // on disk. - calls: Vec::new(), - persona_turns: vec![(mara, fake_persona_turn(PROSE))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas_port = ScriptedPort::new(vec![]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - personas_port.clone(), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - let mara_turns = turn.persona_turns.iter().filter(|(id, _)| *id == mara).count(); - drop(stored); - assert!( - dispatch_result.contains(PROSE), - "the resumed dispatch call must still carry the Persona's own prose: {dispatch_result}" - ); - assert!( - !dispatch_result.contains("already acted"), - "the resumed dispatch call must not be refused as already acted: {dispatch_result}" - ); - // Not the shared `personas_port`'s own `seen_requests` (round 0's - // `end_turn` still legitimately narrates through the same lane for - // the player, on the same port): the direct signal that the Persona - // herself was not re-inferred is that her own `persona_turns` entry - // is still exactly the one this test seeded, not a second one a - // fresh `personas.turn()` call would have pushed. - assert_eq!( - mara_turns, 1, - "resuming a completed dispatch must not re-infer the Persona into a duplicate persona_turns entry" - ); - } - - /// PA.f62/PA.f63: an answer under a fresh request key resumes the - /// waiting turn — already exercised end to end by - /// `a_question_holds_the_turn_open_across_a_restart`, which now uses a - /// distinct key for its own answer. - /// - /// This test covers the sibling rule: replaying that same answer key a - /// second time, while the turn it answered is still open (it asks a - /// second question), is a no-op — it must not re-infer or re-answer. - #[tokio::test] - async fn a_replayed_answer_key_is_a_no_op_while_the_turn_is_still_open() { - let fixture = play_world(None, "player-replay-open").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask_one = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let ask_two = output( - "r1", - vec![call_event( - "c1", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Are you sure?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask_one, ask_two]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(450), "start".into()) - .await - .unwrap(); - let first_question = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - let answer_key = test_turn_id(451); - let answer = || PlayRequest { - text: "left".into(), - answers: Some(first_question.clone()), - }; - table.run(&fixture.principal, answer_key.clone(), answer()).await.unwrap(); - - // The inference queue is now exhausted: a broken replay that treats - // this key as unseen would try to answer round 1's own question and - // infer a third round, failing loudly. A correct no-op leaves round - // 1's question exactly as it was. - table.run(&fixture.principal, answer_key, answer()).await.unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::AwaitingPlayer); - assert_eq!(turn.question.as_deref(), Some("Are you sure?")); - assert!(turn.fault.is_none(), "a replayed key must never touch inference: {:?}", turn.fault); - } - - /// PA.f62/PA.f63: a replayed key from a closed turn returns that turn's - /// state and makes zero new inference calls. - #[tokio::test] - async fn a_replayed_key_from_a_closed_turn_makes_zero_inference_calls() { - let fixture = play_world(None, "player-replay-closed").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let key = test_turn_id(460); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - // Exactly one output: a second inference attempt would exhaust it. - let port = ScriptedPort::new(vec![end]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, key.clone(), "Go.".into()) - .await - .unwrap(); - assert_eq!(port.seen_requests().len(), 1); - - table - .run(&fixture.principal, key.clone(), "Go.".into()) - .await - .unwrap(); - - assert_eq!( - port.seen_requests().len(), - 1, - "a replayed key from a closed turn must make zero new inference calls" - ); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!(turn.applied_keys.contains(&key)); - assert_eq!(turn.state, PlayTurnState::Closed); - } - - /// PA.f63 + PA.f69 S7 + invariant 1: a genuinely new key after a closed - /// turn opens a fresh turn under that key, and nothing from the closed - /// turn's own record crosses into it — checked against the actual - /// inference request the fresh turn's own round sent, not merely the - /// stored `opening_prompt`. - #[tokio::test] - async fn a_new_key_after_a_closed_turn_starts_a_fresh_turn_with_no_prior_transcript() { - let fixture = play_world(None, "player-fresh-after-close").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-1", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(470), "UNIQUE_TURN_ONE_PROSE".into()) - .await - .unwrap(); - } - - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-2", vec![text_event("Also fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end2 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let port = ScriptedPort::new(vec![end2]); - let table = PlayTable::new( - fixture.world.clone(), - personas2, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let fresh_key = test_turn_id(471); - table - .run(&fixture.principal, fresh_key.clone(), "UNIQUE_TURN_TWO_PROSE".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.applied_keys.contains(&fresh_key), - "a new key after a closed turn must open a fresh turn recording that key" - ); - assert!(turn.opening_prompt.contains("UNIQUE_TURN_TWO_PROSE")); - assert!( - !turn.opening_prompt.contains("UNIQUE_TURN_ONE_PROSE"), - "no transcript may cross from a closed turn into a new one" - ); - - let seen = port.seen_requests(); - let request_text = format!("{:?}", seen.first().unwrap()); - assert!( - !request_text.contains("UNIQUE_TURN_ONE_PROSE"), - "the new turn's own request must carry nothing from the closed turn: {request_text}" - ); - } - - // --- Fix batch 8a-fix2 (PA.f83-PA.f97) -------------------------------- - - /// PA.f83: two consecutive turns mint distinct `turn_id`s (never taken - /// from either request key), and their derived command ids never - /// collide. - #[tokio::test] - async fn two_consecutive_turns_mint_distinct_ids_and_non_colliding_command_ids() { - let fixture = play_world(None, "player-distinct-turn-ids").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let personas1 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-1", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end1 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas1, - ScriptedPort::new(vec![end1]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(600), "Turn one.".into()).await.unwrap(); - let turn1_id = table.store.lock().await.current().unwrap().turn_id.clone(); - drop(table); - - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-2", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end2 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas2, - ScriptedPort::new(vec![end2]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(601), "Turn two.".into()).await.unwrap(); - let turn2_id = table.store.lock().await.current().unwrap().turn_id.clone(); - - assert_ne!(turn1_id, turn2_id, "each turn mints its own fresh turn_id (PA.f83)"); - let turn1_command0 = derived_command_id(&turn1_id, 0, 0); - let turn2_command0 = derived_command_id(&turn2_id, 0, 0); - assert_ne!( - format!("{turn1_command0:?}"), - format!("{turn2_command0:?}"), - "command ids derived from distinct turn_ids must not collide" - ); - } - - fn minimal_closed_turn(turn_id: String, keys: Vec) -> PlayTurn { - PlayTurn { - turn_id, - applied_keys: keys, - opening_prompt: String::new(), - player_prose: Vec::new(), - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Closed, - } - } - - /// PA.f100: `turn_id` is minted fresh (`Uuid::new_v4`), never derived - /// from the request key — the same key, once evicted from the store's - /// own `KeyLedger` window and so no longer recognized as a replay, opens - /// a genuinely new turn with a fresh id each time. Mutation: deriving - /// `turn_id` as a hash of `key` fails this, since both opens below share - /// exactly one key and a hash of one value cannot differ from itself. - #[tokio::test] - async fn the_same_evicted_key_opens_two_turns_with_distinct_fresh_ids() { - let fixture = play_world(None, "player-f100-evicted-key").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let key = "reused-opening-key".to_owned(); - - let personas1 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-1", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end1 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas1, - ScriptedPort::new(vec![end1]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, key.clone(), "Turn one.".into()).await.unwrap(); - let turn1_id = table.store.lock().await.current().unwrap().turn_id.clone(); - - // Evict `key` from the ledger: turn one is already the store's - // current (closed) turn, so the first of `KEY_LEDGER_WINDOW + 1` - // more closed turns, written directly through the store (no - // inference needed to close an already-`Closed` fixture turn), - // archives it as the oldest entry, filling the window exactly; the - // one past that finally pushes it out (matching - // `the_ledger_forgets_a_closed_turns_keys_past_the_window`'s own - // window-plus-one shape against the pure `KeyLedger` value). - { - let mut store = table.store.lock().await; - for index in 0..=KEY_LEDGER_WINDOW { - store - .open_new_turn(minimal_closed_turn(format!("filler-{index}"), vec![format!("filler-key-{index}")])) - .unwrap(); - } - assert!( - store.turn_id_for_key(&key).is_none(), - "the original key must be evicted past the window before the second open" - ); - } - drop(table); - - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-2", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end2 = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas2, - ScriptedPort::new(vec![end2]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let outcome = table.run(&fixture.principal, key.clone(), "Turn two.".into()).await.unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. }), "the evicted key must open a fresh turn, not replay: {outcome:?}"); - let turn2_id = table.store.lock().await.current().unwrap().turn_id.clone(); - - assert_ne!( - turn1_id, turn2_id, - "the same request key, reused only after eviction, must still mint a fresh turn_id each open" - ); - } - - /// PA.f83, PA.f116, PA.f128: K1, K2, K3 and K4 each open and close their - /// own turn in order; opening K4 archives K1, K2 and K3 into the - /// store's own `KeyLedger` (one more entry per turn that closes and is - /// then displaced), so a retry of K2 — the archived entry in the - /// ledger's own *middle*, at neither end — must still name K2's own - /// `turn_id`. With only K1 and K2 closed (PA.f116's own shape), the - /// retried key's entry happened to sit at the ledger's back regardless - /// of whether `turn_id_for` actually searched or just returned the back - /// entry, so that shape passed without exercising the search at all - /// (PA.f128); a fourth closed turn is needed to put K2 at neither end. - /// Mutations: `KeyLedger::turn_id_for` returning `self.closed.front()`'s - /// id on any match, or `self.closed.iter().find(...)?; self.closed.back()`'s - /// id on any match, must both fail this. - #[tokio::test] - async fn a_retried_key_from_a_turn_before_the_most_recent_is_a_no_op() { - let fixture = play_world(None, "player-ledger-retry").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - fn personas(world: &WorldMailbox, proj: &'static str) -> PersonaLane { - PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output(proj, vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap() - } - async fn run_and_close( - fixture: &WorldFixture, - store_path: &Path, - proj: &'static str, - key: String, - text: &str, - ) -> String { - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas(&fixture.world, proj), - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - store_path, - ) - .unwrap(); - table.run(&fixture.principal, key, text.into()).await.unwrap(); - drop(table); - PlayTurnStore::open(store_path).unwrap().current().unwrap().turn_id.clone() - } - - let k1 = test_turn_id(610); - let turn1_id = run_and_close(&fixture, &store_path, "proj-k1", k1, "K1.").await; - let k2 = test_turn_id(611); - let turn2_id = run_and_close(&fixture, &store_path, "proj-k2", k2.clone(), "K2.").await; - assert_ne!(turn1_id, turn2_id); - let k3 = test_turn_id(612); - let turn3_id = run_and_close(&fixture, &store_path, "proj-k3", k3, "K3.").await; - assert_ne!(turn2_id, turn3_id); - let k4 = test_turn_id(613); - let turn4_id = run_and_close(&fixture, &store_path, "proj-k4", k4, "K4.").await; - assert_ne!(turn3_id, turn4_id); - - // A retry of K2: an empty scripted port means any inference attempt - // fails loudly, so this stays a genuine "zero inference calls" check. - let port = ScriptedPort::new(vec![]); - let table = PlayTable::new( - fixture.world.clone(), - personas(&fixture.world, "proj-k2-retry"), - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let outcome = table.run(&fixture.principal, k2, "K2.".into()).await.unwrap(); - - assert_eq!( - port.seen_requests().len(), - 0, - "a retried key already known to the ledger must make zero inference calls" - ); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.turn_id, turn4_id, "the retry must not open a fifth turn"); - // PA.f109: the replay's own outcome names the turn it replayed — - // K2's own turn, neither K1's (the ledger's front entry), K3's, nor - // K4's (the store's current one). - assert_eq!( - outcome, - RunOutcome::Replayed { turn_id: turn2_id, question: None }, - "a replayed key's own outcome must name the turn it originally opened, not the ledger's front or back entry" - ); - } - - /// PA.f83, PA.f117: the ledger keeps a closed turn's own keys only while - /// it remains among the most recent `KEY_LEDGER_WINDOW` closed turns; - /// one more closed turn past the window evicts the oldest one's keys. A - /// small helper against the pure `KeyLedger` value directly, so this - /// does not run 65 real turns. Reached through `turn_id_for` — `contains` - /// had no production caller left after PA.f109's `turn_id_for` replaced - /// it and is deleted. - #[test] - fn the_ledger_forgets_a_closed_turns_keys_past_the_window() { - let mut ledger = KeyLedger::default(); - ledger.push_closed("oldest-turn".into(), vec!["oldest-key".into()]); - for index in 0..KEY_LEDGER_WINDOW - 1 { - ledger.push_closed(format!("turn-{index}"), vec![format!("key-{index}")]); - } - assert_eq!( - ledger.turn_id_for("oldest-key"), - Some("oldest-turn".to_owned()), - "the oldest turn's own key must still be known while it is within the window" - ); - // The 65th closed turn: exactly one past the window, evicting the - // oldest. - ledger.push_closed("one-too-many".into(), vec!["newest-key".into()]); - assert_eq!( - ledger.turn_id_for("oldest-key"), - None, - "a key older than the window must be forgotten" - ); - assert_eq!(ledger.turn_id_for("newest-key"), Some("one-too-many".to_owned())); - } - - /// PA.f84 probe: K1 opens the turn and asks Q1; K2 answers Q1 and the - /// next round asks Q2; a retry of K1 is recognized as a replay and is a - /// no-op — it does not, and cannot, answer Q2. - #[tokio::test] - async fn a_retried_opening_key_never_answers_a_later_question() { - let fixture = play_world(None, "player-probe-late-retry").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask_one = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - ); - let ask_two = output( - "r1", - vec![call_event("c1", ASK_PLAYER_TOOL, serde_json::json!({"question": "Are you sure?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = ScriptedPort::new(vec![ask_one, ask_two]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let k1 = test_turn_id(630); - // K1 closes: the turn is now `AwaitingPlayer` on Q1. - table.run(&fixture.principal, k1.clone(), "start".into()).await.unwrap(); - let q1 = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - let k2 = test_turn_id(631); - // K2 asks a question: it answers Q1, and the next round asks Q2. - table - .run( - &fixture.principal, - k2, - PlayRequest { - text: "left".into(), - answers: Some(q1), - }, - ) - .await - .unwrap(); - assert_eq!(table.store.lock().await.current().unwrap().question.as_deref(), Some("Are you sure?")); - let seen_before_retry = port.seen_requests().len(); - - // K1 is retried: it opened the turn and is already known, so this is - // a no-op. - table.run(&fixture.principal, k1, "left".into()).await.unwrap(); - - assert_eq!( - port.seen_requests().len(), - seen_before_retry, - "a retried key must make zero new inference calls" - ); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::AwaitingPlayer); - assert_eq!(turn.question.as_deref(), Some("Are you sure?"), "K1's own retry must not answer Q2"); - } - - /// PA.f84: an answer naming a different, no-longer-open question is - /// refused as stale rather than being applied to whatever question is - /// open now. - #[tokio::test] - async fn an_answer_naming_a_different_question_is_refused_as_stale() { - let fixture = play_world(None, "player-stale-answer").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask_one = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - ); - let ask_two = output( - "r1", - vec![call_event("c1", ASK_PLAYER_TOOL, serde_json::json!({"question": "Are you sure?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask_one, ask_two]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table.run(&fixture.principal, test_turn_id(620), "start".into()).await.unwrap(); - let stale_question = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - table - .run( - &fixture.principal, - test_turn_id(621), - PlayRequest { - text: "left".into(), - answers: Some(stale_question.clone()), - }, - ) - .await - .unwrap(); - - // Q2 is now open; a fresh key answering with Q1's own (now stale) - // question id must be refused, not silently applied to Q2. - let error = table - .run( - &fixture.principal, - test_turn_id(622), - PlayRequest { - text: "sure".into(), - answers: Some(stale_question), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::StaleAnswer { .. }), "{error:?}"); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.question.as_deref(), - Some("Are you sure?"), - "the stale answer must not touch the currently open question" - ); - assert!( - !turn.applied_keys.contains(&test_turn_id(622)), - "a refused stale answer's key must never be marked applied" - ); - } - - /// PA.f188: `open_question_of`'s own `token` field must carry - /// `question_token` exactly — `None` stays `None` — never fabricated into - /// a comparable `Some("")` (PA.f179's own invariant, at this construction - /// site specifically). Production never actually reaches `question_token: - /// None` with a genuinely open question (`execute_round`'s - /// `ASK_PLAYER_TOOL` arm always sets both together), but the type system - /// alone does not forbid it, and this construction site had no direct - /// test of its own before Soul's mutation below. - /// - /// Mutation (Soul's own probe, PA.f188): - /// ```text - /// - token: turn.question_token.clone(), - /// + token: Some(turn.question_token.clone().unwrap_or_default()), - /// ``` - /// survived the whole suite before this test existed. - #[test] - fn open_question_of_never_fabricates_a_token_when_none_was_minted() { - let turn = PlayTurn { - turn_id: test_turn_id(9100), - applied_keys: Vec::new(), - opening_prompt: "opening".into(), - player_prose: vec!["I try the act.".into()], - rounds: vec![output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - let open = open_question_of(&turn).expect("the turn's own open question must still be found"); - assert_eq!( - open.token, None, - "a turn whose question_token was never minted must surface as an absent token, not a \ - comparable empty string" - ); - } - - /// PA.f188: the `StaleAnswer` refusal's own construction site - /// (`run`'s `_ =>` arm) must carry the same guarantee as - /// `open_question_of` — `existing.question_token.clone()` verbatim, never - /// fabricated into `Some("")`. Seeds a turn directly into the store with - /// a genuinely open question but `question_token: None` (a shape - /// production never produces, since `execute_round` always mints both - /// together, but one this refusal arm must still not degrade), then - /// answers it with a deliberately stale question id so the `_ =>` arm - /// actually runs. - /// - /// Mutation: the same shape as `open_question_of`'s own probe, applied to - /// this arm's `token: existing.question_token.clone()` instead. - #[tokio::test] - async fn a_stale_answer_refusal_never_fabricates_a_token_the_turn_never_minted() { - let fixture = play_world(None, "player-stale-no-token").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let turn = PlayTurn { - turn_id: test_turn_id(9200), - applied_keys: Vec::new(), - opening_prompt: "opening".into(), - player_prose: vec!["I try the act.".into()], - rounds: vec![output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - table.store.lock().await.open_new_turn(turn).unwrap(); - - let stale_question = QuestionId { - turn_id: test_turn_id(9200), - round: 99, - slot: 99, - }; - let error = table - .run( - &fixture.principal, - test_turn_id(9201), - PlayRequest { text: "left".into(), answers: Some(stale_question) }, - ) - .await - .unwrap_err(); - let PlayError::StaleAnswer { question: Some(open), .. } = error else { - panic!("expected a StaleAnswer refusal carrying the turn's own still-open question: {error:?}"); - }; - assert_eq!( - open.token, None, - "a turn whose question_token was never minted must surface as an absent token in the \ - stale refusal too, not a comparable empty string" - ); - } - - /// PA.f101: `answers: None` while a question genuinely is open is - /// refused exactly like a stale, wrongly-named one — the match at - /// `run`'s own open-question arm only ever applies the `(Some(given), - /// Some(open)) if given == open` case; every other combination, - /// including `None`, falls to the same `StaleAnswer` refusal. Mutation: - /// accepting `None` there (treating an absent answer as "answer - /// whatever is open") fails this, since nothing here supplied any text - /// naming an actual answer. - #[tokio::test] - async fn an_answer_of_none_while_a_question_is_open_is_refused_and_records_nothing() { - let fixture = play_world(None, "player-none-answer").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(623), "start".into()).await.unwrap(); - let before = table.store.lock().await.current().unwrap().clone(); - assert_eq!(before.state, PlayTurnState::AwaitingPlayer); - - let error = table - .run( - &fixture.principal, - test_turn_id(624), - PlayRequest { - text: "left".into(), - answers: None, - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::StaleAnswer { .. }), "{error:?}"); - - let stored = table.store.lock().await; - let after = stored.current().unwrap(); - assert_eq!(after.state, PlayTurnState::AwaitingPlayer, "the turn must stay AwaitingPlayer"); - assert_eq!(after.question, before.question, "the open question must not change"); - assert_eq!(after.calls.len(), before.calls.len(), "nothing must be recorded from a refused None answer"); - assert!( - !after.applied_keys.contains(&test_turn_id(624)), - "a refused None answer's key must never be marked applied" - ); - } - - /// PA.f84: `answers` given while the turn is `Running` (no question - /// open at all) is refused. - #[tokio::test] - async fn an_answer_while_the_turn_is_running_is_refused() { - let fixture = play_world(None, "player-answers-while-running").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(640); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - // A turn caught mid-loop, `Running`, between rounds — the exact - // shape a real crash could leave persisted (`run` itself persists - // right after each round with `RoundOutcome::Continue`, before its - // own loop condition re-checks `turn.state`). - let running_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: vec![test_turn_id(639)], - opening_prompt: "opening".into(), - player_prose: vec!["hello".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(running_turn).unwrap(); - - let bogus_question = QuestionId { - turn_id, - round: 0, - slot: 0, - }; - let error = table - .run( - &fixture.principal, - test_turn_id(641), - PlayRequest { - text: String::new(), - answers: Some(bogus_question), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::NoQuestionOpen), "{error:?}"); - } - - /// PA.f84: `answers` given when there is no open turn at all is refused. - #[tokio::test] - async fn an_answer_with_no_open_turn_at_all_is_refused() { - let fixture = play_world(None, "player-answers-no-turn").await; - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let bogus_question = QuestionId { - turn_id: "nonexistent".into(), - round: 0, - slot: 0, - }; - let error = table - .run( - &fixture.principal, - test_turn_id(650), - PlayRequest { - text: "hi".into(), - answers: Some(bogus_question), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::NoQuestionOpen), "{error:?}"); - } - - /// PA.f84: while the turn is `Running`, non-empty text is refused - /// rather than dropped silently — the turn is still resolving its own - /// prior request's rounds. - #[tokio::test] - async fn nonempty_text_while_the_turn_is_running_is_refused() { - let fixture = play_world(None, "player-turn-still-running").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(660); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let running_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: vec![test_turn_id(659)], - opening_prompt: "opening".into(), - player_prose: vec!["hello".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(running_turn).unwrap(); - - let error = table.run(&fixture.principal, test_turn_id(661), "more text".into()).await.unwrap_err(); - assert!(matches!(error, PlayError::TurnStillRunning), "{error:?}"); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Running); - assert!(!turn.applied_keys.contains(&test_turn_id(661))); - } - - /// PA.f84: while the turn is `Running`, empty text continues it. - #[tokio::test] - async fn empty_text_while_the_turn_is_running_continues_it() { - let fixture = play_world(None, "player-turn-continues").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(670); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - let snapshot = fixture.world.snapshot().await.unwrap(); - let running_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: vec![test_turn_id(669)], - opening_prompt: format!("{}\n\nThe player writes:\n{}\n", table_view(&snapshot), "hi"), - player_prose: vec!["hi".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(running_turn).unwrap(); - - table.run(&fixture.principal, test_turn_id(671), String::new().into()).await.unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!(turn.applied_keys.contains(&test_turn_id(671))); - } - - /// PA.f108: empty or whitespace-only text with no turn open to continue - /// — no turn at all, or the current one already `Closed` — is refused - /// before a fresh turn opens, rather than opening one with prose `[""]` - /// and running a full inference over nothing the player said. Checked - /// both with no stored turn at all and with a `Closed` one on record, - /// and in each case zero inference calls are made and the store's own - /// current turn is untouched. - #[tokio::test] - async fn empty_text_with_no_turn_open_is_refused_and_infers_nothing() { - let fixture = play_world(None, "player-empty-opening").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let play_port = ScriptedPort::new(vec![]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - play_port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - // No turn on record at all. - let error = table - .run(&fixture.principal, test_turn_id(672), String::new().into()) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::EmptyOpening), "{error:?}"); - assert!(table.store.lock().await.current().is_none(), "no turn must have opened"); - assert!(play_port.seen_requests().is_empty(), "an empty opening must make zero inference calls"); - - // A whitespace-only text with a `Closed` turn already on record must - // be refused the same way, not treated as a continue of the closed - // turn or as a fresh (empty) opening. - let snapshot = fixture.world.snapshot().await.unwrap(); - let closed_turn = PlayTurn { - turn_id: test_turn_id(673), - applied_keys: vec![test_turn_id(674)], - opening_prompt: format!("{}\n\nThe player writes:\n{}\n", table_view(&snapshot), "done"), - player_prose: vec!["done".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: Some("The end.".into()), - fault: None, - state: PlayTurnState::Closed, - }; - table.store.lock().await.commit(closed_turn).unwrap(); - - let error = table - .run(&fixture.principal, test_turn_id(675), " ".into()) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::EmptyOpening), "{error:?}"); - assert!(play_port.seen_requests().is_empty(), "still zero inference calls"); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.turn_id, test_turn_id(673), "the closed turn on record must be untouched"); - assert!( - !turn.applied_keys.contains(&test_turn_id(675)), - "a refused empty opening's key must never be marked applied" - ); - } - - struct DispositionPort { - queue: StdMutex>>, - calls: StdMutex, - } - - impl DispositionPort { - fn new(items: Vec>) -> Arc { - Arc::new(Self { - queue: StdMutex::new(items.into_iter().collect()), - calls: StdMutex::new(0), - }) - } - - fn call_count(&self) -> usize { - *self.calls.lock().unwrap() - } - } - - #[async_trait::async_trait] - impl InferencePort for DispositionPort { - fn prepare(&self, request: InferenceRequest) -> Result { - PreparedInference::prepare("play-test-runtime", far_future_unix_ms(), request) - } - - async fn infer(&self, _prepared: PreparedInference) -> Result { - *self.calls.lock().unwrap() += 1; - match self.queue.lock().unwrap().pop_front() { - Some(item) => item, - None => Err(InferenceFault::recovery_required("disposition port exhausted")), - } - } - } - - /// PA.f85/PA.f93/PA.f94: an `IntegrityViolation` fault never retries — - /// exactly one inference call — and closes the turn with the fault - /// recorded through `close_with_fault`, rather than being left - /// `Running`. The next request then opens a fresh turn. - #[tokio::test] - async fn an_integrity_violation_makes_one_call_then_closes_and_the_next_request_opens_a_fresh_turn() { - let fixture = play_world(None, "player-integrity-violation").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = DispositionPort::new(vec![Err(InferenceFault::integrity_violation("bad tool schema"))]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table.run(&fixture.principal, test_turn_id(680), "Hello?".into()).await.unwrap(); - - assert_eq!(port.call_count(), 1, "an IntegrityViolation must not retry"); - let closed_turn_id = { - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!(turn.fault.as_deref().unwrap().contains("bad tool schema"), "{:?}", turn.fault); - turn.turn_id.clone() - }; - // Release the store's exclusive custody before reopening it below — - // the same "restart" precedent `a_question_holds_the_turn_open_across_a_restart` - // relies on. - drop(table); - - // The next request opens a fresh turn rather than resuming the - // fault-closed one. - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-after", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table2 = PlayTable::new( - fixture.world.clone(), - personas2, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table2.run(&fixture.principal, test_turn_id(681), "Try again.".into()).await.unwrap(); - let stored = table2.store.lock().await; - let turn = stored.current().unwrap(); - assert_ne!(turn.turn_id, closed_turn_id, "the next request must open a fresh turn, not resume the fault-closed one"); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!(turn.fault.is_none()); - } - - /// PA.f94: a `Retryable` fault retries and, on success, the turn - /// proceeds normally. - #[tokio::test] - async fn a_retryable_fault_followed_by_success_succeeds() { - let fixture = play_world(None, "player-retry-success").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let port = DispositionPort::new(vec![Err(InferenceFault::retryable("transient")), Ok(end)]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.set_retry_delay_base_ms(0); - - table.run(&fixture.principal, test_turn_id(682), "Hello?".into()).await.unwrap(); - - assert_eq!(port.call_count(), 2, "a Retryable fault must retry once, then succeed"); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!(turn.fault.is_none()); - } - - /// PA.f86 (S6): a resumed round's opening conversation item must be the - /// turn's own recorded `opening_prompt`, never a `table_view` of a - /// fresh snapshot taken at resume time — the world may have drifted (a - /// patch committed between rounds) by then. Mutation: rebuilding - /// `conversation[0]` from `table_view(&fresh_snapshot)` in - /// `rebuild_conversation`/`infer_round` fails this. - #[tokio::test] - async fn a_resumed_rounds_opening_item_is_the_recorded_prompt_not_a_fresh_view() { - let fixture = play_world(None, "player-s6-fresh-view").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which way?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(510), "I stand at a crossroads.".into()) - .await - .unwrap(); - let (opening_prompt, question_id) = { - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - (turn.opening_prompt.clone(), turn.open_question_id().unwrap()) - }; - drop(table); - - // World drift between the question and the answer. - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("You choose left.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let port = ScriptedPort::new(vec![end]); - let table = PlayTable::new( - fixture.world.clone(), - personas2, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .play - .submit_patch( - CommandId::new(), - decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "silver", "label": "Silver"}).to_string(), - ) - .unwrap(), - ) - .await - .unwrap(); - - table - .run( - &fixture.principal, - test_turn_id(511), - PlayRequest { - text: "left".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap(); - - let seen = port.seen_requests(); - let request_text = format!("{:?}", seen.first().unwrap()); - // `request_text` is `Debug`-formatted, so a literal newline inside - // the recorded `opening_prompt` prints as the two characters `\n`; - // match that same escaping rather than the raw string. - let opening_prompt_escaped = opening_prompt.replace('\n', "\\n"); - assert!( - request_text.contains(&opening_prompt_escaped), - "the resumed round's opening item must be the recorded opening_prompt: {request_text}" - ); - assert!( - !request_text.contains("Silver"), - "a fresh table_view would leak the mid-turn patch into the opening item: {request_text}" - ); - } - - /// PA.f88: Dungeon's own `PLAY_TOOLS` is the single policy owner of - /// what the play agent may call (the library's own copy in - /// `ghostlight::table` is its test fixture only) — pinned here at its - /// exact 24-tool contents, `communicate` excluded (PA.f75). Mutation: - /// re-adding `communicate`. - #[test] - fn play_tools_is_pinned_to_its_exact_contents_with_no_communicate() { - const EXPECTED: &[&str] = &[ - "relocate", - "transfer", - "consume", - "mint", - "bind", - "release", - "witness", - "acquire_knowledge", - "forget", - "create_commitment", - "discharge_commitment", - "advance_pressure", - "reduce_pressure", - "open_route", - "close_route", - "declare_place", - "declare_route", - "declare_resource", - "declare_fact", - "declare_subject", - "set_persona_material", - "retire", - "grant_affordance", - "revoke_affordance", - ]; - assert_eq!(PLAY_TOOLS, EXPECTED); - assert_eq!(PLAY_TOOLS.len(), 24); - assert!(!PLAY_TOOLS.contains(&"communicate")); - } - - /// The `request_id` an `InferenceRequest` `Debug`-prints, extracted so - /// two requests can be compared on that field alone — it is the one - /// place `dispatch_command_id`'s own output value actually reaches - /// something this test can observe from outside `execute_dispatch`. - fn request_id_of(request: &InferenceRequest) -> String { - let text = format!("{request:?}"); - let key = "request_id: \""; - let start = text.find(key).expect("a request carries its own request_id") + key.len(); - let end = start + text[start..].find('"').expect("the request_id value is a quoted string"); - text[start..end].to_owned() - } - - /// PA.f96: replaces the old test that called `dispatch_command_id_for_handle` - /// directly by observing the *actual* production path instead. Two - /// Persona subjects are dispatched together under one shared `turn_id`, - /// once as `[Mara, Borin]` and once as `[Borin, Mara]`. Each dispatched - /// subject's own inference `request_id` carries `dispatch_command_id`'s - /// own output — the same value `execute_dispatch` itself derives and - /// hands to `personas.turn` — so comparing Mara's own `request_id` - /// across the two orders observes whether that id actually depended on - /// her position in the JSON list, without recomputing the hash - /// ourselves. Mutation: hashing the subject's list position (`position`) - /// instead of the subject itself at the `execute_dispatch` call site - /// changes Mara's own `request_id` between the two runs, failing this. - #[tokio::test] - async fn dispatch_order_never_changes_the_request_id_a_subject_gets() { - let fixture = play_world(Some("Mara"), "player-dispatch-order-ids").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - // A second Persona subject, declared through the play table's own - // authoring door — the same mechanism - // `set_persona_material_on_a_same_run_declared_subject_commits` uses. - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(690), "Introduce Borin.".into()).await.unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - - // One turn_id shared by both orders: `dispatch_command_id` derives - // from `(turn_id, round, slot, subject)`, so holding turn_id, round, - // and slot fixed isolates subject-vs-position as the only variable. - let shared_turn_id = test_turn_id(691); - - async fn dispatch_pair( - world: &WorldMailbox, - turn_id: &str, - order: [SubjectId; 2], - ) -> (PlayTurn, Arc) { - let port = ScriptedPort::new(vec![ - output("proj-0", vec![text_event("considers.")]), - output("persona-0", vec![text_event("speaks first.")]), - output("proj-1", vec![text_event("waits.")]), - output("persona-1", vec![text_event("speaks second.")]), - ]); - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - port.clone(), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let mut turn = PlayTurn { - turn_id: turn_id.to_owned(), - applied_keys: Vec::new(), - opening_prompt: String::new(), - player_prose: Vec::new(), - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let (outcome, summary) = table.execute_dispatch(&mut turn, 0, 0, &order).await.unwrap(); - assert!(matches!(outcome, RoundOutcome::Continue), "dispatch must not fault: {summary}"); - assert!(!summary.contains("could not act"), "{summary}"); - (turn, port) - } - - let (forward, forward_port) = dispatch_pair(&fixture.world, &shared_turn_id, [mara, borin]).await; - let (reverse, reverse_port) = dispatch_pair(&fixture.world, &shared_turn_id, [borin, mara]).await; - - for (label, turn, subject) in [ - ("forward", &forward, mara), - ("forward", &forward, borin), - ("reverse", &reverse, mara), - ("reverse", &reverse, borin), - ] { - assert!( - turn.persona_turns.iter().any(|(id, _)| *id == subject), - "{label} dispatch must have recorded a Persona turn for the subject" - ); - } - - // Forward dispatched [Mara, Borin]: Mara's own Projector request is - // seen first (index 0); reverse dispatched [Borin, Mara]: Mara's own - // is seen third (index 2), after Borin's own pair. - let forward_seen = forward_port.seen_requests(); - let reverse_seen = reverse_port.seen_requests(); - let mara_request_id_at_position_0 = request_id_of(&forward_seen[0]); - let mara_request_id_at_position_1 = request_id_of(&reverse_seen[2]); - assert_eq!( - mara_request_id_at_position_0, mara_request_id_at_position_1, - "Mara's own request id must depend only on (turn_id, round, slot, subject), never her position in the dispatch list" - ); - let borin_request_id_at_position_1 = request_id_of(&forward_seen[2]); - let borin_request_id_at_position_0 = request_id_of(&reverse_seen[0]); - assert_eq!( - borin_request_id_at_position_1, borin_request_id_at_position_0, - "Borin's own request id must depend only on (turn_id, round, slot, subject), never his position in the dispatch list" - ); - assert_ne!( - mara_request_id_at_position_0, borin_request_id_at_position_0, - "two distinct subjects dispatched together must not share a request id" - ); - } - - // --- PA.f97: short handles and collision detection -------------------- - - /// The longest possible actor tool name (`<8-hex-handle>__<48-char kind>`) - /// stays well under a common 64-char function-name limit. - #[tokio::test] - async fn the_longest_possible_actor_tool_name_is_64_chars_or_fewer() { - let fixture = play_world(Some("Mara"), "player-handle-length").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let prefix = actor_prefix(player_id(&snapshot)); - assert!(prefix.len() <= 10, "the handle prefix must be short: {prefix}"); - let longest_kind = "x".repeat(48); - let name = format!("{prefix}{longest_kind}"); - assert!(name.len() <= 64, "an actor tool name of {} chars exceeds a common 64-char limit: {name}", name.len()); - } - - /// The player's and a Persona's own short handles round-trip through - /// `resolve_handle` unchanged. - #[tokio::test] - async fn player_and_persona_handles_round_trip() { - let fixture = play_world(Some("Mara"), "player-handle-roundtrip").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona = persona_id(&snapshot); - assert_eq!(handle_for(player).len(), 8); - assert_eq!(handle_for(persona).len(), 8); - assert_ne!(handle_for(player), handle_for(persona)); - - let player_row = SubjectRow { id: player }; - let resolved_player = resolve_handle(&handle_for(player), Some(&player_row), &[persona]); - let resolved = resolved_player.expect("the player's own handle resolves"); - assert_eq!(resolved.subject, player); - assert!(resolved.is_player); - - let resolved_persona = resolve_handle(&handle_for(persona), Some(&player_row), &[persona]); - let resolved = resolved_persona.expect("the dispatched Persona's own handle resolves"); - assert_eq!(resolved.subject, persona); - assert!(!resolved.is_player); - } - - /// PA.f97: two distinct subjects sharing one handle is a collision; - /// `find_handle_collision` is the pure function `round_tools` calls to - /// refuse the whole round as a turn fault. `SubjectId` mints nothing a - /// consumer crate can construct by hand and two real, randomly issued - /// ids cannot feasibly be forced to collide, so this drives the - /// detection algorithm directly with two real (distinct) subject ids - /// under a shared, hand-assigned handle string, rather than attempting - /// to force a genuine 8-hex-digit collision through a fixture — which - /// is the infeasible half of this rule's own test list; reported here - /// rather than faked. - #[tokio::test] - async fn a_handle_collision_between_distinct_subjects_is_detected() { - let fixture = play_world(Some("Mara"), "player-handle-collision").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let a = player_id(&snapshot); - let b = persona_id(&snapshot); - let collision = - find_handle_collision(vec![("SAME1234".to_owned(), a), ("SAME1234".to_owned(), b)].into_iter()); - assert_eq!(collision.as_deref(), Some("SAME1234")); - } - - /// The same subject appearing twice under its own handle (a subject - /// that could in principle be named more than once) is not a collision. - #[tokio::test] - async fn the_same_subject_listed_twice_under_one_handle_is_not_a_collision() { - let fixture = play_world(None, "player-handle-no-collision").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let a = player_id(&snapshot); - let collision = find_handle_collision(vec![(handle_for(a), a), (handle_for(a), a)].into_iter()); - assert_eq!(collision, None); - } - - /// PA.f105: a genuine collision between two *real* `SubjectId`s, not a - /// hand-assigned string pair — `SubjectId` is `#[serde(transparent)]` - /// over a `Uuid` and `WorldSnapshot::subjects[i].id` is `pub`, so a real - /// id can be forged by re-deriving its text with the player's own first - /// hex group spliced in, then deserializing that text back into a real - /// `SubjectId` through the one door this crate has for constructing one - /// at all: `serde_json`. No kernel-level backdoor is needed; this was - /// wrongly reported infeasible in the prior pass. `collision_refusal` is - /// called directly with the mutated snapshot: since PA.f127/PA.f133 - /// collapsed the check out of `round_tools` and into `round_snapshot`, - /// this is the pure rule `round_snapshot` itself calls, not a copy of it. - #[tokio::test] - async fn a_genuine_handle_collision_between_two_real_subjects_is_refused() { - let fixture = play_world(Some("Mara"), "player-real-handle-collision").await; - let mut snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona_index = snapshot - .subjects - .iter() - .position(|row| row.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap(); - - let player_text = subject_id_text(&snapshot, player); - let persona_text = subject_id_text(&snapshot, snapshot.subjects[persona_index].id); - // The player's own first 8-hex-digit group, spliced onto the - // persona's own remaining groups: a distinct, real, well-formed - // uuid string that still shares `handle_for`'s whole extent with - // the player. - let colliding_text = format!("{}{}", &player_text[..8], &persona_text[8..]); - let colliding_id: SubjectId = - serde_json::from_value(serde_json::Value::String(colliding_text)).expect("a valid uuid deserializes"); - assert_ne!(colliding_id, snapshot.subjects[persona_index].id, "the forged id must be a distinct subject"); - snapshot.subjects[persona_index].id = colliding_id; - - let error = collision_refusal(&snapshot).expect("a genuine collision is detected"); - assert!(error.contains("share the handle"), "{error}"); - } - - /// PA.f127/PA.f133: `round_snapshot` is the one owner both `infer_round` - /// and `execute_round` call for a round's own snapshot and collision - /// check. `fetch_round_snapshot`'s test seam (`force_round_snapshot`) - /// stands in for the kernel fetch with the same genuine, spliced-real-id - /// collision the test above drives through the pure rule directly — - /// this drives it through the *production async door itself*, the one - /// both callers share, so a mutation of the `collision_refusal` call - /// inside `round_snapshot` (`.filter(|_| false)`) fails this rather than - /// only a copy of the check. Because `infer_round` and `execute_round` - /// now call the identical function, this one test covers both: there is - /// no second implementation left for either to diverge into. - #[tokio::test] - async fn a_forced_handle_collision_refuses_the_round_through_round_snapshot() { - let fixture = play_world(Some("Mara"), "player-forced-handle-collision").await; - let mut snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona_index = snapshot - .subjects - .iter() - .position(|row| row.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap(); - let player_text = subject_id_text(&snapshot, player); - let persona_text = subject_id_text(&snapshot, snapshot.subjects[persona_index].id); - let colliding_text = format!("{}{}", &player_text[..8], &persona_text[8..]); - let colliding_id: SubjectId = - serde_json::from_value(serde_json::Value::String(colliding_text)).expect("a valid uuid deserializes"); - snapshot.subjects[persona_index].id = colliding_id; - - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.force_round_snapshot(snapshot); - - let error = table.round_snapshot().await.unwrap_err(); - assert!(error.contains("share the handle"), "{error}"); - } - - /// PA.f68/PA.f112/PA.f142: `run_lock` is acquired with `try_lock`, not a - /// blocking wait, so the replay check and the turn open still happen - /// under one acquisition (no two concurrent `run` calls for this table - /// ever interleave), but a second call arriving while the first is still - /// inside its own round loop is refused *at once* with - /// `TurnBusy` (PA.f193-D) rather than queuing behind it for up to - /// `ROUND_BUDGET` rounds of inference (PA.f142). Two concurrent `run` - /// calls carrying the *same* idempotency key — the shape a genuine - /// client retry racing itself takes — therefore give exactly one turn - /// either way: one call actually runs it, and the other, if it loses the - /// race for the lock, is refused immediately rather than replaying (a - /// caller that retries again afterward, once the key is durably applied, - /// gets `Replayed` then). Mutation: removing `run_lock`'s acquisition - /// (or reverting `try_lock` to a blocking `lock().await`) reopens the - /// race this asserts against. - #[tokio::test] - async fn two_concurrent_world_play_calls_for_one_world_give_one_turn() { - let fixture = play_world(None, "player-one-turn-per-world").await; - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output( - "proj-race", - vec![text_event("The race settles.")], - )]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = Arc::new( - PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![output( - "r0", - vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))], - )]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(), - ); - - let key = test_turn_id(919); - let first = { - let table = table.clone(); - let principal = fixture.principal.clone(); - let key = key.clone(); - tokio::spawn(async move { table.run(&principal, key, "Who goes there?".into()).await }) - }; - let second = { - let table = table.clone(); - let principal = fixture.principal.clone(); - let key = key.clone(); - tokio::spawn(async move { table.run(&principal, key, "Who goes there?".into()).await }) - }; - let (first_outcome, second_outcome) = tokio::join!(first, second); - let first_outcome = first_outcome.unwrap(); - let second_outcome = second_outcome.unwrap(); - - // Exactly one of the two actually ran the turn; the other, having - // lost the race for `run_lock`, is refused at once rather than - // queued — `try_lock` decides which one wins, not this test. - let ran = match (first_outcome, second_outcome) { - (Ok(RunOutcome::Ran { .. }), Err(PlayError::TurnBusy)) => true, - (Err(PlayError::TurnBusy), Ok(RunOutcome::Ran { .. })) => true, - other => panic!("expected exactly one Ran and one immediate TurnBusy refusal: {other:?}"), - }; - assert!(ran); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.applied_keys.iter().filter(|applied| **applied == key).count(), - 1, - "the race's shared key must be recorded on the turn exactly once, never once per racer" - ); - } - - /// PA.f142, second half: a request racing an in-flight turn is refused - /// at once, never queued behind it. `GatedPort` blocks the first call's - /// own round-0 inference until this test explicitly releases it, so the - /// second call is known to arrive while `run_lock` is genuinely still - /// held — not a race whose outcome depends on scheduling luck. - #[tokio::test] - async fn a_request_racing_an_in_flight_turn_is_refused_at_once_not_queued() { - let fixture = play_world(None, "player-f142-busy").await; - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-gated", vec![text_event("Settled.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let gated = GatedPort::new(output( - "r0", - vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))], - )); - let table = Arc::new( - PlayTable::new( - fixture.world.clone(), - personas, - gated.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(), - ); - - let first = { - let table = table.clone(); - let principal = fixture.principal.clone(); - let key = test_turn_id(950); - tokio::spawn(async move { table.run(&principal, key, "Who goes there?".into()).await }) - }; - // The first call is now durably inside `infer_round`'s own - // `self.inference.infer(...)` call, holding `run_lock` for the - // whole body of `run` — genuinely busy, not merely "about to run". - tokio::time::timeout(Duration::from_secs(5), gated.wait_ready()) - .await - .expect("the first call must reach the gated inference within the timeout"); - - let busy = tokio::time::timeout(Duration::from_secs(5), { - let table = table.clone(); - let principal = fixture.principal.clone(); - let key = test_turn_id(951); - async move { table.run(&principal, key, "Anyone there?".into()).await } - }) - .await - .expect("a busy table must refuse at once, not block waiting for run_lock"); - assert!( - matches!(busy, Err(PlayError::TurnBusy)), - "a request racing an in-flight turn must be refused immediately: {busy:?}" - ); - - gated.release(); - let first_outcome = first.await.unwrap().unwrap(); - assert!(matches!(first_outcome, RunOutcome::Ran { .. })); - - // PA.f142's own "careful" clause: once the first call has actually - // returned (so `run_lock` is free again), answering an open - // question the *same* table is now waiting on must still succeed — - // `try_lock` never starves a genuine answer, it only refuses a - // request that arrives while the lock is truly held. - let ask_table = Arc::new( - PlayTable::new( - fixture.world.clone(), - PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(), - ScriptedPort::new(vec![output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - )]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1-ask.cc"), - ) - .unwrap(), - ); - ask_table - .run(&fixture.principal, test_turn_id(952), "start".into()) - .await - .unwrap(); - let question_id = ask_table.store.lock().await.current().unwrap().open_question_id().unwrap(); - let answered = ask_table - .run( - &fixture.principal, - test_turn_id(953), - PlayRequest { - text: "left".into(), - answers: Some(question_id), - }, - ) - .await; - assert!( - answered.is_ok(), - "answering an open question must not be refused as busy once no call holds the lock: {answered:?}" - ); - } - - /// PA.f68/PA.f112: `run_lock` lives on `PlayTable`, one per world in - /// production (`AppState.play` holds one `Arc` for the one - /// world this process owns) — so two *different* worlds' own tables never - /// share a lock and never block each other. Two fresh turns, one per - /// table, run concurrently and each resolves on its own, with distinct - /// turn ids. - #[tokio::test] - async fn two_worlds_own_play_tables_run_independently() { - let fixture_a = play_world(None, "player-world-a").await; - let fixture_b = play_world(None, "player-world-b").await; - let directory_a = tempfile::tempdir().unwrap(); - let directory_b = tempfile::tempdir().unwrap(); - - let build_table = |fixture: &WorldFixture, directory: &tempfile::TempDir| { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Settled.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![output( - "r0", - vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))], - )]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap() - }; - let table_a = Arc::new(build_table(&fixture_a, &directory_a)); - let table_b = Arc::new(build_table(&fixture_b, &directory_b)); - - let run_a = { - let table_a = table_a.clone(); - let principal = fixture_a.principal.clone(); - tokio::spawn(async move { - table_a - .run(&principal, test_turn_id(931), "Hello from world A.".into()) - .await - }) - }; - let run_b = { - let table_b = table_b.clone(); - let principal = fixture_b.principal.clone(); - tokio::spawn(async move { - table_b - .run(&principal, test_turn_id(932), "Hello from world B.".into()) - .await - }) - }; - let (outcome_a, outcome_b) = tokio::time::timeout(Duration::from_secs(5), async { - tokio::join!(run_a, run_b) - }) - .await - .expect("two worlds' own tables must not block on each other's run_lock"); - assert!(matches!(outcome_a.unwrap().unwrap(), RunOutcome::Ran { .. })); - assert!(matches!(outcome_b.unwrap().unwrap(), RunOutcome::Ran { .. })); - - let turn_a = table_a.store.lock().await.current().cloned().unwrap(); - let turn_b = table_b.store.lock().await.current().cloned().unwrap(); - assert_ne!( - turn_a.turn_id, turn_b.turn_id, - "each world's own table opened its own turn" - ); - } - - /// PA.f134: a turn that reaches `ask_player` names its own `QuestionId` - /// through `RunOutcome::Ran` itself — no test-local construction of the - /// id — and feeding that exact id back through a fresh `run` call - /// answers it. Also checks `PlayTable::current_turn_view`, the door a - /// separate later reader (PA.f134's own consumer-facing seam) uses - /// instead of a second `run` call, agrees with it at every stage. - #[tokio::test] - async fn a_question_is_answerable_through_run_outcome_alone() { - let fixture = play_world(None, "player-f134-answerable").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-answered", vec![text_event("Settled.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask, end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - // Before any turn opens, there is nothing to view. - assert!(table.current_turn_view().await.is_none()); - - let outcome = table - .run(&fixture.principal, test_turn_id(960), "start".into()) - .await - .unwrap(); - let (turn_id, question) = match outcome { - RunOutcome::Ran { turn_id, question } => (turn_id, question), - other => panic!("expected Ran with an open question: {other:?}"), - }; - let question = question.expect("a turn that asks a question must report it on Ran"); - assert_eq!(question.text, "Which way?"); - assert_eq!(question.id.turn_id, turn_id); - - let view = table.current_turn_view().await.unwrap(); - assert_eq!(view.turn_id, turn_id); - assert_eq!(view.state, PlayTurnState::AwaitingPlayer); - assert_eq!(view.question.as_ref(), Some(&question)); - assert_eq!(view.refusal, None); - - // Feeding exactly `question.id` back — never a hand-built - // `QuestionId` — answers it and the turn closes. - let answered = table - .run( - &fixture.principal, - test_turn_id(961), - PlayRequest { - text: "left".into(), - answers: Some(question.id), - }, - ) - .await - .unwrap(); - match answered { - RunOutcome::Ran { turn_id: answered_turn_id, question: None } => { - assert_eq!(answered_turn_id, turn_id, "the answer resumes the same turn"); - } - other => panic!("expected Ran with no further open question: {other:?}"), - } - let view = table.current_turn_view().await.unwrap(); - assert_eq!(view.state, PlayTurnState::Closed); - assert_eq!(view.question, None); - - // A replay of the key that opened the turn also names the turn and - // its own then-open question — `Replayed` carries the same - // information `Ran` did, not merely the turn id. - let replayed = table - .run(&fixture.principal, test_turn_id(960), "start".into()) - .await - .unwrap(); - assert_eq!( - replayed, - RunOutcome::Replayed { turn_id, question: None }, - "a replay of the opening key, now that the turn has closed, carries no open question" - ); - } - - /// PA.f136: a resumed dispatch call resubmits its own already-recorded - /// subjects, never a value re-derived from `parse_dispatch` against - /// whatever the round's own snapshot says *now* — the same "recorded - /// body decides" rule `commit_authoring_run`/`execute_advance_time`/ - /// `execute_actor_call` already follow. `force_round_snapshot` stands in - /// for a subject the world changed out from under the resumed call (a - /// retirement removing her from the dispatchable set `parse_dispatch` - /// resolves against): the *live* world `execute_dispatch` itself - /// re-fetches still has her, so the resumed call recovers her already- - /// durable prose exactly like `a_resumed_dispatch_call_recovers_its_own_persona_prose_not_already_acted` - /// does, proving the recorded subject list survived, not merely that - /// dispatch didn't crash. Mutation: re-parsing `arguments` against the - /// round's own snapshot regardless of whether a body is already - /// recorded. - #[tokio::test] - async fn a_resumed_dispatch_call_resubmits_its_recorded_subjects_not_a_reparsed_list() { - const PROSE: &str = "Mara studies the retired door."; - let fixture = play_world(Some("Mara"), "player-f136-retire").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - - // The snapshot `round_snapshot` will hand `execute_round` for the - // resumed round: Mara removed from the dispatchable subject list - // entirely, standing in for a retirement between the crash and the - // resume. `execute_dispatch`'s own "already durable" branch never - // reads this snapshot at all (it re-fetches the live one through - // `self.play.snapshot()`), so this only ever affects `parse_dispatch`. - let mut retired_snapshot = snapshot.clone(); - retired_snapshot.subjects.retain(|row| row.id != mara); - - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text]})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let turn_id = test_turn_id(962); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Who's there?" - ), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - // The dispatch call's own body is already durable (recorded - // before `execute_dispatch` ever ran, PA.f125) but its result is - // not: exactly the crash window a resumed call recovers from. - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![mara])), - result: None, - }], - persona_turns: vec![(mara, fake_persona_turn(PROSE))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.force_round_snapshot(retired_snapshot); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - let recorded_body = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.body.clone()); - drop(stored); - assert!( - dispatch_result.contains(PROSE), - "the resumed call must still carry Mara's own already-durable prose: {dispatch_result}" - ); - assert!( - !dispatch_result.contains("names no dispatchable subject"), - "the resumed call must not be re-parsed against the round's own (retired) snapshot: {dispatch_result}" - ); - assert!( - matches!(&recorded_body, Some(RecordedCall::Dispatch(list)) if *list == vec![mara]), - "the call's own recorded body must still name Mara, not be overwritten with a shorter re-parsed list: {recorded_body:?}" - ); - } - - /// PA.f139: `dispatch_owner` credits the *earliest* call, in call order, - /// that names a subject — never the latest. Two dispatch calls in one - /// round both name Mara; only the first actually produced her - /// `persona_turns` entry (the crashed-and-resumed shape: her prose is - /// already durable, but only the first call's own record is present at - /// the point she was credited). Resuming the *second*, still-unresolved - /// call must recognize she already acted through the first and say so, - /// never re-claim her prose as if the second call had produced it. - /// Mutation: `.min()` → `.max()` in `dispatch_owner`. - #[tokio::test] - async fn dispatch_owner_credits_the_earliest_call_not_the_latest() { - const PROSE: &str = "Mara studies the door."; - let fixture = play_world(Some("Mara"), "player-f139-owner").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - let round = output( - "r0", - vec![ - call_event("c0", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text.clone()]})), - call_event("c1", DISPATCH_TOOL, serde_json::json!({"subjects": [mara_text]})), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let turn_id = test_turn_id(963); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Who's there?" - ), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - calls: vec![ - CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Dispatch(vec![mara])), - result: Some(format!("Mara: {PROSE}")), - }, - CallRecord { - call_id: "c1".into(), - round: 0, - slot: 1, - body: Some(RecordedCall::Dispatch(vec![mara])), - result: None, - }, - ], - persona_turns: vec![(mara, fake_persona_turn(PROSE))], - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let second_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - drop(stored); - assert!( - second_result.contains("already acted"), - "the second call naming an already-owned subject must be credited to the earliest \ - call, not re-claim her prose: {second_result}" - ); - assert!( - !second_result.contains(PROSE), - "the second call must not carry the first call's own prose: {second_result}" - ); - } - - /// PA.f143: `execute_dispatch`'s own `pending.into_iter().zip(results)` - /// pairs each dispatched subject's own summary slot and `persona_turns` - /// entry with its own spawned task's result, in the call's own JSON - /// `subjects` order — never completion order. No test in the repo - /// dispatched more than two subjects concurrently before this; three - /// distinct subjects, each with distinguishable prose, pins the joined - /// summary's own order and `persona_turns`'s own order. This follows - /// `a_persona_cannot_speak_another_dispatched_personas_exact_quote`'s own - /// proven two-subject shape (a shared `ScriptedPort` FIFO queue primed - /// in JSON order, concurrently dispatched under a semaphore that never - /// contends) one subject further. Mutation: reversing either side of the - /// zip, or `results`/`pending` independently. - #[tokio::test] - async fn concurrent_three_subject_dispatch_preserves_json_order_in_summary_and_persona_turns() { - let fixture = play_world(Some("Mara"), "player-f143-three").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event( - "c1", - "declare_subject", - serde_json::json!({ - "handle": "cass", - "label": "Cass", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(3)), - directory.path().join("declare-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(964), "Introduce Borin and Cass.".into()) - .await - .unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - let cass = snapshot.subjects.iter().find(|row| row.label == "Cass").unwrap().id; - let mara_text = subject_id_text(&snapshot, mara); - let borin_text = subject_id_text(&snapshot, borin); - let cass_text = subject_id_text(&snapshot, cass); - - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text, borin_text, cass_text]}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-borin", vec![text_event("Borin waits his turn.")]), - output("persona-borin", vec![text_event("Borin clears his throat.")]), - output("proj-cass", vec![text_event("Cass watches quietly.")]), - output("persona-cass", vec![text_event("Cass says nothing.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(3)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(965), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - let mara_at = result.find("Mara nods once.").expect("Mara's own prose is in the summary"); - let borin_at = result - .find("Borin clears his throat.") - .expect("Borin's own prose is in the summary"); - let cass_at = result.find("Cass says nothing.").expect("Cass's own prose is in the summary"); - assert!( - mara_at < borin_at && borin_at < cass_at, - "the dispatch summary must list subjects in the call's own JSON order, not completion order: {result}" - ); - let order: Vec = turn.persona_turns.iter().map(|(id, _)| *id).collect(); - assert_eq!( - order, - vec![mara, borin, cass], - "persona_turns must record dispatched subjects in the call's own JSON order too" - ); - } - - /// PA.f137: `round_snapshot` is the one owner `infer_round` and - /// `execute_round` both call for a round's own snapshot and collision - /// check, but `a_forced_handle_collision_refuses_the_round_through_round_snapshot` - /// above calls `table.round_snapshot()` directly — it would still pass - /// if either caller were mutated to call `fetch_round_snapshot` instead - /// (skipping the collision check). This drives the same forced - /// collision through the *production `run` path* on a fresh turn, which - /// only ever reaches `round_snapshot` through `infer_round`: the turn - /// must close with the collision fault, and `ScriptedPort`'s own empty - /// inference queue proves no `InferenceRequest` was ever built (a - /// mutation bypassing the check would reach `round_tools`/inference - /// instead and fail with "scripted port exhausted", not "share the - /// handle"). - #[tokio::test] - async fn a_forced_handle_collision_closes_a_fresh_round_through_infer_round() { - let fixture = play_world(Some("Mara"), "player-f137-infer").await; - let mut snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona_index = snapshot - .subjects - .iter() - .position(|row| row.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap(); - let player_text = subject_id_text(&snapshot, player); - let persona_text = subject_id_text(&snapshot, snapshot.subjects[persona_index].id); - let colliding_text = format!("{}{}", &player_text[..8], &persona_text[8..]); - let colliding_id: SubjectId = - serde_json::from_value(serde_json::Value::String(colliding_text)).expect("a valid uuid deserializes"); - snapshot.subjects[persona_index].id = colliding_id; - - let directory = tempfile::tempdir().unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.force_round_snapshot(snapshot); - - let outcome = table - .run(&fixture.principal, test_turn_id(966), "Who's there?".into()) - .await - .unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. })); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let fault = turn.fault.as_deref().expect("the collision must close the turn with a fault"); - assert!(fault.contains("share the handle"), "{fault}"); - } - - /// PA.f137's other half: the same forced collision, driven through - /// `execute_round` instead — a round already open (an unresolved call - /// from a prior pass) never touches `infer_round` at all on resume, so - /// this reaches the *other* of the two callers `round_snapshot` unifies. - #[tokio::test] - async fn a_forced_handle_collision_closes_a_resumed_round_through_execute_round() { - let fixture = play_world(Some("Mara"), "player-f137-execute").await; - let mut snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let persona_index = snapshot - .subjects - .iter() - .position(|row| row.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap(); - let player_text = subject_id_text(&snapshot, player); - let persona_text = subject_id_text(&snapshot, snapshot.subjects[persona_index].id); - let colliding_text = format!("{}{}", &player_text[..8], &persona_text[8..]); - let colliding_id: SubjectId = - serde_json::from_value(serde_json::Value::String(colliding_text)).expect("a valid uuid deserializes"); - snapshot.subjects[persona_index].id = colliding_id; - - // A round already open, with an unresolved call — `execute_round`'s - // own entry, `round_is_open`, is what `run` takes when a round is - // present but not yet fully resolved; `infer_round` is never called - // for it. - let round = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let turn_id = test_turn_id(967); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: "opening".into(), - player_prose: vec!["hello".into()], - rounds: vec![round], - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - { - let mut store = PlayTurnStore::open(&store_path).unwrap(); - store.open_new_turn(crashed_turn).unwrap(); - } - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table.force_round_snapshot(snapshot); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let fault = turn.fault.as_deref().expect("the collision must close the resumed round with a fault"); - assert!(fault.contains("share the handle"), "{fault}"); - } -} diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs deleted file mode 100644 index 807f6b5..0000000 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ /dev/null @@ -1,7366 +0,0 @@ -//! One-process runtime for the sealed world owner. - -use crate::{ - app_session::{AppSessionOwner, RefreshRefusal}, - eve::{self, EveCommandInvocation}, - heimdall::{self, HeimdallClient, HeimdallDenied, SessionRefresher, VerificationUnavailable}, - idunn_health::{ - IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, ProcessWriteLeaseGuard, RuntimePresencePublisher, - TARGET as GHOSTLIGHT_TARGET, - }, - mesh::{self, MeshPublisher, MeshRuntimeIdentity}, - play::{Admission, PlayRequest, PlayTable, PlayTurnState, PlayTurnView}, -}; -use ghostlight::{ - CONSUMER_BODY_LIMIT, CommandBody, CommandId, ConnectorBinding, ConsumerPort, - ConsumerRegistry, ControllerError, ControllerModels, ControllerPort, ControllerRunner, ControllerWorkCustody, - CreateJurisdictionIntent, CreateWorldIntent, DEFAULT_LOCAL_MODEL_PREFIX, - DEFAULT_SDK_MODEL_PREFIX, KernelError, Lens, - LensWeights, LocalBinding, MailboxError, PersonaLane, PrincipalCommandIntent, PrincipalId, - SdkBinding, SeedOutcome, SeedPort, SubjectKind, SubmitReceipt, TickMinutes, - VaultEvidenceSource, VerifiedPrincipalEvidence, WorldMailbox, WorldPhase, WorldSnapshot, - open_controller_work, open_inference, -}; -use anyhow::{Context, bail, ensure}; -use axum::{ - Json, Router, - body::Bytes, - extract::{ConnectInfo, DefaultBodyLimit, Path, State}, - http::{HeaderMap, HeaderValue, StatusCode, header}, - response::{ - IntoResponse, Response, - sse::{Event as SseEvent, KeepAlive, Sse}, - }, - routing::{get, post}, -}; -use chrono::{DateTime, Utc}; -use cultnet_rs::{ - CultNetMessage, CultNetWireContract, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, - decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, -}; -use serde::Deserialize; -use serde_json::{Value, json}; -use std::{ - collections::{BTreeMap, HashMap}, - convert::Infallible, - fs, - net::SocketAddr, - path::PathBuf, - sync::Arc, - time::{Duration, Instant}, -}; -use tokio::sync::{Mutex, Semaphore, broadcast, mpsc}; -use tower_http::services::ServeDir; -use tracing_subscriber::EnvFilter; - -const COOKIE_NAME: &str = "ghostlight_session"; -const CULTNET_SNAPSHOT_BODY_LIMIT: usize = 16 * 1024; - -#[derive(Clone)] -struct RuntimeHealthOwner { - publisher: Arc>, - write_lease: Arc, -} - -#[derive(Clone)] -struct AppState { - world: WorldMailbox, - /// The consumer ingress's narrow view of the same owner, and the configured - /// consumers it authenticates against. The registry is read once at startup - /// and holds only digests; a missing file means no consumers. - consumer: ConsumerPort, - consumers: Arc, - /// The one cognition organ, shared. The owner's seeding and the readiness - /// custody probe use it; nothing in this process runs it on its own. - controllers: Option>, - /// The play table (Cut 8b): one operational model running a role-playing - /// game over this process's own world, minted alongside `controllers` - /// from the same opened inference organ. `None` exactly when - /// `controllers` is `None` — the play lane has no cognition of its own to - /// fall back to. - play: Option>, - /// The controller/Persona concurrency pool, sized to the connector's - /// quota. Cut 1 deleted Dungeon's own drivers, so the play table's own - /// `execute_dispatch` and `close_turn` narration (Cut 8b) are this - /// pool's one production consumer now — Persona dispatch *is* the - /// controller work, not a second lane beside it. `runtime_readiness`'s - /// `controllerStatus` "active" arm reads this pool for exactly that - /// reason: live Persona cognition drawing a permit is what "active" - /// reports, not only the test route's own forced exhaustion. - controller_permits: Arc, - sessions: Arc>, - heimdall: Arc, - mesh: Option, - mesh_identity: MeshRuntimeIdentity, - runtime_health: Option, - revisions: broadcast::Sender, - fatal: mpsc::UnboundedSender, - /// The seed lane's vault root, read once at open (`seed_vault_root_from_environment`). - /// The surface offers the seed controls on whether this is `Some`, and - /// `seed_once` takes its root from it, so the two cannot disagree about - /// whether seeding exists on this server. - seed_vault_root: Option, -} - -struct ProductionAdmission { - health: RuntimePresencePublisher, - write_lease: ProcessWriteLeaseGuard, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CreatePayload { - title: String, - /// The world's premise in the owner's words, projected to every lane as - /// guidance. Required, may be empty; a payload that omits it is refused, - /// which is what `world_create.v4` means. - brief: String, - subject_label: String, - #[serde(default)] - narrative_persona_label: Option, - #[serde(default)] - operational_agent_label: Option, - /// World-wide target of goal-bearing subjects per kind. Required, and may - /// be empty: a world with no target is a deliberate choice, not a default - /// that arrives because nobody said anything. A payload that omits it is - /// refused, which is what `world_create.v4` means. - #[serde(deserialize_with = "deserialize_json_capture")] - targets: BTreeMap, - /// The jurisdiction roots, declared by genesis beside the commons because - /// `resolve_patch` only resolves roots the same patch declares. A duplicate - /// handle and a permille sum over 1000 are refused by the resolver, not - /// pre-checked here: a pre-check would be a second reducer. - #[serde(deserialize_with = "deserialize_json_capture")] - jurisdictions: Vec, - /// Required, and must draw: a payload that omits it is refused, which is - /// what `world_create.v4` means. An unknown lens name is refused by - /// deserialization before any handler runs. - #[serde(deserialize_with = "deserialize_json_capture")] - lens_weights: LensWeights, -} - -/// Cut 10 (PA.f149): the vendored Eve browser lowering's own -/// `createEveCommandIntent` (`vendor/eve/packages/eve-browser-lowering`) -/// sends every captured `captureBindings` value enveloped under -/// `payload.bindings` — the documented shape (Eve's own -/// `docs/surface-contract-v1.md`: "Operations declare captureBindings and -/// receive those values under payload.bindings"), confirmed by driving the -/// real lowering over a real Dungeon surface (`tools/eve_client_bridge.mjs`). -/// Dungeon's own payload structs are flat. Rather than teach every payload -/// struct the envelope, this unwraps it once, so a real client's payload -/// deserializes exactly like the flat, hand-built payloads the existing -/// tests already send. A payload with no `bindings` object (no -/// `captureBindings` advertised at all, e.g. `world.approve`/ -/// `world.activate`, whose actions capture nothing) passes through -/// unchanged — `commandPayload` on the lowering's own side never sets -/// `bindings` when `captureBindings` is empty. -/// PA.f168's original rule refused any sibling next to the envelope -/// `bindings` object outright. PA.f170 changes what a real client's payload -/// carries: a button's own `props.action` fields (Cut 13's per-question -/// answer token, for `world.play`) ride the *same* top-level payload object -/// `commandPayload` builds — `{...action, bindings: {...captured}}` — so a -/// sibling next to `bindings` is now the shape the real lowering produces on -/// purpose, not a mismatch to surface. This merges the two into one flat -/// object instead: `bindings`' own fields plus every sibling field, so a -/// payload struct's ordinary `#[serde(deny_unknown_fields)]` still catches a -/// genuinely unknown field exactly as it did before. A field named by both -/// `bindings` and a sibling is refused outright — that shape nothing on the -/// real client's own side produces, and silently preferring one over the -/// other would hide the collision instead of surfacing it. -/// Field names that only ever legitimately arrive as an action sibling -/// (`props.action`'s own spread fields), never as a `captureBindings` value a -/// player's own control edited (PA.f180): `answerToken` is the one member -/// today — `eve::authenticated_surface` renders it DOM-invisibly on the -/// "Play" button's own action, never through any control a `bindings` entry -/// could come from. Nothing downstream of `unwrap_bindings` can tell the two -/// channels apart once merged, so invariant 8 (an answer's own token proves -/// nothing about the world, but must still prove it named a question the -/// player was actually shown) rested entirely on `eve.rs` never emitting it -/// as a control — a single mistake there, or a request built by hand rather -/// than through the real client, would otherwise be indistinguishable from -/// the real thing. This list is checked before the two objects merge, while -/// the channel each field arrived on is still known. -const ACTION_ONLY_PAYLOAD_FIELDS: &[&str] = &["answerToken"]; - -fn unwrap_bindings(payload: &Value) -> Result { - let Some(object) = payload.as_object() else { - return Ok(payload.clone()); - }; - let Some(bindings_object) = object.get("bindings").and_then(Value::as_object) else { - return Ok(payload.clone()); - }; - for field in ACTION_ONLY_PAYLOAD_FIELDS { - if bindings_object.contains_key(*field) { - return Err(format!( - "'{field}' arrived as a captured binding, not as an action field; it is refused \ - rather than trusted as though it named the question the player was actually shown" - )); - } - } - let mut merged = serde_json::Map::with_capacity(object.len() + bindings_object.len()); - for (key, value) in object { - if key == "bindings" { - continue; - } - if bindings_object.contains_key(key) { - return Err(format!( - "payload's envelope 'bindings' object and its own sibling action field both \ - name '{key}'; send it in exactly one place" - )); - } - merged.insert(key.clone(), value.clone()); - } - for (key, value) in bindings_object { - merged.insert(key.clone(), value.clone()); - } - Ok(Value::Object(merged)) -} - -/// Cut 10 (PA.f149): every one of these three fields is edited in a plain -/// `control.input.textarea` — the vendored Eve browser lowering has no -/// JSON-typed control — so a real client always captures the field as the -/// JS string the textarea holds (Dungeon's own `eve::local_draft` calls -/// these fields' `valueKind` `"string"` for exactly this reason), never a -/// parsed object or array. A hand-built payload (existing tests) may still -/// supply the already-parsed value directly. This accepts either: a JSON -/// string is parsed as JSON text; anything else is deserialized as the typed -/// value directly. -fn deserialize_json_capture<'de, D, T>(deserializer: D) -> Result -where - D: serde::Deserializer<'de>, - T: serde::de::DeserializeOwned, -{ - match Value::deserialize(deserializer)? { - Value::String(text) => serde_json::from_str(&text).map_err(serde::de::Error::custom), - other => serde_json::from_value(other).map_err(serde::de::Error::custom), - } -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CreateJurisdiction { - /// The draft handle genesis declares the root under, and the intent's key. - handle: String, - label: String, - permille: u32, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct SeedPayload { - /// A subdirectory of the configured vault root, or empty for the whole - /// vault. Relative and `..`-free, refused at `VaultEvidenceSource::open`. - /// The root itself is configuration: a payload carrying an absolute path - /// would turn an authenticated Eve button into a read primitive over the - /// server's filesystem. - #[serde(default)] - vault_scope: String, - /// One sentence of the owner's own intent, carried into the brief verbatim - /// and into the Vault query as a referent. - #[serde(default)] - brief: Option, -} - -/// The shape a jurisdiction handle may take. Checked at ingress because the -/// handle becomes a draft-index key the model reads back in mismatches, and a -/// handle carrying whitespace or case would make that text unusable. -fn is_handle_shape(value: &str) -> bool { - let mut chars = value.chars(); - value.len() <= 48 - && chars.next().is_some_and(|first| first.is_ascii_lowercase()) - && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') -} - -/// `world.play`'s own payload. Cut 10 (PA.f151) deleted the client-supplied -/// `answers` field: the vendored Eve browser lowering has no authored, -/// non-editable binding value (`hidden` is not a prop any renderer reads), so -/// the id-carrying control it used to name would have rendered as a plain, -/// visible, editable text box holding the raw `QuestionId` — a leak, and once -/// editable, a forgeable one. The server still resolves the currently open -/// question itself (`execute_world`'s `world.play` arm), but PA.f170 replaces -/// the version-only staleness check that once stood in for identity: -/// `answer_token` is the opaque per-question token `play::ASK_PLAYER_TOOL` -/// mints when the question opens, carried DOM-invisibly in the "Play" -/// button's own `props.action` (`eve::authenticated_surface`) rather than -/// through a `captureBindings` control — the real lowering spreads a button's -/// `action` fields straight into the payload alongside `bindings` -/// (`commandPayload`, `vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`). -/// Absent or not naming the currently open question's own token, the answer -/// is refused as stale, never applied to whatever question happens to be -/// open when the request is processed. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PlayPayload { - text: String, - #[serde(default)] - answer_token: Option, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct AdvanceTimePayload { - minutes: u32, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct EmptyPayload {} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct CompleteAuthPayload { - handle: String, -} - -pub(crate) async fn run(state_root_binding: Option) -> anyhow::Result<()> { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .init(); - let runtime_root = admitted_runtime_root(state_root_binding)?; - let service_root = runtime_root.join("service"); - // The bound socket is service observation, not configuration. Retain it - // unopened to application traffic while Warming waits for Idunn's lease. - let requested_address = candidate_bind()?; - let listener = tokio::net::TcpListener::bind(requested_address).await?; - let bound_address = listener.local_addr()?; - let (fatal, mut fatal_events) = mpsc::unbounded_channel(); - let production_admission = initialize_production_admission(bound_address).await?; - let dependency_bindings = production_admission - .as_ref() - .map(|admission| admission.health.dependency_bindings().clone()); - let runtime_id = match &production_admission { - Some(admission) => admission.health.runtime_id().to_owned(), - None => std::env::var("GHOSTLIGHT_RUNTIME_ID") - .context("GHOSTLIGHT_RUNTIME_ID is required outside managed runtime")?, - }; - if runtime_id.is_empty() || runtime_id.trim() != runtime_id { - bail!("GHOSTLIGHT_RUNTIME_ID must be a canonical non-empty identity"); - } - let mesh_identity = MeshRuntimeIdentity { - runtime_id: runtime_id.clone(), - service_id: std::env::var("GHOSTLIGHT_SERVICE_ID") - .unwrap_or_else(|_| "ghostlight-dungeon".into()), - located_service: std::env::var("GHOSTLIGHT_LOCATED_SERVICE") - .unwrap_or_else(|_| "local".into()), - }; - - // Production may bind but not serve its candidate socket, inspect immutable - // root activation inputs and its provider identity, then publish Warming. - // World, session, controller, replay, and CultMesh state remain unopened - // until Idunn grants the lease bound to that exact observed process. - let mut write_lease_guard = None; - let mut idunn_health = match production_admission { - Some(admission) => { - let ProductionAdmission { - health, - write_lease, - } = admission; - write_lease.require_current()?; - write_lease_guard = Some(write_lease); - Some(health) - } - None => None, - }; - - tokio::task::yield_now().await; - require_no_runtime_custody_failure(&mut fatal_events)?; - require_current_write_lease(write_lease_guard.as_ref())?; - if write_lease_guard.is_some() { - prepare_admitted_state_layout(&runtime_root)?; - } else { - fs::create_dir_all(&service_root)?; - } - require_current_write_lease(write_lease_guard.as_ref())?; - let (world, world_owner) = WorldMailbox::open(runtime_root.join("world.cc"))?; - require_no_runtime_custody_failure(&mut fatal_events)?; - let controller_permits = Arc::new(Semaphore::new(configured_controller_concurrency())); - let (controllers, play) = - match open_controller(&world, &service_root, &runtime_id) { - Ok(OpenedControllers { - runner, - inference, - models, - play_model, - }) => { - let play = match open_play( - &world, - &service_root, - inference, - &models, - play_model, - controller_permits.clone(), - ) { - Ok(table) => Some(Arc::new(table)), - Err(error) => { - tracing::warn!(%error, "the play table is unavailable; world authority remains online"); - None - } - }; - (Some(runner), play) - } - Err(error) => { - tracing::warn!(%error, "controller cognition is unavailable; world authority remains online"); - (None, None) - } - }; - require_no_runtime_custody_failure(&mut fatal_events)?; - let wrapping_key = std::env::var_os("GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE") - .map(PathBuf::from) - .unwrap_or_else(|| runtime_root.join("secrets/session-wrapping.key")); - let sessions = AppSessionOwner::open(service_root.join("app-sessions-v2.cc"), wrapping_key)?; - let odin_endpoint = dependency_bindings - .as_ref() - .map(|bindings| bindings.odin_rudp) - .map(Ok) - .unwrap_or_else(configured_odin_endpoint)?; - // Heimdall is not an Idunn dependency: Odin now refuses every Ghostlight - // Active presence while a non-optional dependency it manages is not - // Ready, and Heimdall is not itself an Idunn-v2-managed target (every - // v2 deploy of it has failed; it still runs as the legacy - // heimdall.service). Ghostlight discovers it through Odin at use - // instead, and still enforces loopback-only, non-secret-bearing, - // app-bound HMAC+AES envelopes regardless. When Heimdall becomes a v2 - // target, this returns to a managed `RuntimeDependencyBindings` field - // the way Odin's own endpoint already is. - let heimdall = Arc::new(HeimdallClient::from_env(&runtime_id, odin_endpoint, None)?); - require_no_runtime_custody_failure(&mut fatal_events)?; - let mesh = match open_mesh(&service_root, &mesh_identity, Some(odin_endpoint)) { - Ok(mesh) => Some(mesh), - Err(error) => { - tracing::warn!(%error, "derived CultMesh projection is unavailable; world authority remains online"); - None - } - }; - require_no_runtime_custody_failure(&mut fatal_events)?; - let (revisions, _) = broadcast::channel(32); - let consumers = Arc::new(open_consumer_registry()?); - let mut state = AppState { - consumer: ConsumerPort::new(world.clone()), - consumers, - world, - controllers: controllers.map(Arc::new), - play, - controller_permits, - sessions: Arc::new(Mutex::new(sessions)), - heimdall, - mesh, - mesh_identity: mesh_identity.clone(), - revisions, - fatal, - runtime_health: None, - seed_vault_root: seed_vault_root_from_environment(|name| std::env::var_os(name)), - }; - require_no_runtime_custody_failure(&mut fatal_events)?; - publish_projection(&state).await?; - - let release_web_root = std::env::current_exe()? - .parent() - .map(|parent| parent.join("web")); - let web_root = release_web_root - .filter(|path| path.join("index.html").is_file()) - .unwrap_or_else(|| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../web/dist")); - require_no_runtime_custody_failure(&mut fatal_events)?; - require_current_write_lease(write_lease_guard.as_ref())?; - canonical_readiness(&state) - .await - .context("runtime is not ready for active signed health")?; - if let Some(publisher) = idunn_health.as_mut() { - let write_lease = write_lease_guard - .as_ref() - .context("production runtime lost its process write lease")?; - // Odin refuses Active until it has correlated the lease Idunn just - // granted ("Ready topology state is not supported by correlated - // evidence"), the same publication gap the initial Warming publish - // retries through. Each call is a fresh sequenced message; the lease - // it names cannot change between attempts (`publish_active`). - retry_until_deadline( - || publisher.publish_active(write_lease), - INITIAL_PRESENCE_RETRY_CADENCE, - Instant::now() + WRITE_LEASE_WAIT_TIMEOUT, - "publishing initial active runtime presence", - Instant::now, - |duration| tokio::time::sleep(duration), - ) - .await?; - } - require_current_write_lease(write_lease_guard.as_ref())?; - state.runtime_health = match (idunn_health, write_lease_guard) { - (Some(publisher), Some(write_lease)) => Some(RuntimeHealthOwner { - publisher: Arc::new(Mutex::new(publisher)), - write_lease: Arc::new(write_lease), - }), - (None, None) => None, - _ => bail!("managed runtime health authority is partial"), - }; - tokio::spawn(maintain_mesh_projection(state.clone())); - let app = app_router(state.clone(), web_root); - tracing::info!(address = %bound_address, "Ghostlight Dungeon world owner serving"); - let server = axum::serve( - listener, - app.into_make_service_with_connect_info::(), - ); - tokio::select! { - result = server => result.context("Ghostlight HTTP server stopped"), - result = world_owner => { - result.context("world-owner task panicked")?; - bail!("world-owner task stopped while the daemon was serving") - } - result = maintain_runtime_health(state.clone()) => { - result?; - bail!("runtime readiness owner stopped while the daemon was serving") - } - result = maintain_session_refresh(state.clone()) => { - result?; - bail!("app-session refresh owner stopped while the daemon was serving") - } - detail = fatal_events.recv() => { - bail!( - "runtime custody failed: {}", - detail.unwrap_or_else(|| "fatal signal channel closed".into()) - ) - } - } -} - -/// What `open_controller` opens: the controller organ itself, plus the same -/// opened inference organ and model set the play table (Cut 8b) shares -/// rather than opening its own second connection to the same transports. -struct OpenedControllers { - runner: ControllerRunner, - inference: Arc, - models: ControllerModels, - play_model: String, -} - -fn open_controller( - world: &WorldMailbox, - service_root: &std::path::Path, - runtime_id: &str, -) -> anyhow::Result { - let models = ControllerModels { - projector: std::env::var("GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL") - .unwrap_or_else(|_| "gpt-5.6-luna".into()), - persona: std::env::var("GHOSTLIGHT_CONTROLLER_PERSONA_MODEL") - .unwrap_or_else(|_| "gpt-5.6-sol".into()), - interpreter: std::env::var("GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL") - .unwrap_or_else(|_| "gpt-5.6-terra".into()), - operational_agent: std::env::var("GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL") - .unwrap_or_else(|_| "gpt-5.6-terra".into()), - elaborator: std::env::var("GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL") - .unwrap_or_else(|_| "gpt-5.6-terra".into()), - }; - // The play table's own model (Cut 8b), read beside its four siblings and - // handed into the same `open_inference` call below — exactly as the - // sibling models are handled — so the one opened organ validates and - // routes it too, rather than a second `open_inference` connecting to the - // same transports again for the play lane alone. - let play_model = - std::env::var("GHOSTLIGHT_PLAY_MODEL").unwrap_or_else(|_| "gpt-5.6-terra".into()); - // Dungeon no longer offers the CodexConnector lane: managed admission - // reads no connector env and expects no connector dependency (operator - // ruling 2026-09-23). The library transport stays for other callers. - let connector: Option = None; - // Ghostlight reads no credential for this transport. The sidecar inherits - // the ambient Claude Code login; this process never names, copies, or logs - // it. - let sdk = std::env::var_os("GHOSTLIGHT_SDK_SIDECAR") - .map(PathBuf::from) - .map(|sidecar_entry| SdkBinding { - sidecar_entry, - caller_runtime_id: runtime_id.to_owned(), - model_prefix: std::env::var("GHOSTLIGHT_SDK_MODEL_PREFIX") - .unwrap_or_else(|_| DEFAULT_SDK_MODEL_PREFIX.into()), - }); - // Ghostlight reads no credential for this transport either: a loopback - // local model server needs none, and `open_inference` refuses anything - // else at open. - let local = match std::env::var("GHOSTLIGHT_LOCAL_ENDPOINT") { - Ok(value) => Some(LocalBinding { - endpoint: value.parse().with_context(|| { - format!("GHOSTLIGHT_LOCAL_ENDPOINT `{value}` is not a socket address") - })?, - model_prefix: std::env::var("GHOSTLIGHT_LOCAL_MODEL_PREFIX") - .unwrap_or_else(|_| DEFAULT_LOCAL_MODEL_PREFIX.into()), - caller_runtime_id: runtime_id.to_owned(), - }), - Err(_) => None, - }; - let mut all_models: Vec<&str> = models.each().to_vec(); - all_models.push(&play_model); - let inference = open_inference(connector, sdk, local, &all_models)?; - let work = open_controller_work(service_root.join("controller-work.cc"))?; - let runner = ControllerRunner::open(world.clone(), inference.clone(), work, models.clone())?; - Ok(OpenedControllers { - runner, - inference, - models, - play_model, - }) -} - -/// Opens the play table (Cut 8b) around the same inference organ and world -/// owner `open_controller` already opened, plus its own store row and -/// concurrency pool. `permits` is the exact `Arc` `AppState` hands -/// out as `controller_permits`: `execute_dispatch` and `close_turn`'s own -/// narration draw from it, so the one pool `runtime_readiness` reads is the -/// one play cognition actually contends for (PA.f13, P8.5). -fn open_play( - world: &WorldMailbox, - service_root: &std::path::Path, - inference: Arc, - models: &ControllerModels, - play_model: String, - permits: Arc, -) -> anyhow::Result { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - inference.clone(), - models.projector.clone(), - models.persona.clone(), - )?; - PlayTable::new( - world.clone(), - personas, - inference, - play_model, - permits, - service_root.join("play-turn-v1.cc"), - ) -} - -fn open_mesh( - service_root: &std::path::Path, - identity: &MeshRuntimeIdentity, - target: Option, -) -> anyhow::Result { - MeshPublisher::open(service_root.join("mesh-v2.cc"), target, identity.clone()) -} - -fn app_router(state: AppState, web_root: PathBuf) -> Router { - api_router(state) - .fallback_service(ServeDir::new(web_root).append_index_html_on_directories(true)) -} - -fn api_router(state: AppState) -> Router { - Router::new() - .route("/health", get(health)) - .route( - "/cultnet/snapshot", - post(cultnet_snapshot).layer(DefaultBodyLimit::max(CULTNET_SNAPSHOT_BODY_LIMIT)), - ) - .route( - "/cultnet/world-patch", - post(cultnet_world_patch).layer(DefaultBodyLimit::max(CONSUMER_BODY_LIMIT)), - ) - .route("/api/eve/provider", get(eve_provider)) - .route("/api/eve/surfaces/{surface_id}", get(eve_surface)) - .route("/api/eve/commands", post(eve_command)) - .route("/api/eve/events", get(revision_events)) - .with_state(state) -} - -/// The consumer ingress's door. Loopback and content type are the two gates -/// `/cultnet/snapshot` already established; everything past them belongs to -/// `ghostlight`'s `consumer`, which owns decode, bounds, authentication, and the one -/// receipt. This handler holds no opinion about a patch. -async fn cultnet_world_patch( - ConnectInfo(peer): ConnectInfo, - State(state): State, - headers: HeaderMap, - body: Bytes, -) -> Response { - if !peer.ip().is_loopback() { - return StatusCode::FORBIDDEN.into_response(); - } - if headers.get(header::CONTENT_TYPE) != Some(&HeaderValue::from_static("application/msgpack")) { - return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); - } - let receipt = ghostlight::admit_document(&state.consumer, &state.consumers, &body).await; - match ghostlight::encode_receipt(&receipt) { - Ok(bytes) => ( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/msgpack")], - bytes, - ) - .into_response(), - Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(), - } -} - -/// Read once at startup. A missing or unset path means no configured -/// consumers, which is the fail-closed default; a malformed file fails -/// startup outright, so a mistyped credential cannot silently read as "no -/// consumers configured". -fn open_consumer_registry() -> anyhow::Result { - let Ok(path) = std::env::var(ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT) else { - return Ok(ConsumerRegistry::empty()); - }; - ConsumerRegistry::from_secret_file(&path) - .map_err(|error| anyhow::anyhow!("consumer credentials at {path} are unreadable: {error}")) -} - -async fn cultnet_snapshot( - ConnectInfo(peer): ConnectInfo, - State(state): State, - headers: HeaderMap, - body: Bytes, -) -> Response { - if !peer.ip().is_loopback() { - return StatusCode::FORBIDDEN.into_response(); - } - if headers.get(header::CONTENT_TYPE) != Some(&HeaderValue::from_static("application/msgpack")) { - return StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response(); - } - let message_id = match exact_route_observation_message_id(&body) { - Ok(message_id) => message_id, - Err(_) => return StatusCode::BAD_REQUEST.into_response(), - }; - let Some(owner) = state.runtime_health.clone() else { - return StatusCode::SERVICE_UNAVAILABLE.into_response(); - }; - - // Hold the one publisher across readiness observation and signing so a - // periodic publication cannot age the checked state before this challenge - // is bound into the signed record. - let write_lease = owner.write_lease.clone(); - let mut publisher = owner.publisher.clone().lock_owned().await; - let ready = canonical_readiness(&state).await.is_ok(); - let response = tokio::task::spawn_blocking(move || { - let response = publisher.route_observation(&message_id, ready, &write_lease)?; - encode_cultnet_message_to_vec(&response, CultNetWireContract::CultNetSchemaV0) - }) - .await; - let Ok(Ok(response)) = response else { - return StatusCode::SERVICE_UNAVAILABLE.into_response(); - }; - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "application/msgpack")], - response, - ) - .into_response() -} - -fn exact_route_observation_message_id(body: &[u8]) -> anyhow::Result { - let request = decode_cultnet_message_from_slice(body, CultNetWireContract::CultNetSchemaV0) - .context("decoding route-observation request")?; - ensure!( - encode_cultnet_message_to_vec(&request, CultNetWireContract::CultNetSchemaV0)? == body, - "route-observation request is not canonical MessagePack" - ); - let CultNetMessage::SnapshotRequest { - message_id, - schema_ids, - record_keys, - } = request - else { - bail!("route-observation request is not a snapshot request"); - }; - ensure!( - !message_id.is_empty(), - "route-observation message id is empty" - ); - ensure!( - matches!( - schema_ids.as_deref(), - Some([schema_id]) if schema_id == GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA - ) && matches!( - record_keys.as_deref(), - Some([record_key]) if record_key == GHOSTLIGHT_TARGET - ), - "route-observation request is not the exact Ghostlight presence record" - ); - let detail = format!("route-observation:{message_id}"); - ensure!( - detail.len() <= 512 && !detail.chars().any(char::is_control), - "route-observation message id cannot be signed canonically" - ); - Ok(message_id) -} - -async fn health(State(state): State) -> Response { - match runtime_readiness(&state).await { - Ok(health) => Json(health).into_response(), - Err(error) => ( - StatusCode::SERVICE_UNAVAILABLE, - Json(json!({"status":"failed","message":error.to_string()})), - ) - .into_response(), - } -} - -async fn eve_provider(State(state): State) -> Response { - Json(mesh::provider_advertisement( - &state.mesh_identity, - &Utc::now().to_rfc3339(), - )) - .into_response() -} - -async fn eve_surface( - Path(surface_id): Path, - headers: HeaderMap, - State(state): State, -) -> Response { - if surface_id != mesh::SURFACE_ID { - return StatusCode::NOT_FOUND.into_response(); - } - let Some(principal) = authenticated_principal(&headers, &state).await else { - return Json(eve::anonymous_surface()).into_response(); - }; - let play_view = current_play_view(&state).await; - match current_world(&state).await.and_then(|snapshot| { - eve::authenticated_surface( - principal.account_subject_hash(), - snapshot.as_ref(), - play_view.as_ref(), - state.seed_vault_root.is_some(), - ) - }) { - Ok(surface) => Json(surface).into_response(), - Err(error) => ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"message":error.to_string()})), - ) - .into_response(), - } -} - -async fn eve_command( - headers: HeaderMap, - State(state): State, - Json(invocation): Json, -) -> Response { - if let Err(error) = eve::validate_invocation(&invocation, "https-json") { - return Json(eve::command_result( - &invocation, - "denied", - error.to_string(), - None, - None, - None, - )) - .into_response(); - } - match invocation.operation.operation_id.as_str() { - "heimdall.auth.begin" => begin_authentication(&state, invocation).await, - "heimdall.auth.complete" => complete_authentication(&state, invocation).await, - "app.auth.logout" => logout(&headers, &state, invocation).await, - _ => { - let Some(principal) = authenticated_principal(&headers, &state).await else { - return Json(eve::command_result( - &invocation, - "denied", - "Authentication is required.", - None, - None, - None, - )) - .into_response(); - }; - dispatch_world(&state, &principal, invocation).await - } - } -} - -async fn begin_authentication(state: &AppState, invocation: EveCommandInvocation) -> Response { - let bindings = match unwrap_bindings(&invocation.payload) { - Ok(bindings) => bindings, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - }; - if let Err(error) = serde_json::from_value::(bindings.clone()) { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - let idempotency = invocation - .operation - .idempotency_key - .as_deref() - .unwrap_or(""); - match state.heimdall.begin(idempotency).await { - Ok(receipt) - if receipt.status == "pending" - && !receipt.handle.is_empty() - && receipt - .expires_at - .parse::>() - .is_ok_and(|expiry| expiry > Utc::now()) => - { - Json(eve::command_result( - &invocation, - "accepted", - "Continue authentication with Heimdall.", - None, - Some(json!({ - "pluginId":"gamecult.heimdall.access", - "schemaId":"heimdall.auth_navigation_receipt.v1", - "payload":{ - "schema":"heimdall.auth_navigation_receipt.v1", - "handle":receipt.handle, - "navigation":{ - "url":receipt.navigation.url, - "allowedOrigins":receipt.navigation.allowed_origins - } - } - })), - None, - )) - .into_response() - } - Ok(_) => Json(eve::command_result( - &invocation, - "denied", - "Heimdall returned an invalid authentication attempt.", - None, - None, - None, - )) - .into_response(), - Err(error) => Json(eve::command_result( - &invocation, - "denied", - error.to_string(), - None, - None, - None, - )) - .into_response(), - } -} - -async fn complete_authentication(state: &AppState, invocation: EveCommandInvocation) -> Response { - let bindings = match unwrap_bindings(&invocation.payload) { - Ok(bindings) => bindings, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - }; - let payload = match serde_json::from_value::(bindings.clone()) { - Ok(payload) => payload, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - }; - let handle = payload.handle.as_str(); - if handle.is_empty() { - return Json(eve::command_result( - &invocation, - "denied", - "Authentication completion omitted its opaque handle.", - None, - None, - None, - )) - .into_response(); - } - let idempotency = invocation - .operation - .idempotency_key - .as_deref() - .unwrap_or(""); - let completion = match state.heimdall.complete(handle, idempotency).await { - Ok(value) => value, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - error.to_string(), - None, - None, - None, - )) - .into_response(); - } - }; - if completion.status == "pending" { - return Json(auth_completion_result( - &invocation, - "pending", - "pending", - "Heimdall is waiting for Discord.", - )) - .into_response(); - } - if completion.status != "authenticated" || completion.handle.as_deref() != Some(handle) { - let message = completion - .error - .clone() - .unwrap_or_else(|| "Heimdall denied access.".into()); - return Json(auth_completion_result( - &invocation, - "denied", - "denied", - &message, - )) - .into_response(); - } - let adopted = match adopt_heimdall_completion(state, completion).await { - Ok(value) => value, - Err(error) => { - return Json(auth_completion_result( - &invocation, - "denied", - "denied", - &error.to_string(), - )) - .into_response(); - } - }; - let mut response = Json(auth_completion_result( - &invocation, - "accepted", - "authenticated", - "Authenticated.", - )) - .into_response(); - response.headers_mut().insert( - header::SET_COOKIE, - session_cookie(&adopted.token, adopted.expires_at, Utc::now()), - ); - response -} - -struct AdoptedSession { - token: String, - expires_at: DateTime, -} - -async fn adopt_heimdall_completion( - state: &AppState, - completion: heimdall::AuthCompletionReceipt, -) -> anyhow::Result { - let admission = state.heimdall.verify_completion(completion).await?; - let expires_at = admission.refresh_expires_at(); - let token = { - let mut sessions = state.sessions.lock().await; - let result = sessions.create_session(admission); - if result.is_err() && !sessions.is_healthy() { - signal_fatal( - state, - "app-session authentication adoption", - result.as_ref().unwrap_err(), - ); - } - result? - }; - Ok(AdoptedSession { token, expires_at }) -} - -fn auth_completion_result( - invocation: &EveCommandInvocation, - command_state: &str, - auth_state: &str, - message: &str, -) -> Value { - eve::command_result( - invocation, - command_state, - message, - None, - Some(json!({ - "pluginId":"gamecult.heimdall.access", - "schemaId":"heimdall.auth_completion_status.v1", - "payload":{"schema":"heimdall.auth_completion_status.v1","status":auth_state} - })), - None, - ) -} - -async fn logout( - headers: &HeaderMap, - state: &AppState, - invocation: EveCommandInvocation, -) -> Response { - let bindings = match unwrap_bindings(&invocation.payload) { - Ok(bindings) => bindings, - Err(error) => { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - }; - if let Err(error) = serde_json::from_value::(bindings.clone()) { - return Json(eve::command_result( - &invocation, - "denied", - format!("invalid command payload: {error}"), - None, - None, - None, - )) - .into_response(); - } - let raw_cookie = cookie_value(headers).unwrap_or_default(); - let local_logout = { - let mut sessions = state.sessions.lock().await; - match sessions.session_for_logout(raw_cookie) { - Ok(remote) => match sessions.revoke_cookie(raw_cookie) { - Ok(true) => Ok(remote), - Ok(false) => Ok(remote), - Err(error) => Err(error), - }, - Err(error) => Err(error), - } - }; - let remote = match local_logout { - Ok(remote) => remote, - Err(error) => { - signal_fatal(state, "app-session logout", &error); - return Json(auth_completion_result( - &invocation, - "unknown", - "unknown", - "Local session revocation was not confirmed.", - )) - .into_response(); - } - }; - if let Some(session) = remote { - let heimdall = state.heimdall.clone(); - tokio::spawn(async move { - let idempotency = format!( - "logout:{}:{}", - session.heimdall_session_id, session.access_revision - ); - if let Err(error) = heimdall.logout(&session.refresh_claim, &idempotency).await { - tracing::warn!(%error, "local logout succeeded but Heimdall logout was unavailable"); - } - }); - } - let mut response = Json(auth_completion_result( - &invocation, - "accepted", - "anonymous", - "Signed out.", - )) - .into_response(); - response.headers_mut().insert( - header::SET_COOKIE, - HeaderValue::from_static( - "ghostlight_session=; Max-Age=0; HttpOnly; Secure; SameSite=Lax; Path=/ghostlight/", - ), - ); - response -} - -async fn dispatch_world( - state: &AppState, - principal: &VerifiedPrincipalEvidence, - invocation: EveCommandInvocation, -) -> Response { - let result = execute_world(state, principal, &invocation).await; - match result { - Ok(receipt) => { - let version = match publish_projection(state).await { - Ok(version) => Some(version), - Err(error) => { - tracing::error!(%error, "world revision could not be read after commit"); - current_world(state) - .await - .ok() - .map(|snapshot| eve::surface_version(snapshot.as_ref())) - } - }; - Json(eve::command_result( - &invocation, - "accepted", - "World owner accepted the command.", - version, - None, - Some(receipt), - )) - .into_response() - } - Err(error) => { - let state_name = if matches!(error, RuntimeCommandError::OutcomeUnknown(_)) { - "unknown" - } else { - "denied" - }; - Json(eve::command_result( - &invocation, - state_name, - error.to_string(), - current_world(state) - .await - .ok() - .map(|snapshot| eve::surface_version(snapshot.as_ref())), - None, - None, - )) - .into_response() - } - } -} - -#[derive(Debug, thiserror::Error)] -enum RuntimeCommandError { - #[error("invalid command payload: {0}")] - Payload(String), - #[error("world command outcome is unknown after durable submission: {0}")] - OutcomeUnknown(String), - #[error(transparent)] - Mailbox(#[from] MailboxError), - #[error(transparent)] - Kernel(#[from] KernelError), -} - -/// The one door an answer's own `answerToken` is judged by (PA.f179): admits -/// only when both `given` (`payload.answer_token`) and `expected` -/// (`question.token`) are `Some` and equal. Extracted to its own function so -/// the absence case is a direct, cheap unit test rather than something only -/// reachable through the full `world.play` HTTP round trip — `expected` -/// being `None` (a question whose own token was, for whatever reason, never -/// minted) must refuse unconditionally, including against a `given` that is -/// also `None`, or against `Some("")`; it must never be treated as a -/// wildcard or degrade to a plain string comparison. -fn answer_token_admits(given: Option<&str>, expected: Option<&str>) -> bool { - matches!((given, expected), (Some(given), Some(expected)) if given == expected) -} - -async fn execute_world( - state: &AppState, - verified_principal: &VerifiedPrincipalEvidence, - invocation: &EveCommandInvocation, -) -> Result { - let command_id = CommandId::parse_uuid( - invocation - .operation - .idempotency_key - .as_deref() - .unwrap_or(""), - )?; - if invocation.operation.operation_id == "world.create" { - let payload: CreatePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - if let Some(root) = payload - .jurisdictions - .iter() - .find(|root| !is_handle_shape(&root.handle)) - { - return Err(RuntimeCommandError::Payload(format!( - "jurisdiction handle {} is not a draft handle", - root.handle - ))); - } - let receipt = state - .world - .create( - CreateWorldIntent { - id: command_id, - title: payload.title, - brief: payload.brief, - human_subject_label: payload.subject_label, - narrative_persona_label: payload.narrative_persona_label.filter(|label| !label.trim().is_empty()), - operational_agent_label: payload.operational_agent_label.filter(|label| !label.trim().is_empty()), - targets: payload.targets, - jurisdictions: payload - .jurisdictions - .into_iter() - .map(|root| CreateJurisdictionIntent { - handle: root.handle, - label: root.label, - permille: root.permille, - }) - .collect(), - lens_weights: payload.lens_weights, - }, - verified_principal, - ) - .await - .map_err(map_mailbox)?; - return Ok(json!({ - "kind":"created", - "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), - "worldId":serde_json::to_value(receipt.world_id).unwrap_or(Value::Null), - "stateDigest":receipt.resulting_state_digest, - "commitDigest":receipt.commit_digest - })); - } - if invocation.operation.operation_id == "world.play" { - // Cut 10 (PA.f152): owner-gated, like `world.advance_time` and - // `world.seed` below — `eve::authenticated_surface` already hides - // the card and the button from anyone else, but the route is the - // actual authority boundary. - let snapshot = current_world(state) - .await - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))? - .ok_or_else(|| RuntimeCommandError::Payload("world has not been created".into()))?; - if snapshot.owner != PrincipalId::new(verified_principal.account_subject_hash()) { - return Err(RuntimeCommandError::Payload( - "only the world's owner may play".into(), - )); - } - let payload: PlayPayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - let table = state - .play - .clone() - .ok_or_else(|| RuntimeCommandError::Payload("the play table is unavailable".into()))?; - // The server resolves the open question itself — the client no - // longer names it by `QuestionId`. No question open: this is a plain - // continue/opening, `answers: None`. A question open: the caller - // must be answering it, and PA.f170's guarantee (an answer cannot - // land on a question the player never saw) is judged by the opaque - // per-question token the question's own render carried in the "Play" - // button's `props.action` (`eve::authenticated_surface`), not by - // `routeHint.sourceVersion`: two questions opened in the same turn - // carry the same surface version (asking is conversational and never - // moves it), so a version-only comparison cannot tell them apart — - // exactly the gap Soul proved (PA.f170). A token that is absent, or - // does not name the currently open question's own token, is refused - // as stale. - let view = table.current_turn_view().await; - // PA.f155: `PlayTurnView::state` is the actual rule this branches - // on — the turn is currently waiting on the player — rather than - // `question.is_some()` standing in for it; `question` is set - // whenever the state is, so this and `question.is_some()` can never - // disagree, but the state is the thing the rule is actually about. - let is_awaiting_player = view - .as_ref() - .is_some_and(|view| view.state == PlayTurnState::AwaitingPlayer); - // PA.f178: a stale tab — one that rendered a card for a question - // that has since closed, answered, or been superseded by a fresh - // turn — still carries the spent `answerToken` its own render was - // built from. Before this check, `answer_token` was read only inside - // the `Some(question)` arm below, so with no question currently - // open this fell straight through to `answers: None`, and the - // request's own `text` (the stale tab's stale answer prose) silently - // opened a *new* turn instead: a real inference budget spent on an - // answer to a question that no longer exists. A token naming no - // currently open question is refused outright, the same family of - // refusal as a mismatched token, and never reaches `admit` at all. - if payload.answer_token.is_some() && !is_awaiting_player { - return Err(RuntimeCommandError::Payload( - "that question is closed".into(), - )); - } - let answers = match view.as_ref().and_then(|view| view.question.as_ref()).filter(|_| is_awaiting_player) { - None => None, - Some(question) => { - if !answer_token_admits(payload.answer_token.as_deref(), question.token.as_deref()) { - return Err(RuntimeCommandError::Payload(format!( - "the answer is stale: its token does not name the question \"{}\" \ - currently open", - question.text - ))); - } - Some(question.id.clone()) - } - }; - // PA.f147 (reported, not fixed — see Hands' own report): the - // unconditional `command_id = CommandId::parse_uuid(...)?` at the - // top of this function already refuses a missing or non-uuid key - // for every operation, `world.play` included, before this branch is - // ever reached — verified by reverting a local guard here to this - // exact `unwrap_or_default()` and re-running a test built to catch - // it: it still passed. A local, explicit guard was tried and kept - // only if it could be shown to matter; it could not, so it was not - // kept as unreachable code. - let key = invocation.operation.idempotency_key.clone().unwrap_or_default(); - // Cut 10 (PA.f153/PA.f154): `admit` runs synchronously, here, so a - // replayed key, a stale/empty answer, a busy table, and a poisoned - // table all answer `denied` with the reason, instead of being - // decided inside a spawned task whose result nothing ever read. Only - // the turn's own round loop — the part that can spend a real - // inference budget — is spawned. - let admission = table - .admit(key, PlayRequest { text: payload.text, answers }) - .await - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - let admitted = match admission { - Admission::Replayed { .. } => { - return Ok(json!({"kind":"accepted","outcome":"replayed"})); - } - Admission::Spawn(admitted) => admitted, - }; - let principal = verified_principal.clone(); - let spawned_state = state.clone(); - tokio::spawn(async move { - if let Err(error) = table.execute(admitted, &principal).await { - // PA.f155: `PlayTurnView::turn_id` names which turn faulted, - // for an operator reading this log against the store — the - // one production reader it had none of before. - let turn_id = table.current_turn_view().await.map(|view| view.turn_id); - tracing::warn!(%error, ?turn_id, "a play turn ended in error"); - } - // Cut 10 (PA.f150): a play-row-only change (a fresh refusal, a - // newly open question, a closed turn's narration) commits - // nothing to the world, so nothing on the `eve_command` Ok arm's - // own `publish_projection` call (`dispatch_world`) would ever - // fire for it — that call only runs for this same command's own - // immediate `accepted` response, sent before this turn's own - // execution even started. This is the turn's own commit, - // published on the same door. - if let Err(error) = publish_projection(&spawned_state).await { - tracing::warn!(%error, "play turn projection publish failed"); - } - }); - return Ok(json!({"kind":"accepted"})); - } - - let snapshot = current_world(state) - .await - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))? - .ok_or_else(|| RuntimeCommandError::Payload("world has not been created".into()))?; - let source_version = invocation - .operation - .route_hint - .source_version - .ok_or_else(|| RuntimeCommandError::Payload("source version is required".into()))?; - let expected_revision = source_version.checked_sub(1).ok_or_else(|| { - RuntimeCommandError::Payload("source version does not name a world revision".into()) - })?; - let body = match invocation.operation.operation_id.as_str() { - "world.approve" => { - serde_json::from_value::(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - CommandBody::ApproveDraft - } - "world.activate" => { - serde_json::from_value::(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - CommandBody::ActivateWorld - } - "world.advance_time" => { - let payload: AdvanceTimePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - CommandBody::AdvanceTime { - minutes: TickMinutes::new(payload.minutes).ok_or_else(|| { - RuntimeCommandError::Payload("time span is outside one year of minutes".into()) - })?, - } - } - // The one arm that builds no `CommandBody`: the seed runner submits - // through its own port, as the owner, and the receipt reports what one - // session did rather than what one command committed. - "world.seed" => { - let payload: SeedPayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - let outcome = seed_once(state, verified_principal, &snapshot, payload).await?; - let after = current_world(state) - .await - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - return Ok(json!({ - "kind":"seeded", - "outcome":outcome.name(), - "revision":after.as_ref().map(|world| world.revision), - "deficit":after - .as_ref() - .map(|world| world.scale_deficit.iter().map(|row| u64::from(row.deficit)).sum::()), - })); - } - operation => { - return Err(RuntimeCommandError::Payload(format!( - "unknown world operation {operation}" - ))); - } - }; - let receipt = state - .world - .submit_principal( - PrincipalCommandIntent { - id: command_id, - world_id: snapshot.world_id, - expected_revision, - body, - }, - verified_principal, - ) - .await - .map_err(map_mailbox)?; - Ok(submit_receipt(receipt)) -} - -fn submit_receipt(receipt: SubmitReceipt) -> Value { - match receipt { - SubmitReceipt::Applied(receipt) => json!({ - "kind":"applied", - "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), - "revision":receipt.resulting_revision, - "stateDigest":receipt.resulting_state_digest, - "commitDigest":receipt.commit_digest - }), - SubmitReceipt::AlreadyApplied(receipt) => json!({ - "kind":"already_applied", - "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), - "revision":receipt.resulting_revision, - "stateDigest":receipt.resulting_state_digest, - "commitDigest":receipt.commit_digest - }), - } -} - -fn map_mailbox(error: MailboxError) -> RuntimeCommandError { - match error { - MailboxError::OutcomeUnknown { command_id } => { - RuntimeCommandError::OutcomeUnknown(format!("{command_id:?}")) - } - other => RuntimeCommandError::Mailbox(other), - } -} - -async fn current_world(state: &AppState) -> anyhow::Result> { - match state.world.snapshot().await { - Ok(snapshot) => Ok(Some(snapshot)), - Err(MailboxError::Kernel(KernelError::WorldNotCreated)) => Ok(None), - Err(error) => Err(error.into()), - } -} - -/// The current play turn's own player-facing state (Cut 9), read straight off -/// `PlayTable::current_turn_view` — `None` when the table is unavailable, and -/// `None` again when it is available but no turn has ever opened, exactly as -/// `current_turn_view` itself returns. `eve::authenticated_surface`'s own play -/// card degrades to its own empty shape either way. -async fn current_play_view(state: &AppState) -> Option { - state.play.as_ref()?.current_turn_view().await -} - -async fn publish_projection(state: &AppState) -> anyhow::Result { - let snapshot = current_world(state).await?; - // This function's own return value — `command_result.sourceVersion` in - // `dispatch_world`'s Ok arm — stays plain `surface_version` (PA.f148), - // the only meaning a caller ever feeds back as `routeHint.sourceVersion`. - // The SSE broadcast below is a different field with a different job: it - // wakes a watching client, is never echoed back, and so carries - // `authenticated_wake_version` instead, the value that also moves on a - // play-row-only change (PA.f150) — every play commit, including a fresh - // question, narration, or refusal, reaches this call. - let version = eve::surface_version(snapshot.as_ref()); - let play_view = current_play_view(state).await; - let wake_version = eve::authenticated_wake_version(snapshot.as_ref(), play_view.as_ref()); - let _ = state.revisions.send(wake_version); - if let Some(mesh) = state.mesh.clone() { - let _projection = tokio::task::spawn_blocking(move || { - if let Err(error) = mesh.publish(snapshot.as_ref()) { - tracing::warn!(%error, "derived CultMesh projection update failed"); - } - }); - } - Ok(version) -} - -async fn refresh_mesh_projection(state: &AppState) -> anyhow::Result { - let snapshot = current_world(state).await?; - let version = eve::surface_version(snapshot.as_ref()); - let Some(mesh) = state.mesh.clone() else { - bail!("CultMesh projection is unavailable"); - }; - tokio::task::spawn_blocking(move || mesh.publish(snapshot.as_ref())) - .await - .context("CultMesh projection worker panicked")??; - Ok(version) -} - -async fn canonical_readiness(state: &AppState) -> anyhow::Result { - let snapshot = current_world(state) - .await - .context("world owner is not ready")?; - state - .sessions - .lock() - .await - .validate_custody() - .context("app-session owner is not ready")?; - let expected_version = eve::surface_version(snapshot.as_ref()); - let expected_world_state = eve::world_state(snapshot.as_ref()); - Ok(json!({ - "schema":"ghostlight.service_health.v2", - "status":"ok", - "worldState":expected_world_state, - "surfaceVersion":expected_version, - "updatedAtUtc":Utc::now().to_rfc3339(), - "runtime":state.mesh_identity.runtime_id, - "commit":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development"), - })) -} - -async fn runtime_readiness(state: &AppState) -> anyhow::Result { - let mut health = canonical_readiness(state).await?; - let expected_version = health["surfaceVersion"].as_u64().unwrap_or_default(); - let expected_world_state = health["worldState"].as_str().unwrap_or("unknown"); - let projection_status = match &state.mesh { - Some(mesh) => match mesh.health() { - Ok(health) - if health.get("status").and_then(Value::as_str) == Some("ok") - && health.get("surfaceVersion").and_then(Value::as_u64) - == Some(expected_version) - && health.get("worldState").and_then(Value::as_str) - == Some(expected_world_state) => - { - "ok" - } - Ok(_) | Err(_) => "degraded", - }, - None => "unavailable", - }; - let controller_status = match state.controllers.as_deref() { - Some(controller) => { - match tokio::time::timeout(Duration::from_millis(100), controller.custody_probe()).await - { - Ok(Ok(ControllerWorkCustody::Owned { .. })) => { - // `controller_permits` has one production consumer since - // Cut 1 deleted Dungeon's own drivers: the play table's - // own dispatched-Persona turns and turn-close narration - // (Cut 8b). Every permit taken is therefore genuine - // Persona cognition in flight, so "active" here is - // accurate, not merely reachable through a test's own - // forced exhaustion. - if state.controller_permits.available_permits() == 0 { - "active" - } else { - "ok" - } - } - Ok(Ok(ControllerWorkCustody::Uncertain { .. })) | Ok(Err(_)) => "degraded", - Err(_) => "busy", - } - } - None => "unavailable", - }; - // PA.f172: the play table can fail to open independently of - // `controllers` (a `PlayTurnStore` a prior session's row makes unreadable), - // and before this the only trace was `tracing::warn!("the play table is - // unavailable; world authority remains online")` at startup — visible in - // a log an operator has to go looking for, never on the readiness - // surface a client or operator actually watches. Same `Option` -> - // "ok"/"unavailable" shape as `projectionStatus`/`controllerStatus` - // above; this does not invent a new channel. - // - // PA.f186: `open` can also succeed while quietly retiring a row it could - // not read (`play::retire_unreadable_row`, PA.f184/PA.f177) — before this, - // `state.play.is_some()` alone decided this field, so a successful - // retirement read as plain `"ok"` even though the player's own turn was - // just destroyed and the only trace was one `tracing::warn!`. Reusing the - // same `playStatus` channel (not a new one) rather than a bare enum token - // when a retirement happened this process is what keeps this readable - // without inventing a sibling field. - let play_status = match &state.play { - Some(table) => match table.retired_row_sidecar() { - Some(sidecar) => format!("a play-turn row was retired to {}", sidecar.display()), - None => "ok".into(), - }, - None => "unavailable".into(), - }; - health["projectionStatus"] = Value::String(projection_status.into()); - health["controllerStatus"] = Value::String(controller_status.into()); - health["playStatus"] = Value::String(play_status); - Ok(health) -} - -async fn maintain_mesh_projection(state: AppState) { - let mut interval = tokio::time::interval(Duration::from_secs(120)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - // Startup publishes once before serving. Do not wake clients with a duplicate - // revision event; this loop only renews derived CultMesh freshness. - interval.tick().await; - loop { - interval.tick().await; - if let Err(error) = refresh_mesh_projection(&state).await { - tracing::warn!(%error, "derived CultMesh projection remains degraded"); - } - } -} - -/// Where the seed lane's read-only markdown vault lives. Configuration, read at -/// open exactly as every other runtime path is: the payload names a relative -/// scope inside it and never the root. -const SEED_VAULT_ROOT_ENVIRONMENT: &str = "GHOSTLIGHT_SEED_VAULT_ROOT"; - -/// The vault root the environment names, if any. Open passes the process -/// environment as `lookup` and the result lives in `AppState`, so the root is -/// read once at startup and changing it needs a restart. This is the only place -/// that names the variable and decides what counts as configured, so an empty -/// or whitespace-only value is no configuration wherever the state is built. -fn seed_vault_root_from_environment( - lookup: impl Fn(&str) -> Option, -) -> Option { - lookup(SEED_VAULT_ROOT_ENVIRONMENT) - .filter(|root| !root.to_string_lossy().trim().is_empty()) - .map(PathBuf::from) -} - -/// One seeding session, inside the request that asked for it. There is no -/// background task and no stop channel: a spawned sweep would need the owner's -/// verified evidence to outlive the request that carried it, and then two new -/// authorities in `AppState` to be stoppable. The checkpoint discipline already -/// makes a long request safe — a transport timeout loses the response, not the -/// work — so the owner's repetition is the sweep and not pressing the button -/// again is the stop. -/// -/// Every refusal here happens before anything is spent. The reducer refuses a -/// non-owner too, at `require_patch_author`, and the runner refuses an Active -/// world again at its own phase gate; this is the gate that keeps a paid -/// session from running first. -async fn seed_once( - state: &AppState, - verified_principal: &VerifiedPrincipalEvidence, - snapshot: &WorldSnapshot, - payload: SeedPayload, -) -> Result { - if snapshot.owner != PrincipalId::new(verified_principal.account_subject_hash()) { - return Err(RuntimeCommandError::Payload( - "seeding a world is its owner's lane".into(), - )); - } - if snapshot.phase != WorldPhase::Draft { - return Ok(SeedOutcome::NotDraft); - } - let Some(controllers) = state.controllers.as_deref() else { - return Err(RuntimeCommandError::Payload( - "the cognition organ is closed".into(), - )); - }; - // The refusal a player sees must stay a projection of the world and their - // own act (invariant 8), never a server configuration name: naming - // `SEED_VAULT_ROOT_ENVIRONMENT` to the player taught them a fact about - // this process's own environment, not about the world. The detail is - // still logged, for the operator who can actually act on it. - let root = state.seed_vault_root.clone().ok_or_else(|| { - tracing::warn!( - environment_variable = SEED_VAULT_ROOT_ENVIRONMENT, - "seeding was requested but the vault root is not configured" - ); - RuntimeCommandError::Payload("seeding is not available on this world".into()) - })?; - let vault = VaultEvidenceSource::open(&root, &payload.vault_scope) - .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - let runner = controllers.seeder( - SeedPort::new(state.world.clone(), verified_principal.clone()), - Arc::new(vault), - payload.brief, - ); - runner - .sweep(1) - .await - .map_err(|error| { - // PA.f187: `error.to_string()` used to reach the player verbatim - // through `dispatch_world`'s own `error.to_string()` rendering — - // Soul measured the real string on a fresh workstation with no - // inference endpoint running: "invalid command payload: - // inference failed: the local inference endpoint refused the - // connection: error sending request for url - // (http://127.0.0.1:11434/v1/chat/completions)". Connection- - // refused is the likeliest seed failure of all, so this handed - // this process's own host and port to the player on the first - // real failure. A player-facing refusal on this route names a - // category instead; the full detail is logged here, once, at the - // point this process actually knows it. `ControllerError`'s own - // `Display` is unchanged — every other caller still sees the - // detailed message — so this is scoped to the seed route alone. - tracing::warn!(%error, "seeding a world could not complete"); - RuntimeCommandError::Payload(describe_seed_failure(&error)) - }) -} - -/// PA.f187: categorizes a seed sweep's own `ControllerError` for the player, -/// never its `Display`. `Inference`/`ProviderContract` are the connectivity -/// class Soul's probe hit (a closed or unreachable inference endpoint); every -/// other variant still names no internal detail, just a coarser category — -/// none of `ControllerError`'s other arms are seed-route-reachable today, but -/// this does not assume that stays true. -fn describe_seed_failure(error: &ControllerError) -> String { - match error { - ControllerError::Inference { .. } | ControllerError::ProviderContract { .. } => { - "seeding could not reach the model".into() - } - _ => "seeding could not complete".into(), - } -} - -/// Dungeon policy for a world created before Session Zero's sliders exist -/// (D2). Not a library default; the library requires the weights to be stated. -pub(crate) fn uniform_lens_weights() -> LensWeights { - LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()) -} - -/// Sized to the connector's per-caller quota. `Capacity` and `InFlight` -/// refusals are already retryable faults, so a pool above that quota degrades -/// to retry rather than to corruption. -fn configured_controller_concurrency() -> usize { - std::env::var("GHOSTLIGHT_CONTROLLER_MAX_CONCURRENT") - .ok() - .and_then(|value| value.trim().parse::().ok()) - .filter(|value| *value >= 1) - .unwrap_or(4) -} - -async fn revision_events(State(state): State) -> impl IntoResponse { - let mut revisions = state.revisions.subscribe(); - let stream = async_stream::stream! { - loop { - match revisions.recv().await { - Ok(version) => yield Ok::(SseEvent::default().event("revision").data(version.to_string())), - Err(broadcast::error::RecvError::Lagged(_)) => continue, - Err(broadcast::error::RecvError::Closed) => break, - } - } - }; - Sse::new(stream).keep_alive(KeepAlive::default()) -} - -async fn authenticated_principal( - headers: &HeaderMap, - state: &AppState, -) -> Option { - let raw = cookie_value(headers)?; - match state - .sessions - .lock() - .await - .account_for_cookie(raw, Utc::now()) - { - Ok(account) => account, - Err(error) => { - signal_fatal(state, "app-session custody", &error); - None - } - } -} - -fn signal_fatal(state: &AppState, organ: &str, error: &impl std::fmt::Display) { - let detail = format!("{organ}: {error}"); - tracing::error!(%detail, "runtime owner can no longer uphold its invariant"); - let _ = state.fatal.send(detail); -} - -fn cookie_value(headers: &HeaderMap) -> Option<&str> { - headers - .get(header::COOKIE)? - .to_str() - .ok()? - .split(';') - .map(str::trim) - .find_map(|cookie| cookie.strip_prefix(&format!("{COOKIE_NAME}="))) -} - -fn session_cookie(raw: &str, expires_at: DateTime, now: DateTime) -> HeaderValue { - HeaderValue::from_str(&format!( - "{COOKIE_NAME}={raw}; Max-Age={}; HttpOnly; Secure; SameSite=Lax; Path=/ghostlight/", - (expires_at - now).num_seconds().max(0) - )) - .expect("generated session cookie must be valid") -} - -async fn maintain_session_refresh(state: AppState) -> anyhow::Result<()> { - run_refresh_loop(&state.sessions, state.heimdall.as_ref()).await -} - -/// How often the refresh loop scans for sessions due for refresh. Decided -/// here and nowhere else. -const SESSION_REFRESH_PERIOD: Duration = Duration::from_secs(60); - -/// Runs `refresh_due_sessions` once per `SESSION_REFRESH_PERIOD` for as long -/// as the daemon serves. The receipts it holds are Heimdall refreshes already -/// spent upstream whose verification could not yet be judged. -async fn run_refresh_loop( - sessions: &Mutex, - heimdall: &impl SessionRefresher, -) -> anyhow::Result<()> { - let mut interval = tokio::time::interval(SESSION_REFRESH_PERIOD); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - let mut unverified = HashMap::new(); - loop { - interval.tick().await; - refresh_due_sessions(sessions, heimdall, &mut unverified).await?; - } -} - -/// One pass of the refresh loop over every session due for refresh. -/// `unverified` holds, per session, a receipt Heimdall issued for a refresh -/// that is already spent upstream but whose signing keys could not be -/// fetched: the next pass verifies it instead of refreshing again. -async fn refresh_due_sessions( - sessions: &Mutex, - heimdall: &impl SessionRefresher, - unverified: &mut HashMap, -) -> anyhow::Result<()> { - let candidates = sessions - .lock() - .await - .sessions_due_for_refresh(Utc::now(), chrono::Duration::minutes(2)) - .context("app-session refresh scan lost custody")?; - unverified.retain(|cookie_hash, _| { - candidates - .iter() - .any(|candidate| &candidate.cookie_hash == cookie_hash) - }); - for candidate in candidates { - let completion = match unverified.remove(&candidate.cookie_hash) { - Some(completion) => completion, - None => { - let idempotency = - crate::app_session::refresh_idempotency_key(&candidate.refresh_claim); - match heimdall.refresh(&candidate.refresh_claim, &idempotency).await { - Ok(value) => value, - Err(error) if error.downcast_ref::().is_some() => { - // Heimdall refused this refresh claim: it can never succeed. - tracing::warn!(%error, "Heimdall denied the refresh; revoking the local session"); - let mut sessions = sessions.lock().await; - if let Err(revoke_error) = - sessions.revoke_cookie_hash(&candidate.cookie_hash) - { - return Err(revoke_error) - .context("denied Heimdall refresh could not revoke local custody"); - } - continue; - } - Err(error) => { - tracing::warn!(%error, "Heimdall refresh transport unavailable"); - continue; - } - } - } - }; - let verified = match heimdall.verify_refresh(completion.clone()).await { - Ok(value) => value, - Err(error) if error.downcast_ref::().is_some() => { - // The claim is spent upstream and the receipt may be genuine: - // keep it and verify again on the next pass. - tracing::warn!(%error, "Heimdall refresh receipt could not be verified yet"); - unverified.insert(candidate.cookie_hash.clone(), completion); - continue; - } - Err(error) => { - tracing::warn!(%error, "Heimdall refresh receipt was invalid"); - let mut sessions = sessions.lock().await; - if let Err(revoke_error) = sessions.revoke_cookie_hash(&candidate.cookie_hash) { - return Err(revoke_error) - .context("invalid Heimdall refresh could not revoke local custody"); - } - continue; - } - }; - if verified.heimdall_session_id() != candidate.heimdall_session_id - || crate::app_session::secret_hash(&format!( - "heimdall-account:{}", - verified.account_id() - )) != candidate.account_subject_hash - { - tracing::warn!("Heimdall refresh changed local session custody"); - let mut sessions = sessions.lock().await; - if let Err(revoke_error) = sessions.revoke_cookie_hash(&candidate.cookie_hash) { - return Err(revoke_error) - .context("changed Heimdall custody could not revoke local session"); - } - continue; - } - let mut sessions = sessions.lock().await; - if let Err(error) = - sessions.apply_refresh(&candidate.cookie_hash, candidate.access_revision, verified) - { - if !sessions.is_healthy() { - return Err(error).context("app-session refresh commit lost custody"); - } - tracing::warn!(%error, "local app session refused Heimdall refresh"); - // The refresh claim is spent upstream, so a receipt that went - // backwards leaves nothing to retry. A session that merely moved - // is newer state: the next scan decides. - if error.downcast_ref::() - == Some(&RefreshRefusal::RevisionWentBackwards) - && let Err(revoke_error) = sessions.revoke_cookie_hash(&candidate.cookie_hash) - { - return Err(revoke_error) - .context("backwards Heimdall refresh could not revoke local session"); - } - } - } - Ok(()) -} - -/// Idunn publishes Expected and starts the unit before it observes the -/// running process on a later tick and publishes the activation -/// (Idunn/src/control_plane.rs). Until that activation exists, Odin refuses -/// every Warming presence this daemon publishes ("no exact current -/// activation and provider anchor"). This is the bound both the initial -/// Warming publish retries against and `wait_for_write_lease`'s own timeout -/// — one constant, reused, not two. -const WRITE_LEASE_WAIT_TIMEOUT: Duration = Duration::from_secs(120); - -/// Muninn re-publishes its own Warming presence every 2s while tolerating -/// this same activation-publication gap (Muninn/crates/muninn-daemon/src/main.rs); -/// the initial-publish retry below matches that cadence. -const INITIAL_PRESENCE_RETRY_CADENCE: Duration = Duration::from_secs(2); - -/// Retries `attempt` at `cadence` until it returns `Ok` or `deadline` passes, -/// warning on every failed attempt and returning the last error once the -/// deadline is reached. `sleep` is a seam: production passes -/// `tokio::time::sleep`; a test can pass a no-op paired with a fake `now` so -/// the bounded-retry policy is provable without waiting the real window out. -async fn retry_until_deadline( - mut attempt: Attempt, - cadence: Duration, - deadline: Instant, - what: &str, - mut now: impl FnMut() -> Instant, - mut sleep: Sleep, -) -> anyhow::Result -where - Attempt: FnMut() -> anyhow::Result, - Sleep: FnMut(Duration) -> SleepFut, - SleepFut: std::future::Future, -{ - loop { - match attempt() { - Ok(value) => return Ok(value), - Err(error) => { - tracing::warn!(%error, "{what} failed; retrying"); - if now() >= deadline { - return Err(error).context(format!("{what} timed out")); - } - sleep(cadence).await; - } - } - } -} - -async fn initialize_production_admission( - bound_endpoint: SocketAddr, -) -> anyhow::Result> { - let Some(mut publisher) = RuntimePresencePublisher::from_environment(bound_endpoint)? else { - return Ok(None); - }; - // Each `publish_warming()` call mints a fresh publisher_sequence and - // observed_at_unix_millis (idunn_health.rs's `signed_record`), so a - // retried publish is a new message Odin evaluates, never a duplicate it - // drops. - let warming = retry_until_deadline( - || publisher.publish_warming(), - INITIAL_PRESENCE_RETRY_CADENCE, - Instant::now() + WRITE_LEASE_WAIT_TIMEOUT, - "publishing initial Warming runtime presence", - Instant::now, - |duration| tokio::time::sleep(duration), - ) - .await?; - let write_lease = publisher - .wait_for_write_lease(&warming, WRITE_LEASE_WAIT_TIMEOUT) - .await - .with_context(|| { - format!( - "waiting for process lease bound to Warming {}", - warming.canonical_sha256() - ) - })?; - Ok(Some(ProductionAdmission { - health: publisher, - write_lease, - })) -} - -async fn maintain_runtime_health(state: AppState) -> anyhow::Result<()> { - let mut interval = tokio::time::interval(Duration::from_secs(10)); - interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); - loop { - interval.tick().await; - canonical_readiness(&state) - .await - .context("runtime is not ready for signed health")?; - let Some(owner) = state.runtime_health.clone() else { - continue; - }; - owner - .write_lease - .require_current() - .context("runtime process write lease is not current")?; - let mut publisher = owner.publisher.clone().lock_owned().await; - let published = tokio::task::spawn_blocking(move || { - publisher.republish_active()?; - Ok::<(), anyhow::Error>(()) - }) - .await; - let Ok(result) = published else { - tracing::warn!("runtime-presence worker panicked; publication will retry"); - continue; - }; - if let Err(error) = result { - tracing::warn!(%error, "signed runtime-presence publication failed"); - } - } -} - -fn candidate_bind() -> anyhow::Result { - match std::env::var(IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT) { - Ok(value) => value - .parse() - .context("Idunn candidate bind is not a socket address"), - Err(_) => { - #[cfg(target_os = "linux")] - bail!("{IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT} is mandatory on Linux"); - #[cfg(not(target_os = "linux"))] - return Ok("127.0.0.1:8831".parse()?); - } - } -} - -fn configured_odin_endpoint() -> anyhow::Result { - std::env::var("GHOSTLIGHT_ODIN_RUDP") - .context("GHOSTLIGHT_ODIN_RUDP is required")? - .parse() - .context("GHOSTLIGHT_ODIN_RUDP is not a socket address") -} - -fn require_current_write_lease(lease: Option<&ProcessWriteLeaseGuard>) -> anyhow::Result<()> { - match lease { - Some(lease) => lease.require_current(), - None => Ok(()), - } -} - -fn require_no_runtime_custody_failure( - failures: &mut mpsc::UnboundedReceiver, -) -> anyhow::Result<()> { - match failures.try_recv() { - Ok(detail) => bail!("runtime custody failed before serving: {detail}"), - Err(mpsc::error::TryRecvError::Empty) => Ok(()), - Err(mpsc::error::TryRecvError::Disconnected) => { - bail!("runtime custody signal channel closed before serving") - } - } -} - -fn default_runtime_root() -> PathBuf { - #[cfg(windows)] - { - PathBuf::from(r"F:\GameCult\GhostlightDungeon") - } - #[cfg(not(windows))] - { - PathBuf::from("/var/lib/gamecult/ghostlight-dungeon") - } -} - -fn admitted_runtime_root(binding: Option) -> anyhow::Result { - let Some(binding) = binding else { - #[cfg(target_os = "linux")] - bail!("--state-root is mandatory for the managed Linux runtime"); - #[cfg(not(target_os = "linux"))] - return Ok(default_runtime_root()); - }; - ensure!(binding.is_absolute(), "state-root binding is not absolute"); - let metadata = fs::symlink_metadata(&binding) - .with_context(|| format!("inspecting state-root binding {}", binding.display()))?; - ensure!( - metadata.is_dir() && !metadata.file_type().is_symlink(), - "state-root binding is not a direct directory" - ); - let canonical = fs::canonicalize(&binding) - .with_context(|| format!("canonicalizing state-root binding {}", binding.display()))?; - ensure!( - canonical == binding, - "state-root binding is indirect or non-canonical" - ); - Ok(binding) -} - -fn prepare_admitted_state_layout(runtime_root: &std::path::Path) -> anyhow::Result<()> { - let rebound = admitted_runtime_root(Some(runtime_root.to_owned()))?; - ensure!( - rebound == runtime_root, - "state-root binding changed after process-write-lease admission" - ); - let service_root = runtime_root.join("service"); - match fs::symlink_metadata(&service_root) { - Ok(metadata) => ensure!( - metadata.is_dir() && !metadata.file_type().is_symlink(), - "service state directory is indirect or not a directory" - ), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - fs::create_dir(&service_root).with_context(|| { - format!( - "creating direct service state directory {}", - service_root.display() - ) - })?; - } - Err(error) => return Err(error).context("inspecting service state directory"), - } - ensure_direct_state_directory(&service_root, "service state directory")?; - for path in [ - runtime_root.join("world.cc"), - service_root.join("app-sessions-v2.cc"), - service_root.join("controller-work.cc"), - service_root.join("mesh-v2.cc"), - // PA.f140: `open_play` writes the play table's own row here - // (`service_root.join("play-turn-v1.cc")`), the same path this - // pre-flight check must prove is a direct, non-symlinked, single- - // linked regular file before anything is admitted to write it — - // this list was never updated when Cut 8b added that store. - service_root.join("play-turn-v1.cc"), - ] { - require_direct_state_file_or_absent(&path)?; - require_direct_state_file_or_absent(&sibling_state_lock_path(&path))?; - } - Ok(()) -} - -fn ensure_direct_state_directory(path: &std::path::Path, label: &str) -> anyhow::Result<()> { - let metadata = fs::symlink_metadata(path) - .with_context(|| format!("inspecting {label} {}", path.display()))?; - ensure!( - metadata.is_dir() && !metadata.file_type().is_symlink(), - "{label} is indirect or not a directory" - ); - Ok(()) -} - -fn require_direct_state_file_or_absent(path: &std::path::Path) -> anyhow::Result<()> { - match fs::symlink_metadata(path) { - Ok(metadata) => { - ensure!( - metadata.is_file() && !metadata.file_type().is_symlink(), - "declared state path {} is indirect or not a regular file", - path.display() - ); - #[cfg(target_os = "linux")] - ensure!( - std::os::unix::fs::MetadataExt::nlink(&metadata) == 1, - "declared state path {} is multiply linked", - path.display() - ); - Ok(()) - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => { - Err(error).with_context(|| format!("inspecting declared state path {}", path.display())) - } - } -} - -fn sibling_state_lock_path(path: &std::path::Path) -> PathBuf { - let mut name = path - .file_name() - .map(|value| value.to_os_string()) - .unwrap_or_else(|| "state.cc".into()); - name.push(".lock"); - path.with_file_name(name) -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Small enough that a test can hold every permit and prove a route does - /// not cross the provider boundary. - const TEST_CONTROLLER_CONCURRENCY: usize = 2; - use crate::idunn_health::tests::route_observation_fixture; - use axum::{ - body::{Body, to_bytes}, - http::Request, - }; - use cultnet_rs::{ - GameCultRuntimePresenceHealthRecord, RuntimePresenceAuthenticationContext, - authenticate_runtime_presence_claim, - }; - use ghostlight::{InferenceFault, InferencePurpose}; - use tower::ServiceExt; - - /// A fake clock so the retry-until-deadline policy is provable without - /// waiting the real window out: `now()` only advances when the test's - /// `sleep` seam is called, exactly as it would in production (time - /// passes because we waited the cadence), but instantly here. - struct FakeClock { - now: std::cell::Cell, - } - - impl FakeClock { - fn new() -> Self { - Self { - now: std::cell::Cell::new(Instant::now()), - } - } - fn now(&self) -> Instant { - self.now.get() - } - fn advance(&self, by: Duration) { - self.now.set(self.now.get() + by); - } - } - - /// S2 (a): refused, then refused, then accepted — the loop must retry - /// past the refusals and return the value the successful attempt - /// produced, not an error. - #[tokio::test] - async fn retry_until_deadline_returns_the_first_accepted_value() { - let clock = FakeClock::new(); - let deadline = clock.now() + Duration::from_secs(120); - let mut calls = 0; - let result = retry_until_deadline( - || { - calls += 1; - if calls < 3 { - Err(anyhow::anyhow!("refused (attempt {calls})")) - } else { - Ok(format!("accepted on attempt {calls}")) - } - }, - Duration::from_secs(2), - deadline, - "test publish", - || clock.now(), - |cadence| { - clock.advance(cadence); - std::future::ready(()) - }, - ) - .await; - assert_eq!(result.unwrap(), "accepted on attempt 3"); - assert_eq!(calls, 3); - } - - /// S2 (b): always refused fails once the fake clock crosses the - /// deadline, carrying the last attempt's own error. - #[tokio::test] - async fn retry_until_deadline_fails_with_the_last_error_after_the_window() { - let clock = FakeClock::new(); - let deadline = clock.now() + Duration::from_secs(10); - let mut calls = 0; - let result: anyhow::Result<()> = retry_until_deadline( - || { - calls += 1; - Err(anyhow::anyhow!("refused (attempt {calls})")) - }, - Duration::from_secs(2), - deadline, - "test publish", - || clock.now(), - |cadence| { - clock.advance(cadence); - std::future::ready(()) - }, - ) - .await; - let error = result.unwrap_err(); - assert!(format!("{error:#}").contains("test publish timed out")); - assert!(format!("{error:#}").contains("refused (attempt")); - // Five 2s cadences cross a 10s deadline (0, 2, 4, 6, 8, 10 -> stop). - assert_eq!(calls, 6); - } - - #[cfg(target_os = "linux")] - #[test] - fn managed_linux_runtime_requires_the_admitted_state_root_binding() { - assert!(admitted_runtime_root(None).is_err()); - } - - /// PA.f179's own direct proof, independent of the HTTP round trip: an - /// absent expected token (the mint defeated, or any other path that - /// leaves `question.token` `None`) must refuse every `given`, including - /// `None` and `Some("")` — the exact values a naive fallback - /// (`unwrap_or_default()` on either side, or a plain string compare) - /// would treat as matching. - /// - /// Mutation: replace `answer_token_admits`'s body with - /// `given.unwrap_or_default() == expected.unwrap_or_default()` — the - /// `given: None, expected: None` and `given: Some(""), expected: None` - /// cases below would then wrongly admit. - #[test] - fn answer_token_admits_only_two_present_and_equal_tokens() { - assert!(answer_token_admits(Some("tok-a"), Some("tok-a"))); - assert!(!answer_token_admits(Some("tok-a"), Some("tok-b"))); - assert!(!answer_token_admits(None, Some("tok-a"))); - assert!( - !answer_token_admits(None, None), - "a question whose own token was never minted must refuse even an answer with no token at all" - ); - assert!( - !answer_token_admits(Some(""), None), - "an empty-string answer must not be treated as matching an unminted (None) token" - ); - assert!( - !answer_token_admits(Some("tok-a"), None), - "any given token must be refused when none was ever minted for the question" - ); - } - - #[test] - fn admitted_state_root_must_name_the_direct_canonical_directory() { - let directory = tempfile::tempdir().unwrap(); - let root = fs::canonicalize(directory.path()).unwrap(); - assert_eq!(admitted_runtime_root(Some(root.clone())).unwrap(), root); - assert!(admitted_runtime_root(Some(directory.path().join("missing"))).is_err()); - } - - #[test] - fn post_lease_state_layout_creates_only_the_direct_declared_directory() { - let directory = tempfile::tempdir().unwrap(); - let root = fs::canonicalize(directory.path()).unwrap(); - prepare_admitted_state_layout(&root).unwrap(); - let service = fs::symlink_metadata(root.join("service")).unwrap(); - assert!(service.is_dir()); - assert!(!service.file_type().is_symlink()); - } - - #[cfg(target_os = "linux")] - #[test] - fn post_lease_state_layout_rejects_symlinks_and_hardlinks() { - use std::os::unix::fs::symlink; - - let symlink_directory = tempfile::tempdir().unwrap(); - let symlink_root = fs::canonicalize(symlink_directory.path()).unwrap(); - let target = symlink_root.join("other.cc"); - fs::write(&target, b"not-world").unwrap(); - symlink(&target, symlink_root.join("world.cc")).unwrap(); - assert!(prepare_admitted_state_layout(&symlink_root).is_err()); - - let hardlink_directory = tempfile::tempdir().unwrap(); - let hardlink_root = fs::canonicalize(hardlink_directory.path()).unwrap(); - let target = hardlink_root.join("other.cc"); - fs::write(&target, b"not-world").unwrap(); - fs::hard_link(&target, hardlink_root.join("world.cc")).unwrap(); - assert!(prepare_admitted_state_layout(&hardlink_root).is_err()); - } - - /// PA.f140: `open_play` writes the play table's own row to - /// `service/play-turn-v1.cc` (see its own call site), so the pre-flight - /// layout check must cover that path exactly like `world.cc`, - /// `app-sessions-v2.cc`, `controller-work.cc`, and `mesh-v2.cc` already - /// do — a symlink standing in for it must be refused the same way. - /// Mutation: dropping `play-turn-v1.cc` from the checked list. - #[cfg(target_os = "linux")] - #[test] - fn post_lease_state_layout_rejects_a_symlinked_play_turn_store() { - use std::os::unix::fs::symlink; - - let directory = tempfile::tempdir().unwrap(); - let root = fs::canonicalize(directory.path()).unwrap(); - fs::create_dir(root.join("service")).unwrap(); - let target = root.join("service").join("other.cc"); - fs::write(&target, b"not-play-turn").unwrap(); - symlink(&target, root.join("service").join("play-turn-v1.cc")).unwrap(); - assert!(prepare_admitted_state_layout(&root).is_err()); - } - - struct Fixture { - _directory: tempfile::TempDir, - state: AppState, - cookie: String, - /// Held only by tests that swap `state.play` for a table over a - /// second store path (PA.f161's `play_world_with_a_scripted_question`): - /// keeps that store's own temp directory alive for exactly as long as - /// the swapped-in table is, never read. - _play_directory: Option, - } - - async fn fixture() -> Fixture { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [17_u8; 32]).unwrap(); - let mut sessions = - AppSessionOwner::open(directory.path().join("sessions.cc"), &key).unwrap(); - let cookie = sessions - .create_session(heimdall::VerifiedSessionAdmission::fixture( - "operator-account", - "heimdall-session", - 1, - Utc::now() + chrono::Duration::hours(1), - Utc::now() + chrono::Duration::days(1), - "fixture-refresh", - )) - .unwrap(); - let (world, _owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); - let controller_key = directory.path().join("controller.key"); - std::fs::write(&controller_key, "runtime-test-controller-key").unwrap(); - let models = ControllerModels { - projector: "gpt-5.6-luna".into(), - persona: "gpt-5.6-sol".into(), - interpreter: "gpt-5.6-terra".into(), - operational_agent: "gpt-5.6-terra".into(), - elaborator: "gpt-5.6-terra".into(), - }; - let connector = ConnectorBinding { - endpoint: "127.0.0.1:9".parse().unwrap(), - key_path: controller_key, - caller_runtime_id: "ghostlight-runtime-test".into(), - }; - let inference = open_inference(Some(connector), None, None, &models.each()).unwrap(); - let work = open_controller_work(directory.path().join("controller-work.cc")).unwrap(); - let controllers = - ControllerRunner::open(world.clone(), inference.clone(), work, models.clone()).unwrap(); - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - inference.clone(), - models.projector.clone(), - models.persona.clone(), - ) - .unwrap(); - let play = PlayTable::new( - world.clone(), - personas, - inference, - models.operational_agent.clone(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let mesh = MeshPublisher::open( - directory.path().join("mesh.cc"), - None, - MeshRuntimeIdentity::default(), - ) - .unwrap(); - let mesh_identity = MeshRuntimeIdentity::default(); - let (revisions, _) = broadcast::channel(8); - let (fatal, _fatal_events) = mpsc::unbounded_channel(); - let state = AppState { - consumer: ConsumerPort::new(world.clone()), - consumers: Arc::new(ConsumerRegistry::empty()), - world, - controllers: Some(Arc::new(controllers)), - play: Some(Arc::new(play)), - controller_permits: Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - sessions: Arc::new(Mutex::new(sessions)), - heimdall: Arc::new(HeimdallClient::fixture()), - mesh: Some(mesh), - mesh_identity, - runtime_health: None, - revisions, - fatal, - seed_vault_root: None, - }; - publish_projection(&state).await.unwrap(); - Fixture { - _directory: directory, - state, - cookie, - _play_directory: None, - } - } - - /// The daemon's refresh owner serves until it fails: a body that returns - /// at once would be read by `serve` as the owner stopping. - #[tokio::test] - async fn the_session_refresh_owner_keeps_serving() { - let fixture = fixture().await; - let ran = tokio::time::timeout( - Duration::from_millis(300), - maintain_session_refresh(fixture.state.clone()), - ) - .await; - assert!(ran.is_err(), "the refresh owner returned: {ran:?}"); - } - - /// PA.f172: `state.play` being `None` — a play table that failed to - /// open, for any reason, `PlayTurnStore::open` returning `Err` among - /// them — must be visible on `/health`, the same operator-facing surface - /// `projectionStatus`/`controllerStatus` already report through, not - /// only in a `tracing::warn!` at startup. - /// - /// Mutation: delete the `health["playStatus"] = ...` line from - /// `runtime_readiness` — `/health`'s own JSON would then carry no - /// `playStatus` key at all, and both assertions below would fail. - #[tokio::test] - async fn health_reports_play_status_from_state_play() { - let fixture = fixture().await; - let available = get(&fixture.state, &fixture.cookie, "/health").await; - assert_eq!(available["playStatus"], "ok", "{available}"); - - let mut degraded_state = fixture.state.clone(); - degraded_state.play = None; - let unavailable = get(°raded_state, &fixture.cookie, "/health").await; - assert_eq!(unavailable["playStatus"], "unavailable", "{unavailable}"); - } - - /// PA.f186: `state.play.is_some()` alone used to decide `playStatus`, so - /// a `PlayTable::new` that succeeded by *retiring* an unreadable row - /// (PA.f184/PA.f177's own `retire_unreadable_row`) read as plain `"ok"` - /// on `/health` — the same surface `health_reports_play_status_from_state_play` - /// above already covers for the *unavailable* case — even though the - /// player's own turn had just been destroyed, with only a - /// `tracing::warn!` as evidence. Builds a play table over a store this - /// process's own `open` cannot read (`play::tests::seed_unreadable_row_for_test`, - /// the exact shape `play::tests::a_row_with_the_current_schema_id_and_undecodable_bytes_is_retired_to_a_sidecar_and_play_starts_fresh` - /// proves retires cleanly) and checks the field end to end, through the - /// real `/health` route. - /// - /// Mutation: revert `play_status` to `match &state.play { Some(_) => - /// "ok", None => "unavailable" }` — this test's own `assert_ne!` and - /// `contains("retired")` both then fail, since a successful retirement - /// would read as plain `"ok"` again. - #[tokio::test] - async fn health_reports_a_retirement_not_plain_ok() { - use crate::play::tests::{ScriptedPort, seed_unreadable_row_for_test}; - - let mut fixture = fixture().await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - seed_unreadable_row_for_test(&store_path); - - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - &store_path, - ) - .expect("opening over an unreadable row must retire it, not fail outright (PA.f184)"); - fixture.state.play = Some(Arc::new(table)); - fixture._play_directory = Some(directory); - - let health = get(&fixture.state, &fixture.cookie, "/health").await; - let play_status = health["playStatus"].as_str().unwrap(); - assert_ne!(play_status, "ok", "a retirement must not read as plain ok: {health}"); - assert!( - play_status.contains("retired"), - "the retirement must be nameable from the readiness surface itself: {health}" - ); - assert!( - play_status.contains("play-turn-v1.retired-unreadable-row"), - "the reported status must name the sidecar path, not just that something happened: {health}" - ); - } - - /// PA.f193-E: `playStatus` used to report a retirement only for the - /// process that performed it — `PlayTable::retired_row_sidecar` cached a - /// field set once at `open`, so any later restart's own `open` found a - /// clean store (nothing left to retire) and reported plain `ok` again, - /// even though the sidecar file a prior process wrote was still sitting - /// on disk right beside the store with the only trace of what happened. - /// This reopens a *second* `PlayTable` over the exact same `store_path` - /// after the first retirement already ran (the shape a real daemon - /// restart takes) and proves `/health` still names the retirement. - /// - /// Mutation: revert `PlayTable::retired_row_sidecar` to return a field - /// cached at construction (`self.retired_sidecar.as_deref()`) instead of - /// scanning `retired_row_sidecars_on_disk` fresh — the second table's own - /// `open` retires nothing (the row is already fresh from the first - /// table's retirement), so the cached field would read `None` and the - /// assertion below fails. - #[tokio::test] - async fn health_reports_a_retirement_across_a_restart_not_only_the_process_that_performed_it() { - use crate::play::tests::{ScriptedPort, seed_unreadable_row_for_test}; - - let mut fixture = fixture().await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - seed_unreadable_row_for_test(&store_path); - - // First "process": its own `open` performs the retirement. - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let first_table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - &store_path, - ) - .expect("opening over an unreadable row must retire it, not fail outright (PA.f184)"); - assert!( - first_table.retired_row_sidecar().is_some(), - "the table that actually performed the retirement must report it" - ); - // Dropped here: the redb `Database`'s own single-owner lock must - // release before a second `PlayTable` can open the same path. - } - - // A fresh "process": opens the same, now-clean store. Nothing here - // retires anything — the row `first_table` left behind already reads - // fine — so this is exactly a restart, not a second corruption. - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let second_table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - &store_path, - ) - .expect("reopening the already-retired store must succeed cleanly"); - fixture.state.play = Some(Arc::new(second_table)); - fixture._play_directory = Some(directory); - - let health = get(&fixture.state, &fixture.cookie, "/health").await; - let play_status = health["playStatus"].as_str().unwrap(); - assert_ne!( - play_status, "ok", - "a restart must still surface a retirement a prior process performed, not read as plain ok: {health}" - ); - assert!( - play_status.contains("retired-unreadable-row"), - "the restarted process must still name the sidecar evidence on disk: {health}" - ); - } - - /// PA.f187: a seed-route refusal must never carry `ControllerError`'s own - /// `Display` to the player — Soul measured the real string on a fresh - /// workstation with no inference endpoint running: "invalid command - /// payload: OperationalAgent inference failed: the local inference - /// endpoint refused the connection: error sending request for url - /// (http://127.0.0.1:11434/v1/chat/completions)", handing the player this - /// process's own inference host and port. `describe_seed_failure` must - /// name a category instead, for the connectivity class this actually - /// happens on, without reproducing the endpoint anywhere in its output. - /// - /// Mutation: change `describe_seed_failure`'s `Inference { .. } | - /// ProviderContract { .. }` arm to `_ => error.to_string()` (or any arm - /// that forwards `error`'s own `Display`) — the assertions below, which - /// specifically check the category string never contains the raw fault - /// detail, then fail. - #[test] - fn describe_seed_failure_never_reproduces_the_inference_faults_own_detail() { - let raw_detail = "the local inference endpoint refused the connection: error sending request \ - for url (http://127.0.0.1:11434/v1/chat/completions)"; - let error = ControllerError::Inference { - purpose: InferencePurpose::OperationalAgent, - source: InferenceFault::retryable(raw_detail), - }; - // The raw error's own Display really does carry the host and port — - // pinning that here so this test fails loudly if `InferenceFault`'s - // own `Display` ever stops reproducing its `detail` verbatim, rather - // than silently testing nothing. - assert!(error.to_string().contains("127.0.0.1:11434"), "{error}"); - - let described = describe_seed_failure(&error); - assert!(!described.contains("127.0.0.1"), "the category must not leak the endpoint: {described}"); - assert!(!described.contains("11434"), "the category must not leak the port: {described}"); - assert!(!described.contains("refused the connection"), "{described}"); - assert_eq!(described, "seeding could not reach the model"); - - // A non-connectivity `ControllerError` still gets a category, not a - // raw `Display`, even though Soul's own scenario was specifically - // about the inference class. - let other = describe_seed_failure(&ControllerError::AmbiguousOpportunity); - assert_eq!(other, "seeding could not complete"); - } - - fn route_snapshot_request( - message_id: &str, - schema_ids: Option>, - record_keys: Option>, - ) -> Vec { - encode_cultnet_message_to_vec( - &CultNetMessage::SnapshotRequest { - message_id: message_id.into(), - schema_ids, - record_keys, - }, - CultNetWireContract::CultNetSchemaV0, - ) - .unwrap() - } - - #[test] - fn route_observation_request_is_one_exact_canonical_record() { - let exact = route_snapshot_request( - "route-challenge-41", - Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), - Some(vec![GHOSTLIGHT_TARGET.into()]), - ); - assert_eq!( - exact_route_observation_message_id(&exact).unwrap(), - "route-challenge-41" - ); - - for refused in [ - route_snapshot_request("broad", None, None), - route_snapshot_request( - "foreign-schema", - Some(vec!["gamecult.other.v1".into()]), - Some(vec![GHOSTLIGHT_TARGET.into()]), - ), - route_snapshot_request( - "foreign-record", - Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), - Some(vec!["other-target".into()]), - ), - ] { - assert!(exact_route_observation_message_id(&refused).is_err()); - } - } - - /// The consumer door's two transport gates, the same two - /// `/cultnet/snapshot` established. Everything past them belongs to - /// `ghostlight`'s `consumer`, which is tested there. - #[tokio::test] - async fn a_non_loopback_peer_is_forbidden_and_a_wrong_content_type_is_unsupported() { - let fixture = fixture().await; - let request = |peer: &str, content_type: &str| { - Request::builder() - .method("POST") - .uri("/cultnet/world-patch") - .extension(ConnectInfo(peer.parse::().unwrap())) - .header(header::CONTENT_TYPE, content_type) - .body(Body::from(Vec::new())) - .unwrap() - }; - - let remote = api_router(fixture.state.clone()) - .oneshot(request("192.0.2.9:39001", "application/msgpack")) - .await - .unwrap(); - assert_eq!(remote.status(), StatusCode::FORBIDDEN); - - let wrong_media = api_router(fixture.state.clone()) - .oneshot(request("127.0.0.1:39001", "application/json")) - .await - .unwrap(); - assert_eq!(wrong_media.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - - // A loopback peer with the right media type reaches the ingress, which - // refuses an empty frame with a receipt rather than a status. - let admitted = api_router(fixture.state.clone()) - .oneshot(request("127.0.0.1:39001", "application/msgpack")) - .await - .unwrap(); - assert_eq!(admitted.status(), StatusCode::OK); - } - - #[tokio::test] - async fn http_route_probe_bypasses_provider_and_enforces_exact_admission() { - let mut fixture = fixture().await; - let body = route_snapshot_request( - "route-challenge-42", - Some(vec![GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA.into()]), - Some(vec![GHOSTLIGHT_TARGET.into()]), - ); - let request = |peer: &str, content_type: Option<&str>, body: Vec| { - let mut builder = Request::builder() - .method("POST") - .uri("/cultnet/snapshot") - .extension(ConnectInfo(peer.parse::().unwrap())); - if let Some(content_type) = content_type { - builder = builder.header(header::CONTENT_TYPE, content_type); - } - builder.body(Body::from(body)).unwrap() - }; - - let unmanaged = api_router(fixture.state.clone()) - .oneshot(request( - "127.0.0.1:39001", - Some("application/msgpack"), - body.clone(), - )) - .await - .unwrap(); - assert_eq!(unmanaged.status(), StatusCode::SERVICE_UNAVAILABLE); - - let remote = api_router(fixture.state.clone()) - .oneshot(request( - "192.0.2.9:39001", - Some("application/msgpack"), - body.clone(), - )) - .await - .unwrap(); - assert_eq!(remote.status(), StatusCode::FORBIDDEN); - - let wrong_media = api_router(fixture.state.clone()) - .oneshot(request( - "127.0.0.1:39001", - Some("application/json"), - body.clone(), - )) - .await - .unwrap(); - assert_eq!(wrong_media.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); - - let broad = api_router(fixture.state.clone()) - .oneshot(request( - "127.0.0.1:39001", - Some("application/msgpack"), - route_snapshot_request("broad", None, None), - )) - .await - .unwrap(); - assert_eq!(broad.status(), StatusCode::BAD_REQUEST); - - let health = route_observation_fixture(fixture._directory.path()).unwrap(); - let authority = health.authority; - let write_lease_sha256 = health.write_lease.canonical_sha256().to_owned(); - fixture.state.runtime_health = Some(RuntimeHealthOwner { - publisher: Arc::new(Mutex::new(health.publisher)), - write_lease: Arc::new(health.write_lease), - }); - - // If this route enters the controller/provider boundary, the exhausted - // permit pool makes the request time out. Route admission and signing - // need only the canonical health owner and managed runtime authority. - let permits = fixture.state.controller_permits.clone(); - let _provider_execution_barrier = permits - .acquire_many(u32::try_from(TEST_CONTROLLER_CONCURRENCY).expect("a small pool")) - .await - .expect("the controller permit pool is open"); - let response = tokio::time::timeout( - Duration::from_secs(2), - api_router(fixture.state.clone()).oneshot(request( - "127.0.0.1:39001", - Some("application/msgpack"), - body, - )), - ) - .await - .expect("route observation entered provider execution") - .unwrap(); - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response.headers().get(header::CONTENT_TYPE), - Some(&HeaderValue::from_static("application/msgpack")) - ); - let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); - let message = - decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0) - .unwrap(); - assert_eq!( - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0).unwrap(), - bytes - ); - let CultNetMessage::SnapshotResponseRaw { - message_id, - documents, - } = message - else { - panic!("route observation was not a raw snapshot response"); - }; - assert_eq!(message_id, "route-challenge-42"); - let [document] = documents.as_slice() else { - panic!("route observation did not contain exactly one document"); - }; - assert_eq!(document.schema_id, GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA); - assert_eq!(document.record_key, GHOSTLIGHT_TARGET); - let presence: GameCultRuntimePresenceHealthRecord = - rmp_serde::from_slice(&document.payload).unwrap(); - assert_eq!(presence.state, "active"); - assert_eq!(presence.detail, "route-observation:route-challenge-42"); - assert_eq!( - presence.write_lease_sha256.as_deref(), - Some(write_lease_sha256.as_str()) - ); - authenticate_runtime_presence_claim( - &document.payload, - &authority, - RuntimePresenceAuthenticationContext { - trusted_received_at_unix_millis: presence.observed_at_unix_millis, - maximum_age_millis: 1_000, - maximum_future_skew_millis: 10, - }, - ) - .unwrap(); - } - - fn invocation(operation: &str, schema: &str, version: u64, payload: Value, id: &str) -> Value { - json!({ - "schema":"gamecult.eve.command_invocation.v1", - "providerId":mesh::PROVIDER_ID, - "surfaceId":mesh::SURFACE_ID, - "operation":{ - "operationId":operation, - "schemaId":schema, - "idempotencyKey":id, - "routeHint":{"sourceVersion":version,"transport":"https-json"} - }, - "payload":payload, - "issuedAt":Utc::now().to_rfc3339(), - "clientId":"runtime-test", - "commandBoundary":mesh::COMMAND_BOUNDARY, - "receiptSchema":mesh::COMMAND_RESULT_SCHEMA - }) - } - - async fn post(state: &AppState, cookie: &str, body: Value) -> Value { - let response = api_router(state.clone()) - .oneshot( - Request::builder() - .method("POST") - .uri("/api/eve/commands") - .header(header::CONTENT_TYPE, "application/json") - .header(header::COOKIE, format!("{COOKIE_NAME}={cookie}")) - .body(Body::from(serde_json::to_vec(&body).unwrap())) - .unwrap(), - ) - .await - .unwrap(); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - serde_json::from_slice(&bytes).unwrap() - } - - async fn get(state: &AppState, cookie: &str, path: &str) -> Value { - let response = api_router(state.clone()) - .oneshot( - Request::builder() - .method("GET") - .uri(path) - .header(header::COOKIE, format!("{COOKIE_NAME}={cookie}")) - .body(Body::empty()) - .unwrap(), - ) - .await - .unwrap(); - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - serde_json::from_slice(&bytes).unwrap() - } - - /// Cut 10 (PA.f149): drives the real vendored Eve browser lowering - /// (`vendor/eve/packages/eve-browser-lowering`, submodule pin unmoved) - /// over a surface document this process actually served, through - /// `tools/eve_client_bridge.mjs`. `steps` is the bridge's own - /// `{"set":{node_id:text}}`/`{"click":node_id}` step list; returns the - /// command intent(s) the click(s) produced, in order, exactly as a real - /// client would build and send them — never a hand-built payload - /// asserting its own shape is correct. - fn client_intents(surface: &Value, provider: &Value, steps: Value) -> Vec { - let bridge = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("tools") - .join("eve_client_bridge.mjs"); - let input = serde_json::to_vec(&json!({ - "surface": surface, - "provider": provider, - "steps": steps, - })) - .unwrap(); - let mut child = std::process::Command::new("node") - .arg(&bridge) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() - .expect("node must be on PATH to run the client bridge"); - { - use std::io::Write; - child.stdin.take().unwrap().write_all(&input).unwrap(); - } - let output = child.wait_with_output().unwrap(); - assert!( - output.status.success(), - "eve_client_bridge.mjs failed: {}", - String::from_utf8_lossy(&output.stderr) - ); - String::from_utf8(output.stdout) - .unwrap() - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| serde_json::from_str(line).unwrap()) - .collect() - } - - /// PA.f149's own end-to-end proof: `world.create`, `world.approve`, - /// `world.seed`, and `world.activate` intents, built by the real - /// vendored lowering over surfaces this process actually served, and - /// posted through `api_router` — every one of them accepted. Before - /// this cut, every one of them submitted `{"bindings":{}}` (wrong - /// binding names) and, even with the names fixed, `{"payload": - /// {"bindings":{...}}}` (the envelope) against Dungeon's flat payload - /// structs — either alone was enough to refuse every field as missing. - /// - /// `world.play` is driven twice through the real client: typed text on a - /// fresh Active world opens a turn (held `Running` by an inference port - /// that blocks), and after a restart over the same store an untouched - /// Play box continues that `Running` turn to its close. No vault is - /// configured, so the real client is never offered a seed to click. - /// - /// (Soul's owed item 3): `#[ignore]`d, not skipped-with-a-print. Before - /// this, an unavailable `node`/vendor-lowering environment made this - /// test return early and count as an ordinary pass — cargo's own summary - /// line reported the identical "178 passed" whether or not this actually - /// drove the real Eve client bridge, and only `--nocapture` surfaced the - /// difference (the `eprintln!` above was otherwise captured and - /// discarded on a passing test). `#[ignore]` makes cargo's own default - /// summary distinguish the two: an ordinary `cargo test` run reports this - /// (and its three siblings gated the same way) under "X ignored", visible - /// with no extra flags, and never executes the body at all. Run - /// `cargo test -p ghostlight-dungeon --bin ghostlight-dungeon -- --ignored` - /// where `node` is on PATH and both `npm install --prefix - /// vendor/eve/packages/eve-browser-lowering` and `npm install --prefix - /// vendor/eve/packages/eve-contracts` have been run, then `npm run build - /// --prefix vendor/eve/packages/eve-browser-lowering`, to actually - /// exercise it. The cost: this no longer auto-runs on a workstation that - /// happens to have those dependencies installed — opting in is now - /// explicit, and asking for it without the dependencies present now - /// panics instead of silently returning, since a deliberate `--ignored` - /// run that still cannot drive the real bridge is a real failure to - /// report, not a skip to swallow quietly. - #[tokio::test] - #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn world_create_seed_activate_and_play_round_trip_through_the_real_client() { - if !eve_client_bridge_is_available() { - panic!( - "world_create_seed_activate_and_play_round_trip_through_the_real_client was run \ - (via --ignored) but the real Eve client bridge is not available: `node` is \ - missing, or the vendored lowering's built `dist/index.js`, its `jsdom` \ - devDependency, or the sibling `eve-contracts` package's own `ajv` dependency does \ - not resolve in this environment. Run `node` on PATH and both `npm install --prefix \ - vendor/eve/packages/eve-browser-lowering` and `npm install --prefix \ - vendor/eve/packages/eve-contracts`, then `npm run build --prefix \ - vendor/eve/packages/eve-browser-lowering`, before running this test." - ); - } - let fixture = fixture().await; - let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; - - let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let create_intents = client_intents( - &empty_surface, - &provider, - json!([ - {"set": { - "world.create.title": "Bridge World", - "world.create.brief": "A world built by the real client bridge.", - "world.create.subject": "The Bridge Operator", - "world.create.targets": "{}", - "world.create.jurisdictions": "[]", - "world.create.lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}" - }}, - {"click": "world.create"} - ]), - ); - assert_eq!(create_intents.len(), 1); - let created = post(&fixture.state, &fixture.cookie, create_intents.into_iter().next().unwrap()).await; - assert_eq!(created["receipt"]["state"], "accepted", "world.create via the real client: {created}"); - - let draft_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let approve_intents = client_intents(&draft_surface, &provider, json!([{"click": "world.approve"}])); - assert_eq!(approve_intents.len(), 1); - let approved = post(&fixture.state, &fixture.cookie, approve_intents.into_iter().next().unwrap()).await; - assert_eq!(approved["receipt"]["state"], "accepted", "world.approve via the real client: {approved}"); - - // No fixture sets `AppState.seed_vault_root`, so the Draft surface - // offers no seeding to click at all (PA.f213). - let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - assert!( - !serde_json::to_string(&approved_surface).unwrap().contains("world.seed"), - "a server with no vault offered seeding to the real client" - ); - let activate_intents = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])); - assert_eq!(activate_intents.len(), 1); - let activated = post(&fixture.state, &fixture.cookie, activate_intents.into_iter().next().unwrap()).await; - assert_eq!(activated["receipt"]["state"], "accepted", "world.activate via the real client: {activated}"); - - // A typed Play on a fresh Active world opens a turn. The first table's - // inference port blocks until released, so the turn stays `Running` - // and its row is on disk while the test looks at it. - use crate::play::tests::{GatedPort, ScriptedPort, call_event, output, text_event}; - let store_directory = tempfile::tempdir().unwrap(); - let store_path = store_directory.path().join("play-turn-v1.cc"); - let models = ["gpt-5.6-sol", "gpt-5.6-terra"]; - let world = fixture.state.world.clone(); - let table_over = |inference: Arc, narration: Arc| { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - narration, - models[0].into(), - models[0].into(), - ) - .unwrap(); - Arc::new( - PlayTable::new( - world.clone(), - personas, - inference, - models[1].into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - &store_path, - ) - .unwrap(), - ) - }; - let mut fixture = fixture; - let held = table_over( - GatedPort::new(output("held", vec![call_event("c0", "end_turn", json!({}))])), - ScriptedPort::new(vec![]), - ); - fixture.state.play = Some(held.clone()); - - let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let play_intents = client_intents( - &active_surface, - &provider, - json!([ - {"set": {"world.play.text": "I look around the hall."}}, - {"click": "world.play"} - ]), - ); - assert_eq!(play_intents.len(), 1); - assert_eq!(play_intents[0]["payload"]["bindings"]["text"], "I look around the hall."); - // The first process is a runtime of its own, dropped once the turn is - // open: the spawned round dies with it, which is what a crash does. - let (played, opened) = { - let state = fixture.state.clone(); - let cookie = fixture.cookie.clone(); - let held = held.clone(); - let intent = play_intents.into_iter().next().unwrap(); - tokio::task::spawn_blocking(move || { - let process = tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap(); - let seen = process.block_on(async { - let played = post(&state, &cookie, intent).await; - let opened = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = held.current_turn_view().await { - if view.run_in_progress { - break view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the real client's typed world.play must open a turn a round is working on"); - (played, opened) - }); - process.shutdown_timeout(Duration::from_secs(5)); - seen - }) - .await - .unwrap() - }; - assert_eq!(played["receipt"]["state"], "accepted", "typed world.play via the real client: {played}"); - assert_eq!(opened.state, PlayTurnState::Running); - fixture.state.play = None; - drop(held); - - // The next process opens the same store and finds the turn `Running` with nothing working on it. The - // client now clicks Play with the box untouched, which is exactly what - // the card's hint tells a player to do. - let restarted = table_over( - ScriptedPort::new(vec![output("r0", vec![call_event("c1", "end_turn", json!({}))])]), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The hall settles.")])]), - ); - fixture.state.play = Some(restarted.clone()); - let idle = restarted.current_turn_view().await.expect("the running turn survives the restart"); - assert_eq!(idle.state, PlayTurnState::Running); - assert!(!idle.run_in_progress); - assert_eq!(idle.turn_id, opened.turn_id); - - let running_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let continue_intents = client_intents(&running_surface, &provider, json!([{"click": "world.play"}])); - assert_eq!(continue_intents.len(), 1); - assert_eq!( - continue_intents[0]["payload"]["bindings"]["text"], "", - "an untouched Play box must reach the route as an empty continue: {}", - continue_intents[0] - ); - let continued = post(&fixture.state, &fixture.cookie, continue_intents.into_iter().next().unwrap()).await; - assert_eq!(continued["receipt"]["state"], "accepted", "untouched world.play via the real client: {continued}"); - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - let view = restarted.current_turn_view().await.unwrap(); - if view.state == PlayTurnState::Closed { - break view; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the untouched Play must continue the Running turn to its close"); - assert_eq!(closed.turn_id, opened.turn_id, "the continue opened a new turn instead of continuing"); - assert_eq!(closed.narration.as_deref(), Some("The hall settles.")); - } - - /// PA.f162: the real vendored lowering's own intents for the full - /// create/approve/seed/activate/play round trip, captured once against - /// the pinned `vendor/eve` submodule revision this document names, and - /// committed so the gate never needs `node`, `jsdom`, or the built - /// lowering to replay them. `#[serde(rename_all = "camelCase")]` matches - /// the JSON exactly; no other transformation happens between capture and - /// commit. - const EVE_CLIENT_BRIDGE_FIXTURE: &str = include_str!("fixtures/eve_client_bridge_intents.json"); - - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct BridgeFixture { - #[allow(dead_code)] - schema: String, - vendor_eve_submodule_revision: String, - steps: Vec, - } - - #[derive(Deserialize)] - struct BridgeFixtureStep { - label: String, - intent: Value, - } - - fn bridge_fixture() -> BridgeFixture { - serde_json::from_str(EVE_CLIENT_BRIDGE_FIXTURE) - .expect("the committed eve_client_bridge_intents.json fixture must parse") - } - - /// PA.f162's own gate proof: replays the committed fixture's intents, - /// in order, straight through `api_router` — no `node`, no bridge, no - /// vendored `dist/index.js` anywhere in this call path. This is what - /// `world_create_seed_activate_and_play_round_trip_through_the_real_client` - /// (which drives the real bridge, and so needs `node`) proved once, at - /// capture time; this test proves it again on every run, on the door - /// Idunn's own `cargo test --locked -p ghostlight-dungeon --bin - /// ghostlight-dungeon` gate actually opens. - #[tokio::test] - async fn the_committed_eve_client_bridge_fixture_round_trips_through_api_router_with_no_node() { - let doc = bridge_fixture(); - let fixture = fixture().await; - let mut steps = doc.steps.into_iter(); - - let create = steps.next().unwrap(); - assert_eq!(create.label, "world.create"); - let created = post(&fixture.state, &fixture.cookie, create.intent).await; - assert_eq!(created["receipt"]["state"], "accepted", "{created}"); - - let approve = steps.next().unwrap(); - assert_eq!(approve.label, "world.approve"); - let approved = post(&fixture.state, &fixture.cookie, approve.intent).await; - assert_eq!(approved["receipt"]["state"], "accepted", "{approved}"); - - let activate = steps.next().unwrap(); - assert_eq!(activate.label, "world.activate"); - let activated = post(&fixture.state, &fixture.cookie, activate.intent).await; - assert_eq!(activated["receipt"]["state"], "accepted", "{activated}"); - - let play = steps.next().unwrap(); - assert_eq!(play.label, "world.play"); - let played = post(&fixture.state, &fixture.cookie, play.intent).await; - assert_eq!(played["receipt"]["state"], "accepted", "{played}"); - assert!(steps.next().is_none(), "the fixture must carry exactly these four steps"); - - let table = fixture.state.play.clone().unwrap(); - let observed = tokio::time::timeout(Duration::from_secs(5), async move { - loop { - if table.current_turn_view().await.is_some() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await; - assert!( - observed.is_ok(), - "the fixture's own world.play intent must reach PlayTable::run and open a turn row" - ); - } - - /// Whether `node` is on `PATH` *and* the real bridge's own module graph - /// actually resolves in this environment — the things `client_intents` - /// needs. Idunn's own Linux release container has neither (PA.f162), so - /// this is `false` there. - /// - /// PA.f176: this used to check only `node --version` and that - /// `vendor/eve/packages/eve-browser-lowering/dist/index.js` is a file — - /// but `dist/` is committed, so the second half of that check is always - /// true, whether or not `npm install` has ever run anywhere. Measured on - /// a clean checkout with `node` on `PATH` and no `npm install` run: this - /// old check reported "available", and the three gated tests then failed - /// outright on `jsdom` (`tools/eve_client_bridge.mjs`'s own dependency, - /// resolved through `eve-browser-lowering`'s own `package.json`) — not - /// skipped, failed, exactly the "2 of 151 failed" shape PA.f169 already - /// fixed once for a different pair of tests. Running the runbook's own - /// `npm install --prefix vendor/eve/packages/eve-browser-lowering` step - /// still left `ajv` unresolved: `eve-browser-lowering`'s own `dist/index.js` - /// imports `@gamecult/eve-contracts` (a sibling package under - /// `vendor/eve/packages`, wired in as a `file:` dependency), and that - /// sibling's own `node_modules` — where its own `ajv` dependency - /// lives — is never populated by an `npm install` run only inside - /// `eve-browser-lowering`. - /// - /// So this now proves the dependencies the bridge actually needs by - /// doing exactly what `tools/eve_client_bridge.mjs` does at import time — - /// resolving `jsdom` through the vendored lowering's own `package.json` - /// and dynamically importing the built `dist/index.js` itself, which is - /// what actually pulls in `eve-contracts` and, through it, `ajv` — in a - /// real `node` process (`eve_client_bridge_dependencies_resolve`), rather - /// than asserting a fact about one committed file that was never in - /// question. Cached: every call after the first reuses the one real - /// probe's result rather than spawning `node` again per test. - fn eve_client_bridge_is_available() -> bool { - static AVAILABLE: std::sync::OnceLock = std::sync::OnceLock::new(); - *AVAILABLE.get_or_init(|| { - let node_on_path = std::process::Command::new("node") - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()); - if !node_on_path { - return false; - } - let lowering_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("..") - .join("..") - .join("vendor") - .join("eve") - .join("packages") - .join("eve-browser-lowering"); - if !lowering_root.join("dist").join("index.js").is_file() { - return false; - } - if !eve_client_bridge_dependencies_resolve(&lowering_root) { - return false; - } - // PA.f190: every gated test in this shape needs `node`, the - // built lowering, *and* a real `vendor/eve` git checkout — - // `eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today` - // asserts `git -C vendor/eve rev-parse HEAD` succeeds with - // `.expect(...)`, not a skip. Before this, Soul hit that - // `.expect` by accident with a `vendor/eve` that was a copied - // directory rather than a checkout — this function's own two - // checks above both passed (a copy still carries the committed - // `dist/`), and the test *panicked* instead of skipping, the - // third instance of this exact shape (see this function's own - // doc comment above). Checked last, after the cheaper node/file - // checks, since it is the least likely to fail in a normal dev - // environment and this whole probe is already cached. - vendor_eve_git_checkout_available(&repo_root()) - }) - } - - /// The workspace root two levels above this crate's own manifest — - /// The browser validates every command result against - /// `gamecult.eve.command_result.v1` before it reads a field of it, and - /// that schema is `additionalProperties: false`. A flat result — state, - /// message and ids at the top level rather than inside the receipt — is - /// therefore not a lenient dialect: it is rejected whole, and the client - /// reports "invalid Eve command result: data must not have additional - /// properties" for every command, sign-in included. - /// - /// The assertion reads the contract off disk, from the pinned - /// `vendor/eve` checkout, rather than restating it here. A test that - /// spells the allowed keys itself agrees only with its own author; this - /// one fails when the vendored contract moves under us. - #[tokio::test] - async fn a_command_result_satisfies_the_vendored_result_and_receipt_contracts() { - let fixture = fixture().await; - let denied = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.approve", - "ghostlight.world_approve.v0", - 0, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - - let schema_root = repo_root().join("vendor").join("eve").join("schemas"); - let result_schema: Value = serde_json::from_slice( - &std::fs::read(schema_root.join("gamecult.eve.command_result.v1.schema.json")) - .expect("the pinned vendor/eve checkout carries the result contract"), - ) - .expect("the result contract is JSON"); - let receipt_schema: Value = serde_json::from_slice( - &std::fs::read(schema_root.join("gamecult.eve.command_receipt.v1.schema.json")) - .expect("the pinned vendor/eve checkout carries the receipt contract"), - ) - .expect("the receipt contract is JSON"); - - assert_eq!( - result_schema["additionalProperties"], - Value::Bool(false), - "this test only means anything while the result contract is closed", - ); - let allowed: Vec<&str> = result_schema["properties"] - .as_object() - .expect("the result contract names its properties") - .keys() - .map(String::as_str) - .collect(); - for key in denied - .as_object() - .expect("a command result is an object") - .keys() - { - assert!( - allowed.contains(&key.as_str()), - "`{key}` is not a property the result contract admits: {denied}", - ); - } - - let receipt = &denied["receipt"]; - for required in receipt_schema["required"] - .as_array() - .expect("the receipt contract names its required fields") - { - let required = required.as_str().expect("a required field is named"); - assert!( - !receipt[required].is_null(), - "the receipt is missing its required `{required}`: {denied}", - ); - } - assert_eq!(receipt["state"], "denied", "{denied}"); - assert_eq!( - receipt["schema"], "gamecult.eve.command_receipt.v1", - "{denied}", - ); - } - - /// `vendor/eve` lives here, not under `CARGO_MANIFEST_DIR` itself. - fn repo_root() -> std::path::PathBuf { - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("..").join("..") - } - - /// PA.f190/PA.f193-C: every precondition - /// `eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today` - /// itself asserts must be provable by `eve_client_bridge_is_available` - /// before that test is allowed to run rather than skip — this is the one - /// `eve_client_bridge_is_available` did not check. - /// - /// `git -C vendor/eve rev-parse HEAD` alone certifies nothing: when - /// `vendor/eve` is a plain copied directory rather than a checkout, git - /// does not fail — it walks up past the missing `.git` and answers with - /// whichever *enclosing* repository it finds, which inside this checkout - /// is Ghostlight's own HEAD. Soul measured this directly: the prior form - /// of this check reported "available" for a copy sitting inside the - /// Ghostlight repo, and - /// `eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today` - /// then panicked comparing the fixture's stamped revision against the - /// parent repo's HEAD instead of skipping. - /// - /// `--show-toplevel` names which repository git actually resolved the - /// first command against. Comparing that, canonicalized, to `vendor/eve`'s - /// own canonical path is what actually distinguishes "this directory is - /// its own checkout" from "this directory sits inside someone else's". A - /// bare tempdir outside any repository and a copy sitting inside one both - /// now report unavailable, rather than only the former. - /// `repo_root` is a parameter, not hard-coded, so a test can point this - /// at a scratch directory shaped like a copied-not-checked-out - /// `vendor/eve` without touching the real submodule. - fn vendor_eve_git_checkout_available(repo_root: &std::path::Path) -> bool { - let vendor_eve = repo_root.join("vendor").join("eve"); - let Ok(canonical_vendor_eve) = vendor_eve.canonicalize() else { - return false; - }; - let Ok(toplevel_output) = std::process::Command::new("git") - .args(["-C", "vendor/eve", "rev-parse", "--show-toplevel"]) - .current_dir(repo_root) - .output() - else { - return false; - }; - if !toplevel_output.status.success() { - return false; - } - let toplevel = String::from_utf8_lossy(&toplevel_output.stdout).trim().to_owned(); - let Ok(canonical_toplevel) = std::path::Path::new(&toplevel).canonicalize() else { - return false; - }; - if canonical_toplevel != canonical_vendor_eve { - return false; - } - std::process::Command::new("git") - .args(["-C", "vendor/eve", "rev-parse", "HEAD"]) - .current_dir(repo_root) - .output() - .is_ok_and(|output| output.status.success()) - } - - /// Runs a real `node` process that does exactly what - /// `tools/eve_client_bridge.mjs` does at import time — resolve `jsdom` - /// through the vendored lowering's own `package.json`, then dynamically - /// import the built `dist/index.js` — and reports whether that succeeded. - /// A missing `jsdom`, a missing `eve-contracts`/`ajv`, or any other - /// unresolved module in that same import graph fails this the same way - /// it would fail the real bridge script, which is the whole point: this - /// is a probe built from the bridge's own actual dependency surface, not - /// a second, independently-maintained guess at what it needs. - fn eve_client_bridge_dependencies_resolve(lowering_root: &std::path::Path) -> bool { - let lowering_root_js = lowering_root.to_string_lossy().replace('\\', "/"); - let probe = format!( - "import {{ createRequire }} from \"node:module\";\n\ - import {{ join }} from \"node:path\";\n\ - const loweringRoot = \"{lowering_root_js}\";\n\ - const vendorRequire = createRequire(join(loweringRoot, \"package.json\"));\n\ - vendorRequire(\"jsdom\");\n\ - await import(\"file://\" + join(loweringRoot, \"dist\", \"index.js\").replace(/\\\\/g, \"/\"));\n" - ); - let probe_path = std::env::temp_dir().join(format!( - "ghostlight-eve-bridge-probe-{}-{}.mjs", - std::process::id(), - uuid::Uuid::new_v4() - )); - if std::fs::write(&probe_path, &probe).is_err() { - return false; - } - let outcome = std::process::Command::new("node") - .arg(&probe_path) - .output(); - let _ = std::fs::remove_file(&probe_path); - outcome.is_ok_and(|output| output.status.success()) - } - - /// PA.f190: `eve_client_bridge_is_available`'s own git precondition, - /// isolated from the `node`/`dist` checks it also runs. Requires `git` on - /// `PATH` (not `node`), so it is not itself gated by - /// `eve_client_bridge_is_available`. - /// - /// Mutation: delete the `vendor_eve_git_checkout_available(&repo_root())` - /// call from `eve_client_bridge_is_available` (or its early return) — a - /// `vendor/eve` that is present but not a checkout (this test's own - /// `copied` case) would then still report "available", exactly the shape - /// Soul hit by accident and this test's own negative assertion catches. - /// - /// PA.f194-C: the positive case is gated on `vendor/eve/.git` actually - /// existing, not merely on `git` being on `PATH`. Idunn's own frozen- - /// source gate (`cargo test --locked -p ghostlight-dungeon --bin - /// ghostlight-dungeon`, the exact command this repo's Yggdrasil runbook - /// documents) materializes both the main tree and every Gitlink — - /// `vendor/eve` included — byte for byte from the Git object store - /// (`Idunn::drivers::materialize_tree_raw`/`materialize_gitlink_raw`), - /// and that writer refuses to ever write `.git` metadata - /// ("Git tree contains forbidden .git metadata", enforced on both the - /// main tree and every Gitlink target). So inside Idunn's own gate, - /// `vendor/eve` is unconditionally the "copied, not cloned" shape this - /// test's negative case below proves unavailable — not a bug in the - /// check, and not a shape this test's positive case should assert - /// against. Checking `vendor/eve/.git` directly, rather than trusting - /// `vendor_eve_git_checkout_available`'s own verdict, keeps this - /// ground truth independent of the function under test. - #[test] - fn vendor_eve_git_checkout_available_distinguishes_a_checkout_from_a_copy() { - // The real repo root: on a normal developer checkout `vendor/eve` is - // a genuine git checkout (pinned submodule, `.git` is a gitlink file - // pointing at the superproject's `.git/modules/...`), so this must - // report true whenever `git` itself is on `PATH`. Inside Idunn's own - // frozen-source gate `vendor/eve` carries no `.git` at all by design - // (see the PA.f194-C doc comment above) — that is exactly the copy - // shape this test's negative case proves unavailable, so the positive - // assertion is skipped there instead of failing the gate. - let vendor_eve_has_git_metadata = repo_root().join("vendor").join("eve").join(".git").exists(); - let git_on_path = std::process::Command::new("git") - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()); - if !vendor_eve_has_git_metadata { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_distinguishes_a_checkout_from_a_copy's own \ - positive case: vendor/eve/.git does not exist here. Inside Idunn's own frozen-source \ - gate this is expected (PA.f194-C, see the doc comment above) — the writer that \ - materializes vendor/eve there deliberately never writes .git metadata. On a normal \ - developer checkout this means the vendor/eve submodule was never initialized; run \ - `git submodule update --init vendor/eve`." - ); - } else if git_on_path { - assert!( - vendor_eve_git_checkout_available(&repo_root()), - "vendor/eve carries .git metadata but still reports unavailable" - ); - } else { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_distinguishes_a_checkout_from_a_copy's own \ - positive case: `git` is not on PATH." - ); - } - - // A `vendor/eve` that exists as a plain copied directory — carrying - // real files (this test writes one) but no `.git` — is exactly the - // shape Soul hit by accident: `dist/index.js` can still be present - // (committed and copied along with everything else), so this is the - // one check standing between that shape and a false "available". - let scratch = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(scratch.path().join("vendor").join("eve")).unwrap(); - std::fs::write(scratch.path().join("vendor").join("eve").join("marker.txt"), b"copied, not cloned").unwrap(); - assert!( - !vendor_eve_git_checkout_available(scratch.path()), - "a copied vendor/eve directory with no .git must not report available" - ); - } - - /// PA.f193-C: the bug Soul actually measured, built inside a real `git - /// init` root rather than a bare tempdir. The prior test's own scratch - /// directory sits outside any repository, so `git -C vendor/eve - /// rev-parse HEAD` already failed there for an unrelated reason ("no such - /// repository") and never exercised the actual defect: a naive `git -C - /// vendor/eve rev-parse HEAD` walks up past the missing `.git` and - /// answers with the *enclosing* repository's own HEAD instead of - /// failing. This test proves that false positive exists (the sanity - /// assertion) before proving the fix closes it. - /// - /// Mutation: revert `vendor_eve_git_checkout_available` to a bare `git -C - /// vendor/eve rev-parse HEAD` success check (drop the `--show-toplevel` - /// comparison) — the final assertion then fails, because the copy inside - /// this enclosing repo reports available exactly like Soul found. - #[test] - fn vendor_eve_git_checkout_available_rejects_a_copy_inside_an_enclosing_repo() { - let git_on_path = std::process::Command::new("git") - .arg("--version") - .output() - .is_ok_and(|output| output.status.success()); - if !git_on_path { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_rejects_a_copy_inside_an_enclosing_repo: \ - `git` is not on PATH." - ); - return; - } - - let scratch = tempfile::tempdir().unwrap(); - let init = std::process::Command::new("git") - .args(["init", "-q"]) - .current_dir(scratch.path()) - .status(); - if !init.is_ok_and(|status| status.success()) { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_rejects_a_copy_inside_an_enclosing_repo: \ - `git init` failed in the scratch directory." - ); - return; - } - let commit = std::process::Command::new("git") - .args([ - "-c", - "user.email=ghostlight-test@example.com", - "-c", - "user.name=ghostlight-test", - "commit", - "--allow-empty", - "-q", - "-m", - "scratch root for PA.f193-C", - ]) - .current_dir(scratch.path()) - .status(); - if !commit.is_ok_and(|status| status.success()) { - eprintln!( - "SKIPPED vendor_eve_git_checkout_available_rejects_a_copy_inside_an_enclosing_repo: \ - could not create the scratch repo's initial commit." - ); - return; - } - - // A copy, not a checkout: real files, no `.git`, sitting inside the - // enclosing scratch repo just created above. - std::fs::create_dir_all(scratch.path().join("vendor").join("eve")).unwrap(); - std::fs::write(scratch.path().join("vendor").join("eve").join("marker.txt"), b"copied, not cloned").unwrap(); - - // Sanity: prove the false positive is real before proving the fix - // closes it. If this fails, the scratch fixture stopped reproducing - // Soul's bug and the test below would certify nothing either. - let naive = std::process::Command::new("git") - .args(["-C", "vendor/eve", "rev-parse", "HEAD"]) - .current_dir(scratch.path()) - .output(); - assert!( - naive.is_ok_and(|output| output.status.success()), - "the naive `git -C vendor/eve rev-parse HEAD` must succeed here — this is exactly the \ - false positive PA.f193-C found; if it no longer succeeds, this fixture stopped \ - reproducing the bug" - ); - - assert!( - !vendor_eve_git_checkout_available(scratch.path()), - "a vendor/eve copy sitting inside an enclosing repository must not report available" - ); - } - - /// Strips the fields that legitimately vary between two otherwise - /// identical intents — `idempotencyKey` (`randomIdempotencyKey`) and - /// `issuedAt` (`new Date().toISOString()`), both minted fresh by - /// `createEveCommandIntent` on every call — so a structural comparison - /// judges the payload and routing shape the lowering actually produced, - /// not two different timestamps. - fn normalize_intent_for_diff(mut intent: Value) -> Value { - if let Some(operation) = intent.get_mut("operation").and_then(Value::as_object_mut) { - operation.remove("idempotencyKey"); - } - if let Some(object) = intent.as_object_mut() { - object.remove("issuedAt"); - } - intent - } - - /// PA.f162's own drift check: regenerates the same round trip through the - /// real vendored lowering (`tools/eve_client_bridge.mjs`, submodule pin - /// unmoved) and fails if it no longer matches the committed fixture — - /// the fixture alone, without this, would be a frozen copy of the - /// client's past behavior pretending to still be the client's current - /// one. Runs only when `node` and the built lowering are actually - /// available (`eve_client_bridge_is_available`). `#[ignore]`d for the - /// same reason `world_create_seed_activate_and_play_round_trip_through_the_real_client` - /// is (Soul's owed item 3, above): a normal `cargo test` run must - /// distinguish "not exercised" from "passed" in its own summary line - /// without `--nocapture`, and only `--ignored` opts in. - #[tokio::test] - #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today() { - if !eve_client_bridge_is_available() { - panic!( - "eve_client_bridge_fixture_matches_what_the_pinned_lowering_produces_today was run \ - (via --ignored) but the real Eve client bridge is not available, so the committed \ - fixture (src/fixtures/eve_client_bridge_intents.json) cannot be regenerated or \ - diffed against it. Run `node` on PATH and both `npm install --prefix \ - vendor/eve/packages/eve-browser-lowering` and `npm install --prefix \ - vendor/eve/packages/eve-contracts`, then `npm run build --prefix \ - vendor/eve/packages/eve-browser-lowering`, before running this test." - ); - } - let doc = bridge_fixture(); - assert_eq!( - doc.vendor_eve_submodule_revision, - std::process::Command::new("git") - .args(["-C", "vendor/eve", "rev-parse", "HEAD"]) - .current_dir(repo_root()) - .output() - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned()) - .expect( - "`git -C vendor/eve rev-parse HEAD` must succeed when the bridge is available \ - (eve_client_bridge_is_available's own vendor_eve_git_checkout_available check \ - should have skipped this test otherwise, PA.f190)", - ), - "the fixture's own stamped submodule revision no longer matches vendor/eve's checked-out \ - commit; regenerate the fixture (and update this stamp) against the revision actually \ - pinned, or the diff below is comparing against the wrong baseline" - ); - - let fixture = fixture().await; - let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; - - let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let create_intent = client_intents( - &empty_surface, - &provider, - json!([ - {"set": { - "world.create.title": "Bridge Fixture World", - "world.create.brief": "A world captured for the committed Eve client bridge fixture.", - "world.create.subject": "The Fixture Operator", - "world.create.targets": "{}", - "world.create.jurisdictions": "[]", - "world.create.lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}" - }}, - {"click": "world.create"} - ]), - ) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, create_intent.clone()).await; - - let draft_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let approve_intent = client_intents(&draft_surface, &provider, json!([{"click": "world.approve"}])) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, approve_intent.clone()).await; - - let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let activate_intent = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, activate_intent.clone()).await; - - let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let play_intent = client_intents( - &active_surface, - &provider, - json!([ - {"set": {"world.play.text": "The new owner looks around."}}, - {"click": "world.play"} - ]), - ) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, play_intent.clone()).await; - - let regenerated = [create_intent, approve_intent, activate_intent, play_intent]; - let committed = bridge_fixture().steps; - assert_eq!(regenerated.len(), committed.len(), "step count drifted"); - for (regenerated_intent, committed_step) in regenerated.into_iter().zip(committed) { - assert_eq!( - normalize_intent_for_diff(regenerated_intent), - normalize_intent_for_diff(committed_step.intent), - "the real vendored lowering's own `{}` intent no longer matches the committed fixture; \ - regenerate src/fixtures/eve_client_bridge_intents.json from the pinned submodule \ - revision and commit the difference deliberately", - committed_step.label - ); - } - } - - /// PA.f170's own round trip through the real client: with a question - /// open, the "Play" button's own `props.action` (the answer token) and - /// the bound `text` field must arrive together in one payload — - /// `{"answerToken":..., "bindings":{"text":...}}` — exactly the shape - /// `commandPayload` produces by spreading a button's own action fields - /// into the payload beside `bindings` - /// (`vendor/eve/packages/eve-browser-lowering/src/index.ts:2101`). This - /// is not in the committed fixture above (that walkthrough's own - /// `world.play` click never has an open question, so its action carries - /// no token to prove); this test builds its own scripted question so the - /// merge actually has something in `props.action` to carry. Skips, with - /// `#[ignore]`d, same reason and opt-in as this file's other three - /// bridge-dependent tests (Soul's owed item 3, above). - #[tokio::test] - #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn the_real_client_carries_the_answer_token_and_the_bound_text_together() { - if !eve_client_bridge_is_available() { - panic!( - "the_real_client_carries_the_answer_token_and_the_bound_text_together was run (via \ - --ignored) but the real Eve client bridge is not available: `node` is missing, or \ - the vendored lowering's built `dist/index.js`, its `jsdom` devDependency, or the \ - sibling `eve-contracts` package's own `ajv` dependency does not resolve in this \ - environment." - ); - } - let (fixture, served_version) = play_world_with_a_scripted_question().await; - let table = fixture.state.play.clone().unwrap(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I stand at a crossroads."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.state == PlayTurnState::AwaitingPlayer - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted ask_player call must open a question"); - - let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; - let surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let play_intent = client_intents( - &surface, - &provider, - json!([ - {"set": {"world.play.text": "I go left."}}, - {"click": "world.play"} - ]), - ) - .into_iter() - .next() - .unwrap(); - - let payload = play_intent["payload"].clone(); - assert!( - payload - .get("answerToken") - .and_then(Value::as_str) - .is_some_and(|token| !token.is_empty()), - "the real client's own button action must carry a non-empty answerToken: {payload}" - ); - assert_eq!( - payload["bindings"]["text"], "I go left.", - "the real client's own captured binding must still carry the bound text beside the \ - action's own answerToken: {payload}" - ); - - let answered = post(&fixture.state, &fixture.cookie, play_intent).await; - assert_eq!( - answered["receipt"]["state"], "accepted", - "the real client's own combined payload must be accepted: {answered}" - ); - - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await { - if view.state == PlayTurnState::Closed { - return view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted end_turn call must close the turn"); - assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet.")); - } - - /// PA.f164: `targets`, `jurisdictions`, and `brief` are all documented - /// "required, may be empty" (`CreatePayload`) — a legitimate deliberate - /// choice, not a value nobody supplied. Before this fix, only `lens_weights` - /// carried an authored `value` the real lowering's own - /// `findAuthoredBindingValue` could capture without the field being typed - /// into; the other three carried only a `placeholder`, which the lowering - /// never captures at all (`src/index.ts`'s own `findAuthoredBindingValue` - /// reads `props.value`, never `props.placeholder`). A form filled exactly - /// as its labels suggest — the identity fields a real owner actually types, - /// `title`/`subject`, and nothing else — was refused with "missing field - /// targets". This test drives the real vendored lowering the same way, - /// typing only `title` and `subject`, and the command must still be - /// accepted. - /// `#[ignore]`d, same reason and opt-in as this file's other three - /// bridge-dependent tests (Soul's owed item 3, above). - #[tokio::test] - #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted() { - if !eve_client_bridge_is_available() { - panic!( - "a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted was run \ - (via --ignored) but the real Eve client bridge is not available: `node` is missing, \ - or the vendored lowering's built `dist/index.js`, its `jsdom` devDependency, or the \ - sibling `eve-contracts` package's own `ajv` dependency does not resolve in this \ - environment. Run `node` on PATH and both `npm install --prefix \ - vendor/eve/packages/eve-browser-lowering` and `npm install --prefix \ - vendor/eve/packages/eve-contracts`, then `npm run build --prefix \ - vendor/eve/packages/eve-browser-lowering`, before running this test." - ); - } - let fixture = fixture().await; - let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; - let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - - let create_intents = client_intents( - &empty_surface, - &provider, - json!([ - {"set": { - "world.create.title": "Untouched Fields World", - "world.create.subject": "The Owner" - }}, - {"click": "world.create"} - ]), - ); - assert_eq!(create_intents.len(), 1); - let intent = create_intents.into_iter().next().unwrap(); - // The real lowering's own captured payload — not a hand-built one — - // must have fallen back to the authored `value`s this cut seeded - // rather than omitting the fields outright. - let bindings = &intent["payload"]["bindings"]; - assert_eq!(bindings["brief"], ""); - assert_eq!(bindings["targets"], "{}"); - assert_eq!(bindings["jurisdictions"], "[]"); - - let created = post(&fixture.state, &fixture.cookie, intent).await; - assert_eq!( - created["receipt"]["state"], "accepted", - "a form filled only by its labelled identity fields must still be accepted: {created}" - ); - } - - /// PA.f168 originally refused any sibling next to the envelope - /// `bindings` object outright. PA.f170 changes what a real client's - /// payload carries — a button's own `props.action` fields (the answer - /// token, for `world.play`) ride beside `bindings` on purpose — so - /// `unwrap_bindings` now merges the two instead. An unrecognized sibling - /// is still refused, just one door downstream: the merged object still - /// has to satisfy the operation's own payload struct, and - /// `world.approve`'s `EmptyPayload` (`#[serde(deny_unknown_fields)]`) - /// declares none at all, so `"rogue"` is still an unknown field there. - /// - /// Mutation: have `unwrap_bindings` silently drop an unrecognized sibling - /// instead of merging it in — this command would then be accepted, with - /// `"rogue"` discarded rather than refused. - #[tokio::test] - async fn a_payload_with_an_unrecognized_sibling_field_beside_bindings_is_refused() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Sibling Field World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - - let denied = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.approve", - "ghostlight.world_approve.v0", - 1, - json!({"bindings":{}, "rogue":"a field no real client ever sends beside bindings"}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied"); - assert!( - denied["receipt"]["message"].as_str().unwrap_or_default().contains("rogue"), - "an unrecognized sibling field beside the envelope must still be refused, not silently \ - discarded: {denied}" - ); - } - - /// PA.f170's own negative on the merge itself: a payload naming the same - /// field both inside `bindings` and as a sibling of it — a shape nothing - /// on the real lowering's own side produces (`commandPayload` builds - /// `bindings` and the action's own fields from disjoint sources) — is - /// refused as an outright collision, never resolved by silently - /// preferring one side over the other. - /// - /// Mutation: have `unwrap_bindings` prefer the sibling (or the `bindings` - /// entry) on a name collision instead of refusing it — this command - /// would then be accepted with one of the two conflicting `"minutes"` - /// values silently discarded. - #[tokio::test] - async fn a_payload_naming_the_same_field_in_bindings_and_as_a_sibling_is_refused() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Colliding Field World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - - let denied = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.advance_time", - "ghostlight.world_advance_time.v0", - 3, - json!({"bindings":{"minutes":5}, "minutes":9}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied"); - assert!( - denied["receipt"]["message"].as_str().unwrap_or_default().contains("minutes"), - "a field named by both bindings and a sibling must be refused as a collision: {denied}" - ); - } - - #[tokio::test] - async fn http_eve_journey_uses_one_world_owner() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Cutover World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - assert_eq!(created["receipt"]["sourceVersion"], 1); - - let approved = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.approve", - "ghostlight.world_approve.v0", - 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(approved["receipt"]["sourceVersion"], 2); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.activate", - "ghostlight.world_activate.v0", - 2, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(activated["receipt"]["sourceVersion"], 3); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - 3, - json!({"text":"The new owner looks around."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - // PA.f138: this leg observes the play table itself, not merely the - // accepted response — a route that spawned the task and handed back - // the same JSON without ever calling `table.run` would still pass - // the assertion above, so `world_play_reaches_the_play_table_and_is_ - // accepted`'s own poll is what actually proves the turn opened. - let table = fixture.state.play.clone().unwrap(); - let observed = tokio::time::timeout(Duration::from_secs(5), async move { - loop { - if table.current_turn_view().await.is_some() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await; - assert!( - observed.is_ok(), - "world.play must actually reach PlayTable::run, recording a turn row, not just return \ - the same accepted JSON on its own" - ); - } - - /// Cut 8b's own routing test: `world.play` reaches the play table and - /// returns `accepted` — the request routes and returns before the turn - /// itself resolves, exactly as the spawned-task shape calls for. The - /// fixture's own play table shares the fixture's unreachable test - /// inference organ (`127.0.0.1:9`), so the spawned turn will itself fail - /// once it actually tries to infer; that failure is expected and is not - /// this test's concern; only the HTTP response this route hands back - /// before that happens is. - /// - /// PA.f138: `played["receipt"]["state"] == "accepted"` alone is reachable by a - /// mutation that spawns the task and returns the same JSON without ever - /// calling `table.run` — this route builds `{"kind":"accepted"}` from - /// nothing on the table itself. This test also polls - /// `PlayTable::current_turn_view` (PA.f134's own consumer-facing read) - /// until it reports a turn, bounded by a timeout: `table.run` genuinely - /// ran only if a turn row ever appears, whatever it did with it. - #[tokio::test] - async fn world_play_reaches_the_play_table_and_is_accepted() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Play Routing World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - 1, - json!({"text":"I look around."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - let table = fixture.state.play.clone().unwrap(); - let observed = tokio::time::timeout(Duration::from_secs(5), async move { - loop { - if table.current_turn_view().await.is_some() { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await; - assert!( - observed.is_ok(), - "the spawned task must actually call table.run, recording a turn row, not just return \ - the same accepted JSON on its own" - ); - } - - /// PA.f148/PA.f160: Soul's own probe — the served document's `version` - /// used to fold the play row's own revision in, so a play commit alone - /// (no kernel patch) could move `version` to a number `world.revision` - /// never reached, and every later command's compare-and-swap derived - /// `expected_revision` from it and was permanently denied. This drives - /// the exact same shape: open a play turn (it commits nothing to the - /// world here — the fixture's own inference organ is unreachable, so it - /// faults immediately, which still exercises the row's own revision - /// counter the same way a real committed refusal or question would), - /// wait for the row to move, then read `sourceVersion` from the actually - /// served surface document — as a client does — and submit - /// `world.advance_time` against it. It must still be accepted. - #[tokio::test] - async fn a_play_commit_does_not_poison_the_next_world_commands_compare_and_swap() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"CAS World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - assert_eq!(activated["receipt"]["sourceVersion"], 3, "world.revision is 3, and nothing has folded anything else into it yet"); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation("world.play", "ghostlight.world_play.v0", 3, json!({"text":"I look around."}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - let table = fixture.state.play.clone().unwrap(); - tokio::time::timeout(Duration::from_secs(5), async move { - loop { - if let Some(view) = table.current_turn_view().await { - if view.revision > 0 { - break; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the play row's own revision must move once the turn commits its own row"); - - // As a client does: read `version` off the actually served document, - // never re-derive it from `world.revision` directly. - let surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let served_version = surface["version"].as_u64().expect("the surface document names a version"); - assert_eq!( - served_version, 3, - "PA.f148: the document's own version must still name world.revision alone, not the play row's \ - revision folded in — Soul's own probe was a version that did not" - ); - - let advanced = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.advance_time", - "ghostlight.world_advance_time.v0", - served_version, - json!({"minutes": 5}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - advanced["receipt"]["state"], "accepted", - "a play commit must never poison the next world command's compare-and-swap: {advanced}" - ); - } - - /// The create/approve/activate boilerplate every scripted-question - /// harness below needs, factored out so `play_world_with_a_scripted_question` - /// and `play_world_with_two_scripted_questions` (Cut 13) do not each - /// repeat it. Returns the fixture and the world's `sourceVersion` after - /// activation. - /// A cleared optional label means none. The form's persona and agent boxes - /// have no authored value, so a player who typed a name and deleted it - /// sends "" (or spaces); the route reads that as no persona and no agent, - /// not as a world with an empty-named one the kernel then refuses. - /// - /// Mutation: pass `payload.narrative_persona_label` and - /// `payload.operational_agent_label` through `create` unfiltered. - #[tokio::test] - async fn cleared_optional_labels_create_a_world_with_no_persona_and_no_agent() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Cleared Labels", - "brief":"", - "subject_label":"Operator", - "narrative_persona_label":"", - "operational_agent_label":" ", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted", "{created}"); - let snapshot = fixture.state.world.snapshot().await.unwrap(); - assert_eq!( - snapshot.subjects.len(), - 1, - "a cleared label declared a persona or agent: {} subjects", - snapshot.subjects.len() - ); - } - - async fn activated_fixture(title: &str) -> (Fixture, u64) { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":title, - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - let served_version = activated["receipt"]["sourceVersion"].as_u64().unwrap(); - (fixture, served_version) - } - - /// PA.f161's own harness: builds a play world through the real HTTP - /// route, then swaps `state.play` for a table over `crate::play::tests`' - /// own `ScriptedPort`, scripted to call `ask_player` and then `end_turn` - /// — a deterministic question this test can actually answer through the - /// route, which the fixture's own unreachable test inference organ - /// cannot produce. Returns the fixture and the world's `sourceVersion` - /// after activation (still a required `routeHint.sourceVersion` field on - /// every command envelope, even though `world.play` no longer reads it - /// for staleness — PA.f170 judges an answer by its own token instead). - async fn play_world_with_a_scripted_question() -> (Fixture, u64) { - use crate::play::tests::{ScriptedPort, call_event, output, text_event}; - let (fixture, served_version) = activated_fixture("Scripted Question World").await; - - let ask = output( - "r0", - vec![call_event( - "c0", - "ask_player", - serde_json::json!({"question": "Which way?"}), - )], - ); - let end = output("r1", vec![call_event("c1", "end_turn", serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The hall falls quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![ask, end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let mut fixture = fixture; - fixture.state.play = Some(Arc::new(table)); - fixture._play_directory = Some(directory); - (fixture, served_version) - } - - /// PA.f170's own harness: two scripted `ask_player` calls, one per round, - /// with no `end_turn` — so answering Q1 leaves the turn `AwaitingPlayer` - /// on Q2 rather than closed, the exact shape Soul's own probe needed to - /// show an answer composed for Q1 landing on Q2. - async fn play_world_with_two_scripted_questions() -> (Fixture, u64) { - use crate::play::tests::{ScriptedPort, call_event, output}; - let (fixture, served_version) = activated_fixture("Two Scripted Questions World").await; - - let ask_one = output( - "r0", - vec![call_event("c0", "ask_player", serde_json::json!({"question": "Which way?"}))], - ); - let ask_two = output( - "r1", - vec![call_event("c1", "ask_player", serde_json::json!({"question": "Are you sure?"}))], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.state.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.state.world.clone(), - personas, - ScriptedPort::new(vec![ask_one, ask_two]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(TEST_CONTROLLER_CONCURRENCY)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - let mut fixture = fixture; - fixture.state.play = Some(Arc::new(table)); - fixture._play_directory = Some(directory); - (fixture, served_version) - } - - /// Finds the surface node named `id`, depth-first, the same way - /// `crate::play::tests::find_surface_node` does for `play.rs`'s own - /// tests — duplicated locally rather than exposed `pub(crate)` across a - /// module boundary for one small helper. - fn find_surface_node<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node.get("id").and_then(Value::as_str) == Some(id) { - return Some(node); - } - for child in node.get("children").and_then(Value::as_array).into_iter().flatten() { - if let Some(found) = find_surface_node(child, id) { - return Some(found); - } - } - None - } - - /// Reads the open question's own answer token the same way a real - /// client does (PA.f170): off the "Play" button's own served - /// `props.action.answerToken`, from the actually served surface document - /// — never from `PlayTurnView`/`OpenQuestion` directly, which a test - /// could read but a browser never does. - async fn play_button_token(state: &AppState, cookie: &str) -> Option { - let surface = get(state, cookie, "/api/eve/surfaces/ghostlight.play").await; - let root = surface.get("surface")?.get("root")?; - let button = find_surface_node(root, "world.play")?; - button - .get("props")? - .get("action")? - .get("answerToken")? - .as_str() - .map(str::to_owned) - } - - /// Drives `play_world_with_a_scripted_question` through `world.play` and - /// waits for the scripted `ask_player` call to open the turn's own - /// question, returning the fixture, table, and served version so each - /// caller only writes its own answer's own assertions. - async fn played_and_awaiting_question() -> (Fixture, Arc, u64) { - let (fixture, served_version) = play_world_with_a_scripted_question().await; - let table = fixture.state.play.clone().unwrap(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I stand at a crossroads."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.state == PlayTurnState::AwaitingPlayer - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted ask_player call must open a question"); - (fixture, table, served_version) - } - - /// PA.f170's own end-to-end proof: a question asked, the card served, - /// its own answer token read out of the served surface document — never - /// out of `PlayTurnView` directly, which a real client never sees — the - /// answer accepted, and the turn closes with the scripted narration. - /// - /// Mutation: change the `matches!((payload.answer_token.as_deref(), - /// question.token.as_deref()), (Some(given), Some(expected)) if given == - /// expected)` gate at `runtime.rs`'s `world.play` arm to always pass (or - /// always fail) — this test's own answer, echoing the exact token the - /// surface served, would then be wrongly refused (or a broken-but-lenient - /// comparison would let a later mismatch test wrongly pass). - #[tokio::test] - async fn an_answer_naming_the_open_questions_own_token_read_from_the_served_surface_is_accepted() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface's own \"Play\" button must carry the open question's answer token"); - - let answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I go left.","answerToken":token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - answered["receipt"]["state"], "accepted", - "an answer naming the open question's own token, read off the served surface, must be accepted: {answered}" - ); - - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await { - if view.state == PlayTurnState::Closed { - return view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted end_turn call must close the turn"); - assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet.")); - } - - /// PA.f170's own refusal proof: an answer that carries no token at all - /// while a question is open is refused as stale, and the turn stays open - /// rather than being resumed by an answer that could not have named the - /// question the player was actually shown. - /// - /// Mutation: change the `matches!` gate at `runtime.rs`'s `world.play` - /// arm to treat a missing token (`None`) as a match — this test's - /// answer, carrying no `answerToken` field at all, would then be - /// wrongly accepted. - #[tokio::test] - async fn an_answer_missing_the_open_questions_own_token_is_refused_as_stale() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let stale_answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I go left."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(stale_answered["receipt"]["state"], "denied"); - assert!( - stale_answered["receipt"]["message"] - .as_str() - .unwrap_or_default() - .contains("stale"), - "an answer with no token at all must be refused as stale: {stale_answered}" - ); - - let still_open = table.current_turn_view().await.unwrap(); - assert_eq!( - still_open.state, - PlayTurnState::AwaitingPlayer, - "a stale answer must leave the question open, not resume the turn" - ); - } - - /// PA.f170: an answer naming a token that does not match the currently - /// open question's own — forged, mistyped, or simply wrong — is refused - /// as stale exactly like a missing one, never applied to the open - /// question. - /// - /// Mutation: compare only a prefix or a hash of the token instead of the - /// exact string — this test's near-miss token (the real one with its - /// last character flipped) would then be wrongly accepted for at least - /// some mutations of that weakened comparison. - #[tokio::test] - async fn an_answer_naming_a_mismatched_token_is_refused_as_stale() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let real_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry the open question's own answer token"); - let mut forged = real_token.clone(); - forged.push('x'); - - let stale_answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I go left.","answerToken":forged}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(stale_answered["receipt"]["state"], "denied"); - assert!( - stale_answered["receipt"]["message"] - .as_str() - .unwrap_or_default() - .contains("stale"), - "a mismatched token must be refused as stale: {stale_answered}" - ); - - let still_open = table.current_turn_view().await.unwrap(); - assert_eq!( - still_open.state, - PlayTurnState::AwaitingPlayer, - "a stale answer must leave the question open, not resume the turn" - ); - } - - /// PA.f178: a stale tab — one still holding a render of a question that - /// has since been answered and closed — resubmits its own spent - /// `answerToken` alongside new prose. Before this cut, with no question - /// currently open the token was simply never read, and the prose opened - /// a *fresh* turn instead of being refused: a real inference budget spent - /// answering a question that no longer exists. The token must instead be - /// refused outright, and no new turn opened. - /// - /// Mutation: delete the `payload.answer_token.is_some() && - /// !is_awaiting_player` guard from `execute_world`'s `world.play` arm — - /// this test's stale-tab resubmission would then be accepted, opening a - /// second turn, and both assertions below would fail (the response - /// would read `"accepted"`, and `current_turn_view`'s own `turn_id` - /// would move on from the first, closed turn). - #[tokio::test] - async fn a_stale_tabs_spent_token_is_refused_and_never_opens_a_new_turn() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let spent_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry the open question's own answer token"); - - let answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I go left.","answerToken":spent_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(answered["receipt"]["state"], "accepted"); - - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await { - if view.state == PlayTurnState::Closed { - return view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted end_turn call must close the turn"); - assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet.")); - let closed_turn_id = closed.turn_id.clone(); - - // The stale tab, still holding its own now-spent token, resubmits — - // with a fresh idempotency key, so a key-replay refusal cannot be - // mistaken for this one. - let stale_resubmission = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"a followup the player never actually meant to send here","answerToken":spent_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - stale_resubmission["receipt"]["state"], "denied", - "a spent token, resubmitted once no question is open, must be refused: {stale_resubmission}" - ); - assert!( - stale_resubmission["receipt"]["message"] - .as_str() - .unwrap_or_default() - .contains("closed"), - "the refusal must name the actual defect (the question is closed), not a generic decode error: \ - {stale_resubmission}" - ); - - let still_closed = table.current_turn_view().await.unwrap(); - assert_eq!( - still_closed.turn_id, closed_turn_id, - "a refused stale resubmission must never open a fresh turn" - ); - assert_eq!(still_closed.state, PlayTurnState::Closed); - } - - /// PA.f180: the real client never emits `answerToken` as a captured - /// binding — `eve.rs` renders it DOM-invisibly on the "Play" button's own - /// `props.action`, never through any control a `captureBindings` value - /// could come from — but nothing before this cut refused a request built - /// by hand (or by a compromised/buggy client) that sent the *correct* - /// token through the `bindings` channel instead of the action channel. - /// `unwrap_bindings` used to flatten both into one map with no memory of - /// which channel a field arrived on, so this would have been - /// indistinguishable from a real answer and accepted. - /// - /// Mutation: remove the `ACTION_ONLY_PAYLOAD_FIELDS` check from - /// `unwrap_bindings` — this test's answer, sending the real token through - /// `bindings` instead of as a sibling, would then be wrongly accepted - /// and the turn would close. - #[tokio::test] - async fn an_answer_token_sent_as_a_captured_binding_is_refused_not_admitted() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let real_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry the open question's own answer token"); - - let smuggled = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"bindings":{"text":"I go left.","answerToken":real_token}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - smuggled["receipt"]["state"], "denied", - "the real token, sent through the wrong channel, must still be refused: {smuggled}" - ); - assert!( - smuggled["receipt"]["message"] - .as_str() - .unwrap_or_default() - .contains("captured binding"), - "the refusal must name the actual defect (wrong channel), not a generic decode error: {smuggled}" - ); - - let still_open = table.current_turn_view().await.unwrap(); - assert_eq!( - still_open.state, - PlayTurnState::AwaitingPlayer, - "a smuggled-channel token must leave the question open, not resume the turn" - ); - } - - /// PA.f180's negative: every other command's own round trip — the action - /// spread and the envelope `bindings` arriving together, as the real - /// lowering actually sends them — must keep working; the new check - /// refuses only a reserved action-only field found inside `bindings`, - /// never an ordinary field arriving in its usual place. - /// - /// Mutation: widen `ACTION_ONLY_PAYLOAD_FIELDS` to also cover `"text"` — - /// this test's ordinary answer, whose `text` legitimately arrives via - /// `bindings`, would then be wrongly refused. - #[tokio::test] - async fn an_ordinary_answer_with_text_via_bindings_and_token_as_a_sibling_is_still_accepted() { - let (fixture, table, served_version) = played_and_awaiting_question().await; - - let real_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry the open question's own answer token"); - - let answered = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"answerToken":real_token,"bindings":{"text":"I go left."}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - answered["receipt"]["state"], "accepted", - "the real client's own shape — action fields as siblings, captured values inside \ - bindings — must still be accepted: {answered}" - ); - - let closed = tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await { - if view.state == PlayTurnState::Closed { - return view; - } - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the scripted end_turn call must close the turn"); - assert_eq!(closed.narration.as_deref(), Some("The hall falls quiet.")); - } - - /// PA.f170's own core proof — the defect Soul demonstrated (an answer - /// composed for Q1 lands on Q2, the player never saw): two questions open - /// in the same turn, an answer carries Q1's own token, and by the time it - /// is submitted the turn has moved on to Q2. `question_surface_version` - /// could not refuse this: asking is conversational and never moves the - /// world's own revision, so Q1 and Q2 would have carried the same - /// version, and a version-only comparison could not tell them apart. The - /// per-question token can, and must. - /// - /// Mutation: have `runtime.rs`'s `world.play` arm resolve `answers` from - /// `payload.text` alone, ignoring `answer_token` entirely (reverting to - /// "any request while a question is open answers whatever is open now") - /// — this test's Q1-token answer would then be wrongly accepted against - /// Q2. - #[tokio::test] - async fn soul_an_answer_composed_for_q1_lands_on_q2_the_player_never_saw() { - let (fixture, served_version) = play_world_with_two_scripted_questions().await; - let table = fixture.state.play.clone().unwrap(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I stand at a crossroads."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.question.as_ref().is_some_and(|question| question.text == "Which way?") - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the first scripted ask_player call must open Q1"); - - // The player reads Q1's own token off the served surface — exactly - // what a real client's "Play" button would carry — but does not - // answer it before the table moves on to Q2. - let q1_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry Q1's own answer token"); - - let answered_q1 = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"left","answerToken":q1_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - answered_q1["receipt"]["state"], "accepted", - "Q1's own token must still answer Q1: {answered_q1}" - ); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.question.as_ref().is_some_and(|question| question.text == "Are you sure?") - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("answering Q1 must open Q2"); - - // A late request still carrying Q1's own token — the exact shape a - // slow retry or a second tab would produce — must never be applied - // to Q2, which the player has not been shown yet. - let stale_on_q2 = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"yes","answerToken":q1_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - stale_on_q2["receipt"]["state"], "denied", - "an answer composed for Q1 must never land on Q2: {stale_on_q2}" - ); - assert!( - stale_on_q2["receipt"]["message"].as_str().unwrap_or_default().contains("stale"), - "the refusal must name it stale: {stale_on_q2}" - ); - - let still_on_q2 = table.current_turn_view().await.unwrap(); - assert_eq!( - still_on_q2.question.as_ref().map(|question| question.text.as_str()), - Some("Are you sure?"), - "the wrongly-refused answer must leave Q2 open, not resolve it" - ); - } - - /// PA.f170: a token is single-use across distinct questions — reusing - /// the token a *closed* turn's own answered question carried, replayed - /// against a fresh question with a fresh token, is refused exactly like - /// any other mismatch (the two-questions probe above already proves - /// reuse within one still-open turn is refused; this proves reuse is - /// never grandfathered in just because the token was once genuine). - #[tokio::test] - async fn a_token_from_an_already_answered_question_is_refused_against_the_next_one() { - let (fixture, served_version) = play_world_with_two_scripted_questions().await; - let table = fixture.state.play.clone().unwrap(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"I stand at a crossroads."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.question.as_ref().is_some_and(|question| question.text == "Which way?") - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("the first scripted ask_player call must open Q1"); - let q1_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry Q1's own answer token"); - - let answered_q1 = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"left","answerToken":q1_token.clone()}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(answered_q1["receipt"]["state"], "accepted"); - - tokio::time::timeout(Duration::from_secs(5), async { - loop { - if let Some(view) = table.current_turn_view().await - && view.question.as_ref().is_some_and(|question| question.text == "Are you sure?") - { - return; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await - .expect("answering Q1 must open Q2"); - let q2_token = play_button_token(&fixture.state, &fixture.cookie) - .await - .expect("the served surface must carry Q2's own answer token"); - assert_ne!(q1_token, q2_token, "Q1's own spent token must never be reissued for Q2"); - - let replayed_q1_token = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - served_version, - json!({"text":"yes","answerToken":q1_token}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!( - replayed_q1_token["receipt"]["state"], "denied", - "Q1's own already-spent token must never answer Q2: {replayed_q1_token}" - ); - } - - /// PA.f150: `publish_projection` used to be called only at startup and - /// from `eve_command`'s own Ok arm — which answers a `world.play` - /// request before its own turn ever executes — so a play-row-only - /// change (a fresh refusal, a newly open question, a closed turn's - /// narration, none of which move `world.revision`) never republished at - /// all, and a watching client's SSE subscription never woke for it. - /// Drives a real play turn to a fault (the fixture's own inference organ - /// is unreachable, so this always happens quickly) and asserts the - /// `state.revisions` channel — what `/api/eve/events` relays — receives - /// a fresh value for it, on the same door `eve_command`'s own commits use. - #[tokio::test] - async fn a_play_row_only_change_wakes_the_revisions_channel() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Wake World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - - // The fixture's own inference connector points at an unreachable - // address (127.0.0.1:9), so every round faults immediately; only the - // *retry backoff* is real time. Zeroed here so the turn actually - // closes inside this test's own timeout, rather than spending it on - // `backoff_delay`'s production delay between retries. - fixture.state.play.clone().unwrap().set_retry_delay_base_ms(0); - - // Subscribed after every world-commit publish above has already - // happened, so only the play turn's own publish (or a lagged - // catch-up of it) can satisfy this recv. - let mut revisions = fixture.state.revisions.subscribe(); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation("world.play", "ghostlight.world_play.v0", 3, json!({"text":"I look around."}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(played["receipt"]["state"], "accepted"); - - // PA.f163: `dispatch_world`'s own Ok arm publishes a revision for - // *every* accepted command, including this one's immediate - // `{"kind":"accepted"}` — before the turn's own round has executed - // at all, since admission is synchronous and only the round loop is - // spawned. Draining that message here is what keeps the recv below - // from being satisfiable by it: before this fix, deleting the - // spawned task's own `publish_projection` call left this test green - // regardless, because this same immediate publish alone was already - // enough to satisfy an undrained `recv()`. - let immediate = tokio::time::timeout(Duration::from_secs(5), revisions.recv()).await; - assert!( - immediate.is_ok(), - "world.play's own immediate accepted response must publish a revision" - ); - - // Wait for the turn's own commit to actually land. The fixture's - // play table shares its unreachable test inference organ - // (127.0.0.1:9), so the round faults immediately and the turn closes - // on that fault (`close_with_fault`) rather than reaching - // `AwaitingPlayer` — still a play-row-only commit, and the one this - // test means to observe. - let table = fixture.state.play.clone().unwrap(); - // A generous bound: `set_retry_delay_base_ms(0)` above removes the - // backoff *sleep* between retries, but each of `ROUND_RETRY_BUDGET` - // attempts still pays its own connector-level connect cost against - // the unreachable address, so this can take several seconds even - // with the delay zeroed. - let closed = tokio::time::timeout(Duration::from_secs(30), async move { - loop { - if table - .current_turn_view() - .await - .is_some_and(|view| view.state == PlayTurnState::Closed) - { - break; - } - tokio::time::sleep(Duration::from_millis(10)).await; - } - }) - .await; - assert!( - closed.is_ok(), - "the turn must actually close (on a fault, given the unreachable test inference organ) \ - before this test can observe its own commit's wake" - ); - - // The turn's own commit — its close — must publish a *second*, - // distinct revision: the spawned task's own `publish_projection` - // call, the one PA.f150 added and PA.f163's mutation deletes. - // Deleting it leaves exactly the one message already drained above, - // so this recv would time out. - let woke = tokio::time::timeout(Duration::from_secs(5), revisions.recv()).await; - assert!( - woke.is_ok(), - "the play turn's own commit must publish a fresh revision to the SSE channel, not only \ - eve_command's own already-drained immediate accepted response" - ); - } - - /// PA.f153/PA.f154: before this cut, `world.play`'s route spawned - /// `PlayTable::run` in a task and answered `{"kind":"accepted"}` - /// unconditionally, discarding whatever `run` returned into a log line - /// nothing read — a replayed key, a stale/empty answer, a busy table, and - /// a poisoned table all looked exactly like success to the caller. - /// `admit` now runs synchronously in the route, so an admission refusal - /// — here, an empty/whitespace-only opening with no turn currently open - /// to continue — reaches the caller as `denied`, with the reason, - /// instead. - #[tokio::test] - async fn an_admission_refusal_reaches_the_caller_as_denied_not_accepted() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Admission World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - - let opened_empty = post( - &fixture.state, - &fixture.cookie, - invocation("world.play", "ghostlight.world_play.v0", 3, json!({"text":" "}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!( - opened_empty["receipt"]["state"], "denied", - "an empty/whitespace-only opening with no turn open must be denied, not accepted: {opened_empty}" - ); - assert!( - opened_empty["receipt"]["message"].as_str().unwrap().contains("empty"), - "the denial must carry the admission refusal's own reason: {opened_empty}" - ); - - // No turn was ever admitted, so nothing was ever spawned either. - let table = fixture.state.play.clone().unwrap(); - assert!( - table.current_turn_view().await.is_none(), - "a denied admission must never open a turn row" - ); - } - - /// PA.f152: `world.play` is owner-gated like `world.advance_time` and - /// `world.seed` — both the surface (no card, no controls, no descriptor - /// for anyone else) and the route itself, since the surface is only a - /// hint a hostile client can ignore. - #[tokio::test] - async fn world_play_is_owner_gated_on_the_surface_and_the_route() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Owner-Gated World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - let approved = post( - &fixture.state, - &fixture.cookie, - invocation("world.approve", "ghostlight.world_approve.v0", 1, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(approved["receipt"]["state"], "accepted"); - let activated = post( - &fixture.state, - &fixture.cookie, - invocation("world.activate", "ghostlight.world_activate.v0", 2, json!({}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted"); - - // A stranger's own authenticated session — a different account, so a - // different `PrincipalId`, never the world's own owner. - let mut sessions = fixture.state.sessions.lock().await; - let stranger_cookie = sessions - .create_session(heimdall::VerifiedSessionAdmission::fixture( - "a-stranger-account", - "heimdall-session-stranger", - 2, - Utc::now() + chrono::Duration::hours(1), - Utc::now() + chrono::Duration::days(1), - "fixture-refresh-stranger", - )) - .unwrap(); - drop(sessions); - - let stranger_surface = get(&fixture.state, &stranger_cookie, "/api/eve/surfaces/ghostlight.play").await; - let encoded = serde_json::to_string(&stranger_surface).unwrap(); - assert!( - !encoded.contains("world.play"), - "a non-owner's own surface must carry no play card, control, or command descriptor: {encoded}" - ); - - let played = post( - &fixture.state, - &stranger_cookie, - invocation("world.play", "ghostlight.world_play.v0", 3, json!({"text":"I look around."}), &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!( - played["receipt"]["state"], "denied", - "the route itself must refuse a non-owner's world.play, not only hide the affordance: {played}" - ); - - let table = fixture.state.play.clone().unwrap(); - assert!( - table.current_turn_view().await.is_none(), - "a non-owner's own denied world.play must never open a turn row" - ); - } - - /// Same route, with `state.play` unavailable (mirrors `controllers: - /// None` elsewhere in this suite): refused, not a panic or a hang. - #[tokio::test] - async fn world_play_without_a_play_table_is_denied() { - let mut fixture = fixture().await; - fixture.state.play = None; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"No Play World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - - let played = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.play", - "ghostlight.world_play.v0", - 1, - json!({"text":"I look around."}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(played["receipt"]["state"], "denied"); - } - - /// PA.f147, reported rather than fixed (see Hands' own report): - /// `world.play`'s own arm takes `idempotency_key.unwrap_or_default()` - /// with no local guard, which reads like a hole — a caller that skipped - /// `eve::validate_invocation` (the only door that normally requires a - /// non-empty key) would land on key `""`, and `PlayTable::run`'s own - /// `applied_keys`/`KeyLedger` would record and later replay it as a - /// no-op. A local, explicit guard was tried and reverted: mutation-tested - /// against a fresh key-less call through `dispatch_world` (the one - /// caller that skips HTTP validation), it made no difference, because - /// `execute_world`'s own `command_id = CommandId::parse_uuid(...)?`, - /// computed unconditionally before *every* operation's own branch - /// (`world.play`'s included), already refuses a missing or non-uuid key - /// first. This test pins that guard's own reach over `world.play` - /// specifically, since nothing else in this suite calls `dispatch_world` - /// with a key-less invocation. Mutation: `unwrap_or("")` → - /// `unwrap_or("00000000-0000-0000-0000-000000000000")` on - /// `command_id`'s own parse. - #[tokio::test] - async fn world_play_without_an_idempotency_key_is_refused_not_defaulted() { - let fixture = fixture().await; - let created = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"No Key World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(created["receipt"]["state"], "accepted"); - - let mut keyless: EveCommandInvocation = serde_json::from_value(invocation( - "world.play", - "ghostlight.world_play.v0", - 1, - json!({"text":"I look around."}), - &uuid::Uuid::new_v4().to_string(), - )) - .unwrap(); - keyless.operation.idempotency_key = None; - - let principal = - VerifiedPrincipalEvidence::new("operator-account", Utc::now() + chrono::Duration::hours(1)); - let response = dispatch_world(&fixture.state, &principal, keyless).await; - let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) - .await - .unwrap(); - let played: Value = serde_json::from_slice(&bytes).unwrap(); - assert_eq!(played["receipt"]["state"], "denied"); - assert!( - fixture.state.play.as_ref().unwrap().current_turn_view().await.is_none(), - "a missing key must never reach table.run at all, not just fail inside it" - ); - } - - #[tokio::test] - async fn exact_stale_retry_returns_journal_receipt_without_app_cache() { - let fixture = fixture().await; - let id = uuid::Uuid::new_v4().to_string(); - let command = invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Retry World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &id, - ); - let first = post(&fixture.state, &fixture.cookie, command.clone()).await; - let second = post(&fixture.state, &fixture.cookie, command).await; - assert_eq!( - first["receipt"]["commitDigest"], - second["receipt"]["commitDigest"] - ); - assert_eq!( - current_world(&fixture.state) - .await - .unwrap() - .unwrap() - .revision, - 0 - ); - } - - /// PA.f12: the dropped `no_proposal_projection_carries_the_canonical_world_commit` - /// was the only test of `submit_receipt`'s `applied` / `already_applied` - /// kinds and revision. This is the fix, through the real Dungeon path - /// rather than calling `submit_receipt` directly: one principal command, - /// submitted twice under the same idempotency key, must come back - /// `applied` and then `already_applied`, both naming the same revision. - #[tokio::test] - async fn a_principal_command_resubmitted_under_its_own_key_returns_applied_then_already_applied() - { - let fixture = fixture().await; - post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({ - "title":"Retry Receipt World", - "brief":"", - "subject_label":"Operator", - "targets":{}, - "jurisdictions":[], - "lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1} - }), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - post( - &fixture.state, - &fixture.cookie, - invocation( - "world.approve", - "ghostlight.world_approve.v0", - 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - post( - &fixture.state, - &fixture.cookie, - invocation( - "world.activate", - "ghostlight.world_activate.v0", - 2, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - let id = uuid::Uuid::new_v4().to_string(); - let command = invocation( - "world.advance_time", - "ghostlight.world_advance_time.v0", - 3, - json!({"minutes": 5}), - &id, - ); - let first = post(&fixture.state, &fixture.cookie, command.clone()).await; - let second = post(&fixture.state, &fixture.cookie, command).await; - assert_eq!(first["receipt"]["kind"], "applied"); - assert_eq!(second["receipt"]["kind"], "already_applied"); - assert_eq!(first["receipt"]["revision"], second["receipt"]["revision"]); - } - - #[tokio::test] - async fn logout_retry_clears_a_revoked_cookie_without_reauthentication() { - let fixture = fixture().await; - let id = uuid::Uuid::new_v4().to_string(); - let command = invocation( - "app.auth.logout", - "ghostlight.app_logout.v2", - 0, - json!({}), - &id, - ); - let first = post(&fixture.state, &fixture.cookie, command.clone()).await; - let retry = post(&fixture.state, &fixture.cookie, command).await; - assert_eq!(first["receipt"]["state"], "accepted"); - assert_eq!(retry["receipt"]["state"], "accepted"); - assert_eq!(retry["pluginPayload"]["payload"]["status"], "anonymous"); - } - - #[tokio::test] - async fn removed_legacy_operation_is_denied_before_dispatch() { - let fixture = fixture().await; - for (operation, schema) in [ - ("session_zero.begin", "ghostlight.session_zero_begin.v1"), - ("world.controller.act", "ghostlight.world_controller_act.v0"), - ] { - let result = post( - &fixture.state, - &fixture.cookie, - invocation( - operation, - schema, - 0, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(result["receipt"]["state"], "denied", "{operation}"); - assert!( - result["receipt"]["message"] - .as_str() - .unwrap() - .contains("not advertised"), - "{operation}" - ); - } - } - - #[tokio::test] - async fn authentication_commands_reject_ignored_authority_payloads() { - let fixture = fixture().await; - let result = post( - &fixture.state, - &fixture.cookie, - invocation( - "heimdall.auth.begin", - "heimdall.auth_begin_command.v1", - 0, - json!({"caller":{"principal":"legacy-owner"}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(result["receipt"]["state"], "denied"); - assert!( - result["receipt"]["message"] - .as_str() - .unwrap() - .contains("payload may not supply caller authority") - ); - } - - /// A Draft world from `world.create`'s own intent: the owner's Human - /// subject beside a narrative persona and an operational agent, with the - /// given scale intent and jurisdictions, so a test can look at the Draft - /// world the seed lane actually runs against. - async fn two_cell_world( - state: &AppState, - cookie: &str, - targets: BTreeMap, - jurisdictions: Vec, - ) { - let principal = state - .sessions - .lock() - .await - .account_for_cookie(cookie, Utc::now()) - .unwrap() - .expect("the fixture cookie names a live session"); - state - .world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Draft Fixture".into(), - brief: String::new(), - human_subject_label: "Operator".into(), - narrative_persona_label: Some("Persona".into()), - operational_agent_label: Some("Operational Agent".into()), - targets, - jurisdictions, - lens_weights: uniform_lens_weights(), - }, - &principal, - ) - .await - .unwrap(); - } - - /// The library half of this rule is `sdk_inference`'s own - /// `soul_no_credential_name_appears_in_the_ports_own_source`, which can - /// only read its own crate's source. This is the Dungeon half, on the same - /// needles: nothing in the daemon's production source may name a - /// credential path or token variable the SDK port refuses to read. - #[test] - fn soul_no_credential_name_appears_in_the_runtimes_own_source() { - let source = std::fs::read_to_string( - std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src/runtime.rs"), - ) - .expect("the source reads") - .replace("\r\n", "\n"); - // Assembled from halves so this test's own source is not a match. - let needles: [String; 8] = [ - format!(".{}", "credentials.json"), - format!("CLAUDE_CODE{}", "_OAUTH_TOKEN"), - format!(".{}", "claude.json"), - format!("apiKey{}", "Helper"), - format!("USER{}", "PROFILE"), - format!("home{}", "_dir"), - format!("GHOSTLIGHT_SDK{}", "_TOKEN"), - format!("GHOSTLIGHT_SDK{}", "_CREDENTIAL"), - ]; - // Only the production half; a test may name what production must not. - let production = source - .split_once("\n#[cfg(test)]\nmod tests {") - .map(|(before, _)| before.to_owned()) - .unwrap_or(source); - for needle in &needles { - assert!( - !production.contains(needle.as_str()), - "runtime.rs names {needle}" - ); - } - // `ANTHROPIC_API_KEY` may be named in a comment, never anywhere that - // could read it. - let anthropic = format!("ANTHROPIC{}", "_"); - for line in production.lines() { - if line.contains(anthropic.as_str()) { - assert!( - line.trim_start().starts_with("///") || line.trim_start().starts_with("//"), - "runtime.rs names an ANTHROPIC variable outside a comment: {line}" - ); - } - } - } - - /// Every `.rs` file under this crate's `src`, with each file's own - /// `#[cfg(test)] mod tests { .. }` block excluded the way - /// `soul_no_credential_name_appears_in_the_runtimes_own_source` excludes - /// its own: a test module may name what production must not. Unlike that - /// scan, PA.f11 must reach every file in the crate, not just this one, - /// because a forbidden writer could return in `eve.rs`, `mesh.rs`, or - /// anywhere else Dungeon owns. - fn dungeon_non_test_source() -> Vec<(PathBuf, String)> { - let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src"); - let mut files = Vec::new(); - let mut pending = vec![root]; - while let Some(directory) = pending.pop() { - for entry in std::fs::read_dir(&directory).expect("dungeon src directory reads") { - let entry = entry.expect("dungeon src directory entry reads"); - let path = entry.path(); - if path.is_dir() { - pending.push(path); - continue; - } - if path.extension().and_then(|extension| extension.to_str()) == Some("rs") { - files.push(path); - } - } - } - files - .into_iter() - .map(|path| { - let source = std::fs::read_to_string(&path) - .expect("dungeon source file reads") - .replace("\r\n", "\n"); - // The earliest test-module marker in the file, mirroring the - // one runtime.rs's own scan splits on; a file with neither - // marker (for example main.rs) has no test module, so its - // whole source is production. - let production = ["\n#[cfg(test)]\nmod tests {", "\n#[cfg(test)]\npub(crate) mod tests {"] - .into_iter() - .filter_map(|marker| source.split_once(marker).map(|(before, _)| before.len())) - .min() - .map(|len| source[..len].to_owned()) - .unwrap_or(source); - (path, production) - }) - .collect() - } - - /// PA.f11: P1.1 and P1.2 promise that no Dungeon code path submits a clock - /// tick or runs a Persona, operational or elaboration lane outside a - /// turn. Neither promise had a test; Soul re-added a 30-second - /// `submit_clock` loop and a spawned `sweep` and both passed 46/46. This - /// is the fix: a source-scan test on the precedent below, banning the - /// deleted forbidden writers' own names from every file Dungeon owns. - /// `PersonaLane` (Cut 8) is deliberately not named here: dispatching a - /// Persona through it is the owner this cut clears the ground for, not a - /// forbidden writer. - #[test] - fn soul_no_forbidden_writer_name_appears_in_dungeons_own_source() { - let needles = [ - "submit_clock(", - ".elaborator(", - "run_narrative(", - "run_operational(", - "run_cell(", - "ElaborationRunner", - ]; - for (path, production) in dungeon_non_test_source() { - for needle in needles { - assert!( - !production.contains(needle), - "{} names {needle}", - path.display() - ); - } - } - } - - /// A missing credentials path is the fail-closed default: no consumer is - /// configured, and startup proceeds. A path that names a file that exists - /// but does not decode is a different situation entirely -- a mistyped or - /// corrupted credential -- and must not be swallowed into the same empty - /// registry. It fails startup instead. - #[test] - fn a_malformed_consumer_credentials_file_refuses_the_registry_rather_than_starting_empty() { - // Serialize against any other test in this binary that touches the - // same process-wide environment variable. - static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - let _guard = ENV_LOCK.lock().unwrap_or_else(|poison| poison.into_inner()); - - let previous = std::env::var(ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT).ok(); - let directory = tempfile::tempdir().unwrap(); - let path = directory.path().join("consumers.cc"); - std::fs::write(&path, b"not a consumer credentials file").unwrap(); - // SAFETY: serialized by ENV_LOCK above; no other thread in this test - // binary reads or writes this variable concurrently. - unsafe { - std::env::set_var( - ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT, - path.as_os_str(), - ); - } - let result = open_consumer_registry(); - // SAFETY: same lock, restoring (or clearing) the prior value. - unsafe { - match &previous { - Some(value) => { - std::env::set_var(ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT, value) - } - None => std::env::remove_var(ghostlight::CONSUMER_CREDENTIALS_ENVIRONMENT), - } - } - assert!( - result.is_err(), - "a malformed credentials file must refuse the registry, not start empty" - ); - } - - // ---- The seed command ------------------------------------------------ - - /// Spec test 1. The previous create schema is not kept alive beside - /// `world_create.v4`: a complete payload announcing it dies at validation before any handler runs, - /// and a payload that announces v4 but omits the lens weights or the brief - /// is a payload error rather than a defaulted intent. None creates a world. - #[tokio::test] - async fn a_stale_create_payload_is_refused() { - let fixture = fixture().await; - let stale = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v3", - 0, - json!({"title":"Stale World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[]}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(stale["receipt"]["state"], "denied"); - assert!(current_world(&fixture.state).await.unwrap().is_none()); - - let unweighted = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({"title":"Unweighted World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[]}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(unweighted["receipt"]["state"], "denied"); - // Refused as a payload, before genesis: a defaulted empty set would - // also be denied, by the resolver, and must not pass for this. - let message = unweighted["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.contains("missing field `lens_weights`"), - "the omission was not refused at the payload: {message}" - ); - assert!( - current_world(&fixture.state).await.unwrap().is_none(), - "a v4 payload with no lens weights created a world anyway" - ); - - let partial = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({"title":"Half World","subject_label":"Operator","targets":{},"jurisdictions":[],"lens_weights":{"patina":1,"charter":1,"ledger":1,"hearth":1,"tangle":1,"veil":1,"ember":1,"numen":1}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(partial["receipt"]["state"], "denied"); - assert!( - current_world(&fixture.state).await.unwrap().is_none(), - "a v4 payload with no brief created a world anyway" - ); - } - - /// A lens name the library does not know, beside one it does, is refused - /// by deserialization before any handler runs. No world is created. - #[tokio::test] - async fn a_create_payload_naming_an_unknown_lens_is_refused() { - let fixture = fixture().await; - let result = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({"title":"Tribunal World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[],"lens_weights":{"patina":1,"tribunal":1}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(result["receipt"]["state"], "denied"); - let message = result["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.contains("tribunal"), - "the unknown lens was not refused by name: {message}" - ); - assert!(current_world(&fixture.state).await.unwrap().is_none()); - } - - /// Every lens named, every weight zero: the payload decodes, and the - /// library's genesis refuses it with `LensWeightsNeverDraw`. No world. - #[tokio::test] - async fn a_create_payload_whose_lenses_never_draw_is_refused() { - let fixture = fixture().await; - let result = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.create", - "ghostlight.world_create.v4", - 0, - json!({"title":"Still World","brief":"","subject_label":"Operator","targets":{},"jurisdictions":[],"lens_weights":{"patina":0,"charter":0,"ledger":0,"hearth":0,"tangle":0,"veil":0,"ember":0,"numen":0}}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(result["receipt"]["state"], "denied"); - let message = result["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.contains("LensWeightsNeverDraw"), - "the refusal did not name the draw rule: {message}" - ); - assert!(current_world(&fixture.state).await.unwrap().is_none()); - } - - /// Every `control.button` in a surface, at whatever depth. - fn surface_buttons(node: &Value, into: &mut Vec) { - if node["kind"] == "control.button" - && let Some(command) = node["props"]["command"].as_str() - { - into.push(command.to_owned()); - } - for child in node["children"].as_array().into_iter().flatten() { - surface_buttons(child, into); - } - } - - /// Spec test 11. An operation can be emitted by the panel, handled by - /// `execute_world`, and still be dead because `operation_schema` does not - /// name it — which is exactly what `world.advance_time` was. Every button - /// the panel emits and every descriptor it advertises must resolve, and the - /// descriptor's schema must be the one validation will demand. - #[tokio::test] - async fn every_operation_the_panel_emits_has_a_schema() { - let fixture = fixture().await; - let owner = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap() - .account_subject_hash() - .to_owned(); - let stranger = "someone-else"; - let mut surfaces = vec![eve::authenticated_surface(&owner, None, None, true).unwrap()]; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - assert_eq!(draft.phase, WorldPhase::Draft); - for account in [owner.as_str(), stranger] { - surfaces.push(eve::authenticated_surface(account, Some(&draft), None, true).unwrap()); - } - for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { - let snapshot = fixture.state.world.snapshot().await.unwrap(); - let principal = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap(); - fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: snapshot.world_id, - expected_revision: snapshot.revision, - body, - }, - &principal, - ) - .await - .unwrap(); - } - let active = fixture.state.world.snapshot().await.unwrap(); - assert_eq!(active.phase, WorldPhase::Active); - let play_view = current_play_view(&fixture.state).await; - for account in [owner.as_str(), stranger] { - surfaces.push( - eve::authenticated_surface(account, Some(&active), play_view.as_ref(), true).unwrap(), - ); - } - surfaces.push(eve::anonymous_surface()); - - let mut seen = 0usize; - let mut saw_world_play = false; - for surface in &surfaces { - let mut buttons = Vec::new(); - surface_buttons(&surface["surface"]["root"], &mut buttons); - assert!(!buttons.is_empty()); - for command in &buttons { - assert!( - eve::operation_schema(command).is_some(), - "the panel emits {command}, which Ghostlight does not advertise" - ); - saw_world_play |= command == "world.play"; - seen += 1; - } - for descriptor in surface["commands"].as_array().unwrap() { - let command = descriptor["command"].as_str().unwrap(); - assert_eq!( - eve::operation_schema(command), - descriptor["payloadSchema"].as_str(), - "the descriptor for {command} names a schema validation will refuse" - ); - } - } - assert!(seen > 5, "the walk found almost nothing to check"); - // Cut 9: the play card's own button is now part of this walk — it - // was not, before Cut 9 published its descriptor (PA.f146). - assert!(saw_world_play, "the panel must emit world.play in Active"); - } - - /// Spec test 12. Seeding is the owner's lane and Draft's lane, and both - /// refusals land before the request reaches a paid endpoint or a vault. - #[tokio::test] - async fn world_seed_is_owner_only_and_draft_only_before_it_spends_anything() { - let fixture = fixture().await; - let stranger = fixture - .state - .sessions - .lock() - .await - .create_session(heimdall::VerifiedSessionAdmission::fixture( - "stranger-account", - "heimdall-stranger", - 1, - Utc::now() + chrono::Duration::hours(1), - Utc::now() + chrono::Duration::days(1), - "stranger-refresh", - )) - .unwrap(); - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - - // The vault root is deliberately unset: neither refusal may reach it. - assert!(fixture.state.seed_vault_root.is_none()); - let denied = post( - &fixture.state, - &stranger, - invocation( - "world.seed", - "ghostlight.world_seed.v1", - draft.revision + 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied"); - assert!( - denied["receipt"]["message"].as_str().unwrap().contains("owner"), - "{denied}" - ); - - for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { - let snapshot = fixture.state.world.snapshot().await.unwrap(); - let principal = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap(); - fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: snapshot.world_id, - expected_revision: snapshot.revision, - body, - }, - &principal, - ) - .await - .unwrap(); - } - let active = fixture.state.world.snapshot().await.unwrap(); - let refused = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.seed", - "ghostlight.world_seed.v1", - active.revision + 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(refused["receipt"]["state"], "accepted"); - assert_eq!(refused["receipt"]["outcome"], "not_draft"); - assert_eq!( - fixture.state.world.snapshot().await.unwrap().revision, - active.revision, - "a refused seed still moved the world" - ); - } - - /// The seed refusal must be a projection of the world and the player's - /// own act (invariant 8), never a server configuration name: an - /// unconfigured vault root is this daemon's own operational problem, not - /// a fact about the world worth teaching the player. Names the actual - /// former leak by string, so a regression is caught by name and not only - /// by a vague "does not equal the old message" diff. - /// - /// Mutation: revert `seed_once`'s vault-root refusal back to - /// `format!("{SEED_VAULT_ROOT_ENVIRONMENT} is not configured")` — this - /// test's `assert!(!... .contains("GHOSTLIGHT_SEED_VAULT_ROOT"))` would - /// then fail. - #[tokio::test] - async fn the_unconfigured_seed_vault_refusal_does_not_name_the_environment_variable() { - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - - assert!(fixture.state.seed_vault_root.is_none()); - let denied = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.seed", - "ghostlight.world_seed.v1", - draft.revision + 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied"); - let message = denied["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - !message.contains(SEED_VAULT_ROOT_ENVIRONMENT), - "the player-facing refusal must not name the server's own environment variable: {denied}" - ); - assert_eq!( - message, "invalid command payload: seeding is not available on this world", - "{denied}" - ); - } - - /// Spec test 13. The card's rows are the deficit rows, and the shortfall it - /// names is the row the runner will actually select. - #[tokio::test] - async fn the_seed_card_projects_the_deficit_it_will_answer() { - let fixture = fixture().await; - let owner = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap() - .account_subject_hash() - .to_owned(); - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 9)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - let surface = eve::authenticated_surface(&owner, Some(&draft), None, true).unwrap(); - let encoded = serde_json::to_string(&surface).unwrap(); - let card = surface["surface"]["root"]["children"] - .as_array() - .unwrap() - .iter() - .find(|child| child["id"] == "world.seed.card") - .expect("the seed card"); - assert_eq!( - card["children"].as_array().unwrap().len(), - draft.scale_deficit.len() - ); - let selected = ghostlight::select_row(&draft).expect("a shortfall to answer"); - assert_eq!(selected.target, 9); - assert!( - card["props"]["nextShortfall"] - .as_str() - .unwrap() - .contains(&format!("short {}", selected.deficit)), - "{card}" - ); - assert!( - card["props"]["detail"] - .as_str() - .unwrap() - .contains(&format!("{}", draft.revision.saturating_sub(1))), - ); - assert!(encoded.contains("world.seed"), "the button is missing"); - } - - /// The control node a surface binds to `binding`: what the vendored - /// lowering's `findAuthoredBindingValue` looks up when a button is clicked. - fn control_for_binding<'a>(node: &'a Value, binding: &str) -> Option<&'a Value> { - let bound = node["stateBindings"] - .as_array() - .into_iter() - .flatten() - .any(|state| state["bindingName"] == binding); - if bound { - return Some(node); - } - node["children"] - .as_array() - .into_iter() - .flatten() - .find_map(|child| control_for_binding(child, binding)) - } - - fn button_for_command<'a>(node: &'a Value, command: &str) -> Option<&'a Value> { - if node["kind"] == "control.button" && node["props"]["command"] == command { - return Some(node); - } - node["children"] - .as_array() - .into_iter() - .flatten() - .find_map(|child| button_for_command(child, command)) - } - - /// What the vendored lowering sends when `command`'s button is clicked and - /// the player has typed only into the controls named in `typed`: the - /// button's own action fields beside `bindings`, where a binding takes the - /// typed text, else the control's authored `value`, else is omitted. Every - /// input is read off the served surface, so the payload is whatever the - /// production surface would make a real client send. - fn untouched_intent(surface: &Value, command: &str, typed: &[(&str, Value)]) -> Value { - let root = &surface["surface"]["root"]; - let button = button_for_command(root, command) - .unwrap_or_else(|| panic!("the surface carries no {command} button")); - let mut payload = button["props"]["action"].as_object().cloned().unwrap_or_default(); - payload.remove("command"); - let mut bindings = serde_json::Map::new(); - for binding in button["props"]["captureBindings"].as_array().into_iter().flatten() { - let binding = binding.as_str().unwrap(); - if let Some((_, value)) = typed.iter().find(|(name, _)| *name == binding) { - bindings.insert(binding.to_owned(), value.clone()); - } else if let Some(authored) = control_for_binding(root, binding) - .and_then(|control| control["props"].get("value")) - { - bindings.insert(binding.to_owned(), authored.clone()); - } - } - payload.insert("bindings".into(), Value::Object(bindings)); - let descriptor = surface["commands"] - .as_array() - .unwrap() - .iter() - .find(|descriptor| descriptor["command"] == command) - .unwrap_or_else(|| panic!("the surface advertises no {command} descriptor")); - invocation( - command, - descriptor["payloadSchema"].as_str().unwrap(), - surface["version"].as_u64().unwrap(), - Value::Object(payload), - &uuid::Uuid::new_v4().to_string(), - ) - } - - /// PA.f214, at the layer where it failed: a form left untouched must still - /// submit a payload the route admits. The player types only where an empty - /// answer has no meaning (a world's title and first name, how many minutes - /// to advance); every other control is left exactly as the surface served - /// it, and every bound command must then be admitted, not refused as a - /// missing field. Play with an untouched box sends `text: ""`. - #[tokio::test] - async fn every_bound_command_admits_the_payload_an_untouched_form_submits() { - let fixture = fixture().await; - let mut exercised = Vec::new(); - let steps: [(&str, Vec<(&str, Value)>); 5] = [ - ( - "world.create", - vec![ - ("title", json!("Untouched Form World")), - ("subject_label", json!("The Operator")), - ], - ), - ("world.approve", vec![]), - ("world.activate", vec![]), - ("world.advance_time", vec![("minutes", json!(1))]), - ("world.play", vec![]), - ]; - for (command, typed) in steps { - let surface = - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let intent = untouched_intent(&surface, command, &typed); - let sent = intent["payload"].clone(); - let result = post(&fixture.state, &fixture.cookie, intent).await; - if command == "world.play" { - // No turn is running here, so the empty box is refused by the - // play table itself (a new turn needs text). That refusal is - // the proof: the payload reached the table as `text: ""` - // instead of dying at the route as a missing field. Continuing - // a Running turn on that empty text is the table's own rule - // (`empty_text_while_the_turn_is_running_continues_it`). - assert_eq!(sent["bindings"]["text"], "", "{sent}"); - let message = result["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.contains("a new turn needs non-empty text"), - "an untouched Play box did not reach the play table: {result}" - ); - } else { - assert_eq!( - result["receipt"]["state"], "accepted", - "{command} with an untouched form ({sent}) was not admitted: {result}" - ); - } - exercised.push(command); - } - // The walk reaches every button that captures a binding on the Active - // surface, so a control added later without an authored value or a - // place in this walk fails here rather than in a player's hands. - let active = - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let mut buttons = Vec::new(); - surface_buttons(&active["surface"]["root"], &mut buttons); - for command in buttons { - let bound = button_for_command(&active["surface"]["root"], &command).unwrap()["props"] - ["captureBindings"] - .as_array() - .unwrap() - .len(); - assert!( - bound == 0 || exercised.contains(&command.as_str()), - "{command} captures bindings but the untouched-form walk never submitted it" - ); - } - } - - /// PA.f213: the seed controls, card and descriptor exist only where a vault - /// is configured; with none, pressing the button could only be refused. - #[tokio::test] - async fn the_seed_controls_are_offered_only_where_a_vault_is_configured() { - let fixture = fixture().await; - let owner = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap() - .account_subject_hash() - .to_owned(); - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - let seed_nodes = [ - "world.seed.vault_scope", - "world.seed.brief", - "world.seed", - "world.seed.card", - ]; - let node_ids = |surface: &Value| -> Vec { - fn walk(node: &Value, into: &mut Vec) { - if let Some(id) = node["id"].as_str() { - into.push(id.to_owned()); - } - for child in node["children"].as_array().into_iter().flatten() { - walk(child, into); - } - } - let mut ids = Vec::new(); - walk(&surface["surface"]["root"], &mut ids); - ids - }; - let described = |surface: &Value| { - surface["commands"] - .as_array() - .unwrap() - .iter() - .any(|descriptor| descriptor["command"] == "world.seed") - }; - - let without = eve::authenticated_surface(&owner, Some(&draft), None, false).unwrap(); - let ids = node_ids(&without); - for id in seed_nodes { - assert!(!ids.iter().any(|node| node == id), "{id} offered with no vault"); - } - assert!(!described(&without), "world.seed advertised with no vault"); - - let with = eve::authenticated_surface(&owner, Some(&draft), None, true).unwrap(); - let ids = node_ids(&with); - for id in seed_nodes { - assert!(ids.iter().any(|node| node == id), "{id} missing with a vault"); - } - assert!(described(&with), "world.seed not advertised with a vault"); - - // The served surface reads the root from the state it is served with: - // nothing configured offers nothing, and a configured root offers the - // lane without the root's own text ever reaching the card. - let served = - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - assert!(!described(&served), "the served surface offered seeding with no vault"); - assert!(!node_ids(&served).iter().any(|id| id == "world.seed")); - - let mut configured = fixture.state.clone(); - configured.seed_vault_root = Some(PathBuf::from("/canary-vault-root-4f9a1c")); - let served = get(&configured, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - assert!(described(&served), "the served surface hid seeding with a vault"); - assert!(node_ids(&served).iter().any(|id| id == "world.seed")); - assert!( - !serde_json::to_string(&served).unwrap().contains("canary-vault-root"), - "the configured root reached the surface" - ); - } - - /// The root the server opens with comes from `GHOSTLIGHT_SEED_VAULT_ROOT` - /// and from nothing else the environment holds. - /// - /// Mutation: look up another name, or ignore the answer, in - /// `seed_vault_root_from_environment`. - #[test] - fn the_vault_root_is_read_from_the_seed_variable_only() { - use std::ffi::OsString; - let environment = |entries: &'static [(&'static str, &'static str)]| { - move |name: &str| { - entries - .iter() - .find(|(key, _)| *key == name) - .map(|(_, value)| OsString::from(*value)) - } - }; - assert_eq!( - seed_vault_root_from_environment(environment(&[( - "GHOSTLIGHT_SEED_VAULT_ROOT", - "/vault" - )])), - Some(PathBuf::from("/vault")) - ); - assert_eq!( - seed_vault_root_from_environment(environment(&[ - ("GHOSTLIGHT_VAULT_ROOT", "/other"), - ("HOME", "/home"), - ])), - None - ); - } - - /// What counts as a configured vault root is decided in one place. An - /// absent, empty or whitespace-only value is no configuration, so a - /// server started with a blank variable offers no seeding and refuses a - /// forged `world.seed` with the same fixed message as an unset one, which - /// never echoes the value it was started with. - /// - /// Mutation: drop the `.trim()` in `seed_vault_root_from_environment` and the - /// whitespace cases below answer `Some`. - #[tokio::test] - async fn a_blank_vault_root_is_no_configuration_and_its_refusal_echoes_nothing() { - use std::ffi::OsString; - assert_eq!(seed_vault_root_from_environment(|_| None), None); - for blank in ["", " ", " - "] { - assert_eq!( - seed_vault_root_from_environment(|_| Some(OsString::from(blank))), - None, - "{blank:?} counted as a configured vault root" - ); - } - assert_eq!( - seed_vault_root_from_environment(|_| Some(OsString::from("/vault"))), - Some(PathBuf::from("/vault")) - ); - - // A blank value in the state a server would build from it refuses a - // forged seed with the fixed message, never the value. - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - let mut state = fixture.state.clone(); - state.seed_vault_root = seed_vault_root_from_environment(|_| Some(OsString::from(" "))); - let denied = post( - &state, - &fixture.cookie, - invocation( - "world.seed", - "ghostlight.world_seed.v1", - draft.revision + 1, - json!({"vault_scope":"canary-scope-7c2e","brief":""}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied", "{denied}"); - let message = denied["receipt"]["message"].as_str().unwrap_or_default(); - assert_eq!( - message, "invalid command payload: seeding is not available on this world", - "{denied}" - ); - } - - /// `SeedPort` holds one `VerifiedPrincipalEvidence` for the whole session, - /// and a multi-round session can run long past the moment the evidence was - /// minted. `VerifiedPrincipalEvidence` now carries `valid_until`, minted - /// from the session's own `access_expires_at`, and `submit_principal` - /// reads the wall clock at ingress and refuses anything presented after - /// that moment — so evidence cannot outlive the session that vouched for - /// it, whatever the port itself still believes. - #[tokio::test] - async fn soul_verified_evidence_outlives_the_session_that_minted_it() { - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::new(), - Vec::new(), - ) - .await; - let live = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .expect("the fixture cookie names a live session"); - // Same account as the live evidence, but minted with an expiry that - // has already passed. - let expired = VerifiedPrincipalEvidence::new( - live.account_subject_hash().to_owned(), - Utc::now() - chrono::Duration::seconds(1), - ); - - let before = fixture.state.world.snapshot().await.unwrap(); - let committed = fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: before.world_id, - expected_revision: before.revision, - // `ApproveDraft`, because it is refused for anyone but a - // required approver and its effect is visible in the - // snapshot. What is under test is whether an expired - // claim is spent at all. - body: CommandBody::ApproveDraft, - }, - &expired, - ) - .await; - assert!( - committed.is_err(), - "evidence minted with a past valid_until still committed: {committed:?}" - ); - let after = fixture.state.world.snapshot().await.unwrap(); - assert_eq!( - after.revision, before.revision, - "an expired submission moved the world" - ); - assert!(after.draft_approvals.is_empty()); - } - - /// Sibling to the above: evidence still inside its own window is not - /// touched by the expiry gate, so the same account submitting the same - /// command before `valid_until` commits normally. - #[tokio::test] - async fn soul_verified_evidence_within_its_window_still_commits() { - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::new(), - Vec::new(), - ) - .await; - let live = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .expect("the fixture cookie names a live session"); - - let before = fixture.state.world.snapshot().await.unwrap(); - let committed = fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: before.world_id, - expected_revision: before.revision, - body: CommandBody::ApproveDraft, - }, - &live, - ) - .await; - assert!(committed.is_ok(), "{committed:?}"); - let after = fixture.state.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision + 1); - } -} - -/// The refresh loop against a scripted Heimdall: it answers each call from a -/// queue and records the claim and idempotency key it was sent. -#[cfg(test)] -mod refresh_loop_tests { - use super::*; - use crate::heimdall::{AuthCompletionReceipt, VerificationUnavailable, VerifiedSessionRefresh}; - use std::{collections::VecDeque, sync::Mutex as StdMutex}; - - enum Reply { - Deny, - Timeout, - Receipt { revision: u64, claim: &'static str }, - /// A receipt for the same claim but another Heimdall session. - OtherSession, - /// A receipt for the same claim but another account. - OtherAccount, - } - - /// What `verify_refresh` answers; an empty queue accepts the receipt. - enum Verdict { - /// The signing keys could not be fetched. - Unavailable, - /// The receipt positively fails verification. - Invalid, - } - - #[derive(Default)] - struct ScriptedHeimdall { - replies: StdMutex>, - verdicts: StdMutex>, - verifications: StdMutex, - /// While answering a refresh, apply a newer revision to this store, - /// as a concurrent refresh of the same session would. - moving: Option>>, - pending: StdMutex>, - calls: StdMutex>, - /// Verify receipts with this real client, against the access token - /// each receipt carries, instead of from the verdict queue. - real: Option<(HeimdallClient, String)>, - } - - impl ScriptedHeimdall { - fn answering(replies: Vec) -> Self { - Self { - replies: StdMutex::new(replies.into()), - ..Self::default() - } - } - - fn verifying(mut self, verdicts: Vec) -> Self { - self.verdicts = StdMutex::new(verdicts.into()); - self - } - - fn verifying_for_real(mut self, client: HeimdallClient, token: String) -> Self { - self.real = Some((client, token)); - self - } - - fn verifications(&self) -> usize { - *self.verifications.lock().unwrap() - } - - fn calls(&self) -> Vec<(String, String)> { - self.calls.lock().unwrap().clone() - } - } - - #[async_trait::async_trait] - impl SessionRefresher for ScriptedHeimdall { - async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - self.calls - .lock() - .unwrap() - .push((refresh_token.into(), idempotency_key.into())); - if let Some(sessions) = &self.moving { - let mut sessions = sessions.lock().await; - let candidate = sessions - .sessions_due_for_refresh(Utc::now(), chrono::Duration::minutes(2)) - .unwrap() - .remove(0); - sessions - .apply_refresh( - &candidate.cookie_hash, - candidate.access_revision, - VerifiedSessionRefresh::fixture( - "operator-account", - "heimdall-session", - candidate.access_revision + 1, - Utc::now() + chrono::Duration::seconds(90), - Utc::now() + chrono::Duration::days(1), - "claim-moved", - ), - ) - .unwrap(); - } - match self.replies.lock().unwrap().pop_front().expect("scripted reply") { - Reply::Deny => Err(HeimdallDenied { - diagnostics: "Idempotency key was reused".into(), - } - .into()), - Reply::Timeout => bail!("Heimdall private command timed out"), - reply @ (Reply::Receipt { .. } | Reply::OtherSession | Reply::OtherAccount) => { - let (account, session, revision, claim) = match reply { - Reply::OtherSession => ("operator-account", "other-session", 1, "claim-1"), - Reply::OtherAccount => ("other-account", "heimdall-session", 1, "claim-1"), - Reply::Receipt { revision, claim } => { - ("operator-account", "heimdall-session", revision, claim) - } - _ => unreachable!("matched above"), - }; - *self.pending.lock().unwrap() = Some(VerifiedSessionRefresh::fixture( - account, - session, - revision, - Utc::now() + chrono::Duration::seconds(90), - Utc::now() + chrono::Duration::days(1), - claim, - )); - Ok(AuthCompletionReceipt { - status: "authenticated".into(), - handle: None, - error: None, - account: None, - session: None, - access_token: self.real.as_ref().map(|(_, token)| token.clone()), - refresh_token: None, - refresh: None, - shared_capabilities: Vec::new(), - }) - } - } - } - - async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - *self.verifications.lock().unwrap() += 1; - if let Some((client, _)) = &self.real { - return client.verify_refresh(completion).await; - } - match self.verdicts.lock().unwrap().pop_front() { - Some(Verdict::Unavailable) => { - Err(VerificationUnavailable::new("timed out").into()) - } - Some(Verdict::Invalid) => bail!("Heimdall access token signature is invalid"), - None => Ok(self.pending.lock().unwrap().take().expect("scripted receipt")), - } - } - } - - /// A store holding one live session at `revision` whose access expires in - /// a minute, so every pass finds it due. - fn due_session( - revision: u64, - ) -> (tempfile::TempDir, std::sync::Arc>, String) { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [23_u8; 32]).unwrap(); - let mut owner = AppSessionOwner::open(directory.path().join("sessions.cc"), &key).unwrap(); - let cookie = owner - .create_session(heimdall::VerifiedSessionAdmission::fixture( - "operator-account", - "heimdall-session", - revision, - Utc::now() + chrono::Duration::seconds(60), - Utc::now() + chrono::Duration::days(1), - "claim-0", - )) - .unwrap(); - (directory, std::sync::Arc::new(Mutex::new(owner)), cookie) - } - - /// One refresh pass with no receipt carried over from an earlier one. - async fn pass(sessions: &Mutex, heimdall: &ScriptedHeimdall) { - refresh_due_sessions(sessions, heimdall, &mut HashMap::new()) - .await - .unwrap(); - } - - async fn is_live(sessions: &Mutex, cookie: &str) -> bool { - sessions - .lock() - .await - .account_for_cookie(cookie, Utc::now()) - .unwrap() - .is_some() - } - - #[derive(Clone, Default)] - struct Captured(std::sync::Arc>>); - - impl std::io::Write for Captured { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Captured { - type Writer = Captured; - - fn make_writer(&'a self) -> Captured { - self.clone() - } - } - - #[tokio::test] - async fn a_real_refresh_at_an_equal_revision_is_applied_and_the_session_stays_live() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]); - pass(&sessions, &heimdall).await; - assert!(is_live(&sessions, &cookie).await); - // The wrapped claim is the receipt's: the next refresh spends it. - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls()[1].0, "claim-1"); - assert!(is_live(&sessions, &cookie).await); - } - - #[tokio::test] - async fn a_retry_of_the_same_refresh_sends_the_same_key() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Timeout, Reply::Timeout]); - pass(&sessions, &heimdall).await; - pass(&sessions, &heimdall).await; - let calls = heimdall.calls(); - assert_eq!(calls.len(), 2, "a transport failure leaves the session offered"); - assert_eq!(calls[0], calls[1]); - assert!(is_live(&sessions, &cookie).await); - } - - #[tokio::test] - async fn the_refresh_after_a_successful_one_sends_a_new_key() { - let (_directory, sessions, _cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]); - pass(&sessions, &heimdall).await; - pass(&sessions, &heimdall).await; - let calls = heimdall.calls(); - assert_ne!(calls[0].1, calls[1].1); - assert!(calls.iter().all(|(claim, key)| !key.contains(claim))); - } - - #[tokio::test] - async fn a_receipt_with_a_lower_revision_is_refused_and_the_session_revoked() { - let (_directory, sessions, cookie) = due_session(2); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]); - pass(&sessions, &heimdall).await; - assert!(!is_live(&sessions, &cookie).await); - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls().len(), 1, "a revoked session is not offered again"); - } - - #[tokio::test] - async fn a_receipt_for_another_session_or_account_revokes_the_local_session() { - for reply in [Reply::OtherSession, Reply::OtherAccount] { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![reply]); - pass(&sessions, &heimdall).await; - assert!(!is_live(&sessions, &cookie).await); - } - } - - #[tokio::test] - async fn a_heimdall_denial_revokes_the_session_and_it_is_not_offered_again() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Deny, Reply::Timeout]); - pass(&sessions, &heimdall).await; - assert!(!is_live(&sessions, &cookie).await); - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls().len(), 1); - } - - #[tokio::test] - async fn a_local_refusal_logs_the_clause_that_fired() { - let (_directory, sessions, _cookie) = due_session(2); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]); - let captured = Captured::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(captured.clone()) - .with_ansi(false) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); - pass(&sessions, &heimdall).await; - let logged = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap(); - assert!( - logged.contains("refresh receipt revision went backwards"), - "{logged}" - ); - } - - #[tokio::test] - async fn a_receipt_whose_keys_were_unavailable_is_kept_and_applied_on_the_next_pass() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]) - .verifying(vec![Verdict::Unavailable]); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert!(is_live(&sessions, &cookie).await, "unavailable keys do not revoke"); - assert_eq!(unverified.len(), 1); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(heimdall.calls().len(), 1, "the spent claim is not sent again"); - assert_eq!(heimdall.verifications(), 2); - assert!(unverified.is_empty(), "an applied receipt is not kept"); - assert!(is_live(&sessions, &cookie).await); - // The applied receipt's claim is the one the next refresh spends. - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(heimdall.calls()[1].0, "claim-1"); - assert_eq!(heimdall.verifications(), 2, "the receipt was applied once"); - } - - #[tokio::test] - async fn a_receipt_that_positively_fails_verification_revokes_and_is_not_kept() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]) - .verifying(vec![Verdict::Invalid]); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert!(!is_live(&sessions, &cookie).await); - assert!(unverified.is_empty()); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(heimdall.calls().len(), 1); - assert_eq!(heimdall.verifications(), 1); - } - - #[tokio::test] - async fn a_kept_receipt_for_a_session_that_is_gone_is_dropped() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]) - .verifying(vec![Verdict::Unavailable]); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(unverified.len(), 1); - let cookie_hash = unverified.keys().next().unwrap().clone(); - sessions.lock().await.revoke_cookie_hash(&cookie_hash).unwrap(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert!(unverified.is_empty()); - assert!(!is_live(&sessions, &cookie).await); - assert_eq!(heimdall.verifications(), 1); - } - - #[tokio::test] - async fn a_session_that_moved_while_refreshing_is_not_revoked() { - let (_directory, sessions, cookie) = due_session(1); - let mut heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]); - heimdall.moving = Some(sessions.clone()); - pass(&sessions, &heimdall).await; - assert!(is_live(&sessions, &cookie).await); - // The newer state wins: the next refresh spends its claim, not the - // stale receipt's. - heimdall.moving = None; - heimdall.replies.lock().unwrap().push_back(Reply::Timeout); - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls()[1].0, "claim-moved"); - } - - #[tokio::test] - async fn a_receipt_with_a_higher_revision_is_applied() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 2, claim: "claim-1" }, - Reply::Receipt { revision: 1, claim: "claim-2" }, - ]); - pass(&sessions, &heimdall).await; - assert!(is_live(&sessions, &cookie).await); - // The session now sits at revision 2, so a revision-1 receipt is - // backwards. - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls()[1].0, "claim-1"); - assert!(!is_live(&sessions, &cookie).await); - } - - /// Lets the loop under test run everything that is ready, without moving - /// the paused clock. - async fn settle() { - for _ in 0..16 { - tokio::task::yield_now().await; - } - } - - #[tokio::test(start_paused = true)] - async fn the_refresh_loop_runs_a_pass_every_sixty_seconds_and_keeps_going() { - let (_directory, sessions, _cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering((0..64).map(|_| Reply::Timeout).collect()); - let observed = async { - settle().await; - assert_eq!(heimdall.calls().len(), 1, "the first pass runs at once"); - tokio::time::advance(Duration::from_millis(59_999)).await; - settle().await; - assert_eq!(heimdall.calls().len(), 1, "no pass before sixty seconds"); - tokio::time::advance(Duration::from_millis(1)).await; - settle().await; - assert_eq!(heimdall.calls().len(), 2, "the second pass at sixty seconds"); - for passes in 3..=12 { - tokio::time::advance(Duration::from_secs(60)).await; - settle().await; - assert_eq!(heimdall.calls().len(), passes); - } - }; - tokio::select! { - ran = run_refresh_loop(&sessions, &heimdall) => panic!("the loop returned: {ran:?}"), - () = observed => {} - } - } - - /// A refresh receipt verified by the real client against `issuer_url`, - /// whose key-set fetch fails. Returns whether the session stayed live, - /// how many receipts were kept, and the log the pass wrote. - async fn pass_through_real_verification(issuer_url: &str) -> (bool, usize, String) { - let (_directory, sessions, cookie) = due_session(1); - let client = HeimdallClient::fixture().with_issuer(issuer_url); - let token = heimdall::test_issuer::signed_token(issuer_url); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]) - .verifying_for_real(client, token); - let captured = Captured::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(captured.clone()) - .with_ansi(false) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - let logged = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap(); - (is_live(&sessions, &cookie).await, unverified.len(), logged) - } - - #[tokio::test] - async fn a_key_fetch_failure_in_the_real_verification_keeps_the_session_and_the_receipt() { - let failing = heimdall::test_issuer::Issuer::start(heimdall::test_issuer::Answer::ServerError) - .await; - for issuer_url in [heimdall::test_issuer::Issuer::closed_url(), failing.url.clone()] { - let (live, kept, logged) = pass_through_real_verification(&issuer_url).await; - assert!(live, "{issuer_url}: a key-fetch failure signed the session out"); - assert_eq!(kept, 1, "{issuer_url}: the spent receipt was not kept"); - assert!(logged.contains("could not be verified yet"), "{logged}"); - } - } - - #[tokio::test] - async fn the_unverified_warning_names_the_error_class_and_no_url() { - let canary = format!("{}/canary-q7x1", heimdall::test_issuer::Issuer::closed_url()); - let (_, _, logged) = pass_through_real_verification(&canary).await; - assert!(logged.contains("could not connect"), "{logged}"); - for leaked in [canary.as_str(), "canary-q7x1", "127.0.0.1", "jwks.json"] { - assert!(!logged.contains(leaked), "{leaked} leaked into the log: {logged}"); - } - } -} diff --git a/crates/ghostlight-dungeon/tests/build_provenance.rs b/crates/ghostlight-dungeon/tests/build_provenance.rs deleted file mode 100644 index 09a4ee1..0000000 --- a/crates/ghostlight-dungeon/tests/build_provenance.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -#[test] -fn embedded_commit_matches_checkout_head() { - let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let output = Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(&repo_root) - .output() - .expect("git must be available for the provenance regression test"); - let checkout_head = if output.status.success() { - String::from_utf8(output.stdout) - .expect("git commit must be UTF-8") - .trim() - .to_owned() - } else { - fs::read_to_string(repo_root.join(".ghostlight-source-commit")) - .expect("frozen source archive must carry its exact commit witness") - .trim() - .to_owned() - }; - assert_eq!( - env!("GHOSTLIGHT_BUILD_COMMIT"), - checkout_head, - "the binary embedded stale Git provenance" - ); -} diff --git a/crates/ghostlight-verse/Cargo.toml b/crates/ghostlight-verse/Cargo.toml new file mode 100644 index 0000000..89dd37f --- /dev/null +++ b/crates/ghostlight-verse/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ghostlight-verse" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +ghostlight = { path = "../ghostlight" } +tokio.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tempfile = "3" diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs new file mode 100644 index 0000000..9c70d06 --- /dev/null +++ b/crates/ghostlight-verse/src/main.rs @@ -0,0 +1,346 @@ +//! The verse runner's operator command line. +//! +//! `genesis` creates the canonical Aetheria verse world in a state root, and +//! `run` advances it a given number of ticks under a token cap, printing per +//! tick what each faction did and what it cost. A run starts only when this +//! command is invoked, and ends when it has done what it was asked. +//! +//! The state root holds `world.cc`, `controller-work.cc`, `runs.cc` and +//! `calls.cc`. The provider key is read from a file at run time and appears +//! nowhere else. + +use anyhow::{Context, Result, anyhow, bail}; +use chrono::{Duration, Utc}; +use ghostlight::{ + CommandBody, CommandId, ControllerModels, ControllerOpenError, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX, + DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent, + HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort, + RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore, + WorldMailbox, call_run_id, id_text, open_controller_work, open_inference, + read_aetheria_catalog, run_manual, +}; +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::ops::ControlFlow; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// What a manual run spends per tick on cognition cells. The operator's lever +/// on cost is the token cap; the partition always covers every faction. +const COVER: CoverBudget = CoverBudget { + cells: 8, + constituent_cap: 8, + urgency_slots: 2, +}; + +const RUNTIME_ID: &str = "ghostlight-verse"; + +#[tokio::main] +async fn main() -> ExitCode { + let arguments: Vec = 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) -> Result { + 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 { + 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(&self, name: &str) -> Result { + match self.text(name).parse::() { + Ok(value) if value > T::default() => Ok(value), + _ => bail!("--{name} is not a positive integer"), + } + } +} + +async fn genesis(flags: Flags) -> Result { + 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 { + 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> { + 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://
:")?; + 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(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", + } +} diff --git a/crates/ghostlight-verse/tests/verse_binary.rs b/crates/ghostlight-verse/tests/verse_binary.rs new file mode 100644 index 0000000..28cf75d --- /dev/null +++ b/crates/ghostlight-verse/tests/verse_binary.rs @@ -0,0 +1,445 @@ +//! The `ghostlight-verse` command against a loopback scripted responder. No +//! real model is called and no key file is read: the responder answers the +//! OpenAI-compatible chat-completions request the local lane sends. + +use serde_json::{Value, json}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Command, Output}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const BINARY: &str = env!("CARGO_BIN_EXE_ghostlight-verse"); +const CATALOG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../ghostlight/tests/fixtures/aetheria-verse-catalog.cc" +); +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +/// What the responder reports each call cost: 120 prompt and 30 completion. +const PER_CALL: u64 = 150; + +fn verse(arguments: &[&str]) -> Output { + Command::new(BINARY).args(arguments).output().unwrap() +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn genesis(state: &Path) -> Output { + verse(&[ + "genesis", + "--catalog", + CATALOG, + "--aetheria-commit", + COMMIT, + "--state", + state.to_str().unwrap(), + "--owner-hash", + "verse-owner", + ]) +} + +/// Answers every chat-completions request with one tool call: the faction the +/// prompt names speaks its own line. Counts the requests it served. +struct Responder { + address: SocketAddr, + served: Arc, +} + +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::().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 = 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}" + ); + } + } +} diff --git a/crates/ghostlight/Cargo.toml b/crates/ghostlight/Cargo.toml index 765334b..7fca500 100644 --- a/crates/ghostlight/Cargo.toml +++ b/crates/ghostlight/Cargo.toml @@ -12,6 +12,7 @@ cultcache-rs.workspace = true ghostlight-persona-projection = { path = "../ghostlight-persona-projection" } reqwest.workspace = true rmp-serde.workspace = true +rmpv = "1.3" serde.workspace = true serde_bytes = "0.11" serde_json.workspace = true @@ -19,6 +20,8 @@ sha2.workspace = true thiserror.workspace = true tokio.workspace = true tracing.workspace = true +unicode-normalization = "0.1.25" +unicode-properties = "0.1.4" uuid.workspace = true zeroize.workspace = true diff --git a/crates/ghostlight/src/aetheria.rs b/crates/ghostlight/src/aetheria.rs new file mode 100644 index 0000000..4db8eaa --- /dev/null +++ b/crates/ghostlight/src/aetheria.rs @@ -0,0 +1,297 @@ +//! Aetheria's catalog file, read once into the two things Ghostlight takes from +//! it: the verse grammar, as a `GrammarBinding`, and the factions, as records +//! keyed for import. +//! +//! The catalog is Aetheria's: this reader opens it read-only and never writes +//! it, and it ignores every schema it does not mirror. The mirrors below decode +//! `aetheria.verse_grammar` v1, `aetheria.verse_verb` v1 and `aetheria.faction` +//! v1 by integer key: a MessagePack-keyed object is an array indexed by key, +//! nil in the gaps, so a field Aetheria adds later at a higher key is ignored +//! and a field it drops is an error naming the record and the key. A record +//! that does not decode, an enum value outside the mirror and a verb that +//! breaks the grammar's name or role rules are errors, never skipped rows. + +use crate::ImportKey; +use crate::grammar::{ + GrammarBinding, GrammarError, GrammarReferentKind, GrammarRole, GrammarVerb, RenderPath, +}; +use cultcache_rs::SingleFileMessagePackBackingStore; +use rmpv::Value; +use std::collections::BTreeMap; +use std::path::Path; + +const GRAMMAR_SCHEMA: &str = "aetheria.verse_grammar"; +const VERB_SCHEMA: &str = "aetheria.verse_verb"; +const FACTION_SCHEMA: &str = "aetheria.faction"; + +const VERB_NAME_MAX: usize = 48; +const ROLE_NAME_MAX: usize = 32; +/// The invoking subject, never a role. +const ACTOR_ROLE: &str = "actor"; + +/// What Ghostlight takes from Aetheria's catalog. +#[derive(Clone, Debug, PartialEq)] +pub struct AetheriaCatalog { + pub grammar: GrammarBinding, + /// In record-key order. + pub factions: Vec, +} + +/// 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, +} + +/// 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) -> Result { + 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, +} + +impl<'a> Cells<'a> { + fn of(record: &'a str, payload: &[u8]) -> Result { + 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 { + self.cell(field)? + .as_i64() + .ok_or_else(|| self.malformed(field)) + } + + fn text(&self, field: u8) -> Result { + 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 { + 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, 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 = 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 { + 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; diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs new file mode 100644 index 0000000..c9333ec --- /dev/null +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -0,0 +1,589 @@ +use super::*; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope}; +use serde_json::{Value, json}; + +/// Written by Aetheria's own fixture writer (`VerseGrammarTests.WriteFixture`) +/// at Aetheria master 92b82e9a; never edited. See `tests/fixtures/aetheria-verse-catalog.provenance.txt`. +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); + +fn fixture_rows() -> Vec { + 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)) { + 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 { + 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)| { + 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 { + 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), +) -> (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)) -> Vec { + 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}" + ); + } +} diff --git a/crates/ghostlight/src/consumer.rs b/crates/ghostlight/src/consumer.rs index 5db9a04..e764dde 100644 --- a/crates/ghostlight/src/consumer.rs +++ b/crates/ghostlight/src/consumer.rs @@ -469,7 +469,7 @@ mod tests { AuthorityTargetRef, CallerId, CausalBoundary, CommandBody, CommandEnvelope, CommitmentKind, ComponentOp, CoverBudget, DecisionOpportunity, DecisionScope, Declaration, DraftHandle, EntityDeclaration, EntityId, EntityKind, EvidenceRef, FactDeclaration, FactStandingRef, - FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, + FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, TickIndex, RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability, WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph, commit_digest, derive_boundaries, derive_cover, derive_opportunities, @@ -518,6 +518,7 @@ mod tests { }, controller, position: Some(Ref::Existing(commons)), + import_key: None, }) }; WorldPatch { @@ -735,8 +736,7 @@ mod tests { assert!(!graph.subjects.contains(&mirror.first)); let cover = derive_cover( snapshot.world_id, - snapshot.now, - 30, + TickIndex(0), &opportunities, &graph, CoverBudget { @@ -1195,6 +1195,7 @@ mod tests { controller: NewController::External { consumer }, affordances: BTreeSet::new(), position: Some(Ref::Existing(commons)), + import_key: None, })]) }; // A binding to another consumer is outside its ground. @@ -1573,6 +1574,7 @@ mod tests { controller, affordances, position: Some(Ref::Existing(mirror.commons)), + import_key: None, })]) }; let pairing = |result: Result| { diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 18d060c..97d7edd 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -11,7 +11,9 @@ use super::elaboration::{ NullEvidenceSource, SeedCheckpoint, SeedRunner, valid_elaboration_progression, valid_seed_progression, }; -use super::local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding, open_local_port}; +use super::local_inference::{ + DEFAULT_LOCAL_MODEL_PREFIX, HostedBinding, LocalBinding, open_hosted_port, open_local_port, +}; use super::sdk_inference::{ ChildProcessLink, DEFAULT_SDK_MODEL_PREFIX, RoutedInferencePort, SdkBinding, SdkInferencePort, }; @@ -76,11 +78,11 @@ pub(super) const CELL_TOOL_STEP_BUDGET: usize = 2; /// carried by tool identity, never by a model-written argument. const HANDLE_SEPARATOR: &str = "__"; const PERSONA_WORD_BUDGET: usize = 180; -const CONTROLLER_WORK_ROW: &str = "controller_work.v17"; +const CONTROLLER_WORK_ROW: &str = "controller_work.v18"; /// The `controller-work` state slot's schema. `pub` so a deploy recipe (or a -/// test pinning one, e.g. `ghostlight-dungeon`'s recipe-drift test) can check +/// test pinning one) can check /// itself against this constant instead of hand-copying the version number. -pub const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v17"; +pub const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v18"; /// The Interpreter's byte-span capture tool. It is not the generated `speak` /// affordance tool: one captures an utterance out of preserved prose, the other @@ -156,6 +158,21 @@ pub enum InferenceEvent { pub struct InferenceOutput { pub(super) events: Vec, pub(super) receipt_digest: String, + /// What the provider reported spending on this reply. `None` is a reply + /// that reported none (the connector lane's receipt carries no counts); + /// it is never a zero. + #[serde(default)] + pub(super) usage: Option, +} + +/// Tokens one reply cost, as the provider counted them. `prompt` is the +/// whole prompt, cache hits included; `cached_prompt` is the part the +/// provider served from its cache, `None` when it reported no cache figure. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TokenUsage { + pub prompt: u64, + pub completion: u64, + pub cached_prompt: Option, } impl InferenceOutput { @@ -165,9 +182,16 @@ impl InferenceOutput { Self { events, receipt_digest: receipt_digest.into(), + usage: None, } } + /// The same output carrying the provider's reported token usage. + pub fn with_usage(mut self, usage: TokenUsage) -> Self { + self.usage = Some(usage); + self + } + pub(super) fn prose_only( self, purpose: InferencePurpose, @@ -226,6 +250,9 @@ pub enum InferenceFaultClass { Status(u16), /// The endpoint's reply was not the declared shape. BadReply, + /// A recording port refused the call because it could pass the run's + /// token cap. Nothing was sent; the run stops and does not retry. + BudgetExhausted, Unclassified, } @@ -276,6 +303,14 @@ impl InferenceFault { Self::new(detail) } + /// The run's token cap would be passed by this call, so it was not made. + /// An integrity violation is the one disposition that stops the owning + /// flow without retrying, which is what a spent budget asks for. + pub(crate) fn budget_exhausted() -> Self { + Self::integrity_violation("the run's token cap would be passed by this call") + .classed(InferenceFaultClass::BudgetExhausted) + } + /// Names what kind of trouble this fault is. pub(super) fn classed(mut self, class: InferenceFaultClass) -> Self { self.class = class; @@ -611,6 +646,7 @@ impl CodexConnectorInferencePort { Ok(InferenceOutput { events, receipt_digest: format!("sha256:{:x}", Sha256::digest(&receipt_bytes)), + usage: None, }) } } @@ -1410,6 +1446,34 @@ fn record_persona_turn( )) } +/// A narrative turn the world overtook, for a driver test that has no +/// narrative subject to run one: the same value `interrupted` builds, bound to +/// the opportunity it is given. +#[cfg(test)] +pub(crate) fn overtaken_narrative_run(opportunity: &DecisionOpportunity) -> NarrativeRun { + let turn = record_persona_turn( + opportunity, + "projector".into(), + "persona".into(), + "Prose.".into(), + ) + .expect("a fixture opportunity encodes"); + NarrativeRun::Interrupted(NarrativeInterruption { + subject: opportunity.scope.subject_id, + bound_scope_digest: opportunity.scope_digest.as_str().to_owned(), + fresh_scope_digest: None, + gap: TranslationGapSummary { + kind: TranslationGapKind::Unresolved, + source: SourceRange { + start_byte: 0, + end_byte: 6, + }, + detail: OVERTAKEN_DETAIL.into(), + }, + turn, + }) +} + /// One opportunity's selection against one snapshot. /// /// The expected controller mode is read from the opportunity rather than @@ -1477,10 +1541,20 @@ fn select_scope( .cloned() .collect::>(); let [opportunity] = matches.as_slice() else { - return if matches.is_empty() { - Err(ControllerError::NoOpportunity { expected }) - } else { + return if !matches.is_empty() { Err(ControllerError::AmbiguousOpportunity) + } else if require_digest + && snapshot + .opportunities + .iter() + .filter(|opportunity| opportunity.scope == exact_opportunity.scope) + .count() + == 1 + { + // The scope is still offered, at a digest the bound run did not see. + Err(ControllerError::ScopeMoved) + } else { + Err(ControllerError::NoOpportunity { expected }) }; }; let granted: Vec = snapshot @@ -1548,12 +1622,15 @@ fn turn_matches_opportunity(turn: &PersonaTurn, opportunity: &DecisionOpportunit && opportunity.scope_digest.as_str() == turn.binding().scope_digest } -/// Two opportunity values that bind the same proposal. The revision is a receipt -/// and an ordering value; the scope digest is the binding. -fn binds_same_scope(left: &DecisionOpportunity, right: &DecisionOpportunity) -> bool { - left.world_id == right.world_id - && left.scope == right.scope - && left.scope_digest == right.scope_digest +/// Two opportunity values that name the same decision: the same world and the +/// same decision scope. This is the one owner of which decision a persisted +/// row answers, for both lanes. The caller's opportunity only names the +/// decision; it never decides staleness. The world does: the operational lane +/// checks `ensure_scope_unchanged` before each round, and the narrative lane +/// runs to the kernel's `ScopeChanged` at submit, where `interrupted` +/// re-lowers the same prose once. +fn same_decision(left: &DecisionOpportunity, right: &DecisionOpportunity) -> bool { + left.world_id == right.world_id && left.scope == right.scope } fn derive_narrative_capture( @@ -2485,7 +2562,11 @@ pub enum ControllerOpenError { SdkSidecarMissing { path: String }, #[error("the local inference endpoint `{endpoint}` is not loopback")] LocalEndpointNotLoopback { endpoint: SocketAddr }, - #[error("the local and SDK inference transports both claim model prefix `{prefix}`")] + #[error("the hosted inference endpoint must be an https URL")] + HostedEndpointNotHttps, + #[error("the hosted inference key file is unreadable, empty, or is not one run of visible ASCII")] + HostedKeyUnreadable, + #[error("two inference transports both claim model prefix `{prefix}`")] SharedModelPrefix { prefix: String }, #[error("the {transport} inference transport's model prefix must not be empty")] EmptyModelPrefix { transport: &'static str }, @@ -2508,6 +2589,7 @@ pub fn open_inference( connector: Option, sdk: Option, local: Option, + hosted: Option, models: &[&str], ) -> Result, ControllerOpenError> { let connector: Option> = match connector { @@ -2551,15 +2633,31 @@ pub fn open_inference( } None => None, }; - if let Some((prefix, _)) = &local - && sdk.is_some() - && *prefix == sdk_model_prefix - { + // The hosted lane is the local port's second binding: the same + // OpenAI-compatible port, an https endpoint and a bearer key read once + // from a file. It claims its own prefix beside the local one. + let hosted: Option<(String, Arc)> = match hosted { + Some(binding) => { + if binding.model_prefix.is_empty() { + return Err(ControllerOpenError::EmptyModelPrefix { transport: "hosted" }); + } + let prefix = binding.model_prefix.clone(); + Some((prefix, open_hosted_port(binding)?)) + } + None => None, + }; + let claims: Vec<(String, Arc)> = local.into_iter().chain(hosted).collect(); + let mut claimed: Vec<&str> = claims.iter().map(|(prefix, _)| prefix.as_str()).collect(); + if sdk.is_some() { + claimed.push(&sdk_model_prefix); + } + claimed.sort_unstable(); + if let Some(pair) = claimed.windows(2).find(|pair| pair[0] == pair[1]) { return Err(ControllerOpenError::SharedModelPrefix { - prefix: prefix.clone(), + prefix: pair[0].to_owned(), }); } - let routed = RoutedInferencePort::new(connector, sdk, sdk_model_prefix, local); + let routed = RoutedInferencePort::new(connector, sdk, sdk_model_prefix, claims); for &model in models { if routed.route(model).is_none() { return Err(ControllerOpenError::UnroutableModel { @@ -2786,6 +2884,46 @@ pub struct ConstituentSubmission { pub submission: SubmissionDisposition, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConstituentRefusal { + pub subject: SubjectId, + pub reason: ConstituentRefusalReason, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConstituentRefusalReason { + /// The subject holds no single decidable opportunity on its scope any more, + /// so it was not selected and nothing was submitted for it. + LeftBeforeSelection, + /// The scope the member was bound to no longer held: found moved at + /// selection (still offered at a new digest, nothing submitted), or refused + /// by the kernel at admission (a neighbour's act moved it). + ScopeChanged, + /// The kernel refused its command for any other reason. + WorldRefused, + /// The member's own tool call could not be decoded and it proposed nothing + /// else, so no turn was submitted for it: a decode failure is not a + /// decision to decline. + ProposalUndecodable, + /// The persisted row of a resumed cell does not hold this member, so the + /// cognition behind the row never saw it. Nothing is submitted for it. + RowDisagreesWithCell, + /// A tool call in the cell named no handle and this member finished + /// without a choice of its own, so whether the call was meant for it is + /// unknown. Declining it would pass the model's gap off as a decision. + CallUnattributed, +} + +/// The label for a member whose selection failed: only a moved scope is +/// `ScopeChanged`; every other error means the subject holds no single +/// decidable opportunity. +fn selection_refusal(error: &ControllerError) -> ConstituentRefusalReason { + match error { + ControllerError::ScopeMoved => ConstituentRefusalReason::ScopeChanged, + _ => ConstituentRefusalReason::LeftBeforeSelection, + } +} + /// One coarse cell's outcome: one inference, N ordinary one-opportunity /// submissions, and whatever the decode could not attribute. #[derive(Debug)] @@ -2793,7 +2931,14 @@ pub struct GroupedRun { pub cell: CellId, pub resolution: Resolution, pub submissions: Vec, + /// Decode problems the grouped decode could not attribute to a constituent. pub needs: Vec, + /// The tool names of the calls that named no handle, already valid under + /// the provider contract; never their arguments. + pub unattributed: Vec, + /// Constituents of this cell that did not commit, one entry each, with the + /// reason. A constituent is in `submissions` or here, never lost. + pub refused: Vec, /// Set when the run stopped before every constituent was submitted. The /// constituents already in `submissions` are committed; the rest resume from /// the persisted row against the kernel's idempotency ledger. @@ -2826,6 +2971,8 @@ pub enum ControllerError { AmbiguousOpportunity, #[error("opportunity does not match this exact controller command")] OpportunityMismatch, + #[error("the subject's opportunity on this scope moved to a new digest")] + ScopeMoved, #[error("controller opportunity has no Speak affordance")] SpeakUnavailable, #[error("controller opportunity grants no affordance")] @@ -2979,7 +3126,9 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Narrative(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + // A moved scope is the kernel's to refuse at submit, and + // `interrupted` re-lowers once (ruling narrative-resume-relowers). + if !same_decision(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return self @@ -2987,7 +3136,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Narrative(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !same_decision(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return Ok(narrative_pending( @@ -3059,7 +3208,7 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Operational(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !same_decision(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return self @@ -3067,7 +3216,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Operational(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !same_decision(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return Ok(operational_pending( @@ -3315,7 +3464,7 @@ impl ControllerRunner { if checkpoint.cell() != cell { return Err(ControllerError::OpportunityMismatch); } - return self.resume_persisted_group(command_id, checkpoint).await; + return self.resume_row(command_id, checkpoint, members).await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Grouped(checkpoint)) => { if checkpoint.cell() != cell { @@ -3354,7 +3503,7 @@ impl ControllerRunner { .map_err(ControllerError::Snapshot)?; let mut selected = Vec::new(); let mut constituents = Vec::new(); - let mut needs = Vec::new(); + let mut refused = Vec::new(); for member in members { match select_one(&snapshot, &member.opportunity) { Ok(decision) => { @@ -3375,8 +3524,11 @@ impl ControllerRunner { // cell was not active when the cell ran. It is dropped rather // than declined: declining a stale opportunity would be refused // anyway, and the drop is recorded where a reader can see it. - Err(error) => needs.push(ControllerNeed { - detail: format!("a constituent left the cell before selection: {error}"), + // Still offered at a new digest is a moved scope; anything else + // means the subject holds no single decidable opportunity. + Err(error) => refused.push(ConstituentRefusal { + subject: member.subject, + reason: selection_refusal(&error), }), } } @@ -3385,7 +3537,9 @@ impl ControllerRunner { cell, resolution: Resolution::Coarse { constituents: 0 }, submissions: Vec::new(), - needs, + needs: Vec::new(), + unattributed: Vec::new(), + refused, pending: None, }); } @@ -3438,12 +3592,43 @@ impl ControllerRunner { { grouped_pending(checkpoint, ControllerPendingReason::StoreReopenRequired) } else { - self.resume_persisted_group(command_id, checkpoint).await? + self.resume_persisted_group(command_id, checkpoint, members) + .await? }; // The constituents that left the cell are reported beside the run they // were dropped from, not written into the row: they produced no // cognition, so they are not cognition evidence. - run.needs.splice(0..0, needs); + run.refused.splice(0..0, refused); + Ok(run) + } + + /// A persisted row resumed for the cell the driver froze. The cell owns + /// the member set: a cell member the row does not hold is refused here, + /// once, with the reason that is true of it, and a row member the cell + /// lacks is not run. Nothing downstream repairs the lists. + async fn resume_row( + &self, + command_id: CommandId, + checkpoint: GroupedCheckpoint, + members: &[Constituent], + ) -> Result { + let lacked: Vec = members + .iter() + .filter(|member| { + !checkpoint + .constituents() + .iter() + .any(|held| held.subject == member.subject) + }) + .map(|member| ConstituentRefusal { + subject: member.subject, + reason: ConstituentRefusalReason::RowDisagreesWithCell, + }) + .collect(); + let mut run = self + .resume_persisted_group(command_id, checkpoint, members) + .await?; + run.refused.splice(0..0, lacked); Ok(run) } @@ -3451,16 +3636,17 @@ impl ControllerRunner { &self, command_id: CommandId, checkpoint: GroupedCheckpoint, + members: &[Constituent], ) -> Result { if checkpoint.command_id() != command_id { return Err(ControllerError::CommandMismatch); } match checkpoint { checkpoint @ GroupedCheckpoint::AgentInFlight { .. } => { - self.run_group_pending(checkpoint).await + self.run_group_pending(checkpoint, members).await } checkpoint @ GroupedCheckpoint::Submitting { .. } => { - self.submit_group(checkpoint).await + self.submit_group(checkpoint, members).await } } } @@ -3468,6 +3654,7 @@ impl ControllerRunner { async fn run_group_pending( &self, mut checkpoint: GroupedCheckpoint, + members: &[Constituent], ) -> Result { loop { let GroupedCheckpoint::AgentInFlight { @@ -3515,7 +3702,7 @@ impl ControllerRunner { ControllerPendingReason::StoreReopenRequired, )); } - return self.submit_group(next).await; + return self.submit_group(next, members).await; } Ok(GroupedLoopEvaluation::Continue { conversation }) => { let round = completed.len(); @@ -3566,6 +3753,7 @@ impl ControllerRunner { async fn submit_group( &self, checkpoint: GroupedCheckpoint, + members: &[Constituent], ) -> Result { let GroupedCheckpoint::Submitting { cell, @@ -3592,8 +3780,30 @@ impl ControllerRunner { let resolution = Resolution::Coarse { constituents: constituents.len(), }; - let mut needs = capture.needs; + let needs = capture.needs; + let unattributed = capture.unattributed; let mut submissions = Vec::new(); + let mut refused = Vec::new(); + // The world's attention order, read once: among exercises the subject the + // world would attend first lands first, so a member refused behind a + // neighbour's act (and so ahead of it next tick) is the one that lands + // next time. `order_opportunities` starves nobody. + let attention = match self.mailbox.snapshot().await { + Ok(snapshot) => snapshot.opportunities, + Err(MailboxError::Unavailable) => { + return Ok(grouped_pending( + checkpoint.clone(), + ControllerPendingReason::WorldUnavailable, + )); + } + Err(MailboxError::OutcomeUnknown { .. }) => { + return Ok(grouped_pending( + checkpoint.clone(), + ControllerPendingReason::WorldOutcomeUnknown, + )); + } + Err(error) => return Err(ControllerError::Snapshot(error)), + }; // Declines first. A decline changes no other subject's scope, so every // silent constituent's turn is consumed. An exercise can change what its // neighbours bound to, and co-located subjects are exactly @@ -3602,8 +3812,34 @@ impl ControllerRunner { // outcome; ordering the non-mutating submissions ahead of it loses no // turns and costs nothing. let mut ordered: Vec<(usize, &ConstituentWork)> = constituents.iter().enumerate().collect(); - ordered.sort_by_key(|(handle, _)| capture.proposals.contains_key(handle)); + ordered.sort_by_key(|(handle, constituent)| { + let exercises = capture.proposals.contains_key(handle); + let place = attention + .iter() + .position(|opportunity| opportunity.scope.subject_id == constituent.subject) + .unwrap_or(usize::MAX); + (exercises, if exercises { place } else { 0 }) + }); for (handle, constituent) in ordered { + // The cell being run owns who acts. A row member the cell lacks + // has left it (the driver accounts for it there) and is not run. + if !members.iter().any(|member| member.subject == constituent.subject) { + continue; + } + if capture.undecodable.contains(&handle) { + refused.push(ConstituentRefusal { + subject: constituent.subject, + reason: ConstituentRefusalReason::ProposalUndecodable, + }); + continue; + } + if capture.call_unattributed.contains(&handle) { + refused.push(ConstituentRefusal { + subject: constituent.subject, + reason: ConstituentRefusalReason::CallUnattributed, + }); + continue; + } let command = match capture.proposals.get(&handle) { Some(invocation) => ControllerWorldCommand::Exercise(invocation.clone()), None => ControllerWorldCommand::Decline, @@ -3624,14 +3860,20 @@ impl ControllerRunner { resolution, submissions, needs, + unattributed, + refused, pending: Some(reason), }); } // One constituent's refusal is that constituent's outcome. The // cell is not a transaction: every other handle's proposal was // bound to its own opportunity and is unaffected. - Err(ControllerError::World(error)) => needs.push(ControllerNeed { - detail: format!("constituent c{handle} was refused by the world: {error}"), + Err(ControllerError::World(error)) => refused.push(ConstituentRefusal { + subject: constituent.subject, + reason: match error { + KernelError::ScopeChanged { .. } => ConstituentRefusalReason::ScopeChanged, + _ => ConstituentRefusalReason::WorldRefused, + }, }), Err(error) => return Err(error), } @@ -3647,6 +3889,8 @@ impl ControllerRunner { resolution, submissions, needs, + unattributed, + refused, pending: None, }) } @@ -3667,10 +3911,13 @@ impl ControllerRunner { select_one(&snapshot, exact_opportunity) } - /// The scope this run bound still derives the same digest. The operational - /// lane's early abort, and only its: a lane holding nothing to preserve - /// stops as soon as the scope moves, while the narrative lane runs to the - /// kernel's refusal so its prose and receipts survive to be lowered again. + /// The one owner of "an operational in-flight row whose scope moved spends + /// nothing": the scope this run bound still derives the same digest, checked + /// against the world before every round, fresh or resumed. The caller's + /// opportunity only names the decision and never decides staleness; a moved + /// digest is `ScopeMoved`. The operational lane's early abort, and only its: + /// the narrative lane runs to the kernel's refusal so its prose and receipts + /// survive to be lowered again. async fn ensure_scope_unchanged( &self, opportunity: &DecisionOpportunity, @@ -4078,16 +4325,9 @@ impl ControllerRunner { .snapshot() .await .map_err(ControllerError::Snapshot)?; + // The kernel's `ScopeChanged` already decided that the scope moved; + // nothing here compares digests to decide it again. let fresh = select_fresh(&snapshot, &opportunity)?; - if fresh.opportunity.scope_digest == opportunity.scope_digest { - // The refusal was not this scope's move, and the runner does not - // invent a second explanation for it. - return Err(ControllerError::World(KernelError::ScopeChanged { - scope: opportunity.scope, - expected: opportunity.scope_digest.clone(), - actual: fresh.opportunity.scope_digest, - })); - } // No branch here checks the fresh grant set for `SPEAK_KIND`: grants // are recorded in an insert-only ledger (`affordance_grants` never // removes an entry), and this checkpoint could only reach @@ -5707,8 +5947,17 @@ fn split_handle(name: &str, constituents: usize) -> Option<(usize, &str)> { /// The batched decode. Vector-valued by construction: the singleton evaluator /// keeps its scalar proposal and is not touched. struct GroupedCapture { - /// Handle to proposal. A handle absent here proposed nothing and declines. + /// Handle to proposal. A handle absent here proposed nothing and declines, + /// unless it is in `undecodable`. proposals: BTreeMap, + /// Handles whose own tool call failed to decode and which never finished + /// otherwise: nothing is submitted for them. + undecodable: BTreeSet, + /// Handles that finished with no choice while some call in the cell named + /// no handle: nothing says the call was not theirs, so they do not decline. + call_unattributed: BTreeSet, + /// The names of the calls that named no handle. + unattributed: Vec, needs: Vec, } @@ -5722,6 +5971,11 @@ enum GroupedLoopEvaluation { pub(super) struct GroupedFold { proposals: BTreeMap, terminal: BTreeSet, + /// Handles with a call that failed to decode, whether or not a later call + /// finished them. + decode_failed: BTreeSet, + /// The names of the calls that named no handle, capped. + unattributed: Vec, needs: Vec, } @@ -5730,6 +5984,8 @@ impl GroupedFold { Self { proposals: BTreeMap::new(), terminal: BTreeSet::new(), + decode_failed: BTreeSet::new(), + unattributed: Vec::new(), needs: Vec::new(), } } @@ -5748,6 +6004,7 @@ pub(super) fn grouped_tool_result( // A model that writes `c99__carry` has not proposed anything. It has // produced a gap. None => { + fold.unattributed.push(name.to_string()); fold.needs.push(tool_decode_need( name, arguments, @@ -5778,6 +6035,7 @@ pub(super) fn grouped_tool_result( "invocation captured".into() } Err(detail) => { + fold.decode_failed.insert(handle); fold.needs.push(tool_decode_need(name, arguments, &detail)); "arguments recorded as a need".into() } @@ -5792,6 +6050,7 @@ pub(super) fn grouped_tool_result( "need recorded".into() } Err(error) => { + fold.decode_failed.insert(handle); fold.needs .push(tool_decode_need(name, arguments, &error.to_string())); "arguments recorded as a need".into() @@ -5812,6 +6071,7 @@ pub(super) fn grouped_tool_result( "decision finished without a proposal".into() } Err(error) => { + fold.decode_failed.insert(handle); fold.needs .push(tool_decode_need(name, arguments, &error.to_string())); "arguments recorded as a need".into() @@ -5819,6 +6079,7 @@ pub(super) fn grouped_tool_result( } } _ => { + fold.decode_failed.insert(handle); fold.needs.push(tool_decode_need( name, arguments, @@ -5936,6 +6197,22 @@ fn evaluate_grouped_loop( return Ok(GroupedLoopEvaluation::Complete { capture: GroupedCapture { proposals: fold.proposals, + undecodable: fold + .decode_failed + .difference(&fold.terminal) + .copied() + .collect(), + call_unattributed: if fold.unattributed.is_empty() { + BTreeSet::new() + } else { + (0..constituents.len()) + .filter(|handle| { + !fold.terminal.contains(handle) + && !fold.decode_failed.contains(handle) + }) + .collect() + }, + unattributed: fold.unattributed, needs: fold.needs, }, }); @@ -5998,6 +6275,8 @@ fn grouped_pending(checkpoint: GroupedCheckpoint, reason: ControllerPendingReaso }, submissions: Vec::new(), needs: Vec::new(), + unattributed: Vec::new(), + refused: Vec::new(), pending: Some(reason), } } @@ -6905,6 +7184,7 @@ mod tests { AffordanceSnapshot { id, entry: kernel_speak_entry(), + render_path: None, } } use crate::{ @@ -7092,6 +7372,7 @@ mod tests { arguments: json!({"detail":"The wind reading is missing."}).to_string(), }], receipt_digest: "sha256:operational-round-zero".into(), + usage: None, }]; let substituted_operational = operational_in_flight( operational_command, @@ -7130,6 +7411,7 @@ mod tests { .to_string(), }], receipt_digest: "sha256:narrative-round-zero".into(), + usage: None, }]; let substituted_narrative = narrative_interpreter_in_flight( narrative_command, @@ -7209,6 +7491,7 @@ mod tests { let heard = EntityId::issue(); let unheard = EntityId::issue(); let actor = SubjectSnapshot { + import_key: None, id: actor_id, label: "Mara at the rain gate".into(), kind: SubjectKind::Person, @@ -7255,6 +7538,7 @@ mod tests { last_acted_at: None, }; let speaker = SubjectSnapshot { + import_key: None, id: speaker_id, label: "Iris in the tollhouse".into(), kind: SubjectKind::Person, @@ -7285,6 +7569,7 @@ mod tests { last_acted_at: None, }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -7377,6 +7662,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let actor = SubjectSnapshot { + import_key: None, id: actor_id, label: "The Ledger Clerk".into(), kind: SubjectKind::Person, @@ -7407,6 +7693,7 @@ mod tests { last_acted_at: None, }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -7650,6 +7937,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("mara"), @@ -7658,6 +7946,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("otho"), @@ -7666,12 +7955,14 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("vault"))), + import_key: None, }), ], operations: Vec::new(), evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -7802,6 +8093,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("human"), @@ -7812,6 +8104,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("operational"), @@ -7820,12 +8113,14 @@ mod tests { controller: NewController::OperationalAgent, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("vault"))), + import_key: None, }), ], operations: Vec::new(), evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -8137,12 +8432,7 @@ mod tests { .turn(CommandId::new(), &stale_opportunity) .await .unwrap_err(); - assert!(matches!( - error, - ControllerError::NoOpportunity { - expected: ControllerMode::NarrativePersona - } - )); + assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}"); assert!( capturing.prepared.lock().unwrap().is_empty(), "a stale scope digest reached inference" @@ -8994,6 +9284,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let subject = SubjectSnapshot { + import_key: None, id: subject_id, label: "Mara at the rain gate".into(), kind: SubjectKind::Person, @@ -9049,6 +9340,7 @@ mod tests { last_acted_at: None, }; let speaker = SubjectSnapshot { + import_key: None, id: speaker_id, label: "Iris in the tollhouse".into(), kind: SubjectKind::Person, @@ -9071,6 +9363,7 @@ mod tests { last_acted_at: None, }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -9169,6 +9462,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let actor = SubjectSnapshot { + import_key: None, id: actor_id, label: "The Walker".into(), kind: SubjectKind::Person, @@ -9194,6 +9488,7 @@ mod tests { last_acted_at: None, }; let other = SubjectSnapshot { + import_key: None, id: other_id, label: "The Vault Keeper".into(), kind: SubjectKind::Person, @@ -9233,6 +9528,7 @@ mod tests { open: true, }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -9434,6 +9730,7 @@ mod tests { }], carries_speech: false, }, + render_path: None, }; let speak = speak_snapshot(AffordanceId::issue()); let granted = vec![carry.clone(), speak.clone()]; @@ -9645,6 +9942,7 @@ mod tests { index: 0, }; let subject = |id, label: &str, position, components, commitments, pressures| SubjectSnapshot { + import_key: None, id, label: label.into(), kind: SubjectKind::Person, @@ -9734,6 +10032,7 @@ mod tests { affordance_ids: vec![speak_affordance], }; let snapshot = WorldSnapshot { + grammar: None, lens_weights: crate::tests::stock_weights(), world_id: opportunity.world_id, revision: opportunity.revision, @@ -9931,6 +10230,7 @@ mod tests { .map(|(id, name, arguments)| oracle_call(id, name, arguments)) .collect(), receipt_digest: format!("sha256:{receipt}"), + usage: None, } } @@ -10767,12 +11067,14 @@ mod tests { affordances: kernel_speak_grant(), // Speech needs a room to fill. position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), ], operations: Vec::new(), evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -10807,6 +11109,7 @@ mod tests { Ok(InferenceOutput { events, receipt_digest: format!("sha256:{receipt}"), + usage: None, }) } @@ -11045,14 +11348,13 @@ mod tests { let refreshed = after.opportunities[0].clone(); assert_ne!(refreshed, opportunity); assert_eq!(refreshed.scope_digest, opportunity.scope_digest); - // A digest the world does not derive selects nothing. + // A digest the world does not derive selects nothing: the scope is + // still offered, at a digest this caller did not see. let mut forged = opportunity.clone(); forged.scope_digest = ScopeDigest::fixture("sha256:not-this-scope"); assert!(matches!( runner.run_narrative(CommandId::new(), &forged).await, - Err(ControllerError::NoOpportunity { - expected: ControllerMode::NarrativePersona - }) + Err(ControllerError::ScopeMoved) )); let NarrativeRun::Completed(resumed) = runner.run_narrative(command_id, &refreshed).await.unwrap() @@ -11364,6 +11666,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("roll-hall"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("watch-officer"), @@ -11372,6 +11675,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("roll-hall"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("signal-council"), @@ -11380,12 +11684,14 @@ mod tests { controller: NewController::OperationalAgent, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("roll-hall"))), + import_key: None, }), ], operations: Vec::new(), evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &owner_caller, ) @@ -11491,6 +11797,7 @@ mod tests { model_prefix: local_model_prefix, caller_runtime_id: runtime_id, }), + None, &models.each(), ) .unwrap(); @@ -11863,6 +12170,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(road_container))), + import_key: None, })); let operations = if roots == Roots::None { Vec::new() @@ -11904,6 +12212,7 @@ mod tests { ]), }, }, + grammar: None, }, &authenticated, ) @@ -12862,17 +13171,17 @@ mod tests { /// refused at open and not migrated; the same payload under the current /// version opens, so the refusal is the version's. #[tokio::test] - async fn a_controller_work_row_from_v15_is_refused_at_open() { + async fn a_controller_work_row_from_v17_is_refused_at_open() { let checkpoint = refused_checkpoint().await; let directory = tempfile::tempdir().unwrap(); let Err(error) = open_with_raw_row( &directory, - "v15.cc", + "v17.cc", &checkpoint, - "controller_work.v15", - "ghostlight.controller_work.v15", + "controller_work.v17", + "ghostlight.controller_work.v17", ) else { - panic!("a v15 row was accepted by the {CONTROLLER_WORK_ROW} store"); + panic!("a v17 row was accepted by the {CONTROLLER_WORK_ROW} store"); }; assert!(matches!(error, ControllerWorkStoreError::Fault { .. })); open_with_raw_row( @@ -13545,6 +13854,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position, + import_key: None, }) }; let creation = mailbox @@ -13582,6 +13892,7 @@ mod tests { evidence: Vec::new(), }, scale_intent, + grammar: None, }, &authenticated, ) @@ -14552,6 +14863,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, })); } let creation = mailbox @@ -14568,6 +14880,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -14602,8 +14915,7 @@ mod tests { let graph = mailbox.agency_graph().await.unwrap(); let cover = derive_cover( snapshot.world_id, - snapshot.now, - 60, + TickIndex(snapshot.now.0 / 60), &snapshot.opportunities, &graph, CoverBudget { @@ -14650,8 +14962,9 @@ mod tests { /// Verification 20. One inference proposes for `c0`, names a handle outside /// the cell, and says nothing for the rest. The valid call commits under its /// own opportunity; the out-of-cell name produces a gap and reaches no - /// mailbox; every silent handle declines, so "attended and stayed silent" is - /// distinguishable from "was never attended". + /// mailbox; every handle that finished with no choice is refused as + /// `CallUnattributed` and not declined, because the call that named no + /// handle may have been meant for it. #[tokio::test] async fn soul_a_batched_turn_attributes_by_tool_identity_and_refuses_an_outside_handle() { let (_directory, mailbox, task) = active_cell_mailbox(vec![ @@ -14684,7 +14997,8 @@ mod tests { }; assert!(run.pending.is_none()); assert_eq!(run.resolution, Resolution::Coarse { constituents: 3 }); - assert_eq!(run.submissions.len(), 3, "every constituent finished"); + assert_eq!(run.submissions.len(), 1, "only the attributed call committed"); + assert_eq!(run.unattributed, vec!["c7__speak".to_owned()]); let first = cell.members()[0].subject; assert!(matches!( run.submissions @@ -14693,16 +15007,17 @@ mod tests { .map(|entry| &entry.submission), Some(SubmissionDisposition::Completed(_)) )); - for entry in run - .submissions - .iter() - .filter(|entry| entry.subject != first) - { - assert!( - matches!(entry.submission, SubmissionDisposition::NoProposal(_)), - "a silent constituent did not decline" - ); - } + assert_eq!( + run.refused, + cell.members()[1..] + .iter() + .map(|member| ConstituentRefusal { + subject: member.subject, + reason: ConstituentRefusalReason::CallUnattributed, + }) + .collect::>(), + "a silent constituent was declined behind a call that named no handle" + ); assert!( run.needs .iter() @@ -14720,6 +15035,241 @@ mod tests { task.await.unwrap(); } + /// A resumed row is run for the cell the driver froze. The cell owns the + /// member set: a member the row lacks is refused once as + /// `RowDisagreesWithCell` wherever it stands in a three-member cell, and a + /// row member the cell lacks is not run. + #[tokio::test] + async fn a_resumed_row_is_run_for_the_cell_and_refuses_the_member_it_lacks() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let Cell::Group { id, tick, members } = one_group(&mailbox).await else { + panic!("the cell is not coarse") + }; + let [m0, m1, m2] = [0, 1, 2].map(|index| members[index].clone()); + let group = |members: Vec| Cell::Group { id, tick, members }; + let (runner, _store) = grouped_runner( + &mailbox, + vec![output( + vec![ + speak_call("c0", "c0__speak", "Hold the bridge."), + InferenceEvent::ToolCall { + call_id: "c1".into(), + name: "c1__finish_without_proposal".into(), + arguments: "{}".into(), + }, + ], + "row", + )], + ); + let subjects = |run: &GroupedRun| -> Vec { + run.submissions.iter().map(|entry| entry.subject).collect() + }; + // The row is persisted for [m0, m2]. + let CellRun::Grouped(first) = runner + .run_cell(&group(vec![m0.clone(), m2.clone()])) + .await + .unwrap() + else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(subjects(&first), vec![m0.subject, m2.subject]); + let committed = mailbox.operator_log().await.unwrap().len(); + + // The cell is [m0, m1, m2]: the member in the middle is the one refused. + let CellRun::Grouped(second) = runner + .run_cell(&group(vec![m0.clone(), m1.clone(), m2.clone()])) + .await + .unwrap() + else { + panic!("a resumed coarse cell did not run the grouped lane") + }; + assert_eq!( + second.refused, + vec![ConstituentRefusal { + subject: m1.subject, + reason: ConstituentRefusalReason::RowDisagreesWithCell, + }] + ); + assert_eq!(subjects(&second), vec![m0.subject, m2.subject]); + + // The cell is [m2, m1]: m1 is refused, m2 runs, and m0 (in the row, + // not in the cell) is neither run nor named. + let CellRun::Grouped(third) = runner + .run_cell(&group(vec![m2.clone(), m1.clone()])) + .await + .unwrap() + else { + panic!("a resumed coarse cell did not run the grouped lane") + }; + assert_eq!( + third.refused, + vec![ConstituentRefusal { + subject: m1.subject, + reason: ConstituentRefusalReason::RowDisagreesWithCell, + }] + ); + assert_eq!(subjects(&third), vec![m2.subject]); + assert_eq!(mailbox.operator_log().await.unwrap().len(), committed); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + /// Every site that marks a member's own call undecodable refuses that + /// member, for either handle; a well-formed `record_need` does not. + #[tokio::test] + async fn a_member_whose_own_call_cannot_be_decoded_is_refused_at_every_site() { + let bad_speak = || InferenceEvent::ToolCall { + call_id: "bad".into(), + name: "c0__speak".into(), + arguments: json!({ "text": 5 }).to_string(), + }; + let call = |name: &str, arguments: &str| InferenceEvent::ToolCall { + call_id: "bad".into(), + name: name.into(), + arguments: arguments.into(), + }; + let sites: Vec<(&str, InferenceEvent, bool)> = vec![ + ("catalog arguments", bad_speak(), true), + ( + "record_need arguments", + call("c0__record_need", "not json at all"), + true, + ), + ( + "finish_without_proposal arguments", + call("c0__finish_without_proposal", "not json at all"), + true, + ), + ("tool not offered", call("c0__teleport", "{}"), true), + ( + "a well-formed record_need", + call("c0__record_need", r#"{"detail":"no route"}"#), + false, + ), + ]; + for (site, event, refuses) in sites { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let cell = one_group(&mailbox).await; + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + output( + vec![event, speak_call("c1", "c1__speak", "Hold the bridge.")], + "site-one", + ), + output(vec![InferenceEvent::Text("Nothing further.".into())], "site-two"), + ], + ); + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + let (zero, one) = (cell.members()[0].subject, cell.members()[1].subject); + if refuses { + assert_eq!( + run.refused, + vec![ConstituentRefusal { + subject: zero, + reason: ConstituentRefusalReason::ProposalUndecodable, + }], + "{site}" + ); + assert_eq!(run.submissions.len(), 1, "{site}"); + assert_eq!(run.submissions[0].subject, one, "{site}"); + } else { + assert!(run.refused.is_empty(), "{site}: {:?}", run.refused); + assert_eq!(run.submissions.len(), 2, "{site}"); + assert!( + run.needs.iter().any(|need| need.detail.contains("c0: no route")), + "{site}: the need was not recorded" + ); + } + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + } + + /// A call that names no handle is reported by tool name alone, and a + /// member that finished with no choice behind it is refused, not + /// declined; a member that decided to finish still declines. + #[tokio::test] + async fn a_call_naming_no_handle_is_reported_and_refuses_the_members_it_may_have_meant() { + for finishes in [false, true] { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let cell = one_group(&mailbox).await; + let mut events = vec![ + speak_call("c0", "c0__speak", "Hold the bridge."), + InferenceEvent::ToolCall { + call_id: "stray".into(), + name: "c9__speak".into(), + arguments: json!({ "text": "SENTINEL-ARGUMENT" }).to_string(), + }, + ]; + if finishes { + events.push(InferenceEvent::ToolCall { + call_id: "c1".into(), + name: "c1__finish_without_proposal".into(), + arguments: "{}".into(), + }); + } + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + output(events, "stray-one"), + output(vec![InferenceEvent::Text("Nothing further.".into())], "stray-two"), + ], + ); + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(run.unattributed, vec!["c9__speak".to_owned()]); + assert!( + !format!("{run:?}").contains("SENTINEL-ARGUMENT"), + "the call's arguments were kept" + ); + if finishes { + assert!(run.refused.is_empty(), "{:?}", run.refused); + assert_eq!(run.submissions.len(), 2); + } else { + assert_eq!(run.submissions.len(), 1); + assert_eq!( + run.refused, + vec![ConstituentRefusal { + subject: cell.members()[1].subject, + reason: ConstituentRefusalReason::CallUnattributed, + }] + ); + } + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + } + + /// The call's arguments are not kept at all: only the name is. + #[test] + fn the_name_of_a_call_naming_no_handle_is_kept_and_its_arguments_are_not() { + let mut fold = GroupedFold::new(); + grouped_tool_result(&[], &mut fold, "c9__speak", r#"{"text":"SENTINEL-ARGUMENT"}"#) + .unwrap(); + assert_eq!(fold.unattributed, vec!["c9__speak".to_string()]); + } + /// Verification 20b. No spelling of a handle outside the cell decodes, and /// the index-to-subject mapping is scheduler-owned rather than /// model-supplied: a shared tool with a `subject` argument would be the @@ -14763,12 +15313,14 @@ mod tests { InferenceOutput { events: vec![speak_call("call", "c0__notgranted", "Anything.")], receipt_digest: "sha256:grouped".into(), + usage: None, }, // The repair round the grouped budget buys. It calls nothing, so the // handle finishes silent. InferenceOutput { events: vec![InferenceEvent::Text("Nothing further.".into())], receipt_digest: "sha256:grouped-repair".into(), + usage: None, }, ]; let GroupedLoopEvaluation::Complete { capture } = @@ -15061,13 +15613,8 @@ mod tests { run.submissions[0].submission, SubmissionDisposition::Completed(_) )); - assert!( - run.needs - .iter() - .any(|need| need.detail.contains("scope changed")), - "the refused act was not recorded: {:?}", - run.needs - ); + assert_eq!(run.refused.len(), 1, "the refused act was not recorded"); + assert_eq!(run.refused[0].reason, ConstituentRefusalReason::ScopeChanged); assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); drop(runner); @@ -15100,6 +15647,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(&format!("room{index}")))), + import_key: None, })); } let creation = mailbox @@ -15116,6 +15664,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -15233,12 +15782,9 @@ mod tests { panic!("a coarse cell did not run the grouped lane") }; assert_eq!(run.submissions.len(), 1); - assert!( - run.needs - .iter() - .any(|need| need.detail.contains("scope changed")), - "{:?}", - run.needs + assert_eq!( + run.refused.iter().map(|entry| (entry.subject, entry.reason)).collect::>(), + vec![(second, ConstituentRefusalReason::ScopeChanged)] ); let after = digest_of(&mailbox.snapshot().await.unwrap(), second) @@ -15375,7 +15921,8 @@ mod tests { panic!("a coarse cell did not run the grouped lane") }; assert!(run.pending.is_none()); - assert_eq!(run.submissions.len(), 3); + assert_eq!(run.submissions.len(), 1); + assert_eq!(run.refused.len(), 2); let live = mailbox.snapshot().await.unwrap(); assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); @@ -15579,6 +16126,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), ], operations: Vec::new(), @@ -15588,6 +16136,7 @@ mod tests { targets: BTreeMap::from([(SubjectKind::Person, target)]), jurisdictions: BTreeMap::from([(DraftHandle::new("sere"), 1000)]), }, + grammar: None, }, &authenticated, ) @@ -16497,12 +17046,14 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }))) .collect(), operations: Vec::new(), evidence: Vec::new(), }, scale_intent: intent, + grammar: None, }; let (mailbox, task) = WorldMailbox::open(directory.path().join("two-roots.cc")).unwrap(); @@ -17984,6 +18535,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("subject1"), @@ -17992,6 +18544,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }), ]; let creation = mailbox @@ -18019,6 +18572,7 @@ mod tests { evidence: vec![ledger], }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -18491,6 +19045,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new("commons"))), + import_key: None, }) }; let ledger = EvidenceRef::new("the fixture ledger"); @@ -18547,6 +19102,7 @@ mod tests { evidence: vec![ledger], }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) @@ -18975,7 +19531,7 @@ mod tests { .run_operational(operational_command, &operational) .await; assert!( - matches!(refused, Err(ControllerError::NoOpportunity { .. })), + matches!(refused, Err(ControllerError::ScopeMoved)), "the operational lane stopped holding its early abort: {refused:?}" ); assert_eq!( @@ -19021,4 +19577,1401 @@ mod tests { drop(mailbox); task.await.unwrap(); } + + /// The lookup-first tests use two subjects in one room, so one subject's + /// act moves the other's scope digest as well as its own. + fn fresh_opportunity(snapshot: &WorldSnapshot, subject: SubjectId) -> DecisionOpportunity { + snapshot + .opportunities + .iter() + .find(|opportunity| opportunity.scope.subject_id == subject) + .expect("the subject still holds an opportunity") + .clone() + } + + fn operational_speaks(text: &str, receipt: &str) -> Result { + output( + vec![InferenceEvent::ToolCall { + call_id: "speak".into(), + name: INTERPRETER_SPEAK_TOOL.into(), + arguments: json!({ "text": text }).to_string(), + }], + receipt, + ) + } + + fn narrative_turn_outputs( + tag: &str, + speech: &str, + ) -> Vec> { + vec![ + output( + vec![InferenceEvent::Text(format!("{tag} watches the water."))], + &format!("{tag}-projector"), + ), + output( + vec![InferenceEvent::Text(format!("I say, \"{speech}\""))], + &format!("{tag}-persona"), + ), + output( + vec![ + InferenceEvent::ToolCall { + call_id: "call_speak".into(), + name: INTERPRETER_SPEAK_TOOL.into(), + arguments: json!({ "source_quote": speech }).to_string(), + }, + InferenceEvent::ToolCall { + call_id: "call_finish".into(), + name: FINISH_INTERPRETATION_TOOL.into(), + arguments: "{}".into(), + }, + ], + &format!("{tag}-interpreter"), + ), + ] + } + + #[tokio::test] + async fn a_committed_operational_row_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + // Exactly two provider answers: a third call would panic the port. + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + operational_speaks("The hinge is flooding.", "first"), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let OperationalRun::Completed(committed) = runner + .run_operational(first_command, &first) + .await + .unwrap() + else { + panic!("the first act remained pending") + }; + let SubmissionDisposition::Completed(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first act did not commit") + }; + // Another subject speaks in the commons. + let after_first = mailbox.snapshot().await.unwrap(); + let second_now = fresh_opportunity(&after_first, second.scope.subject_id); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second_now) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + + let moved = mailbox.snapshot().await.unwrap(); + let fresh = fresh_opportunity(&moved, first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let OperationalRun::Completed(answered) = runner + .run_operational(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed row was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::PreviouslyConfirmed(receipt) if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 2); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_committed_narrative_row_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = narrative_turn_outputs("Mara", "The rain has teeth tonight."); + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, _store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let NarrativeRun::Completed(committed) = runner + .run_narrative(first_command, &first) + .await + .unwrap() + else { + panic!("the first turn remained pending") + }; + let SubmissionDisposition::Completed(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first turn did not commit") + }; + let after_first = mailbox.snapshot().await.unwrap(); + let second_now = fresh_opportunity(&after_first, second.scope.subject_id); + assert!(matches!( + runner + .run_narrative(CommandId::new(), &second_now) + .await + .unwrap(), + NarrativeRun::Completed(_) + )); + + let moved = mailbox.snapshot().await.unwrap(); + let fresh = fresh_opportunity(&moved, first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let NarrativeRun::Completed(answered) = runner + .run_narrative(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed row was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::PreviouslyConfirmed(receipt) if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 2); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_row_in_flight_whose_scope_moved_is_refused_before_inference() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + // The first call is pending, the second commits, and a third would + // panic the port: the refused rerun must not reach the provider. + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + Err(InferenceFault::retryable("the provider is busy")), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + assert!(matches!( + runner + .run_operational(first_command, &first) + .await + .unwrap(), + OperationalRun::Pending(_) + )); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + let moved = mailbox.snapshot().await.unwrap(); + let fresh = fresh_opportunity(&moved, first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let error = runner + .run_operational(first_command, &fresh) + .await + .unwrap_err(); + assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}"); + // The row's own stale opportunity reads the same: the world decides. + let error = runner + .run_operational(first_command, &first) + .await + .unwrap_err(); + assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}"); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_committed_row_is_never_read_for_another_decision() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let (runner, _store) = grouped_runner( + &mailbox, + vec![operational_speaks("The hinge is flooding.", "first")], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let other = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_operational(command, &first).await.unwrap(), + OperationalRun::Completed(_) + )); + let error = runner.run_operational(command, &other).await.unwrap_err(); + assert!(matches!(error, ControllerError::OpportunityMismatch)); + let mut foreign = first.clone(); + foreign.world_id = WorldId::issue(); + let error = runner.run_operational(command, &foreign).await.unwrap_err(); + assert!(matches!(error, ControllerError::OpportunityMismatch)); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_grouped_member_the_world_refuses_is_named_in_refused() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let cell = one_group(&mailbox).await; + let Cell::Group { members, .. } = &cell else { + panic!("the cell is not coarse") + }; + let (runner, _store) = grouped_runner( + &mailbox, + vec![output( + vec![ + speak_call("c0", "c0__speak", "The hinge is flooding."), + speak_call("c1", "c1__speak", "Then we close the span."), + ], + "two-acts", + )], + ); + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(run.submissions.len(), 1); + let committed = run.submissions[0].subject; + let refused_subject = members + .iter() + .map(|member| member.subject) + .find(|subject| *subject != committed) + .unwrap(); + assert_eq!( + run.refused, + vec![ConstituentRefusal { + subject: refused_subject, + reason: ConstituentRefusalReason::ScopeChanged, + }] + ); + assert!(run.needs.is_empty(), "{:?}", run.needs); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_grouped_member_whose_scope_moved_is_named_in_refused_as_scope_changed() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let cell = one_group(&mailbox).await; + let Cell::Group { id, tick, members } = cell else { + panic!("the cell is not coarse") + }; + // One member's bound opportunity is stale (its digest is not the + // world's); the other is bound to the world as it is. + let world = mailbox.snapshot().await.unwrap(); + let leaver = members[1].subject; + let mut stale = members[1].opportunity.clone(); + stale.scope_digest = members[0].opportunity.scope_digest.clone(); + let bound = vec![ + Constituent { + subject: members[0].subject, + opportunity: fresh_opportunity(&world, members[0].subject), + }, + Constituent { + subject: leaver, + opportunity: stale, + }, + ]; + let (runner, _store) = grouped_runner( + &mailbox, + vec![output( + vec![speak_call("c0", "c0__speak", "The hinge is flooding.")], + "lone-act", + )], + ); + let cell = Cell::Group { + id, + tick, + members: bound, + }; + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!( + run.refused, + vec![ConstituentRefusal { + subject: leaver, + reason: ConstituentRefusalReason::ScopeChanged, + }] + ); + assert!(run.needs.is_empty(), "{:?}", run.needs); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + /// `AmbiguousOpportunity` cannot be built through a live world (one + /// opportunity per scope), so the mapping is pinned directly. + #[test] + fn selection_refusal_names_only_a_moved_scope_scope_changed() { + assert_eq!( + selection_refusal(&ControllerError::ScopeMoved), + ConstituentRefusalReason::ScopeChanged + ); + for left in [ + ControllerError::NoOpportunity { + expected: ControllerMode::NarrativePersona, + }, + ControllerError::AmbiguousOpportunity, + ControllerError::OpportunityMismatch, + ] { + assert_eq!( + selection_refusal(&left), + ConstituentRefusalReason::LeftBeforeSelection + ); + } + } + + #[tokio::test] + async fn a_grouped_member_that_truly_left_is_named_in_refused_as_left_before_selection() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let cell = one_group(&mailbox).await; + let Cell::Group { id, tick, members } = cell else { + panic!("the cell is not coarse") + }; + // One member's subject is no longer in the world at all; the other is + // bound to the world as it is. + let world = mailbox.snapshot().await.unwrap(); + let leaver = SubjectId::issue(); + let mut gone = members[1].opportunity.clone(); + gone.scope.subject_id = leaver; + let bound = vec![ + Constituent { + subject: members[0].subject, + opportunity: fresh_opportunity(&world, members[0].subject), + }, + Constituent { + subject: leaver, + opportunity: gone, + }, + ]; + let (runner, _store) = grouped_runner( + &mailbox, + vec![output( + vec![speak_call("c0", "c0__speak", "The hinge is flooding.")], + "lone-act", + )], + ); + let cell = Cell::Group { + id, + tick, + members: bound, + }; + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!( + run.refused, + vec![ConstituentRefusal { + subject: leaver, + reason: ConstituentRefusalReason::LeftBeforeSelection, + }] + ); + assert!(run.needs.is_empty(), "{:?}", run.needs); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_grouped_cell_submits_exercises_in_attention_order() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let both_act = |tag: &str| { + output( + vec![ + speak_call("c0", "c0__speak", "The hinge is flooding."), + speak_call("c1", "c1__speak", "Then we close the span."), + ], + tag, + ) + }; + let (runner, _store) = grouped_runner(&mailbox, vec![both_act("one"), both_act("two")]); + + let first_cell = one_group(&mailbox).await; + let Cell::Group { members, .. } = &first_cell else { + panic!("the cell is not coarse") + }; + let low = members[0].subject; + let CellRun::Grouped(first) = runner.run_cell(&first_cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(first.submissions.len(), 1); + assert_eq!(first.refused.len(), 1); + let refused_first = first.refused[0].subject; + assert_eq!(first.submissions[0].subject, low); + assert_ne!(refused_first, low); + + // Time passes; the refused member has waited, so the world attends it first. + let owner = PrincipalId::new("owner"); + let before = mailbox.snapshot().await.unwrap(); + mailbox + .submit_fixture( + CommandEnvelope { + id: CommandId::new(), + world_id: before.world_id, + expected_revision: before.revision, + caller: CallerId::Principal(owner.clone()), + body: CommandBody::AdvanceTime { + minutes: crate::TickMinutes::new(60).unwrap(), + }, + }, + &AuthenticatedCaller::fixture(CallerId::Principal(owner)), + ) + .await + .unwrap(); + let second_cell = one_group(&mailbox).await; + let CellRun::Grouped(second) = runner.run_cell(&second_cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(second.submissions.len(), 1); + assert_eq!( + second.submissions[0].subject, refused_first, + "the member refused last time did not land first this time" + ); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + /// The same rows as the store sees them while its custody is uncertain. + struct UncertainStore(Arc); + + #[async_trait] + impl ControllerWorkStore for UncertainStore { + async fn lookup( + &self, + command_id: CommandId, + ) -> Result { + Ok(match self.0.lookup(command_id).await? { + ControllerWorkLookup::Confirmed(work) => ControllerWorkLookup::CustodyUncertain(work), + other => other, + }) + } + + async fn persist( + &self, + work: &ControllerWork, + ) -> Result { + self.0.persist(work).await + } + + async fn custody_probe(&self) -> Result { + self.0.custody_probe().await + } + + async fn elaboration_in_flight( + &self, + ) -> Result, ControllerWorkStoreError> { + self.0.elaboration_in_flight().await + } + } + + fn uncertain_runner(mailbox: &WorldMailbox, store: &Arc) -> ControllerRunner { + let port = Arc::new(RecordingPort { + outputs: Mutex::new(Vec::new()), + persisted_before_interpreter: Arc::new(AtomicBool::new(true)), + }); + ControllerRunner::open(mailbox.clone(), port, Arc::new(UncertainStore(store.clone())), models()) + .expect("the fixture ports open") + } + + #[tokio::test] + async fn a_row_read_in_uncertain_custody_is_reported_pending_for_its_own_decision_only() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let snapshot = mailbox.snapshot().await.unwrap(); + let by_mode = |mode: ControllerMode| { + snapshot + .opportunities + .iter() + .filter(|opportunity| opportunity.controller_mode == mode) + .cloned() + .collect::>() + }; + let operational = by_mode(ControllerMode::OperationalAgent); + let narrative = by_mode(ControllerMode::NarrativePersona); + let mut outputs = vec![operational_speaks("The hinge is flooding.", "operational")]; + outputs.extend(narrative_turn_outputs("Mara", "The rain has teeth tonight.")); + outputs.push(operational_speaks("Then we close the span.", "other")); + let (runner, store) = grouped_runner(&mailbox, outputs); + let operational_command = CommandId::new(); + let narrative_command = CommandId::new(); + runner + .run_operational(operational_command, &operational[0]) + .await + .unwrap(); + // The operational act moved the narrative subject's scope: bind it as + // the world now holds it. + let narrative_bound = fresh_opportunity( + &mailbox.snapshot().await.unwrap(), + narrative[0].scope.subject_id, + ); + runner + .run_narrative(narrative_command, &narrative_bound) + .await + .unwrap(); + // Another subject speaks in the commons, so every scope moves. + let other_bound = fresh_opportunity( + &mailbox.snapshot().await.unwrap(), + operational[1].scope.subject_id, + ); + runner + .run_operational(CommandId::new(), &other_bound) + .await + .unwrap(); + + let uncertain = uncertain_runner(&mailbox, &store); + let moved = mailbox.snapshot().await.unwrap(); + let operational_now = fresh_opportunity(&moved, operational[0].scope.subject_id); + let narrative_now = fresh_opportunity(&moved, narrative[0].scope.subject_id); + assert_ne!(operational_now.scope_digest, operational[0].scope_digest); + assert_ne!(narrative_now.scope_digest, narrative_bound.scope_digest); + let OperationalRun::Pending(pending) = uncertain + .run_operational(operational_command, &operational_now) + .await + .unwrap() + else { + panic!("an operational row in uncertain custody was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + let NarrativeRun::Pending(pending) = uncertain + .run_narrative(narrative_command, &narrative_now) + .await + .unwrap() + else { + panic!("a narrative row in uncertain custody was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + + let other_operational = fresh_opportunity(&moved, operational[1].scope.subject_id); + let other_narrative = fresh_opportunity(&moved, narrative[1].scope.subject_id); + assert!(matches!( + uncertain + .run_operational(operational_command, &other_operational) + .await, + Err(ControllerError::OpportunityMismatch) + )); + assert!(matches!( + uncertain + .run_narrative(narrative_command, &other_narrative) + .await, + Err(ControllerError::OpportunityMismatch) + )); + + drop((runner, uncertain)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_narrative_row_in_flight_whose_scope_moved_runs_to_the_kernel_and_re_lowers_once() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + // The first turn's projector call is pending; the second subject's turn + // commits and moves the first's scope. + let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))]; + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_narrative(command, &first).await.unwrap(), + NarrativeRun::Pending(_) + )); + assert!(matches!( + runner.run_narrative(CommandId::new(), &second).await.unwrap(), + NarrativeRun::Completed(_) + )); + let moved = mailbox.snapshot().await.unwrap(); + let fresh = fresh_opportunity(&moved, first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let log_before = mailbox.operator_log().await.unwrap().len(); + assert_eq!(log_before, 1); + + // The fresh caller names the row's decision, so the row runs on: the + // three turn calls, the kernel's refusal, then one re-lowering. A fifth + // answer is left over to show that no further call was made. + let relowering = || narrative_turn_outputs("Mara", "The rain has teeth tonight.").remove(2); + let mut resumed = narrative_turn_outputs("Mara", "The rain has teeth tonight."); + resumed.push(relowering()); + resumed.push(relowering()); + let (resume, port) = runner_over(&mailbox, resumed, store.clone()); + let NarrativeRun::Completed(decision) = + resume.run_narrative(command, &fresh).await.unwrap() + else { + panic!("the resumed narrative row did not re-lower to a commit") + }; + assert!( + decision.persona_turn().binding().interrupted_from.is_some(), + "the turn committed without recording the interruption" + ); + assert_eq!( + port.outputs.lock().unwrap().len(), + 1, + "the resume did not spend exactly four provider calls" + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), log_before + 1); + + drop((runner, resume)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_narrative_row_resumed_for_another_decision_is_refused_without_spend() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))]; + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_narrative(command, &first).await.unwrap(), + NarrativeRun::Pending(_) + )); + let (resume, port) = + runner_over(&mailbox, narrative_turn_outputs("Mara", "Rain."), store.clone()); + assert!(matches!( + resume.run_narrative(command, &second).await, + Err(ControllerError::OpportunityMismatch) + )); + assert_eq!(port.outputs.lock().unwrap().len(), 3, "the refused resume spent a call"); + // The row's own decision still resumes: the refusal was identity alone. + assert!(matches!( + resume.run_narrative(command, &first).await.unwrap(), + NarrativeRun::Completed(_) + )); + + drop((runner, resume)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_narrative_row_whose_scope_did_not_move_commits_without_re_lowering() { + let (_directory, mailbox, task) = + active_cell_mailbox(vec![NewController::NarrativePersona]).await; + let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))]; + outputs.extend(narrative_turn_outputs("Mara", "The rain has teeth tonight.")); + let (runner, _store) = grouped_runner(&mailbox, outputs); + let opportunity = mailbox.snapshot().await.unwrap().opportunities[0].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_narrative(command, &opportunity).await.unwrap(), + NarrativeRun::Pending(_) + )); + // The kernel accepts the unmoved scope, so nothing is refused and + // `interrupted` is never reached: the move is the kernel's to decide. + let NarrativeRun::Completed(decision) = + runner.run_narrative(command, &opportunity).await.unwrap() + else { + panic!("an unmoved scope did not commit") + }; + assert!(decision.persona_turn().binding().interrupted_from.is_none()); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + fn operational_declines(receipt: &str) -> Result { + output( + vec![ + InferenceEvent::ToolCall { + call_id: "need".into(), + name: RECORD_NEED_TOOL.into(), + arguments: json!({"detail":"No current intervention is warranted."}) + .to_string(), + }, + InferenceEvent::ToolCall { + call_id: "finish".into(), + name: FINISH_WITHOUT_PROPOSAL_TOOL.into(), + arguments: "{}".into(), + }, + ], + receipt, + ) + } + + fn narrative_declines(tag: &str) -> Vec> { + let source = format!("{tag} lets the question pass."); + vec![ + output( + vec![InferenceEvent::Text(format!("{tag} watches the water."))], + &format!("{tag}-projector"), + ), + output( + vec![InferenceEvent::Text(source.clone())], + &format!("{tag}-persona"), + ), + output( + vec![ + InferenceEvent::ToolCall { + call_id: "gap".into(), + name: INTERPRETER_RECORD_GAP_TOOL.into(), + arguments: json!({ + "kind":"unresolved", + "source_quote":source, + "detail":"The prose expresses no supported world action." + }) + .to_string(), + }, + InferenceEvent::ToolCall { + call_id: "finish".into(), + name: FINISH_INTERPRETATION_TOOL.into(), + arguments: "{}".into(), + }, + ], + &format!("{tag}-interpreter"), + ), + ] + } + + #[tokio::test] + async fn a_committed_operational_decline_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + // Exactly two provider answers: a third call would panic the port. + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + operational_declines("first"), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let OperationalRun::Completed(committed) = runner + .run_operational(first_command, &first) + .await + .unwrap() + else { + panic!("the first decline remained pending") + }; + let SubmissionDisposition::NoProposal(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first turn did not commit a decline") + }; + let second_now = + fresh_opportunity(&mailbox.snapshot().await.unwrap(), second.scope.subject_id); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second_now) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let OperationalRun::Completed(answered) = runner + .run_operational(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed decline was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::NoProposal(SubmitReceipt::AlreadyApplied(receipt)) + if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_committed_narrative_decline_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = narrative_declines("Mara"); + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, _store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let NarrativeRun::Completed(committed) = runner + .run_narrative(first_command, &first) + .await + .unwrap() + else { + panic!("the first decline remained pending") + }; + let SubmissionDisposition::NoProposal(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first turn did not commit a decline") + }; + let second_now = + fresh_opportunity(&mailbox.snapshot().await.unwrap(), second.scope.subject_id); + assert!(matches!( + runner + .run_narrative(CommandId::new(), &second_now) + .await + .unwrap(), + NarrativeRun::Completed(_) + )); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let NarrativeRun::Completed(answered) = runner + .run_narrative(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed decline was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::NoProposal(SubmitReceipt::AlreadyApplied(receipt)) + if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn select_scope_tells_a_moved_digest_from_a_gone_scope() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let mut moved = snapshot.clone(); + moved.opportunities[0].scope_digest = snapshot.opportunities[1].scope_digest.clone(); + assert_ne!(first.scope_digest, moved.opportunities[0].scope_digest); + assert!(matches!( + select_scope(&moved, &first, true), + Err(ControllerError::ScopeMoved) + )); + assert!(select_scope(&moved, &first, false).is_ok()); + let mut gone = snapshot.clone(); + gone.opportunities.remove(0); + assert!(matches!( + select_scope(&gone, &first, true), + Err(ControllerError::NoOpportunity { .. }) + )); + let mut doubled = snapshot.clone(); + doubled.opportunities.push(first.clone()); + assert!(matches!( + select_scope(&doubled, &first, true), + Err(ControllerError::AmbiguousOpportunity) + )); + drop(mailbox); + task.await.unwrap(); + } + + /// The probe's first case: the world moves under a run between its rounds. + /// The check at the top of the next round refuses it, one provider call in + /// total, and the driver would label it `ScopeMoved`. + #[tokio::test] + async fn an_operational_scope_move_between_rounds_is_refused_before_the_next_round() { + let (_directory, mailbox, task, world) = interruption_world().await; + let operational = mailbox + .snapshot() + .await + .unwrap() + .opportunities + .iter() + .find(|entry| entry.scope.subject_id == world.neighbour) + .expect("the neighbour has a live opportunity") + .clone(); + let calls = Arc::new(AtomicUsize::new(0)); + let runner = ControllerRunner::open( + mailbox.clone(), + Arc::new(InterruptingPort { + mailbox: mailbox.clone(), + outputs: Mutex::new(vec![output( + vec![InferenceEvent::ToolCall { + call_id: "need".into(), + name: RECORD_NEED_TOOL.into(), + arguments: json!({ "detail": "no route" }).to_string(), + }], + "operational-need", + )]), + calls: calls.clone(), + commits: vec![( + 0, + MidTurnCommit::Ops(vec![crate::patch::ComponentOp::CloseRoute { + route: Ref::Existing(world.ramp), + }]), + )], + seen: Arc::new(Mutex::new(Vec::new())), + }), + Arc::new(RecordingWorkStore { + persisted: Arc::new(AtomicBool::new(true)), + work: Mutex::new(BTreeMap::new()), + }), + models(), + ) + .expect("the fixture ports open"); + let result = runner.run_operational(CommandId::new(), &operational).await; + assert!( + matches!(result, Err(ControllerError::ScopeMoved)), + "{result:?}" + ); + assert_eq!(calls.load(Ordering::SeqCst), 1, "a doomed round spent inference"); + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + /// An in-flight operational row read in uncertain custody is pending for + /// its own decision whatever digest the caller holds: nothing is spent + /// there, and the world check runs at the point of spend after reopen. + #[tokio::test] + async fn an_operational_row_in_flight_in_uncertain_custody_is_pending_for_its_own_decision() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let (runner, store) = grouped_runner( + &mailbox, + vec![ + Err(InferenceFault::retryable("the provider is busy")), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_operational(command, &first).await.unwrap(), + OperationalRun::Pending(_) + )); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let uncertain = uncertain_runner(&mailbox, &store); + for caller in [&fresh, &first] { + let OperationalRun::Pending(pending) = + uncertain.run_operational(command, caller).await.unwrap() + else { + panic!("a row in uncertain custody was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + } + + drop((runner, uncertain)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_narrative_row_in_flight_in_uncertain_custody_is_pending_for_its_own_decision() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))]; + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_narrative(command, &first).await.unwrap(), + NarrativeRun::Pending(_) + )); + assert!(matches!( + runner + .run_narrative(CommandId::new(), &second) + .await + .unwrap(), + NarrativeRun::Completed(_) + )); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let uncertain = uncertain_runner(&mailbox, &store); + for caller in [&fresh, &first] { + let NarrativeRun::Pending(pending) = + uncertain.run_narrative(command, caller).await.unwrap() + else { + panic!("a row of the caller's own decision was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + } + + drop((runner, uncertain)); + drop(mailbox); + task.await.unwrap(); + } + + /// A provider port that severs the world's owner task once the answer is + /// in, so the next read of the world finds the mailbox unavailable. The + /// fixture's only fault injection: production code has no such seam. + struct SeversWorldAfterAnswer { + inner: RecordingPort, + world: tokio::task::AbortHandle, + } + + #[async_trait] + impl InferencePort for SeversWorldAfterAnswer { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer( + &self, + request: PreparedInference, + ) -> Result { + let answer = self.inner.infer(request).await; + self.world.abort(); + tokio::task::yield_now().await; + answer + } + } + + #[tokio::test] + async fn a_grouped_cell_whose_world_cannot_be_read_at_submission_is_pending_with_nothing_submitted() + { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let both_act = output( + vec![ + speak_call("c0", "c0__speak", "The hinge is flooding."), + speak_call("c1", "c1__speak", "Then we close the span."), + ], + "one", + ); + let persisted = Arc::new(AtomicBool::new(true)); + let port = Arc::new(SeversWorldAfterAnswer { + inner: RecordingPort { + outputs: Mutex::new(vec![both_act]), + persisted_before_interpreter: persisted.clone(), + }, + world: task.abort_handle(), + }); + let store = Arc::new(RecordingWorkStore { + persisted, + work: Mutex::new(BTreeMap::new()), + }); + let runner = ControllerRunner::open(mailbox.clone(), port, store.clone(), models()) + .expect("the fixture ports open"); + let cell = one_group(&mailbox).await; + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(run.pending, Some(ControllerPendingReason::WorldUnavailable)); + assert!(run.submissions.is_empty(), "{:?}", run.submissions); + assert!(run.refused.is_empty(), "{:?}", run.refused); + assert!( + !store.work.lock().unwrap().is_empty(), + "the cell's row was not kept for the resume" + ); + + drop(runner); + drop(mailbox); + assert!(task.await.unwrap_err().is_cancelled()); + } + + /// A store that answers the write of a row that awaits the world with + /// uncertain custody, so the run ends pending with the submission body + /// persisted but never sent. + struct NeverSentStore { + inner: Arc, + armed: AtomicBool, + } + + #[async_trait] + impl ControllerWorkStore for NeverSentStore { + async fn lookup( + &self, + command_id: CommandId, + ) -> Result { + self.inner.lookup(command_id).await + } + + async fn persist( + &self, + work: &ControllerWork, + ) -> Result { + let written = self.inner.persist(work).await?; + let awaiting = match work { + ControllerWork::Narrative(row) => matches!( + row, + NarrativeCheckpoint::ReadyToSubmit { .. } | NarrativeCheckpoint::NoProposal { .. } + ), + ControllerWork::Operational(row) => matches!( + row, + OperationalCheckpoint::ReadyToSubmit { .. } | OperationalCheckpoint::NoProposal { .. } + ), + _ => false, + }; + if awaiting && self.armed.load(Ordering::SeqCst) { + return Ok(ControllerWorkWrite::CustodyUncertain); + } + Ok(written) + } + + async fn custody_probe(&self) -> Result { + self.inner.custody_probe().await + } + + async fn elaboration_in_flight( + &self, + ) -> Result, ControllerWorkStoreError> { + self.inner.elaboration_in_flight().await + } + } + + fn runner_over( + mailbox: &WorldMailbox, + outputs: Vec>, + store: Arc, + ) -> (ControllerRunner, Arc) { + let port = Arc::new(RecordingPort { + outputs: Mutex::new(outputs), + persisted_before_interpreter: Arc::new(AtomicBool::new(true)), + }); + ( + ControllerRunner::open(mailbox.clone(), port.clone(), store, models()) + .expect("the fixture ports open"), + port, + ) + } + + fn recording_store() -> Arc { + Arc::new(RecordingWorkStore { + persisted: Arc::new(AtomicBool::new(true)), + work: Mutex::new(BTreeMap::new()), + }) + } + + /// An operational row that awaits the world but never reached it, then a + /// neighbour moves the scope, then the same command is rerun against the + /// scope as it now stands. The row is submitted and the world refuses its + /// stale body: no provider call, nothing new in the log. + async fn operational_row_never_sent_then_scope_moved( + first_output: Result, + ) { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let inner = recording_store(); + let never_sent = Arc::new(NeverSentStore { + inner: inner.clone(), + armed: AtomicBool::new(true), + }); + let (a, _) = runner_over(&mailbox, vec![first_output], never_sent.clone()); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + a.run_operational(command, &first).await, + Ok(OperationalRun::Pending(_)) + )); + never_sent.armed.store(false, Ordering::SeqCst); + let (b, _) = runner_over( + &mailbox, + vec![operational_speaks("Then we close the span.", "second")], + inner.clone(), + ); + assert!(matches!( + b.run_operational(CommandId::new(), &second).await.unwrap(), + OperationalRun::Completed(_) + )); + let log_before = mailbox.operator_log().await.unwrap().len(); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let (c, port) = runner_over(&mailbox, Vec::new(), inner.clone()); + assert!(matches!( + c.run_operational(command, &fresh).await, + Err(ControllerError::World(KernelError::ScopeChanged { .. })) + )); + assert_eq!(mailbox.operator_log().await.unwrap().len(), log_before); + assert!(port.outputs.lock().unwrap().is_empty()); + + drop((a, b, c)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn an_operational_row_ready_to_submit_but_never_sent_is_refused_by_the_world_after_its_scope_moved() + { + operational_row_never_sent_then_scope_moved(operational_speaks( + "The hinge is flooding.", + "first", + )) + .await; + } + + #[tokio::test] + async fn an_operational_decline_never_sent_is_refused_by_the_world_after_its_scope_moved() { + operational_row_never_sent_then_scope_moved(operational_declines("first")).await; + } + + /// The narrative lane answers the same refusal with its one bounded + /// re-lowering: one Interpreter call against the scope as it now stands, + /// and the re-lowered act commits. + #[tokio::test] + async fn a_narrative_row_ready_to_submit_but_never_sent_is_lowered_once_more_after_its_scope_moved() + { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let inner = recording_store(); + let never_sent = Arc::new(NeverSentStore { + inner: inner.clone(), + armed: AtomicBool::new(true), + }); + let (a, _) = runner_over( + &mailbox, + narrative_turn_outputs("Mara", "The rain has teeth tonight."), + never_sent.clone(), + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + a.run_narrative(command, &first).await, + Ok(NarrativeRun::Pending(_)) + )); + never_sent.armed.store(false, Ordering::SeqCst); + let (b, _) = runner_over( + &mailbox, + narrative_turn_outputs("Tobin", "Then we close the span."), + inner.clone(), + ); + assert!(matches!( + b.run_narrative(CommandId::new(), &second).await.unwrap(), + NarrativeRun::Completed(_) + )); + let log_before = mailbox.operator_log().await.unwrap().len(); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + // One answer is the bound; the second would be a second round. + let relowering = || narrative_turn_outputs("Mara", "The rain has teeth tonight.").remove(2); + let (c, port) = runner_over(&mailbox, vec![relowering(), relowering()], inner.clone()); + assert!(matches!( + c.run_narrative(command, &fresh).await, + Ok(NarrativeRun::Completed(_)) + )); + assert_eq!( + port.outputs.lock().unwrap().len(), + 1, + "the re-lowering did not spend exactly one provider call" + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), log_before + 1); + + drop((a, b, c)); + drop(mailbox); + task.await.unwrap(); + } } diff --git a/crates/ghostlight/src/cover.rs b/crates/ghostlight/src/cover.rs index 02c0adb..5d1ce42 100644 --- a/crates/ghostlight/src/cover.rs +++ b/crates/ghostlight/src/cover.rs @@ -11,7 +11,6 @@ //! The cover is a partition, never a filter: every active subject is in exactly //! one cell every tick. The budget decides at what resolution, not whether. -use super::clock::FictionalMinutes; use super::{ControllerMode, DecisionOpportunity, SubjectId, WorldId}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -29,25 +28,18 @@ pub(crate) const AGENCY_STAR_THRESHOLD: usize = 16; /// the name, not by a second namespace. const COVER_NAMESPACE: &str = "ghostlight.cover.v1"; -/// Monotone tick index. Derived from the world clock, never stored. It is -/// threaded into every derived command id, so a resumed tick over the same -/// snapshot re-derives the same ids and the kernel's idempotency ledger answers -/// with the original receipts instead of double-committing. +/// A tick's ordinal: the number of clock advances the world had committed when +/// the tick opened. The driver reads it from the world and freezes it in the +/// tick document; it is never derived from `now`, so it does not depend on the +/// span. It is threaded into every derived command id, so a resumed tick +/// re-submits the same ids and the kernel's idempotency ledger answers with the +/// original receipts instead of double-committing. #[derive( Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, )] #[serde(transparent)] pub struct TickIndex(pub u64); -impl TickIndex { - /// Floor division by the span one `AdvanceTime` carries. The driver submits - /// the clock *after* the cells run, so every cell in one tick sees the same - /// `now` and therefore the same index. - pub(crate) fn of(now: FictionalMinutes, tick_minutes: u32) -> Self { - Self(now.0 / u64::from(tick_minutes.max(1))) - } -} - /// How a subject was represented this tick. Derived from the cell, carried /// nowhere in world state: representation is a compute budget, not world truth. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -254,7 +246,7 @@ impl AgencyGraph { } } -/// The partition. Pure over `(world, now, opportunities, graph, budget)`. +/// The partition. Pure over `(world, tick, opportunities, graph, budget)`. /// /// `opportunities` arrives in the kernel's one attention order (pressure, then /// attention debt, then id) and is never re-sorted and never filtered here: a @@ -262,13 +254,11 @@ impl AgencyGraph { /// is a fact about cognition rather than a scheduling preference. pub fn derive_cover( world: WorldId, - now: FictionalMinutes, - tick_minutes: u32, + tick: TickIndex, opportunities: &[DecisionOpportunity], graph: &AgencyGraph, budget: CoverBudget, ) -> Cover { - let tick = TickIndex::of(now, tick_minutes); let active: Vec<&DecisionOpportunity> = opportunities .iter() .filter(|opportunity| opportunity.controller_mode != ControllerMode::Human) @@ -601,8 +591,7 @@ mod tests { ) -> Cover { derive_cover( WorldId::nil_for_test(), - FictionalMinutes(tick * 60), - 60, + TickIndex(tick), opportunities, graph, budget, diff --git a/crates/ghostlight/src/elaboration.rs b/crates/ghostlight/src/elaboration.rs index c8efd49..0c2c119 100644 --- a/crates/ghostlight/src/elaboration.rs +++ b/crates/ghostlight/src/elaboration.rs @@ -2158,6 +2158,7 @@ mod tests { }) .collect(), receipt_digest: "sha256:receipt".into(), + usage: None, } } diff --git a/crates/ghostlight/src/grammar.rs b/crates/ghostlight/src/grammar.rs new file mode 100644 index 0000000..a11cd69 --- /dev/null +++ b/crates/ghostlight/src/grammar.rs @@ -0,0 +1,209 @@ +//! The action grammar a world is bound to at creation. +//! +//! A grammar is the closed set of affordance kinds a world may declare, each +//! with its role signature and the one render path its acts take. The grammar's +//! home is the Aetheria repo; the kernel holds only this binding: the verbs it +//! enforces, and a content digest that names exactly which grammar that was. +//! The digest is derived here and nowhere else, so a binding cannot claim a +//! digest its verbs do not have. + +use crate::SubjectKind; +use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// How an act of one verb reaches the player: filmed in the engine as a ship +/// action, or shown as Aetheria's portrait-and-choices conversation. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum RenderPath { + ShipAction, + Conversation, +} + +/// What a grammar role may be bound to, in the grammar's own vocabulary. Each +/// lowers to exactly one kernel `RefKind`. +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +pub enum GrammarReferentKind { + Person, + Faction, + Population, + Place, + Cargo, +} + +impl GrammarReferentKind { + fn ref_kind(self) -> RefKind { + match self { + Self::Person => RefKind::Subject(Some(SubjectKind::Person)), + Self::Faction => RefKind::Subject(Some(SubjectKind::Institution)), + Self::Population => RefKind::Subject(Some(SubjectKind::Population)), + Self::Place => RefKind::Entity(EntityKind::Place), + Self::Cargo => RefKind::Entity(EntityKind::Resource), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct GrammarRole { + pub role: String, + pub referent: GrammarReferentKind, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct GrammarVerb { + pub render_path: RenderPath, + /// In signature order: the order is part of the verb. + pub roles: Vec, +} + +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 { + 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, +} + +#[derive(Deserialize)] +struct RawBinding { + schema_id: String, + revision: u32, + digest: String, + verbs: BTreeMap, +} + +impl TryFrom for GrammarBinding { + type Error = GrammarError; + + fn try_from(raw: RawBinding) -> Result { + 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, + revision: u32, + verbs: impl IntoIterator, + ) -> Result { + 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 { + 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 { + 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; diff --git a/crates/ghostlight/src/grammar/tests.rs b/crates/ghostlight/src/grammar/tests.rs new file mode 100644 index 0000000..85b3b1e --- /dev/null +++ b/crates/ghostlight/src/grammar/tests.rs @@ -0,0 +1,745 @@ +use super::*; +use crate::patch::{ + AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EntityKind, + Mismatch, OutcomeBand, Precondition, Role, RoleSpec, +}; +use crate::tests::{auth_principal, command, creation, owner}; +use crate::{ + CallerId, CommandBody, CommandId, CreateWorld, KernelError, SubjectKind, SubmitReceipt, + WorldId, WorldKernel, WorldPatch, +}; + +fn verb(path: RenderPath, roles: &[(&str, GrammarReferentKind)]) -> GrammarVerb { + GrammarVerb { + render_path: path, + roles: roles + .iter() + .map(|(role, referent)| GrammarRole { + role: (*role).into(), + referent: *referent, + }) + .collect(), + } +} + +/// What the fixture world's genesis needs: the kernel speak entry and the +/// fixture's own `convene`. Anything else a test wants is added to it. +fn grammar_with(extra: &[(&str, GrammarVerb)]) -> GrammarBinding { + let mut verbs = vec![ + ("speak".to_owned(), verb(RenderPath::Conversation, &[])), + ("convene".to_owned(), verb(RenderPath::Conversation, &[])), + ]; + verbs.extend( + extra + .iter() + .map(|(kind, v)| ((*kind).to_owned(), v.clone())), + ); + GrammarBinding::new("aetheria.grammar", 3, verbs).unwrap() +} + +fn bound(grammar: Option, 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) -> WorldPatch { + WorldPatch { + declarations, + operations: Vec::new(), + evidence: Vec::new(), + } +} + +fn rejected(result: Result) -> Vec { + 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) { + 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 { + 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 { + vec![Mismatch::AffordanceKindNotInGrammar { + handle: DraftHandle::new(handle), + }] +} + +fn disagrees(handle: &str) -> Vec { + 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 = + 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::(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::(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::(&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, + patch: WorldPatch, +) -> Result { + 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); + +/// 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) { + 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}"); + } + } +} diff --git a/crates/ghostlight/src/import_key_tests.rs b/crates/ghostlight/src/import_key_tests.rs new file mode 100644 index 0000000..43ca2bb --- /dev/null +++ b/crates/ghostlight/src/import_key_tests.rs @@ -0,0 +1,752 @@ +//! One record, one subject: admission keeps the import-key mapping injective at +//! every door and at every revision, and a world nobody imported into is +//! byte-for-byte what it was before keys existed. + +use crate::patch::{ + AccessKind, ComponentOp, Cost, Declaration, DraftHandle, EntityDeclaration, EntityKind, + ImportKey, Mismatch, Ref, RouteDeclaration, +}; +use crate::tests::{activate, auth_principal, command, creation, owner}; +use crate::{ + CallerId, CommandBody, CommandId, ConsumerId, CreateWorld, EntityId, JurisdictionKey, + KernelError, NewController, PatchAnswer, SubjectDeclaration, SubjectKind, SubmitReceipt, + SystemCapability, WorldKernel, WorldPatch, +}; +use std::collections::BTreeSet; + +fn key(schema: &str, record: &str) -> ImportKey { + ImportKey::new(schema, record) +} + +fn alpha() -> ImportKey { + key("aetheria.ship", "Alpha") +} + +/// The fixture genesis with its witness imported from `alpha()`. +fn keyed_creation(title: &str) -> CreateWorld { + let mut world = creation(CommandId::new(), title); + for declaration in &mut world.patch.declarations { + if let Declaration::Subject(subject) = declaration + && subject.handle == DraftHandle::new("persona") + { + subject.import_key = Some(alpha()); + } + } + world +} + +fn create(world: CreateWorld) -> (tempfile::TempDir, Result) { + 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) -> Vec { + 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) -> WorldPatch { + WorldPatch { + declarations, + operations: Vec::new(), + evidence: Vec::new(), + } +} + +fn submit_as( + kernel: &mut WorldKernel, + caller: CallerId, + answers: Option, + patch: WorldPatch, +) -> Result { + 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, 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 { + 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)>, +) { + 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 = 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 = "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" + ); +} diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index 982a807..a2d36fb 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -181,6 +181,11 @@ impl WorldJournal { self.commits.get(&command_id) } + /// Every commit the journal holds, in no particular order. + pub(super) fn commits(&self) -> impl Iterator { + self.commits.values() + } + #[cfg(test)] pub(super) fn commit_count(&self) -> usize { self.commits.len() @@ -826,6 +831,17 @@ pub(super) fn verify_state_shape(state: &WorldState) -> Result<(), JournalError> "one subject holds two overlapping jurisdictions of one kind".into(), )); } + let mut declared_keys = BTreeSet::new(); + let no_held = BTreeSet::new(); + for subject in state.subjects.values() { + if let Some(key) = &subject.import_key + && super::patch::import_key_fault(key, &no_held, &mut declared_keys).is_some() + { + return Err(JournalError::Corrupt( + "subject import key is malformed or held by two subjects".into(), + )); + } + } let mut controller_ids = BTreeSet::new(); for (subject_id, subject) in &state.subjects { if !super::patch::is_canonical_text(&subject.label) { @@ -1107,8 +1123,10 @@ mod tests { }, affordances: kernel_speak_grant(), position: None, + import_key: None, }), scale_intent: WorldScaleIntentRef::default(), + grammar: None, }; let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap(); let snapshot = kernel.snapshot().unwrap(); @@ -1190,8 +1208,10 @@ mod tests { }, affordances: kernel_speak_grant(), position: None, + import_key: None, }), scale_intent: WorldScaleIntentRef::default(), + grammar: None, }; let (mut kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap(); let snapshot = kernel.snapshot().unwrap(); @@ -1220,6 +1240,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak_entry(&kernel)]), position: Some(Ref::Draft(DraftHandle::new("rhythm-road"))), + import_key: None, }), ], operations: Vec::new(), @@ -1755,8 +1776,10 @@ mod tests { controller: NewController::Human { principal: owner }, affordances: kernel_speak_grant(), position: None, + import_key: None, }), scale_intent: WorldScaleIntentRef::default(), + grammar: None, }; let (kernel, _) = WorldKernel::create(&path, creation, &authenticated).unwrap(); let mut forged_head = kernel.state.clone(); diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 68a60f5..3f64965 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -56,11 +56,15 @@ //! ``` mod action; +mod aetheria; mod clock; mod consumer; mod controllers; mod cover; mod elaboration; +mod grammar; +#[cfg(test)] +mod import_key_tests; mod journal; mod lens; mod local_inference; @@ -70,6 +74,7 @@ mod sdk_inference; mod table; mod tool_schema; mod vault; +mod verse; pub(crate) use action::ActionMismatch; pub(crate) use clock::Motion; @@ -80,23 +85,31 @@ pub use consumer::{ }; pub use controllers::{ CONTROLLER_WORK_SCHEMA, CellRun, ConnectorBinding, ControllerError, ControllerModels, + ControllerOpenError, ControllerNeed, ControllerPendingReason, ControllerRunner, ControllerWork, ControllerWorkCustody, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError, ControllerWorkWrite, GroupedCheckpoint, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceFaultDisposition, InferenceOutput, InferencePort, InferencePurpose, InferenceRequest, NarrativeCheckpoint, NarrativeRun, OperationalCheckpoint, - OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, ToolResultOracle, + OperationalRun, PersonaLane, PreparedInference, SubmissionDisposition, TokenUsage, ToolResultOracle, TracingInferencePort, open_controller_work, open_inference, }; pub(crate) use cover::{CellId, Constituent, Resolution}; +pub use aetheria::{ + AetheriaCatalog, AetheriaFaction, CatalogReadError, VerbRule, read_aetheria_catalog, +}; pub use cover::{AgencyGraph, Cell, Cover, CoverBudget, CoverBudgetError, TickIndex, derive_cover}; pub use elaboration::{ ElaborationCheckpoint, ElaborationRunner, ElaboratorSession, EvidenceError, EvidenceQuery, EvidenceReceipt, EvidenceSource, SeedCheckpoint, SeedOutcome, SeedRunner, select_row, }; +pub use grammar::{ + GrammarBinding, GrammarError, GrammarIdentity, GrammarReferentKind, GrammarRole, GrammarVerb, + RenderPath, +}; pub use lens::{Lens, LensWeights}; -pub use local_inference::{DEFAULT_LOCAL_MODEL_PREFIX, LocalBinding}; +pub use local_inference::{DEFAULT_HOSTED_MODEL_PREFIX, DEFAULT_LOCAL_MODEL_PREFIX, HostedBinding, LocalBinding}; #[cfg(feature = "test-support")] pub use local_inference::open_local_port_with_timeout; pub(crate) use mailbox::ElaborationPort; @@ -113,6 +126,7 @@ pub(crate) use patch::{ PersonaMaterial, Position, PressureMagnitude, PressureSource, Quantity, Reach, SubjectDeclaration, WorldScaleIntent, WorldScaleIntentRef, }; +pub use patch::ImportKey; #[cfg(test)] use patch::{ AffordanceDeclaration, AudienceRef, AudienceSpec, ChannelDeclaration, ComponentOp, @@ -130,6 +144,13 @@ pub use table::{ id_text, id_text_matches, table_view, }; pub use vault::{VaultError, VaultEvidenceSource}; +pub use verse::{ + CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord, + RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, RunClaim, + ReplayInferencePort, RunField, RunId, RunOutcome, RunStart, RunStatus, TickCheckpoint, + TickReport, UnattributedCall, VerseGenesisError, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, + call_run_id, run_manual, +}; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; use chrono::{DateTime, Utc}; @@ -425,6 +446,10 @@ struct CreateWorld { /// The world's flavor as genesis writes it. `SetLensWeights` may replace /// it later; this command carries what the world began with. lens_weights: LensWeights, + /// The grammar the world is bound to for its whole life, or none for an + /// open-catalog world. Authored here and nowhere else: genesis installs it. + #[serde(default, skip_serializing_if = "Option::is_none")] + grammar: Option, } /// Unattributed creation intent. World ingress derives ownership, controller @@ -709,6 +734,10 @@ pub enum CommandBody { struct SubjectState { label: String, kind: SubjectKind, + /// Written only when a subject declaration is resolved; absent from a + /// subject nobody imported, so its digests are unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + import_key: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -828,6 +857,11 @@ struct WorldState { /// Authored at creation and never mutated; the one prose the world says /// about itself, projected as guidance to every lane. brief: String, + /// Bound by genesis and never written again; absent from a world with no + /// grammar, so such a world's state and digests are what they were before + /// grammars existed. + #[serde(default, skip_serializing_if = "Option::is_none")] + grammar: Option, draft_approvals: BTreeSet, subjects: BTreeMap, entities: BTreeMap, @@ -993,6 +1027,8 @@ pub struct SubjectSnapshot { pub id: SubjectId, pub label: String, pub kind: SubjectKind, + /// The Aetheria record this subject was imported from, if it was. + pub import_key: Option, /// 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, @@ -1135,6 +1171,9 @@ pub(crate) struct ForumSnapshot { pub struct AffordanceSnapshot { pub id: AffordanceId, pub entry: Affordance, + /// Derived from the world's grammar by this entry's kind; none when the + /// world has no grammar. + pub render_path: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1197,6 +1236,8 @@ pub struct WorldSnapshot { pub owner: PrincipalId, pub title: String, pub brief: String, + /// The bound grammar's identity; none for a world with an open catalog. + pub grammar: Option, pub draft_approvals: BTreeSet, pub required_approvers: BTreeSet, pub subjects: Vec, @@ -1238,6 +1279,21 @@ pub struct OperatorEvent { pub speaker_label: String, pub speech: Option, pub display: Option, + /// 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, + /// What the act bound each of the verb's roles to, in the invocation's + /// order. + pub roles: Vec, +} + +/// One role an act bound, with the label of what it was bound to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoundRole { + pub role: String, + pub target: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1425,7 +1481,12 @@ fn prepare_creation( // derived ID, so it is minted before resolution rather than by it. let world_id = WorldId::issue(); let resolved = patch::resolve_patch( - &WorldState::empty(world_id, input.owner.clone(), title.clone()), + &WorldState::empty( + world_id, + input.owner.clone(), + title.clone(), + input.grammar.clone(), + ), input.id, &input.patch, Some(&input.scale_intent), @@ -1523,7 +1584,43 @@ impl WorldKernel { /// no consumer may read in full would be a loaded gun with a comment on it. fn operator_log(&self) -> Result, KernelError> { self.journal.ensure_healthy()?; - operator_log(&self.state) + // An event records what the world did; the invocation, which names the + // bound roles, lives on the commit that produced it. + let invocations: BTreeMap = 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, 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 { + self.journal.ensure_healthy()?; + Ok(self + .journal + .commits() + .filter(|commit| matches!(commit.effect, WorldEffect::TimeAdvanced { .. })) + .count() as u64) } /// The scheduler's adjacency projection. Separate from `snapshot` for the @@ -1896,7 +1993,12 @@ impl WorldState { /// The world before any structure. Genesis resolves its own patch against /// this value, so the genesis lane and `AdmitPatch` share one resolver /// signature instead of hand-passing empty partitions. - fn empty(world_id: WorldId, owner: PrincipalId, title: String) -> Self { + fn empty( + world_id: WorldId, + owner: PrincipalId, + title: String, + grammar: Option, + ) -> Self { Self { schema: STATE_SCHEMA.into(), world_id, @@ -1905,6 +2007,7 @@ impl WorldState { owner, title, brief: String::new(), + grammar, draft_approvals: BTreeSet::new(), subjects: BTreeMap::new(), entities: BTreeMap::new(), @@ -1962,7 +2065,7 @@ impl WorldState { // The same re-derive-and-compare that `apply_committed_command` runs for // every other command. Deterministic allocation is what lets one // equality replace a field-by-field binding zip. - let mut state = Self::empty(world_id, owner.clone(), title.clone()); + let mut state = Self::empty(world_id, owner.clone(), title.clone(), command.grammar.clone()); state.brief = brief.clone(); let expected = patch::resolve_patch( &state, @@ -2071,6 +2174,18 @@ fn admit_resolved( .filter_map(ControllerAssignment::id) .collect(); for entry in &resolved.affordances { + // The catalog's only writer holds the same grammar check the resolver + // runs, so an effect resolved elsewhere cannot install an entry the + // bound grammar refuses. + if state.grammar.as_ref().is_some_and(|grammar| { + grammar + .refusal(&entry.affordance.kind, &entry.affordance.roles) + .is_some() + }) { + return Err(KernelError::Invariant( + "admitted affordance is outside the bound grammar".into(), + )); + } if state .affordance_catalog .insert(entry.affordance_id, entry.affordance.clone()) @@ -2081,6 +2196,21 @@ fn admit_resolved( )); } } + // The subjects map's only writer holds the same injectivity the resolver + // checks, decided by the same function. + { + let held = patch::held_import_keys(state.subjects.values()); + let mut declared = BTreeSet::new(); + for subject in &resolved.subjects { + if let Some(key) = &subject.subject.import_key + && patch::import_key_fault(key, &held, &mut declared).is_some() + { + return Err(KernelError::Invariant( + "admitted subject's import key is malformed, held or repeated".into(), + )); + } + } + } for subject in &resolved.subjects { let scope = DecisionScope { subject_id: subject.subject_id, @@ -3665,7 +3795,10 @@ fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponent /// The human operator's story feed, derived from the causal record. The events /// log is not a perception surface: `knowledge` is the sole answer to what a /// subject may act on and what its projection may contain. -fn operator_log(state: &WorldState) -> Result, KernelError> { +fn operator_log( + state: &WorldState, + invocations: &BTreeMap, +) -> Result, KernelError> { state .events .iter() @@ -3674,6 +3807,30 @@ fn operator_log(state: &WorldState) -> Result, KernelError> { .subjects .get(&event.scope.subject_id) .ok_or_else(|| KernelError::Invariant("event names no live subject".into()))?; + let invocation = invocations.get(&event.id).ok_or_else(|| { + KernelError::Invariant("event has no committed invocation".into()) + })?; + let entry = state + .affordance_catalog + .get(&event.affordance) + .ok_or_else(|| KernelError::Invariant("event names no catalog entry".into()))?; + let roles = invocation + .bindings + .iter() + .map(|binding| { + let label = match binding.target { + Target::Subject(id) => state.subjects.get(&id).map(|s| s.label.clone()), + Target::Entity(id) => state.entities.get(&id).map(|e| e.label.clone()), + Target::Edge(id) => state.edges.contains_key(&id).then(|| "edge".to_owned()), + }; + label + .map(|target| BoundRole { + role: binding.role.0.clone(), + target, + }) + .ok_or_else(|| KernelError::Invariant("event binds no live referent".into())) + }) + .collect::, _>>()?; Ok(OperatorEvent { revision: event.revision, speaker: event.scope.subject_id, @@ -3686,6 +3843,13 @@ fn operator_log(state: &WorldState) -> Result, KernelError> { .display .and_then(|fact| state.facts.get(&fact)) .map(|record| record.statement.clone()), + verb: entry.kind.0.clone(), + render_path: state + .grammar + .as_ref() + .and_then(|grammar| grammar.verb(&entry.kind.0)) + .map(|verb| verb.render_path), + roles, }) }) .collect() @@ -3964,6 +4128,7 @@ fn snapshot(state: &WorldState) -> Result { id: *subject_id, label: subject.label.clone(), kind: subject.kind, + import_key: subject.import_key.clone(), controller_id: controller.id(), controller_mode: controller.mode(), human_controller: controller.human_principal().cloned(), @@ -4079,6 +4244,7 @@ fn snapshot(state: &WorldState) -> Result { owner: state.owner.clone(), title: state.title.clone(), brief: state.brief.clone(), + grammar: state.grammar.as_ref().map(GrammarBinding::identity), draft_approvals: state.draft_approvals.clone(), required_approvers: required_approvers(state), subjects, @@ -4088,6 +4254,11 @@ fn snapshot(state: &WorldState) -> Result { .map(|(affordance_id, entry)| AffordanceSnapshot { id: *affordance_id, entry: entry.clone(), + render_path: state + .grammar + .as_ref() + .and_then(|grammar| grammar.verb(&entry.kind.0)) + .map(|verb| verb.render_path), }) .collect(), places, @@ -5438,6 +5609,7 @@ mod tests { // The fixture world is one room. Co-located speech needs a place to // fill, so every fixture subject stands in the commons. position: Some(Ref::Draft(DraftHandle::new(COMMONS))), + import_key: None, }) } @@ -5540,11 +5712,13 @@ mod tests { .chain(std::iter::once(Ref::Draft(DraftHandle::new("convene")))) .collect(), position: Some(Ref::Draft(DraftHandle::new(COMMONS))), + import_key: None, }), ], }, scale_intent: WorldScaleIntentRef::default(), lens_weights: stock_weights(), + grammar: None, } } @@ -5689,6 +5863,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak]), position: Some(Ref::Draft(DraftHandle::new("yard"))), + import_key: None, }), ], // A route that should start closed is declared and closed in one @@ -5794,6 +5969,7 @@ mod tests { })) .collect(), position: Some(Ref::Existing(place)), + import_key: None, }) } @@ -6314,6 +6490,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: civic_grants(handles, &speak), position: Some(place), + import_key: None, }) }; let declarations = civic_affordances() @@ -6370,6 +6547,7 @@ mod tests { &speak, ), position: Some(Ref::Draft(DraftHandle::new("hall"))), + import_key: None, }), Declaration::Subject(SubjectDeclaration { handle: DraftHandle::new("reeve"), @@ -6381,6 +6559,7 @@ mod tests { &speak, ), position: Some(Ref::Draft(DraftHandle::new("chamber"))), + import_key: None, }), person( "farmer", @@ -6404,6 +6583,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: civic_grants(&["levy", "petition"], &speak), position: None, + import_key: None, }), ]) .collect(); @@ -6567,6 +6747,7 @@ mod tests { Ref::Draft(DraftHandle::new("recant")), ]), position: place.map(|place| Ref::Draft(DraftHandle::new(place))), + import_key: None, }) }; submit_owner( @@ -7835,6 +8016,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak.clone()]), position: Some(Ref::Existing(topology.road)), + import_key: None, }), ], operations: Vec::new(), @@ -8853,6 +9035,7 @@ mod custody_tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak.clone()]), position: Some(Ref::Existing(topology.yard)), + import_key: None, }), ], operations: vec![ComponentOp::Admit { @@ -9512,6 +9695,7 @@ mod custody_tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak.clone()]), position: Some(Ref::Existing(topology.yard)), + import_key: None, }), ], operations: vec![admit(u64::MAX), admit(1)], @@ -11438,7 +11622,7 @@ mod soul_knowledge_tests { // Nobody holds it. assert!(kernel.state.knowledge.is_empty()); // The operator reads it anyway, with the speaker resolved by label. - let log = operator_log(&kernel.state).unwrap(); + let log = kernel.operator_log().unwrap(); assert_eq!( log, vec![OperatorEvent { @@ -11447,6 +11631,9 @@ mod soul_knowledge_tests { speaker_label: "The Yard Bystander".into(), speech: Some(Statement::new(text).unwrap()), display: None, + verb: "speak".into(), + render_path: None, + roles: Vec::new(), }] ); // And no subject snapshot carries it. @@ -11504,6 +11691,7 @@ mod witness_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak.clone(), Ref::Draft(DraftHandle::new("beacon"))]), position: Some(Ref::Draft(DraftHandle::new(place))), + import_key: None, }) } @@ -12617,6 +12805,7 @@ mod witness_tests { // Every non-mirror subject must hold one. affordances: BTreeSet::from([Ref::Existing(beacon)]), position: position.map(Ref::Existing), + import_key: None, }), ], operations: vec![witness( @@ -12981,6 +13170,7 @@ mod clock_tests { Ref::Draft(DraftHandle::new("threaten")), ]), position: Some(Ref::Draft(DraftHandle::new(place))), + import_key: None, }) }; let subject = |handle: &str| Ref::Draft(DraftHandle::new(handle)); @@ -15162,7 +15352,7 @@ mod clock_tests { })); let title = normalize_title(&input.title).unwrap(); let resolved = patch::resolve_patch( - &WorldState::empty(world_id, input.owner.clone(), title.clone()), + &WorldState::empty(world_id, input.owner.clone(), title.clone(), None), input.id, &input.patch, Some(&input.scale_intent), @@ -15191,7 +15381,7 @@ mod clock_tests { }); let title = normalize_title(&input.title).unwrap(); let resolved = patch::resolve_patch( - &WorldState::empty(world_id, input.owner.clone(), title.clone()), + &WorldState::empty(world_id, input.owner.clone(), title.clone(), None), input.id, &input.patch, Some(&input.scale_intent), @@ -17034,6 +17224,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, }), ], operations: Vec::new(), @@ -17100,6 +17291,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, }), ], operations: Vec::new(), @@ -17177,6 +17369,7 @@ mod clock_tests { }, affordances: BTreeSet::new(), position: Some(Ref::Existing(commons)), + import_key: None, })], operations: Vec::new(), evidence: Vec::new(), @@ -17270,6 +17463,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, }), ], operations: vec![ComponentOp::Mint { @@ -17512,6 +17706,7 @@ mod clock_tests { // the boundary its own answer claims: an occupied // place is no longer unelaborated. position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: Vec::new(), evidence: Vec::new(), @@ -17690,6 +17885,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: vec![ComponentOp::Retire { subject: Ref::Draft(DraftHandle::new("stray")), @@ -17799,6 +17995,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(commons)), + import_key: None, }), ], operations: vec![ComponentOp::GrantAffordance { @@ -18013,6 +18210,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: vec![ComponentOp::GrantAffordance { subject: Ref::Draft(DraftHandle::new("stray")), @@ -18431,6 +18629,7 @@ mod clock_tests { Ref::Existing(clockwork.threaten), ]), position: Some(Ref::Existing(clockwork.dead_end)), + import_key: None, })], operations: vec![ComponentOp::RevokeAffordance { subject: Ref::Draft(DraftHandle::new("stray")), @@ -18879,6 +19078,7 @@ mod clock_tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak.clone()]), position: None, + import_key: None, }), Declaration::Channel(ChannelDeclaration { handle: DraftHandle::new("wire"), diff --git a/crates/ghostlight/src/local_inference.rs b/crates/ghostlight/src/local_inference.rs index 83e0bc1..fd7cdff 100644 --- a/crates/ghostlight/src/local_inference.rs +++ b/crates/ghostlight/src/local_inference.rs @@ -1,5 +1,8 @@ -//! A local OpenAI-compatible inference port, for a loopback model server this -//! process does not authenticate to and does not need a credential for. +//! An OpenAI-compatible inference port with two bindings. The local binding +//! reaches a loopback model server this process does not authenticate to and +//! needs no credential for. The hosted binding reaches an https endpoint +//! (a cheap hosted model for the Aetheria verse) with a bearer key read once +//! from a file. //! //! This module owns one authority: lowering a prepared request to one //! `POST /v1/chat/completions` call and lifting the reply back into the same @@ -10,8 +13,8 @@ use super::controllers::{ ControllerOpenError, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceOutput, InferencePort, - InferenceRequest, PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, call_id_is_valid, - tool_name_is_valid, unix_ms, + InferenceRequest, PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, TokenUsage, + call_id_is_valid, tool_name_is_valid, unix_ms, }; use async_trait::async_trait; use codex_connector::CodexInputItem; @@ -19,7 +22,9 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::net::SocketAddr; +use std::path::PathBuf; use std::sync::Arc; +use zeroize::Zeroizing; /// The model-name prefix that names the local transport when nothing /// configures one. @@ -33,6 +38,41 @@ pub struct LocalBinding { pub caller_runtime_id: String, } +/// The model-name prefix that names the hosted transport when nothing +/// configures one. +pub const DEFAULT_HOSTED_MODEL_PREFIX: &str = "hosted/"; + +/// Everything the hosted binding needs to open. `base_url` is the provider's +/// origin (`https://api.deepseek.com`); the port appends +/// `/v1/chat/completions`. The key is read from `key_path` once, at open. +pub struct HostedBinding { + pub base_url: String, + pub key_path: PathBuf, + pub model_prefix: String, + pub caller_runtime_id: String, +} + +/// Where the port sends its one request. The credential lives only in the +/// `Hosted` variant, so the loopback binding cannot carry one. +enum Endpoint { + Loopback(SocketAddr), + Hosted { + base_url: String, + key: Zeroizing, + }, +} + +impl Endpoint { + fn url(&self) -> String { + match self { + Self::Loopback(address) => format!("http://{address}/v1/chat/completions"), + Self::Hosted { base_url, .. } => { + format!("{}/v1/chat/completions", base_url.trim_end_matches('/')) + } + } + } +} + const LOCAL_RECEIPT_SCHEMA: &str = "ghostlight.local_inference_receipt.v1"; /// The identity half is computed here from the invocation, exactly as the SDK @@ -113,22 +153,47 @@ struct LocalChatFunctionCall { arguments: String, } -#[derive(Debug, Deserialize)] -struct LocalChatUsage { - #[serde(default)] - prompt_tokens: u64, - #[serde(default)] - completion_tokens: u64, +/// A count may be absent from the wire (no usage reported, never a zero), but +/// a count that is present must be a number: an explicit null is an integrity +/// violation, as it is on the local lane before the hosted lane existed. +fn present_count<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + u64::deserialize(deserializer).map(Some) } -/// The local OpenAI-compatible backend behind Ghostlight's inference seam. +/// Every count is optional on the wire: a reply missing the prompt or +/// completion count reports no usage rather than a zero. +#[derive(Debug, Deserialize)] +struct LocalChatUsage { + #[serde(default, deserialize_with = "present_count")] + prompt_tokens: Option, + #[serde(default, deserialize_with = "present_count")] + completion_tokens: Option, + /// DeepSeek's cache-hit count, part of `prompt_tokens`. + #[serde(default)] + prompt_cache_hit_tokens: Option, +} + +impl LocalChatUsage { + fn reported(&self) -> Option { + Some(TokenUsage { + prompt: self.prompt_tokens?, + completion: self.completion_tokens?, + cached_prompt: self.prompt_cache_hit_tokens, + }) + } +} + +/// The OpenAI-compatible backend behind Ghostlight's inference seam. /// Unlike the SDK port, it runs no query loop: one prepared request lowers to /// one HTTP call, and any tool call in the reply comes back inert for Rust's /// own evaluator to drive the next round, exactly as the connector lane /// already does. pub(super) struct LocalInferencePort { client: reqwest::Client, - endpoint: SocketAddr, + endpoint: Endpoint, prefix: String, caller_runtime_id: String, /// Read only by the tests that pin which timeout a constructor resolves @@ -138,16 +203,17 @@ pub(super) struct LocalInferencePort { } impl LocalInferencePort { - /// The loopback check happens here, in the constructor, so a non-loopback - /// endpoint cannot reach this port through any path that builds one — - /// not only the one `open_inference` exercises today. The client is - /// built with `.no_proxy()` so an environment proxy variable can never - /// carry this request, or a `proxy-authorization` header derived from a - /// proxy URL's userinfo, off this loopback endpoint; and with redirects - /// disabled, so a 3xx reply cannot re-POST the request's contents to a - /// second endpoint this port never opened. + /// The endpoint checks happen here, in the constructor, so neither a + /// non-loopback local endpoint nor a non-https hosted one can reach this + /// port through any path that builds one — not only the one + /// `open_inference` exercises today. The client is built with + /// `.no_proxy()` so an environment proxy variable can never carry this + /// request, or a `proxy-authorization` header derived from a proxy URL's + /// userinfo, off the endpoint; and with redirects disabled, so a 3xx + /// reply cannot re-POST the request's contents (or the hosted bearer) to + /// a second endpoint this port never opened. fn new( - endpoint: SocketAddr, + endpoint: Endpoint, prefix: impl Into, caller_runtime_id: impl Into, ) -> Result { @@ -159,13 +225,34 @@ impl LocalInferencePort { /// owner; the other callers are tests that reach the timeout path without /// waiting `RESPONSE_TIMEOUT` out. fn with_timeout( - endpoint: SocketAddr, + endpoint: Endpoint, prefix: impl Into, caller_runtime_id: impl Into, timeout: std::time::Duration, ) -> Result { - if !endpoint.ip().is_loopback() { - return Err(ControllerOpenError::LocalEndpointNotLoopback { endpoint }); + Self::build(endpoint, prefix, caller_runtime_id, timeout, false) + } + + /// The one builder. `allow_plain_http` is false on every production path; + /// the tests' hosted seam passes true so a hosted endpoint can be a + /// loopback responder with no TLS. + fn build( + endpoint: Endpoint, + prefix: impl Into, + caller_runtime_id: impl Into, + timeout: std::time::Duration, + allow_plain_http: bool, + ) -> Result { + match &endpoint { + Endpoint::Loopback(address) if !address.ip().is_loopback() => { + return Err(ControllerOpenError::LocalEndpointNotLoopback { endpoint: *address }); + } + Endpoint::Hosted { base_url, .. } + if !allow_plain_http && !base_url.starts_with("https://") => + { + return Err(ControllerOpenError::HostedEndpointNotHttps); + } + _ => {} } Ok(Self { client: reqwest::Client::builder() @@ -183,13 +270,11 @@ impl LocalInferencePort { } async fn send(&self, body: Value) -> Result { - let response = self - .client - .post(format!("http://{}/v1/chat/completions", self.endpoint)) - .json(&body) - .send() - .await - .map_err(send_fault)?; + let mut request = self.client.post(self.endpoint.url()).json(&body); + if let Endpoint::Hosted { key, .. } = &self.endpoint { + request = request.bearer_auth(key.as_str()); + } + let response = request.send().await.map_err(send_fault)?; let status = response.status(); if !status.is_success() { let detail = "the local inference endpoint returned a non-success status"; @@ -240,8 +325,9 @@ fn bad_reply() -> InferenceFault { /// Lowers one prepared request to the OpenAI chat-completions body: the /// system message is the request's instructions, every input item maps to -/// one message in order, and every offered tool is declared `strict`. No -/// header but content type is ever set on the request this builds. +/// one message in order, and every offered tool is declared `strict`. The +/// body carries no credential; the hosted binding's bearer is the one header +/// `send` adds. fn lower_request(prepared: &PreparedInference, prefix: &str) -> Result { let request = &prepared.invocation.request; let model = request.model.strip_prefix(prefix).unwrap_or(&request.model); @@ -345,6 +431,7 @@ fn assemble_output( arguments: call.function.arguments, }); } + let usage = response.usage.as_ref().and_then(LocalChatUsage::reported); let receipt = LocalInferenceReceipt { schema_id: LOCAL_RECEIPT_SCHEMA.to_owned(), request_id: request.request_id.clone(), @@ -358,20 +445,21 @@ fn assemble_output( prompt_tokens: response .usage .as_ref() - .map(|usage| usage.prompt_tokens) + .and_then(|usage| usage.prompt_tokens) .unwrap_or_default(), completion_tokens: response .usage .as_ref() - .map(|usage| usage.completion_tokens) + .and_then(|usage| usage.completion_tokens) .unwrap_or_default(), }; let receipt_bytes = rmp_serde::to_vec_named(&receipt) .map_err(|_| InferenceFault::new("the local inference receipt could not be encoded"))?; - Ok(InferenceOutput::new( - events, - format!("sha256:{:x}", Sha256::digest(&receipt_bytes)), - )) + let output = InferenceOutput::new(events, format!("sha256:{:x}", Sha256::digest(&receipt_bytes))); + Ok(match usage { + Some(usage) => output.with_usage(usage), + None => output, + }) } #[async_trait] @@ -421,7 +509,7 @@ pub fn open_local_port_with_timeout( timeout: std::time::Duration, ) -> Result, ControllerOpenError> { Ok(Arc::new(LocalInferencePort::with_timeout( - binding.endpoint, + Endpoint::Loopback(binding.endpoint), binding.model_prefix, binding.caller_runtime_id, timeout, @@ -429,7 +517,47 @@ pub fn open_local_port_with_timeout( } fn local_port(binding: LocalBinding) -> Result { - LocalInferencePort::new(binding.endpoint, binding.model_prefix, binding.caller_runtime_id) + LocalInferencePort::new( + Endpoint::Loopback(binding.endpoint), + binding.model_prefix, + binding.caller_runtime_id, + ) +} + +/// Builds the hosted port from its binding: reads the key file once, then the +/// constructor checks the URL is https. Every failure names the field's +/// error and never the path, the URL or the key. +pub(super) fn open_hosted_port( + binding: HostedBinding, +) -> Result, ControllerOpenError> { + Ok(Arc::new(hosted_port(binding)?)) +} + +fn hosted_port(binding: HostedBinding) -> Result { + 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, ControllerOpenError> { + let bytes = Zeroizing::new(std::fs::read(path).map_err(|_| ControllerOpenError::HostedKeyUnreadable)?); + let raw = std::str::from_utf8(bytes.as_slice()).map_err(|_| ControllerOpenError::HostedKeyUnreadable)?; + let key = raw.trim_end_matches(['\r', '\n']); + // A bearer token is one run of visible ASCII: anything else (padding, an + // interior newline, a control character) cannot ride an Authorization + // header, so it is refused here and not at the first send. + if key.is_empty() || !key.bytes().all(|byte| byte.is_ascii_graphic()) { + return Err(ControllerOpenError::HostedKeyUnreadable); + } + Ok(Zeroizing::new(key.to_owned())) } #[cfg(test)] @@ -663,7 +791,7 @@ mod tests { let _guard = client_build_lock() .lock() .expect("the client-build lock is never poisoned"); - LocalInferencePort::new(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME) + LocalInferencePort::new(Endpoint::Loopback(endpoint), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME) .expect("a loopback endpoint opens") } @@ -824,6 +952,7 @@ mod tests { model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(), caller_runtime_id: TEST_RUNTIME.into(), }), + None, &[TEST_MODEL], ) .err() @@ -845,14 +974,14 @@ mod tests { .lock() .expect("the client-build lock is never poisoned"); LocalInferencePort::new( - "[::1]:1".parse().unwrap(), + Endpoint::Loopback("[::1]:1".parse().unwrap()), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, ) .expect("the IPv6 loopback address opens"); let error = LocalInferencePort::new( - "[2001:db8::1]:8080".parse().unwrap(), + Endpoint::Loopback("[2001:db8::1]:8080".parse().unwrap()), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, ) @@ -903,7 +1032,7 @@ mod tests { Some(Arc::clone(&connector_port)), Some(Arc::clone(&sdk_port)), "claude", - Some(("claude-local/".to_owned(), Arc::clone(&local_port))), + vec![("claude-local/".to_owned(), Arc::clone(&local_port))], ); assert!(Arc::ptr_eq( routed.route("claude-local/llama-8b").unwrap(), @@ -930,6 +1059,7 @@ mod tests { model_prefix: "claude".into(), caller_runtime_id: TEST_RUNTIME.into(), }), + None, &["claude-opus-5"], ) .err() @@ -1050,7 +1180,7 @@ mod tests { std::env::set_var("HTTP_PROXY", &proxy_url); std::env::set_var("http_proxy", &proxy_url); } - let built = LocalInferencePort::new(target.endpoint(), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME); + let built = LocalInferencePort::new(Endpoint::Loopback(target.endpoint()), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME); unsafe { std::env::remove_var("HTTP_PROXY"); std::env::remove_var("http_proxy"); @@ -1212,6 +1342,7 @@ mod tests { model_prefix: String::new(), caller_runtime_id: TEST_RUNTIME.into(), }), + None, &[TEST_MODEL], ) .err() @@ -1237,6 +1368,7 @@ mod tests { model_prefix: String::new(), }), None, + None, &[TEST_MODEL], ) .err() @@ -1566,7 +1698,7 @@ mod tests { let port = { let _guard = client_build_lock().lock().expect("the client-build lock is never poisoned"); LocalInferencePort::with_timeout( - addr, + Endpoint::Loopback(addr), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, std::time::Duration::from_millis(150), @@ -1613,7 +1745,7 @@ mod tests { let _guard = client_build_lock() .lock() .expect("the client-build lock is never poisoned"); - LocalInferencePort::with_timeout(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, timeout) + LocalInferencePort::with_timeout(Endpoint::Loopback(endpoint), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME, timeout) .expect("a loopback endpoint opens") } @@ -1706,7 +1838,7 @@ mod tests { let endpoint: SocketAddr = "127.0.0.1:9".parse().unwrap(); let constructed = { let _guard = client_build_lock().lock().expect("the client-build lock is never poisoned"); - LocalInferencePort::new(endpoint, DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME) + LocalInferencePort::new(Endpoint::Loopback(endpoint), DEFAULT_LOCAL_MODEL_PREFIX, TEST_RUNTIME) .expect("a loopback endpoint opens") }; assert_eq!(constructed.response_timeout, RESPONSE_TIMEOUT); @@ -1751,4 +1883,383 @@ mod tests { } } } + + // ---- The hosted binding and token usage (cut hosted-lane) ---- + + /// A synthetic bearer, generated for the test and written to a temp key + /// file, so the production reader is the path under test. + const SYNTHETIC_KEY: &str = "synthetic-bearer-0123456789abcdef"; + + /// One file per call (named by its content's length and first byte), so + /// several cases in one directory never overwrite each other. + fn write_key_file(directory: &tempfile::TempDir, content: &[u8]) -> PathBuf { + let tag = format!("{}-{}", content.len(), content.first().copied().unwrap_or(0)); + let path = directory.path().join(format!("provider-{tag}.key")); + std::fs::write(&path, content).unwrap(); + path + } + + fn hosted_binding(base_url: &str, key_path: PathBuf) -> HostedBinding { + HostedBinding { + base_url: base_url.into(), + key_path, + model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(), + caller_runtime_id: TEST_RUNTIME.into(), + } + } + + /// The test-only seam: the production key reader and the production + /// builder, with the https requirement lifted so the endpoint can be a + /// loopback responder with no TLS. + fn hosted_port_over_plain_http(base_url: String, key_path: &std::path::Path) -> LocalInferencePort { + let _guard = client_build_lock() + .lock() + .expect("the client-build lock is never poisoned"); + LocalInferencePort::build( + Endpoint::Hosted { + base_url, + key: read_key_file(key_path).expect("the synthetic key file reads"), + }, + DEFAULT_HOSTED_MODEL_PREFIX, + TEST_RUNTIME, + RESPONSE_TIMEOUT, + true, + ) + .expect("the hosted seam opens") + } + + fn usage_reply(usage: Value) -> String { + json!({ + "id": "resp-usage", + "choices": [{ + "message": {"role": "assistant", "content": "ok", "tool_calls": []}, + "finish_reason": "stop", + }], + "usage": usage, + }) + .to_string() + } + + #[test] + fn hosted_binding_refuses_plain_http() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes()); + for url in ["http://api.example.test", "ftp://api.example.test", "api.example.test", ""] { + let error = open_hosted_port(hosted_binding(url, key_path.clone())) + .err() + .expect("a non-https hosted endpoint opened"); + assert!(matches!(error, ControllerOpenError::HostedEndpointNotHttps), "{url}"); + assert!(!error.to_string().contains("example"), "the refusal echoed the URL"); + } + let through_open_inference = open_inference( + None, + None, + None, + Some(hosted_binding("http://api.example.test", key_path.clone())), + &["hosted/deepseek-chat"], + ) + .err() + .expect("open_inference opened a plain-http hosted endpoint"); + assert!(matches!(through_open_inference, ControllerOpenError::HostedEndpointNotHttps)); + assert!(open_hosted_port(hosted_binding("https://api.example.test", key_path)).is_ok()); + } + + #[test] + fn hosted_binding_refuses_an_empty_key_file() { + let directory = tempfile::tempdir().unwrap(); + let absent = directory.path().join("absent.key"); + let cases: Vec<(&str, PathBuf)> = vec![ + ("empty", write_key_file(&directory, b"")), + ("only a newline", write_key_file(&directory, b"\n")), + ("padded", write_key_file(&directory, b" key-with-padding \n")), + ("not utf-8", write_key_file(&directory, &[0xff, 0xfe, 0xfd])), + ("absent", absent), + ]; + for (label, path) in cases { + let canary = path.display().to_string(); + let error = open_hosted_port(hosted_binding("https://api.example.test", path)) + .err() + .unwrap_or_else(|| panic!("{label}: a bad key file opened")); + assert!(matches!(error, ControllerOpenError::HostedKeyUnreadable), "{label}"); + let rendered = format!("{error} | {error:?}"); + assert!(!rendered.contains(&canary), "{label}: the path leaked"); + assert!(!rendered.contains("provider-"), "{label}: the file name leaked"); + } + // A key file that ends in the usual newline is the key without it. + let ok = write_key_file(&directory, b"fine-key\r\n"); + assert_eq!(read_key_file(&ok).unwrap().as_str(), "fine-key"); + } + + /// A bearer token is one run of visible ASCII. Each shape that cannot ride + /// an Authorization header is refused at open with the typed fault, and the + /// refusal carries neither the key text nor the path. + #[test] + fn hosted_binding_refuses_a_key_shape_that_cannot_ride_a_header() { + let directory = tempfile::tempdir().unwrap(); + let cases: [(&str, &[u8]); 9] = [ + ("interior newline", b"keycanary\nsecondline"), + ("interior carriage return", b"keycanary\rsecondline"), + ("interior tab", b"keycanary\tsecondline"), + ("interior NUL", b"keycanary\0secondline"), + ("leading space", b" keycanary"), + ("trailing space before the newline", b"keycanary \n"), + ("non-ascii", "keycanary-\u{e9}".as_bytes()), + ("leading newline", b"\nkeycanary"), + ("interior DEL", b"keycanary\x7fsecondline"), + ]; + for (label, content) in cases { + let path = write_key_file(&directory, content); + let canary = path.display().to_string(); + let error = open_hosted_port(hosted_binding("https://api.example.test", path)) + .err() + .unwrap_or_else(|| panic!("{label}: a key that cannot ride a header opened")); + assert!(matches!(error, ControllerOpenError::HostedKeyUnreadable), "{label}"); + let rendered = format!("{error} | {error:?}"); + assert!(!rendered.contains("keycanary"), "{label}: the key leaked"); + assert!(!rendered.contains("secondline"), "{label}: the key leaked"); + assert!(!rendered.contains(&canary), "{label}: the path leaked"); + } + } + + #[tokio::test] + async fn hosted_request_carries_the_bearer_and_loopback_carries_none() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, format!("{SYNTHETIC_KEY}\n").as_bytes()); + + let hosted_responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await; + let hosted = hosted_port_over_plain_http(format!("http://{}", hosted_responder.endpoint()), &key_path); + let output = infer_once(&hosted).await.expect("the hosted port infers"); + assert_eq!(output.events, vec![InferenceEvent::Text("ok".into())]); + let header_block = hosted_responder.headers()[0].to_ascii_lowercase(); + assert!( + header_block.contains(&format!("authorization: bearer {}", SYNTHETIC_KEY.to_ascii_lowercase())), + "{header_block}" + ); + + let local_responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await; + infer_once(&port(local_responder.endpoint())).await.expect("the local port infers"); + assert!(!local_responder.headers()[0].to_ascii_lowercase().contains("authorization")); + } + + #[tokio::test] + async fn the_hosted_request_strips_its_prefix_and_names_the_v1_path() { + let directory = tempfile::tempdir().unwrap(); + let key_path = write_key_file(&directory, SYNTHETIC_KEY.as_bytes()); + let responder = ScriptedResponder::start(vec![(200, text_reply("ok"))]).await; + // A trailing slash on the origin must not double the path separator. + let hosted = hosted_port_over_plain_http(format!("http://{}/", responder.endpoint()), &key_path); + let request = tool_request( + CommandId::new(), + 0, + InferencePurpose::Persona, + "hosted/deepseek-chat", + "Respond only in natural prose.", + vec![CodexInputItem::UserText { text: "Say something true.".into() }], + Vec::::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; + let hosted_port = Arc::new(port("127.0.0.1:1".parse().unwrap())) as Arc; + 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; + 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)); + } } diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index 3593348..c9b232b 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -3,9 +3,10 @@ use super::{ ConsumerId, CreateWorld, CreateWorldIntent, CreationReceipt, DecisionInvocation, DecisionOpportunity, Declaration, DraftHandle, EntityDeclaration, EntityKind, JurisdictionKey, KernelError, NewController, OperatorEvent, PatchAnswer, PrincipalCommandIntent, PrincipalId, - Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, + EntityId, Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, WorldKernel, WorldPatch, WorldPhase, WorldScaleIntentRef, WorldSnapshot, journal, patch::kernel_speak_grant, prepare_creation, + verse::{CreateVerseWorldIntent, VerseGenesisError}, }; use crate::VerifiedPrincipalEvidence; use chrono::Utc; @@ -18,6 +19,21 @@ use tokio::task::JoinHandle; /// other. A world that wants more declares them through the patch lane. const GENESIS_PLACE: &str = "commons"; +/// The commons every genesis declares, the one room its first subjects share. +pub(crate) fn commons_declaration() -> Declaration { + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(GENESIS_PLACE), + label: "The Commons".into(), + kind: EntityKind::Place, + container: None, + }) +} + +/// Where a genesis subject stands. +pub(crate) fn commons_ref() -> Ref { + Ref::Draft(DraftHandle::new(GENESIS_PLACE)) +} + const REQUEST_CAPACITY: usize = 32; #[derive(Clone, Debug)] @@ -66,6 +82,16 @@ enum Request { AgencyGraph { reply: oneshot::Sender>, }, + /// 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, 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>, + }, ControllerReceipt { command_id: CommandId, opportunity: super::DecisionOpportunity, @@ -120,7 +146,8 @@ impl WorldMailbox { kind, controller, affordances: kernel_speak_grant(), - position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), + position: Some(commons_ref()), + import_key: None, }) }; // Roots stand beside the commons, not inside it: a root contained by @@ -129,12 +156,7 @@ impl WorldMailbox { // jurisdiction's population target. The owner subject lands in // `Uncovered`, visible and reducing nothing. let mut declarations = vec![ - Declaration::Entity(EntityDeclaration { - handle: DraftHandle::new(GENESIS_PLACE), - label: "The Commons".into(), - kind: EntityKind::Place, - container: None, - }), + commons_declaration(), declare( "first-person", input.human_subject_label, @@ -189,6 +211,7 @@ impl WorldMailbox { }, scale_intent, lens_weights: input.lens_weights, + grammar: None, }, AuthenticatedCaller::verified_principal(principal_id), ) @@ -217,6 +240,20 @@ impl WorldMailbox { .map_err(mailbox_outcome) } + /// Creates the canonical Aetheria verse world through the same + /// authenticated creation `create` uses. + pub async fn create_verse( + &self, + intent: CreateVerseWorldIntent, + principal: &VerifiedPrincipalEvidence, + ) -> Result { + let principal_id = PrincipalId::new(principal.account_subject_hash()); + let input = intent.lower(principal_id.clone())?; + Ok(self + .create_authenticated(input, AuthenticatedCaller::verified_principal(principal_id)) + .await?) + } + /// A wall-clock read at the boundary a request crosses into the mailbox is /// ingress, not reduce: the reducer stays deterministic over its journal, /// and this is the one place a caller's own claim of continued authority @@ -459,6 +496,37 @@ impl WorldMailbox { .map_err(MailboxError::Kernel) } + /// The tick driver's opportunity list, in snapshot order. It is on + /// `WorldMailbox` and on no controller port, as `agency_graph` is. + pub async fn opportunities(&self) -> Result, 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 { + 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, MailboxError> { let (reply, response) = oneshot::channel(); self.sender @@ -928,6 +996,32 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver) return; } } + Request::Opportunities { reply } => { + let result = match &owned { + OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated), + OwnedWorld::Live(kernel) => kernel.opportunities(), + }; + let owner_must_stop = result + .as_ref() + .is_err_and(KernelError::requires_owner_restart); + let _ = reply.send(result); + if owner_must_stop { + return; + } + } + Request::ClockAdvances { reply } => { + let result = match &owned { + OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated), + OwnedWorld::Live(kernel) => kernel.clock_advances(), + }; + let owner_must_stop = result + .as_ref() + .is_err_and(KernelError::requires_owner_restart); + let _ = reply.send(result); + if owner_must_stop { + return; + } + } Request::ControllerReceipt { command_id, opportunity, @@ -988,6 +1082,7 @@ mod tests { fn creation(id: CommandId, title: &str) -> CreateWorld { CreateWorld { lens_weights: crate::tests::stock_weights(), + grammar: None, id, owner: PrincipalId::new("owner"), title: title.into(), @@ -1009,6 +1104,7 @@ mod tests { }, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), + import_key: None, }), ], operations: Vec::new(), @@ -1396,6 +1492,7 @@ mod tests { controller, affordances: kernel_speak_grant(), position: Some(Ref::Draft(DraftHandle::new(format!("room-{handle}")))), + import_key: None, }) }; let room = |handle: &str| { @@ -1425,6 +1522,7 @@ mod tests { evidence: Vec::new(), }, scale_intent: WorldScaleIntentRef::default(), + grammar: None, }, &authenticated, ) diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index abac54f..aa50ae6 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -13,6 +13,7 @@ use super::clock::{FictionalMinutes, TickMinutes}; use super::tool_schema; +use super::grammar::GrammarRefusal; use super::{ AffordanceId, CommandId, ControllerAssignment, ControllerId, EdgeId, EntityId, LensWeights, NewController, SubjectId, SubjectKind, SubjectState, WorldId, @@ -22,6 +23,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, BTreeSet, btree_map::Entry}; +use unicode_properties::{GeneralCategory, UnicodeGeneralCategory}; use uuid::Uuid; #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] @@ -944,6 +946,102 @@ impl EvidenceRef { } } +/// The Aetheria record a subject was imported from: the CultCache schema id and +/// the record key, compared exactly as two separate texts so no pair of +/// distinct records can spell one key. Admission keeps the mapping injective; +/// a key is never matched by prefix, case or normalisation. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(deny_unknown_fields)] +pub struct ImportKey { + schema: String, + record: String, +} + +/// The longest either part of an import key may be, in bytes. +const MAX_IMPORT_KEY_PART_BYTES: usize = 200; + +impl ImportKey { + pub fn new(schema: impl Into, record: impl Into) -> 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, +) -> 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 { + if !key.is_canonical() { + Some(ImportKeyFault::Malformed) + } else if held.contains(key) { + Some(ImportKeyFault::Held) + } else if !declared.insert(key) { + Some(ImportKeyFault::Duplicate) + } else { + None + } +} + /// `position` is the subject's presence: one place it stands in. A subject /// declared without one is unplaced until a later pass gives placement an owner. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -958,6 +1056,11 @@ pub(crate) struct SubjectDeclaration { /// atomically. pub(crate) affordances: BTreeSet>, pub(crate) position: Option>, + /// 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, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -1350,6 +1453,33 @@ pub enum Mismatch { EmptyLabel { handle: DraftHandle, }, + /// The world is bound to a grammar and this affordance's kind is not one of + /// its verbs. + AffordanceKindNotInGrammar { + handle: DraftHandle, + }, + /// The kind is a verb of the bound grammar, but the declared roles are not + /// its signature (name, referent kind or order). + AffordanceRolesDisagreeWithGrammar { + handle: DraftHandle, + }, + /// The grammar binds the kernel's speak verb as anything but a Conversation. + SpeakRenderPathDisagreesWithGrammar { + handle: DraftHandle, + }, + /// An import key is empty, has whitespace, or is longer than 200 bytes in + /// either part. + MalformedImportKey { + handle: DraftHandle, + }, + /// Two subjects in one patch declare the same import key. + DuplicateImportKey { + handle: DraftHandle, + }, + /// A subject already in the world, retired or not, holds this import key. + ImportKeyHeld { + handle: DraftHandle, + }, UnresolvedDraft { site: Site, referent: DraftHandle, @@ -3061,6 +3191,28 @@ fn candidate_set( } } +/// A bound grammar is the whole catalog: a kind it lacks, or a role signature +/// it does not carry, is refused. A world with no grammar is not checked. +fn check_against_grammar( + state: &super::WorldState, + handle: &DraftHandle, + kind: &AffordanceKindName, + roles: &[RoleSpec], + mismatches: &mut Vec, +) { + let Some(grammar) = &state.grammar else { + return; + }; + let handle = handle.clone(); + mismatches.extend(grammar.refusal(kind, roles).map(|refusal| match refusal { + GrammarRefusal::KindNotInGrammar => Mismatch::AffordanceKindNotInGrammar { handle }, + GrammarRefusal::RolesDisagree => Mismatch::AffordanceRolesDisagreeWithGrammar { handle }, + GrammarRefusal::SpeakNotConversation => { + Mismatch::SpeakRenderPathDisagreesWithGrammar { handle } + } + })); +} + /// The one resolution owner for every admission lane: declarations, references, /// topology admission, and operation preconditions. Every check runs against the /// complete candidate graph — what the world already holds plus what this patch @@ -3084,6 +3236,11 @@ pub(super) fn resolve_patch( if admits_human { index.insert(speak_handle.clone(), RefKind::Affordance); } + if admits_human { + // The kernel-built entry is held to the grammar like any declaration. + let speak = kernel_speak_entry(); + check_against_grammar(state, &speak_handle, &speak.kind, &speak.roles, &mut mismatches); + } let mut kind_names: BTreeSet = state .affordance_catalog .values() @@ -3093,6 +3250,8 @@ pub(super) fn resolve_patch( kind_names.insert(kernel_speak_entry().kind); } + let held_keys = held_import_keys(state.subjects.values()); + let mut declared_keys: BTreeSet<&ImportKey> = BTreeSet::new(); for (position, declaration) in patch.declarations.iter().enumerate() { let (handle, label, kind) = match declaration { Declaration::Subject(subject) => ( @@ -3132,6 +3291,21 @@ pub(super) fn resolve_patch( }), } } + if let Declaration::Subject(subject) = declaration + && let Some(key) = &subject.import_key + { + let handle = subject.handle.clone(); + match import_key_fault(key, &held_keys, &mut declared_keys) { + Some(ImportKeyFault::Malformed) => { + mismatches.push(Mismatch::MalformedImportKey { handle }); + } + Some(ImportKeyFault::Held) => mismatches.push(Mismatch::ImportKeyHeld { handle }), + Some(ImportKeyFault::Duplicate) => { + mismatches.push(Mismatch::DuplicateImportKey { handle }); + } + None => {} + } + } if let Declaration::Affordance(affordance) = declaration { validate_affordance( &affordance.handle, @@ -3143,6 +3317,13 @@ pub(super) fn resolve_patch( affordance.carries_speech, &mut mismatches, ); + check_against_grammar( + state, + &affordance.handle, + &affordance.kind, + &affordance.roles, + &mut mismatches, + ); if !kind_names.insert(affordance.kind.clone()) { mismatches.push(Mismatch::DuplicateAffordanceKind { handle: affordance.handle.clone(), @@ -5229,6 +5410,7 @@ pub(super) fn resolve_patch( subject: SubjectState { label: input.label.clone(), kind: input.kind, + import_key: input.import_key.clone(), }, controller, affordances: granted, @@ -7734,7 +7916,8 @@ mod tests { }; /// Owner: `WorldPatch::sets_persona_of_existing_subject`. Consumer: - /// `ghostlight-dungeon`'s play agent (PA.f61), which refuses a + /// `ghostlight-dungeon`'s play agent (PA.f61, parked at + /// `parked/ghostlight-eve-surface`), which refuses a /// `set_persona_material` run whose patch answers true here, since /// `WorldPatch`'s own fields are `pub(crate)` and Dungeon may not parse /// its JSON to decide the question itself. @@ -7853,6 +8036,7 @@ mod tests { controller: NewController::OperationalAgent, affordances: BTreeSet::from([speak_entry(kernel)]), position, + import_key: None, }) } @@ -8120,6 +8304,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([Ref::Draft(DraftHandle::new(handle))]), position: None, + import_key: None, }) }; @@ -8420,6 +8605,7 @@ mod tests { }, affordances: BTreeSet::from([speak_entry(&kernel)]), position: None, + import_key: None, }), ]); let error = kernel @@ -8472,6 +8658,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak_entry(&kernel)]), position: None, + import_key: None, }), ]); let receipt = submit_owner(&mut kernel, &before, admit(repaired)); @@ -9027,6 +9214,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak_entry(&kernel)]), position: None, + import_key: None, })]); submit_owner(&mut kernel, &before, admit(patch.clone())); let admitted = *kernel @@ -9303,6 +9491,7 @@ mod tests { controller: NewController::NarrativePersona, affordances: BTreeSet::from([speak]), position: None, + import_key: None, })])), ); let active = activate(&mut kernel); diff --git a/crates/ghostlight/src/sdk_inference.rs b/crates/ghostlight/src/sdk_inference.rs index 68a88a1..d112be3 100644 --- a/crates/ghostlight/src/sdk_inference.rs +++ b/crates/ghostlight/src/sdk_inference.rs @@ -7,7 +7,7 @@ //! its own, computes no tool result, and holds no credential. use super::controllers::{ - InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest, + InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferenceRequest, TokenUsage, PreparedInference, REQUEST_EXPIRY, RESPONSE_TIMEOUT, ToolResultOracle, call_id_is_valid, tool_name_is_valid, unix_ms, }; @@ -121,6 +121,22 @@ pub(super) struct SdkModelUsage { pub(super) cache_creation_input_tokens: u64, } +/// The reply's token usage summed over the models the run used. The whole +/// prompt is the fresh input plus both cache figures (Anthropic reports them +/// apart); the cached part is what was read from cache. A run that reported +/// no usage at all is `None`, never a zero. +fn sdk_usage(usage: &[SdkModelUsage]) -> Option { + if usage.is_empty() { + return None; + } + let sum = |field: fn(&SdkModelUsage) -> u64| usage.iter().map(field).sum::(); + Some(TokenUsage { + prompt: sum(|u| u.input_tokens) + sum(|u| u.cache_read_input_tokens) + sum(|u| u.cache_creation_input_tokens), + completion: sum(|u| u.output_tokens), + cached_prompt: Some(sum(|u| u.cache_read_input_tokens)), + }) +} + /// The failures the sidecar is allowed to name. It reports a reason; this /// module assigns the disposition, so TypeScript can never quarantine the /// world's cognition by naming an integrity violation directly. @@ -654,9 +670,10 @@ fn assemble_output( }; let receipt_bytes = rmp_serde::to_vec_named(&receipt) .map_err(|_| InferenceFault::new("the SDK receipt could not be encoded"))?; - Ok(InferenceOutput { - events: lowered, - receipt_digest: format!("sha256:{:x}", Sha256::digest(&receipt_bytes)), + let output = InferenceOutput::new(lowered, format!("sha256:{:x}", Sha256::digest(&receipt_bytes))); + Ok(match sdk_usage(&receipt.usage) { + Some(usage) => output.with_usage(usage), + None => output, }) } @@ -713,10 +730,10 @@ pub(super) struct RoutedInferencePort { connector: Option>, sdk: Option>, sdk_model_prefix: String, - /// The local port's own claimed prefix, carried beside it rather than in a - /// third bare `String` field, so `route` can compare its length against - /// the SDK's claim without a third parallel field to keep in sync. - local: Option<(String, Arc)>, + /// 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)>, } impl RoutedInferencePort { @@ -724,13 +741,13 @@ impl RoutedInferencePort { connector: Option>, sdk: Option>, sdk_model_prefix: impl Into, - local: Option<(String, Arc)>, + claims: Vec<(String, Arc)>, ) -> Self { Self { connector, sdk, sdk_model_prefix: sdk_model_prefix.into(), - local, + claims, } } @@ -747,9 +764,10 @@ impl RoutedInferencePort { // the connector, which is what keeps a typo'd model prefix loud // instead of silently reaching the wrong backend. let local_claim = self - .local - .as_ref() + .claims + .iter() .filter(|(prefix, _)| model.starts_with(prefix.as_str())) + .max_by_key(|(prefix, _)| prefix.len()) .map(|(prefix, port)| (prefix.len(), port)); let sdk_claim = model .starts_with(&self.sdk_model_prefix) @@ -1202,6 +1220,41 @@ mod tests { assert_eq!(output.events.len(), 2); } + /// The SDK lane reports the run's tokens summed over its models: the whole + /// prompt is fresh input plus both cache figures, the cached part is the + /// cache read, and a run that reported nothing is `None`, not a zero. + #[tokio::test] + async fn the_sdk_receipts_token_counts_reach_inference_output() { + let port = SdkInferencePort::new(ScriptedLink::new(Vec::new()), TEST_RUNTIME); + let prepared = prose_prepared(&port, "Say something true."); + let events = vec![SidecarEvent::Text { text: "ok".into() }]; + let mut two_models = material(); + two_models.usage = vec![ + SdkModelUsage { + model: TEST_MODEL.into(), + input_tokens: 11, + output_tokens: 5, + cache_read_input_tokens: 3, + cache_creation_input_tokens: 2, + }, + SdkModelUsage { + model: "claude-haiku-5".into(), + input_tokens: 100, + output_tokens: 40, + cache_read_input_tokens: 7, + cache_creation_input_tokens: 0, + }, + ]; + let output = assemble_output(&prepared, events.clone(), two_models, &[]).unwrap(); + assert_eq!( + output.usage(), + Some(TokenUsage { prompt: 123, completion: 45, cached_prompt: Some(10) }) + ); + let mut none = material(); + none.usage = Vec::new(); + assert_eq!(assemble_output(&prepared, events, none, &[]).unwrap().usage(), None); + } + /// Spec test 11. The digest is a pure function of the receipt, so it is /// compared directly rather than through two whole queries. #[tokio::test] @@ -1274,6 +1327,7 @@ mod tests { let granted = vec![AffordanceSnapshot { id: AffordanceId::issue(), entry: kernel_speak_entry(), + render_path: None, }]; let speak = granted[0].entry.kind.0.clone(); let link = ScriptedLink::new(vec![ @@ -1335,7 +1389,7 @@ mod tests { Some(Arc::clone(&connector)), Some(Arc::clone(&sdk)), DEFAULT_SDK_MODEL_PREFIX, - None, + Vec::new(), ); assert!(Arc::ptr_eq(routed.route("claude-opus-5").unwrap(), &sdk)); assert!(Arc::ptr_eq( @@ -1346,7 +1400,7 @@ mod tests { Some(Arc::clone(&connector)), Some(Arc::clone(&sdk)), "gpt-", - None, + Vec::new(), ); assert!(Arc::ptr_eq(moved.route("gpt-5.6-terra").unwrap(), &sdk)); assert!(Arc::ptr_eq( @@ -1385,11 +1439,11 @@ mod tests { }; assert!(matches!( - open_inference(None, Some(sdk()), None, &models("gpt-5.6-terra").each()), + open_inference(None, Some(sdk()), None, None, &models("gpt-5.6-terra").each()), Err(ControllerOpenError::UnroutableModel { .. }) )); assert!(matches!( - open_inference(Some(connector()), None, None, &models("claude-opus-5").each()), + open_inference(Some(connector()), None, None, None, &models("claude-opus-5").each()), Err(ControllerOpenError::UnroutableModel { .. }) )); assert!(matches!( @@ -1401,12 +1455,13 @@ mod tests { model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(), }), None, + None, &models(TEST_MODEL).each(), ), Err(ControllerOpenError::SdkSidecarMissing { .. }) )); assert!( - open_inference(Some(connector()), Some(sdk()), None, &models(TEST_MODEL).each()).is_ok(), + open_inference(Some(connector()), Some(sdk()), None, None, &models(TEST_MODEL).each()).is_ok(), "a configuration where every model routes did not open" ); } @@ -1759,6 +1814,7 @@ mod tests { model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(), }), None, + None, &models(TEST_MODEL).each(), ) .expect("the SDK model routes"); @@ -1881,7 +1937,7 @@ mod tests { )) as Arc), None, DEFAULT_SDK_MODEL_PREFIX, - None, + Vec::new(), ); assert!(connector_only.route(TEST_MODEL).is_none()); let prepared = @@ -1913,6 +1969,7 @@ mod tests { model_prefix: DEFAULT_SDK_MODEL_PREFIX.into(), }), None, + None, &models(TEST_MODEL).each(), ) .is_ok() @@ -1990,6 +2047,7 @@ mod tests { let granted = vec![AffordanceSnapshot { id: AffordanceId::issue(), entry: kernel_speak_entry(), + render_path: None, }]; let mut emitted: Vec<(String, String)> = patch_tools() .into_iter() diff --git a/crates/ghostlight/src/table.rs b/crates/ghostlight/src/table.rs index 054285c..b4049f0 100644 --- a/crates/ghostlight/src/table.rs +++ b/crates/ghostlight/src/table.rs @@ -14,7 +14,7 @@ //! Projector may call it. use super::controllers::{ - self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest, + self, ControllerError, InferenceEvent, InferenceOutput, InferencePurpose, InferenceRequest, TokenUsage, RequestShape, tool_request, }; use super::patch::{ @@ -1070,6 +1070,12 @@ impl InferenceOutput { pub fn receipt_digest(&self) -> &str { &self.receipt_digest } + + /// The tokens the provider reported for this reply; `None` when it + /// reported none. + pub fn usage(&self) -> Option { + self.usage + } } /// The canonical id as the patch vocabulary spells it: the bare UUID text, @@ -1600,7 +1606,8 @@ mod tests { // PA.f75: `communicate` was removed from `PLAY_TOOLS`; Dungeon drops it // from its own list too. This is now the complete 24-tool list. // PA.f88: this is the library's own test fixture, kept in sync by hand. - // Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` is the + // Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` (parked at + // `parked/ghostlight-eve-surface`) is the // single policy owner of what the play agent may actually call; this // copy pins nothing there and is not read by production code. const PLAY_TOOLS: &[&str] = &[ @@ -2489,6 +2496,7 @@ mod tests { outcome_bands: Vec::new(), carries_speech: false, }, + render_path: None, } } diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs new file mode 100644 index 0000000..079fa44 --- /dev/null +++ b/crates/ghostlight/src/verse/calls.rs @@ -0,0 +1,759 @@ +//! Provider call records, the run's spend, and replay. +//! +//! `RecordingInferencePort` is the one owner of a run's spend. Before it +//! forwards a call it decides, from the request alone, whether the call could +//! pass the run's token cap; after the reply it charges what the call really +//! cost. Every reply is stored as a `ProviderCallRecord` before the output +//! returns to the lane, so `ReplayInferencePort` can serve the same run again +//! from the store with no provider behind it. +//! +//! Lanes and runners never count tokens. A request is held against the cap by +//! its bound: the serialized provider request's bytes (a token is at least one +//! byte, so bytes bound the prompt) plus the request's own output allowance. +//! +//! The run's spend is one persisted ledger row per run, owned by the store and +//! written where it is spent. A call reserves its bound in that row before the +//! provider is called; a reply settles the reservation to what it reported +//! (prompt plus completion, cached prompt tokens being part of the prompt; none +//! reported is charged the bound); a fault leaves the reservation charged. A +//! restart reads the row, so no restart returns budget. A reply that reports +//! more than the call's bound is charged in full and faults the call, so the +//! run's spend never passes the cap unnoticed. One port at a time owns a run. + +use crate::controllers::{ + InferenceEvent, InferenceFault, InferenceOutput, InferencePort, InferencePurpose, + InferenceRequest, PreparedInference, REQUEST_EXPIRY, TokenUsage, ToolResultOracle, unix_ms, +}; +use async_trait::async_trait; +use chrono::Utc; +use codex_connector::CodexProviderRequest; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + path::Path, + sync::{Arc, Mutex}, +}; +use thiserror::Error; + +const CALL_RECORD_ROW: &str = "verse_provider_call.v1"; +const RUN_LEDGER_ROW: &str = "verse_run_ledger.v1"; +/// The run ledger document's schema. +const RUN_LEDGER_SCHEMA: &str = "ghostlight.verse_run_ledger.v1"; +/// The call record document's schema. A store holding any other row type or +/// schema is refused at open: this store is not migrated from, or shared with, +/// another document. +pub const CALL_RECORD_SCHEMA: &str = "ghostlight.verse_provider_call.v1"; + +#[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] +pub enum CallRecordError { + #[error("a run id is 1 to 64 characters of letters, digits, '-' and '_'")] + InvalidRunId, + #[error("the call record store could not be read or written")] + Store, + #[error("the call record store holds a row that is not a canonical call record")] + Corrupt, + #[error("a call record's sequence is not the next in its run")] + OutOfSequence, + #[error("replay needs one run's records in dense sequence from 1")] + NotReplayable, + #[error("another recording port already owns this run")] + RunInUse, + #[error("the call could pass the run's token cap")] + OverCap, + #[error("the run was recorded with a different {0}")] + RunMismatch(RunField), +} + +/// What a run is bound to when it begins, named by the field a mismatch refuses. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RunField { + /// The token cap the run was opened with. + Cap, + /// The identity of the world the run started from. + WorldId, + /// The state digest of the world the run started from. + StateDigest, +} + +impl std::fmt::Display for RunField { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Cap => "token cap", + Self::WorldId => "world id", + Self::StateDigest => "world state digest", + }) + } +} + +/// The world a run began from: its identity and state digest. Replay is of a +/// restored copy of this world, so it takes the same two values. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RunStart { + world_id: String, + state_digest: String, +} + +impl RunStart { + pub fn of(world: &crate::WorldSnapshot) -> Self { + Self { + world_id: world.world_id.text(), + state_digest: world.state_digest.clone(), + } + } +} + +/// A run's identity as a key segment. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct RunId(String); + +impl RunId { + pub fn new(id: &str) -> Result { + 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, + receipt_digest: String, + usage: Option, + 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 { + 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 { + // 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 Deserialize<'de>>( + row: &CultCacheEnvelope, +) -> Result { + 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 { + 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, +} + +struct StoreState { + store: OwnedRedbMessagePackBackingStore, + runs: BTreeMap, + /// Runs with a recording port alive; one ledger has one owner. + owned: BTreeSet, +} + +/// 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, +} + +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) -> Result { + 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 = BTreeMap::new(); + let mut counts: BTreeMap = BTreeMap::new(); + let mut charged: BTreeMap = 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, 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 { + 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 { + 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, 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, 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, + store: Arc, + 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, + store: Arc, + run: RunId, + cap_tokens: u64, + start: &RunStart, + ) -> Result { + 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 { + 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, + ) -> Result { + // 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 { + self.inner.prepare(request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + 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) { + 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>, +} + +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, + store: &CallRecordStore, + run: &RunId, + world: &RunStart, + ) -> Result { + 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::prepare( + &self.caller_runtime_id, + unix_ms()?.saturating_add(REQUEST_EXPIRY.as_millis() as u64), + request, + ) + } + + async fn infer(&self, request: PreparedInference) -> Result { + 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; diff --git a/crates/ghostlight/src/verse/calls/tests.rs b/crates/ghostlight/src/verse/calls/tests.rs new file mode 100644 index 0000000..02131ae --- /dev/null +++ b/crates/ghostlight/src/verse/calls/tests.rs @@ -0,0 +1,1230 @@ +use super::*; +use crate::CommandId; +use crate::controllers::{ + InferenceFaultClass, InferenceFaultDisposition, RequestShape, tool_request, +}; +use codex_connector::{CodexInputItem, CodexToolDefinition}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const MODEL: &str = "hosted/test-model"; + +type Script = Vec>; + +/// A provider stand-in: answers from a script, counts the calls that +/// reached it, and can hold a call open until released. +struct ScriptedPort { + replies: Mutex>>, + calls: AtomicUsize, + lent: AtomicUsize, + gate: Option>, +} + +impl ScriptedPort { + fn new(replies: Script) -> Arc { + Arc::new(Self { + replies: Mutex::new(replies.into()), + calls: AtomicUsize::new(0), + lent: AtomicUsize::new(0), + gate: None, + }) + } + + fn gated(replies: Script, gate: Arc) -> Arc { + Arc::new(Self { + replies: Mutex::new(replies.into()), + calls: AtomicUsize::new(0), + lent: AtomicUsize::new(0), + gate: Some(gate), + }) + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl InferencePort for ScriptedPort { + fn prepare(&self, request: InferenceRequest) -> Result { + PreparedInference::prepare("verse-test", 4_102_444_800_000, request) + } + + fn lend_tool_results(&self, _: &PreparedInference, _: Box) { + self.lent.fetch_add(1, Ordering::SeqCst); + } + + async fn infer(&self, _: PreparedInference) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + if let Some(gate) = &self.gate { + gate.acquire().await.expect("the gate stays open").forget(); + } + self.replies + .lock() + .unwrap() + .pop_front() + .expect("the script has a reply for every call that reaches it") + } +} + +fn request_for(command: CommandId, prompt: &str, max_output_tokens: u32) -> InferenceRequest { + tool_request( + command, + 0, + InferencePurpose::Persona, + MODEL, + "Respond only in prose.", + vec![CodexInputItem::UserText { + text: prompt.into(), + }], + Vec::::new(), + RequestShape { + max_output_tokens, + parallel_tool_calls: false, + }, + ) + .expect("the request builds") +} + +fn prepared_for(prompt: &str, max_output_tokens: u32) -> PreparedInference { + PreparedInference::prepare( + "verse-test", + 4_102_444_800_000, + request_for(CommandId::new(), prompt, max_output_tokens), + ) + .expect("the request prepares") +} + +/// What the spec defines the bound as, computed from the prepared request. +fn bound_of(prepared: &PreparedInference) -> u64 { + let payload = serde_json::to_vec(&prepared.invocation.request) + .expect("a provider request serializes") + .len() as u64; + payload + + u64::from( + prepared + .invocation + .request + .max_output_tokens + .expect("an allowance"), + ) +} + +fn reply(text: &str, receipt: &str) -> InferenceOutput { + InferenceOutput::new(vec![InferenceEvent::Text(text.into())], receipt) +} + +fn usage(prompt: u64, completion: u64, cached_prompt: Option) -> TokenUsage { + TokenUsage { + prompt, + completion, + cached_prompt, + } +} + +fn store_in(directory: &tempfile::TempDir) -> (Arc, std::path::PathBuf) { + let path = directory.path().join("calls.redb"); + ( + Arc::new(CallRecordStore::open(&path).expect("the store opens")), + path, + ) +} + +/// A world identity and digest for tests that need a run to start somewhere. +fn start() -> RunStart { + RunStart { + world_id: "world-a".into(), + state_digest: "sha256:a".into(), + } +} + +fn recording( + inner: &Arc, + store: &Arc, + run: &str, + cap: u64, +) -> RecordingInferencePort { + RecordingInferencePort::open( + inner.clone(), + store.clone(), + RunId::new(run).unwrap(), + cap, + &start(), + ) + .expect("the port opens") +} + +fn json(output: &InferenceOutput) -> Vec { + serde_json::to_vec(output).expect("an output serializes") +} + +fn ok_all(outputs: &[InferenceOutput]) -> Script { + outputs.iter().cloned().map(Ok).collect() +} + +#[tokio::test] +async fn recording_port_stores_each_call_before_returning() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let run = RunId::new("run-a").unwrap(); + let outputs = vec![ + reply(" first\nreply \u{e9}\u{1f9ff} ", "receipt-1").with_usage(usage(100, 20, Some(40))), + InferenceOutput::new( + vec![ + InferenceEvent::Text("a".into()), + InferenceEvent::ToolCall { + call_id: "c1".into(), + name: "speak".into(), + arguments: "{\"b\":1, \"a\":[1,2]}".into(), + }, + ], + "receipt-2", + ), + InferenceOutput::new(Vec::new(), "receipt-3").with_usage(usage(7, 0, None)), + ]; + let inner = ScriptedPort::new(ok_all(&outputs)); + let port = recording(&inner, &store, "run-a", u64::MAX); + for (index, expected) in outputs.iter().enumerate() { + let prepared = port + .prepare(request_for(CommandId::new(), "tell", 1_200)) + .unwrap(); + let digest = content_digest(&prepared.invocation.request).unwrap(); + let returned = port.infer(prepared).await.unwrap(); + assert_eq!(json(&returned), json(expected)); + let stored = store.records(&run).unwrap(); + assert_eq!( + stored.len(), + index + 1, + "the call was stored before it returned" + ); + let record = &stored[index]; + assert_eq!(record.sequence(), index as u64 + 1); + assert_eq!(record.run_id(), "run-a"); + assert_eq!(record.content_sha256(), digest); + assert_eq!(record.model(), MODEL); + assert_eq!(record.purpose(), InferencePurpose::Persona); + assert_eq!(record.usage(), expected.usage); + assert_eq!(json(&record.output()), json(expected), "byte for byte"); + } + // Prompt plus completion; the cached figure is part of the prompt. + let charges: Vec = store + .records(&run) + .unwrap() + .iter() + .map(|record| record.charged()) + .collect(); + assert_eq!(charges[0], 120); + assert_eq!(charges[2], 7); + assert_eq!(port.spent(), charges.iter().sum::()); + + // Another run keys its own sequence from 1. + let other = recording(&inner, &store, "run-b", u64::MAX); + inner + .replies + .lock() + .unwrap() + .push_back(Ok(reply("b", "receipt-b"))); + other + .infer( + other + .prepare(request_for(CommandId::new(), "tell", 1_200)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + store.records(&RunId::new("run-b").unwrap()).unwrap()[0].sequence(), + 1 + ); + + // The records are in the file, not in the port. + let before = store.records(&run).unwrap(); + drop((port, other, store)); + let reopened = CallRecordStore::open(&path).unwrap(); + assert_eq!(reopened.records(&run).unwrap(), before); +} + +#[tokio::test] +async fn cap_refuses_before_the_provider_is_called() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let probe = prepared_for("probe", 1_200); + let bound = bound_of(&probe); + assert!(bound > 1_200, "the bound includes the request's bytes"); + + let inner = ScriptedPort::new(vec![Ok(reply("never", "r"))]); + let port = recording(&inner, &store, "run-cap", bound - 1); + let fault = port.infer(probe.clone()).await.expect_err("over the cap"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!( + fault.disposition(), + InferenceFaultDisposition::IntegrityViolation + ); + assert_eq!(inner.calls(), 0, "the provider was never invoked"); + assert!( + store + .records(&RunId::new("run-cap").unwrap()) + .unwrap() + .is_empty() + ); + assert_eq!(port.spent(), 0); + drop(port); + + // Exactly the cap is allowed. + let exact = recording(&inner, &store, "run-exact-cap", bound); + exact + .infer(probe) + .await + .expect("a call that fits the cap is made"); + assert_eq!(inner.calls(), 1); + + // A request with no output allowance has no bound and is refused. + let mut open_ended = prepared_for("open ended", 1_200); + open_ended.invocation.request.max_output_tokens = None; + let unbounded = recording(&inner, &store, "run-open", u64::MAX); + let fault = unbounded + .infer(open_ended) + .await + .expect_err("no allowance, no bound"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 1); +} + +#[tokio::test] +async fn spend_never_exceeds_cap() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-spend").unwrap(); + let prompt = "a moderately long prompt for the sample call"; + let bound = bound_of(&prepared_for(prompt, 600)); + let cap = bound * 5 / 2; + let script: Script = (0..40) + .map(|n| Ok(reply("ok", &format!("r{n}")).with_usage(usage(bound / 4, bound / 8, None)))) + .collect(); + let inner = ScriptedPort::new(script); + let port = recording(&inner, &store, "run-spend", cap); + let mut made = 0; + loop { + match port.infer(prepared_for(prompt, 600)).await { + Ok(_) => made += 1, + Err(fault) => { + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + break; + } + } + assert!(made < 40, "the cap never refused"); + } + assert!(made >= 2, "the cap let some calls through: {made}"); + assert_eq!( + inner.calls(), + made, + "a refused call never reached the provider" + ); + let total: u64 = store + .records(&run) + .unwrap() + .iter() + .map(|record| record.charged()) + .sum(); + assert!(total <= cap, "{total} > {cap}"); + assert_eq!(total, port.spent()); + // The refusal is the cap's doing: another call would not have fit. + assert!(total + bound > cap); +} + +#[tokio::test] +async fn calls_on_the_wire_hold_their_bound() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let bound = bound_of(&prepared_for("held", 1_200)); + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + let inner = ScriptedPort::gated(vec![Ok(reply("one", "r1"))], gate.clone()); + // Room for one call's bound and not two. + let port = Arc::new(recording(&inner, &store, "run-flight", bound + bound / 2)); + let first = { + let port = port.clone(); + tokio::spawn(async move { port.infer(prepared_for("held", 1_200)).await }) + }; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while inner.calls() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("the first call reached the provider"); + // A second call that was let through would wait on the closed gate. + let second = tokio::time::timeout( + std::time::Duration::from_secs(5), + port.infer(prepared_for("held", 1_200)), + ) + .await + .expect("the second call was refused, not left waiting on the provider"); + let fault = second.expect_err("the first call's bound is still held"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 1); + gate.add_permits(1); + first.await.unwrap().expect("the first call completes"); +} + +struct NoTools; + +impl ToolResultOracle for NoTools { + fn remaining_rounds(&self) -> u32 { + 0 + } + + fn answer(&mut self, _: &str, _: &str) -> Result { + Ok(String::new()) + } +} + +#[test] +fn tool_results_are_lent_to_the_port_underneath() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let inner = ScriptedPort::new(Vec::new()); + let port = recording(&inner, &store, "run-lend", u64::MAX); + port.lend_tool_results(&prepared_for("lend", 1_200), Box::new(NoTools)); + assert_eq!(inner.lent.load(Ordering::SeqCst), 1); +} + +#[tokio::test] +async fn a_reply_with_no_usage_is_charged_its_bound() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("no usage reported", 1_200); + let bound = bound_of(&prepared); + let inner = ScriptedPort::new(vec![Ok(reply("silent", "r"))]); + let port = recording(&inner, &store, "run-silent", u64::MAX); + port.infer(prepared).await.unwrap(); + let record = store + .records(&RunId::new("run-silent").unwrap()) + .unwrap() + .remove(0); + assert_eq!(record.usage(), None); + assert_eq!(record.charged(), bound); + assert_eq!(port.spent(), bound); +} + +#[tokio::test] +async fn a_fault_keeps_its_reservation_charged() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("fault", 1_200); + let bound = bound_of(&prepared); + let inner = ScriptedPort::new(vec![ + Err(InferenceFault::retryable("down").classed(InferenceFaultClass::Connect)), + Err(InferenceFault::retryable("slow").classed(InferenceFaultClass::Timeout)), + Err(InferenceFault::new("junk").classed(InferenceFaultClass::BadReply)), + ]); + let port = recording(&inner, &store, "run-fault", u64::MAX); + for (index, class) in [ + InferenceFaultClass::Connect, + InferenceFaultClass::Timeout, + InferenceFaultClass::BadReply, + ] + .into_iter() + .enumerate() + { + let fault = port.infer(prepared.clone()).await.expect_err("a fault"); + assert_eq!(fault.class(), class, "the fault passes through"); + assert_eq!(port.spent(), bound * (index as u64 + 1)); + } + assert!( + store + .records(&RunId::new("run-fault").unwrap()) + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn faulting_calls_across_restarts_never_pass_the_cap() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let prepared = prepared_for("loop", 1_200); + let bound = bound_of(&prepared); + let cap = bound * 3 + bound / 2; + drop(store); + let mut reached_the_provider = 0; + for attempt in 0..12 { + let store = Arc::new(CallRecordStore::open(&path).unwrap()); + let class = if attempt % 2 == 0 { + InferenceFaultClass::BadReply + } else { + InferenceFaultClass::Timeout + }; + let inner = ScriptedPort::new(vec![Err(InferenceFault::new("down").classed(class))]); + let port = recording(&inner, &store, "run-loop", cap); + let fault = port.infer(prepared.clone()).await.expect_err("a fault"); + reached_the_provider += inner.calls(); + if inner.calls() == 0 { + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + } + assert!(port.spent() <= cap, "{} > {cap}", port.spent()); + } + assert_eq!(reached_the_provider, 3, "a restart returns no budget"); +} + +#[tokio::test] +async fn a_call_abandoned_on_the_wire_stays_charged_after_a_restart() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let prepared = prepared_for("crash", 1_200); + let bound = bound_of(&prepared); + let gate = Arc::new(tokio::sync::Semaphore::new(0)); + let inner = ScriptedPort::gated(vec![Ok(reply("never", "r"))], gate); + let port = Arc::new(recording(&inner, &store, "run-crash", bound)); + let call = { + let port = port.clone(); + let prepared = prepared.clone(); + tokio::spawn(async move { port.infer(prepared).await }) + }; + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while inner.calls() == 0 { + tokio::task::yield_now().await; + } + }) + .await + .expect("the call reached the provider"); + call.abort(); + let _ = call.await; + drop((port, store)); + + let store = Arc::new(CallRecordStore::open(&path).unwrap()); + let inner = ScriptedPort::new(vec![Ok(reply("again", "r2"))]); + let reopened = recording(&inner, &store, "run-crash", bound); + assert_eq!(reopened.spent(), bound); + let fault = reopened + .infer(prepared) + .await + .expect_err("the cap is spent"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 0); +} + +#[tokio::test] +async fn one_run_has_one_ledger_and_one_owner() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("owner", 1_200); + let bound = bound_of(&prepared); + let cap = bound + bound / 2; + let inner = ScriptedPort::new(vec![Ok(reply("a", "r1")), Ok(reply("b", "r2"))]); + let first = recording(&inner, &store, "run-own", cap); + let second = RecordingInferencePort::open( + inner.clone(), + store.clone(), + RunId::new("run-own").unwrap(), + cap, + &start(), + ); + assert_eq!(second.err(), Some(CallRecordError::RunInUse)); + // Another run is another ledger. + drop(recording(&inner, &store, "run-other", cap)); + first.infer(prepared.clone()).await.unwrap(); + assert_eq!(first.spent(), bound); + // The owner going away frees the run, and its spend carries over. + drop(first); + let next = recording(&inner, &store, "run-own", cap); + assert_eq!(next.spent(), bound); + next.infer(prepared) + .await + .expect_err("the cap holds across owners"); + assert_eq!(inner.calls(), 1); +} + +#[tokio::test] +async fn usage_over_the_bound_is_charged_in_full_and_faults_the_call() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("liar", 1_200); + let bound = bound_of(&prepared); + let cap = bound + 200; + let run = RunId::new("run-over").unwrap(); + let inner = ScriptedPort::new(vec![ + Ok(reply("big", "r1").with_usage(usage(bound, bound, None))), + Ok(reply("never", "r2")), + ]); + let port = recording(&inner, &store, "run-over", cap); + let fault = port.infer(prepared.clone()).await.expect_err("over bound"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!( + fault.disposition(), + InferenceFaultDisposition::IntegrityViolation + ); + assert_eq!(port.spent(), bound * 2, "charged in full"); + assert!(store.records(&run).unwrap().is_empty()); + // The run is stopped: the next call is refused before the provider. + let fault = port.infer(prepared).await.expect_err("the cap is passed"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 1); +} + +#[tokio::test] +async fn usage_exactly_at_the_bound_is_a_normal_reply() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("exact", 1_200); + let bound = bound_of(&prepared); + let inner = ScriptedPort::new(vec![Ok(reply("fits", "r1").with_usage(usage( + bound - 10, + 10, + None, + )))]); + let port = recording(&inner, &store, "run-exact", bound); + port.infer(prepared).await.expect("a reply at its bound"); + assert_eq!(port.spent(), bound); + assert_eq!( + store + .records(&RunId::new("run-exact").unwrap()) + .unwrap() + .len(), + 1 + ); +} + +#[tokio::test] +async fn a_reopened_run_resumes_its_spend() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let probe = prepared_for("resume", 1_200); + let bound = bound_of(&probe); + let inner = ScriptedPort::new(vec![Ok( + reply("one", "r1").with_usage(usage(bound, 0, None)) + )]); + let first = recording(&inner, &store, "run-resume", bound * 2 - 1); + first.infer(probe.clone()).await.unwrap(); + drop((first, store)); + + let store = Arc::new(CallRecordStore::open(&path).unwrap()); + let inner = ScriptedPort::new(vec![Ok(reply("two", "r2"))]); + let resumed = recording(&inner, &store, "run-resume", bound * 2 - 1); + assert_eq!(resumed.spent(), bound); + let fault = resumed + .infer(probe) + .await + .expect_err("the earlier spend counts"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(inner.calls(), 0); +} + +#[tokio::test] +async fn replay_serves_stored_responses_and_refuses_strangers() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-replay").unwrap(); + let commands = [CommandId::new(), CommandId::new(), CommandId::new()]; + let outputs = [ + reply("one \u{e9}", "r1").with_usage(usage(5, 6, Some(1))), + InferenceOutput::new( + vec![InferenceEvent::ToolCall { + call_id: "c".into(), + name: "speak".into(), + arguments: "{ \"x\" : 1 }".into(), + }], + "r2", + ), + reply("three", "r3"), + ]; + let inner = ScriptedPort::new(ok_all(&outputs)); + let live = recording(&inner, &store, "run-replay", u64::MAX); + for (command, prompt) in commands.iter().zip(["a", "b", "c"]) { + let prepared = live.prepare(request_for(*command, prompt, 1_200)).unwrap(); + live.infer(prepared).await.unwrap(); + } + let calls_made = inner.calls(); + + let replay = ReplayInferencePort::new("verse-replay", &store, &run, &start()).unwrap(); + // A request the run never made is a fault and consumes nothing. + let stranger = replay + .prepare(request_for(CommandId::new(), "z", 1_200)) + .unwrap(); + let fault = replay.infer(stranger).await.expect_err("a stranger"); + assert_eq!( + fault.disposition(), + InferenceFaultDisposition::IntegrityViolation + ); + // Out of order is a stranger too. + let second_first = replay + .prepare(request_for(commands[1], "b", 1_200)) + .unwrap(); + replay.infer(second_first).await.expect_err("out of order"); + // In order, each request gets exactly what the run received. + for ((command, prompt), expected) in commands.iter().zip(["a", "b", "c"]).zip(&outputs) { + let prepared = replay + .prepare(request_for(*command, prompt, 1_200)) + .unwrap(); + let served = replay.infer(prepared).await.unwrap(); + assert_eq!(json(&served), json(expected)); + } + let again = replay + .prepare(request_for(commands[0], "a", 1_200)) + .unwrap(); + replay.infer(again).await.expect_err("the record is spent"); + assert_eq!(inner.calls(), calls_made, "replay made no provider call"); +} + +fn sample_record(run: &str, sequence: u64) -> ProviderCallRecord { + ProviderCallRecord { + run_id: run.into(), + sequence, + purpose: InferencePurpose::Persona, + model: MODEL.into(), + content_sha256: "ab".into(), + events: vec![InferenceEvent::Text("t".into())], + receipt_digest: "r".into(), + usage: None, + charged: 3, + } +} + +#[test] +fn replay_needs_a_run_the_store_began() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("never-begun").unwrap(); + assert_eq!( + ReplayInferencePort::new("x", &store, &run, &start()).err(), + Some(CallRecordError::NotReplayable) + ); +} + +#[tokio::test] +async fn a_record_holds_no_request_text() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let canary = "REQUEST-CANARY-91c4"; + let inner = ScriptedPort::new(vec![Ok(reply("fine", "r"))]); + let port = recording(&inner, &store, "run-secret", u64::MAX); + port.infer(prepared_for(canary, 1_200)).await.unwrap(); + drop((port, store)); + let raw = OwnedRedbMessagePackBackingStore::new(&path) + .unwrap() + .pull_all() + .unwrap(); + assert_eq!(raw.len(), 2, "one record and the run's ledger"); + for row in raw { + let bytes = [row.key.as_bytes(), row.payload.as_slice()].concat(); + assert!( + !bytes + .windows(canary.len()) + .any(|window| window == canary.as_bytes()), + "the request text reached the store" + ); + } + let refusal = InferenceFault::budget_exhausted(); + assert!(!format!("{refusal} {refusal:?}").contains(canary)); +} + +fn spend_row(run: &str, spent: u64) -> CultCacheEnvelope { + envelope( + RUN_LEDGER_ROW, + Some(RUN_LEDGER_SCHEMA), + run, + rmp_serde::to_vec_named(&RunLedger { + run_id: run.into(), + cap: u64::MAX, + start: start(), + spent, + }) + .unwrap(), + ) +} + +fn envelope( + row_type: &str, + schema: Option<&str>, + key: &str, + payload: Vec, +) -> CultCacheEnvelope { + CultCacheEnvelope { + key: key.into(), + r#type: row_type.into(), + payload, + stored_at: Utc::now().to_rfc3339(), + schema_id: schema.map(str::to_owned), + } +} + +#[test] +fn the_store_refuses_any_row_that_is_not_a_canonical_v1_record() { + let good = sample_record("run-x", 1); + let payload = rmp_serde::to_vec_named(&good).unwrap(); + let key = row_key("run-x", 1); + let mut loose = payload.clone(); + loose.push(0xc0); + let v1 = |key: &str, payload: Vec| { + envelope(CALL_RECORD_ROW, Some(CALL_RECORD_SCHEMA), key, payload) + }; + let record_at = |sequence: u64| { + v1( + &row_key("run-x", sequence), + rmp_serde::to_vec_named(&sample_record("run-x", sequence)).unwrap(), + ) + }; + let cases = [ + ( + "earlier version", + envelope( + "verse_provider_call.v0", + Some("ghostlight.verse_provider_call.v0"), + &key, + payload.clone(), + ), + ), + ( + "later version", + envelope( + "verse_provider_call.v2", + Some("ghostlight.verse_provider_call.v2"), + &key, + payload.clone(), + ), + ), + ( + "another document", + envelope( + "controller_work.v18", + Some("ghostlight.controller_work.v18"), + &key, + payload.clone(), + ), + ), + ( + "no schema", + envelope(CALL_RECORD_ROW, None, &key, payload.clone()), + ), + ("wrong key", v1("run-x/2", payload.clone())), + ("not canonical", v1(&key, loose)), + ("not a record", v1(&key, vec![1, 2, 3])), + ( + "a run id that is not a key segment", + v1( + &row_key("bad/run", 1), + rmp_serde::to_vec_named(&sample_record("bad/run", 1)).unwrap(), + ), + ), + ("sequence zero", record_at(0)), + ("a hole in the run", record_at(2)), + ("a record with no ledger", record_at(1)), + ( + "a ledger at the wrong key", + envelope( + RUN_LEDGER_ROW, + Some(RUN_LEDGER_SCHEMA), + "other", + rmp_serde::to_vec_named(&RunLedger { + run_id: "run-x".into(), + cap: u64::MAX, + start: start(), + spent: 1, + }) + .unwrap(), + ), + ), + ( + "a ledger that is not canonical", + envelope( + RUN_LEDGER_ROW, + Some(RUN_LEDGER_SCHEMA), + "run-x", + [ + rmp_serde::to_vec_named(&RunLedger { + run_id: "run-x".into(), + cap: u64::MAX, + start: start(), + spent: 1, + }) + .unwrap(), + vec![0xc0], + ] + .concat(), + ), + ), + ]; + for (label, row) in cases { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("calls.redb"); + OwnedRedbMessagePackBackingStore::new(&path) + .unwrap() + .push(&row) + .unwrap(); + let error = CallRecordStore::open(&path).err(); + assert_eq!(error, Some(CallRecordError::Corrupt), "{label}"); + } + // A ledger under the charges of its records is refused too. + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("calls.redb"); + let mut backing = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); + backing.push(&v1(&key, payload.clone())).unwrap(); + backing.push(&spend_row("run-x", 2)).unwrap(); + drop(backing); + assert_eq!( + CallRecordStore::open(&path).err(), + Some(CallRecordError::Corrupt) + ); + // The same rows, well formed and covered by the ledger, open. + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("calls.redb"); + let mut backing = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); + backing.push(&v1(&key, payload)).unwrap(); + backing.push(&spend_row("run-x", 3)).unwrap(); + drop(backing); + let store = CallRecordStore::open(&path).unwrap(); + let run = RunId::new("run-x").unwrap(); + assert_eq!(store.records(&run).unwrap(), vec![good]); + assert_eq!(store.spent(&run).unwrap(), 3); +} + +#[test] +fn a_record_is_appended_only_at_the_next_sequence() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-seq").unwrap(); + assert_eq!( + store.append(&sample_record("run-seq", 2)), + Err(CallRecordError::OutOfSequence) + ); + store.append(&sample_record("run-seq", 1)).unwrap(); + assert_eq!( + store.append(&sample_record("run-seq", 1)), + Err(CallRecordError::OutOfSequence) + ); + assert_eq!( + store.append(&sample_record("run-seq", 3)), + Err(CallRecordError::OutOfSequence) + ); + store.append(&sample_record("run-seq", 2)).unwrap(); + assert_eq!(store.next_sequence(&run), Ok(3)); +} + +#[test] +fn a_run_id_is_a_key_safe_segment() { + assert!(RunId::new("run_1-A").is_ok()); + assert!(RunId::new(&"a".repeat(64)).is_ok()); + for bad in ["", "has/slash", "has space", "caf\u{e9}", &"a".repeat(65)] { + assert_eq!(RunId::new(bad), Err(CallRecordError::InvalidRunId)); + } +} + +#[tokio::test] +async fn replay_keys_the_request_content_not_the_command_identity() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-world").unwrap(); + let inner = ScriptedPort::new(ok_all(&[reply("one", "r1"), reply("two", "r2")])); + let live = recording(&inner, &store, "run-world", u64::MAX); + for prompt in ["first", "second"] { + live.infer( + live.prepare(request_for(CommandId::new(), prompt, 1_200)) + .unwrap(), + ) + .await + .unwrap(); + } + // A fresh genesis names every command anew; the content is what repeats. + let replay = ReplayInferencePort::new("verse-replay", &store, &run, &start()).unwrap(); + let under_new_names = |prompt: &str, max: u32| { + replay + .prepare(request_for(CommandId::new(), prompt, max)) + .unwrap() + }; + // The same words under a different output allowance are another request. + replay + .infer(under_new_names("first", 1_201)) + .await + .expect_err("a different shape"); + let one = replay.infer(under_new_names("first", 1_200)).await.unwrap(); + let two = replay + .infer(under_new_names("second", 1_200)) + .await + .unwrap(); + assert_eq!(json(&one), json(&reply("one", "r1"))); + assert_eq!(json(&two), json(&reply("two", "r2"))); +} + +#[tokio::test] +async fn usage_one_token_over_the_bound_faults_and_stores_no_record() { + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let prepared = prepared_for("one over", 1_200); + let bound = bound_of(&prepared); + let inner = ScriptedPort::new(vec![Ok( + reply("edge", "r1").with_usage(usage(bound, 1, None)) + )]); + let port = recording(&inner, &store, "run-edge", bound + 200); + let fault = port.infer(prepared).await.expect_err("one over"); + assert_eq!(fault.class(), InferenceFaultClass::BudgetExhausted); + assert_eq!(port.spent(), bound + 1, "charged in full"); + assert!( + store + .records(&RunId::new("run-edge").unwrap()) + .unwrap() + .is_empty() + ); +} + +/// Every field of the provider request is in the replay key except the two +/// ids that name the call. +#[test] +fn the_replay_key_covers_what_the_request_asks_and_not_what_names_it() { + let base = prepared_for("key", 1_200).invocation.request.clone(); + let digest = |edit: &dyn Fn(&mut CodexProviderRequest)| { + let mut request = base.clone(); + edit(&mut request); + content_digest(&request).unwrap() + }; + let same = digest(&|_| {}); + assert_eq!(same, content_digest(&base).unwrap()); + let changes: Vec<(&str, Box)> = vec![ + ("model", Box::new(|r| r.model.push('x'))), + ("instructions", Box::new(|r| r.instructions.push('x'))), + ( + "tools", + Box::new(|r| { + r.tools.push(CodexToolDefinition { + name: "t".into(), + description: "d".into(), + parameters_json: "{}".into(), + }) + }), + ), + ( + "input", + Box::new(|r| r.input.push(CodexInputItem::UserText { text: "x".into() })), + ), + ( + "max_output_tokens", + Box::new(|r| r.max_output_tokens = Some(1)), + ), + ( + "reasoning_effort", + Box::new(|r| r.reasoning_effort = Some("low".into())), + ), + ( + "service_tier", + Box::new(|r| r.service_tier = Some("flex".into())), + ), + ( + "output_schema_json", + Box::new(|r| r.output_schema_json = Some("{}".into())), + ), + ( + "previous_response_id", + Box::new(|r| r.previous_response_id = Some("p".into())), + ), + ( + "parallel_tool_calls", + Box::new(|r| r.parallel_tool_calls = !r.parallel_tool_calls), + ), + ("schema_id", Box::new(|r| r.schema_id.push_str(".v2"))), + ( + "reasoning_summary", + Box::new(|r| r.reasoning_summary = Some("auto".into())), + ), + ( + "output_format_name", + Box::new(|r| r.output_format_name = Some("persona_reply".into())), + ), + ( + "tool_choice", + Box::new(|r| { + r.tool_choice = match r.tool_choice { + codex_connector::CodexToolChoice::Auto => { + codex_connector::CodexToolChoice::Required + } + codex_connector::CodexToolChoice::Required => { + codex_connector::CodexToolChoice::Auto + } + } + }), + ), + ( + "prompt_cache_key", + Box::new(|r| r.prompt_cache_key = Some("ghostlight-persona-1".into())), + ), + ]; + for (field, change) in changes { + assert_ne!(digest(&*change), same, "{field} is not in the replay key"); + } + assert_eq!(digest(&|r| r.request_id.push('x')), same); + assert_eq!(digest(&|r| r.conversation_id.push('x')), same); +} + +fn open_with( + rows: Vec, +) -> (tempfile::TempDir, Result) { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("calls.redb"); + let mut backing = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); + for row in &rows { + backing.push(row).unwrap(); + } + drop(backing); + let opened = CallRecordStore::open(&path); + (directory, opened) +} + +fn record_row(record: &ProviderCallRecord) -> CultCacheEnvelope { + envelope( + CALL_RECORD_ROW, + Some(CALL_RECORD_SCHEMA), + &row_key(&record.run_id, record.sequence), + rmp_serde::to_vec_named(record).unwrap(), + ) +} + +#[test] +fn a_run_with_sequence_zero_beside_a_hole_is_refused() { + let (_directory, opened) = open_with(vec![ + record_row(&sample_record("run-x", 0)), + record_row(&sample_record("run-x", 2)), + spend_row("run-x", 6), + ]); + assert_eq!(opened.err(), Some(CallRecordError::Corrupt)); +} + +#[test] +fn a_record_charged_nothing_still_needs_its_ledger() { + let mut record = sample_record("run-y", 1); + record.charged = 0; + let (_directory, opened) = open_with(vec![record_row(&record)]); + assert_eq!(opened.err(), Some(CallRecordError::Corrupt)); +} + +#[test] +fn a_record_whose_run_id_is_no_whole_key_segment_is_refused() { + let mut stray = sample_record("run-z/b", 1); + stray.charged = 0; + let (_directory, opened) = open_with(vec![ + record_row(&stray), + record_row(&sample_record("run-z", 1)), + spend_row("run-z", 3), + ]); + assert_eq!(opened.err(), Some(CallRecordError::Corrupt)); + // A neighbouring run id that merely shares the prefix keeps its own records. + let (_directory, opened) = open_with(vec![ + record_row(&sample_record("run-z", 1)), + spend_row("run-z", 3), + spend_row("run-z-b", 0), + ]); + let store = opened.unwrap(); + assert_eq!( + store.records(&RunId::new("run-z").unwrap()).unwrap().len(), + 1 + ); +} + +#[tokio::test] +async fn a_run_reopens_only_under_the_cap_it_began_with() { + let directory = tempfile::tempdir().unwrap(); + let (store, path) = store_in(&directory); + let inner = ScriptedPort::new(Vec::new()); + drop(recording(&inner, &store, "run-cap", 5_000)); + let run = RunId::new("run-cap").unwrap(); + let open = |store: &Arc, cap: u64| { + RecordingInferencePort::open(inner.clone(), store.clone(), run.clone(), cap, &start()) + }; + for other in [4_999, 5_001, u64::MAX] { + assert_eq!( + open(&store, other).err(), + Some(CallRecordError::RunMismatch(RunField::Cap)) + ); + } + // A refused reopen does not hold the run, and the cap outlives the process. + drop(open(&store, 5_000).expect("the same cap reopens")); + drop(store); + let store = Arc::new(CallRecordStore::open(&path).unwrap()); + assert_eq!( + open(&store, 5_001).err(), + Some(CallRecordError::RunMismatch(RunField::Cap)) + ); + open(&store, 5_000).expect("the same cap reopens after a restart"); + let message = format!("{}", CallRecordError::RunMismatch(RunField::Cap)); + assert!(message.contains("token cap") && !message.contains("5000")); +} + +/// A world made by the production path: one genesis command in a fresh world +/// file, its snapshot, and the file's path. +async fn genesis( + directory: &std::path::Path, + command: CommandId, +) -> (crate::WorldSnapshot, std::path::PathBuf) { + let path = directory.join("world.cc"); + let (mailbox, task) = crate::WorldMailbox::open(&path).expect("an empty world"); + mailbox + .create_fixture( + crate::tests::creation(command, "Replayed"), + &crate::tests::auth_principal(crate::tests::owner()), + ) + .await + .expect("a created world"); + let snapshot = mailbox.snapshot().await.unwrap(); + drop(mailbox); + task.await.unwrap(); + (snapshot, path) +} + +async fn reopened(path: &std::path::Path) -> crate::WorldSnapshot { + let (mailbox, task) = crate::WorldMailbox::open(path).expect("a restored world"); + let snapshot = mailbox.snapshot().await.unwrap(); + drop(mailbox); + task.await.unwrap(); + snapshot +} + +#[tokio::test] +async fn replay_runs_against_a_restored_copy_and_refuses_any_other_world_up_front() { + let command = CommandId::new(); + let first = tempfile::tempdir().unwrap(); + let (world, world_file) = genesis(first.path(), command).await; + let began = RunStart::of(&world); + + let directory = tempfile::tempdir().unwrap(); + let (store, _) = store_in(&directory); + let run = RunId::new("run-world").unwrap(); + let inner = ScriptedPort::new(ok_all(&[reply("one", "r1")])); + let live = + RecordingInferencePort::open(inner.clone(), store.clone(), run.clone(), u64::MAX, &began) + .unwrap(); + live.infer(live.prepare(request_for(command, "go", 1_200)).unwrap()) + .await + .unwrap(); + drop(live); + + // A byte copy of the starting world, restored, replays the run. + let restored_dir = tempfile::tempdir().unwrap(); + let restored_file = restored_dir.path().join("world.cc"); + std::fs::copy(&world_file, &restored_file).unwrap(); + let restored = RunStart::of(&reopened(&restored_file).await); + assert_eq!(restored, began); + let replay = ReplayInferencePort::new("verse-replay", &store, &run, &restored) + .expect("a restored copy replays"); + let served = replay + .infer(replay.prepare(request_for(command, "go", 1_200)).unwrap()) + .await + .unwrap(); + assert_eq!(json(&served), json(&reply("one", "r1"))); + + // The same genesis created again is another world and is refused before + // any call, naming the field and nothing it held. + let again = tempfile::tempdir().unwrap(); + let (recreated, _) = genesis(again.path(), command).await; + let refusal = ReplayInferencePort::new("verse-replay", &store, &run, &RunStart::of(&recreated)) + .err() + .expect("a re-created world"); + assert_eq!(refusal, CallRecordError::RunMismatch(RunField::WorldId)); + let text = format!("{refusal} {refusal:?}"); + assert!(!text.contains(&began.world_id) && !text.contains(&began.state_digest)); + + // The same id with another digest is refused too. + let drifted = RunStart { + world_id: began.world_id.clone(), + state_digest: format!("{}x", began.state_digest), + }; + assert_eq!( + ReplayInferencePort::new("verse-replay", &store, &run, &drifted).err(), + Some(CallRecordError::RunMismatch(RunField::StateDigest)) + ); + assert_eq!(inner.calls(), 1, "replay never reached the provider"); +} diff --git a/crates/ghostlight/src/verse/genesis.rs b/crates/ghostlight/src/verse/genesis.rs new file mode 100644 index 0000000..9641467 --- /dev/null +++ b/crates/ghostlight/src/verse/genesis.rs @@ -0,0 +1,143 @@ +//! The canonical Aetheria verse world's genesis. +//! +//! One intent lowers to one `CreateWorld`: a commons, every grammar verb +//! declared as an affordance, and each Aetheria faction as an institution +//! imported by its record key. The world is bound to the catalog's grammar, so +//! the kernel refuses any affordance the grammar does not carry. +//! +//! Phase A verbs record the act and change no component: an outcome band with +//! no effects, and the act carried as the utterance that makes an entry +//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling +//! `verb-effects-ghostlight`), and those arrive with phase B. + +use crate::aetheria::AetheriaCatalog; +use crate::mailbox::{MailboxError, commons_declaration, commons_ref}; +use crate::patch::{ + AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef, + FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration, + kernel_speak_entry, kernel_speak_grant, +}; +use crate::{ + CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind, + WorldPatch, WorldScaleIntentRef, +}; +use std::collections::BTreeMap; + +const COMMIT_LEN: usize = 40; + +/// What a caller states to create the canonical verse world. Everything else a +/// world needs (the commons, the subjects, the affordances, their grants and +/// the provenance of what is known) is derived from the catalog. +#[derive(Clone, Debug)] +pub struct CreateVerseWorldIntent { + pub id: CommandId, + pub title: String, + pub brief: String, + pub catalog: AetheriaCatalog, + /// The Aetheria commit the catalog was read at: 40 lower-case hex digits. + /// It is the provenance of every fact the world holds about a faction. + pub aetheria_commit: String, + pub lens_weights: LensWeights, + pub targets: BTreeMap, +} + +#[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 { + 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; diff --git a/crates/ghostlight/src/verse/genesis/tests.rs b/crates/ghostlight/src/verse/genesis/tests.rs new file mode 100644 index 0000000..24f19d8 --- /dev/null +++ b/crates/ghostlight/src/verse/genesis/tests.rs @@ -0,0 +1,476 @@ +use super::*; +use crate::aetheria::read_aetheria_catalog; +use crate::patch::{FactStanding, Mismatch}; +use crate::tests::{auth_principal, owner, stock_weights}; +use crate::{ + CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb, + KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel, + WorldMailbox, WorldPhase, +}; +use chrono::{Duration, Utc}; +use std::collections::BTreeSet; + +/// Aetheria master when the fixture catalog was written. +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); + +fn catalog() -> AetheriaCatalog { + read_aetheria_catalog(FIXTURE).unwrap() +} + +fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent { + CreateVerseWorldIntent { + id: CommandId::new(), + title: "The Verse".into(), + brief: "Aetheria.".into(), + catalog, + aetheria_commit: commit.into(), + lens_weights: stock_weights(), + targets: BTreeMap::new(), + } +} + +/// A world created through the kernel from the lowering, for what the +/// snapshot hides: the facts' evidence. +fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result) { + 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::>() + ); + + // 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 = 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::>() + ); + } +} + +#[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::>() + ); + 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.")); +} diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs new file mode 100644 index 0000000..abef1b3 --- /dev/null +++ b/crates/ghostlight/src/verse/mod.rs @@ -0,0 +1,19 @@ +//! The Aetheria verse run's own organs. The verse binary sits above this +//! module; here sit the parts of a run that belong to the library: the record +//! of every provider call and the spend it implies, the genesis of the +//! canonical verse world, and the manual run that advances it with its record. + +mod calls; +mod genesis; +mod run; + +pub use genesis::{CreateVerseWorldIntent, VerseGenesisError}; +pub use calls::{ + CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, + RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, +}; +pub use run::{ + RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunClaim, RunOutcome, RunStatus, + TickCheckpoint, TickReport, UnattributedCall, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, + call_run_id, run_manual, +}; diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs new file mode 100644 index 0000000..aced104 --- /dev/null +++ b/crates/ghostlight/src/verse/run.rs @@ -0,0 +1,1165 @@ +//! The manual verse run: the tick driver and its record. +//! +//! One invocation advances the world tick by tick. A tick reads the world, +//! opens (or finds open) its tick document, runs each frozen cell through the +//! controller organ, advances the clock once by the frozen span, and +//! checkpoints. The driver owns the order of those steps and nothing else: +//! cells decide what factions do, the recording port owns spend, the kernel +//! owns time. +//! +//! A tick is named by the world's clock-advance count, never by `now`. The +//! resume point is the open tick document (world, ordinal), written before the +//! tick's first cell and read by any later run: it froze the span, the cover +//! budget and the cells when the tick opened, so a run under another span or +//! budget finishes the open tick as it was opened and applies its own config +//! from the next tick it opens. A tick whose cells did not all finish never +//! advances the clock, so the same ordinal, cells and command ids come back, +//! the controller-work rows pick up where they stopped, and the kernel answers +//! every re-submission with the original receipt. A run record reports a run +//! and is never read to resume one. +//! +//! A run starts only when it is invoked: nothing here waits, schedules or +//! repeats. + +use super::calls::{RecordingInferencePort, canonical}; +use crate::controllers::{ + CellRun, ConstituentRefusalReason, ControllerError, ControllerPendingReason, ControllerRunner, InferenceFaultClass, + NarrativeRun, OperationalRun, SubmissionDisposition, +}; +use crate::cover::{ + Cell, CellId, Constituent, CoverBudget, CoverBudgetError, TickIndex, derive_cover, +}; +use crate::{ + AgencyGraph, CommandId, DecisionOpportunity, KernelError, MailboxError, OperatorEvent, + RenderPath, + SubjectId, SubmitReceipt, TickMinutes, WorldId, WorldMailbox, WorldSnapshot, +}; +use chrono::Utc; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, ops::ControlFlow, path::Path, sync::Mutex}; +use thiserror::Error; + +/// The run record document's schema. +pub const RUN_RECORD_SCHEMA: &str = "ghostlight.verse_run.v1"; +const RUN_ROW: &str = "verse_run.v1"; +/// The tick document's schema. +pub const TICK_RECORD_SCHEMA: &str = "ghostlight.verse_tick.v1"; +const TICK_ROW: &str = "verse_tick.v1"; +const CLOCK_NAMESPACE: &str = "ghostlight.verse.clock"; + +/// Why a run could not be recorded or started. Every variant names a kind of +/// fault; none carries a value the run held. +#[derive(Debug, Error)] +pub enum VerseRunError { + #[error("the run record store could not be read or written")] + Store, + #[error("the run record store holds a row that is not a canonical run record")] + Corrupt, + #[error("a run record's sequence is not an existing run or the next in its world")] + OutOfSequence, + #[error("the recording port belongs to another run")] + PortNotThisRun, + #[error("the run's configuration is not the one its claim recorded")] + ConfigNotThisClaim, + #[error("the world is not at the revision its claim was made from")] + WorldMovedSinceClaim, + #[error("the cover budget is not valid: {0}")] + Budget(#[from] CoverBudgetError), + #[error(transparent)] + Mailbox(#[from] MailboxError), +} + +/// Where a run stands. `Running` is only ever the record of a run in flight, +/// or of one whose process died before it could say how it ended. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RunStatus { + Running, + Completed, + /// A provider call could pass the token cap, so the run ended before the + /// open tick's clock advance. + BudgetExhausted, + /// The run ended without finishing its open tick. `reason` is a fixed code + /// naming the kind of fault, never a value. + Failed { + reason: String, + }, + /// The caller asked the run to end after a finished tick. + Stopped, +} + +/// Why a cell committed nothing and the run went on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RefusalReason { + /// A narrative turn the world moved under and could not lower again. + Interrupted, + /// The world refused the cell's submission. + WorldRejected, + /// The cell's subject no longer holds an opportunity. + OpportunityGone, + /// The cell's work was bound to a scope that has since moved. Work still + /// reasoning is refused by the operational lane's world check before each + /// inference round. Work whose submission body + /// was persisted but never sent is submitted and refused by the world with + /// `ScopeChanged`. Narrative work is never refused for a moved scope at + /// resume: the kernel refuses it at submit and the same prose is + /// re-lowered once, and only a second move ends it overtaken. A cell whose + /// command already committed does not end this way: the controller answers it with its receipt whatever the scope + /// has done since. + ScopeMoved, + /// A grouped member's own tool call could not be decoded and it proposed + /// nothing else. No turn was submitted for it. + ProposalUndecodable, + /// A resumed cell's persisted row did not hold this member. No turn was + /// submitted for it. + RowDisagreesWithCell, + /// A tool call in the member's cell named no handle and the member + /// finished with no choice of its own, so no turn was submitted for it. + CallUnattributed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Refusal { + /// The cell's position in the tick's cover. + pub cell: u32, + /// The frozen member that committed nothing. Every member of a closed + /// tick's cells is in `committed` or here, once. + pub subject: SubjectId, + pub reason: RefusalReason, +} + +/// A tool call in a grouped cell that named no handle in it. The name is the +/// call's own, capped; its arguments are never kept. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct UnattributedCall { + /// The cell's position in the tick's cover. + pub cell: u32, + pub tool: String, +} + +/// A tick as the run left it. A closed tick has all its cells run and the +/// clock advanced by the tick's frozen span. The tick a run ended inside is +/// recorded too, unclosed, so what its cells committed is not lost; a later +/// run finds that tick's document open and works in it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TickCheckpoint { + /// The tick's ordinal, as its tick document names it. + pub tick: u64, + pub closed: bool, + pub cells: u32, + /// Every command the tick's cells hold a receipt for, in cell order. A + /// decline is a commit, so it is listed. + pub committed: Vec, + pub refusals: Vec, + /// The calls grouped cells received that named no handle, in cell order. + pub unattributed: Vec, + /// Tokens the recording port charged during the tick. + pub tokens: u64, +} + +/// The cover budget as the record carries it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordedCoverBudget { + pub cells: u16, + pub constituent_cap: u16, + pub urgency_slots: u16, +} + +/// A cell as the tick document froze it: the partition's identity and its +/// members, decided once when the tick opened. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrozenCell { + pub id: CellId, + /// True for a `Cell::Group`, even one a refresh has left a single member. + pub group: bool, + pub members: Vec, +} + +impl FrozenCell { + fn of(cell: &Cell) -> Self { + let (id, group) = match cell { + Cell::Singleton { id, .. } => (*id, false), + Cell::Group { id, .. } => (*id, true), + }; + Self { + id, + group, + members: cell.members().iter().map(|member| member.subject).collect(), + } + } +} + +/// What closed a tick. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TickClose { + /// The run with this sequence submitted the tick's clock advance. + Clock { run: u64 }, + /// A later run found the world's ordinal past the tick with the document + /// still open: its clock landed and the close was lost, or another clock + /// writer moved time. + World, +} + +/// What a tick decided when it opened. Document `ghostlight.verse_tick.v1`, +/// keyed (world, ordinal). Written once before the tick's first cell, closed +/// once, never reopened or deleted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerseTick { + pub world_id: WorldId, + pub ordinal: u64, + pub opened_now: u64, + pub opened_revision: u64, + pub span_minutes: u32, + pub cover_budget: RecordedCoverBudget, + pub oversubscribed: bool, + pub cells: Vec, + pub opened_by_run: u64, + pub closed: Option, +} + +/// One invocation of the verse runner: what it was asked, what it did. +/// Document `ghostlight.verse_run.v1`, keyed (world, run sequence). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerseRunRecord { + pub world_id: WorldId, + pub sequence: u64, + pub token_cap: u64, + pub ticks_requested: u32, + pub tick_span_minutes: u32, + pub cover_budget: RecordedCoverBudget, + /// The world's revision when the run began. + pub start_revision: u64, + pub checkpoints: Vec, + pub status: RunStatus, +} + +impl VerseRunRecord { + /// The ticks this run finished: every cell run and the clock advanced. + pub fn ticks_closed(&self) -> usize { + self.checkpoints.iter().filter(|c| c.closed).count() + } +} + +/// What a caller asks of one run. The token cap is not here: the recording +/// port owns it. +#[derive(Clone, Copy, Debug)] +pub struct VerseRunConfig { + pub ticks: u32, + pub tick_span: TickMinutes, + pub cover: CoverBudget, +} + +/// A tick as the operator reads it, including the one a run ended inside. +#[derive(Clone, Debug)] +pub struct TickReport { + pub checkpoint: TickCheckpoint, + /// Every act the tick committed, from the operator log. + pub acts: Vec, + /// The labels of the subjects the checkpoint's refusals name, from the + /// snapshot the tick opened on. + pub labels: BTreeMap, + pub spent: u64, + pub remaining: u64, +} + +impl std::fmt::Display for RunStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Running => f.write_str("Running"), + Self::Completed => f.write_str("Completed"), + Self::BudgetExhausted => f.write_str("BudgetExhausted"), + Self::Failed { reason } => write!(f, "Failed ({reason})"), + Self::Stopped => f.write_str("Stopped"), + } + } +} + +/// The tick as the operator reads it: a header, one line per committed act +/// (who, which verb, how it renders, what its roles are bound to, what was +/// said or shown), one per refusal, and the spend. +impl std::fmt::Display for TickReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let checkpoint = &self.checkpoint; + writeln!( + f, + "tick {} {}: {} cells, {} acts, {} refused", + checkpoint.tick, + if checkpoint.closed { "closed" } else { "open" }, + checkpoint.cells, + self.acts.len(), + checkpoint.refusals.len() + )?; + for act in &self.acts { + let path = match act.render_path { + Some(RenderPath::ShipAction) => "ship_action", + Some(RenderPath::Conversation) => "conversation", + None => "-", + }; + write!(f, " {} {} [{path}]", act.speaker_label, act.verb)?; + for role in &act.roles { + write!(f, " {}={}", role.role, role.target)?; + } + if let Some(speech) = &act.speech { + write!(f, " says {:?}", speech.as_str())?; + } + if let Some(display) = &act.display { + write!(f, " shows {:?}", display.as_str())?; + } + writeln!(f)?; + } + for refusal in &checkpoint.refusals { + let reason = match refusal.reason { + RefusalReason::Interrupted => "interrupted", + RefusalReason::WorldRejected => "world_rejected", + RefusalReason::OpportunityGone => "opportunity_gone", + RefusalReason::ScopeMoved => "scope_moved", + RefusalReason::ProposalUndecodable => "proposal_undecodable", + RefusalReason::RowDisagreesWithCell => "row_disagrees_with_cell", + RefusalReason::CallUnattributed => "call_unattributed", + }; + let label = self.labels.get(&refusal.subject).map_or("-", String::as_str); + writeln!(f, " refused: cell {} {label} {reason}", refusal.cell)?; + } + for call in &checkpoint.unattributed { + writeln!(f, " note: cell {} call names no handle: tool {:?}", call.cell, call.tool)?; + } + writeln!( + f, + " tokens: {} this tick, {} spent, {} remaining", + checkpoint.tokens, self.spent, self.remaining + ) + } +} + +#[derive(Clone, Debug)] +pub struct RunOutcome { + pub record: VerseRunRecord, +} + +fn row_key(world: WorldId, sequence: u64) -> String { + format!("{}/{sequence:020}", world.key()) +} + +fn tick_key(world: WorldId, ordinal: u64) -> String { + format!("{}/{ordinal:020}", world.key()) +} + +fn tick_row(tick: &VerseTick) -> Result { + Ok(CultCacheEnvelope { + key: tick_key(tick.world_id, tick.ordinal), + r#type: TICK_ROW.into(), + payload: rmp_serde::to_vec_named(tick).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(TICK_RECORD_SCHEMA.into()), + }) +} + +fn run_row(record: &VerseRunRecord) -> Result { + Ok(CultCacheEnvelope { + key: row_key(record.world_id, record.sequence), + r#type: RUN_ROW.into(), + payload: rmp_serde::to_vec_named(record).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(RUN_RECORD_SCHEMA.into()), + }) +} + +/// A run's number and cap, claimed from the run store before anything of the +/// run exists. The call ledger's run id derives from it ([`call_run_id`]), and +/// [`run_manual`] runs only the record it names. Not `Clone`: a claim runs once +/// or is abandoned. +#[derive(Debug)] +pub struct RunClaim { + world_id: WorldId, + sequence: u64, + token_cap: u64, +} + +impl RunClaim { + /// The run's number, the one [`call_run_id`] names. + pub fn sequence(&self) -> u64 { + self.sequence + } +} + +/// Every row of the file, split by type. +struct Rows { + runs: Vec, + ticks: Vec, +} + +/// Run records and tick documents in one CultCache file. A run is claimed +/// when it begins and saved again each time it moves, so a row is replaced by +/// its own later state. A tick is opened once and closed once, and only by the tick +/// driver: the writers are private to this module. +/// +/// ```compile_fail +/// fn wedge(store: &ghostlight::VerseRunStore, world: ghostlight::WorldId) { +/// store.close_ticks_before(world, 1).unwrap(); +/// } +/// ``` +pub struct VerseRunStore { + store: Mutex, +} + +impl VerseRunStore { + /// Opens the file, creating it empty when absent. Every row must be a + /// canonical run record of this schema under its own key. + pub fn open(path: impl AsRef) -> Result { + let store = OwnedRedbMessagePackBackingStore::new(path.as_ref()) + .map_err(|_| VerseRunError::Store)?; + store + .validate_path_identity() + .map_err(|_| VerseRunError::Store)?; + let opened = Self { + store: Mutex::new(store), + }; + opened.records()?; + Ok(opened) + } + + fn records(&self) -> Result { + let rows = self + .store + .lock() + .map_err(|_| VerseRunError::Store)? + .pull_all() + .map_err(|_| VerseRunError::Store)?; + let mut runs = Vec::new(); + let mut ticks = Vec::new(); + for row in &rows { + match (row.r#type.as_str(), row.schema_id.as_deref()) { + (RUN_ROW, Some(RUN_RECORD_SCHEMA)) => { + let record: VerseRunRecord = + canonical(row).map_err(|_| VerseRunError::Corrupt)?; + if row.key != row_key(record.world_id, record.sequence) + || record.sequence == 0 + { + return Err(VerseRunError::Corrupt); + } + runs.push(record); + } + (TICK_ROW, Some(TICK_RECORD_SCHEMA)) => { + let tick: VerseTick = canonical(row).map_err(|_| VerseRunError::Corrupt)?; + if row.key != tick_key(tick.world_id, tick.ordinal) { + return Err(VerseRunError::Corrupt); + } + ticks.push(tick); + } + _ => return Err(VerseRunError::Corrupt), + } + } + runs.sort_by_key(|record| (record.world_id.key(), record.sequence)); + ticks.sort_by_key(|tick| (tick.world_id.key(), tick.ordinal)); + Ok(Rows { runs, ticks }) + } + + /// The sequence the next claim of this world takes. Sequences count from + /// one. Private: a caller never chooses a run's number, `claim` does. + fn next_sequence(&self, world: WorldId) -> Result { + Ok(self + .records()? + .runs + .iter() + .filter(|record| record.world_id == world) + .map(|record| record.sequence) + .max() + .unwrap_or(0) + + 1) + } + + /// Every run of the world, in sequence. + pub fn runs(&self, world: WorldId) -> Result, VerseRunError> { + Ok(self + .records()? + .runs + .into_iter() + .filter(|record| record.world_id == world) + .collect()) + } + + /// The tick document of (world, ordinal), when one was opened. + pub fn tick(&self, world: WorldId, ordinal: u64) -> Result, VerseRunError> { + Ok(self + .records()? + .ticks + .into_iter() + .find(|tick| tick.world_id == world && tick.ordinal == ordinal)) + } + + /// Writes a tick document that does not exist yet, open. The store's own + /// insert-if-absent decides, so two opens of one ordinal cannot both win. + /// Private: the tick driver is the only writer of tick rows. + fn open_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { + if tick.closed.is_some() { + return Err(VerseRunError::OutOfSequence); + } + let row = tick_row(tick)?; + let inserted = self + .store + .lock() + .map_err(|_| VerseRunError::Store)? + .insert_entry_if_absent(row) + .map_err(|_| VerseRunError::Store)?; + if inserted { + Ok(()) + } else { + Err(VerseRunError::OutOfSequence) + } + } + + /// Closes an open tick document. Private, like every tick writer. + fn close_tick( + &self, + world: WorldId, + ordinal: u64, + close: TickClose, + ) -> Result<(), VerseRunError> { + let Some(mut tick) = self.tick(world, ordinal)? else { + return Err(VerseRunError::OutOfSequence); + }; + if tick.closed.is_some() { + return Err(VerseRunError::OutOfSequence); + } + tick.closed = Some(close); + self.put_tick(&tick) + } + + /// Closes, as the world's, every open tick of the world below `ordinal`. + fn close_ticks_before(&self, world: WorldId, ordinal: u64) -> Result<(), VerseRunError> { + for mut tick in self.records()?.ticks { + if tick.world_id == world && tick.ordinal < ordinal && tick.closed.is_none() { + tick.closed = Some(TickClose::World); + self.put_tick(&tick)?; + } + } + Ok(()) + } + + fn put_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { + let row = tick_row(tick)?; + self.store + .lock() + .map_err(|_| VerseRunError::Store)? + .push(&row) + .map_err(|_| VerseRunError::Store) + } + + /// Claims the next run of the snapshot's world: writes its `Running` + /// record, with the cap and config it was claimed under, in one insert that + /// cannot overwrite a record. The returned claim is the only way to run it; + /// a claim that never runs stays `Running` (a process that died) or is + /// ended by [`abandon`](Self::abandon), and its number is never issued + /// again. + pub fn claim( + &self, + snapshot: &WorldSnapshot, + token_cap: u64, + config: &VerseRunConfig, + ) -> Result { + let cover_budget = config.cover.validated()?; + let record = VerseRunRecord { + world_id: snapshot.world_id, + sequence: self.next_sequence(snapshot.world_id)?, + token_cap, + ticks_requested: config.ticks, + tick_span_minutes: config.tick_span.minutes(), + cover_budget: RecordedCoverBudget { + cells: cover_budget.cells, + constituent_cap: cover_budget.constituent_cap, + urgency_slots: cover_budget.urgency_slots, + }, + start_revision: snapshot.revision, + checkpoints: Vec::new(), + status: RunStatus::Running, + }; + self.insert_run(&record)?; + Ok(RunClaim { + world_id: record.world_id, + sequence: record.sequence, + token_cap, + }) + } + + /// Writes a run record that does not exist yet. The store's own + /// insert-if-absent decides, so two claims of one sequence cannot both win. + fn insert_run(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { + let inserted = self + .store + .lock() + .map_err(|_| VerseRunError::Store)? + .insert_entry_if_absent(run_row(record)?) + .map_err(|_| VerseRunError::Store)?; + if inserted { + Ok(()) + } else { + Err(VerseRunError::OutOfSequence) + } + } + + /// Ends a claim that never ran: its record becomes `Failed { reason }`. + pub fn abandon(&self, claim: RunClaim, reason: &'static str) -> Result<(), VerseRunError> { + let mut record = self.claimed(&claim)?; + record.status = failed(reason); + self.save(&record) + } + + /// The record a claim names. + fn claimed(&self, claim: &RunClaim) -> Result { + self.records()? + .runs + .into_iter() + .find(|record| record.world_id == claim.world_id && record.sequence == claim.sequence) + .ok_or(VerseRunError::OutOfSequence) + } + + /// Replaces a claimed record with its later state. A sequence with no + /// record is refused: only `claim` creates one. + fn save(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { + self.claimed(&RunClaim { + world_id: record.world_id, + sequence: record.sequence, + token_cap: record.token_cap, + })?; + self.store + .lock() + .map_err(|_| VerseRunError::Store)? + .push(&run_row(record)?) + .map_err(|_| VerseRunError::Store) + } +} + +/// How an error ends a cell. +enum CellEnd { + Budget, + Fatal(&'static str), + Refused(RefusalReason), +} + +fn classify(error: &ControllerError) -> CellEnd { + match error { + ControllerError::Inference { source, .. } + if source.class() == InferenceFaultClass::BudgetExhausted => + { + CellEnd::Budget + } + _ if error.requires_quarantine() => CellEnd::Fatal("controller_quarantined"), + ControllerError::World(KernelError::ScopeChanged { .. }) => { + CellEnd::Refused(RefusalReason::ScopeMoved) + } + ControllerError::World(_) => CellEnd::Refused(RefusalReason::WorldRejected), + ControllerError::NoOpportunity { .. } + | ControllerError::AmbiguousOpportunity + | ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::OpportunityGone), + ControllerError::ScopeMoved => CellEnd::Refused(RefusalReason::ScopeMoved), + _ => CellEnd::Fatal("controller_error"), + } +} + +fn pending_code(reason: ControllerPendingReason) -> &'static str { + match reason { + ControllerPendingReason::InferenceRetryable => "inference_retryable", + ControllerPendingReason::InferenceRecoveryRequired => "inference_recovery_required", + ControllerPendingReason::WorldUnavailable => "world_unavailable", + ControllerPendingReason::WorldOutcomeUnknown => "world_outcome_unknown", + ControllerPendingReason::StoreReopenRequired => "store_reopen_required", + } +} + +fn command_of(submission: &SubmissionDisposition) -> CommandId { + match submission { + SubmissionDisposition::NoProposal(receipt) | SubmissionDisposition::Completed(receipt) => { + match receipt { + SubmitReceipt::Applied(commit) | SubmitReceipt::AlreadyApplied(commit) => { + commit.command_id + } + } + } + SubmissionDisposition::PreviouslyConfirmed(commit) => commit.command_id, + } +} + +/// Folds one cell's outcome into the tick. Returns the status that ends the +/// run, when the cell did not finish. A pending cell's unreached members are +/// not refusals: the tick stays open and they resume. +fn absorb( + result: Result, + cell: &Cell, + position: u32, + committed: &mut Vec, + refusals: &mut Vec, + unattributed: &mut Vec, +) -> Option { + match result { + Ok(run) => { + absorb_run(run, cell, position, committed, refusals, unattributed).map(failed) + } + Err(error) => match classify(&error) { + CellEnd::Budget => Some(RunStatus::BudgetExhausted), + CellEnd::Fatal(code) => Some(failed(code)), + CellEnd::Refused(reason) => { + refusals.extend(refuse_all(cell, position, reason)); + None + } + }, + } +} + +/// A finished cell's commits and refusals; the code of the pending state when +/// it did not finish. +fn absorb_run( + run: CellRun, + cell: &Cell, + position: u32, + committed: &mut Vec, + refusals: &mut Vec, + unattributed: &mut Vec, +) -> Option<&'static str> { + match run { + CellRun::Narrative(NarrativeRun::Completed(decision)) => { + committed.push(command_of(decision.submission())); + None + } + CellRun::Narrative(NarrativeRun::Pending(pending)) => Some(pending_code(pending.reason())), + CellRun::Narrative(NarrativeRun::Interrupted(_)) => { + refusals.extend(refuse_all(cell, position, RefusalReason::Interrupted)); + None + } + CellRun::Operational(OperationalRun::Completed(decision)) => { + committed.push(command_of(decision.submission())); + None + } + CellRun::Operational(OperationalRun::Pending(pending)) => { + Some(pending_code(pending.reason())) + } + CellRun::Grouped(run) => { + // The controller owns who is in the cell and says, for each + // member, committed or refused. The driver copies. A finished + // cell whose lists miss a member, or name one it lacks, is a + // broken invariant and ends the run, not a relabel. + unattributed.extend(run.unattributed.iter().map(|tool| UnattributedCall { + cell: position, + tool: tool.clone(), + })); + for entry in &run.submissions { + committed.push(command_of(&entry.submission)); + } + for refused in &run.refused { + refusals.push(Refusal { + cell: position, + subject: refused.subject, + reason: match refused.reason { + ConstituentRefusalReason::LeftBeforeSelection => { + RefusalReason::OpportunityGone + } + ConstituentRefusalReason::ScopeChanged => RefusalReason::ScopeMoved, + ConstituentRefusalReason::WorldRefused => RefusalReason::WorldRejected, + ConstituentRefusalReason::ProposalUndecodable => { + RefusalReason::ProposalUndecodable + } + ConstituentRefusalReason::RowDisagreesWithCell => { + RefusalReason::RowDisagreesWithCell + } + ConstituentRefusalReason::CallUnattributed => { + RefusalReason::CallUnattributed + } + }, + }); + } + if run.pending.is_none() { + let listed: Vec = run + .submissions + .iter() + .map(|entry| entry.subject) + .chain(run.refused.iter().map(|refused| refused.subject)) + .collect(); + let members = cell.members(); + let exact = listed.len() == members.len() + && members.iter().all(|member| listed.contains(&member.subject)); + if !exact { + return Some("cell_membership"); + } + } + run.pending.map(pending_code) + } + } +} + +/// One refusal per member of the cell. +fn refuse_all(cell: &Cell, position: u32, reason: RefusalReason) -> impl Iterator { + cell.members().iter().map(move |member| Refusal { + cell: position, + subject: member.subject, + reason, + }) +} + +/// The frozen cell with each member bound to the opportunity the world holds +/// for that subject now, and the frozen members that hold none. The partition +/// is fixed when the tick opens; an opportunity is the world's current binding +/// for a subject, and an earlier cell's act can move it (a word spoken in a +/// shared room changes what its listeners know). A subject that no longer holds +/// an opportunity leaves the cell, and an empty cell is gone. +fn refreshed( + frozen: &FrozenCell, + tick: TickIndex, + fresh: &[DecisionOpportunity], +) -> (Option, Vec) { + let mut gone = Vec::new(); + let mut members: Vec = Vec::new(); + for subject in &frozen.members { + match fresh + .iter() + .find(|opportunity| opportunity.scope.subject_id == *subject) + { + Some(opportunity) => members.push(Constituent { + subject: *subject, + opportunity: opportunity.clone(), + }), + None => gone.push(*subject), + } + } + let cell = if frozen.group { + (!members.is_empty()).then(|| Cell::Group { + id: frozen.id, + tick, + members, + }) + } else { + members.into_iter().next().map(|member| Cell::Singleton { + id: frozen.id, + tick, + member, + }) + }; + (cell, gone) +} + +enum TickEnd { + /// Every cell finished and the clock advanced. + Closed, + /// The run ends inside this tick with this status. + Open(RunStatus), +} + +fn failed(reason: &str) -> RunStatus { + RunStatus::Failed { + reason: reason.to_owned(), + } +} + +/// Runs `config.ticks` ticks from the world's present clock under the cap the +/// recording port was opened with. `observe` sees each tick as it ends, +/// including the one a run ends inside, and may stop the run after a finished +/// tick by breaking. `claim` is the run's number from +/// [`VerseRunStore::claim`]; the port must have been opened for it +/// ([`call_run_id`]) under the cap the claim recorded, and `config` is the one +/// the claim was made with. A claim this refuses is abandoned, so a refused +/// start binds nothing. +pub async fn run_manual( + mailbox: &WorldMailbox, + runner: &ControllerRunner, + port: &RecordingInferencePort, + store: &VerseRunStore, + claim: RunClaim, + config: &VerseRunConfig, + mut observe: impl FnMut(&TickReport) -> ControlFlow<()>, +) -> Result { + let begun = match mailbox.snapshot().await { + Ok(begun) => begun, + Err(error) => { + store.abandon(claim, "world_unavailable")?; + return Err(error.into()); + } + }; + if port.run_id() != &call_run_id(claim.sequence) + || port.cap() != claim.token_cap + || begun.world_id != claim.world_id + { + store.abandon(claim, "port_not_this_run")?; + return Err(VerseRunError::PortNotThisRun); + } + let sequence = claim.sequence; + let mut record = store.claimed(&claim)?; + let cover_budget = match config.cover.validated() { + Ok(cover_budget) if record_matches(&record, config, cover_budget) => cover_budget, + _ => { + store.abandon(claim, "config_not_this_claim")?; + return Err(VerseRunError::ConfigNotThisClaim); + } + }; + // The record names the revision the run started from, so the run starts + // from it. + if record.start_revision != begun.revision { + store.abandon(claim, "world_moved_since_claim")?; + return Err(VerseRunError::WorldMovedSinceClaim); + } + + let mut spent_before = port.spent(); + for completed in 0..config.ticks { + let (end, report) = run_tick( + mailbox, + runner, + port, + store, + sequence, + config, + cover_budget, + spent_before, + || mailbox.operator_log(), + ) + .await; + spent_before = port.spent(); + match end { + TickEnd::Open(status) => { + record.status = status; + if let Some(report) = report { + record.checkpoints.push(report.checkpoint.clone()); + store.save(&record)?; + let _ = observe(&report); + } else { + store.save(&record)?; + } + return Ok(RunOutcome { record }); + } + TickEnd::Closed => { + let report = report.expect("a closed tick has its report"); + record.checkpoints.push(report.checkpoint.clone()); + store.save(&record)?; + if observe(&report).is_break() && completed + 1 < config.ticks { + record.status = RunStatus::Stopped; + store.save(&record)?; + return Ok(RunOutcome { record }); + } + } + } + } + record.status = RunStatus::Completed; + store.save(&record)?; + Ok(RunOutcome { record }) +} + +/// Whether the record a claim wrote was made under this config. +fn record_matches(record: &VerseRunRecord, config: &VerseRunConfig, cover: CoverBudget) -> bool { + record.ticks_requested == config.ticks + && record.tick_span_minutes == config.tick_span.minutes() + && record.cover_budget + == RecordedCoverBudget { + cells: cover.cells, + constituent_cap: cover.constituent_cap, + urgency_slots: cover.urgency_slots, + } +} + +/// The key a run's provider calls are recorded under: the run's sequence, so a +/// run record and its call records name each other. +pub fn call_run_id(sequence: u64) -> super::RunId { + super::RunId::new(&format!("run-{sequence}")) + .expect("a decimal sequence is a canonical run id") +} + +/// The id of a tick's clock advance: the world and the ordinal, nothing the +/// run holds. A retry in the same tick has the same body and is answered +/// `AlreadyApplied`. +pub(crate) fn clock_command_id(world: WorldId, ordinal: u64) -> CommandId { + CommandId::derived(CLOCK_NAMESPACE, &[&world.text(), &ordinal.to_string()]) +} + +/// One tick. The report is absent only when the world or the tick document +/// could not be read at all. +#[allow(clippy::too_many_arguments)] +async fn run_tick( + mailbox: &WorldMailbox, + runner: &ControllerRunner, + port: &RecordingInferencePort, + store: &VerseRunStore, + sequence: u64, + config: &VerseRunConfig, + cover_budget: CoverBudget, + spent_before: u64, + read_log: ReadLog, +) -> (TickEnd, Option) +where + ReadLog: FnOnce() -> Log, + Log: Future, MailboxError>>, +{ + let read = async { + let snapshot = mailbox.snapshot().await?; + let ordinal = mailbox.clock_advances().await?; + let opportunities = mailbox.opportunities().await?; + let graph = mailbox.agency_graph().await?; + Ok::<_, MailboxError>((snapshot, ordinal, opportunities, graph)) + } + .await; + let Ok((snapshot, ordinal, opportunities, graph)) = read else { + return (TickEnd::Open(failed("world_unavailable")), None); + }; + let world = snapshot.world_id; + let Ok(tick) = open_or_find( + store, + ordinal, + sequence, + config, + cover_budget, + &snapshot, + &opportunities, + &graph, + ) else { + return (TickEnd::Open(failed("run_store")), None); + }; + let index = TickIndex(ordinal); + let Some(span) = TickMinutes::new(tick.span_minutes) else { + return (TickEnd::Open(failed("run_store")), None); + }; + + let mut committed = Vec::new(); + let mut refusals = Vec::new(); + let mut unattributed = Vec::new(); + let mut open: Option = None; + for (position, frozen) in tick.cells.iter().enumerate() { + let position = position as u32; + let fresh = match mailbox.opportunities().await { + Ok(fresh) => fresh, + Err(_) => { + open = Some(failed("world_unavailable")); + break; + } + }; + let (cell, gone) = refreshed(frozen, index, &fresh); + refusals.extend(gone.iter().map(|&subject| Refusal { + cell: position, + subject, + reason: RefusalReason::OpportunityGone, + })); + let Some(cell) = cell else { + continue; + }; + let result = runner.run_cell(&cell).await; + if let Some(status) = absorb( + result, + &cell, + position, + &mut committed, + &mut refusals, + &mut unattributed, + ) { + open = Some(status); + break; + } + } + + // The act listing is the tick's report to the operator: a tick whose log + // cannot be read is not closed, and the run ends there. + let acts = match acts_since(read_log().await, snapshot.revision) { + Some(acts) => acts, + None => { + open = Some(failed("operator_log_unavailable")); + Vec::new() + } + }; + + if open.is_none() { + if mailbox + .submit_clock(clock_command_id(world, ordinal), span) + .await + .is_err() + { + open = Some(failed("clock_refused")); + } else if store + .close_tick(world, ordinal, TickClose::Clock { run: sequence }) + .is_err() + { + open = Some(failed("run_store")); + } + } + + let spent = port.spent(); + let checkpoint_closed = open.is_none(); + let labels = refusals + .iter() + .filter_map(|refusal| { + snapshot + .subjects + .iter() + .find(|subject| subject.id == refusal.subject) + .map(|subject| (refusal.subject, subject.label.clone())) + }) + .collect(); + let report = TickReport { + checkpoint: TickCheckpoint { + tick: ordinal, + closed: checkpoint_closed, + cells: tick.cells.len() as u32, + committed, + refusals, + unattributed, + tokens: spent.saturating_sub(spent_before), + }, + acts, + labels, + spent, + remaining: port.cap().saturating_sub(spent), + }; + (open.map_or(TickEnd::Closed, TickEnd::Open), Some(report)) +} + +/// The acts a tick committed: the operator log's events past the tick's +/// opening revision. A log that cannot be read is `None`, never an empty tick. +fn acts_since( + log: Result, MailboxError>, + since: u64, +) -> Option> { + log.ok().map(|log| { + log.into_iter() + .filter(|event| event.revision > since) + .collect() + }) +} + +/// The tick document for `ordinal`: the open one a run left, or a new one +/// frozen from a cover derived now. An open tick the world has moved past is +/// closed as the world's first. A document already closed at the world's +/// current ordinal is a store that disagrees with the world. +#[allow(clippy::too_many_arguments)] +fn open_or_find( + store: &VerseRunStore, + ordinal: u64, + sequence: u64, + config: &VerseRunConfig, + cover_budget: CoverBudget, + snapshot: &WorldSnapshot, + opportunities: &[DecisionOpportunity], + graph: &AgencyGraph, +) -> Result { + let world = snapshot.world_id; + store.close_ticks_before(world, ordinal)?; + if let Some(tick) = store.tick(world, ordinal)? { + return if tick.closed.is_none() { + Ok(tick) + } else { + Err(VerseRunError::OutOfSequence) + }; + } + let cover = derive_cover(world, TickIndex(ordinal), opportunities, graph, cover_budget); + let tick = VerseTick { + world_id: world, + ordinal, + opened_now: snapshot.now.0, + opened_revision: snapshot.revision, + span_minutes: config.tick_span.minutes(), + cover_budget: RecordedCoverBudget { + cells: cover_budget.cells, + constituent_cap: cover_budget.constituent_cap, + urgency_slots: cover_budget.urgency_slots, + }, + oversubscribed: cover.oversubscribed, + cells: cover.cells.iter().map(FrozenCell::of).collect(), + opened_by_run: sequence, + closed: None, + }; + store.open_tick(&tick)?; + Ok(tick) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs new file mode 100644 index 0000000..a5f00f8 --- /dev/null +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -0,0 +1,2783 @@ +use super::*; +use crate::aetheria::read_aetheria_catalog; +use crate::controllers::{ + ControllerModels, ControllerWorkStore, InferenceEvent, InferenceFault, InferenceOutput, + InferencePort, InferencePurpose, InferenceRequest, PreparedInference, TokenUsage, + open_controller_work, +}; +use crate::cover::derive_cover; +use crate::tests::stock_weights; +use crate::verse::{ + CallRecordStore, CreateVerseWorldIntent, RecordingInferencePort, ReplayInferencePort, + RunStart, +}; +use crate::{ + BoundRole, CommandBody, ControllerMode, FictionalMinutes, KernelError, + PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, id_text, +}; +use async_trait::async_trait; +use chrono::Duration; +use serde_json::json; +use std::collections::{BTreeMap, VecDeque}; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::task::JoinHandle; + +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); +const SPAN: u32 = 60; + +/// Creates the verse world at `dir/world.cc` from the fixture catalog and +/// activates it, then lets go of the file so a copy of it is a restored world. +async fn create_world(dir: &Path) { + let (mailbox, task) = WorldMailbox::open(dir.join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1)); + let receipt = mailbox + .create_verse( + CreateVerseWorldIntent { + id: CommandId::new(), + title: "The Verse".into(), + brief: "Aetheria.".into(), + catalog: read_aetheria_catalog(FIXTURE).unwrap(), + aetheria_commit: COMMIT.into(), + lens_weights: stock_weights(), + targets: BTreeMap::new(), + }, + &principal, + ) + .await + .unwrap(); + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let revision = mailbox.snapshot().await.unwrap().revision; + mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: receipt.world_id, + expected_revision: revision, + body, + }, + &principal, + ) + .await + .unwrap(); + } + drop(mailbox); + task.await.unwrap(); +} + +/// A scripted provider. `reply` sees the serialized request and answers with +/// the events a model would. It reports usage as a fixed share of the bound the +/// recording port holds the call to, so the charge is a function of the +/// request. +struct ScriptedPort { + reply: Box Vec + Send + Sync>, + faults: std::sync::Mutex>, + bounds: std::sync::Mutex>, + calls: AtomicUsize, +} + +impl ScriptedPort { + fn new(reply: impl Fn(&str) -> Vec + Send + Sync + 'static) -> Arc { + Arc::new(Self { + reply: Box::new(reply), + faults: std::sync::Mutex::new(VecDeque::new()), + bounds: std::sync::Mutex::new(Vec::new()), + calls: AtomicUsize::new(0), + }) + } + + /// Every faction speaks its own line. + fn speaking() -> Arc { + Self::new(|request| { + let name = who(request); + vec![speak_call(&format!("{name} holds the line."))] + }) + } + + fn fail_next(&self, fault: InferenceFault) { + self.faults.lock().unwrap().push_back(fault); + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + fn bounds(&self) -> Vec { + self.bounds.lock().unwrap().clone() + } +} + +/// Which faction a request is for: the prompt names its decision owner. +fn who(request: &str) -> String { + for name in ["Adrasteia", "Brannoch"] { + if request.contains(&format!("Decision owner:\\n{name}\\n")) { + return name.to_owned(); + } + } + panic!("a request names no faction"); +} + +fn speak_call(text: &str) -> InferenceEvent { + InferenceEvent::ToolCall { + call_id: "act".into(), + name: "speak".into(), + arguments: json!({ "text": text }).to_string(), + } +} + +#[async_trait] +impl InferencePort for ScriptedPort { + fn prepare(&self, request: InferenceRequest) -> Result { + PreparedInference::prepare("verse-run-test", 4_102_444_800_000, request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + let sequence = self.calls.fetch_add(1, Ordering::SeqCst); + let wire = &request.invocation.request; + let bound = serde_json::to_vec(wire).unwrap().len() as u64 + + u64::from(wire.max_output_tokens.unwrap()); + self.bounds.lock().unwrap().push(bound); + if let Some(fault) = self.faults.lock().unwrap().pop_front() { + return Err(fault); + } + let text = serde_json::to_string(wire).unwrap(); + Ok( + InferenceOutput::new((self.reply)(&text), format!("sha256:scripted-{sequence}")) + .with_usage(TokenUsage { + prompt: bound / 2, + completion: bound / 4, + cached_prompt: None, + }), + ) + } +} + +fn models() -> ControllerModels { + ControllerModels { + projector: "scripted".into(), + persona: "scripted".into(), + interpreter: "scripted".into(), + operational_agent: "scripted".into(), + elaborator: "scripted".into(), + } +} + +fn two_cells() -> CoverBudget { + CoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + } +} + +fn config(ticks: u32) -> VerseRunConfig { + VerseRunConfig { + ticks, + tick_span: TickMinutes::new(SPAN).unwrap(), + cover: two_cells(), + } +} + +/// An opened verse state root: the world, its call records, its run records +/// and its controller work, as the binary holds them. +struct Harness { + mailbox: WorldMailbox, + _task: JoinHandle<()>, + calls: Arc, + runs: VerseRunStore, + work: Arc, +} + +impl Harness { + async fn open(dir: &Path) -> Self { + let (mailbox, task) = WorldMailbox::open(dir.join("world.cc")).unwrap(); + Self { + mailbox, + _task: task, + calls: Arc::new(CallRecordStore::open(dir.join("calls.cc")).unwrap()), + runs: VerseRunStore::open(dir.join("runs.cc")).unwrap(), + work: open_controller_work(dir.join("controller-work.cc")).unwrap(), + } + } + + async fn fresh() -> (tempfile::TempDir, Self) { + let dir = tempfile::tempdir().unwrap(); + create_world(dir.path()).await; + let harness = Self::open(dir.path()).await; + (dir, harness) + } + + /// One run over `inner`, claimed and recorded under the next sequence. + async fn run( + &self, + inner: Arc, + cap: u64, + ticks: u32, + observe: impl FnMut(&TickReport) -> ControlFlow<()>, + ) -> RunOutcome { + self.run_under(inner, cap, config(ticks), observe).await + } + + /// One run under the given config, claimed and recorded under the next + /// sequence. + async fn run_under( + &self, + inner: Arc, + cap: u64, + config: VerseRunConfig, + observe: impl FnMut(&TickReport) -> ControlFlow<()>, + ) -> RunOutcome { + let world = self.mailbox.snapshot().await.unwrap(); + let claim = self.runs.claim(&world, cap, &config).unwrap(); + let sequence = claim.sequence(); + let port = Arc::new( + RecordingInferencePort::open( + inner, + self.calls.clone(), + call_run_id(sequence), + cap, + &RunStart::of(&world), + ) + .unwrap(), + ); + let runner = + ControllerRunner::open(self.mailbox.clone(), port.clone(), self.work.clone(), models()) + .unwrap(); + run_manual( + &self.mailbox, + &runner, + &port, + &self.runs, + claim, + &config, + observe, + ) + .await + .unwrap() + } + + async fn run_quietly(&self, inner: Arc, cap: u64, ticks: u32) -> RunOutcome { + self.run(inner, cap, ticks, |_| ControlFlow::Continue(())) + .await + } + + async fn run_quietly_under( + &self, + inner: Arc, + config: VerseRunConfig, + ) -> RunOutcome { + self.run_under(inner, u64::MAX, config, |_| ControlFlow::Continue(())) + .await + } + + async fn now(&self) -> u64 { + self.mailbox.snapshot().await.unwrap().now.0 + } + + async fn acts(&self) -> usize { + self.mailbox.operator_log().await.unwrap().len() + } + + async fn subject(&self, label: &str) -> crate::SubjectId { + self.mailbox + .snapshot() + .await + .unwrap() + .subjects + .iter() + .find(|subject| subject.label == label) + .unwrap() + .id + } +} + +#[tokio::test] +async fn scripted_run_commits_and_checkpoints_every_tick() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let port = ScriptedPort::speaking(); + let mut reports = Vec::new(); + let outcome = verse + .run(port.clone(), 10_000_000, 3, |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + + // One checkpoint per tick, tick indexes 0 to 2, the clock moved three spans. + assert_eq!(outcome.record.status, RunStatus::Completed); + let ticks: Vec = outcome.record.checkpoints.iter().map(|c| c.tick).collect(); + assert_eq!(ticks, vec![0, 1, 2]); + assert_eq!(outcome.record.ticks_closed(), 3); + assert_eq!(verse.now().await, 3 * u64::from(SPAN)); + // Both factions acted every tick, the second on a scope the first moved. + assert_eq!(verse.acts().await, 6); + assert_eq!(port.calls(), 6); + + // Every committed command id is the one the tick document's frozen cells + // derive, in cell order, and the document is closed by this run's clock. + for checkpoint in &outcome.record.checkpoints { + let tick = verse + .runs + .tick(world.world_id, checkpoint.tick) + .unwrap() + .unwrap(); + assert_eq!(tick.closed, Some(TickClose::Clock { run: 1 })); + assert_eq!(tick.opened_by_run, 1); + assert_eq!(tick.span_minutes, SPAN); + let expected: Vec = tick + .cells + .iter() + .map(|cell| { + assert!(!cell.group, "two factions under two cells are singletons"); + CommandId::for_cell_constituent( + world.world_id, + cell.id, + cell.members[0], + TickIndex(checkpoint.tick), + ) + }) + .collect(); + assert_eq!(expected.len(), 2); + assert_eq!(checkpoint.committed, expected); + assert_eq!(checkpoint.cells, 2); + assert!(checkpoint.closed); + assert!(checkpoint.refusals.is_empty()); + } + + // The record in the store is the record the run returned, and it says what + // the run was asked. + assert_eq!( + verse.runs.runs(world.world_id).unwrap(), + vec![outcome.record.clone()] + ); + assert_eq!(outcome.record.world_id, world.world_id); + assert_eq!(outcome.record.sequence, 1); + assert_eq!(outcome.record.token_cap, 10_000_000); + assert_eq!(outcome.record.ticks_requested, 3); + assert_eq!(outcome.record.tick_span_minutes, SPAN); + assert_eq!(outcome.record.start_revision, world.revision); + assert_eq!( + outcome.record.cover_budget, + RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0 + } + ); + + // A tick's tokens are what the call store charged for its two calls. + let records = verse.calls.records(&call_run_id(1)).unwrap(); + assert_eq!(records.len(), 6); + for (tick, checkpoint) in outcome.record.checkpoints.iter().enumerate() { + let charged = records[2 * tick].charged() + records[2 * tick + 1].charged(); + assert!(charged > 0); + assert_eq!(checkpoint.tokens, charged); + } + let total: u64 = outcome.record.checkpoints.iter().map(|c| c.tokens).sum(); + assert_eq!(total, verse.calls.spent(&call_run_id(1)).unwrap()); + + // The operator sees each tick as it ends: its acts, and the spend so far. + assert_eq!(reports.len(), 3); + let mut spent = 0; + for (tick, report) in reports.iter().enumerate() { + spent += report.checkpoint.tokens; + assert!(report.checkpoint.closed); + assert_eq!(report.acts.len(), 2); + assert_eq!(report.spent, spent); + assert_eq!(report.remaining, 10_000_000 - spent); + assert_eq!(report.checkpoint, outcome.record.checkpoints[tick]); + for act in &report.acts { + assert_eq!(act.verb, "speak"); + assert_eq!( + act.speech.as_ref().unwrap().as_str(), + format!("{} holds the line.", act.speaker_label) + ); + } + } + // A tick reports only its own acts. + let revisions: Vec = reports + .iter() + .flat_map(|report| report.acts.iter().map(|act| act.revision)) + .collect(); + assert_eq!(revisions.len(), 6); + assert!(revisions.windows(2).all(|pair| pair[0] < pair[1])); +} + +#[tokio::test] +async fn budget_exhausted_stops_before_the_clock_and_resumes() { + // What the first call is held to, measured on a world of its own. + let (_probe_dir, probe) = Harness::fresh().await; + let measuring = ScriptedPort::speaking(); + probe.run_quietly(measuring.clone(), u64::MAX, 1).await; + let first_bound = measuring.bounds()[0]; + + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + // Room for the first call, and not for a second after the first's charge. + let first = ScriptedPort::speaking(); + let mut reports = Vec::new(); + let ended = verse + .run(first.clone(), first_bound + 64, 2, |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + assert_eq!(ended.record.status, RunStatus::BudgetExhausted); + // The tick it ended inside is recorded, unclosed, with the act it committed. + let [open] = ended.record.checkpoints.as_slice() else { + panic!("the open tick is the run's one checkpoint") + }; + assert!(!open.closed); + assert_eq!(open.tick, 0); + assert_eq!(open.committed.len(), 1); + assert_eq!(ended.record.ticks_closed(), 0); + assert_eq!(verse.now().await, 0, "the open tick's clock did not advance"); + assert_eq!(first.calls(), 1, "the second call never reached the provider"); + assert_eq!(verse.acts().await, 1); + // The open tick is reported, unclosed, with what it committed. + assert_eq!(reports.len(), 1); + assert!(!reports[0].checkpoint.closed); + assert_eq!(reports[0].acts.len(), 1); + assert_eq!(&reports[0].checkpoint, open); + assert!(reports[0].remaining < first_bound); + assert_eq!( + verse.runs.runs(world.world_id).unwrap(), + vec![ended.record.clone()] + ); + + // A later run, under its own cap, finishes the tick and costs no call for + // the act already committed. + let second = ScriptedPort::speaking(); + let mut resumed_reports = Vec::new(); + let resumed = verse + .run(second.clone(), u64::MAX, 2, |report| { + resumed_reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + assert_eq!(resumed.record.status, RunStatus::Completed); + assert_eq!(resumed.record.sequence, 2); + // The resumed tick is the document run 1 opened, closed by run 2. + let opened = verse.runs.tick(world.world_id, 0).unwrap().unwrap(); + assert_eq!(opened.opened_by_run, 1); + assert_eq!(opened.closed, Some(TickClose::Clock { run: 2 })); + let ticks: Vec = resumed.record.checkpoints.iter().map(|c| c.tick).collect(); + assert_eq!(ticks, vec![0, 1], "the open tick is the first this run closes"); + assert_eq!(verse.now().await, 2 * u64::from(SPAN)); + assert_eq!(second.calls(), 3, "one call finished tick 0, two ran tick 1"); + assert_eq!(verse.acts().await, 4, "no act was committed twice"); + // The frozen cells run in their frozen order: the cell that already acted + // answers with its original receipt and costs no call, and the one the + // first run left in flight finishes. + let closing = &resumed.record.checkpoints[0]; + assert!(closing.closed); + assert_eq!(closing.cells, 2); + assert_eq!(closing.committed.len(), 2, "{closing:?}"); + assert_eq!(closing.committed[0], open.committed[0], "{closing:?}"); + assert_ne!(closing.committed[1], open.committed[0], "{closing:?}"); + assert!(closing.refusals.is_empty(), "{closing:?}"); + assert_eq!(resumed.record.ticks_closed(), 2); + // The tick reports the act this run committed and not the one it found. + assert_eq!(resumed_reports[0].acts.len(), 1); + assert!(resumed_reports[0].acts[0].revision > reports[0].acts[0].revision); + assert_ne!( + resumed_reports[0].acts[0].speaker_label, + reports[0].acts[0].speaker_label + ); + assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); +} + +/// Both cells of a tick committed through another driver before any run opened +/// it, one after the other, so each one's act has since moved the other's +/// scope. The run that opens the tick reaches both committed rows, in whatever +/// order it froze, and answers each with its receipt: no inference, no refusal, +/// no second act. `reverse` flips which cell the other driver committed first, +/// so the answer is shown not to depend on the order the cells are reached in. +async fn resumed_after_both_cells_committed_elsewhere(reverse: bool) { + let (_dir, verse) = Harness::fresh().await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + let graph = verse.mailbox.agency_graph().await.unwrap(); + let cover = derive_cover( + snapshot.world_id, + TickIndex(0), + &snapshot.opportunities, + &graph, + CoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + ); + assert_eq!(cover.cells.len(), 2); + let elsewhere = ScriptedPort::speaking(); + let runner = ControllerRunner::open( + verse.mailbox.clone(), + elsewhere.clone(), + verse.work.clone(), + models(), + ) + .unwrap(); + let order: Vec<&Cell> = if reverse { + cover.cells.iter().rev().collect() + } else { + cover.cells.iter().collect() + }; + for cell in order { + let fresh = verse.mailbox.opportunities().await.unwrap(); + let bound = refreshed(&FrozenCell::of(cell), TickIndex(0), &fresh).0.unwrap(); + runner.run_cell(&bound).await.unwrap(); + } + assert_eq!(verse.acts().await, 2); + + let resuming = ScriptedPort::speaking(); + let ran = verse + .run_quietly_under(resuming.clone(), config_of(1, 60, 2)) + .await; + assert_eq!(ran.record.status, RunStatus::Completed); + let [closing] = ran.record.checkpoints.as_slice() else { + panic!("the run closed one tick") + }; + assert!(closing.closed); + assert!(closing.refusals.is_empty(), "{closing:?}"); + assert_eq!(closing.committed.len(), 2, "{closing:?}"); + assert_eq!(resuming.calls(), 0, "a committed cell was asked again"); + assert_eq!(verse.acts().await, 2, "no act was committed twice"); +} + +#[tokio::test] +async fn committed_cells_are_answered_in_the_order_the_tick_froze() { + resumed_after_both_cells_committed_elsewhere(false).await; +} + +#[tokio::test] +async fn committed_cells_are_answered_against_the_order_the_tick_froze() { + resumed_after_both_cells_committed_elsewhere(true).await; +} + +#[tokio::test] +async fn recorded_run_replays_to_the_same_journal() { + let live_dir = tempfile::tempdir().unwrap(); + create_world(live_dir.path()).await; + let replay_dir = tempfile::tempdir().unwrap(); + std::fs::copy( + live_dir.path().join("world.cc"), + replay_dir.path().join("world.cc"), + ) + .unwrap(); + let live = Harness::open(live_dir.path()).await; + let replayed = Harness::open(replay_dir.path()).await; + + let provider = ScriptedPort::speaking(); + let recorded = live.run_quietly(provider.clone(), u64::MAX, 3).await; + assert_eq!(recorded.record.status, RunStatus::Completed); + + // The same run again, from the recorded replies and no provider. + let start = RunStart::of(&replayed.mailbox.snapshot().await.unwrap()); + let replay = Arc::new( + ReplayInferencePort::new("verse-replay", &live.calls, &call_run_id(1), &start).unwrap(), + ); + let again = replayed.run_quietly(replay, u64::MAX, 3).await; + assert_eq!(again.record.status, RunStatus::Completed); + assert_eq!(provider.calls(), 6, "replay made no provider call"); + + assert_eq!(again.record.checkpoints, recorded.record.checkpoints); + let live_world = live.mailbox.snapshot().await.unwrap(); + let replayed_world = replayed.mailbox.snapshot().await.unwrap(); + assert_eq!(replayed_world.revision, live_world.revision); + assert_eq!(replayed_world.state_digest, live_world.state_digest); + assert_eq!( + replayed.mailbox.operator_log().await.unwrap(), + live.mailbox.operator_log().await.unwrap() + ); + // Both stores hold the same calls, charged the same. + assert_eq!( + replayed.calls.records(&call_run_id(1)).unwrap(), + live.calls.records(&call_run_id(1)).unwrap() + ); +} + +#[tokio::test] +async fn a_run_the_caller_stops_ends_after_a_finished_tick() { + let (_dir, verse) = Harness::fresh().await; + let stopped = verse + .run(ScriptedPort::speaking(), u64::MAX, 3, |_| { + ControlFlow::Break(()) + }) + .await; + assert_eq!(stopped.record.status, RunStatus::Stopped); + assert_eq!(stopped.record.checkpoints.len(), 1); + assert_eq!(verse.now().await, u64::from(SPAN)); + + // A stop asked on the last tick is not a stop: the run was done. + let done = verse + .run(ScriptedPort::speaking(), u64::MAX, 1, |_| { + ControlFlow::Break(()) + }) + .await; + assert_eq!(done.record.status, RunStatus::Completed); +} + +#[tokio::test] +async fn a_pending_cell_ends_the_run_without_the_clock_and_a_later_run_finishes_it() { + let (_dir, verse) = Harness::fresh().await; + let flaky = ScriptedPort::speaking(); + flaky.fail_next(InferenceFault::retryable("the provider is busy")); + let ended = verse.run_quietly(flaky.clone(), u64::MAX, 2).await; + assert_eq!( + ended.record.status, + RunStatus::Failed { + reason: "inference_retryable".into() + } + ); + assert_eq!(ended.record.ticks_closed(), 0); + let [open] = ended.record.checkpoints.as_slice() else { + panic!("the open tick is recorded") + }; + assert!(!open.closed && open.committed.is_empty()); + assert_eq!(verse.now().await, 0); + assert_eq!(verse.acts().await, 0); + + let finished = verse + .run_quietly(ScriptedPort::speaking(), u64::MAX, 1) + .await; + assert_eq!(finished.record.status, RunStatus::Completed); + assert_eq!(verse.now().await, u64::from(SPAN)); + assert_eq!(verse.acts().await, 2); +} + +#[tokio::test] +async fn a_provider_answer_against_the_tool_contract_ends_the_run_without_the_clock() { + let (_dir, verse) = Harness::fresh().await; + // The faction acts and then calls finish_without_proposal, which + // contradicts its own choice. + let port = ScriptedPort::new(|request| { + let name = who(request); + vec![ + speak_call(&format!("{name} speaks.")), + InferenceEvent::ToolCall { + call_id: "finish".into(), + name: "finish_without_proposal".into(), + arguments: "{}".into(), + }, + ] + }); + let outcome = verse.run_quietly(port, u64::MAX, 1).await; + assert_eq!( + outcome.record.status, + RunStatus::Failed { + reason: "inference_recovery_required".into() + } + ); + assert_eq!(outcome.record.ticks_closed(), 0); + assert_eq!(verse.now().await, 0); +} + +#[tokio::test] +async fn a_cell_the_world_refuses_is_a_refusal_and_the_tick_closes() { + let (_dir, verse) = Harness::fresh().await; + // Adrasteia attacks something that is not in the world; Brannoch speaks. + let nothing = id_text(CommandId::new()); + let port = ScriptedPort::new(move |request| { + let name = who(request); + if name == "Adrasteia" { + vec![InferenceEvent::ToolCall { + call_id: "act".into(), + name: "attack".into(), + arguments: json!({ "target": nothing, "text": "Adrasteia strikes." }).to_string(), + }] + } else { + vec![speak_call("Brannoch speaks.")] + } + }); + let outcome = verse.run_quietly(port, u64::MAX, 1).await; + assert_eq!(outcome.record.status, RunStatus::Completed); + let [checkpoint] = outcome.record.checkpoints.as_slice() else { + panic!("one tick, one checkpoint") + }; + assert_eq!(checkpoint.cells, 2); + assert_eq!(checkpoint.committed.len(), 1); + assert_eq!(checkpoint.refusals.len(), 1); + assert_eq!(checkpoint.refusals[0].reason, RefusalReason::WorldRejected); + assert!(checkpoint.refusals[0].cell < 2); + assert_eq!(checkpoint.refusals[0].subject, verse.subject("Adrasteia").await); + assert_eq!(verse.now().await, u64::from(SPAN)); + assert_eq!(verse.acts().await, 1); +} + +#[test] +fn the_operator_reads_refusals_and_how_a_run_ended() { + let [one, two, three, four, five, six] = [(); 6].map(|()| crate::SubjectId::issue()); + let refusal = |cell, subject, reason| Refusal { + cell, + subject, + reason, + }; + let report = TickReport { + checkpoint: TickCheckpoint { + tick: 4, + closed: false, + cells: 3, + committed: Vec::new(), + refusals: vec![ + refusal(1, one, RefusalReason::WorldRejected), + refusal(2, two, RefusalReason::OpportunityGone), + refusal(0, three, RefusalReason::Interrupted), + refusal(5, four, RefusalReason::ScopeMoved), + refusal(3, five, RefusalReason::RowDisagreesWithCell), + refusal(3, six, RefusalReason::CallUnattributed), + ], + unattributed: vec![UnattributedCall { + cell: 3, + tool: "c9__speak".into(), + }], + tokens: 7, + }, + acts: Vec::new(), + labels: BTreeMap::from([ + (one, "Ada".to_owned()), + (two, "Brann".to_owned()), + (three, "Cato".to_owned()), + (four, "Dura".to_owned()), + (five, "Eve".to_owned()), + (six, "Fay".to_owned()), + ]), + spent: 40, + remaining: 60, + }; + assert_eq!( + report.to_string(), + concat!( + "tick 4 open: 3 cells, 0 acts, 6 refused\n", + " refused: cell 1 Ada world_rejected\n", + " refused: cell 2 Brann opportunity_gone\n", + " refused: cell 0 Cato interrupted\n", + " refused: cell 5 Dura scope_moved\n", + " refused: cell 3 Eve row_disagrees_with_cell\n", + " refused: cell 3 Fay call_unattributed\n", + " note: cell 3 call names no handle: tool \"c9__speak\"\n", + " tokens: 7 this tick, 40 spent, 60 remaining\n", + ) + ); + let ended: Vec = [ + RunStatus::Running, + RunStatus::Completed, + RunStatus::BudgetExhausted, + RunStatus::Failed { + reason: "clock_refused".into(), + }, + RunStatus::Stopped, + ] + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!( + ended, + ["Running", "Completed", "BudgetExhausted", "Failed (clock_refused)", "Stopped"] + ); +} + +#[tokio::test] +async fn an_act_names_its_verb_render_path_and_bound_roles() { + let (_dir, verse) = Harness::fresh().await; + let target = id_text(verse.subject("Brannoch").await); + let port = ScriptedPort::new(move |request| { + if who(request) == "Adrasteia" { + vec![InferenceEvent::ToolCall { + call_id: "act".into(), + name: "attack".into(), + arguments: json!({ "target": target, "text": "Adrasteia strikes." }).to_string(), + }] + } else { + vec![speak_call("Brannoch answers.")] + } + }); + let mut reports = Vec::new(); + let outcome = verse + .run(port, u64::MAX, 1, |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + assert_eq!(outcome.record.status, RunStatus::Completed); + let catalog = read_aetheria_catalog(FIXTURE).unwrap(); + let acts = &reports[0].acts; + let attack = acts.iter().find(|act| act.verb == "attack").unwrap(); + assert_eq!(attack.speaker_label, "Adrasteia"); + assert_eq!( + attack.render_path, + Some(catalog.grammar.verb("attack").unwrap().render_path) + ); + assert_eq!( + attack.roles, + vec![BoundRole { + role: "target".into(), + target: "Brannoch".into() + }] + ); + + // And the operator reads them so. + let name = |verb: &str| match catalog.grammar.verb(verb).unwrap().render_path { + RenderPath::ShipAction => "ship_action", + RenderPath::Conversation => "conversation", + }; + let text = reports[0].to_string(); + let checkpoint = &reports[0].checkpoint; + assert!( + text.starts_with("tick 0 closed: 2 cells, 2 acts, 0 refused\n"), + "{text}" + ); + let attack_line = format!( + "\n Adrasteia attack [{}] target=Brannoch says \"Adrasteia strikes.\"\n", + name("attack") + ); + assert!(text.contains(&attack_line), "{text}"); + let speak_line = format!( + "\n Brannoch speak [{}] says \"Brannoch answers.\"\n", + name("speak") + ); + assert!(text.contains(&speak_line), "{text}"); + let tokens_line = format!( + " tokens: {} this tick, {} spent, {} remaining\n", + checkpoint.tokens, reports[0].spent, reports[0].remaining + ); + assert!(text.ends_with(&tokens_line), "{text}"); + let speak = acts.iter().find(|act| act.verb == "speak").unwrap(); + assert_eq!(speak.speaker_label, "Brannoch"); + assert_eq!( + speak.render_path, + Some(catalog.grammar.verb("speak").unwrap().render_path) + ); + assert!(speak.roles.is_empty()); +} + +#[tokio::test] +async fn the_opportunity_door_answers_what_a_snapshot_carries() { + let (_dir, verse) = Harness::fresh().await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.opportunities.len(), 2); + assert_eq!( + verse.mailbox.opportunities().await.unwrap(), + snapshot.opportunities + ); +} + +#[tokio::test] +async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { + let (_dir, verse) = Harness::fresh().await; + let opportunities = verse.mailbox.opportunities().await.unwrap(); + let graph = verse.mailbox.agency_graph().await.unwrap(); + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let singles = derive_cover( + world, + TickIndex(0), + &opportunities, + &graph, + two_cells(), + ); + let group = derive_cover( + world, + TickIndex(0), + &opportunities, + &graph, + CoverBudget { + cells: 1, + constituent_cap: 8, + urgency_slots: 0, + }, + ); + let [grouped @ Cell::Group { members, .. }] = group.cells.as_slice() else { + panic!("one cell for two subjects is a group") + }; + assert_eq!(members.len(), 2); + + // Changed opportunities replace the ones the cover held. + let mut moved = opportunities.clone(); + for opportunity in &mut moved { + opportunity.revision += 1; + } + for cell in &singles.cells { + let Cell::Singleton { member: held, .. } = cell else { + panic!("two subjects under two cells are singletons") + }; + let frozen = FrozenCell::of(cell); + let (Some(Cell::Singleton { member, .. }), gone) = refreshed(&frozen, TickIndex(0), &moved) else { + panic!("a subject that still has an opportunity keeps its cell") + }; + assert_ne!(member.opportunity, held.opportunity); + assert!(moved.contains(&member.opportunity)); + // The member is bound to its own subject's opportunity, not a neighbour's. + assert_eq!(member.subject, held.subject); + assert_eq!(member.opportunity.scope.subject_id, member.subject); + assert!(gone.is_empty()); + } + let frozen_group = FrozenCell::of(grouped); + let (Some(Cell::Group { members: now, .. }), gone) = + refreshed(&frozen_group, TickIndex(0), &moved) + else { + panic!("a group keeps its members") + }; + assert!(gone.is_empty()); + assert_eq!(now.len(), 2); + assert!(now.iter().all(|member| moved.contains(&member.opportunity))); + assert!( + now.iter() + .all(|member| member.opportunity.scope.subject_id == member.subject) + ); + + // A subject with no opportunity leaves its cell, and an empty cell is gone. + let one = &opportunities[..1]; + let (Some(Cell::Group { members: left, .. }), left_out) = + refreshed(&frozen_group, TickIndex(0), one) + else { + panic!("one member remains") + }; + assert_eq!(left.len(), 1); + assert_eq!(left[0].opportunity.scope, one[0].scope); + let gone = singles + .cells + .iter() + .filter(|cell| refreshed(&FrozenCell::of(cell), TickIndex(0), one).0.is_none()) + .count(); + assert_eq!(gone, 1); + let (nothing, all_gone) = refreshed(&frozen_group, TickIndex(0), &[]); + assert!(nothing.is_none()); + assert_eq!(all_gone, frozen_group.members); + assert_eq!(left_out, vec![opportunities[1].scope.subject_id]); +} + +#[test] +fn an_error_ends_a_cell_as_the_run_must_treat_it() { + let budget = ControllerError::Inference { + purpose: InferencePurpose::OperationalAgent, + source: InferenceFault::budget_exhausted(), + }; + assert!(matches!(classify(&budget), CellEnd::Budget)); + // Any other fault that stops the cognition organ stops the run. + let corrupted = ControllerError::Inference { + purpose: InferencePurpose::OperationalAgent, + source: InferenceFault::integrity_violation("the reply cannot be trusted"), + }; + assert!(matches!( + classify(&corrupted), + CellEnd::Fatal("controller_quarantined") + )); + assert!(matches!( + classify(&ControllerError::Serialization("x".into())), + CellEnd::Fatal("controller_quarantined") + )); + // What the world or the subject's standing refused stays with its cell. + assert!(matches!( + classify(&ControllerError::World(KernelError::AffordanceDenied)), + CellEnd::Refused(RefusalReason::WorldRejected) + )); + for gone in [ + ControllerError::NoOpportunity { + expected: ControllerMode::OperationalAgent, + }, + ControllerError::AmbiguousOpportunity, + ControllerError::OpportunityMismatch, + ] { + assert!(matches!( + classify(&gone), + CellEnd::Refused(RefusalReason::OpportunityGone) + )); + } + assert!(matches!( + classify(&ControllerError::ScopeMoved), + CellEnd::Refused(RefusalReason::ScopeMoved) + )); + assert!(matches!( + classify(&ControllerError::CommandMismatch), + CellEnd::Fatal("controller_error") + )); +} + +/// One fact, one label: the world refusing a body whose scope moved is +/// `ScopeMoved`, the same label the controller's own refusal of in-flight work +/// carries, and not the catch-all `WorldRejected`. +#[tokio::test] +async fn a_world_refusal_for_a_moved_scope_is_scope_moved() { + let (_dir, verse) = Harness::fresh().await; + let opportunity = verse.mailbox.snapshot().await.unwrap().opportunities[0].clone(); + let moved = ControllerError::World(KernelError::ScopeChanged { + scope: opportunity.scope.clone(), + expected: opportunity.scope_digest.clone(), + actual: opportunity.scope_digest.clone(), + }); + assert!(matches!( + classify(&moved), + CellEnd::Refused(RefusalReason::ScopeMoved) + )); + assert!(matches!( + classify(&ControllerError::World(KernelError::AffordanceDenied)), + CellEnd::Refused(RefusalReason::WorldRejected) + )); +} + +/// A recording port over the harness's call store for `sequence` under `cap`, +/// and a runner on it. +fn port_and_runner( + verse: &Harness, + world: &WorldSnapshot, + sequence: u64, + cap: u64, +) -> (Arc, ControllerRunner) { + let port = Arc::new( + RecordingInferencePort::open( + ScriptedPort::speaking(), + verse.calls.clone(), + call_run_id(sequence), + cap, + &RunStart::of(world), + ) + .unwrap(), + ); + let runner = ControllerRunner::open( + verse.mailbox.clone(), + port.clone(), + verse.work.clone(), + models(), + ) + .unwrap(); + (port, runner) +} + +/// Runs `claim` on the given port and runner, which must refuse it. +async fn refused( + verse: &Harness, + port: &RecordingInferencePort, + runner: &ControllerRunner, + claim: RunClaim, + config: &VerseRunConfig, +) -> VerseRunError { + run_manual( + &verse.mailbox, + runner, + port, + &verse.runs, + claim, + config, + |_| ControlFlow::Continue(()), + ) + .await + .unwrap_err() +} + +#[tokio::test] +async fn a_run_started_on_the_wrong_port_world_or_config_is_refused_and_its_claim_ends_failed() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let (_other_dir, elsewhere) = Harness::fresh().await; + let strange = elsewhere.mailbox.snapshot().await.unwrap(); + + // A cover budget with no cells cannot be claimed: no record exists. + let mut bad = config(1); + bad.cover.cells = 0; + assert!(matches!( + verse.runs.claim(&world, 1_000, &bad), + Err(VerseRunError::Budget(CoverBudgetError::NoCells)) + )); + assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); + + let mut expected: Vec<(u64, RunStatus)> = Vec::new(); + + // A port opened for another run's number. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + expected.push((claim.sequence(), failed("port_not_this_run"))); + let (port, runner) = port_and_runner(&verse, &world, 70, 1_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::PortNotThisRun)); + + // A port under a cap other than the claim recorded, either side of it. + for port_cap in [999, 1_001] { + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + expected.push((claim.sequence(), failed("port_not_this_run"))); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), port_cap); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::PortNotThisRun), "cap {port_cap}"); + } + + // A config differing from the claim's in any one field. + let differing: Vec<(&str, VerseRunConfig)> = vec![ + ("ticks", config(2)), + ("tick span", VerseRunConfig { + tick_span: TickMinutes::new(SPAN + 1).unwrap(), + ..config(1) + }), + ("cells", config_of(1, SPAN, 3)), + ("constituent cap", VerseRunConfig { + cover: CoverBudget { constituent_cap: 9, ..two_cells() }, + ..config(1) + }), + ("urgency slots", VerseRunConfig { + cover: CoverBudget { urgency_slots: 1, ..two_cells() }, + ..config(1) + }), + ]; + for (field, other) in &differing { + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + expected.push((claim.sequence(), failed("config_not_this_claim"))); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), 1_000); + let error = refused(&verse, &port, &runner, claim, other).await; + assert!(matches!(error, VerseRunError::ConfigNotThisClaim), "{field}"); + } + + // A claim made from a world revision ahead of the one the run starts from. + let mut ahead = world.clone(); + ahead.revision += 3; + let claim = verse.runs.claim(&ahead, 1_000, &config(1)).unwrap(); + expected.push((claim.sequence(), failed("world_moved_since_claim"))); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), 1_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::WorldMovedSinceClaim), "ahead"); + + // Another world's claim 1, on this world's mailbox and a port that fits it. + let claim = verse.runs.claim(&strange, 1_000, &config(1)).unwrap(); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), 1_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::PortNotThisRun)); + + let statuses = |world: &WorldSnapshot| -> Vec<(u64, RunStatus)> { + verse + .runs + .runs(world.world_id) + .unwrap() + .into_iter() + .map(|record| (record.sequence, record.status)) + .collect() + }; + assert_eq!(statuses(&world), expected); + assert_eq!(statuses(&strange), vec![(1, failed("port_not_this_run"))]); + assert!(verse.runs.tick(world.world_id, 0).unwrap().is_none()); + assert!(verse.runs.tick(strange.world_id, 0).unwrap().is_none()); + assert_eq!(verse.mailbox.operator_log().await.unwrap().len(), 0); +} + +/// The record names the revision the run started from: a claim made from a +/// world the run no longer starts from is refused and ends Failed. +#[tokio::test] +async fn a_claim_from_a_world_that_has_since_moved_is_refused() { + let (_dir, verse) = Harness::fresh().await; + let stale = verse.mailbox.snapshot().await.unwrap(); + verse + .run_quietly(ScriptedPort::speaking(), 1_000_000, 1) + .await; + let moved = verse.mailbox.snapshot().await.unwrap(); + assert!(moved.revision > stale.revision); + + let claim = verse.runs.claim(&stale, 1_000, &config(1)).unwrap(); + let sequence = claim.sequence(); + let (port, runner) = port_and_runner(&verse, &moved, sequence, 1_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::WorldMovedSinceClaim)); + let runs = verse.runs.runs(moved.world_id).unwrap(); + assert_eq!(runs[1].sequence, sequence); + assert_eq!(runs[1].status, failed("world_moved_since_claim")); + assert!(runs[1].checkpoints.is_empty()); + + // A claim from the world as it stands is run, and records that revision. + let outcome = verse + .run_quietly(ScriptedPort::speaking(), 1_000_000, 1) + .await; + assert_eq!(outcome.record.start_revision, moved.revision); +} + +fn record(world: WorldId, sequence: u64) -> VerseRunRecord { + VerseRunRecord { + world_id: world, + sequence, + token_cap: 10, + ticks_requested: 1, + tick_span_minutes: SPAN, + cover_budget: RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + start_revision: 0, + checkpoints: Vec::new(), + status: RunStatus::Running, + } +} + +#[tokio::test] +async fn the_run_store_claims_runs_per_world_and_replaces_a_claimed_run_with_its_later_state() { + let (_one_dir, one) = Harness::fresh().await; + let (_other_dir, other) = Harness::fresh().await; + let one = one.mailbox.snapshot().await.unwrap(); + let other = other.mailbox.snapshot().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runs.cc"); + let store = VerseRunStore::open(&path).unwrap(); + + // Claims number runs from one, per world, and write the Running record. + let first = store.claim(&one, 10, &config(3)).unwrap(); + let second = store.claim(&one, 20, &config(1)).unwrap(); + let elsewhere = store.claim(&other, 10, &config(1)).unwrap(); + assert_eq!( + (first.sequence(), second.sequence(), elsewhere.sequence()), + (1, 2, 1) + ); + let claimed = store.runs(one.world_id).unwrap(); + assert_eq!(claimed.len(), 2); + assert_eq!(claimed[0].token_cap, 10); + assert_eq!(claimed[0].ticks_requested, 3); + assert_eq!(claimed[1].token_cap, 20); + assert_eq!(claimed[0].start_revision, one.revision); + assert_eq!(claimed[0].status, RunStatus::Running); + + // A record is only ever a claimed one: a sequence with no record is refused. + assert!(matches!( + store.save(&record(one.world_id, 3)), + Err(VerseRunError::OutOfSequence) + )); + assert!(matches!( + store.save(&record(one.world_id, 0)), + Err(VerseRunError::OutOfSequence) + )); + assert_eq!(store.runs(one.world_id).unwrap().len(), 2); + + // Saving a claimed run again replaces it, and the file reopens to the + // same rows. + let mut finished = claimed[0].clone(); + finished.status = RunStatus::Completed; + store.save(&finished).unwrap(); + drop(store); + let store = VerseRunStore::open(&path).unwrap(); + assert_eq!( + store.runs(one.world_id).unwrap(), + vec![finished, claimed[1].clone()] + ); + assert_eq!(store.runs(other.world_id).unwrap().len(), 1); +} + +#[tokio::test] +async fn a_claim_is_never_reissued_even_when_its_run_never_started() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + + // Run 1 is claimed, then nothing happens: the process died. + let lost = verse.runs.claim(&world, 1_000_000_000, &config(1)).unwrap(); + assert_eq!(lost.sequence(), 1); + let before = verse.runs.runs(world.world_id).unwrap(); + drop(lost); + + // The next run is run 2 and leaves run 1's record exactly as it was. + let outcome = verse + .run_quietly(ScriptedPort::speaking(), 1_000_000_000, 1) + .await; + assert_eq!(outcome.record.sequence, 2); + assert_eq!(outcome.record.status, RunStatus::Completed); + let after = verse.runs.runs(world.world_id).unwrap(); + assert_eq!(after.len(), 2); + assert_eq!(after[0], before[0], "run 1's record was overwritten"); + assert_eq!(after[0].status, RunStatus::Running); + assert_eq!(after[1], outcome.record); +} + +/// The store's insert-if-absent, not a read before it, refuses a second record +/// of one sequence: concurrent claims on one handle read the same next number. +#[tokio::test] +async fn two_claims_of_one_sequence_cannot_both_succeed() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let claim = verse.runs.claim(&world, 10, &config(1)).unwrap(); + let held = verse.runs.runs(world.world_id).unwrap(); + + let mut colliding = record(world.world_id, claim.sequence()); + colliding.token_cap = 99; + assert!(matches!( + verse.runs.insert_run(&colliding), + Err(VerseRunError::OutOfSequence) + )); + assert_eq!(verse.runs.runs(world.world_id).unwrap(), held); +} + +#[tokio::test] +async fn a_start_refused_after_the_claim_leaves_the_next_run_free_to_choose_its_cap() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + + // Run 1 is claimed and its call ledger opens under a cap, then the start + // is refused before the run begins. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + let ledger = RecordingInferencePort::open( + ScriptedPort::speaking(), + verse.calls.clone(), + call_run_id(claim.sequence()), + 1_000, + &RunStart::of(&world), + ) + .unwrap(); + drop(ledger); + verse.runs.abandon(claim, "start_refused").unwrap(); + + // The operator tries again under another cap: it is a new run, and runs. + let outcome = verse + .run_quietly(ScriptedPort::speaking(), 50_000, 1) + .await; + assert_eq!(outcome.record.sequence, 2); + assert_eq!(outcome.record.token_cap, 50_000); + assert_eq!(outcome.record.status, RunStatus::Completed); + let runs = verse.runs.runs(world.world_id).unwrap(); + assert_eq!(runs[0].status, failed("start_refused")); + assert_eq!(runs[0].token_cap, 1_000); +} + +#[tokio::test] +async fn an_unreadable_operator_log_is_no_tick_report() { + let (_dir, verse) = Harness::fresh().await; + verse.run_quietly(ScriptedPort::speaking(), u64::MAX, 2).await; + let log = verse.mailbox.operator_log().await.unwrap(); + assert!(log.len() >= 4, "two ticks of two cells acted"); + + // A log that cannot be read is no tick report, whatever the revision. + assert!(acts_since(Err(MailboxError::Unavailable), 0).is_none()); + assert!(acts_since(Err(MailboxError::Unavailable), u64::MAX).is_none()); + + // A log that can is cut at the tick's opening revision. + assert_eq!(acts_since(Ok(log.clone()), 0), Some(log.clone())); + let middle = log[1].revision; + let later: Vec<_> = log.iter().filter(|e| e.revision > middle).cloned().collect(); + assert!(!later.is_empty() && later.len() < log.len()); + assert_eq!(acts_since(Ok(log.clone()), middle), Some(later)); + assert_eq!( + acts_since(Ok(log), u64::MAX), + Some(Vec::new()), + "an empty tick is a readable one" + ); +} + +/// A tick whose operator log cannot be read is not closed: the cells' work is +/// checkpointed, the clock does not advance, and the run ends Failed. +#[tokio::test] +async fn an_unreadable_operator_log_ends_the_run_with_no_clock_advance() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let (port, runner) = port_and_runner(&verse, &world, 1, u64::MAX); + let config = config(1); + let (end, report) = run_tick( + &verse.mailbox, + &runner, + &port, + &verse.runs, + 1, + &config, + config.cover.validated().unwrap(), + 0, + || async { Err(MailboxError::Unavailable) }, + ) + .await; + + assert!( + matches!(&end, TickEnd::Open(status) if *status == failed("operator_log_unavailable")) + ); + let report = report.expect("the tick's checkpoint is kept"); + assert!(!report.checkpoint.closed); + assert!(!report.checkpoint.committed.is_empty(), "the cells ran"); + assert!(report.acts.is_empty()); + assert_eq!(verse.mailbox.clock_advances().await.unwrap(), 0); + assert_eq!(verse.now().await, world.now.0); + let tick = verse.runs.tick(world.world_id, 0).unwrap().unwrap(); + assert!(tick.closed.is_none()); +} + +#[test] +fn the_run_store_refuses_rows_that_are_not_canonical_run_records() { + let dir = tempfile::tempdir().unwrap(); + let world = WorldId::nil_for_test(); + let good = record(world, 1); + let envelope = |key: String, schema: &str, payload: Vec| CultCacheEnvelope { + key, + r#type: RUN_ROW.into(), + payload, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(schema.into()), + }; + let named = rmp_serde::to_vec_named(&good).unwrap(); + let cases = [ + // Another schema version is not migrated. + envelope(row_key(world, 1), "ghostlight.verse_run.v2", named.clone()), + // A row under a key that is not its own. + envelope(row_key(world, 9), RUN_RECORD_SCHEMA, named.clone()), + // A payload that is not the record. + envelope(row_key(world, 1), RUN_RECORD_SCHEMA, vec![0xc0]), + // The record in a non-canonical encoding. + envelope( + row_key(world, 1), + RUN_RECORD_SCHEMA, + rmp_serde::to_vec(&good).unwrap(), + ), + // A sequence of zero. + envelope( + row_key(world, 0), + RUN_RECORD_SCHEMA, + rmp_serde::to_vec_named(&record(world, 0)).unwrap(), + ), + ]; + for (index, row) in cases.into_iter().enumerate() { + let file = dir.path().join(format!("case-{index}.cc")); + { + let mut raw = OwnedRedbMessagePackBackingStore::new(&file).unwrap(); + raw.push(&row).unwrap(); + } + assert!( + matches!(VerseRunStore::open(&file), Err(VerseRunError::Corrupt)), + "case {index} was accepted" + ); + } +} + +/// A port that answers like `ScriptedPort::speaking` and fails the `at`th +/// provider call it sees, retryably. +struct FaultAt { + inner: Arc, + at: usize, + seen: AtomicUsize, +} + +#[async_trait] +impl InferencePort for FaultAt { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + if self.seen.fetch_add(1, Ordering::SeqCst) == self.at { + return Err(InferenceFault::retryable("the provider is busy")); + } + self.inner.infer(request).await + } +} + +fn fault_at(at: usize) -> Arc { + Arc::new(FaultAt { + inner: ScriptedPort::speaking(), + at, + seen: AtomicUsize::new(0), + }) +} + +fn config_of(ticks: u32, span: u32, cells: u16) -> VerseRunConfig { + VerseRunConfig { + ticks, + tick_span: TickMinutes::new(span).unwrap(), + cover: CoverBudget { + cells, + constituent_cap: 8, + urgency_slots: 0, + }, + } +} + +/// Run 1 closes tick 0 (two cells, 60 minutes). Run 2 commits one faction's act +/// in tick 1 and ends pending. Run 3 resumes under `resume`. Asserts that each +/// faction acted exactly once between the end of run 1 and the end of run 3. +async fn resumed_after_a_pending_cell( + resume: VerseRunConfig, +) -> (tempfile::TempDir, Harness, RunOutcome) { + let (dir, verse) = Harness::fresh().await; + let first = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(1, 60, 2)) + .await; + assert_eq!(first.record.status, RunStatus::Completed); + let mark = verse.mailbox.snapshot().await.unwrap().revision; + + let ended = verse + .run_quietly_under(fault_at(1), config_of(1, 60, 2)) + .await; + assert_eq!( + ended.record.status, + RunStatus::Failed { + reason: "inference_retryable".into() + } + ); + let opened: Vec<_> = verse + .mailbox + .operator_log() + .await + .unwrap() + .into_iter() + .filter(|event| event.revision > mark) + .map(|event| event.speaker) + .collect(); + assert_eq!(opened.len(), 1, "one faction acted in the open tick"); + + let resumed = verse + .run_quietly_under(ScriptedPort::speaking(), resume) + .await; + assert_eq!(resumed.record.status, RunStatus::Completed); + let since: Vec<_> = verse + .mailbox + .operator_log() + .await + .unwrap() + .into_iter() + .filter(|event| event.revision > mark) + .map(|event| event.speaker) + .collect(); + assert_eq!(since.len(), 2, "each faction acted once in the open tick"); + assert_eq!( + since.iter().filter(|speaker| **speaker == opened[0]).count(), + 1, + "the faction that acted before the pause did not act again" + ); + (dir, verse, resumed) +} + +#[tokio::test] +async fn a_resume_under_another_span_finishes_the_open_tick_under_its_own() { + let (_dir, verse, resumed) = resumed_after_a_pending_cell(config_of(1, 30, 2)).await; + // Tick 1's clock advance is the frozen 60 minutes, not the resuming 30. + assert_eq!(verse.now().await, 120); + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let tick = verse.runs.tick(world, 1).unwrap().unwrap(); + assert_eq!(tick.span_minutes, 60); + assert_eq!(tick.opened_by_run, 2); + assert_eq!(tick.closed, Some(TickClose::Clock { run: 3 })); + let [closing] = resumed.record.checkpoints.as_slice() else { + panic!("the resuming run closed one tick") + }; + assert_eq!(closing.tick, 1); + assert!(closing.closed); +} + +#[tokio::test] +async fn a_resume_under_another_cover_budget_keeps_the_frozen_cells() { + // One cell would be a group of both factions; the open tick keeps its two + // singletons. + let (_dir, verse, resumed) = resumed_after_a_pending_cell(config_of(1, 60, 1)).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let tick = verse.runs.tick(world, 1).unwrap().unwrap(); + assert_eq!(tick.cover_budget.cells, 2); + assert_eq!(tick.cells.len(), 2); + assert!(tick.cells.iter().all(|cell| !cell.group)); + assert_eq!(resumed.record.checkpoints[0].cells, 2); + assert_eq!(verse.now().await, 120); +} + +#[tokio::test] +async fn a_new_span_opens_a_new_tick_and_never_reuses_a_closed_one() { + let (_dir, verse) = Harness::fresh().await; + let first = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(2, 60, 2)) + .await; + assert_eq!(first.record.status, RunStatus::Completed); + assert_eq!(verse.now().await, 120); + let acts = verse.acts().await; + + let port = ScriptedPort::speaking(); + let second = verse + .run_quietly_under(port.clone(), config_of(2, 120, 2)) + .await; + assert_eq!(second.record.status, RunStatus::Completed); + let ordinals: Vec = second.record.checkpoints.iter().map(|c| c.tick).collect(); + assert_eq!(ordinals, vec![2, 3]); + assert_eq!(verse.now().await, 120 + 240); + assert_eq!(verse.acts().await, acts + 4); + assert_eq!(port.calls(), 4); + let before: Vec = first + .record + .checkpoints + .iter() + .flat_map(|c| c.committed.clone()) + .collect(); + for checkpoint in &second.record.checkpoints { + assert!(checkpoint.committed.iter().all(|id| !before.contains(id))); + } +} + +#[tokio::test] +async fn a_closed_ticks_clock_is_answered_from_the_kernels_ledger() { + let (_dir, verse) = Harness::fresh().await; + verse.run_quietly(ScriptedPort::speaking(), u64::MAX, 2).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + assert_eq!(verse.now().await, 120); + for ordinal in 0..2 { + let receipt = verse + .mailbox + .submit_clock( + clock_command_id(world, ordinal), + TickMinutes::new(SPAN).unwrap(), + ) + .await + .unwrap(); + assert!( + matches!(receipt, SubmitReceipt::AlreadyApplied(_)), + "the clock of tick {ordinal} was not the id the run submitted" + ); + } + assert_eq!(verse.now().await, 120); +} + +#[tokio::test] +async fn an_open_tick_the_world_moved_past_is_closed_by_the_world() { + let (_probe_dir, probe) = Harness::fresh().await; + let measuring = ScriptedPort::speaking(); + probe.run_quietly(measuring.clone(), u64::MAX, 1).await; + let first_bound = measuring.bounds()[0]; + + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let ended = verse + .run_under( + ScriptedPort::speaking(), + first_bound + 64, + config(1), + |_| ControlFlow::Continue(()), + ) + .await; + assert_eq!(ended.record.status, RunStatus::BudgetExhausted); + assert_eq!(verse.acts().await, 1); + // Another writer moves time without closing the tick. + verse + .mailbox + .submit_clock(CommandId::new(), TickMinutes::new(SPAN).unwrap()) + .await + .unwrap(); + assert_eq!(verse.now().await, 60); + + let resumed = verse + .run_quietly(ScriptedPort::speaking(), u64::MAX, 1) + .await; + assert_eq!(resumed.record.status, RunStatus::Completed); + let abandoned = verse.runs.tick(world, 0).unwrap().unwrap(); + assert_eq!(abandoned.closed, Some(TickClose::World)); + let next = verse.runs.tick(world, 1).unwrap().unwrap(); + assert_eq!(next.opened_by_run, 2); + assert_eq!(next.closed, Some(TickClose::Clock { run: 2 })); + let [checkpoint] = resumed.record.checkpoints.as_slice() else { + panic!("one tick, one checkpoint") + }; + assert_eq!(checkpoint.tick, 1); + assert_eq!(checkpoint.committed.len(), 2, "a fresh cover ran both factions"); + assert_eq!(verse.acts().await, 3); + assert_eq!(verse.now().await, 120); + // No faction has two acts in one ordinal: ordinal 1 opened after run 1's + // act, so its acts are the ones past the tick's opening revision. + let log = verse.mailbox.operator_log().await.unwrap(); + for speaker in log.iter().map(|event| event.speaker) { + let in_first = log + .iter() + .filter(|event| event.speaker == speaker && event.revision <= next.opened_revision) + .count(); + let in_second = log + .iter() + .filter(|event| event.speaker == speaker && event.revision > next.opened_revision) + .count(); + assert!(in_first <= 1 && in_second <= 1); + } +} + +fn tick_doc(world: WorldId, ordinal: u64) -> VerseTick { + VerseTick { + world_id: world, + ordinal, + opened_now: 0, + opened_revision: 3, + span_minutes: SPAN, + cover_budget: RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + oversubscribed: false, + cells: Vec::new(), + opened_by_run: 1, + closed: None, + } +} + +#[test] +fn the_tick_store_opens_once_closes_once_and_refuses_non_canonical_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runs.cc"); + let store = VerseRunStore::open(&path).unwrap(); + let world = WorldId::nil_for_test(); + let other = WorldId::issue(); + + store.open_tick(&tick_doc(world, 0)).unwrap(); + assert!(matches!( + store.open_tick(&tick_doc(world, 0)), + Err(VerseRunError::OutOfSequence) + )); + // A tick opened already closed is not an open. + let mut born_closed = tick_doc(world, 5); + born_closed.closed = Some(TickClose::World); + assert!(matches!( + store.open_tick(&born_closed), + Err(VerseRunError::OutOfSequence) + )); + assert!(matches!( + store.close_tick(world, 9, TickClose::World), + Err(VerseRunError::OutOfSequence) + )); + store + .close_tick(world, 0, TickClose::Clock { run: 4 }) + .unwrap(); + assert!(matches!( + store.close_tick(world, 0, TickClose::World), + Err(VerseRunError::OutOfSequence) + )); + + // The world closes the open ticks below an ordinal and no others. + for ordinal in 1..4 { + store.open_tick(&tick_doc(world, ordinal)).unwrap(); + } + store.open_tick(&tick_doc(other, 1)).unwrap(); + store.close_ticks_before(world, 3).unwrap(); + let closed_by = |store: &VerseRunStore, world, ordinal| { + store.tick(world, ordinal).unwrap().unwrap().closed + }; + assert_eq!(closed_by(&store, world, 0), Some(TickClose::Clock { run: 4 })); + assert_eq!(closed_by(&store, world, 1), Some(TickClose::World)); + assert_eq!(closed_by(&store, world, 2), Some(TickClose::World)); + assert_eq!(closed_by(&store, world, 3), None); + assert_eq!(closed_by(&store, other, 1), None); + assert!(store.tick(world, 7).unwrap().is_none()); + + // Tick documents and run records share the file and reopen intact. + store + .store + .lock() + .unwrap() + .push(&run_row(&record(world, 1)).unwrap()) + .unwrap(); + drop(store); + let store = VerseRunStore::open(&path).unwrap(); + assert_eq!(store.runs(world).unwrap(), vec![record(world, 1)]); + assert_eq!(closed_by(&store, world, 2), Some(TickClose::World)); + + let good = tick_doc(world, 0); + let envelope = |key: String, row_type: &str, schema: &str, payload: Vec| CultCacheEnvelope { + key, + r#type: row_type.into(), + payload, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(schema.into()), + }; + let named = rmp_serde::to_vec_named(&good).unwrap(); + let cases = [ + envelope(tick_key(world, 9), TICK_ROW, TICK_RECORD_SCHEMA, named.clone()), + envelope(tick_key(world, 0), TICK_ROW, "ghostlight.verse_tick.v2", named.clone()), + envelope(tick_key(world, 0), TICK_ROW, TICK_RECORD_SCHEMA, vec![0xc0]), + envelope( + tick_key(world, 0), + TICK_ROW, + TICK_RECORD_SCHEMA, + rmp_serde::to_vec(&good).unwrap(), + ), + envelope(tick_key(world, 0), "something_else.v1", TICK_RECORD_SCHEMA, named), + ]; + for (index, row) in cases.into_iter().enumerate() { + let file = dir.path().join(format!("tick-case-{index}.cc")); + { + let mut raw = OwnedRedbMessagePackBackingStore::new(&file).unwrap(); + raw.push(&row).unwrap(); + } + assert!( + matches!(VerseRunStore::open(&file), Err(VerseRunError::Corrupt)), + "tick case {index} was accepted" + ); + } +} + +/// A restored world keeps its run store: the closed tick document at the +/// world's own ordinal is refused before any provider call or act. +#[tokio::test] +async fn a_closed_tick_at_the_worlds_current_ordinal_is_refused_before_any_cell() { + let live = tempfile::tempdir().unwrap(); + create_world(live.path()).await; + let backup = live.path().join("world.backup"); + std::fs::copy(live.path().join("world.cc"), &backup).unwrap(); + { + let verse = Harness::open(live.path()).await; + let ran = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(2, 60, 2)) + .await; + assert_eq!(ran.record.status, RunStatus::Completed); + let Harness { + mailbox, + _task, + calls, + runs, + work, + } = verse; + drop((calls, runs, work, mailbox)); + _task.await.unwrap(); + } + let restored = tempfile::tempdir().unwrap(); + std::fs::copy(&backup, restored.path().join("world.cc")).unwrap(); + std::fs::copy(live.path().join("runs.cc"), restored.path().join("runs.cc")).unwrap(); + let verse = Harness::open(restored.path()).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + assert_eq!(verse.mailbox.clock_advances().await.unwrap(), 0); + assert!(verse.runs.tick(world, 0).unwrap().unwrap().closed.is_some()); + let acts = verse.acts().await; + + let port = ScriptedPort::speaking(); + let refused = verse.run_quietly_under(port.clone(), config_of(1, 60, 2)).await; + assert_eq!( + refused.record.status, + RunStatus::Failed { + reason: "run_store".into() + } + ); + assert_eq!(port.calls(), 0, "no provider call before the refusal"); + assert_eq!(verse.acts().await, acts); + assert_eq!(verse.now().await, 0); +} + +/// A port that moves the world's clock through another writer on its first +/// call, then answers like the wrapped port. +struct ClockMovedMid { + inner: Arc, + mailbox: WorldMailbox, + seen: AtomicUsize, + minutes: u32, +} + +#[async_trait] +impl InferencePort for ClockMovedMid { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + if self.seen.fetch_add(1, Ordering::SeqCst) == 0 { + self.mailbox + .submit_clock(CommandId::new(), TickMinutes::new(self.minutes).unwrap()) + .await + .unwrap(); + } + self.inner.infer(request).await + } +} + +/// The ordinal counts every clock advance the world committed, whatever its +/// length: a 45-minute advance by another writer inside an open tick moves +/// it, so the next tick opens at ordinal 2 and ordinal 1 never has a document. +#[tokio::test] +async fn every_clock_advance_moves_the_ordinal_whatever_its_length() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let port = Arc::new(ClockMovedMid { + inner: ScriptedPort::speaking(), + mailbox: verse.mailbox.clone(), + seen: AtomicUsize::new(0), + minutes: 45, + }); + let first = verse.run_quietly_under(port, config_of(1, 60, 2)).await; + assert_eq!(first.record.status, RunStatus::Completed); + assert_eq!(verse.mailbox.clock_advances().await.unwrap(), 2); + assert_eq!( + verse.runs.tick(world, 0).unwrap().unwrap().closed, + Some(TickClose::Clock { run: 1 }) + ); + + let second = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(1, 60, 2)) + .await; + assert_eq!(second.record.status, RunStatus::Completed); + assert_eq!(second.record.checkpoints[0].tick, 2); + assert!(verse.runs.tick(world, 1).unwrap().is_none()); + assert_eq!(verse.now().await, 45 + 60 + 60); +} + +/// Two opens of one ordinal cannot both succeed, even raced. +#[test] +fn two_racing_opens_of_one_tick_cannot_both_succeed() { + let dir = tempfile::tempdir().unwrap(); + let store = VerseRunStore::open(dir.path().join("runs.cc")).unwrap(); + let world = WorldId::nil_for_test(); + for ordinal in 0..100u64 { + let barrier = std::sync::Barrier::new(2); + let opened = std::thread::scope(|scope| { + let handles: Vec<_> = (1..=2u64) + .map(|run| { + let (store, barrier) = (&store, &barrier); + scope.spawn(move || { + let mut doc = tick_doc(world, ordinal); + doc.opened_by_run = run; + barrier.wait(); + store.open_tick(&doc).is_ok() + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .filter(|ok| *ok) + .count() + }); + assert_eq!(opened, 1, "ordinal {ordinal} was opened {opened} times"); + } +} + + +/// A reply for the one-cell cover: every constituent the request offers +/// speaks, so the grouped cell gets its one inference answered for both. +fn grouped_reply(request: &str) -> Vec { + let handles: Vec = (0..8) + .filter(|i| request.contains(&format!("c{i}__speak"))) + .collect(); + assert!(!handles.is_empty(), "a one-cell cover offers grouped handles"); + handles + .into_iter() + .map(|i| InferenceEvent::ToolCall { + call_id: format!("act{i}"), + name: format!("c{i}__speak"), + arguments: json!({ "text": format!("Member {i} speaks.") }).to_string(), + }) + .collect() +} + +fn one_cell(ticks: u32) -> VerseRunConfig { + VerseRunConfig { + ticks, + tick_span: TickMinutes::new(SPAN).unwrap(), + cover: CoverBudget { + cells: 1, + constituent_cap: 8, + urgency_slots: 0, + }, + } +} + +/// Runs the grouped cover for `ticks`, returning the verse, the outcome and +/// every report the operator saw. +async fn grouped_run(ticks: u32) -> (tempfile::TempDir, Harness, RunOutcome, Vec) { + let (dir, verse) = Harness::fresh().await; + let mut reports = Vec::new(); + let outcome = verse + .run_under( + ScriptedPort::new(grouped_reply), + u64::MAX, + one_cell(ticks), + |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }, + ) + .await; + (dir, verse, outcome, reports) +} + +/// Every member of every frozen cell of a closed tick is, once, either a +/// subject whose command id the checkpoint holds or a subject it refused. +async fn assert_every_member_accounted_for(verse: &Harness, outcome: &RunOutcome) { + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let closed: Vec<_> = outcome.record.checkpoints.iter().filter(|c| c.closed).collect(); + assert!(!closed.is_empty(), "a run with no closed tick proves nothing"); + for checkpoint in closed { + let tick = verse.runs.tick(world, checkpoint.tick).unwrap().unwrap(); + let mut members = 0; + for cell in &tick.cells { + for subject in &cell.members { + members += 1; + let command = CommandId::for_cell_constituent( + world, + cell.id, + *subject, + TickIndex(checkpoint.tick), + ); + let committed = checkpoint.committed.iter().filter(|c| **c == command).count(); + let refused = checkpoint + .refusals + .iter() + .filter(|r| r.subject == *subject) + .count(); + assert_eq!(committed + refused, 1, "tick {} member", checkpoint.tick); + } + } + assert_eq!(checkpoint.committed.len() + checkpoint.refusals.len(), members); + } +} + +#[tokio::test] +async fn a_grouped_cell_records_every_member_it_could_not_commit() { + let (_dir, verse, outcome, reports) = grouped_run(2).await; + assert_eq!(outcome.record.status, RunStatus::Completed); + assert_eq!(reports.len(), 2); + let world = verse.mailbox.snapshot().await.unwrap(); + for (checkpoint, report) in outcome.record.checkpoints.iter().zip(&reports) { + assert!(checkpoint.closed); + assert_eq!(checkpoint.cells, 1); + assert_eq!(checkpoint.committed.len(), 1); + let [refusal] = checkpoint.refusals.as_slice() else { + panic!("one member of two is refused") + }; + assert_eq!(refusal.reason, RefusalReason::ScopeMoved); + let label = &world + .subjects + .iter() + .find(|subject| subject.id == refusal.subject) + .unwrap() + .label; + assert!( + report + .to_string() + .contains(&format!("refused: cell 0 {label} scope_moved")), + "the operator reads who was refused" + ); + } + assert_every_member_accounted_for(&verse, &outcome).await; +} + +#[tokio::test] +async fn every_member_of_a_closed_tick_is_committed_or_refused_once() { + // Singletons, all committing. + let (_dir, verse) = Harness::fresh().await; + let outcome = verse.run_quietly(ScriptedPort::speaking(), u64::MAX, 2).await; + assert_every_member_accounted_for(&verse, &outcome).await; + + // Singletons, one refused by the world. + let (_dir, verse) = Harness::fresh().await; + let nothing = id_text(CommandId::new()); + let port = ScriptedPort::new(move |request| { + if who(request) == "Adrasteia" { + vec![InferenceEvent::ToolCall { + call_id: "act".into(), + name: "attack".into(), + arguments: json!({ "target": nothing, "text": "Adrasteia strikes." }).to_string(), + }] + } else { + vec![speak_call("Brannoch speaks.")] + } + }); + let outcome = verse.run_quietly(port, u64::MAX, 2).await; + assert!(outcome.record.checkpoints.iter().all(|c| c.refusals.len() == 1)); + assert_every_member_accounted_for(&verse, &outcome).await; + + // One grouped cell. + let (_dir, verse, outcome, _) = grouped_run(2).await; + assert_every_member_accounted_for(&verse, &outcome).await; +} + +#[tokio::test] +async fn a_refused_grouped_member_acts_next_tick() { + let (_dir, verse, outcome, _) = grouped_run(2).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let [first, second] = outcome.record.checkpoints.as_slice() else { + panic!("two ticks, two checkpoints") + }; + let refused_first = first.refusals[0].subject; + let tick = verse.runs.tick(world, second.tick).unwrap().unwrap(); + let again = CommandId::for_cell_constituent( + world, + tick.cells[0].id, + refused_first, + TickIndex(second.tick), + ); + assert!(second.committed.contains(&again)); + assert_ne!(second.refusals[0].subject, refused_first); +} + +// ---- Refusals are recorded by the tick's frozen cover ---- + +/// The cover of the fixture world at tick 0 under `cells` cells. +async fn cover_of(verse: &Harness, cells: u16) -> crate::cover::Cover { + let snapshot = verse.mailbox.snapshot().await.unwrap(); + let graph = verse.mailbox.agency_graph().await.unwrap(); + derive_cover( + snapshot.world_id, + TickIndex(0), + &snapshot.opportunities, + &graph, + CoverBudget { + cells, + constituent_cap: 8, + urgency_slots: 0, + }, + ) +} + +/// Opens tick 0's document with these frozen cells, as an earlier run would +/// have left it. +async fn open_tick_with(verse: &Harness, cells: Vec) { + let snapshot = verse.mailbox.snapshot().await.unwrap(); + verse + .runs + .open_tick(&VerseTick { + world_id: snapshot.world_id, + ordinal: 0, + opened_now: snapshot.now.0, + opened_revision: snapshot.revision, + span_minutes: SPAN, + cover_budget: RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + oversubscribed: false, + cells, + opened_by_run: 1, + closed: None, + }) + .unwrap(); +} + +/// The fixture's one-cell cover: its group's id and the two real members. +async fn the_group(verse: &Harness) -> (crate::cover::CellId, Vec) { + let cover = cover_of(verse, 1).await; + let [Cell::Group { id, members, .. }] = cover.cells.as_slice() else { + panic!("one cell over the fixture is a group") + }; + (*id, members.iter().map(|member| member.subject).collect()) +} + +fn command_for(world: WorldId, cell: crate::cover::CellId, subject: SubjectId) -> CommandId { + CommandId::for_cell_constituent(world, cell, subject, TickIndex(0)) +} + +#[tokio::test] +async fn a_tick_refuses_the_frozen_members_that_hold_no_opportunity_by_cell_and_member() { + let (_dir, verse) = Harness::fresh().await; + let (group, real) = the_group(&verse).await; + let single = match &cover_of(&verse, 2).await.cells[1] { + Cell::Singleton { id, .. } => *id, + Cell::Group { .. } => panic!("the second cell of two is a singleton"), + }; + let (g1, g2, g3) = (SubjectId::issue(), SubjectId::issue(), SubjectId::issue()); + let mut members = real.clone(); + members.extend([g1, g2]); + open_tick_with( + &verse, + vec![ + FrozenCell { + id: group, + group: true, + members, + }, + FrozenCell { + id: single, + group: false, + members: vec![g3], + }, + ], + ) + .await; + let mut reports = Vec::new(); + let outcome = verse + .run_under(ScriptedPort::new(grouped_reply), u64::MAX, one_cell(1), |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + let [checkpoint] = outcome.record.checkpoints.as_slice() else { + panic!("one tick, one checkpoint") + }; + assert!(checkpoint.closed); + let gone: Vec<(SubjectId, u32)> = checkpoint + .refusals + .iter() + .filter(|refusal| refusal.reason == RefusalReason::OpportunityGone) + .map(|refusal| (refusal.subject, refusal.cell)) + .collect(); + // A gone member of the first cell, a second one, and a cell gone + // entirely: each once, at its own cell. + assert_eq!(gone.len(), 3); + for expected in [(g1, 0), (g2, 0), (g3, 1)] { + assert_eq!(gone.iter().filter(|g| **g == expected).count(), 1); + } + // The two real members: one committed, one refused for the shared room. + assert_eq!(checkpoint.committed.len(), 1); + assert_eq!(checkpoint.refusals.len(), 4); + assert!( + reports[0] + .to_string() + .contains("refused: cell 1 - opportunity_gone"), + "a subject outside the snapshot prints without a label" + ); +} + +#[tokio::test] +async fn a_frozen_member_the_resumed_row_lacks_is_refused_not_lost() { + // The member the row lacks is the cell's second member, then its first, + // with the group first in the tick and then behind a cell that is gone. + for behind_a_gone_cell in [false, true] { + for lacking_first in [false, true] { + a_row_lacking_a_frozen_member(lacking_first, behind_a_gone_cell).await; + } + } +} + +async fn a_row_lacking_a_frozen_member(lacking_first: bool, behind_a_gone_cell: bool) { + let (_dir, verse) = Harness::fresh().await; + let (group, real) = the_group(&verse).await; + let (a, b) = (real[0], real[1]); + let single = match &cover_of(&verse, 2).await.cells[1] { + Cell::Singleton { id, .. } => *id, + Cell::Group { .. } => panic!("the second cell of two is a singleton"), + }; + let gone = SubjectId::issue(); + let leading = |cells: Vec| -> Vec { + if behind_a_gone_cell { + let mut all = vec![FrozenCell { + id: single, + group: false, + members: vec![gone], + }]; + all.extend(cells); + all + } else { + cells + } + }; + // The first attempt froze [a, ghost]: the row it persists holds only a. + open_tick_with( + &verse, + leading(vec![FrozenCell { + id: group, + group: true, + members: vec![a, SubjectId::issue()], + }]), + ) + .await; + let first = verse + .run_under(fault_at(0), u64::MAX, one_cell(1), |_| { + ControlFlow::Continue(()) + }) + .await; + assert!(matches!(first.record.status, RunStatus::Failed { .. })); + // On resume the frozen cell names a and b; the row still holds only a. + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let mut tick = verse.runs.tick(world, 0).unwrap().unwrap(); + tick.cells = leading(vec![FrozenCell { + id: group, + group: true, + members: if lacking_first { vec![b, a] } else { vec![a, b] }, + }]); + verse.runs.put_tick(&tick).unwrap(); + let cell = u32::from(behind_a_gone_cell); + let second = verse + .run_under(ScriptedPort::new(grouped_reply), u64::MAX, one_cell(1), |_| { + ControlFlow::Continue(()) + }) + .await; + let checkpoint = &second.record.checkpoints[0]; + assert!(checkpoint.closed); + assert_eq!(checkpoint.committed, vec![command_for(world, group, a)]); + let mut expected = Vec::new(); + if behind_a_gone_cell { + expected.push(Refusal { + cell: 0, + subject: gone, + reason: RefusalReason::OpportunityGone, + }); + } + expected.push(Refusal { + cell, + subject: b, + reason: RefusalReason::RowDisagreesWithCell, + }); + assert_eq!(checkpoint.refusals, expected); +} + +#[tokio::test] +async fn a_row_member_outside_the_frozen_cell_is_never_listed() { + let (_dir, verse) = Harness::fresh().await; + let (group, real) = the_group(&verse).await; + let (a, b) = (real[0], real[1]); + open_tick_with( + &verse, + vec![FrozenCell { + id: group, + group: true, + members: vec![a, b], + }], + ) + .await; + let first = verse + .run_under(fault_at(0), u64::MAX, one_cell(1), |_| { + ControlFlow::Continue(()) + }) + .await; + assert!(matches!(first.record.status, RunStatus::Failed { .. })); + // On resume the frozen cell names only a; the row holds a and b. + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let mut tick = verse.runs.tick(world, 0).unwrap().unwrap(); + tick.cells = vec![FrozenCell { + id: group, + group: true, + members: vec![a], + }]; + verse.runs.put_tick(&tick).unwrap(); + let second = verse + .run_under(ScriptedPort::new(grouped_reply), u64::MAX, one_cell(1), |_| { + ControlFlow::Continue(()) + }) + .await; + let checkpoint = &second.record.checkpoints[0]; + assert!(checkpoint.closed); + assert_eq!(checkpoint.committed, vec![command_for(world, group, a)]); + assert!(checkpoint.refusals.is_empty()); +} + +/// The cell folding is the one place a cell's outcome becomes the tick's +/// committed and refused lists, so it is driven with each outcome directly. +fn fold( + result: Result, + cell: &Cell, + position: u32, +) -> (Option, Vec, Vec) { + let (mut committed, mut refusals) = (Vec::new(), Vec::new()); + let status = absorb( + result, + cell, + position, + &mut committed, + &mut refusals, + &mut Vec::new(), + ); + (status, committed, refusals) +} + +fn grouped_refusing( + group: &Cell, + reasons: &[ConstituentRefusalReason], +) -> Result { + let Cell::Group { id, members, .. } = group else { + panic!("a group") + }; + Ok(CellRun::Grouped(crate::controllers::GroupedRun { + cell: *id, + resolution: crate::cover::Resolution::Coarse { + constituents: members.len(), + }, + submissions: Vec::new(), + needs: Vec::new(), + unattributed: Vec::new(), + refused: members + .iter() + .zip(reasons) + .map(|(member, reason)| crate::controllers::ConstituentRefusal { + subject: member.subject, + reason: *reason, + }) + .collect(), + pending: None, + })) +} + +#[tokio::test] +async fn a_moved_scope_member_in_a_coarse_cell_is_recorded_as_scope_moved_at_its_position() { + let (_dir, verse) = Harness::fresh().await; + let group = three_member_group(&verse).await; + let Cell::Group { members, .. } = &group else { + panic!("a group") + }; + let reasons = [ + ConstituentRefusalReason::LeftBeforeSelection, + ConstituentRefusalReason::ScopeChanged, + ConstituentRefusalReason::LeftBeforeSelection, + ]; + let (status, committed, refusals) = fold(grouped_refusing(&group, &reasons), &group, 3); + assert!(status.is_none() && committed.is_empty()); + assert_eq!( + refusals, + vec![ + Refusal { cell: 3, subject: members[0].subject, reason: RefusalReason::OpportunityGone }, + Refusal { cell: 3, subject: members[1].subject, reason: RefusalReason::ScopeMoved }, + Refusal { cell: 3, subject: members[2].subject, reason: RefusalReason::OpportunityGone }, + ] + ); +} + +#[tokio::test] +async fn a_cell_the_driver_folds_records_each_refused_member_at_its_own_position() { + let (_dir, verse) = Harness::fresh().await; + let cover = cover_of(&verse, 1).await; + let group = &cover.cells[0]; + let Cell::Group { members, .. } = group else { + panic!("a group") + }; + + // Every grouped reason maps to its own driver reason, for every member, + // at the position given (not cell 0). + for (grouped, driver) in [ + ( + ConstituentRefusalReason::LeftBeforeSelection, + RefusalReason::OpportunityGone, + ), + ( + ConstituentRefusalReason::ScopeChanged, + RefusalReason::ScopeMoved, + ), + ( + ConstituentRefusalReason::WorldRefused, + RefusalReason::WorldRejected, + ), + ( + ConstituentRefusalReason::ProposalUndecodable, + RefusalReason::ProposalUndecodable, + ), + ] { + let (status, committed, refusals) = + fold(grouped_refusing(group, &[grouped, grouped]), group, 3); + assert!(status.is_none() && committed.is_empty()); + let expected: Vec = members + .iter() + .map(|member| Refusal { + cell: 3, + subject: member.subject, + reason: driver, + }) + .collect(); + assert_eq!(refusals, expected, "{grouped:?}"); + } + + // A cell-level refusal of a grouped cell refuses every member. + let error = ControllerError::NoOpportunity { + expected: ControllerMode::OperationalAgent, + }; + let (status, _, refusals) = fold(Err(error), group, 2); + assert!(status.is_none()); + let expected: Vec = members + .iter() + .map(|member| Refusal { + cell: 2, + subject: member.subject, + reason: RefusalReason::OpportunityGone, + }) + .collect(); + assert_eq!(refusals, expected); + + // A narrative turn the world overtook refuses its member. + let single = &cover_of(&verse, 2).await.cells[1]; + let Cell::Singleton { member, .. } = single else { + panic!("a singleton") + }; + let overtaken = crate::controllers::overtaken_narrative_run(&member.opportunity); + let (status, committed, refusals) = fold(Ok(CellRun::Narrative(overtaken)), single, 1); + assert!(status.is_none() && committed.is_empty()); + assert_eq!( + refusals, + vec![Refusal { + cell: 1, + subject: member.subject, + reason: RefusalReason::Interrupted + }] + ); +} + +#[tokio::test] +async fn a_grouped_member_whose_proposal_fails_to_decode_is_refused_not_declined() { + for finished_anyway in [false, true] { + let (_dir, verse) = Harness::fresh().await; + let port = ScriptedPort::new(move |request| { + let mut events = grouped_reply(request); + if let InferenceEvent::ToolCall { arguments, .. } = &mut events[1] { + *arguments = json!({ "text": 5 }).to_string(); + } + if finished_anyway { + events.push(InferenceEvent::ToolCall { + call_id: "done".into(), + name: "c1__finish_without_proposal".into(), + arguments: "{}".into(), + }); + } + events + }); + let mut reports = Vec::new(); + let outcome = verse + .run_under(port, u64::MAX, one_cell(1), |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + let checkpoint = &outcome.record.checkpoints[0]; + assert!(checkpoint.closed); + if finished_anyway { + // A later finish is the member's decision: it declines, committed. + assert_eq!(checkpoint.committed.len(), 2); + assert!(checkpoint.refusals.is_empty()); + } else { + assert_eq!(checkpoint.committed.len(), 1); + let [refusal] = checkpoint.refusals.as_slice() else { + panic!("the undecodable member is the one refusal") + }; + assert_eq!(refusal.reason, RefusalReason::ProposalUndecodable); + assert!(reports[0].to_string().contains("proposal_undecodable")); + assert_every_member_accounted_for(&verse, &outcome).await; + } + } +} + +#[tokio::test] +async fn a_grouped_cell_behind_another_is_accounted_at_its_own_position() { + let (_dir, verse) = Harness::fresh().await; + let (group, real) = the_group(&verse).await; + let single = match &cover_of(&verse, 2).await.cells[1] { + Cell::Singleton { id, .. } => *id, + Cell::Group { .. } => panic!("the second cell of two is a singleton"), + }; + let gone = SubjectId::issue(); + open_tick_with( + &verse, + vec![ + FrozenCell { + id: single, + group: false, + members: vec![gone], + }, + FrozenCell { + id: group, + group: true, + members: real.clone(), + }, + ], + ) + .await; + let outcome = verse + .run_under(ScriptedPort::new(grouped_reply), u64::MAX, one_cell(1), |_| { + ControlFlow::Continue(()) + }) + .await; + let [checkpoint] = outcome.record.checkpoints.as_slice() else { + panic!("one tick, one checkpoint") + }; + assert!(checkpoint.closed); + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let [first, second] = checkpoint.refusals.as_slice() else { + panic!("the gone cell and the refused member: {:?}", checkpoint.refusals) + }; + assert_eq!( + *first, + Refusal { + cell: 0, + subject: gone, + reason: RefusalReason::OpportunityGone + } + ); + assert_eq!(second.cell, 1); + assert_eq!(second.reason, RefusalReason::ScopeMoved); + let acted: Vec = real.iter().copied().filter(|s| *s != second.subject).collect(); + assert_eq!(acted.len(), 1); + assert_eq!(checkpoint.committed, vec![command_for(world, group, acted[0])]); +} + +#[tokio::test] +async fn a_grouped_cell_whose_lists_do_not_match_its_members_ends_the_run() { + let (_dir, verse) = Harness::fresh().await; + let cover = cover_of(&verse, 1).await; + let group = &cover.cells[0]; + let Cell::Group { id, members, .. } = group else { + panic!("a group") + }; + let run = |refused: Vec, pending| { + Ok(CellRun::Grouped(crate::controllers::GroupedRun { + cell: *id, + resolution: crate::cover::Resolution::Coarse { + constituents: members.len(), + }, + submissions: Vec::new(), + needs: Vec::new(), + unattributed: Vec::new(), + refused, + pending, + })) + }; + let refusal = |subject, reason| crate::controllers::ConstituentRefusal { subject, reason }; + let left = ConstituentRefusalReason::LeftBeforeSelection; + let broken = Some(failed("cell_membership")); + + // A finished cell that lists one of its two members. + let (status, _, _) = fold(run(vec![refusal(members[0].subject, left)], None), group, 0); + assert_eq!(status, broken); + // One that lists a subject the cell does not hold, and misses one it does. + let (status, _, _) = fold( + run( + vec![refusal(members[0].subject, left), refusal(SubjectId::issue(), left)], + None, + ), + group, + 0, + ); + assert_eq!(status, broken); + // One that lists a member twice. + let (status, _, _) = fold( + run( + vec![refusal(members[0].subject, left), refusal(members[0].subject, left)], + None, + ), + group, + 0, + ); + assert_eq!(status, broken); + // A cell that stopped pending has not listed its unreached members, and + // that is not a broken invariant: the tick stays open and they resume. + let reason = crate::controllers::ControllerPendingReason::WorldUnavailable; + let (status, committed, refusals) = fold(run(Vec::new(), Some(reason)), group, 0); + assert_eq!(status, Some(failed(pending_code(reason)))); + assert!(committed.is_empty() && refusals.is_empty()); +} + +/// A cell of three members at a later position, so that the guard is held to +/// its length, to every member, and to the cell it is asked about rather than +/// to the two-member first cell the fixture covers. +async fn three_member_group(verse: &Harness) -> Cell { + let cover = cover_of(verse, 1).await; + let Cell::Group { id, tick, members } = &cover.cells[0] else { + panic!("a group") + }; + let mut members = members.clone(); + let mut third = members[0].clone(); + third.subject = SubjectId::issue(); + members.push(third); + Cell::Group { + id: *id, + tick: *tick, + members, + } +} + +#[tokio::test] +async fn a_finished_cell_of_three_must_list_each_member_exactly_once_wherever_it_sits() { + let (_dir, verse) = Harness::fresh().await; + let group = three_member_group(&verse).await; + let Cell::Group { id, members, .. } = &group else { + panic!("a group") + }; + let left = ConstituentRefusalReason::LeftBeforeSelection; + let run = |listed: Vec| { + Ok(CellRun::Grouped(crate::controllers::GroupedRun { + cell: *id, + resolution: crate::cover::Resolution::Coarse { + constituents: members.len(), + }, + submissions: Vec::new(), + needs: Vec::new(), + unattributed: Vec::new(), + refused: listed + .into_iter() + .map(|subject| crate::controllers::ConstituentRefusal { subject, reason: left }) + .collect(), + pending: None, + })) + }; + let [a, b, c] = [members[0].subject, members[1].subject, members[2].subject]; + let broken = Some(failed("cell_membership")); + for position in [0, 1, 2] { + let (status, _, _) = fold(run(vec![a, b, c]), &group, position); + assert_eq!(status, None, "every member listed once, at cell {position}"); + // Every member listed, and one subject the cell does not hold. + let (status, _, _) = fold(run(vec![a, b, c, SubjectId::issue()]), &group, position); + assert_eq!(status, broken, "a foreign extra at cell {position}"); + // Every member listed, and one of them twice. + let (status, _, _) = fold(run(vec![a, b, c, b]), &group, position); + assert_eq!(status, broken, "a duplicate at cell {position}"); + // The right number of entries, and the last member missing: only the + // member at the end of the cell can be the one that is not there. + let (status, _, _) = fold(run(vec![a, b, SubjectId::issue()]), &group, position); + assert_eq!(status, broken, "a missing last member at cell {position}"); + let (status, _, _) = fold(run(vec![a, c, a]), &group, position); + assert_eq!(status, broken, "a missing middle member at cell {position}"); + let (status, _, _) = fold(run(vec![a, b]), &group, position); + assert_eq!(status, broken, "a short list at cell {position}"); + } +} + +#[tokio::test] +async fn a_call_naming_no_handle_is_recorded_at_the_cell_it_was_made_in() { + let (_dir, verse) = Harness::fresh().await; + let group = three_member_group(&verse).await; + let Cell::Group { id, members, .. } = &group else { + panic!("a group") + }; + for position in [0, 1, 4] { + let run = CellRun::Grouped(crate::controllers::GroupedRun { + cell: *id, + resolution: crate::cover::Resolution::Coarse { + constituents: members.len(), + }, + submissions: Vec::new(), + needs: Vec::new(), + unattributed: vec!["c9__speak".to_string()], + refused: members + .iter() + .map(|member| crate::controllers::ConstituentRefusal { + subject: member.subject, + reason: ConstituentRefusalReason::CallUnattributed, + }) + .collect(), + pending: None, + }); + let mut unattributed = Vec::new(); + let status = absorb( + Ok(run), + &group, + position, + &mut Vec::new(), + &mut Vec::new(), + &mut unattributed, + ); + assert_eq!(status, None); + assert_eq!( + unattributed, + vec![UnattributedCall { + cell: position, + tool: "c9__speak".into() + }] + ); + } +} + +#[tokio::test] +async fn a_call_naming_no_handle_is_printed_and_its_quiet_member_is_refused_not_declined() { + let (_dir, verse) = Harness::fresh().await; + let rounds = AtomicUsize::new(0); + let port = ScriptedPort::new(move |request| { + // The first inference proposes for one member and strays once; the + // round after it says nothing more. + if rounds.fetch_add(1, Ordering::SeqCst) > 0 { + return Vec::new(); + } + let mut events = grouped_reply(request); + events.truncate(1); + events.push(InferenceEvent::ToolCall { + call_id: "stray".into(), + name: "c9__speak".into(), + arguments: json!({ "text": "SENTINEL-ARGUMENT" }).to_string(), + }); + events + }); + let mut reports = Vec::new(); + let outcome = verse + .run_under(port, u64::MAX, one_cell(1), |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + let [checkpoint] = outcome.record.checkpoints.as_slice() else { + panic!("one tick, one checkpoint") + }; + assert!(checkpoint.closed); + assert_eq!(checkpoint.committed.len(), 1); + let [refusal] = checkpoint.refusals.as_slice() else { + panic!("the quiet member is the one refusal") + }; + assert_eq!(refusal.reason, RefusalReason::CallUnattributed); + assert_eq!( + checkpoint.unattributed, + vec![UnattributedCall { + cell: 0, + tool: "c9__speak".into() + }] + ); + let printed = reports[0].to_string(); + assert!(printed.contains("call_unattributed"), "{printed}"); + assert!( + printed.contains("note: cell 0 call names no handle: tool \"c9__speak\""), + "{printed}" + ); + assert!(!printed.contains("SENTINEL-ARGUMENT")); + assert_every_member_accounted_for(&verse, &outcome).await; +} diff --git a/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc new file mode 100644 index 0000000..f408315 Binary files /dev/null and b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.cc differ diff --git a/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt new file mode 100644 index 0000000..7ba8196 --- /dev/null +++ b/crates/ghostlight/tests/fixtures/aetheria-verse-catalog.provenance.txt @@ -0,0 +1,16 @@ +aetheria-verse-catalog.cc was written by Aetheria's own types, not by hand and not by Ghostlight. + +Writer: tests/Aetheria.Shared.Tests/VerseGrammarTests.cs, WriteFixture (xunit test + Fixture_writer_is_deterministic_and_emits_what_the_grammar_reader_needs), + which authors VerseGrammar, three VerseVerb and two Faction records through + Aetheria's CultCache types and leaves the file at AETHERIA_VERSE_FIXTURE_PATH. +Aetheria: GameCult/Aetheria origin/master 92b82e9aa5bb422c568c4bfc85a8f641426a41fc + (contains aetheria-release cut verse-grammar, merged at 35af2930). +Command: AETHERIA_VERSE_FIXTURE_PATH=/tmp/aetheria-verse-catalog.cc + dotnet test tests/Aetheria.Shared.Tests + -p:CultLibRoot= -p:CultMathRoot= + --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. diff --git a/crates/ghostlight/tests/fixtures/base_world.cc b/crates/ghostlight/tests/fixtures/base_world.cc new file mode 100644 index 0000000..d4c8ead Binary files /dev/null and b/crates/ghostlight/tests/fixtures/base_world.cc differ diff --git a/crates/ghostlight/tests/fixtures/base_world.digests b/crates/ghostlight/tests/fixtures/base_world.digests new file mode 100644 index 0000000..ab9edc3 --- /dev/null +++ b/crates/ghostlight/tests/fixtures/base_world.digests @@ -0,0 +1,3 @@ +3 +sha256:b706f57cb417603366a1f47a5dd099fbf68bfecc5b2e8d61d281630791d1d76d +sha256:2039956a26c3d5b073d101e5898ac5a60a9eb63a3b93f447b2d8f78ef4caf979 diff --git a/crates/ghostlight/tests/fixtures/base_world.id b/crates/ghostlight/tests/fixtures/base_world.id new file mode 100644 index 0000000..f23316a --- /dev/null +++ b/crates/ghostlight/tests/fixtures/base_world.id @@ -0,0 +1 @@ +"a534b32a-4d00-429c-bf6c-ce1d55385380" diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml deleted file mode 100644 index 1ef01f3..0000000 --- a/deployment/idunn/recipe.toml +++ /dev/null @@ -1,188 +0,0 @@ -schema = "gamecult.idunn.target_declaration.v1" -target = "ghostlight" -source_stamp_environment = "GHOSTLIGHT_BUILD_COMMIT" -required_gitlinks = ["vendor/eve"] - -[[steps]] -id = "test-persona-boundary" -phase = "test" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "test", "--locked", "-p", "ghostlight-persona-projection"] - -[[steps]] -id = "test-world-library" -phase = "test" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "test", "--locked", "-p", "ghostlight"] - -[[steps]] -id = "test-dungeon" -phase = "test" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "test", "--locked", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"] - -[[steps]] -id = "build-daemon" -phase = "build" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "build", "--locked", "--release", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"] - -[[steps]] -id = "install-web-dependencies" -phase = "prepare" -runner = "web-build" -required_environment = ["HOME", "NPM_CONFIG_CACHE"] -argv = ["npm", "--prefix", "web", "ci"] - -[[steps]] -id = "test-web" -phase = "test" -runner = "web-build" -required_environment = ["HOME", "NPM_CONFIG_CACHE"] -argv = ["npm", "--prefix", "web", "test"] - -[[steps]] -id = "build-web" -phase = "build" -runner = "web-build" -required_environment = ["HOME", "NPM_CONFIG_CACHE"] -argv = ["npm", "--prefix", "web", "run", "build"] - -[[steps]] -id = "accept-real-persona-membrane" -phase = "acceptance" -runner = "rust-acceptance" -argv = [ - "cargo", - "test", - "--locked", - "-p", - "ghostlight", - "--lib", - "controllers::tests::real_local_model_cognition_modes_commit_speech", - "--", - "--ignored", - "--exact", -] -required_environment = [ - "CARGO_HOME", - "GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG", - "GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID", - "GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL", - "GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL", - "GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL", - "GHOSTLIGHT_CONTROLLER_PERSONA_MODEL", - "GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL", - "HOME", - "GHOSTLIGHT_LOCAL_ENDPOINT", -] - -[[artifacts]] -id = "ghostlight-dungeon" -source_kind = "runner-output" -runner = "rust-build" -source = "target/release/ghostlight-dungeon" -destination = "ghostlight-dungeon" -executable = true - -[[artifacts]] -id = "web" -source_kind = "runner-output" -runner = "web-build" -source = "web/dist" -destination = "web" - -[service] -executable_artifact = "ghostlight-dungeon" -required_adjacent_artifacts = ["web"] -arguments = [ - { kind = "literal", value = "--state-root" }, - { kind = "binding", name = "state_root" }, -] -transport = "http" -route_required = true -required_environment = [ - "GAMECULT_IDUNN_CANDIDATE_BIND", - "GAMECULT_IDUNN_PROCESS_WRITE_LEASE", - "GAMECULT_IDUNN_RUNTIME_BUNDLE", - "GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL", - "GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL", - "GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL", - "GHOSTLIGHT_CONTROLLER_PERSONA_MODEL", - "GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL", - "GHOSTLIGHT_DISCORD_GUILD_ID", - "GHOSTLIGHT_DISCORD_ROLE_ID", - "GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE", - "GHOSTLIGHT_HEIMDALL_BASE_URL", - "GHOSTLIGHT_LOCAL_ENDPOINT", - "GHOSTLIGHT_LOCATED_SERVICE", - "GHOSTLIGHT_ODIN_RUDP", - "GHOSTLIGHT_PLAY_MODEL", - "GHOSTLIGHT_PUBLIC_APP_URL", - "GHOSTLIGHT_SERVICE_ID", - "GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE", -] -optional_environment = ["RUST_LOG"] - -[service.health] -contract = "ghostlight.cultnet-rudp-service-health" - -[state] -schema_generation = "world-v3" - -[[state.slots]] -id = "world" -relative_path = "world.cc" -kind = "cultcache-file" -schema = "ghostlight.world_state.consumer.v6" -writer = "process-bound-single-writer" -recovery = "preserve" -startup = "create-or-open-after-write-lease" - -[[state.slots]] -id = "app-sessions" -relative_path = "service/app-sessions-v2.cc" -kind = "cultcache-file" -schema = "ghostlight.app_session_store.v2" -writer = "process-bound-single-writer" -recovery = "preserve" -startup = "create-or-open-after-write-lease" - -[[state.slots]] -id = "controller-work" -relative_path = "service/controller-work.cc" -kind = "cultcache-file" -schema = "ghostlight.controller_work.v17" -writer = "process-bound-single-writer" -recovery = "preserve" -startup = "create-or-open-after-write-lease" - -[[state.slots]] -id = "mesh-projection" -relative_path = "service/mesh-v2.cc" -kind = "cultcache-file" -schema = "gamecult.eve.surface.v1" -writer = "process-bound-single-writer" -recovery = "rebuildable" -startup = "create-or-open-after-write-lease" - -[[provides]] -capability = "ghostlight.world-service" -schema = "ghostlight.world_state.consumer.v6" -compatibility = "consumer-v6" - -[[provides]] -capability = "gamecult.eve.surface-provider" -schema = "gamecult.eve.surface.v1" -compatibility = "v1" - -[[dependencies]] -kind = "shared-infrastructure" -capability = "odin.verse-rendezvous" -schema = "odin.verse-topology.v1" -compatibility = "v1" -startup = "before-promotion" diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 0df4607..3cfd486 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -1,5 +1,7 @@ # Ghostlight authority architecture +> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main. + ## Status This document is the adopted rebuild target for the Ghostlight runtime. It is @@ -624,9 +626,11 @@ lane still uses. The real-model gate that exists today is the Idunn recipe's runs `controllers::tests::real_local_model_cognition_modes_commit_speech` against a real model; exercising a whole play turn against one is not gated by this contract. -`world.create`'s current payload is `world_create.v4`: title, brief, targets, -jurisdiction roots and lens weights are required, and Dungeon's create surface -offers uniform weights as its editable default. `world.seed` runs one +`world.begin` is the one route that makes a world. Its payload is +`world_begin.v1`: title, brief and subject_label, nothing else; the route +decides everything else a world is born with (no targets, no roots, no persona +or agent label, uniform lens weights) and `begin_plan` decides how far Begin +goes. `world.seed` runs one `SeedRunner` session per invocation against a `VaultEvidenceSource`. Focused tests must prove: diff --git a/docs/architecture/ghostlight-eve-native-interface.md b/docs/architecture/ghostlight-eve-native-interface.md deleted file mode 100644 index a296f15..0000000 --- a/docs/architecture/ghostlight-eve-native-interface.md +++ /dev/null @@ -1,100 +0,0 @@ -# Ghostlight Eve-native interface - -## Objective - -Ghostlight Dungeon exposes one logical surface, `ghostlight.play`, from the -provider `gamecult.ghostlight.dungeon` (`crates/ghostlight-dungeon/src/mesh.rs`). -Eve owns editable bindings, command invocation, receipts, plugin composition, -and browser lowering. Ghostlight owns world creation, seeding, activation, play, -and world admission; Heimdall owns authentication and entitlement decisions. - -```text -Ghostlight projection (eve.rs) - -> EveBrowserProviderHost - -> canonical Eve lowering - -> gamecult.eve.command_invocation.v1 - -> Ghostlight command ingress (runtime.rs) or Heimdall private command plane - -> gamecult.eve.command_result.v1 - -> authoritative surface refresh -``` - -## Authority map - -- Owner: the world kernel owns world changes (through `WorldMailbox`, `SeedPort` - and the play table), Heimdall owns OAuth attempts and claims, and Ghostlight's - app-session owner (`app_session.rs`) binds a verified Heimdall subject to a - local cookie. -- Inputs: the projector reads only the caller's app session, the world - snapshot, and the caller's play-turn view. Command ingress reads one canonical - Eve invocation and derives the principal server-side. -- Outputs: one `gamecult.eve.surface.v1` document and one - `gamecult.eve.command_receipt.v1` inside a command result. -- Derived state: browser drafts, focus, command status, and rendered HTML are - projections or local interaction state. None grants access or mutates - fiction. -- Forbidden writers: the browser, the Eve lowerer, the Heimdall plugin, and the - SSE stream cannot choose a principal or commit world state. -- Shared path: every operation resolves the authenticated account, then calls - the world mailbox, the seed port, or the play port. - SSE carries invalidation only and the host refetches the same surface. - -## The surface - -`GET /api/eve/surfaces/ghostlight.play` returns a different document by caller -and world phase (`anonymous_surface`, `authenticated_surface` in -`crates/ghostlight-dungeon/src/eve.rs`): - -| Caller and world | Content | Operations | -|---|---|---| -| Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` | -| Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) | -| Authenticated, no world | the create form: title, brief, your name, optional Narrative persona and Operational agent labels, scale target, jurisdiction roots, lens weights | `world.create` (`ghostlight.world_create.v4`) | -| Draft world | world summary card; approve button for a required approver who has not approved; for the owner, the seed form and Seed card, and the activate button once every required approver has approved | `world.approve` (`ghostlight.world_approve.v0`), `world.seed` (`ghostlight.world_seed.v1`), `world.activate` (`ghostlight.world_activate.v0`) | -| Active world, owner | advance-time control and the Play card: narration, the open question, the refusal of the player's own act, one free-text control | `world.advance_time` (`ghostlight.world_advance_time.v0`), `world.play` (`ghostlight.world_play.v0`) | - -`world.play` is the only human play action. The open question's identity rides -the play button's own action as an opaque token, never as an editable field. -`operation_schema` in `eve.rs` is the single table from operation id to schema. - -## Editable values - -Eve bindings are the input model. A component binds a typed value by stable -binding name, document identity, field path, value kind, and access mode. -Renderer-local composers use `local-draft`. An operation captures one or more -named binding values atomically. - -The browser lowerer may use an HTML form for keyboard and accessibility -behavior. HTML form structure never enters Eve state or command payload -semantics. Accepted receipts may clear named drafts; rejection and stale -conflict preserve them. An omitted clear-binding list means clear the surface's -drafts; an explicit empty list means clear nothing. Ghostlight validates every -payload at ingress regardless of what the control advertises. - -## Authentication membrane - -Anonymous projection contains only `heimdall.access_gate`. Its begin and -complete operations cross Heimdall's encrypted, loopback-only CultNet boundary -(`heimdall.rs`). Ghostlight reads the redacted `heimdall:command-boundary` -record from Odin, validates it, and invokes the discovered route. Odin does not -proxy the command and never receives claims or completion payloads. The browser -retains only the opaque attempt handle (`web/src/heimdall-access-adapter.ts`). -Ghostlight redeems the completion, validates the access claim, and creates a -local HttpOnly session cookie (`ghostlight_session`). - -Routine requests use local verified session state, persisted in -`app-sessions-v2.cc` (`app_session.rs`): the cookie hash, the Heimdall -session and `access_revision`, expiries, and a wrapped refresh claim. -`access_revision` is Heimdall's revocation epoch. - -## Public boundary - -- `GET /api/eve/provider` -- `GET /api/eve/surfaces/{surface_id}` -- `POST /api/eve/commands` -- `GET /api/eve/events` -- `GET /health`, a probe of the service's typed CultMesh health -- `POST /cultnet/snapshot` and `POST /cultnet/world-patch`, the loopback - CultNet doors (see `ghostlight-world-consumer-api.md` for the second) - -Static assets in `web/` contain only the Eve provider host, the transport, the -Heimdall adapter, and the SSE invalidation hook. diff --git a/docs/architecture/ghostlight-play-agent.md b/docs/architecture/ghostlight-play-agent.md index 1f5aea1..279965e 100644 --- a/docs/architecture/ghostlight-play-agent.md +++ b/docs/architecture/ghostlight-play-agent.md @@ -1,5 +1,7 @@ # Ghostlight Dungeon play agent (plan step 16) +> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main. + ## Status Target, adopted 2026-09-22. Not implemented. The means will be in diff --git a/docs/architecture/ghostlight-session-zero.md b/docs/architecture/ghostlight-session-zero.md index bffb00c..e5e6fdb 100644 --- a/docs/architecture/ghostlight-session-zero.md +++ b/docs/architecture/ghostlight-session-zero.md @@ -1,5 +1,7 @@ # Ghostlight Dungeon Session Zero +> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main. + Status: adopted direction, 2026-09-15. Not implemented. Its role as the playtest gate is superseded (operator, 2026-09-22) by the play agent, `ghostlight-play-agent.md` (plan step 16). D1–D3 and the library passes diff --git a/docs/architecture/ghostlight-world-consumer-api.md b/docs/architecture/ghostlight-world-consumer-api.md index 5eacfbf..5c996a7 100644 --- a/docs/architecture/ghostlight-world-consumer-api.md +++ b/docs/architecture/ghostlight-world-consumer-api.md @@ -63,6 +63,11 @@ attributed proposal — is not part of this pass; see "Not in this pass" below. ## Transport +Status: the HTTP door below lived in the Dungeon daemon's `api_router` and is +parked with it at tag `parked/ghostlight-eve-surface`; no process on main serves +it. `ghostlight::admit_document` and `encode_receipt` are the contract a host +serves. + `POST /cultnet/world-patch`, declared beside `/cultnet/snapshot` in `api_router` (`runtime.rs`). The handler gates loopback and content type (`application/msgpack`) exactly as the snapshot route does, then hands the diff --git a/docs/architecture/ghostlight-world-ontology.md b/docs/architecture/ghostlight-world-ontology.md index 51457ce..549c127 100644 --- a/docs/architecture/ghostlight-world-ontology.md +++ b/docs/architecture/ghostlight-world-ontology.md @@ -307,7 +307,7 @@ provider request is a distinct round (`interpreter_round`) with its own content-addressed request id, so it cannot collide with the first lowering's request. State schema is `ghostlight.world_state.consumer.v6`, commit schema `ghostlight.world_commit.consumer.v6`, controller work -`ghostlight.controller_work.v17`, Persona turn receipt +`ghostlight.controller_work.v18`, Persona turn receipt `ghostlight.persona_turn_receipt.v3`; earlier stores and earlier rows are refused. Ghostlight owns a conserved narrative ledger; Delvehold owns the economy (`delvehold-forced-ontology-integration.md`). diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index c7f13b6..5d8ce69 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -2,25 +2,21 @@ This is a pointer. It does not carry open work; the Eureka mind does. -## What runs +## What exists -Ghostlight Dungeon runs on Yggdrasil under Idunn at -`https://yggdrasil.gamecult.org/ghostlight/` (runtime `ghostlight-yggdrasil`, -target `ghostlight`, ref `main`, state root `/var/lib/gamecult/ghostlight-world-v2`). -`/health` names the commit it serves (`crates/ghostlight-dungeon/src/runtime.rs`). -The binary is `ghostlight-dungeon`; its inference reaches Bonsai on Raven -through a loopback tunnel, and sign-in goes through Heimdall, discovered via -Odin. +Nothing is deployed. The Dungeon (daemon, play table, Eve surface, browser host, +`vendor/eve`, Idunn recipe) is parked at the tag `parked/ghostlight-eve-surface` +and is not on main. A Thing 2 rebuild starts from that tag. -## How it deploys +What is in the tree is the kernel (`crates/ghostlight`), the persona projection +(`crates/ghostlight-persona-projection`) and the verse runner +(`crates/ghostlight-verse`). `cargo test` runs them on Linux; use the +`ygg-verify` stopgap on Yggdrasil, not the workstation, and say which host +you used. -Idunn is the only deploy path (`idunn up ghostlight`). The recipe is -`deployment/idunn/recipe.toml`; the runbook is -`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. The Ghostlight daemon -cannot start without Odin: its Warming presence and process write lease go -through Odin. Preflight Odin's route health before a deploy. Do not invoke the -retired v1 actuator (`gamecult-ops/scripts/deploy-ghostlight-yggdrasil.sh`), its -wiring test, or `ghostlight-dungeon.service`. +The Idunn target `ghostlight` is braked and stopped, not retired. Its state is in +`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. Do not run +`idunn up ghostlight`, the v1 actuator, or `ghostlight-dungeon.service`. ## Where open work is @@ -35,9 +31,10 @@ target and probed body facts are `notes/ghostlight-verse-target.md` and 1. `README.md`: what exists and what does not. 2. `AGENTS.md`: invariants and where things are. -3. `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture. -4. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and +3. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and current mechanism. +4. `docs/architecture/ghostlight-dungeon-mvp.md`: the parked Dungeon's authority + architecture. History until Thing 2 revives it. 5. `docs/architecture/ghostlight-play-agent-cut.md`: the dated record of the play agent and of the first deploy on 2026-09-23 (findings PA.f198 to PA.f216). It is history; the mind says which findings are still open. diff --git a/notes/ghostlight-verse-map.md b/notes/ghostlight-verse-map.md index aa5ba6a..75f1d53 100644 --- a/notes/ghostlight-verse-map.md +++ b/notes/ghostlight-verse-map.md @@ -1,6 +1,8 @@ # Ghostlight Verse: Map -Campaign `ghostlight-verse`, target `ghostlight-verse:target:r2` +> Status: Dungeon, eve.rs and the bridge-test references below are parked at tag `parked/ghostlight-eve-surface`; line refs are to that tag. + +Campaign `ghostlight-verse`, target `ghostlight-verse:target:r3` (`notes/ghostlight-verse-target.md`). This page holds body facts, the model page and rationale only. Cut specs, questions, rulings and follow-ups live in the Eureka mind; query them there, never copy them here. @@ -9,6 +11,15 @@ Phase 1 map: Imagination, session `self-2026-10-09-morning`, 2026-10-09. Base for every phase-1 anchor: Ghostlight `origin/main` `4fee97f`. Times are UTC unless marked local (UTC+2). +## Cut sequence + +Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec is admitted when Self is about to dispatch the cut. The live run waits on the operator's DeepSeek key. Re-map a line by a fresh Imagination pass against the Body when the frontier reaches it. + +- model-asleep: The play card says the storyteller is waking while Bonsai reloads, and refuses a turn with an asleep line when the. Waits on: play-faults (live run waits on the operator DeepSeek key). +- raven-bonsai-scripts: Raven's Bonsai scripts enter gamecult-ops; the server stays up and sleeps its model on idle, the reaper is gone, and. Waits on: nothing in the mind (not queued; Ghostlight live run waits on the operator DeepSeek key). +- deploy-main: Main deploys on Yggdrasil through Idunn, stays up, and a human plays a session end to end. Waits on: play-faults, play-surface, session-refresh, acceptance-gate, raven-bonsai-scripts, model-asleep, create-flow (blocked on the DeepSeek key and the Ghostlight live-run chain). +- v1-retire: The retired v1 Ghostlight body leaves Yggdrasil and gamecult-ops. Waits on: nothing in the mind (not queued; Ghostlight frontier is the live run). + ## Body facts (probed 2026-10-09) ### Does main run on Yggdrasil? @@ -57,7 +68,7 @@ Times are UTC unless marked local (UTC+2). ### Does main build and pass? -Two jobs through `ygg-verify.sh` at `4fee97f`, with `vendor/eve` initialised: +Two jobs through `ygg-verify.sh` at `4fee97f`, with the Eve submodule initialised: - Rust (image `eureka-verify-rust`), the recipe's own steps: `ghostlight-persona-projection` 35 passed; `ghostlight` lib 621 passed, @@ -202,6 +213,67 @@ from 21:04 the denial forever. Mechanism: code is flat under `crates/ghostlight/src/`. Dated cut documents and postmortems keep their paths: they are history. +### Who refuses an operational in-flight row whose scope moved? (probed 2026-10-17, `4741d50`) + +- Three paths answer the same fact (the subject still holds one opportunity on + the bound scope, at a new digest), with two errors. A probe test run on + Yggdrasil returned these, with one provider call in total: + - The scope moves during round 0 of a fresh run. Round 1's + `ensure_scope_unchanged` returns `NoOpportunity`. + - A resume passing the row's own stale opportunity returns `NoOpportunity` + (same gate). + - A resume passing the fresh opportunity returns `OpportunityMismatch`, from + the digest clause of `row_answers_caller` at lookup. +- The driver's `classify` (`verse/run.rs` 641-651) labels the first two + `OpportunityGone` and the third `ScopeMoved`. That makes the first two + mislabels: the label's doc says `OpportunityGone` means the subject holds no + opportunity. Cause: when `select_scope`'s digest clause empties the match + list, it answers `NoOpportunity` (`controllers.rs` ~1552). +- In the narrative lane, the lookup digest clause is the only gate. A driver + resume with the fresh opportunity is refused there. Fork A's kernel-refusal + re-lowering (`1ef973a`) runs only when a caller passes the stale opportunity. +- Grouped lane (probed 2026-10-17, `771a79d`, by reading; `select_one` now + returns `ScopeMoved`): `run_group` maps every `select_one` error to + `LeftBeforeSelection`, which the driver records as `OpportunityGone`. A member + whose scope moved is dropped before any constituent, inference or submission + exists, so only the label is wrong; spend is identical and nothing branches on + the reason (`grouped-record-and-carry` keys on membership in `refused`). Own + cut: `cut-grouped-scope-moved-label.r1`. + +### Who refuses a resumed narrative row whose scope moved? (probed 2026-10-17, `73f26a5`) + +- **Today, at lookup, by the caller's digest.** `run_narrative`'s Confirmed + and CustodyUncertain arms (`controllers.rs` 3144, 3152) call + `row_answers_caller`, the only callers it has since `in-flight-gate` + (`771a79d`). A fresh caller is refused `OpportunityMismatch`; a stale caller + passes to Fork A. Nothing in Projector, Persona or Interpreter rounds reads + the world (source read: `self.select` is called only at fresh run, round + checks of the operational lane, and the other lanes' fresh paths). +- **The kernel path is already whole.** `submit_narrative`'s `ScopeChanged` + arm calls `interrupted`, which re-lowers once. A second move reads + `interrupted_from.is_some()` and ends the turn `overtaken`, with nothing + submitted and no further inference. The progression arm (~2375) refuses to + persist a second re-lowering even if the runner tried. Pinned by + `a_second_scope_change_after_the_re_lowering_spends_nothing`. +- **Probe** (Yggdrasil, ygg-verify rust, clone at `73f26a5`, sed in the job; + script `probe1.sh` in the imagination-ag5-narrative scratchpad): both lookup + arms put on `same_decision`, and `OpportunityMismatch` moved from `ScopeMoved` + to `OpportunityGone` in `classify`. `cargo test -p ghostlight --lib`: 803 + passed, 3 failed. The failures were + `a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference` + (the resumed row now called the provider and ran the test port dry), + `a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate`, and + `an_error_ends_a_cell_as_the_run_must_treat_it` (the classify table). No + verse driver test reached `ScopeMoved` through `OpportunityMismatch`. +- **`OpportunityMismatch` is no longer a moved scope anywhere.** After the cut + it arises from `same_decision` (another decision), `select_scope`'s + controller-id check, and the Persona lane's forged-field refusals. The driver + cannot reach a `same_decision` mismatch, because `run_cell` derives the + command id from world, cell, subject and tick. +- **Dead code.** Both `awaits_world` methods have no production caller once + `row_answers_caller` goes. The operational one has been test-only since + `771a79d`. The only other reader is the `NeverSentStore` test fixture. + ## Model page One row per persistent kind phase 1 touches. No cell may be empty. @@ -216,6 +288,7 @@ One row per persistent kind phase 1 touches. No cell may be empty. | v1 body (`ghostlight-dungeon.service`, `/srv/ghostlight`, v1 state) | unit name; paths | retired in fact 2026-09-23; unit still enabled | nobody today; phase 1 assigns its removal to a gamecult-ops cut, data by operator ruling | | Bonsai lifecycle (Raven containers) | container `bonsai`; scripts under `/opt/gamecult/bonsai` | started by hand ("Start AI Chat"); restarted by the RSS watchdog; stopped by the idle reaper | gamecult-ops/Raven; Dungeon only reads the endpoint; availability policy is an operator question | | Repository docs (README, AGENTS, live architecture pages, handoff) | path on `main` | rewritten to the live machine; pre-rebuild material leaves `main` by operator ruling | the Ghostlight repo; history in git, dated cut documents and postmortems | +| Controller-work row (`controller-work.cc`, narrative and operational checkpoints) | `CommandId` (driver: derived from world, cell, subject, tick) | persisted before each spend; advanced only by the progression rule; a narrative row re-lowers at most once (`interrupted_from`), then commits, declines or ends overtaken | which decision a row answers: `same_decision`, both lanes, the caller naming identity only. Whether its scope moved: the world (operational: `ensure_scope_unchanged` before each round; narrative: the kernel's `ScopeChanged` at submit, answered by `interrupted`). Labels: `classify` alone | ### Where phase 2's verb sets plug in @@ -264,3 +337,16 @@ verbs as the target. Dungeon tests that drive the real client, and nothing runs them. Fixing the gate means a runner with both Rust and Node, which is Idunn's recipe and runner surface: a follow-up, with each phase-1 Hands run doing them by hand. +- **A narrative resume has one detector, and it is the kernel** (ruling + `narrative-resume-relowers`). The caller's opportunity names the decision + and nothing more, in both lanes, through the one `same_decision`. The lanes + differ only in when the world is asked. The operational lane asks before each + round, because it has nothing to preserve. The narrative lane asks at submit, + because its prose and receipts survive to be lowered again. The price is the + remaining rounds plus at most one Interpreter call. `OpportunityMismatch` + then stops meaning a moved scope, so `classify` labels it `OpportunityGone`, + as the grouped lane already does. Keeping it on `ScopeMoved` would leave a + second, wrong answer to "did the scope move". The grouped selection label + becomes a pure function, because `AmbiguousOpportunity` cannot be built + through a live world, which offers one opportunity per scope. Pinning the + function is what closes follow-up `grouped-left-arms-pin`. diff --git a/notes/local-live-smoke.md b/notes/local-live-smoke.md index 941c6e4..7170047 100644 --- a/notes/local-live-smoke.md +++ b/notes/local-live-smoke.md @@ -5,8 +5,14 @@ No live smoke exists in this tree. The ignored test this file once documented, ran the production tick driver and the Active elaboration sweep against a real CodexConnector; the play agent's Cut 1 (`d69e9d4`) deleted both, and the test with them. A command that names it now matches nothing and exits 0. No -replacement exists. The real-model gate that does exist is the Idunn recipe's -`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`). +replacement exists. The recipe that once gated a deploy on a real model is +gone from main, and so is its `accept-real-persona-membrane` step. The one real-model test +left is the ignored `controllers::tests::real_local_model_cognition_modes_commit_speech` +(`crates/ghostlight/src/controllers.rs`). It has no automated run: it needs a model server +on `GHOSTLIGHT_LOCAL_ENDPOINT` plus the `GHOSTLIGHT_CONTROLLER_*_MODEL`, +`GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID` and `GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG` +variables, and a person runs it by hand with `cargo test -p ghostlight -- --ignored +real_local_model_cognition_modes_commit_speech`. What remains true and stays here: bringing up a real CodexConnector and a built Claude SDK sidecar. The seed lane (`world.seed`) still runs real diff --git a/package.json b/package.json index 0a3700c..287051d 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,8 @@ "name": "ghostlight", "private": true, "version": "0.0.0", - "description": "Ghostlight: the Rust kernel for socially situated agents, and Ghostlight Dungeon, the single-player DM-led client.", + "description": "Ghostlight: the Rust kernel for socially situated agents.", "scripts": { - "dungeon:build": "cargo build --workspace && npm --prefix web run build", - "dungeon:test": "cargo test --workspace && npm --prefix web run build", - "dungeon:run": "cargo run -p ghostlight-dungeon", "sdk:build": "npm --prefix sidecar/claude-sdk run build", "sdk:test": "npm --prefix sidecar/claude-sdk test" }, diff --git a/tools/eve_client_bridge.mjs b/tools/eve_client_bridge.mjs deleted file mode 100644 index 63f17f2..0000000 --- a/tools/eve_client_bridge.mjs +++ /dev/null @@ -1,117 +0,0 @@ -// Cut 10 (PA.f149): drives the real vendored Eve browser lowering -// (`vendor/eve/packages/eve-browser-lowering`, submodule pin unmoved) over a -// surface document Dungeon actually served, so the command intent this -// prints is what a real client sends -- never a hand-built payload asserting -// its own shape is correct. -// -// stdin: {"surface": , "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("
", { - 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 `