diff --git a/.gitignore b/.gitignore index 3c71615..e90c6ba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +web/dist/ sidecar/claude-sdk/dist/ target/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..578af4b --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "vendor/eve"] + path = vendor/eve + url = https://github.com/GameCult/Eve.git diff --git a/AGENTS.md b/AGENTS.md index f0e4406..d258e9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,8 +5,7 @@ Ghostlight is a Rust world kernel and the play daemon built on it. Models propose; the kernel admits or refuses. The same kernel is meant to run the simulation behind Aetheria's public story, a persistent cast and storylets, as -well as Ghostlight Dungeon, the single-player DM-led client (parked at -tag `parked/ghostlight-eve-surface`). `README.md` says +well as Ghostlight Dungeon, the single-player DM-led client. `README.md` says what exists today and what does not. ## Where State Lives @@ -56,21 +55,28 @@ what exists today and what does not. - Kernel: `crates/ghostlight/src/`. Its vocabulary and current mechanism: `docs/architecture/ghostlight-world-ontology.md`. The consumer contract: `docs/architecture/ghostlight-world-consumer-api.md`. -- Parked Dungeon (daemon, play table, Eve surface, browser host, Idunn recipe): - tag `parked/ghostlight-eve-surface`; not on main. +- Dungeon daemon: `crates/ghostlight-dungeon/src/` (`runtime.rs` for routes and + commands, `play.rs` for the play table, `eve.rs` for the surface). + Interface authority: `docs/architecture/ghostlight-eve-native-interface.md`. - Persona membrane: `crates/ghostlight-persona-projection/src/lib.rs`. -- Claude SDK sidecar: `sidecar/claude-sdk/`. -- Architecture rationale (the parked Dungeon's): `docs/architecture/ghostlight-dungeon-mvp.md`. +- Browser host: `web/`. Claude SDK sidecar: `sidecar/claude-sdk/`. +- Architecture rationale: `docs/architecture/ghostlight-dungeon-mvp.md`. `notes/ghostlight-implementation-plan.md` is the historical plan of the 2026-09 rebuild, not the current plan. ## Build, Verify, Deploy -Nothing deploys from this repo: the Idunn target `ghostlight` is braked and stopped (deployment and -lifecycle brakes engaged, unit stopped; retirement waits on -`ghostlight-verse:follow_up:idunn-v2-target-retirement`) and no recipe exists on main. Kernel and verse tests run on Linux on Yggdrasil (the -`ygg-verify` stopgap), not on the workstation; a Windows compile is not evidence -about Linux, so say which host you used. Do not run `idunn up ghostlight`. +Ghostlight Dungeon ships to Linux on Yggdrasil. Idunn freezes an exact commit, +runs the locked native Linux tests, builds the daemon and web projection in +pinned containers, and seals the release. The steps are +`deployment/idunn/recipe.toml`; the owning runbook is +`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. + +You are probably reading this on a Windows workstation. That is where the shell +is, not where the artifact runs. A Windows compile is not evidence about the +Linux release; build for the target, or let the deploy path's builder do it, and +say which one you used. Heavy builds and test runs go to Yggdrasil, not the +workstation. Deploy only through Idunn (`idunn up ghostlight`). For infrastructure, SSH, Idunn, Odin, or Heimdall work, consult `F:\Projects\gamecult-ops` before acting. diff --git a/Cargo.lock b/Cargo.lock index 09a0b20..dfffd46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,6 +37,15 @@ 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" @@ -52,6 +61,28 @@ 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" @@ -75,6 +106,70 @@ 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" @@ -87,6 +182,12 @@ 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" @@ -187,6 +288,12 @@ 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" @@ -259,6 +366,85 @@ 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" @@ -267,6 +453,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -280,6 +467,30 @@ 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" @@ -296,6 +507,12 @@ 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" @@ -404,6 +621,18 @@ 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" @@ -413,7 +642,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi", + "r-efi 6.0.0", "rand_core 0.10.1", "wasm-bindgen", ] @@ -454,6 +683,44 @@ dependencies = [ "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", + "uuid", + "zeroize", +] + [[package]] name = "ghostlight-persona-projection" version = "0.1.0" @@ -464,15 +731,12 @@ dependencies = [ ] [[package]] -name = "ghostlight-verse" -version = "0.1.0" +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "anyhow", - "chrono", - "ghostlight", - "serde_json", - "tempfile", - "tokio", + "digest", ] [[package]] @@ -508,12 +772,24 @@ 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" @@ -527,6 +803,7 @@ dependencies = [ "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -567,7 +844,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.5", "tokio", "tower-service", "tracing", @@ -733,6 +1010,27 @@ 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" @@ -772,12 +1070,43 @@ 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" @@ -789,6 +1118,40 @@ 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" @@ -833,6 +1196,16 @@ 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" @@ -845,6 +1218,16 @@ 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" @@ -866,6 +1249,21 @@ 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" @@ -888,7 +1286,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2", + "socket2 0.6.5", "thiserror", "tokio", "tracing", @@ -904,7 +1302,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand", + "rand 0.10.2", "rand_pcg", "ring", "rustc-hash", @@ -926,7 +1324,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.6.5", "tracing", "windows-sys 0.61.2", ] @@ -940,12 +1338,28 @@ 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" @@ -957,6 +1371,16 @@ 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" @@ -966,6 +1390,15 @@ 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" @@ -999,6 +1432,23 @@ 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" @@ -1080,6 +1530,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" dependencies = [ "rmp", + "serde", + "serde_bytes", ] [[package]] @@ -1088,6 +1540,15 @@ 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" @@ -1154,6 +1615,12 @@ 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" @@ -1207,6 +1674,17 @@ 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" @@ -1230,6 +1708,15 @@ 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" @@ -1246,6 +1733,27 @@ 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" @@ -1258,6 +1766,16 @@ 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" @@ -1268,6 +1786,16 @@ 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" @@ -1355,6 +1883,45 @@ 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" @@ -1392,7 +1959,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] @@ -1444,6 +2011,7 @@ dependencies = [ "tokio", "tower-layer", "tower-service", + "tracing", ] [[package]] @@ -1454,13 +2022,23 @@ 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", ] @@ -1482,6 +2060,7 @@ 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", @@ -1505,6 +2084,36 @@ 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]] @@ -1519,6 +2128,12 @@ 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" @@ -1592,6 +2207,12 @@ 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" @@ -1613,6 +2234,15 @@ 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" @@ -1882,6 +2512,12 @@ 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" @@ -1911,6 +2547,26 @@ 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 aa81258..7bfeb7e 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,10 +13,19 @@ version = "0.1.0" [workspace.dependencies] anyhow = "1" +aes-gcm = "0.10" async-trait = "0.1" +async-stream = "0.3" +axum = { version = "0.8", features = ["macros"] } chrono = { version = "0.4", features = ["serde"] } +base64 = "0.22" codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" } cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultnet-rs" } +cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultmesh-rs" } +jsonwebtoken = "9" +hmac = "0.12" +rand = "0.9" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } rmp-serde = "1.3" serde = { version = "1", features = ["derive"] } @@ -24,7 +33,9 @@ serde_json = "1" sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["fs", "trace"] } tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } unicode-segmentation = "1" uuid = { version = "1", features = ["v4", "serde"] } zeroize = "1.8" diff --git a/README.md b/README.md index ff4a893..ffd2855 100644 --- a/README.md +++ b/README.md @@ -1,121 +1,149 @@ # Ghostlight -**Ghostlight is a world kernel: typed world state that a language model can -propose changes to and can never write.** The valuable part is -`crates/ghostlight`. The Dungeon game, one client of it, is parked (below). +Ghostlight is a Rust library that keeps a simulated world honest, and a play +daemon built on it. A language model proposes; the library's kernel checks each +proposal against typed world state and admits or refuses it. Places, people, +knowledge, commitments, and time live in the kernel's journal, not in a model's +context window, so they survive restarts and long stories. -**The thesis.** World state is typed documents. Every change to it, and every -narrative dependency on it, is an operation in a closed, predefined algebra. -Models propose operations; the kernel checks each against the state (references, -preconditions, authority, conservation, who knows what) and admits it or refuses -it with the complete list of mismatches. Narration describes only what was -committed. A model-as-game-master holds the world in a context window and -drifts; here the world is a digest-chained journal that survives restarts and -cannot be talked into contradicting itself. +## What is here -**Use it as a structured state backend.** Do not judge it by the parked Dungeon's -prose. The kernel is a library; prose comes from whichever model you plug into -its ports. +| Part | Path | What it is | +|---|---|---| +| World kernel | `crates/ghostlight/` | The sealed `ghostlight` library: world state, the closed operation set and reducer, the CultCache journal, the mailbox that is its only door, controllers, elaboration, lenses, the consumer ingress, and the inference ports. | +| Persona projection | `crates/ghostlight-persona-projection/` | The membrane between structured state and a prose-only Persona: projection, Persona turn receipts, and the controller descriptors the kernel consumes. | +| Dungeon | `crates/ghostlight-dungeon/` | The `ghostlight-dungeon` daemon: single-player, DM-led play over one world. HTTP and Eve routes, Heimdall sign-in, app sessions, the play table, CultMesh publication, and Idunn health. | +| Browser | `web/` | A thin Eve browser host and the Heimdall access adapter. It renders the server's surface and owns no product state. | +| Claude SDK sidecar | `sidecar/claude-sdk/` | A Node process that serves `claude`-prefixed models to the kernel through the Claude Agent SDK. It is a stopgap for a direct Messages API port. | +| Deploy recipe | `deployment/idunn/recipe.toml` | The steps Idunn runs to test, build, and seal a release. | +| Eve | `vendor/eve/` | A git submodule (`.gitmodules`) supplying the Eve browser lowering and contracts. | -- `crates/ghostlight`: the sealed library. A consumer gets create/open - (`WorldMailbox::open`, `create`), immutable snapshots (`snapshot`), command - submission through role-scoped ports (`PlayPort`, `ConsumerPort`, - `ControllerPort`, `SeedPort`), typed receipts, and the runner and inference-port - entry points (`ControllerRunner`, `ElaborationRunner`, `SeedRunner`, - `InferencePort`). Mutable state, ID issuers, the reducer and the journal - writer are not public. `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` - and `crates/ghostlight/tests/external_admission.rs` prove the seal. -- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary (subjects, - places, routes, holdings, knowledge, commitments, pressures), the operation - algebra, and what the reducer checks. -- `docs/architecture/ghostlight-world-consumer-api.md`: the contract for a - program that reads a world and proposes changes to the characters it controls - (`POST /cultnet/world-patch`; no process on main serves that door, a host serves - `ghostlight::admit_document`). +## How it works -Start with those two documents, then `cargo doc -p ghostlight --open`. +- The kernel is sealed. A consumer reaches it through create/open, immutable + snapshots, command submission, typed receipts, and the runner and port entry + points. There is no public mutable state, ID issuer, reducer, or journal + writer, and the crate declares no cargo features. The seal is proven by + `compile_fail` doc-tests in `crates/ghostlight/src/lib.rs` and by + `crates/ghostlight/tests/external_admission.rs`, which forges IDs, digests, + and opportunities and asserts that each is refused with nothing committed. +- Every change enters one mailbox (`crates/ghostlight/src/mailbox.rs`) as a + command, is decided by one reducer, and commits atomically to a + digest-chained journal (`crates/ghostlight/src/journal.rs`, CultCache on redb). +- Models author through typed tools and a repair loop + (`crates/ghostlight/src/elaboration.rs`, `tool_schema.rs`, `patch.rs`). A + refused proposal returns its mismatches to the model; it never edits state. +- A Persona speaks only prose. A Projector builds its view, and an Interpreter + lowers its words into typed proposals with exact source quotes + (`crates/ghostlight/src/controllers.rs`). +- Vault evidence for seeding comes from a read-only markdown directory reader + (`crates/ghostlight/src/vault.rs`). The Dungeon's seed lane reads the + directory named by `GHOSTLIGHT_SEED_VAULT_ROOT`, read once at startup, so + changing it needs a restart + (`crates/ghostlight-dungeon/src/runtime.rs`). +- Other programs can propose changes to the characters they control through + `POST /cultnet/world-patch` (`crates/ghostlight-dungeon/src/runtime.rs`, + `crates/ghostlight/src/consumer.rs`). The contract is + `docs/architecture/ghostlight-world-consumer-api.md`. -**Populate the algebra first.** The kernel owns the component operations; a world owns its -affordance catalog, the verbs its actors can attempt. A fresh world knows one: the built-in -Speak, which records an utterance and changes nothing. Until your world declares affordances -(at genesis, by patch, or through elaborators) for moving, trading, taking, deciding and so on, -every intent a model voices lowers to speech or to a recorded gap, and the prose built on that -will be thin no matter how good the model is. The Aetheria verse genesis -(`crates/ghostlight/src/verse/genesis.rs`) declares its grammar's verbs, and today they still -only record the act; their effects are the next phase. +## Ghostlight Dungeon -## Elaborators +Dungeon is the single-player, DM-led client. A player signs in with Heimdall, +creates a world, seeds it from a vault, approves and activates it, and plays in +free text. The play table (`crates/ghostlight-dungeon/src/play.rs`) owns the +turn lifecycle; the interface is one Eve surface, `ghostlight.play` +(`crates/ghostlight-dungeon/src/eve.rs`, described in +`docs/architecture/ghostlight-eve-native-interface.md`). Because its player is +not a developer, Dungeon is judged by whether someone can sit down and play. -An elaborator is a model session, confined to one jurisdiction of the world, that -adds structure where the world is thin: it answers a derived causal boundary -(a destination nobody has described yet) or a scale deficit with typed -declarations, and the kernel verifies the result like any other patch. Sessions -are resumable and checkpointed against committed history. Eight stock lenses -(`crates/ghostlight/src/lens.rs`: patina, charter, ledger, hearth, tangle, veil, -ember, numen) give each session a role-specific emphasis, for example customs and -local jokes, or money and ledgers. A lens changes the prompt only, never what the -kernel admits. Because boundaries are derived from state, elaborators can be -dispatched as the player approaches a place or a social structure, instead of -authoring the world up front. +Where it runs: -## Direction (planned, not built) +- **Yggdrasil**, under Idunn, built from `main` by `deployment/idunn/recipe.toml`. + Idunn owns the daemon's lifecycle and the write lease. Odin supplies discovery + and Heimdall's command-boundary record. The operating runbook is + `gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. +- **Inference** is routed by model name: a `local/` prefix goes to an + OpenAI-compatible loopback endpoint + (`GHOSTLIGHT_LOCAL_ENDPOINT`, `crates/ghostlight/src/local_inference.rs`), + which in production is Bonsai on Raven's GPU reached through a loopback + tunnel; a `claude` prefix goes to the SDK sidecar + (`GHOSTLIGHT_SDK_SIDECAR`, `crates/ghostlight/src/sdk_inference.rs`). + Ghostlight never reads, stores, or forwards a model credential. +- **Sign-in** is Heimdall's. Ghostlight keeps a hashed HttpOnly session and + derives authority from the world's owner and approvers. -Use the kernel to simulate a whole world with many kinds of actor: Aetheria's -factions and a persistent cast whose members keep identity, memory and -commitments across sessions. Newspaper-staff simulations before the 2026-08 -teardown are the earlier proof of the idea; that code is in git history, not in -the tree. Running this at scale is gated by inference cost, not by the design. -`crates/ghostlight-verse` is the first slice: `ghostlight-verse genesis` creates -an Aetheria verse world from the Aetheria catalog and `ghostlight-verse run` -advances it a number of ticks under a token cap, reporting per tick what each -faction did and what it cost. Nothing loads AetheriaLore prose, models -storylets, or produces episodes yet. Targets: `notes/ghostlight-verse-target.md`. +## Direction -## Ghostlight Dungeon (parked) +The next work makes Ghostlight the simulation behind Aetheria's public story. +The operator's rulings (campaign `ghostlight-verse` in the Eureka mind) ask for: -The single-player, DM-led client and its daemon, Eve play surface and browser -host are parked at tag `parked/ghostlight-eve-surface`. Nothing is deployed and -nothing on main serves HTTP. The Dungeon returns when Thing 2 gives it a UI -(follow-up `dungeon-ui-on-thing2`); the rebuild starts from the tag. +- a world loaded from the Aetheria verse, with factions and a cast; +- storylets for Aetheria; +- a persistent cast whose characters keep identity, memory, and commitments + across sessions, so serialized episodes can follow them; +- episodes a cheap pipeline can turn into a video stream, and channel + characters who later appear in the game. -## Repo map - -| Path | What it is | -|---|---| -| `crates/ghostlight/` | The sealed kernel library. | -| `crates/ghostlight-persona-projection/` | Membrane between structured state and a prose-only Persona. | -| `crates/ghostlight-verse/` | The `ghostlight-verse` operator command line (`genesis`, `run`). | -| `sidecar/claude-sdk/` | Node process serving `claude`-prefixed models to the kernel; a stopgap for a direct Messages API port. | -| `AGENTS.md` | Invariants and where things are. | -| `docs/architecture/ghostlight-dungeon-mvp.md` | The parked Dungeon's authority architecture (design record). | -| `notes/` | The verse campaign's target and map, the handoff pointer, local smoke notes. | - -`docs/public-architecture/`, `docs/articles/` and `docs/product/` are design -essays and proposals from before the rebuild. They describe organs (campaigns, -co-op, Nemesis, newspapers) that are not in `crates/`; they do not describe the -running machine. Pages ending in `-cut.md` or `-postmortem.md` are dated history. +None of this exists yet. Nothing under `crates/` loads Aetheria lore, models +storylets, or produces an episode. Every event the kernel admits is meant to +have exactly one render path: ship action filmed in Aetheria, or a social verb +staged as an Aetheria conversation. Today a world can declare any affordance +kind, and the kernel does not enforce that restriction. ## Build and test -Nothing deploys from this repo. Linux verification runs on Yggdrasil, not on a -workstation: +The release target is Linux on Yggdrasil; a Windows build says nothing about +it. Idunn runs the steps in `deployment/idunn/recipe.toml`, which are the +verification: ```sh -cargo build --locked --workspace +git submodule update --init vendor/eve cargo test --locked -p ghostlight-persona-projection cargo test --locked -p ghostlight -cargo test --locked -p ghostlight-verse +cargo test --locked -p ghostlight-dungeon --bin ghostlight-dungeon +cargo build --locked --release -p ghostlight-dungeon --bin ghostlight-dungeon +npm --prefix web ci +npm --prefix web test +npm --prefix web run build ``` -The kernel alone needs only `cargo test -p ghostlight`. The sidecar has -`npm run sdk:build` and `npm run sdk:test`. The acceptance test -`controllers::tests::real_local_model_cognition_modes_commit_speech` is ignored -by default and needs a real model. +The sidecar has its own `npm --prefix sidecar/claude-sdk run build` and `test` +(also the root scripts `sdk:build` and `sdk:test`). Four Dungeon tests are +`#[ignore]`d because they drive the vendored browser lowering through +`tools/eve_client_bridge.mjs` and need Node beside cargo. The release +acceptance step runs `controllers::tests::real_local_model_cognition_modes_commit_speech` +(ignored by default) against a real model. -Inference is routed by model name: `local/` goes to an OpenAI-compatible -loopback endpoint, `claude` to the SDK sidecar, and the verse runner's `hosted/` -to an https endpoint. Ghostlight never reads, stores or forwards a model -credential beyond a key file named at run time. +To run Dungeon locally: `npm run dungeon:run`, or +`cargo run -p ghostlight-dungeon -- --state-root PATH`. Configuration is by +`GHOSTLIGHT_*` environment variables; the recipe's `required_environment` +lists the ones a deployment sets. -Runtime documents are MessagePack-backed CultCache. JSON appears only at schema -publication, model-provider and diagnostic boundaries. +## Documents + +- `AGENTS.md`: repository doctrine for agents. +- `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture + of the world machine. +- `docs/architecture/ghostlight-world-ontology.md`: the vocabulary and the + kernel's current mechanism. +- `docs/architecture/ghostlight-world-consumer-api.md`: the consumer contract. +- `docs/architecture/ghostlight-eve-native-interface.md`: the Eve surface. +- `docs/architecture/ghostlight-library-extraction.md`, + `ghostlight-stock-lenses.md`: closed records of landed work. Pages ending in + `-cut.md` and `-postmortem.md` are dated history. +- `docs/architecture/ghostlight-play-agent.md`: historical. Its status line + predates the play-agent cuts; read it with `-cut.md` beside it, not as the + current play surface (`crates/ghostlight-dungeon/src/play.rs` is). +- `docs/architecture/ghostlight-session-zero.md`: unbuilt design, superseded by + the play agent. Nothing in `crates/` implements it. +- `notes/fresh-workspace-handoff.md`: where things run and where open work is + recorded. +- `docs/public-architecture/`, `docs/articles/`: unbuilt design and essays + written before the rebuild. They describe campaigns, co-op, Nemesis + and strategic resolvers as organs; none of that exists in `crates/`. They do not + describe the running machine. +- `docs/product/lore-vault-entitlements.md`: a product proposal for hosted + vaults; nothing in `crates/` implements it. + +Runtime documents use MessagePack-backed CultCache. JSON appears only at schema +publication, browser, model-provider, and diagnostic boundaries. diff --git a/crates/ghostlight-dungeon/Cargo.toml b/crates/ghostlight-dungeon/Cargo.toml new file mode 100644 index 0000000..032aecd --- /dev/null +++ b/crates/ghostlight-dungeon/Cargo.toml @@ -0,0 +1,48 @@ +[package] +name = "ghostlight-dungeon" +version.workspace = true +edition.workspace = true +license.workspace = true +autobins = false + +[[bin]] +name = "ghostlight-dungeon" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +aes-gcm.workspace = true +async-trait.workspace = true +async-stream.workspace = true +axum.workspace = true +chrono.workspace = true +base64.workspace = true +codex-connector.workspace = true +cultcache-rs.workspace = true +cultnet-rs.workspace = true +cultmesh-rs.workspace = true +jsonwebtoken.workspace = true +libc = "0.2" +hmac.workspace = true +ghostlight = { path = "../ghostlight" } +ghostlight-persona-projection = { path = "../ghostlight-persona-projection" } +rand.workspace = true +reqwest.workspace = true +rmp-serde.workspace = true +serde.workspace = true +serde_json.workspace = true +serde_bytes = "0.11" +sha2.workspace = true +thiserror.workspace = true +tokio.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true +zeroize.workspace = true + +[dev-dependencies] +ghostlight = { path = "../ghostlight", features = ["test-support"] } +tempfile = "3" +tokio = { workspace = true, features = ["test-util"] } +tower = { version = "0.5", features = ["util"] } diff --git a/crates/ghostlight-dungeon/build.rs b/crates/ghostlight-dungeon/build.rs new file mode 100644 index 0000000..9eaf054 --- /dev/null +++ b/crates/ghostlight-dungeon/build.rs @@ -0,0 +1,65 @@ +use std::{env, path::Path, process::Command}; + +fn main() { + println!("cargo:rerun-if-env-changed=GHOSTLIGHT_BUILD_COMMIT"); + let manifest_dir = + env::var("CARGO_MANIFEST_DIR").expect("Cargo must supply CARGO_MANIFEST_DIR"); + let repo_root = Path::new(&manifest_dir).join("../.."); + emit_git_rerun_paths(&repo_root); + // Release tooling injects the clean-tree commit. Git discovery is only a + // convenience for local builds. The exact ref and reflog dependencies above + // keep that convenience from silently publishing stale provenance. + let commit = env::var("GHOSTLIGHT_BUILD_COMMIT") + .ok() + .filter(|value| is_commit(value)) + .or_else(|| git_commit(&repo_root)) + .expect("GhostlightDungeon builds require an exact 40-hex source commit"); + println!("cargo:rustc-env=GHOSTLIGHT_BUILD_COMMIT={commit}"); +} + +fn emit_git_rerun_paths(repo_root: &Path) { + for git_path in ["HEAD", "logs/HEAD", "packed-refs"] { + if let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", git_path]) { + emit_rerun_path(repo_root, &path); + } + } + + if let Some(reference) = git_output(repo_root, &["symbolic-ref", "-q", "HEAD"]) + && let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", &reference]) + { + emit_rerun_path(repo_root, &path); + } +} + +fn emit_rerun_path(repo_root: &Path, git_path: &str) { + let path = Path::new(git_path); + let resolved = if path.is_absolute() { + path.to_path_buf() + } else { + repo_root.join(path) + }; + println!("cargo:rerun-if-changed={}", resolved.display()); +} + +fn git_commit(repo_root: &Path) -> Option { + 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 new file mode 100644 index 0000000..f35a449 --- /dev/null +++ b/crates/ghostlight-dungeon/src/app_session.rs @@ -0,0 +1,939 @@ +//! Local custody for authenticated Heimdall sessions. +//! +//! This store owns only browser-session continuity. It does not own world +//! selection, commands, idempotency, exports, or fictional state. + +use crate::heimdall::{VerifiedSessionAdmission, VerifiedSessionRefresh}; +use ghostlight::VerifiedPrincipalEvidence; +use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; +use anyhow::{Context, bail}; +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use chrono::{DateTime, Utc}; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{collections::BTreeMap, path::Path}; + +const STORE_TYPE: &str = "ghostlight.app_session_store.v2"; +/// `pub(crate)` so the recipe-drift test in `idunn_health.rs` can check the +/// `app-sessions` state slot's schema against this constant. +pub(crate) const STORE_SCHEMA: &str = "ghostlight.app_session_store.v2"; +const STORE_KEY: &str = "primary"; +const MAX_SESSION_RECORDS: usize = 1_024; + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AppSessionState { + schema: String, + sessions: BTreeMap, +} + +#[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 new file mode 100644 index 0000000..4249b93 --- /dev/null +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -0,0 +1,1007 @@ +//! 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 new file mode 100644 index 0000000..3907bbc --- /dev/null +++ b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json @@ -0,0 +1,107 @@ +{ + "schema": "ghostlight.eve_client_bridge_fixture.v1", + "steps": [ + { + "intent": { + "clientId": "eve.bridge.test", + "commandBoundary": "ghostlight.eve.commands", + "issuedAt": "2026-10-09T23:58:34.345Z", + "operation": { + "idempotencyKey": "00e4b100-7626-4c62-8f45-5c2cfd5b5528", + "operationId": "world.create", + "routeHint": { + "sourceVersion": 0, + "transport": "https-json" + }, + "schemaId": "ghostlight.world_create.v4" + }, + "payload": { + "bindings": { + "brief": "A world captured for the committed Eve client bridge fixture.", + "jurisdictions": "[]", + "lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}", + "subject_label": "The Fixture Operator", + "targets": "{}", + "title": "Bridge Fixture World" + } + }, + "providerId": "gamecult.ghostlight.dungeon", + "receiptSchema": "gamecult.eve.command_result.v1", + "schema": "gamecult.eve.command_invocation.v1", + "surfaceId": "ghostlight.play" + }, + "label": "world.create" + }, + { + "intent": { + "clientId": "eve.bridge.test", + "commandBoundary": "ghostlight.eve.commands", + "issuedAt": "2026-10-09T23:58:35.759Z", + "operation": { + "idempotencyKey": "10b5cf50-43c3-4735-b54c-3cec0c1e632b", + "operationId": "world.approve", + "routeHint": { + "sourceVersion": 1, + "transport": "https-json" + }, + "schemaId": "ghostlight.world_approve.v0" + }, + "payload": {}, + "providerId": "gamecult.ghostlight.dungeon", + "receiptSchema": "gamecult.eve.command_result.v1", + "schema": "gamecult.eve.command_invocation.v1", + "surfaceId": "ghostlight.play" + }, + "label": "world.approve" + }, + { + "intent": { + "clientId": "eve.bridge.test", + "commandBoundary": "ghostlight.eve.commands", + "issuedAt": "2026-10-09T23:58:37.240Z", + "operation": { + "idempotencyKey": "d89dea99-cfeb-4992-9d5d-33b3f7a1cce2", + "operationId": "world.activate", + "routeHint": { + "sourceVersion": 2, + "transport": "https-json" + }, + "schemaId": "ghostlight.world_activate.v0" + }, + "payload": {}, + "providerId": "gamecult.ghostlight.dungeon", + "receiptSchema": "gamecult.eve.command_result.v1", + "schema": "gamecult.eve.command_invocation.v1", + "surfaceId": "ghostlight.play" + }, + "label": "world.activate" + }, + { + "intent": { + "clientId": "eve.bridge.test", + "commandBoundary": "ghostlight.eve.commands", + "issuedAt": "2026-10-09T23:58:38.611Z", + "operation": { + "idempotencyKey": "d654e813-be7d-4818-9872-9aecd86c4e39", + "operationId": "world.play", + "routeHint": { + "sourceVersion": 3, + "transport": "https-json" + }, + "schemaId": "ghostlight.world_play.v0" + }, + "payload": { + "bindings": { + "text": "The new owner looks around." + } + }, + "providerId": "gamecult.ghostlight.dungeon", + "receiptSchema": "gamecult.eve.command_result.v1", + "schema": "gamecult.eve.command_invocation.v1", + "surfaceId": "ghostlight.play" + }, + "label": "world.play" + } + ], + "vendorEveSubmoduleRevision": "672c0c1e9fc828b63752345385727b06c50491d0" +} diff --git a/crates/ghostlight-dungeon/src/heimdall.rs b/crates/ghostlight-dungeon/src/heimdall.rs new file mode 100644 index 0000000..9b9e27b --- /dev/null +++ b/crates/ghostlight-dungeon/src/heimdall.rs @@ -0,0 +1,1678 @@ +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 new file mode 100644 index 0000000..f11f0f5 --- /dev/null +++ b/crates/ghostlight-dungeon/src/idunn_health.rs @@ -0,0 +1,2001 @@ +//! 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.v18\"\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 new file mode 100644 index 0000000..6f0faee --- /dev/null +++ b/crates/ghostlight-dungeon/src/main.rs @@ -0,0 +1,21 @@ +mod app_session; +mod eve; +mod heimdall; +mod idunn_health; +mod mesh; +mod play; +mod runtime; + +use anyhow::bail; +use std::path::PathBuf; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let args = std::env::args_os().skip(1).collect::>(); + 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 new file mode 100644 index 0000000..e230aa1 --- /dev/null +++ b/crates/ghostlight-dungeon/src/mesh.rs @@ -0,0 +1,464 @@ +//! Derived CultMesh and Eve projection. +//! +//! The mesh publishes the world owner's current surface. It never pulls or +//! repairs world state and it has no command authority. + +use crate::eve; +use ghostlight::{CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, STATE_SCHEMA, WorldSnapshot}; +use anyhow::Result; +use chrono::{DateTime, Utc}; +use cultcache_rs::DatabaseEntry; +use cultmesh_rs::{ + CultMesh, CultMeshNode, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions, + publish_cultnet_messages_to_rudp_catalog, +}; +use serde_json::{Value, json}; +use std::{ + net::SocketAddr, + path::Path, + sync::{ + Arc, Condvar, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, +}; + +pub(crate) const PROVIDER_ID: &str = "gamecult.ghostlight.dungeon"; +pub(crate) const SURFACE_ID: &str = "ghostlight.play"; +pub(crate) const COMMAND_BOUNDARY: &str = "ghostlight.eve.commands"; +pub(crate) const COMMAND_RESULT_SCHEMA: &str = "gamecult.eve.command_result.v1"; +pub(crate) const COMMAND_RECEIPT_SCHEMA: &str = "gamecult.eve.command_receipt.v1"; +/// The repository that owns this surface, as the command receipt contract +/// requires of every result. +pub(crate) const OWNER_REPO: &str = "GameCult/Ghostlight"; + +const HEALTH_KEY: &str = "ghostlight:dungeon:health"; +const ADVERTISEMENT_KEY: &str = "eve:provider:gamecult.ghostlight.dungeon"; +const SURFACE_KEY: &str = "eve:surface:ghostlight.play"; + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache(type = "gamecult.eve.surface", schema = "gamecult.eve.surface.v1")] +struct EveSurfaceRecord { + #[cultcache(key = 0)] + value: Value, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "gamecult.eve.provider_advertisement", + schema = "gamecult.eve.provider_advertisement.v1" +)] +struct EveProviderAdvertisementRecord { + #[cultcache(key = 0)] + value: Value, +} + +#[derive(Clone, Debug, PartialEq, DatabaseEntry)] +#[cultcache( + type = "ghostlight.service_health", + schema = "ghostlight.service_health.v2" +)] +struct ServiceHealthRecord { + #[cultcache(key = 0)] + value: Value, +} + +cultmesh_rs::cultmesh_documents!(GhostlightDocuments { + EveSurfaceRecord => "gamecult.eve.surface.v1", + EveProviderAdvertisementRecord => "gamecult.eve.provider_advertisement.v1", + ServiceHealthRecord => "ghostlight.service_health.v2", +}); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct MeshRuntimeIdentity { + pub(crate) runtime_id: String, + pub(crate) service_id: String, + pub(crate) located_service: String, +} + +impl Default for MeshRuntimeIdentity { + fn default() -> Self { + Self { + runtime_id: "ghostlight-dungeon".into(), + service_id: "ghostlight-dungeon".into(), + located_service: "local".into(), + } + } +} + +#[derive(Clone)] +pub(crate) struct MeshPublisher { + node: Arc>, + 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 new file mode 100644 index 0000000..6a08445 --- /dev/null +++ b/crates/ghostlight-dungeon/src/play.rs @@ -0,0 +1,11900 @@ +//! 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), None, &["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 new file mode 100644 index 0000000..74fd519 --- /dev/null +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -0,0 +1,7366 @@ +//! 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, None, &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, 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 new file mode 100644 index 0000000..09a4ee1 --- /dev/null +++ b/crates/ghostlight-dungeon/tests/build_provenance.rs @@ -0,0 +1,27 @@ +use std::{fs, path::Path, process::Command}; + +#[test] +fn embedded_commit_matches_checkout_head() { + let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(&repo_root) + .output() + .expect("git must be available for the provenance regression test"); + let checkout_head = if output.status.success() { + String::from_utf8(output.stdout) + .expect("git commit must be UTF-8") + .trim() + .to_owned() + } else { + fs::read_to_string(repo_root.join(".ghostlight-source-commit")) + .expect("frozen source archive must carry its exact commit witness") + .trim() + .to_owned() + }; + assert_eq!( + env!("GHOSTLIGHT_BUILD_COMMIT"), + checkout_head, + "the binary embedded stale Git provenance" + ); +} diff --git a/crates/ghostlight-verse/Cargo.toml b/crates/ghostlight-verse/Cargo.toml deleted file mode 100644 index 89dd37f..0000000 --- a/crates/ghostlight-verse/Cargo.toml +++ /dev/null @@ -1,15 +0,0 @@ -[package] -name = "ghostlight-verse" -version.workspace = true -edition.workspace = true -license.workspace = true - -[dependencies] -anyhow.workspace = true -chrono.workspace = true -ghostlight = { path = "../ghostlight" } -tokio.workspace = true - -[dev-dependencies] -serde_json.workspace = true -tempfile = "3" diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs deleted file mode 100644 index 9c70d06..0000000 --- a/crates/ghostlight-verse/src/main.rs +++ /dev/null @@ -1,346 +0,0 @@ -//! The verse runner's operator command line. -//! -//! `genesis` creates the canonical Aetheria verse world in a state root, and -//! `run` advances it a given number of ticks under a token cap, printing per -//! tick what each faction did and what it cost. A run starts only when this -//! command is invoked, and ends when it has done what it was asked. -//! -//! The state root holds `world.cc`, `controller-work.cc`, `runs.cc` and -//! `calls.cc`. The provider key is read from a file at run time and appears -//! nowhere else. - -use anyhow::{Context, Result, anyhow, bail}; -use chrono::{Duration, Utc}; -use ghostlight::{ - CommandBody, CommandId, ControllerModels, ControllerOpenError, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX, - DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent, - HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort, - RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore, - WorldMailbox, call_run_id, id_text, open_controller_work, open_inference, - read_aetheria_catalog, run_manual, -}; -use std::collections::BTreeMap; -use std::net::SocketAddr; -use std::ops::ControlFlow; -use std::path::{Path, PathBuf}; -use std::process::ExitCode; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; - -/// What a manual run spends per tick on cognition cells. The operator's lever -/// on cost is the token cap; the partition always covers every faction. -const COVER: CoverBudget = CoverBudget { - cells: 8, - constituent_cap: 8, - urgency_slots: 2, -}; - -const RUNTIME_ID: &str = "ghostlight-verse"; - -#[tokio::main] -async fn main() -> ExitCode { - let arguments: Vec = 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 deleted file mode 100644 index 28cf75d..0000000 --- a/crates/ghostlight-verse/tests/verse_binary.rs +++ /dev/null @@ -1,445 +0,0 @@ -//! The `ghostlight-verse` command against a loopback scripted responder. No -//! real model is called and no key file is read: the responder answers the -//! OpenAI-compatible chat-completions request the local lane sends. - -use serde_json::{Value, json}; -use std::io::{Read, Write}; -use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::path::Path; -use std::process::{Command, Output}; -use std::sync::Arc; -use std::sync::atomic::{AtomicUsize, Ordering}; - -const BINARY: &str = env!("CARGO_BIN_EXE_ghostlight-verse"); -const CATALOG: &str = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../ghostlight/tests/fixtures/aetheria-verse-catalog.cc" -); -const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; -/// What the responder reports each call cost: 120 prompt and 30 completion. -const PER_CALL: u64 = 150; - -fn verse(arguments: &[&str]) -> Output { - Command::new(BINARY).args(arguments).output().unwrap() -} - -fn stdout(output: &Output) -> String { - String::from_utf8_lossy(&output.stdout).into_owned() -} - -fn stderr(output: &Output) -> String { - String::from_utf8_lossy(&output.stderr).into_owned() -} - -fn genesis(state: &Path) -> Output { - verse(&[ - "genesis", - "--catalog", - CATALOG, - "--aetheria-commit", - COMMIT, - "--state", - state.to_str().unwrap(), - "--owner-hash", - "verse-owner", - ]) -} - -/// Answers every chat-completions request with one tool call: the faction the -/// prompt names speaks its own line. Counts the requests it served. -struct Responder { - address: SocketAddr, - served: Arc, -} - -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/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index c9333ec..45f1702 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -524,66 +524,3 @@ fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() { 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 e764dde..2df43ad 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, TickIndex, + FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability, WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph, commit_digest, derive_boundaries, derive_cover, derive_opportunities, @@ -736,7 +736,8 @@ mod tests { assert!(!graph.subjects.contains(&mirror.first)); let cover = derive_cover( snapshot.world_id, - TickIndex(0), + snapshot.now, + 30, &opportunities, &graph, CoverBudget { diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 97d7edd..5d3e573 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -80,7 +80,7 @@ const HANDLE_SEPARATOR: &str = "__"; const PERSONA_WORD_BUDGET: usize = 180; 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) can check +/// test pinning one, e.g. `ghostlight-dungeon`'s recipe-drift test) can check /// itself against this constant instead of hand-copying the version number. pub const CONTROLLER_WORK_SCHEMA: &str = "ghostlight.controller_work.v18"; @@ -1446,34 +1446,6 @@ 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 @@ -1541,20 +1513,10 @@ fn select_scope( .cloned() .collect::>(); let [opportunity] = matches.as_slice() 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 { + return if matches.is_empty() { Err(ControllerError::NoOpportunity { expected }) + } else { + Err(ControllerError::AmbiguousOpportunity) }; }; let granted: Vec = snapshot @@ -1622,15 +1584,12 @@ fn turn_matches_opportunity(turn: &PersonaTurn, opportunity: &DecisionOpportunit && opportunity.scope_digest.as_str() == turn.binding().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 +/// 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 } fn derive_narrative_capture( @@ -2884,46 +2843,6 @@ 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)] @@ -2931,14 +2850,7 @@ 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. @@ -2971,8 +2883,6 @@ 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")] @@ -3126,9 +3036,7 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Narrative(checkpoint)) => { - // 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) { + if !binds_same_scope(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return self @@ -3136,7 +3044,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Narrative(checkpoint)) => { - if !same_decision(checkpoint.opportunity(), opportunity) { + if !binds_same_scope(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return Ok(narrative_pending( @@ -3208,7 +3116,7 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Operational(checkpoint)) => { - if !same_decision(checkpoint.opportunity(), opportunity) { + if !binds_same_scope(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return self @@ -3216,7 +3124,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Operational(checkpoint)) => { - if !same_decision(checkpoint.opportunity(), opportunity) { + if !binds_same_scope(checkpoint.opportunity(), opportunity) { return Err(ControllerError::OpportunityMismatch); } return Ok(operational_pending( @@ -3464,7 +3372,7 @@ impl ControllerRunner { if checkpoint.cell() != cell { return Err(ControllerError::OpportunityMismatch); } - return self.resume_row(command_id, checkpoint, members).await; + return self.resume_persisted_group(command_id, checkpoint).await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Grouped(checkpoint)) => { if checkpoint.cell() != cell { @@ -3503,7 +3411,7 @@ impl ControllerRunner { .map_err(ControllerError::Snapshot)?; let mut selected = Vec::new(); let mut constituents = Vec::new(); - let mut refused = Vec::new(); + let mut needs = Vec::new(); for member in members { match select_one(&snapshot, &member.opportunity) { Ok(decision) => { @@ -3524,11 +3432,8 @@ 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. - // 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), + Err(error) => needs.push(ControllerNeed { + detail: format!("a constituent left the cell before selection: {error}"), }), } } @@ -3537,9 +3442,7 @@ impl ControllerRunner { cell, resolution: Resolution::Coarse { constituents: 0 }, submissions: Vec::new(), - needs: Vec::new(), - unattributed: Vec::new(), - refused, + needs, pending: None, }); } @@ -3592,43 +3495,12 @@ impl ControllerRunner { { grouped_pending(checkpoint, ControllerPendingReason::StoreReopenRequired) } else { - self.resume_persisted_group(command_id, checkpoint, members) - .await? + self.resume_persisted_group(command_id, checkpoint).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.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); + run.needs.splice(0..0, needs); Ok(run) } @@ -3636,17 +3508,16 @@ 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, members).await + self.run_group_pending(checkpoint).await } checkpoint @ GroupedCheckpoint::Submitting { .. } => { - self.submit_group(checkpoint, members).await + self.submit_group(checkpoint).await } } } @@ -3654,7 +3525,6 @@ impl ControllerRunner { async fn run_group_pending( &self, mut checkpoint: GroupedCheckpoint, - members: &[Constituent], ) -> Result { loop { let GroupedCheckpoint::AgentInFlight { @@ -3702,7 +3572,7 @@ impl ControllerRunner { ControllerPendingReason::StoreReopenRequired, )); } - return self.submit_group(next, members).await; + return self.submit_group(next).await; } Ok(GroupedLoopEvaluation::Continue { conversation }) => { let round = completed.len(); @@ -3753,7 +3623,6 @@ impl ControllerRunner { async fn submit_group( &self, checkpoint: GroupedCheckpoint, - members: &[Constituent], ) -> Result { let GroupedCheckpoint::Submitting { cell, @@ -3780,30 +3649,8 @@ impl ControllerRunner { let resolution = Resolution::Coarse { constituents: constituents.len(), }; - let needs = capture.needs; - let unattributed = capture.unattributed; + let mut needs = capture.needs; 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 @@ -3812,34 +3659,8 @@ 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, 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 }) - }); + ordered.sort_by_key(|(handle, _)| capture.proposals.contains_key(handle)); 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, @@ -3860,20 +3681,14 @@ 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)) => refused.push(ConstituentRefusal { - subject: constituent.subject, - reason: match error { - KernelError::ScopeChanged { .. } => ConstituentRefusalReason::ScopeChanged, - _ => ConstituentRefusalReason::WorldRefused, - }, + Err(ControllerError::World(error)) => needs.push(ControllerNeed { + detail: format!("constituent c{handle} was refused by the world: {error}"), }), Err(error) => return Err(error), } @@ -3889,8 +3704,6 @@ impl ControllerRunner { resolution, submissions, needs, - unattributed, - refused, pending: None, }) } @@ -3911,13 +3724,10 @@ impl ControllerRunner { select_one(&snapshot, exact_opportunity) } - /// 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. + /// 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. async fn ensure_scope_unchanged( &self, opportunity: &DecisionOpportunity, @@ -4325,9 +4135,16 @@ 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 @@ -5947,17 +5764,8 @@ 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, - /// unless it is in `undecodable`. + /// Handle to proposal. A handle absent here proposed nothing and declines. 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, } @@ -5971,11 +5779,6 @@ 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, } @@ -5984,8 +5787,6 @@ impl GroupedFold { Self { proposals: BTreeMap::new(), terminal: BTreeSet::new(), - decode_failed: BTreeSet::new(), - unattributed: Vec::new(), needs: Vec::new(), } } @@ -6004,7 +5805,6 @@ 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, @@ -6035,7 +5835,6 @@ 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() } @@ -6050,7 +5849,6 @@ 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() @@ -6071,7 +5869,6 @@ 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() @@ -6079,7 +5876,6 @@ pub(super) fn grouped_tool_result( } } _ => { - fold.decode_failed.insert(handle); fold.needs.push(tool_decode_need( name, arguments, @@ -6197,22 +5993,6 @@ 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, }, }); @@ -6275,8 +6055,6 @@ fn grouped_pending(checkpoint: GroupedCheckpoint, reason: ControllerPendingReaso }, submissions: Vec::new(), needs: Vec::new(), - unattributed: Vec::new(), - refused: Vec::new(), pending: Some(reason), } } @@ -8432,7 +8210,12 @@ mod tests { .turn(CommandId::new(), &stale_opportunity) .await .unwrap_err(); - assert!(matches!(error, ControllerError::ScopeMoved), "{error:?}"); + assert!(matches!( + error, + ControllerError::NoOpportunity { + expected: ControllerMode::NarrativePersona + } + )); assert!( capturing.prepared.lock().unwrap().is_empty(), "a stale scope digest reached inference" @@ -11348,13 +11131,14 @@ 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: the scope is - // still offered, at a digest this caller did not see. + // A digest the world does not derive selects nothing. 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::ScopeMoved) + Err(ControllerError::NoOpportunity { + expected: ControllerMode::NarrativePersona + }) )); let NarrativeRun::Completed(resumed) = runner.run_narrative(command_id, &refreshed).await.unwrap() @@ -14915,7 +14699,8 @@ mod tests { let graph = mailbox.agency_graph().await.unwrap(); let cover = derive_cover( snapshot.world_id, - TickIndex(snapshot.now.0 / 60), + snapshot.now, + 60, &snapshot.opportunities, &graph, CoverBudget { @@ -14962,9 +14747,8 @@ 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 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. + /// mailbox; every silent handle declines, so "attended and stayed silent" is + /// distinguishable from "was never attended". #[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![ @@ -14997,8 +14781,7 @@ mod tests { }; assert!(run.pending.is_none()); assert_eq!(run.resolution, Resolution::Coarse { constituents: 3 }); - assert_eq!(run.submissions.len(), 1, "only the attributed call committed"); - assert_eq!(run.unattributed, vec!["c7__speak".to_owned()]); + assert_eq!(run.submissions.len(), 3, "every constituent finished"); let first = cell.members()[0].subject; assert!(matches!( run.submissions @@ -15007,17 +14790,16 @@ mod tests { .map(|entry| &entry.submission), Some(SubmissionDisposition::Completed(_)) )); - 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" - ); + for entry in run + .submissions + .iter() + .filter(|entry| entry.subject != first) + { + assert!( + matches!(entry.submission, SubmissionDisposition::NoProposal(_)), + "a silent constituent did not decline" + ); + } assert!( run.needs .iter() @@ -15035,241 +14817,6 @@ 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 @@ -15613,8 +15160,13 @@ mod tests { run.submissions[0].submission, SubmissionDisposition::Completed(_) )); - assert_eq!(run.refused.len(), 1, "the refused act was not recorded"); - assert_eq!(run.refused[0].reason, ConstituentRefusalReason::ScopeChanged); + assert!( + run.needs + .iter() + .any(|need| need.detail.contains("scope changed")), + "the refused act was not recorded: {:?}", + run.needs + ); assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); drop(runner); @@ -15782,9 +15334,12 @@ mod tests { panic!("a coarse cell did not run the grouped lane") }; assert_eq!(run.submissions.len(), 1); - assert_eq!( - run.refused.iter().map(|entry| (entry.subject, entry.reason)).collect::>(), - vec![(second, ConstituentRefusalReason::ScopeChanged)] + assert!( + run.needs + .iter() + .any(|need| need.detail.contains("scope changed")), + "{:?}", + run.needs ); let after = digest_of(&mailbox.snapshot().await.unwrap(), second) @@ -15921,8 +15476,7 @@ mod tests { panic!("a coarse cell did not run the grouped lane") }; assert!(run.pending.is_none()); - assert_eq!(run.submissions.len(), 1); - assert_eq!(run.refused.len(), 2); + assert_eq!(run.submissions.len(), 3); let live = mailbox.snapshot().await.unwrap(); assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); @@ -19531,7 +19085,7 @@ mod tests { .run_operational(operational_command, &operational) .await; assert!( - matches!(refused, Err(ControllerError::ScopeMoved)), + matches!(refused, Err(ControllerError::NoOpportunity { .. })), "the operational lane stopped holding its early abort: {refused:?}" ); assert_eq!( @@ -19577,1401 +19131,4 @@ 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 5d1ce42..02c0adb 100644 --- a/crates/ghostlight/src/cover.rs +++ b/crates/ghostlight/src/cover.rs @@ -11,6 +11,7 @@ //! The cover is a partition, never a filter: every active subject is in exactly //! one cell every tick. The budget decides at what resolution, not whether. +use super::clock::FictionalMinutes; use super::{ControllerMode, DecisionOpportunity, SubjectId, WorldId}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -28,18 +29,25 @@ pub(crate) const AGENCY_STAR_THRESHOLD: usize = 16; /// the name, not by a second namespace. const COVER_NAMESPACE: &str = "ghostlight.cover.v1"; -/// A tick's ordinal: the number of clock advances the world had committed when -/// the tick opened. The driver reads it from the world and freezes it in the -/// tick document; it is never derived from `now`, so it does not depend on the -/// span. It is threaded into every derived command id, so a resumed tick -/// re-submits the same ids and the kernel's idempotency ledger answers with the -/// original receipts instead of double-committing. +/// Monotone tick index. Derived from the world clock, never stored. It is +/// threaded into every derived command id, so a resumed tick over the same +/// snapshot re-derives the same ids and the kernel's idempotency ledger answers +/// with the original receipts instead of double-committing. #[derive( Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, )] #[serde(transparent)] pub struct TickIndex(pub u64); +impl TickIndex { + /// Floor division by the span one `AdvanceTime` carries. The driver submits + /// the clock *after* the cells run, so every cell in one tick sees the same + /// `now` and therefore the same index. + pub(crate) fn of(now: FictionalMinutes, tick_minutes: u32) -> Self { + Self(now.0 / u64::from(tick_minutes.max(1))) + } +} + /// How a subject was represented this tick. Derived from the cell, carried /// nowhere in world state: representation is a compute budget, not world truth. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -246,7 +254,7 @@ impl AgencyGraph { } } -/// The partition. Pure over `(world, tick, opportunities, graph, budget)`. +/// The partition. Pure over `(world, now, opportunities, graph, budget)`. /// /// `opportunities` arrives in the kernel's one attention order (pressure, then /// attention debt, then id) and is never re-sorted and never filtered here: a @@ -254,11 +262,13 @@ impl AgencyGraph { /// is a fact about cognition rather than a scheduling preference. pub fn derive_cover( world: WorldId, - tick: TickIndex, + now: FictionalMinutes, + tick_minutes: u32, opportunities: &[DecisionOpportunity], graph: &AgencyGraph, budget: CoverBudget, ) -> Cover { + let tick = TickIndex::of(now, tick_minutes); let active: Vec<&DecisionOpportunity> = opportunities .iter() .filter(|opportunity| opportunity.controller_mode != ControllerMode::Human) @@ -591,7 +601,8 @@ mod tests { ) -> Cover { derive_cover( WorldId::nil_for_test(), - TickIndex(tick), + FictionalMinutes(tick * 60), + 60, opportunities, graph, budget, diff --git a/crates/ghostlight/src/grammar.rs b/crates/ghostlight/src/grammar.rs index a11cd69..b827461 100644 --- a/crates/ghostlight/src/grammar.rs +++ b/crates/ghostlight/src/grammar.rs @@ -8,7 +8,7 @@ //! digest its verbs do not have. use crate::SubjectKind; -use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry}; +use crate::patch::{AffordanceKindName, EntityKind, RefKind, RoleSpec, kernel_speak_entry}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; @@ -63,20 +63,10 @@ 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(), + self.roles.len() == roles.len() + && self.roles.iter().zip(roles).all(|(want, have)| { + want.role == have.role.0 && want.referent.ref_kind() == have.kind }) - .collect() } } @@ -175,11 +165,6 @@ impl GrammarBinding { } } - /// 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) } diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index a2d36fb..8163d3a 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -181,11 +181,6 @@ impl WorldJournal { self.commits.get(&command_id) } - /// Every commit the journal holds, in no particular order. - pub(super) fn commits(&self) -> impl Iterator { - self.commits.values() - } - #[cfg(test)] pub(super) fn commit_count(&self) -> usize { self.commits.len() diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 3f64965..727733e 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -85,7 +85,6 @@ pub use consumer::{ }; pub use controllers::{ CONTROLLER_WORK_SCHEMA, CellRun, ConnectorBinding, ControllerError, ControllerModels, - ControllerOpenError, ControllerNeed, ControllerPendingReason, ControllerRunner, ControllerWork, ControllerWorkCustody, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError, ControllerWorkWrite, @@ -145,11 +144,8 @@ pub use table::{ }; 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, + CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, + ReplayInferencePort, RunField, RunId, RunStart, }; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; @@ -1279,21 +1275,6 @@ 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)] @@ -1584,43 +1565,7 @@ 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()?; - // 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) + operator_log(&self.state) } /// The scheduler's adjacency projection. Separate from `snapshot` for the @@ -3795,10 +3740,7 @@ fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponent /// The human operator's story feed, derived from the causal record. The events /// log is not a perception surface: `knowledge` is the sole answer to what a /// subject may act on and what its projection may contain. -fn operator_log( - state: &WorldState, - invocations: &BTreeMap, -) -> Result, KernelError> { +fn operator_log(state: &WorldState) -> Result, KernelError> { state .events .iter() @@ -3807,30 +3749,6 @@ fn operator_log( .subjects .get(&event.scope.subject_id) .ok_or_else(|| KernelError::Invariant("event names no live subject".into()))?; - let invocation = invocations.get(&event.id).ok_or_else(|| { - KernelError::Invariant("event has no committed invocation".into()) - })?; - let entry = state - .affordance_catalog - .get(&event.affordance) - .ok_or_else(|| KernelError::Invariant("event names no catalog entry".into()))?; - let roles = invocation - .bindings - .iter() - .map(|binding| { - let label = match binding.target { - Target::Subject(id) => state.subjects.get(&id).map(|s| s.label.clone()), - Target::Entity(id) => state.entities.get(&id).map(|e| e.label.clone()), - Target::Edge(id) => state.edges.contains_key(&id).then(|| "edge".to_owned()), - }; - label - .map(|target| BoundRole { - role: binding.role.0.clone(), - target, - }) - .ok_or_else(|| KernelError::Invariant("event binds no live referent".into())) - }) - .collect::, _>>()?; Ok(OperatorEvent { revision: event.revision, speaker: event.scope.subject_id, @@ -3843,13 +3761,6 @@ fn operator_log( .display .and_then(|fact| state.facts.get(&fact)) .map(|record| record.statement.clone()), - verb: entry.kind.0.clone(), - render_path: state - .grammar - .as_ref() - .and_then(|grammar| grammar.verb(&entry.kind.0)) - .map(|verb| verb.render_path), - roles, }) }) .collect() @@ -11622,7 +11533,7 @@ mod soul_knowledge_tests { // Nobody holds it. assert!(kernel.state.knowledge.is_empty()); // The operator reads it anyway, with the speaker resolved by label. - let log = kernel.operator_log().unwrap(); + let log = operator_log(&kernel.state).unwrap(); assert_eq!( log, vec![OperatorEvent { @@ -11631,9 +11542,6 @@ mod soul_knowledge_tests { speaker_label: "The Yard Bystander".into(), speech: Some(Statement::new(text).unwrap()), display: None, - verb: "speak".into(), - render_path: None, - roles: Vec::new(), }] ); // And no subject snapshot carries it. diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index c9b232b..be84e8f 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -3,10 +3,9 @@ use super::{ ConsumerId, CreateWorld, CreateWorldIntent, CreationReceipt, DecisionInvocation, DecisionOpportunity, Declaration, DraftHandle, EntityDeclaration, EntityKind, JurisdictionKey, KernelError, NewController, OperatorEvent, PatchAnswer, PrincipalCommandIntent, PrincipalId, - EntityId, Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, + Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, WorldKernel, WorldPatch, WorldPhase, WorldScaleIntentRef, WorldSnapshot, journal, patch::kernel_speak_grant, prepare_creation, - verse::{CreateVerseWorldIntent, VerseGenesisError}, }; use crate::VerifiedPrincipalEvidence; use chrono::Utc; @@ -19,21 +18,6 @@ use tokio::task::JoinHandle; /// other. A world that wants more declares them through the patch lane. const GENESIS_PLACE: &str = "commons"; -/// The commons every genesis declares, the one room its first subjects share. -pub(crate) fn commons_declaration() -> Declaration { - Declaration::Entity(EntityDeclaration { - handle: DraftHandle::new(GENESIS_PLACE), - label: "The Commons".into(), - kind: EntityKind::Place, - container: None, - }) -} - -/// Where a genesis subject stands. -pub(crate) fn commons_ref() -> Ref { - Ref::Draft(DraftHandle::new(GENESIS_PLACE)) -} - const REQUEST_CAPACITY: usize = 32; #[derive(Clone, Debug)] @@ -82,16 +66,6 @@ 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, @@ -146,7 +120,7 @@ impl WorldMailbox { kind, controller, affordances: kernel_speak_grant(), - position: Some(commons_ref()), + position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), import_key: None, }) }; @@ -156,7 +130,12 @@ impl WorldMailbox { // jurisdiction's population target. The owner subject lands in // `Uncovered`, visible and reducing nothing. let mut declarations = vec![ - commons_declaration(), + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(GENESIS_PLACE), + label: "The Commons".into(), + kind: EntityKind::Place, + container: None, + }), declare( "first-person", input.human_subject_label, @@ -240,20 +219,6 @@ impl WorldMailbox { .map_err(mailbox_outcome) } - /// Creates the canonical Aetheria verse world through the same - /// authenticated creation `create` uses. - pub async fn create_verse( - &self, - intent: CreateVerseWorldIntent, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let principal_id = PrincipalId::new(principal.account_subject_hash()); - let input = intent.lower(principal_id.clone())?; - Ok(self - .create_authenticated(input, AuthenticatedCaller::verified_principal(principal_id)) - .await?) - } - /// A wall-clock read at the boundary a request crosses into the mailbox is /// ingress, not reduce: the reducer stays deterministic over its journal, /// and this is the one place a caller's own claim of continued authority @@ -496,37 +461,6 @@ impl WorldMailbox { .map_err(MailboxError::Kernel) } - /// The tick driver's opportunity list, in snapshot order. It is on - /// `WorldMailbox` and on no controller port, as `agency_graph` is. - pub async fn opportunities(&self) -> Result, 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 @@ -996,32 +930,6 @@ 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, diff --git a/crates/ghostlight/src/patch.rs b/crates/ghostlight/src/patch.rs index aa50ae6..5385b4a 100644 --- a/crates/ghostlight/src/patch.rs +++ b/crates/ghostlight/src/patch.rs @@ -7916,8 +7916,7 @@ mod tests { }; /// Owner: `WorldPatch::sets_persona_of_existing_subject`. Consumer: - /// `ghostlight-dungeon`'s play agent (PA.f61, parked at - /// `parked/ghostlight-eve-surface`), which refuses a + /// `ghostlight-dungeon`'s play agent (PA.f61), which refuses a /// `set_persona_material` run whose patch answers true here, since /// `WorldPatch`'s own fields are `pub(crate)` and Dungeon may not parse /// its JSON to decide the question itself. diff --git a/crates/ghostlight/src/table.rs b/crates/ghostlight/src/table.rs index b4049f0..5192b8f 100644 --- a/crates/ghostlight/src/table.rs +++ b/crates/ghostlight/src/table.rs @@ -1606,8 +1606,7 @@ mod tests { // PA.f75: `communicate` was removed from `PLAY_TOOLS`; Dungeon drops it // from its own list too. This is now the complete 24-tool list. // PA.f88: this is the library's own test fixture, kept in sync by hand. - // Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` (parked at - // `parked/ghostlight-eve-surface`) is the + // Dungeon's `PLAY_TOOLS` in `ghostlight-dungeon/src/play.rs` is the // single policy owner of what the play agent may actually call; this // copy pins nothing there and is not read by production code. const PLAY_TOOLS: &[&str] = &[ diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs index 079fa44..d86a92d 100644 --- a/crates/ghostlight/src/verse/calls.rs +++ b/crates/ghostlight/src/verse/calls.rs @@ -256,7 +256,7 @@ enum DecodedRow { Ledger(RunLedger), } -pub(super) fn canonical Deserialize<'de>>( +fn canonical Deserialize<'de>>( row: &CultCacheEnvelope, ) -> Result { let value: T = rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?; @@ -585,16 +585,6 @@ impl RecordingInferencePort { }) } - /// 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) diff --git a/crates/ghostlight/src/verse/genesis.rs b/crates/ghostlight/src/verse/genesis.rs deleted file mode 100644 index 9641467..0000000 --- a/crates/ghostlight/src/verse/genesis.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! The canonical Aetheria verse world's genesis. -//! -//! One intent lowers to one `CreateWorld`: a commons, every grammar verb -//! declared as an affordance, and each Aetheria faction as an institution -//! imported by its record key. The world is bound to the catalog's grammar, so -//! the kernel refuses any affordance the grammar does not carry. -//! -//! Phase A verbs record the act and change no component: an outcome band with -//! no effects, and the act carried as the utterance that makes an entry -//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling -//! `verb-effects-ghostlight`), and those arrive with phase B. - -use crate::aetheria::AetheriaCatalog; -use crate::mailbox::{MailboxError, commons_declaration, commons_ref}; -use crate::patch::{ - AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef, - FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration, - kernel_speak_entry, kernel_speak_grant, -}; -use crate::{ - CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind, - WorldPatch, WorldScaleIntentRef, -}; -use std::collections::BTreeMap; - -const COMMIT_LEN: usize = 40; - -/// What a caller states to create the canonical verse world. Everything else a -/// world needs (the commons, the subjects, the affordances, their grants and -/// the provenance of what is known) is derived from the catalog. -#[derive(Clone, Debug)] -pub struct CreateVerseWorldIntent { - pub id: CommandId, - pub title: String, - pub brief: String, - pub catalog: AetheriaCatalog, - /// The Aetheria commit the catalog was read at: 40 lower-case hex digits. - /// It is the provenance of every fact the world holds about a faction. - pub aetheria_commit: String, - pub lens_weights: LensWeights, - pub targets: BTreeMap, -} - -#[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 deleted file mode 100644 index 24f19d8..0000000 --- a/crates/ghostlight/src/verse/genesis/tests.rs +++ /dev/null @@ -1,476 +0,0 @@ -use super::*; -use crate::aetheria::read_aetheria_catalog; -use crate::patch::{FactStanding, Mismatch}; -use crate::tests::{auth_principal, owner, stock_weights}; -use crate::{ - CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb, - KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel, - WorldMailbox, WorldPhase, -}; -use chrono::{Duration, Utc}; -use std::collections::BTreeSet; - -/// Aetheria master when the fixture catalog was written. -const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; -const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; - -const FIXTURE: &str = concat!( - env!("CARGO_MANIFEST_DIR"), - "/tests/fixtures/aetheria-verse-catalog.cc" -); - -fn catalog() -> AetheriaCatalog { - read_aetheria_catalog(FIXTURE).unwrap() -} - -fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent { - CreateVerseWorldIntent { - id: CommandId::new(), - title: "The Verse".into(), - brief: "Aetheria.".into(), - catalog, - aetheria_commit: commit.into(), - lens_weights: stock_weights(), - targets: BTreeMap::new(), - } -} - -/// A world created through the kernel from the lowering, for what the -/// snapshot hides: the facts' evidence. -fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result) { - 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 index abef1b3..ba0cd08 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -1,19 +1,10 @@ -//! 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. +//! The Aetheria verse run's own organs. The verse binary and its run record +//! live 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. 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 deleted file mode 100644 index aced104..0000000 --- a/crates/ghostlight/src/verse/run.rs +++ /dev/null @@ -1,1165 +0,0 @@ -//! 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 deleted file mode 100644 index a5f00f8..0000000 --- a/crates/ghostlight/src/verse/run/tests.rs +++ /dev/null @@ -1,2783 +0,0 @@ -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/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml new file mode 100644 index 0000000..7a5b6cc --- /dev/null +++ b/deployment/idunn/recipe.toml @@ -0,0 +1,188 @@ +schema = "gamecult.idunn.target_declaration.v1" +target = "ghostlight" +source_stamp_environment = "GHOSTLIGHT_BUILD_COMMIT" +required_gitlinks = ["vendor/eve"] + +[[steps]] +id = "test-persona-boundary" +phase = "test" +runner = "rust-build" +required_environment = ["CARGO_HOME", "HOME"] +argv = ["cargo", "test", "--locked", "-p", "ghostlight-persona-projection"] + +[[steps]] +id = "test-world-library" +phase = "test" +runner = "rust-build" +required_environment = ["CARGO_HOME", "HOME"] +argv = ["cargo", "test", "--locked", "-p", "ghostlight"] + +[[steps]] +id = "test-dungeon" +phase = "test" +runner = "rust-build" +required_environment = ["CARGO_HOME", "HOME"] +argv = ["cargo", "test", "--locked", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"] + +[[steps]] +id = "build-daemon" +phase = "build" +runner = "rust-build" +required_environment = ["CARGO_HOME", "HOME"] +argv = ["cargo", "build", "--locked", "--release", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"] + +[[steps]] +id = "install-web-dependencies" +phase = "prepare" +runner = "web-build" +required_environment = ["HOME", "NPM_CONFIG_CACHE"] +argv = ["npm", "--prefix", "web", "ci"] + +[[steps]] +id = "test-web" +phase = "test" +runner = "web-build" +required_environment = ["HOME", "NPM_CONFIG_CACHE"] +argv = ["npm", "--prefix", "web", "test"] + +[[steps]] +id = "build-web" +phase = "build" +runner = "web-build" +required_environment = ["HOME", "NPM_CONFIG_CACHE"] +argv = ["npm", "--prefix", "web", "run", "build"] + +[[steps]] +id = "accept-real-persona-membrane" +phase = "acceptance" +runner = "rust-acceptance" +argv = [ + "cargo", + "test", + "--locked", + "-p", + "ghostlight", + "--lib", + "controllers::tests::real_local_model_cognition_modes_commit_speech", + "--", + "--ignored", + "--exact", +] +required_environment = [ + "CARGO_HOME", + "GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG", + "GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID", + "GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL", + "GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL", + "GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL", + "GHOSTLIGHT_CONTROLLER_PERSONA_MODEL", + "GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL", + "HOME", + "GHOSTLIGHT_LOCAL_ENDPOINT", +] + +[[artifacts]] +id = "ghostlight-dungeon" +source_kind = "runner-output" +runner = "rust-build" +source = "target/release/ghostlight-dungeon" +destination = "ghostlight-dungeon" +executable = true + +[[artifacts]] +id = "web" +source_kind = "runner-output" +runner = "web-build" +source = "web/dist" +destination = "web" + +[service] +executable_artifact = "ghostlight-dungeon" +required_adjacent_artifacts = ["web"] +arguments = [ + { kind = "literal", value = "--state-root" }, + { kind = "binding", name = "state_root" }, +] +transport = "http" +route_required = true +required_environment = [ + "GAMECULT_IDUNN_CANDIDATE_BIND", + "GAMECULT_IDUNN_PROCESS_WRITE_LEASE", + "GAMECULT_IDUNN_RUNTIME_BUNDLE", + "GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL", + "GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL", + "GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL", + "GHOSTLIGHT_CONTROLLER_PERSONA_MODEL", + "GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL", + "GHOSTLIGHT_DISCORD_GUILD_ID", + "GHOSTLIGHT_DISCORD_ROLE_ID", + "GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE", + "GHOSTLIGHT_HEIMDALL_BASE_URL", + "GHOSTLIGHT_LOCAL_ENDPOINT", + "GHOSTLIGHT_LOCATED_SERVICE", + "GHOSTLIGHT_ODIN_RUDP", + "GHOSTLIGHT_PLAY_MODEL", + "GHOSTLIGHT_PUBLIC_APP_URL", + "GHOSTLIGHT_SERVICE_ID", + "GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE", +] +optional_environment = ["RUST_LOG"] + +[service.health] +contract = "ghostlight.cultnet-rudp-service-health" + +[state] +schema_generation = "world-v3" + +[[state.slots]] +id = "world" +relative_path = "world.cc" +kind = "cultcache-file" +schema = "ghostlight.world_state.consumer.v6" +writer = "process-bound-single-writer" +recovery = "preserve" +startup = "create-or-open-after-write-lease" + +[[state.slots]] +id = "app-sessions" +relative_path = "service/app-sessions-v2.cc" +kind = "cultcache-file" +schema = "ghostlight.app_session_store.v2" +writer = "process-bound-single-writer" +recovery = "preserve" +startup = "create-or-open-after-write-lease" + +[[state.slots]] +id = "controller-work" +relative_path = "service/controller-work.cc" +kind = "cultcache-file" +schema = "ghostlight.controller_work.v18" +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 3cfd486..0df4607 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -1,7 +1,5 @@ # Ghostlight authority architecture -> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main. - ## Status This document is the adopted rebuild target for the Ghostlight runtime. It is @@ -626,11 +624,9 @@ lane still uses. The real-model gate that exists today is the Idunn recipe's runs `controllers::tests::real_local_model_cognition_modes_commit_speech` against a real model; exercising a whole play turn against one is not gated by this contract. -`world.begin` is the one route that makes a world. Its payload is -`world_begin.v1`: title, brief and subject_label, nothing else; the route -decides everything else a world is born with (no targets, no roots, no persona -or agent label, uniform lens weights) and `begin_plan` decides how far Begin -goes. `world.seed` runs one +`world.create`'s current payload is `world_create.v4`: title, brief, targets, +jurisdiction roots and lens weights are required, and Dungeon's create surface +offers uniform weights as its editable default. `world.seed` runs one `SeedRunner` session per invocation against a `VaultEvidenceSource`. Focused tests must prove: diff --git a/docs/architecture/ghostlight-eve-native-interface.md b/docs/architecture/ghostlight-eve-native-interface.md new file mode 100644 index 0000000..a296f15 --- /dev/null +++ b/docs/architecture/ghostlight-eve-native-interface.md @@ -0,0 +1,100 @@ +# Ghostlight Eve-native interface + +## Objective + +Ghostlight Dungeon exposes one logical surface, `ghostlight.play`, from the +provider `gamecult.ghostlight.dungeon` (`crates/ghostlight-dungeon/src/mesh.rs`). +Eve owns editable bindings, command invocation, receipts, plugin composition, +and browser lowering. Ghostlight owns world creation, seeding, activation, play, +and world admission; Heimdall owns authentication and entitlement decisions. + +```text +Ghostlight projection (eve.rs) + -> EveBrowserProviderHost + -> canonical Eve lowering + -> gamecult.eve.command_invocation.v1 + -> Ghostlight command ingress (runtime.rs) or Heimdall private command plane + -> gamecult.eve.command_result.v1 + -> authoritative surface refresh +``` + +## Authority map + +- Owner: the world kernel owns world changes (through `WorldMailbox`, `SeedPort` + and the play table), Heimdall owns OAuth attempts and claims, and Ghostlight's + app-session owner (`app_session.rs`) binds a verified Heimdall subject to a + local cookie. +- Inputs: the projector reads only the caller's app session, the world + snapshot, and the caller's play-turn view. Command ingress reads one canonical + Eve invocation and derives the principal server-side. +- Outputs: one `gamecult.eve.surface.v1` document and one + `gamecult.eve.command_receipt.v1` inside a command result. +- Derived state: browser drafts, focus, command status, and rendered HTML are + projections or local interaction state. None grants access or mutates + fiction. +- Forbidden writers: the browser, the Eve lowerer, the Heimdall plugin, and the + SSE stream cannot choose a principal or commit world state. +- Shared path: every operation resolves the authenticated account, then calls + the world mailbox, the seed port, or the play port. + SSE carries invalidation only and the host refetches the same surface. + +## The surface + +`GET /api/eve/surfaces/ghostlight.play` returns a different document by caller +and world phase (`anonymous_surface`, `authenticated_surface` in +`crates/ghostlight-dungeon/src/eve.rs`): + +| Caller and world | Content | Operations | +|---|---|---| +| Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` | +| Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) | +| Authenticated, no world | the create form: title, brief, your name, optional Narrative persona and Operational agent labels, scale target, jurisdiction roots, lens weights | `world.create` (`ghostlight.world_create.v4`) | +| Draft world | world summary card; approve button for a required approver who has not approved; for the owner, the seed form and Seed card, and the activate button once every required approver has approved | `world.approve` (`ghostlight.world_approve.v0`), `world.seed` (`ghostlight.world_seed.v1`), `world.activate` (`ghostlight.world_activate.v0`) | +| Active world, owner | advance-time control and the Play card: narration, the open question, the refusal of the player's own act, one free-text control | `world.advance_time` (`ghostlight.world_advance_time.v0`), `world.play` (`ghostlight.world_play.v0`) | + +`world.play` is the only human play action. The open question's identity rides +the play button's own action as an opaque token, never as an editable field. +`operation_schema` in `eve.rs` is the single table from operation id to schema. + +## Editable values + +Eve bindings are the input model. A component binds a typed value by stable +binding name, document identity, field path, value kind, and access mode. +Renderer-local composers use `local-draft`. An operation captures one or more +named binding values atomically. + +The browser lowerer may use an HTML form for keyboard and accessibility +behavior. HTML form structure never enters Eve state or command payload +semantics. Accepted receipts may clear named drafts; rejection and stale +conflict preserve them. An omitted clear-binding list means clear the surface's +drafts; an explicit empty list means clear nothing. Ghostlight validates every +payload at ingress regardless of what the control advertises. + +## Authentication membrane + +Anonymous projection contains only `heimdall.access_gate`. Its begin and +complete operations cross Heimdall's encrypted, loopback-only CultNet boundary +(`heimdall.rs`). Ghostlight reads the redacted `heimdall:command-boundary` +record from Odin, validates it, and invokes the discovered route. Odin does not +proxy the command and never receives claims or completion payloads. The browser +retains only the opaque attempt handle (`web/src/heimdall-access-adapter.ts`). +Ghostlight redeems the completion, validates the access claim, and creates a +local HttpOnly session cookie (`ghostlight_session`). + +Routine requests use local verified session state, persisted in +`app-sessions-v2.cc` (`app_session.rs`): the cookie hash, the Heimdall +session and `access_revision`, expiries, and a wrapped refresh claim. +`access_revision` is Heimdall's revocation epoch. + +## Public boundary + +- `GET /api/eve/provider` +- `GET /api/eve/surfaces/{surface_id}` +- `POST /api/eve/commands` +- `GET /api/eve/events` +- `GET /health`, a probe of the service's typed CultMesh health +- `POST /cultnet/snapshot` and `POST /cultnet/world-patch`, the loopback + CultNet doors (see `ghostlight-world-consumer-api.md` for the second) + +Static assets in `web/` contain only the Eve provider host, the transport, the +Heimdall adapter, and the SSE invalidation hook. diff --git a/docs/architecture/ghostlight-play-agent.md b/docs/architecture/ghostlight-play-agent.md index 279965e..1f5aea1 100644 --- a/docs/architecture/ghostlight-play-agent.md +++ b/docs/architecture/ghostlight-play-agent.md @@ -1,7 +1,5 @@ # Ghostlight Dungeon play agent (plan step 16) -> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main. - ## Status Target, adopted 2026-09-22. Not implemented. The means will be in diff --git a/docs/architecture/ghostlight-session-zero.md b/docs/architecture/ghostlight-session-zero.md index e5e6fdb..bffb00c 100644 --- a/docs/architecture/ghostlight-session-zero.md +++ b/docs/architecture/ghostlight-session-zero.md @@ -1,7 +1,5 @@ # Ghostlight Dungeon Session Zero -> Status: the Dungeon daemon described here is parked at tag `parked/ghostlight-eve-surface` (2026-10-22); it is not on main. - Status: adopted direction, 2026-09-15. Not implemented. Its role as the playtest gate is superseded (operator, 2026-09-22) by the play agent, `ghostlight-play-agent.md` (plan step 16). D1–D3 and the library passes diff --git a/docs/architecture/ghostlight-world-consumer-api.md b/docs/architecture/ghostlight-world-consumer-api.md index 5c996a7..5eacfbf 100644 --- a/docs/architecture/ghostlight-world-consumer-api.md +++ b/docs/architecture/ghostlight-world-consumer-api.md @@ -63,11 +63,6 @@ attributed proposal — is not part of this pass; see "Not in this pass" below. ## Transport -Status: the HTTP door below lived in the Dungeon daemon's `api_router` and is -parked with it at tag `parked/ghostlight-eve-surface`; no process on main serves -it. `ghostlight::admit_document` and `encode_receipt` are the contract a host -serves. - `POST /cultnet/world-patch`, declared beside `/cultnet/snapshot` in `api_router` (`runtime.rs`). The handler gates loopback and content type (`application/msgpack`) exactly as the snapshot route does, then hands the diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 5d8ce69..c7f13b6 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -2,21 +2,25 @@ This is a pointer. It does not carry open work; the Eureka mind does. -## What exists +## What runs -Nothing is deployed. The Dungeon (daemon, play table, Eve surface, browser host, -`vendor/eve`, Idunn recipe) is parked at the tag `parked/ghostlight-eve-surface` -and is not on main. A Thing 2 rebuild starts from that tag. +Ghostlight Dungeon runs on Yggdrasil under Idunn at +`https://yggdrasil.gamecult.org/ghostlight/` (runtime `ghostlight-yggdrasil`, +target `ghostlight`, ref `main`, state root `/var/lib/gamecult/ghostlight-world-v2`). +`/health` names the commit it serves (`crates/ghostlight-dungeon/src/runtime.rs`). +The binary is `ghostlight-dungeon`; its inference reaches Bonsai on Raven +through a loopback tunnel, and sign-in goes through Heimdall, discovered via +Odin. -What is in the tree is the kernel (`crates/ghostlight`), the persona projection -(`crates/ghostlight-persona-projection`) and the verse runner -(`crates/ghostlight-verse`). `cargo test` runs them on Linux; use the -`ygg-verify` stopgap on Yggdrasil, not the workstation, and say which host -you used. +## How it deploys -The Idunn target `ghostlight` is braked and stopped, not retired. Its state is in -`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. Do not run -`idunn up ghostlight`, the v1 actuator, or `ghostlight-dungeon.service`. +Idunn is the only deploy path (`idunn up ghostlight`). The recipe is +`deployment/idunn/recipe.toml`; the runbook is +`gamecult-ops/runbooks/ghostlight-dungeon-yggdrasil.md`. The Ghostlight daemon +cannot start without Odin: its Warming presence and process write lease go +through Odin. Preflight Odin's route health before a deploy. Do not invoke the +retired v1 actuator (`gamecult-ops/scripts/deploy-ghostlight-yggdrasil.sh`), its +wiring test, or `ghostlight-dungeon.service`. ## Where open work is @@ -31,10 +35,9 @@ target and probed body facts are `notes/ghostlight-verse-target.md` and 1. `README.md`: what exists and what does not. 2. `AGENTS.md`: invariants and where things are. -3. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and +3. `docs/architecture/ghostlight-dungeon-mvp.md`: the authority architecture. +4. `docs/architecture/ghostlight-world-ontology.md`: the kernel's vocabulary and current mechanism. -4. `docs/architecture/ghostlight-dungeon-mvp.md`: the parked Dungeon's authority - architecture. History until Thing 2 revives it. 5. `docs/architecture/ghostlight-play-agent-cut.md`: the dated record of the play agent and of the first deploy on 2026-09-23 (findings PA.f198 to PA.f216). It is history; the mind says which findings are still open. diff --git a/notes/ghostlight-verse-map.md b/notes/ghostlight-verse-map.md index 75f1d53..aa5ba6a 100644 --- a/notes/ghostlight-verse-map.md +++ b/notes/ghostlight-verse-map.md @@ -1,8 +1,6 @@ # Ghostlight Verse: Map -> Status: Dungeon, eve.rs and the bridge-test references below are parked at tag `parked/ghostlight-eve-surface`; line refs are to that tag. - -Campaign `ghostlight-verse`, target `ghostlight-verse:target:r3` +Campaign `ghostlight-verse`, target `ghostlight-verse:target:r2` (`notes/ghostlight-verse-target.md`). This page holds body facts, the model page and rationale only. Cut specs, questions, rulings and follow-ups live in the Eureka mind; query them there, never copy them here. @@ -11,15 +9,6 @@ Phase 1 map: Imagination, session `self-2026-10-09-morning`, 2026-10-09. Base for every phase-1 anchor: Ghostlight `origin/main` `4fee97f`. Times are UTC unless marked local (UTC+2). -## Cut sequence - -Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec is admitted when Self is about to dispatch the cut. The live run waits on the operator's DeepSeek key. Re-map a line by a fresh Imagination pass against the Body when the frontier reaches it. - -- model-asleep: The play card says the storyteller is waking while Bonsai reloads, and refuses a turn with an asleep line when the. Waits on: play-faults (live run waits on the operator DeepSeek key). -- raven-bonsai-scripts: Raven's Bonsai scripts enter gamecult-ops; the server stays up and sleeps its model on idle, the reaper is gone, and. Waits on: nothing in the mind (not queued; Ghostlight live run waits on the operator DeepSeek key). -- deploy-main: Main deploys on Yggdrasil through Idunn, stays up, and a human plays a session end to end. Waits on: play-faults, play-surface, session-refresh, acceptance-gate, raven-bonsai-scripts, model-asleep, create-flow (blocked on the DeepSeek key and the Ghostlight live-run chain). -- v1-retire: The retired v1 Ghostlight body leaves Yggdrasil and gamecult-ops. Waits on: nothing in the mind (not queued; Ghostlight frontier is the live run). - ## Body facts (probed 2026-10-09) ### Does main run on Yggdrasil? @@ -68,7 +57,7 @@ Cuts without a `cut_spec`, withdrawn 2026-10-12 under map-only-what-runs: a spec ### Does main build and pass? -Two jobs through `ygg-verify.sh` at `4fee97f`, with the Eve submodule initialised: +Two jobs through `ygg-verify.sh` at `4fee97f`, with `vendor/eve` initialised: - Rust (image `eureka-verify-rust`), the recipe's own steps: `ghostlight-persona-projection` 35 passed; `ghostlight` lib 621 passed, @@ -213,67 +202,6 @@ from 21:04 the denial forever. Mechanism: code is flat under `crates/ghostlight/src/`. Dated cut documents and postmortems keep their paths: they are history. -### Who refuses an operational in-flight row whose scope moved? (probed 2026-10-17, `4741d50`) - -- Three paths answer the same fact (the subject still holds one opportunity on - the bound scope, at a new digest), with two errors. A probe test run on - Yggdrasil returned these, with one provider call in total: - - The scope moves during round 0 of a fresh run. Round 1's - `ensure_scope_unchanged` returns `NoOpportunity`. - - A resume passing the row's own stale opportunity returns `NoOpportunity` - (same gate). - - A resume passing the fresh opportunity returns `OpportunityMismatch`, from - the digest clause of `row_answers_caller` at lookup. -- The driver's `classify` (`verse/run.rs` 641-651) labels the first two - `OpportunityGone` and the third `ScopeMoved`. That makes the first two - mislabels: the label's doc says `OpportunityGone` means the subject holds no - opportunity. Cause: when `select_scope`'s digest clause empties the match - list, it answers `NoOpportunity` (`controllers.rs` ~1552). -- In the narrative lane, the lookup digest clause is the only gate. A driver - resume with the fresh opportunity is refused there. Fork A's kernel-refusal - re-lowering (`1ef973a`) runs only when a caller passes the stale opportunity. -- Grouped lane (probed 2026-10-17, `771a79d`, by reading; `select_one` now - returns `ScopeMoved`): `run_group` maps every `select_one` error to - `LeftBeforeSelection`, which the driver records as `OpportunityGone`. A member - whose scope moved is dropped before any constituent, inference or submission - exists, so only the label is wrong; spend is identical and nothing branches on - the reason (`grouped-record-and-carry` keys on membership in `refused`). Own - cut: `cut-grouped-scope-moved-label.r1`. - -### Who refuses a resumed narrative row whose scope moved? (probed 2026-10-17, `73f26a5`) - -- **Today, at lookup, by the caller's digest.** `run_narrative`'s Confirmed - and CustodyUncertain arms (`controllers.rs` 3144, 3152) call - `row_answers_caller`, the only callers it has since `in-flight-gate` - (`771a79d`). A fresh caller is refused `OpportunityMismatch`; a stale caller - passes to Fork A. Nothing in Projector, Persona or Interpreter rounds reads - the world (source read: `self.select` is called only at fresh run, round - checks of the operational lane, and the other lanes' fresh paths). -- **The kernel path is already whole.** `submit_narrative`'s `ScopeChanged` - arm calls `interrupted`, which re-lowers once. A second move reads - `interrupted_from.is_some()` and ends the turn `overtaken`, with nothing - submitted and no further inference. The progression arm (~2375) refuses to - persist a second re-lowering even if the runner tried. Pinned by - `a_second_scope_change_after_the_re_lowering_spends_nothing`. -- **Probe** (Yggdrasil, ygg-verify rust, clone at `73f26a5`, sed in the job; - script `probe1.sh` in the imagination-ag5-narrative scratchpad): both lookup - arms put on `same_decision`, and `OpportunityMismatch` moved from `ScopeMoved` - to `OpportunityGone` in `classify`. `cargo test -p ghostlight --lib`: 803 - passed, 3 failed. The failures were - `a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference` - (the resumed row now called the provider and ran the test port dry), - `a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate`, and - `an_error_ends_a_cell_as_the_run_must_treat_it` (the classify table). No - verse driver test reached `ScopeMoved` through `OpportunityMismatch`. -- **`OpportunityMismatch` is no longer a moved scope anywhere.** After the cut - it arises from `same_decision` (another decision), `select_scope`'s - controller-id check, and the Persona lane's forged-field refusals. The driver - cannot reach a `same_decision` mismatch, because `run_cell` derives the - command id from world, cell, subject and tick. -- **Dead code.** Both `awaits_world` methods have no production caller once - `row_answers_caller` goes. The operational one has been test-only since - `771a79d`. The only other reader is the `NeverSentStore` test fixture. - ## Model page One row per persistent kind phase 1 touches. No cell may be empty. @@ -288,7 +216,6 @@ One row per persistent kind phase 1 touches. No cell may be empty. | v1 body (`ghostlight-dungeon.service`, `/srv/ghostlight`, v1 state) | unit name; paths | retired in fact 2026-09-23; unit still enabled | nobody today; phase 1 assigns its removal to a gamecult-ops cut, data by operator ruling | | Bonsai lifecycle (Raven containers) | container `bonsai`; scripts under `/opt/gamecult/bonsai` | started by hand ("Start AI Chat"); restarted by the RSS watchdog; stopped by the idle reaper | gamecult-ops/Raven; Dungeon only reads the endpoint; availability policy is an operator question | | Repository docs (README, AGENTS, live architecture pages, handoff) | path on `main` | rewritten to the live machine; pre-rebuild material leaves `main` by operator ruling | the Ghostlight repo; history in git, dated cut documents and postmortems | -| Controller-work row (`controller-work.cc`, narrative and operational checkpoints) | `CommandId` (driver: derived from world, cell, subject, tick) | persisted before each spend; advanced only by the progression rule; a narrative row re-lowers at most once (`interrupted_from`), then commits, declines or ends overtaken | which decision a row answers: `same_decision`, both lanes, the caller naming identity only. Whether its scope moved: the world (operational: `ensure_scope_unchanged` before each round; narrative: the kernel's `ScopeChanged` at submit, answered by `interrupted`). Labels: `classify` alone | ### Where phase 2's verb sets plug in @@ -337,16 +264,3 @@ verbs as the target. Dungeon tests that drive the real client, and nothing runs them. Fixing the gate means a runner with both Rust and Node, which is Idunn's recipe and runner surface: a follow-up, with each phase-1 Hands run doing them by hand. -- **A narrative resume has one detector, and it is the kernel** (ruling - `narrative-resume-relowers`). The caller's opportunity names the decision - and nothing more, in both lanes, through the one `same_decision`. The lanes - differ only in when the world is asked. The operational lane asks before each - round, because it has nothing to preserve. The narrative lane asks at submit, - because its prose and receipts survive to be lowered again. The price is the - remaining rounds plus at most one Interpreter call. `OpportunityMismatch` - then stops meaning a moved scope, so `classify` labels it `OpportunityGone`, - as the grouped lane already does. Keeping it on `ScopeMoved` would leave a - second, wrong answer to "did the scope move". The grouped selection label - becomes a pure function, because `AmbiguousOpportunity` cannot be built - through a live world, which offers one opportunity per scope. Pinning the - function is what closes follow-up `grouped-left-arms-pin`. diff --git a/notes/local-live-smoke.md b/notes/local-live-smoke.md index 7170047..941c6e4 100644 --- a/notes/local-live-smoke.md +++ b/notes/local-live-smoke.md @@ -5,14 +5,8 @@ No live smoke exists in this tree. The ignored test this file once documented, ran the production tick driver and the Active elaboration sweep against a real CodexConnector; the play agent's Cut 1 (`d69e9d4`) deleted both, and the test with them. A command that names it now matches nothing and exits 0. No -replacement exists. The recipe that once gated a deploy on a real model is -gone from main, and so is its `accept-real-persona-membrane` step. The one real-model test -left is the ignored `controllers::tests::real_local_model_cognition_modes_commit_speech` -(`crates/ghostlight/src/controllers.rs`). It has no automated run: it needs a model server -on `GHOSTLIGHT_LOCAL_ENDPOINT` plus the `GHOSTLIGHT_CONTROLLER_*_MODEL`, -`GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID` and `GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG` -variables, and a person runs it by hand with `cargo test -p ghostlight -- --ignored -real_local_model_cognition_modes_commit_speech`. +replacement exists. The real-model gate that does exist is the Idunn recipe's +`accept-real-persona-membrane` step (`deployment/idunn/recipe.toml`). What remains true and stays here: bringing up a real CodexConnector and a built Claude SDK sidecar. The seed lane (`world.seed`) still runs real diff --git a/package.json b/package.json index 287051d..0a3700c 100644 --- a/package.json +++ b/package.json @@ -2,8 +2,11 @@ "name": "ghostlight", "private": true, "version": "0.0.0", - "description": "Ghostlight: the Rust kernel for socially situated agents.", + "description": "Ghostlight: the Rust kernel for socially situated agents, and Ghostlight Dungeon, the single-player DM-led client.", "scripts": { + "dungeon:build": "cargo build --workspace && npm --prefix web run build", + "dungeon:test": "cargo test --workspace && npm --prefix web run build", + "dungeon:run": "cargo run -p ghostlight-dungeon", "sdk:build": "npm --prefix sidecar/claude-sdk run build", "sdk:test": "npm --prefix sidecar/claude-sdk test" }, diff --git a/tools/eve_client_bridge.mjs b/tools/eve_client_bridge.mjs new file mode 100644 index 0000000..63f17f2 --- /dev/null +++ b/tools/eve_client_bridge.mjs @@ -0,0 +1,117 @@ +// Cut 10 (PA.f149): drives the real vendored Eve browser lowering +// (`vendor/eve/packages/eve-browser-lowering`, submodule pin unmoved) over a +// surface document Dungeon actually served, so the command intent this +// prints is what a real client sends -- never a hand-built payload asserting +// its own shape is correct. +// +// stdin: {"surface": , "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 `