diff --git a/.gitignore b/.gitignore index e90c6ba..3c71615 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,4 @@ node_modules/ -web/dist/ sidecar/claude-sdk/dist/ target/ diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index 578af4b..0000000 --- a/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "vendor/eve"] - path = vendor/eve - url = https://github.com/GameCult/Eve.git diff --git a/Cargo.lock b/Cargo.lock index 251c9fe..09a0b20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -37,15 +37,6 @@ dependencies = [ "subtle", ] -[[package]] -name = "aho-corasick" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -dependencies = [ - "memchr", -] - [[package]] name = "android_system_properties" version = "0.1.6" @@ -61,28 +52,6 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" -[[package]] -name = "async-stream" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" -dependencies = [ - "async-stream-impl", - "futures-core", - "pin-project-lite", -] - -[[package]] -name = "async-stream-impl" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "async-trait" version = "0.1.92" @@ -106,70 +75,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "axum-macros", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "base64" version = "0.22.1" @@ -182,12 +87,6 @@ version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bitflags" version = "2.13.1" @@ -288,12 +187,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "const-oid" -version = "0.9.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -366,85 +259,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "cultmesh-rs" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191" -dependencies = [ - "anyhow", - "cultcache-rs", - "cultnet-rs", - "serde", -] - -[[package]] -name = "cultnet-rs" -version = "0.1.0" -source = "git+https://github.com/GameCult/CultLib.git?rev=85f7024a35ea42cf9f0a0199a064526427150191#85f7024a35ea42cf9f0a0199a064526427150191" -dependencies = [ - "aes-gcm", - "anyhow", - "base64 0.22.1", - "chrono", - "cultcache-rs", - "ed25519-dalek", - "hmac", - "rand 0.9.5", - "rmp-serde", - "rmpv", - "serde", - "serde_bytes", - "serde_json", - "sha2", - "socket2 0.5.10", - "uuid", - "windows-sys 0.61.2", - "zeroize", -] - -[[package]] -name = "curve25519-dalek" -version = "4.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" -dependencies = [ - "cfg-if", - "cpufeatures 0.2.17", - "curve25519-dalek-derive", - "digest", - "fiat-crypto", - "rustc_version", - "subtle", - "zeroize", -] - -[[package]] -name = "curve25519-dalek-derive" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "der" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" -dependencies = [ - "const-oid", - "zeroize", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - [[package]] name = "digest" version = "0.10.7" @@ -453,7 +267,6 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", - "subtle", ] [[package]] @@ -467,30 +280,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "ed25519" -version = "2.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" -dependencies = [ - "pkcs8", - "signature", -] - -[[package]] -name = "ed25519-dalek" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" -dependencies = [ - "curve25519-dalek", - "ed25519", - "serde", - "sha2", - "subtle", - "zeroize", -] - [[package]] name = "errno" version = "0.3.14" @@ -507,12 +296,6 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" -[[package]] -name = "fiat-crypto" -version = "0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" - [[package]] name = "find-msvc-tools" version = "0.1.11" @@ -621,18 +404,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -642,7 +413,7 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "r-efi 6.0.0", + "r-efi", "rand_core 0.10.1", "wasm-bindgen", ] @@ -683,44 +454,6 @@ 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" @@ -742,15 +475,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "hmac" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" -dependencies = [ - "digest", -] - [[package]] name = "http" version = "1.5.0" @@ -784,24 +508,12 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "http-range-header" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" - [[package]] name = "httparse" version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.11.0" @@ -815,7 +527,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "smallvec", @@ -856,7 +567,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.5", + "socket2", "tokio", "tower-service", "tracing", @@ -1022,27 +733,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.189" @@ -1082,43 +772,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "mio" version = "1.2.2" @@ -1130,40 +789,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -1208,16 +833,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64 0.22.1", - "serde_core", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -1230,16 +845,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pkcs8" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" -dependencies = [ - "der", - "spki", -] - [[package]] name = "polyval" version = "0.6.2" @@ -1261,21 +866,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -1298,7 +888,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.5", + "socket2", "thiserror", "tokio", "tracing", @@ -1314,7 +904,7 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand", "rand_pcg", "ring", "rustc-hash", @@ -1336,7 +926,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.5", + "socket2", "tracing", "windows-sys 0.61.2", ] @@ -1350,28 +940,12 @@ dependencies = [ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha", - "rand_core 0.9.5", -] - [[package]] name = "rand" version = "0.10.2" @@ -1383,16 +957,6 @@ dependencies = [ "rand_core 0.10.1", ] -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -1402,15 +966,6 @@ dependencies = [ "getrandom 0.2.17", ] -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - [[package]] name = "rand_core" version = "0.10.1" @@ -1444,23 +999,6 @@ dependencies = [ "bitflags", ] -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - [[package]] name = "reqwest" version = "0.12.28" @@ -1542,8 +1080,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" dependencies = [ "rmp", - "serde", - "serde_bytes", ] [[package]] @@ -1552,15 +1088,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - [[package]] name = "rustix" version = "1.1.4" @@ -1627,12 +1154,6 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - [[package]] name = "serde" version = "1.0.229" @@ -1686,17 +1207,6 @@ dependencies = [ "zmij", ] -[[package]] -name = "serde_path_to_error" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" -dependencies = [ - "itoa", - "serde", - "serde_core", -] - [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1720,15 +1230,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "2.0.1" @@ -1745,27 +1246,6 @@ dependencies = [ "libc", ] -[[package]] -name = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core 0.6.4", -] - -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time", -] - [[package]] name = "slab" version = "0.4.12" @@ -1778,16 +1258,6 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.5" @@ -1798,16 +1268,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "spki" -version = "0.7.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" -dependencies = [ - "base64ct", - "der", -] - [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -1895,45 +1355,6 @@ dependencies = [ "syn 3.0.3", ] -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.4" @@ -1971,7 +1392,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.5", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] @@ -2023,7 +1444,6 @@ dependencies = [ "tokio", "tower-layer", "tower-service", - "tracing", ] [[package]] @@ -2034,23 +1454,13 @@ checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "bitflags", "bytes", - "futures-core", "futures-util", "http", "http-body", - "http-body-util", - "http-range-header", - "httpdate", - "mime", - "mime_guess", - "percent-encoding", "pin-project-lite", - "tokio", - "tokio-util", "tower", "tower-layer", "tower-service", - "tracing", "url", ] @@ -2072,7 +1482,6 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ - "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -2096,36 +1505,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", ] [[package]] @@ -2140,12 +1519,6 @@ 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" @@ -2219,12 +1592,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "version_check" version = "0.9.5" @@ -2246,15 +1613,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -2524,12 +1882,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "writeable" version = "0.6.4" @@ -2559,26 +1911,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zerocopy" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.56" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "zerofrom" version = "0.1.8" diff --git a/Cargo.toml b/Cargo.toml index 681ebc9..aa81258 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ [workspace] members = [ "crates/ghostlight", - "crates/ghostlight-dungeon", "crates/ghostlight-persona-projection", "crates/ghostlight-verse", ] @@ -14,19 +13,10 @@ version = "0.1.0" [workspace.dependencies] anyhow = "1" -aes-gcm = "0.10" async-trait = "0.1" -async-stream = "0.3" -axum = { version = "0.8", features = ["macros"] } chrono = { version = "0.4", features = ["serde"] } -base64 = "0.22" codex-connector = { git = "https://github.com/GameCult/CodexConnector.git", rev = "68fe94b9475823de92ed766a97ea962d48723708" } cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultcache-rs" } -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultnet-rs" } -cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "85f7024a35ea42cf9f0a0199a064526427150191", package = "cultmesh-rs" } -jsonwebtoken = "9" -hmac = "0.12" -rand = "0.9" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } rmp-serde = "1.3" serde = { version = "1", features = ["derive"] } @@ -34,9 +24,7 @@ serde_json = "1" sha2 = "0.10" thiserror = "2" tokio = { version = "1", features = ["full"] } -tower-http = { version = "0.6", features = ["fs", "trace"] } tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } unicode-segmentation = "1" uuid = { version = "1", features = ["v4", "serde"] } zeroize = "1.8" diff --git a/crates/ghostlight-dungeon/Cargo.toml b/crates/ghostlight-dungeon/Cargo.toml deleted file mode 100644 index 032aecd..0000000 --- a/crates/ghostlight-dungeon/Cargo.toml +++ /dev/null @@ -1,48 +0,0 @@ -[package] -name = "ghostlight-dungeon" -version.workspace = true -edition.workspace = true -license.workspace = true -autobins = false - -[[bin]] -name = "ghostlight-dungeon" -path = "src/main.rs" - -[dependencies] -anyhow.workspace = true -aes-gcm.workspace = true -async-trait.workspace = true -async-stream.workspace = true -axum.workspace = true -chrono.workspace = true -base64.workspace = true -codex-connector.workspace = true -cultcache-rs.workspace = true -cultnet-rs.workspace = true -cultmesh-rs.workspace = true -jsonwebtoken.workspace = true -libc = "0.2" -hmac.workspace = true -ghostlight = { path = "../ghostlight" } -ghostlight-persona-projection = { path = "../ghostlight-persona-projection" } -rand.workspace = true -reqwest.workspace = true -rmp-serde.workspace = true -serde.workspace = true -serde_json.workspace = true -serde_bytes = "0.11" -sha2.workspace = true -thiserror.workspace = true -tokio.workspace = true -tower-http.workspace = true -tracing.workspace = true -tracing-subscriber.workspace = true -uuid.workspace = true -zeroize.workspace = true - -[dev-dependencies] -ghostlight = { path = "../ghostlight", features = ["test-support"] } -tempfile = "3" -tokio = { workspace = true, features = ["test-util"] } -tower = { version = "0.5", features = ["util"] } diff --git a/crates/ghostlight-dungeon/build.rs b/crates/ghostlight-dungeon/build.rs deleted file mode 100644 index 9eaf054..0000000 --- a/crates/ghostlight-dungeon/build.rs +++ /dev/null @@ -1,65 +0,0 @@ -use std::{env, path::Path, process::Command}; - -fn main() { - println!("cargo:rerun-if-env-changed=GHOSTLIGHT_BUILD_COMMIT"); - let manifest_dir = - env::var("CARGO_MANIFEST_DIR").expect("Cargo must supply CARGO_MANIFEST_DIR"); - let repo_root = Path::new(&manifest_dir).join("../.."); - emit_git_rerun_paths(&repo_root); - // Release tooling injects the clean-tree commit. Git discovery is only a - // convenience for local builds. The exact ref and reflog dependencies above - // keep that convenience from silently publishing stale provenance. - let commit = env::var("GHOSTLIGHT_BUILD_COMMIT") - .ok() - .filter(|value| is_commit(value)) - .or_else(|| git_commit(&repo_root)) - .expect("GhostlightDungeon builds require an exact 40-hex source commit"); - println!("cargo:rustc-env=GHOSTLIGHT_BUILD_COMMIT={commit}"); -} - -fn emit_git_rerun_paths(repo_root: &Path) { - for git_path in ["HEAD", "logs/HEAD", "packed-refs"] { - if let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", git_path]) { - emit_rerun_path(repo_root, &path); - } - } - - if let Some(reference) = git_output(repo_root, &["symbolic-ref", "-q", "HEAD"]) - && let Some(path) = git_output(repo_root, &["rev-parse", "--git-path", &reference]) - { - emit_rerun_path(repo_root, &path); - } -} - -fn emit_rerun_path(repo_root: &Path, git_path: &str) { - let path = Path::new(git_path); - let resolved = if path.is_absolute() { - path.to_path_buf() - } else { - repo_root.join(path) - }; - println!("cargo:rerun-if-changed={}", resolved.display()); -} - -fn git_commit(repo_root: &Path) -> Option { - git_output(repo_root, &["rev-parse", "HEAD"]).filter(|value| is_commit(value)) -} - -fn git_output(repo_root: &Path, args: &[&str]) -> Option { - let output = Command::new("git") - .args(args) - .current_dir(repo_root) - .output() - .ok()?; - if !output.status.success() { - return None; - } - String::from_utf8(output.stdout) - .ok() - .map(|value| value.trim().to_owned()) - .filter(|value| !value.is_empty()) -} - -fn is_commit(value: &str) -> bool { - value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) -} diff --git a/crates/ghostlight-dungeon/src/app_session.rs b/crates/ghostlight-dungeon/src/app_session.rs deleted file mode 100644 index f35a449..0000000 --- a/crates/ghostlight-dungeon/src/app_session.rs +++ /dev/null @@ -1,939 +0,0 @@ -//! Local custody for authenticated Heimdall sessions. -//! -//! This store owns only browser-session continuity. It does not own world -//! selection, commands, idempotency, exports, or fictional state. - -use crate::heimdall::{VerifiedSessionAdmission, VerifiedSessionRefresh}; -use ghostlight::VerifiedPrincipalEvidence; -use aes_gcm::{Aes256Gcm, KeyInit, aead::Aead}; -use anyhow::{Context, bail}; -use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; -use chrono::{DateTime, Utc}; -use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; -use rand::RngCore; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{collections::BTreeMap, path::Path}; - -const STORE_TYPE: &str = "ghostlight.app_session_store.v2"; -/// `pub(crate)` so the recipe-drift test in `idunn_health.rs` can check the -/// `app-sessions` state slot's schema against this constant. -pub(crate) const STORE_SCHEMA: &str = "ghostlight.app_session_store.v2"; -const STORE_KEY: &str = "primary"; -const MAX_SESSION_RECORDS: usize = 1_024; - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct AppSessionState { - schema: String, - sessions: BTreeMap, -} - -#[derive(Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -struct AppSession { - schema: String, - cookie_hash: String, - account_subject_hash: String, - heimdall_session_id: String, - access_revision: u64, - verified_capabilities: Vec, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - encrypted_refresh_claim: String, - created_at: DateTime, - last_refresh_at: DateTime, - revoked_at: Option>, -} - -/// The clause by which the local commit refuses a refresh Heimdall executed. -#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] -pub(crate) enum RefreshRefusal { - #[error("local session was revoked during refresh")] - Revoked, - #[error("local session moved to another revision since the refresh candidate was taken")] - MovedSinceCandidate, - #[error("refresh receipt revision went backwards")] - RevisionWentBackwards, - #[error("refresh receipt names a different Heimdall session")] - SessionId, - #[error("refresh receipt names a different account")] - Account, -} - -pub(crate) struct RefreshCandidate { - pub(crate) cookie_hash: String, - pub(crate) account_subject_hash: String, - pub(crate) heimdall_session_id: String, - pub(crate) access_revision: u64, - pub(crate) refresh_claim: String, -} - -pub(crate) struct AppSessionOwner { - store: OwnedRedbMessagePackBackingStore, - row: CultCacheEnvelope, - state: AppSessionState, - wrapping_key: [u8; 32], - healthy: bool, -} - -impl AppSessionOwner { - pub(crate) fn open( - store_path: impl AsRef, - wrapping_key_path: impl AsRef, - ) -> anyhow::Result { - let wrapping_key = read_wrapping_key(wrapping_key_path.as_ref())?; - let store = OwnedRedbMessagePackBackingStore::new(store_path.as_ref())?; - store - .validate_path_identity() - .context("app-session store path identity is invalid")?; - let rows = store.pull_all()?; - let (row, state) = match rows.as_slice() { - [] => { - let state = AppSessionState { - schema: STORE_SCHEMA.into(), - sessions: BTreeMap::new(), - }; - let row = envelope(&state)?; - if !store.compare_and_swap_batch(&[], vec![row.clone()])? { - bail!("app-session store changed during initialization"); - } - (row, state) - } - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(STORE_SCHEMA) => - { - let state: AppSessionState = - rmp_serde::from_slice(&row.payload).context("app-session state is corrupt")?; - if rmp_serde::to_vec_named(&state)? != row.payload { - bail!("app-session state is not canonical"); - } - if state.schema != STORE_SCHEMA { - bail!("app-session state schema is invalid"); - } - validate_state(&state, &wrapping_key)?; - (row.clone(), state) - } - _ => bail!( - "app-session v2 store contains foreign or legacy records; start with a fresh store" - ), - }; - Ok(Self { - store, - row, - state, - wrapping_key, - healthy: true, - }) - } - - pub(crate) fn create_session( - &mut self, - input: VerifiedSessionAdmission, - ) -> anyhow::Result { - if !input - .capabilities() - .iter() - .any(|value| value == "app_access") - { - bail!("Heimdall session lacks app_access"); - } - if input.account_id().trim().is_empty() - || input.heimdall_session_id().trim().is_empty() - || input.refresh_claim().is_empty() - || input.refresh_expires_at() <= input.access_expires_at() - { - bail!("Heimdall session material is invalid"); - } - let mut cookie = [0_u8; 32]; - rand::rng().fill_bytes(&mut cookie); - let raw_cookie = URL_SAFE_NO_PAD.encode(cookie); - let cookie_hash = secret_hash(&raw_cookie); - let account_subject_hash = secret_hash(&format!("heimdall-account:{}", input.account_id())); - let now = Utc::now(); - let session = AppSession { - schema: "ghostlight.app_session.v2".into(), - cookie_hash: cookie_hash.clone(), - account_subject_hash: account_subject_hash.clone(), - heimdall_session_id: input.heimdall_session_id().to_owned(), - access_revision: input.access_revision(), - verified_capabilities: input.capabilities().to_vec(), - access_expires_at: input.access_expires_at(), - refresh_expires_at: input.refresh_expires_at(), - encrypted_refresh_claim: wrap_refresh( - &self.wrapping_key, - &account_subject_hash, - input.heimdall_session_id(), - input.refresh_claim(), - )?, - created_at: now, - last_refresh_at: now, - revoked_at: None, - }; - let mut next = self.state.clone(); - for previous in next.sessions.values_mut().filter(|previous| { - previous.revoked_at.is_none() - && previous.heimdall_session_id == input.heimdall_session_id() - }) { - previous.revoked_at = Some(now); - } - next.sessions.insert(cookie_hash, session); - self.commit(next)?; - Ok(raw_cookie) - } - - pub(crate) fn is_healthy(&self) -> bool { - self.healthy - } - - pub(crate) fn validate_custody(&mut self) -> anyhow::Result<()> { - self.ensure_owned() - } - - pub(crate) fn account_for_cookie( - &mut self, - raw_cookie: &str, - now: DateTime, - ) -> anyhow::Result> { - self.ensure_owned()?; - let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else { - return Ok(None); - }; - Ok((session.revoked_at.is_none() - && session.access_expires_at > now - && session.refresh_expires_at > now - && session - .verified_capabilities - .iter() - .any(|value| value == "app_access")) - .then(|| { - VerifiedPrincipalEvidence::new( - session.account_subject_hash.clone(), - session.access_expires_at, - ) - })) - } - - pub(crate) fn revoke_cookie(&mut self, raw_cookie: &str) -> anyhow::Result { - self.ensure_owned()?; - let cookie_hash = secret_hash(raw_cookie); - let mut next = self.state.clone(); - let Some(session) = next.sessions.get_mut(&cookie_hash) else { - return Ok(false); - }; - if session.revoked_at.is_some() { - return Ok(false); - } - session.revoked_at = Some(Utc::now()); - self.commit(next)?; - Ok(true) - } - - pub(crate) fn revoke_cookie_hash(&mut self, cookie_hash: &str) -> anyhow::Result { - self.ensure_owned()?; - let mut next = self.state.clone(); - let Some(session) = next.sessions.get_mut(cookie_hash) else { - return Ok(false); - }; - if session.revoked_at.is_some() { - return Ok(false); - } - session.revoked_at = Some(Utc::now()); - self.commit(next)?; - Ok(true) - } - - pub(crate) fn session_for_logout( - &mut self, - raw_cookie: &str, - ) -> anyhow::Result> { - self.ensure_owned()?; - let Some(session) = self.state.sessions.get(&secret_hash(raw_cookie)) else { - return Ok(None); - }; - match self.refresh_candidate(session) { - Ok(candidate) => Ok(Some(candidate)), - Err(error) => self.poison(error), - } - } - - pub(crate) fn sessions_due_for_refresh( - &mut self, - now: DateTime, - refresh_before: chrono::Duration, - ) -> anyhow::Result> { - self.ensure_owned()?; - let candidates = self - .state - .sessions - .values() - .filter(|session| { - session.revoked_at.is_none() - && session.refresh_expires_at > now - && session.access_expires_at <= now + refresh_before - }) - .map(|session| self.refresh_candidate(session)) - .collect::>>(); - match candidates { - Ok(candidates) => Ok(candidates), - Err(error) => self.poison(error), - } - } - - pub(crate) fn apply_refresh( - &mut self, - cookie_hash: &str, - expected_access_revision: u64, - input: VerifiedSessionRefresh, - ) -> anyhow::Result<()> { - if !input - .capabilities() - .iter() - .any(|value| value == "app_access") - { - bail!("refreshed Heimdall session lacks app_access"); - } - if input.refresh_claim().is_empty() - || input.refresh_expires_at() <= input.access_expires_at() - { - bail!("refreshed Heimdall session material is invalid"); - } - let mut next = self.state.clone(); - let session = next - .sessions - .get_mut(cookie_hash) - .context("local session vanished during refresh")?; - if session.revoked_at.is_some() { - return Err(RefreshRefusal::Revoked.into()); - } - if session.access_revision != expected_access_revision { - return Err(RefreshRefusal::MovedSinceCandidate.into()); - } - // Heimdall's access_revision is a revocation epoch: refresh rotates - // the token at the same revision, so equal is the normal receipt and - // only a lower one is a refusal. - if input.access_revision() < expected_access_revision { - return Err(RefreshRefusal::RevisionWentBackwards.into()); - } - if session.heimdall_session_id != input.heimdall_session_id() { - return Err(RefreshRefusal::SessionId.into()); - } - if session.account_subject_hash - != secret_hash(&format!("heimdall-account:{}", input.account_id())) - { - return Err(RefreshRefusal::Account.into()); - } - session.access_revision = input.access_revision(); - session.verified_capabilities = input.capabilities().to_vec(); - session.access_expires_at = input.access_expires_at(); - session.refresh_expires_at = input.refresh_expires_at(); - session.encrypted_refresh_claim = wrap_refresh( - &self.wrapping_key, - &session.account_subject_hash, - &session.heimdall_session_id, - input.refresh_claim(), - )?; - session.last_refresh_at = Utc::now(); - self.commit(next) - } - - fn refresh_candidate(&self, session: &AppSession) -> anyhow::Result { - Ok(RefreshCandidate { - cookie_hash: session.cookie_hash.clone(), - account_subject_hash: session.account_subject_hash.clone(), - heimdall_session_id: session.heimdall_session_id.clone(), - access_revision: session.access_revision, - refresh_claim: unwrap_refresh( - &self.wrapping_key, - &session.account_subject_hash, - &session.heimdall_session_id, - &session.encrypted_refresh_claim, - )?, - }) - } - - fn commit(&mut self, mut next: AppSessionState) -> anyhow::Result<()> { - self.ensure_owned()?; - prune_sessions(&mut next, Utc::now()); - let next_row = envelope(&next)?; - let swapped = self - .store - .compare_and_swap_batch(std::slice::from_ref(&self.row), vec![next_row.clone()]); - if !matches!(swapped, Ok(true)) { - return self.poison(anyhow::anyhow!( - "app-session commit outcome is uncertain; sole ownership was lost" - )); - } - if self.store.validate_path_identity().is_err() { - return self.poison(anyhow::anyhow!( - "app-session store path changed after durable commit" - )); - } - self.row = next_row; - self.state = next; - Ok(()) - } - - fn ensure_owned(&mut self) -> anyhow::Result<()> { - if !self.healthy { - bail!("app-session custody is poisoned; reopen the owner"); - } - if self.store.validate_path_identity().is_err() { - self.healthy = false; - bail!("app-session store ownership was lost"); - } - Ok(()) - } - - fn poison(&mut self, error: anyhow::Error) -> anyhow::Result { - self.healthy = false; - Err(error) - } -} - -/// The idempotency key of the refresh that spends `refresh_claim`: a retry of -/// that refresh derives the same key, the next refresh (a new claim) a new one, -/// and the claim itself never leaves the process in it. -pub(crate) fn refresh_idempotency_key(refresh_claim: &str) -> String { - format!( - "refresh:{}", - secret_hash(&format!("heimdall-refresh-key:{refresh_claim}")) - ) -} - -pub(crate) fn secret_hash(value: &str) -> String { - format!("sha256:{:x}", Sha256::digest(value.as_bytes())) -} - -fn envelope(state: &AppSessionState) -> anyhow::Result { - Ok(CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(state)?, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }) -} - -fn validate_state(state: &AppSessionState, wrapping_key: &[u8; 32]) -> anyhow::Result<()> { - if state.schema != STORE_SCHEMA { - bail!("app-session state schema is invalid"); - } - for (key, session) in &state.sessions { - if key != &session.cookie_hash - || !is_secret_hash(key) - || !is_secret_hash(&session.account_subject_hash) - || session.schema != "ghostlight.app_session.v2" - || session.heimdall_session_id.trim().is_empty() - || session.heimdall_session_id.trim() != session.heimdall_session_id - || !session - .verified_capabilities - .iter() - .any(|value| value == "app_access") - || session.refresh_expires_at <= session.access_expires_at - || session.last_refresh_at < session.created_at - || session - .revoked_at - .is_some_and(|revoked_at| revoked_at < session.created_at) - { - bail!("app-session state contains a noncanonical session"); - } - unwrap_refresh( - wrapping_key, - &session.account_subject_hash, - &session.heimdall_session_id, - &session.encrypted_refresh_claim, - ) - .context("app-session state contains an unreadable refresh claim")?; - } - Ok(()) -} - -fn is_secret_hash(value: &str) -> bool { - value.len() == 71 - && value.strip_prefix("sha256:").is_some_and(|hex| { - hex.bytes() - .all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f')) - }) -} - -fn prune_sessions(state: &mut AppSessionState, now: DateTime) { - let retention_floor = now - chrono::Duration::days(7); - state.sessions.retain(|_, session| { - session.refresh_expires_at > retention_floor - && session - .revoked_at - .is_none_or(|revoked_at| revoked_at > retention_floor) - }); - if state.sessions.len() <= MAX_SESSION_RECORDS { - return; - } - let mut oldest = state - .sessions - .iter() - .map(|(key, session)| { - let live = session.revoked_at.is_none() && session.refresh_expires_at > now; - (live, session.last_refresh_at, key.clone()) - }) - .collect::>(); - oldest.sort(); - for (_, _, key) in oldest - .into_iter() - .take(state.sessions.len() - MAX_SESSION_RECORDS) - { - state.sessions.remove(&key); - } -} - -fn read_wrapping_key(path: &Path) -> anyhow::Result<[u8; 32]> { - let source = std::fs::read(path) - .with_context(|| format!("failed to read session wrapping key {}", path.display()))?; - let decoded = if source.len() == 32 { - source - } else { - URL_SAFE_NO_PAD - .decode(std::str::from_utf8(&source)?.trim()) - .context("session wrapping key must be 32 raw bytes or base64url")? - }; - decoded - .try_into() - .map_err(|_| anyhow::anyhow!("session wrapping key must be exactly 32 bytes")) -} - -fn wrap_refresh( - key: &[u8; 32], - account_hash: &str, - session_id: &str, - claim: &str, -) -> anyhow::Result { - let mut iv = [0_u8; 12]; - rand::rng().fill_bytes(&mut iv); - let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}"); - let ciphertext = Aes256Gcm::new_from_slice(key)? - .encrypt( - (&iv).into(), - aes_gcm::aead::Payload { - msg: claim.as_bytes(), - aad: aad.as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to wrap Heimdall refresh claim"))?; - Ok(format!( - "{}.{}", - URL_SAFE_NO_PAD.encode(iv), - URL_SAFE_NO_PAD.encode(ciphertext) - )) -} - -fn unwrap_refresh( - key: &[u8; 32], - account_hash: &str, - session_id: &str, - wrapped: &str, -) -> anyhow::Result { - let (iv, ciphertext) = wrapped - .split_once('.') - .context("wrapped refresh claim is malformed")?; - let iv: [u8; 12] = URL_SAFE_NO_PAD - .decode(iv)? - .try_into() - .map_err(|_| anyhow::anyhow!("wrapped refresh IV is invalid"))?; - let ciphertext = URL_SAFE_NO_PAD.decode(ciphertext)?; - let aad = format!("ghostlight.app_session.refresh.v2\n{account_hash}\n{session_id}"); - let plaintext = Aes256Gcm::new_from_slice(key)? - .decrypt( - (&iv).into(), - aes_gcm::aead::Payload { - msg: &ciphertext, - aad: aad.as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to unwrap Heimdall refresh claim"))?; - String::from_utf8(plaintext).context("Heimdall refresh claim is not UTF-8") -} - -#[cfg(test)] -mod tests { - use super::*; - - /// Every `.rs` file under `src`, subdirectories included, with everything - /// a `#[cfg(test)]` attribute guards removed — whatever that item is, and - /// whatever a test module is called. - /// - /// The item's extent is read from the layout: an attribute at indent `N` - /// guards lines until the first later line at indent `N` that closes a - /// block, or, for an item with no body, until the first line at indent `N` - /// that ends in `;` or `,`. This is a claim about rustfmt's output, not - /// about Rust's grammar, and it is the reason the scan does not try to - /// balance braces: braces inside string and character literals are not - /// block structure, and a scanner that counts them reads the end of a test - /// module in the wrong place. - fn production_sources() -> Vec<(std::path::PathBuf, String)> { - fn indent(line: &str) -> usize { - line.len() - line.trim_start().len() - } - - fn strip_cfg_test(text: &str) -> String { - let lines: Vec<&str> = text.lines().collect(); - let mut kept: Vec<&str> = Vec::new(); - let mut index = 0; - while index < lines.len() { - let line = lines[index]; - if line.trim() != "#[cfg(test)]" { - kept.push(line); - index += 1; - continue; - } - let guard = indent(line); - index += 1; - // A body opens on the attribute's own item; without one, the - // item ends at its first terminator. - let mut opened = lines - .get(index) - .is_some_and(|next| next.trim_end().ends_with('{')); - while index < lines.len() { - let body = lines[index]; - index += 1; - let trimmed = body.trim(); - if trimmed.is_empty() { - continue; - } - if opened { - if indent(body) == guard - && (trimmed == "}" || trimmed == "};" || trimmed == "},") - { - break; - } - } else if indent(body) == guard - && (trimmed.ends_with(';') || trimmed.ends_with(',')) - { - break; - } else if indent(body) == guard && trimmed.ends_with('{') { - opened = true; - } - } - } - kept.join("\n") - } - - let mut sources = Vec::new(); - let mut pending = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - while let Some(directory) = pending.pop() { - for entry in std::fs::read_dir(&directory).expect("the source tree reads") { - let path = entry.expect("a readable entry").path(); - if path.is_dir() { - pending.push(path); - continue; - } - if path.extension().and_then(|value| value.to_str()) != Some("rs") { - continue; - } - let text = std::fs::read_to_string(&path) - .expect("the source reads") - .replace("\r\n", "\n"); - sources.push((path, strip_cfg_test(&text))); - } - } - sources - } - - /// True when the text immediately following the type's name calls its - /// `new`. Written as a scan rather than one literal so the qualified - /// `::new(` form and any whitespace the formatter may introduce - /// count the same as the plain path. - fn calls_new(tail: &str) -> bool { - let tail = tail.trim_start(); - let tail = tail.strip_prefix('>').unwrap_or(tail).trim_start(); - let Some(tail) = tail.strip_prefix("::") else { - return false; - }; - let Some(tail) = tail.trim_start().strip_prefix("new") else { - return false; - }; - tail.trim_start().starts_with('(') - } - - /// The library owns `VerifiedPrincipalEvidence` and gives it a public - /// constructor, so the single-minter rule its private fields used to - /// enforce is this one: production mints it in `account_for_cookie` and - /// nowhere else, from a live cookie this owner holds custody of. A second - /// minter anywhere in Dungeon's production source is a second authority - /// over who the kernel believes is speaking. - /// - /// The type is followed by name, so production may not give it a second - /// name: an `as` import or a type alias is refused outright rather than - /// counted, because a renamed minter is one this test cannot follow. What - /// it still cannot see is a call that never spells the type — a macro - /// expansion, or a generic instantiated elsewhere. - #[test] - fn soul_exactly_one_production_site_mints_verified_principal_evidence() { - // Assembled from halves so this test's own source is never a match, - // whatever the scan above decides about this module's extent. - let name = format!("VerifiedPrincipal{}", "Evidence"); - let mut sites = Vec::new(); - for (path, production) in production_sources() { - for (at, _) in production.match_indices(name.as_str()) { - let tail = &production[at + name.len()..]; - assert!( - !tail.trim_start().starts_with("as "), - "{} renames {name} on import", - path.display() - ); - if calls_new(tail) { - sites.push(path.clone()); - } - } - for line in production.lines() { - assert!( - !(line.trim_start().starts_with("type ") && line.contains(name.as_str())), - "{} aliases {name} to another name: {line}", - path.display() - ); - } - } - assert_eq!( - sites.len(), - 1, - "production mints {name} in exactly one place: {sites:?}" - ); - assert!( - sites[0].ends_with("app_session.rs"), - "the one minter is not this owner: {:?}", - sites[0] - ); - } - - fn session(claim: &str) -> VerifiedSessionAdmission { - VerifiedSessionAdmission::fixture( - "account-1", - "heimdall-session-1", - 3, - Utc::now() + chrono::Duration::minutes(5), - Utc::now() + chrono::Duration::days(7), - claim, - ) - } - - #[test] - fn store_rejects_an_alternate_messagepack_shape() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [5_u8; 32]).unwrap(); - let store_path = directory.path().join("app-sessions-v2.cc"); - let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let state = AppSessionState { - schema: STORE_SCHEMA.into(), - sessions: BTreeMap::new(), - }; - let row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec(&state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }; - assert!(store.compare_and_swap_batch(&[], vec![row]).unwrap()); - drop(store); - assert!(AppSessionOwner::open(&store_path, &key).is_err()); - } - - #[test] - fn stores_only_session_custody_and_reopens() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [7_u8; 32]).unwrap(); - let store = directory.path().join("app-sessions-v2.cc"); - let mut owner = AppSessionOwner::open(&store, &key).unwrap(); - let cookie = owner - .create_session(session("private-refresh-claim")) - .unwrap(); - let account = owner - .account_for_cookie(&cookie, Utc::now()) - .unwrap() - .unwrap(); - let account_hash = account.account_subject_hash().to_owned(); - drop(owner); - - let mut reopened = AppSessionOwner::open(&store, &key).unwrap(); - assert_eq!( - reopened - .account_for_cookie(&cookie, Utc::now()) - .unwrap() - .unwrap() - .account_subject_hash(), - account_hash - ); - assert!(reopened.revoke_cookie(&cookie).unwrap()); - assert_eq!( - reopened.account_for_cookie(&cookie, Utc::now()).unwrap(), - None - ); - } - - #[test] - fn exact_heimdall_adoption_retry_rotates_the_only_live_cookie() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [9_u8; 32]).unwrap(); - let store = directory.path().join("app-sessions-v2.cc"); - let mut owner = AppSessionOwner::open(&store, &key).unwrap(); - let first = owner - .create_session(session("same-refresh-receipt")) - .unwrap(); - let second = owner - .create_session(session("same-refresh-receipt")) - .unwrap(); - - assert_ne!(first, second); - assert_eq!(owner.account_for_cookie(&first, Utc::now()).unwrap(), None); - assert!( - owner - .account_for_cookie(&second, Utc::now()) - .unwrap() - .is_some() - ); - assert_eq!( - owner - .state - .sessions - .values() - .filter(|session| session.revoked_at.is_none()) - .count(), - 1 - ); - } - - #[test] - fn rejects_legacy_or_foreign_store_instead_of_migrating_it() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [3_u8; 32]).unwrap(); - let store_path = directory.path().join("app-sessions-v2.cc"); - let store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let foreign = CultCacheEnvelope { - key: "primary".into(), - r#type: "app_session_store.v1".into(), - payload: vec![], - stored_at: Utc::now().to_rfc3339(), - schema_id: Some("ghostlight.app_session_store.v1".into()), - }; - assert!(store.compare_and_swap_batch(&[], vec![foreign]).unwrap()); - assert!(AppSessionOwner::open(&store_path, &key).is_err()); - } - - #[test] - fn a_refresh_for_a_session_that_moved_is_refused_by_name() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [5_u8; 32]).unwrap(); - let mut owner = - AppSessionOwner::open(directory.path().join("sessions.cc"), &key).unwrap(); - let cookie = owner - .create_session(VerifiedSessionAdmission::fixture( - "account", - "session", - 1, - Utc::now() + chrono::Duration::minutes(1), - Utc::now() + chrono::Duration::days(1), - "claim-0", - )) - .unwrap(); - let receipt = VerifiedSessionRefresh::fixture( - "account", - "session", - 1, - Utc::now() + chrono::Duration::minutes(1), - Utc::now() + chrono::Duration::days(1), - "claim-1", - ); - let error = owner - .apply_refresh(&secret_hash(&cookie), 0, receipt) - .unwrap_err(); - assert_eq!( - error.downcast_ref::(), - Some(&RefreshRefusal::MovedSinceCandidate) - ); - } - - #[test] - fn refresh_claim_is_encrypted_and_exactly_bound() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [11_u8; 32]).unwrap(); - let store = directory.path().join("app-sessions-v2.cc"); - let mut owner = AppSessionOwner::open(&store, &key).unwrap(); - let cookie = owner - .create_session(session("needle-private-refresh")) - .unwrap(); - let candidate = owner.session_for_logout(&cookie).unwrap().unwrap(); - assert_eq!(candidate.refresh_claim, "needle-private-refresh"); - drop(owner); - let bytes = std::fs::read(store).unwrap(); - assert!( - !bytes - .windows("needle-private-refresh".len()) - .any(|window| window == b"needle-private-refresh") - ); - } - - #[test] - fn replacing_the_store_path_revokes_session_authentication_until_reopen() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [19_u8; 32]).unwrap(); - let path = directory.path().join("app-sessions-v2.cc"); - let displaced = directory.path().join("displaced.cc"); - let mut owner = AppSessionOwner::open(&path, &key).unwrap(); - let cookie = owner - .create_session(session("private-refresh-claim")) - .unwrap(); - - std::fs::rename(&path, &displaced).unwrap(); - std::fs::File::create(&path).unwrap(); - assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err()); - assert!(!owner.is_healthy()); - - std::fs::remove_file(&path).unwrap(); - std::fs::rename(&displaced, &path).unwrap(); - assert!(owner.account_for_cookie(&cookie, Utc::now()).is_err()); - drop(owner); - - let mut reopened = AppSessionOwner::open(&path, &key).unwrap(); - assert!( - reopened - .account_for_cookie(&cookie, Utc::now()) - .unwrap() - .is_some() - ); - } - - #[test] - fn reopen_rejects_corrupt_inner_session_custody() { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [23_u8; 32]).unwrap(); - let path = directory.path().join("app-sessions-v2.cc"); - let mut owner = AppSessionOwner::open(&path, &key).unwrap(); - owner - .create_session(session("private-refresh-claim")) - .unwrap(); - drop(owner); - - let store = OwnedRedbMessagePackBackingStore::new(&path).unwrap(); - let rows = store.pull_all().unwrap(); - let mut state: AppSessionState = rmp_serde::from_slice(&rows[0].payload).unwrap(); - state - .sessions - .values_mut() - .next() - .unwrap() - .encrypted_refresh_claim = "not-an-encrypted-claim".into(); - let corrupt = envelope(&state).unwrap(); - assert!(store.compare_and_swap_batch(&rows, vec![corrupt]).unwrap()); - drop(store); - - assert!(AppSessionOwner::open(&path, &key).is_err()); - } -} diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs deleted file mode 100644 index b7de3a7..0000000 --- a/crates/ghostlight-dungeon/src/eve.rs +++ /dev/null @@ -1,1006 +0,0 @@ -//! Eve/CultUI projection for the one live world owner. - -use crate::mesh::{COMMAND_BOUNDARY, COMMAND_RECEIPT_SCHEMA, COMMAND_RESULT_SCHEMA, OWNER_REPO, PROVIDER_ID, SURFACE_ID}; -use crate::play::{PlayTurnState, PlayTurnView}; -use ghostlight::{JurisdictionKey, WorldPhase, WorldSnapshot}; -use anyhow::{Context, bail}; -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; -use uuid::Uuid; - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub(crate) struct EveCommandInvocation { - pub(crate) schema: String, - #[serde(rename = "providerId")] - pub(crate) provider_id: String, - #[serde(rename = "surfaceId")] - pub(crate) surface_id: String, - pub(crate) operation: EveOperation, - pub(crate) payload: Value, - #[serde(rename = "issuedAt")] - pub(crate) issued_at: String, - #[serde(rename = "clientId")] - pub(crate) client_id: String, - #[serde(rename = "commandBoundary")] - pub(crate) command_boundary: String, - #[serde(rename = "receiptSchema")] - pub(crate) receipt_schema: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct EveOperation { - pub(crate) operation_id: String, - pub(crate) schema_id: Option, - pub(crate) idempotency_key: Option, - #[serde(default)] - pub(crate) route_hint: EveRouteHint, -} - -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -pub(crate) struct EveRouteHint { - pub(crate) source_version: Option, - pub(crate) transport: Option, -} - -/// The card's whole account of a turn closed by a fault: a fixed line, so -/// the row can carry no detail text at all. -const PLAY_FAULT_LINE: &str = - "The storyteller lost the thread and your turn ended. Write again to start a new turn."; - -/// What the Begin button captures from the owner: the three fields the form -/// asks for, and all `world_begin.v1` carries. Every other choice a world is -/// born with is the route's (`runtime::begin_intent`), not the surface's. -const BEGIN_BINDINGS: [&str; 3] = ["title", "brief", "subject_label"]; - -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 => { - children.extend([ - json!({ - "id":"world.begin.title", - "kind":"control.input.text", - "props":{"label":"World title","placeholder":"A name for this world"}, - "stateBindings":[local_draft("title", "string")], - "children":[] - }), - json!({ - "id":"world.begin.subject", - "kind":"control.input.text", - "props":{"label":"Your name","placeholder":"The first person in the world"}, - "stateBindings":[local_draft("subject_label", "string")], - "children":[] - }), - // `brief` is "required, may be empty" (`BeginPayload::brief`): - // an authored `value`, because the lowering captures only a - // `value`, so a form left untouched here still submits. - json!({ - "id":"world.begin.brief", - "kind":"control.input.textarea", - "props":{"label":"Brief (optional)","rows":2,"value":"","placeholder":"One sentence of what this world is for"}, - "stateBindings":[local_draft("brief", "string")], - "children":[] - }), - command_button( - "world.begin", - "Begin", - "world.begin", - json!({}), - &BEGIN_BINDINGS, - ), - ]); - commands.push(command_descriptor( - "world.begin", - "ghostlight.world_begin.v1", - &BEGIN_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.begin.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.begin" => "ghostlight.world_begin.v1", - "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); - } - - fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node["id"] == id { - return Some(node); - } - node["children"].as_array()?.iter().find_map(|child| find(child, id)) - } - - /// R2 at the surface: the empty world asks for a title, a name and an - /// optional brief, and offers one command, Begin. The button carries no - /// authored choice of its own: what a world is born with beyond those - /// three fields is the route's, and the descriptor captures only what the - /// form asks. - #[test] - fn the_empty_world_surface_asks_for_a_title_a_name_and_offers_one_begin() { - let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); - assert_eq!(surface["version"], 0); - let encoded = serde_json::to_string(&surface).unwrap(); - for retired in [ - "narrative_persona", - "operational_agent", - "world.create", - "lens_weights", - "jurisdictions", - "session_zero", - "campaign", - ] { - assert!( - !encoded.contains(retired), - "the empty-world surface still carries {retired}" - ); - } - let root = &surface["surface"]["root"]; - for id in ["world.begin.title", "world.begin.subject", "world.begin.brief"] { - assert!(find(root, id).is_some(), "{id}"); - } - for id in [ - "world.begin.narrative_persona", - "world.begin.operational_agent", - "world.begin.targets", - "world.begin.jurisdictions", - "world.begin.lens_weights", - ] { - assert!(find(root, id).is_none(), "{id}"); - } - assert_eq!(find(root, "world.begin.brief").unwrap()["props"]["value"], ""); - - // Beside the sign-out every signed-in surface carries, Begin is the - // only command. - let commands: Vec<&Value> = surface["commands"] - .as_array() - .unwrap() - .iter() - .filter(|descriptor| descriptor["command"] != "app.auth.logout") - .collect(); - assert_eq!(commands.len(), 1, "{commands:?}"); - assert_eq!(commands[0]["command"], "world.begin"); - assert_eq!(commands[0]["payloadSchema"], "ghostlight.world_begin.v1"); - assert_eq!(commands[0]["authority"], "WorldMailbox"); - assert_eq!(commands[0]["captureBindings"], json!(["title", "brief", "subject_label"])); - - let button = find(root, "world.begin").expect("a Begin button"); - assert_eq!(button["props"]["label"], "Begin"); - assert_eq!( - button["props"]["action"], - json!({"command":"world.begin"}), - "the Begin button authors nothing beyond its command" - ); - } - - /// Each control feeds the field it is named for: typing into a control - /// puts the text under the binding that control declares, and the Begin - /// button captures exactly the bindings the three controls declare. A - /// control bound to another control's field would submit no name (or - /// overwrite the title) in a real client. - #[test] - fn each_begin_control_is_bound_to_its_own_field_and_the_button_captures_them_all() { - let surface = authenticated_surface("sha256:owner", None, None, false).unwrap(); - let root = &surface["surface"]["root"]; - let bound = |id: &str| -> String { - let bindings = find(root, id).unwrap()["stateBindings"].as_array().unwrap().clone(); - assert_eq!(bindings.len(), 1, "{id}"); - bindings[0]["bindingName"].as_str().unwrap().to_owned() - }; - assert_eq!(bound("world.begin.title"), "title"); - assert_eq!(bound("world.begin.subject"), "subject_label"); - assert_eq!(bound("world.begin.brief"), "brief"); - let mut captured: Vec = find(root, "world.begin").unwrap()["props"]["captureBindings"] - .as_array() - .unwrap() - .iter() - .map(|name| name.as_str().unwrap().to_owned()) - .collect(); - captured.sort(); - assert_eq!(captured, ["brief", "subject_label", "title"]); - } - - /// `world.play`'s own operation id resolves to its schema through - /// `operation_schema`, so an invocation whose payload matches it - /// validates, and a payload that tries to claim authority is refused — - /// the same claim this test once made for the now-deleted speak - /// operation, PA.f146's own precedent (Cut 8b's schema existed here - /// before Cut 9 published `world.play`'s own descriptor and button, - /// below). - #[test] - fn payload_cannot_claim_authority() { - assert_eq!(operation_schema("world.play"), Some("ghostlight.world_play.v0")); - - let bound = invocation( - "world.play", - "ghostlight.world_play.v0", - json!({"text":"I look around.", "answers": null}), - ); - assert!(validate_invocation(&bound, "https-json").is_ok()); - - let forged = invocation( - "world.play", - "ghostlight.world_play.v0", - json!({"text":"I look around.", "answers": null, "caller":"owner"}), - ); - assert!(validate_invocation(&forged, "https-json").is_err()); - } - - #[test] - fn invocation_metadata_rejects_legacy_authority_extensions() { - let exact = invocation( - "world.play", - "ghostlight.world_play.v0", - json!({"text":"I look around.", "answers": null}), - ); - let mut outer = serde_json::to_value(&exact).unwrap(); - outer["callerId"] = json!("legacy-owner"); - assert!(serde_json::from_value::(outer).is_err()); - - let mut route = serde_json::to_value(&exact).unwrap(); - route["operation"]["routeHint"]["caller"] = json!("legacy-owner"); - assert!(serde_json::from_value::(route).is_err()); - } - - #[test] - fn removed_operation_is_unknown() { - assert!(operation_schema("session_zero.begin").is_none()); - assert!(operation_schema("world.assess").is_none()); - assert!(operation_schema("governance.time.propose").is_none()); - assert!(operation_schema("world.speak").is_none()); - } -} diff --git a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json deleted file mode 100644 index 5798d2d..0000000 --- a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json +++ /dev/null @@ -1,60 +0,0 @@ -{ - "schema": "ghostlight.eve_client_bridge_fixture.v1", - "steps": [ - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-10T00:38:56.201Z", - "operation": { - "idempotencyKey": "25d921aa-f228-425c-92e3-08c5dd9af508", - "operationId": "world.begin", - "routeHint": { - "sourceVersion": 0, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_begin.v1" - }, - "payload": { - "bindings": { - "brief": "A world captured for the committed Eve client bridge fixture.", - "subject_label": "The Fixture Operator", - "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.begin" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-10T00:38:57.625Z", - "operation": { - "idempotencyKey": "fc391d0d-d107-4a66-b5c5-b754ffa814fa", - "operationId": "world.play", - "routeHint": { - "sourceVersion": 3, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_play.v0" - }, - "payload": { - "bindings": { - "text": "The new owner looks around." - } - }, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.play" - } - ], - "vendorEveSubmoduleRevision": "672c0c1e9fc828b63752345385727b06c50491d0" -} diff --git a/crates/ghostlight-dungeon/src/heimdall.rs b/crates/ghostlight-dungeon/src/heimdall.rs deleted file mode 100644 index 9b9e27b..0000000 --- a/crates/ghostlight-dungeon/src/heimdall.rs +++ /dev/null @@ -1,1678 +0,0 @@ -use aes_gcm::{ - Aes256Gcm, KeyInit, - aead::{Aead, Payload}, -}; -use anyhow::{Context, bail}; -use base64::{ - Engine, - engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, -}; -use chrono::{DateTime, SecondsFormat, Utc}; -use cultcache_rs::DatabaseEntry; -use cultmesh_rs::{CultMesh, CultMeshNodeOptions, CultMeshRudpSnapshotOptions}; -use cultnet_rs::{ - CULTNET_OPERATION_CONNECTION_ID, CultNetMessage, CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, -}; -use hmac::{Hmac, Mac}; -use jsonwebtoken::{ - Algorithm, DecodingKey, Validation, decode, decode_header, - jwk::{Jwk, JwkSet}, -}; -use rand::RngCore; -use reqwest::Client; -use serde::{Deserialize, Serialize, de::DeserializeOwned}; -use sha2::{Digest, Sha256}; -use std::{ - net::{SocketAddr, UdpSocket}, - path::PathBuf, - sync::{Arc, Mutex as StdMutex}, - time::{Duration, Instant}, -}; - -const APP_SLUG: &str = "ghostlight"; -const PRIVATE_SERVICE: &str = "heimdall.private.commands"; -const PRIVATE_ENVELOPE_SCHEMA: &str = "heimdall.private_command_envelope.v1"; - -/// Heimdall answered a private command with status denied. Distinct from a -/// transport failure so the refresh loop never reads a message string. -#[derive(Debug, thiserror::Error)] -#[error("Heimdall denied the private command: {diagnostics}")] -pub(crate) struct HeimdallDenied { - pub(crate) diagnostics: String, -} - -/// Heimdall's signing keys could not be fetched, so a receipt could not be -/// judged either way. Distinct from a receipt that positively fails -/// verification: the refresh loop keeps the receipt for this and revokes for -/// that. It carries an error class and never the fetch error itself, whose -/// text names the key-set URL: an error never echoes an input value. -#[derive(Debug, thiserror::Error)] -#[error("Heimdall signing keys unavailable: the key-set fetch {class}")] -pub(crate) struct VerificationUnavailable { - class: &'static str, -} - -impl VerificationUnavailable { - fn of_fetch(error: &reqwest::Error) -> Self { - let class = if error.is_timeout() { - "timed out" - } else if error.is_connect() { - "could not connect" - } else if error.is_status() { - "returned an error status" - } else if error.is_decode() { - "returned an unreadable key set" - } else { - "failed" - }; - Self { class } - } - - #[cfg(test)] - pub(crate) fn new(class: &'static str) -> Self { - Self { class } - } -} - -/// How long a fetched signing-key set is trusted before it is fetched again. -const JWKS_TTL: Duration = Duration::from_secs(300); - -/// The last fetched signing-key set and when it was fetched. -#[derive(Default)] -pub(crate) struct JwksCache { - fetched: Option<(Instant, JwkSet)>, -} - -impl JwksCache { - /// The key published under `kid`, if the cached set is still fresh and - /// holds it. A miss on a fresh set still refetches, so a rotated key is - /// found. - pub(crate) fn key(&self, kid: &str, now: Instant) -> Option { - let (fetched_at, set) = self.fetched.as_ref()?; - if now.saturating_duration_since(*fetched_at) >= JWKS_TTL { - return None; - } - set.find(kid).cloned() - } - - pub(crate) fn store(&mut self, set: JwkSet, now: Instant) { - self.fetched = Some((now, set)); - } -} - -/// The two Heimdall calls the refresh loop makes, as a port so a scripted -/// Heimdall can stand in for the real one. -#[async_trait::async_trait] -pub(crate) trait SessionRefresher { - async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result; - - async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result; -} - -#[async_trait::async_trait] -impl SessionRefresher for HeimdallClient { - async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - HeimdallClient::refresh(self, refresh_token, idempotency_key).await - } - - async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - HeimdallClient::verify_refresh(self, completion).await - } -} - -#[derive(Clone)] -pub struct HeimdallClient { - http: Client, - issuer: String, - public_app_url: String, - discord_guild_id: String, - discord_role_id: String, - boundary_locator: HeimdallBoundaryLocator, - runtime_id: String, - shared_secret: String, - jwks: Arc>, -} - -/// Idunn's projection of a managed Heimdall dependency, when Heimdall is a -/// v2-managed target (it is not, as of this cut: every v2 deploy of it has -/// failed and it still runs as the legacy `heimdall.service`, so -/// `runtime.rs` always passes `None` here). Not an Idunn dependency in -/// `deployment/idunn/recipe.toml` right now, but the type stays owned here -/// so `require_expected_heimdall_boundary` below can resume checking a -/// discovered boundary against it the moment Heimdall returns as a managed -/// dependency, without re-deriving this shape from scratch. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct HeimdallDependencyBinding { - pub(crate) provider_id: String, - pub(crate) endpoint: SocketAddr, -} - -#[derive(Clone)] -enum HeimdallBoundaryLocator { - Odin { - endpoint: SocketAddr, - expected: Option, - }, - #[cfg(test)] - Fixed(ResolvedHeimdallBoundary), -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct ResolvedHeimdallBoundary { - endpoint: SocketAddr, - daemon_id: String, - operations: Vec, -} - -#[derive(Clone, Debug, DatabaseEntry)] -#[cultcache( - type = "heimdall.command_boundary", - schema = "heimdall.command_boundary.v1" -)] -struct HeimdallCommandBoundaryCatalogEntry { - #[cultcache(key = 0)] - value: serde_json::Value, -} - -/// Heimdall owns this document and the receipts below; Dungeon reads the -/// fields it needs and ignores the rest. Only the signed `PrivateEnvelope`, -/// whose shape both sides fix, rejects unknown fields. -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HeimdallCommandBoundaryRecord { - schema: String, - boundary_id: String, - daemon_id: String, - provider_id: String, - updated_at: String, - commands: Vec, - private_route: HeimdallPrivateRoute, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HeimdallBoundaryCommand { - operation: String, - request_schema: String, - response_schema: String, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -struct HeimdallPrivateRoute { - endpoint: String, - exposure: String, - authentication: String, - secret_bearing: bool, -} - -cultmesh_rs::cultmesh_documents!(HeimdallDiscoveryDocuments { - HeimdallCommandBoundaryCatalogEntry => "heimdall.command_boundary.v1", -}); - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields, rename_all = "camelCase")] -struct PrivateEnvelope { - schema: String, - app_slug: String, - operation: String, - content_schema: String, - issued_at: String, - expires_at: String, - nonce: String, - idempotency_key: String, - iv: String, - ciphertext: String, - auth_tag: String, - signature: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct BeginCommand<'a> { - provider: &'a str, - mode: &'a str, - return_to: &'a str, - entitlement_policy: DiscordRolePolicy<'a>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct RefreshCommand<'a> { - schema: &'static str, - refresh_token: &'a str, - entitlement_policy: DiscordRolePolicy<'a>, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct LogoutCommand<'a> { - schema: &'static str, - refresh_token: &'a str, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct DiscordRolePolicy<'a> { - kind: &'a str, - guild_id: &'a str, - allowed_role_ids: [&'a str; 1], -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthBeginReceipt { - pub status: String, - pub handle: String, - pub expires_at: String, - pub navigation: AuthNavigation, -} - -#[derive(Debug, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthNavigation { - pub url: String, - pub allowed_origins: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthCompletionReceipt { - pub status: String, - pub handle: Option, - pub error: Option, - pub account: Option, - pub session: Option, - pub access_token: Option, - pub refresh_token: Option, - pub refresh: Option, - #[serde(default)] - pub shared_capabilities: Vec, -} - -#[derive(Clone, Debug, Deserialize)] -pub struct AccountSummary { - pub id: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct HeimdallSession { - pub account_id: String, - pub session_id: String, - pub app_slug: String, - pub access_revision: u64, - pub expires_at: String, -} - -#[derive(Clone, Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RefreshSummary { - pub expires_at: String, -} - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AuthLogoutReceipt { - pub status: String, - pub session_id: String, - pub access_revision: u64, - pub revoked_at: String, -} - -#[derive(Debug, Deserialize)] -pub struct AccessClaims { - pub iss: String, - pub aud: String, - pub sub: String, - pub sid: String, - pub exp: u64, - pub typ: String, - pub account_id: String, - pub access_revision: u64, - pub capabilities: Vec, - pub app: AppClaim, -} - -#[derive(Debug, Deserialize)] -pub struct AppClaim { - pub slug: String, -} - -/// Opaque session material admitted only after a signed Heimdall completion -/// and its response envelope agree exactly. -pub(crate) struct VerifiedSessionAdmission(VerifiedSessionMaterial); - -/// Opaque refresh material admitted only after a signed Heimdall refresh and -/// its response envelope agree exactly. -pub(crate) struct VerifiedSessionRefresh(VerifiedSessionMaterial); - -struct VerifiedSessionMaterial { - account_id: String, - heimdall_session_id: String, - access_revision: u64, - capabilities: Vec, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - refresh_claim: String, -} - -macro_rules! verified_session_accessors { - ($name:ident) => { - impl $name { - pub(crate) fn account_id(&self) -> &str { - &self.0.account_id - } - - pub(crate) fn heimdall_session_id(&self) -> &str { - &self.0.heimdall_session_id - } - - pub(crate) fn access_revision(&self) -> u64 { - self.0.access_revision - } - - pub(crate) fn capabilities(&self) -> &[String] { - &self.0.capabilities - } - - pub(crate) fn access_expires_at(&self) -> DateTime { - self.0.access_expires_at.to_owned() - } - - pub(crate) fn refresh_expires_at(&self) -> DateTime { - self.0.refresh_expires_at.to_owned() - } - - pub(crate) fn refresh_claim(&self) -> &str { - &self.0.refresh_claim - } - } - }; -} - -verified_session_accessors!(VerifiedSessionAdmission); -verified_session_accessors!(VerifiedSessionRefresh); - -#[cfg(test)] -impl VerifiedSessionAdmission { - pub(crate) fn fixture( - account_id: impl Into, - heimdall_session_id: impl Into, - access_revision: u64, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - refresh_claim: impl Into, - ) -> Self { - Self(VerifiedSessionMaterial { - account_id: account_id.into(), - heimdall_session_id: heimdall_session_id.into(), - access_revision, - capabilities: vec!["app_access".into()], - access_expires_at, - refresh_expires_at, - refresh_claim: refresh_claim.into(), - }) - } -} - -#[cfg(test)] -impl VerifiedSessionRefresh { - pub(crate) fn fixture( - account_id: impl Into, - heimdall_session_id: impl Into, - access_revision: u64, - access_expires_at: DateTime, - refresh_expires_at: DateTime, - refresh_claim: impl Into, - ) -> Self { - Self(VerifiedSessionMaterial { - account_id: account_id.into(), - heimdall_session_id: heimdall_session_id.into(), - access_revision, - capabilities: vec!["app_access".into()], - access_expires_at, - refresh_expires_at, - refresh_claim: refresh_claim.into(), - }) - } -} - -impl HeimdallClient { - pub fn from_env( - runtime_id: &str, - odin_endpoint: SocketAddr, - expected: Option, - ) -> anyhow::Result { - if runtime_id.is_empty() || runtime_id.trim() != runtime_id { - bail!("Ghostlight runtime identity is not canonical"); - } - let secret_path = std::env::var_os("GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE") - .map(PathBuf::from) - .context("GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE is required")?; - let shared_secret = std::fs::read_to_string(&secret_path) - .with_context(|| format!("failed to read {}", secret_path.display()))? - .trim() - .to_owned(); - if shared_secret.is_empty() { - bail!("Ghostlight's Heimdall app secret is empty"); - } - Ok(Self { - http: Client::builder().timeout(Duration::from_secs(10)).build()?, - issuer: std::env::var("GHOSTLIGHT_HEIMDALL_BASE_URL") - .unwrap_or_else(|_| "https://heimdall.gamecult.org".into()) - .trim_end_matches('/') - .into(), - public_app_url: std::env::var("GHOSTLIGHT_PUBLIC_APP_URL") - .unwrap_or_else(|_| "https://yggdrasil.gamecult.org/ghostlight/".into()), - discord_guild_id: required_env("GHOSTLIGHT_DISCORD_GUILD_ID")?, - discord_role_id: required_env("GHOSTLIGHT_DISCORD_ROLE_ID")?, - boundary_locator: HeimdallBoundaryLocator::Odin { - endpoint: odin_endpoint, - expected, - }, - runtime_id: runtime_id.into(), - shared_secret, - jwks: Arc::default(), - }) - } - - #[cfg(test)] - pub fn fixture() -> Self { - Self { - http: Client::new(), - issuer: "https://heimdall.invalid".into(), - public_app_url: "https://ghostlight.invalid/".into(), - discord_guild_id: "guild-kltst".into(), - discord_role_id: "role-kltst".into(), - boundary_locator: HeimdallBoundaryLocator::Fixed(ResolvedHeimdallBoundary { - endpoint: "127.0.0.1:9".parse().unwrap(), - daemon_id: "yggdrasil-heimdall".into(), - operations: vec![ - "heimdall.auth.begin".into(), - "heimdall.auth.complete".into(), - "heimdall.auth.refresh".into(), - "heimdall.auth.logout".into(), - ], - }), - runtime_id: "ghostlight-test-runtime".into(), - shared_secret: "fixture-secret".into(), - jwks: Arc::default(), - } - } - - /// The same client against another issuer, for tests that stand up their own. - #[cfg(test)] - pub(crate) fn with_issuer(mut self, issuer: impl Into) -> Self { - self.issuer = issuer.into(); - self - } - - pub async fn begin(&self, idempotency_key: &str) -> anyhow::Result { - let payload = BeginCommand { - provider: "discord", - mode: "sign_in", - return_to: &self.public_app_url, - entitlement_policy: DiscordRolePolicy { - kind: "discord_role_access", - guild_id: &self.discord_guild_id, - allowed_role_ids: [&self.discord_role_id], - }, - }; - self.invoke( - "heimdall.auth.begin", - "heimdall.auth_begin_command.v1", - idempotency_key, - &payload, - "heimdall.auth_begin_receipt.v1", - ) - .await - } - - pub async fn complete( - &self, - handle: &str, - idempotency_key: &str, - ) -> anyhow::Result { - self.invoke( - "heimdall.auth.complete", - "heimdall.auth_complete_command.v1", - idempotency_key, - &serde_json::json!({"handle": handle}), - "heimdall.auth_completion_receipt.v1", - ) - .await - } - - pub async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - let payload = RefreshCommand { - schema: "heimdall.auth_refresh_command.v1", - refresh_token, - entitlement_policy: DiscordRolePolicy { - kind: "discord_role_access", - guild_id: &self.discord_guild_id, - allowed_role_ids: [&self.discord_role_id], - }, - }; - self.invoke( - "heimdall.auth.refresh", - "heimdall.auth_refresh_command.v1", - idempotency_key, - &payload, - "heimdall.auth_refresh_receipt.v1", - ) - .await - } - - pub async fn logout( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - let payload = LogoutCommand { - schema: "heimdall.auth_logout_command.v1", - refresh_token, - }; - self.invoke( - "heimdall.auth.logout", - "heimdall.auth_logout_command.v1", - idempotency_key, - &payload, - "heimdall.auth_logout_receipt.v1", - ) - .await - } - - pub async fn verify_completion( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - if completion.status != "authenticated" { - bail!("Heimdall completion is not authenticated"); - } - let token = completion - .access_token - .as_deref() - .context("Heimdall omitted the access token")?; - let claims = self.verify_access(token).await?; - verify_claim_binding(&completion, &claims, true)?; - Ok(VerifiedSessionAdmission(verified_session_material( - completion, claims, - )?)) - } - - pub async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - if completion.status != "authenticated" { - bail!("Heimdall refresh is not authenticated"); - } - let token = completion - .access_token - .as_deref() - .context("Heimdall refresh omitted the access token")?; - let claims = self.verify_access(token).await?; - verify_claim_binding(&completion, &claims, false)?; - Ok(VerifiedSessionRefresh(verified_session_material( - completion, claims, - )?)) - } - - async fn fetch_jwks(&self) -> Result { - self.http - .get(format!("{}/.well-known/jwks.json", self.issuer)) - .send() - .await? - .error_for_status()? - .json() - .await - } - - async fn verify_access(&self, token: &str) -> anyhow::Result { - let header = decode_header(token)?; - if header.alg != Algorithm::EdDSA { - bail!("Heimdall used an unsupported signing algorithm"); - } - let kid = header.kid.context("Heimdall token omitted kid")?; - let cached = self.jwks.lock().expect("jwks cache").key(&kid, Instant::now()); - let jwk = match cached { - Some(jwk) => jwk, - None => { - let set = self - .fetch_jwks() - .await - .map_err(|error| VerificationUnavailable::of_fetch(&error))?; - let jwk = set.find(&kid).cloned(); - self.jwks - .lock() - .expect("jwks cache") - .store(set, Instant::now()); - jwk.context("Heimdall signing key was not published")? - } - }; - let mut validation = Validation::new(Algorithm::EdDSA); - validation.set_audience(&[APP_SLUG]); - validation.set_issuer(&[self.issuer.as_str()]); - let claims = - decode::(token, &DecodingKey::from_jwk(&jwk)?, &validation)?.claims; - if claims.typ != "heimdall_access" - || claims.aud != APP_SLUG - || claims.iss != self.issuer - || claims.sub != claims.account_id - || claims.app.slug != APP_SLUG - || !claims - .capabilities - .iter() - .any(|value| value == "app_access") - { - bail!("Heimdall claim did not grant Ghostlight app_access"); - } - Ok(claims) - } - - async fn invoke( - &self, - operation: &str, - content_schema: &str, - idempotency_key: &str, - payload: &impl Serialize, - expected_schema: &str, - ) -> anyhow::Result { - let envelope = seal_envelope( - operation, - content_schema, - idempotency_key, - &self.shared_secret, - payload, - )?; - let boundary_locator = self.boundary_locator.clone(); - let runtime_id = self.runtime_id.clone(); - let operation = operation.to_owned(); - let request_payload = STANDARD.encode(rmp_serde::to_vec_named(&envelope)?); - let transport_operation = operation.clone(); - let response = tokio::task::spawn_blocking(move || { - let boundary = boundary_locator.resolve(&runtime_id, &transport_operation)?; - invoke_operation( - boundary.endpoint, - &boundary.daemon_id, - &runtime_id, - &transport_operation, - request_payload, - ) - }) - .await??; - let CultNetMessage::OperationResponse { - status, - payload_schema, - payload_encoding, - payload, - diagnostics, - .. - } = response - else { - bail!("Heimdall returned a non-operation response"); - }; - refuse_denied(&status, &diagnostics)?; - if payload_schema != PRIVATE_ENVELOPE_SCHEMA || payload_encoding != "messagepack-base64" { - bail!("Heimdall returned the wrong private response contract"); - } - let sealed: PrivateEnvelope = rmp_serde::from_slice(&STANDARD.decode(payload)?)?; - validate_private_response_binding(&sealed, &operation, expected_schema, idempotency_key)?; - open_envelope(&sealed, &self.shared_secret) - } -} - -/// A response with status denied is Heimdall's answer, not a transport -/// failure: it becomes the typed `HeimdallDenied`. -fn refuse_denied(status: &str, diagnostics: &[String]) -> anyhow::Result<()> { - if status == "denied" { - return Err(HeimdallDenied { - diagnostics: diagnostics.join("; "), - } - .into()); - } - Ok(()) -} - -fn validate_private_response_binding( - envelope: &PrivateEnvelope, - operation: &str, - content_schema: &str, - idempotency_key: &str, -) -> anyhow::Result<()> { - if envelope.app_slug != APP_SLUG { - bail!("Heimdall private response belongs to another app"); - } - if envelope.operation != operation || envelope.content_schema != content_schema { - bail!("Heimdall private response disagrees with the requested operation"); - } - if envelope.idempotency_key != idempotency_key { - bail!("Heimdall private response belongs to another request"); - } - Ok(()) -} - -fn verify_claim_binding( - completion: &AuthCompletionReceipt, - claims: &AccessClaims, - require_account_summary: bool, -) -> anyhow::Result<()> { - if require_account_summary && completion.account.is_none() { - bail!("Heimdall omitted the account"); - } - if completion - .account - .as_ref() - .is_some_and(|account| account.id != claims.account_id) - { - bail!("Heimdall account summary disagrees with its signed claim"); - } - let session = completion - .session - .as_ref() - .context("Heimdall omitted the session")?; - if session.account_id != claims.account_id - || session.session_id != claims.sid - || session.access_revision != claims.access_revision - || session.app_slug != APP_SLUG - { - bail!("Heimdall session fields disagree with its signed claim"); - } - Ok(()) -} - -fn verified_session_material( - completion: AuthCompletionReceipt, - claims: AccessClaims, -) -> anyhow::Result { - if !completion - .shared_capabilities - .iter() - .any(|value| value == "app_access") - { - bail!("Heimdall response did not expose signed app_access"); - } - let session = completion - .session - .as_ref() - .context("Heimdall omitted the verified session")?; - let access_expiry = i64::try_from(claims.exp).context("Heimdall access expiry is too large")?; - let access_expires_at = - DateTime::from_timestamp(access_expiry, 0).context("Heimdall access expiry is invalid")?; - // Heimdall's session record lives as long as its refresh token, so the - // session expiry it reports is the refresh expiry (Heimdall - // `issueAccessClaim`: `session.expiresAt` and `refresh.expiresAt` are both - // the record's `expiresAt`); only the signed access claim carries the - // shorter access expiry. - let session_expires_at: DateTime = session - .expires_at - .parse() - .context("Heimdall session expiry is invalid")?; - let refresh_expires_at: DateTime = completion - .refresh - .as_ref() - .context("Heimdall omitted refresh expiry")? - .expires_at - .parse() - .context("Heimdall refresh expiry is invalid")?; - if session_expires_at != refresh_expires_at { - bail!("Heimdall session expiry disagrees with its refresh expiry"); - } - if refresh_expires_at <= access_expires_at { - bail!("Heimdall refresh expiry does not outlive access"); - } - let refresh_claim = completion - .refresh_token - .context("Heimdall omitted its refresh claim")?; - if refresh_claim.is_empty() || refresh_claim.trim() != refresh_claim { - bail!("Heimdall refresh claim is empty or noncanonical"); - } - Ok(VerifiedSessionMaterial { - account_id: claims.account_id, - heimdall_session_id: claims.sid, - access_revision: claims.access_revision, - capabilities: claims.capabilities, - access_expires_at, - refresh_expires_at, - refresh_claim, - }) -} - -fn invoke_operation( - endpoint: SocketAddr, - target_runtime_id: &str, - runtime_id: &str, - operation: &str, - payload: String, -) -> anyhow::Result { - let socket = UdpSocket::bind("127.0.0.1:0")?; - socket.set_read_timeout(Some(Duration::from_millis(25)))?; - let mut options = CultNetRudpSocketTransportOptions::client( - runtime_id.to_owned(), - socket, - endpoint, - CULTNET_OPERATION_CONNECTION_ID, - ); - options.max_fragment_bytes = Some(2_048); - let mut transport = CultNetRudpSocketTransportConnection::new(options)?; - transport.connect(Vec::new())?; - let deadline = Instant::now() + Duration::from_secs(10); - while !transport.connected() && Instant::now() < deadline { - let _ = transport.receive_once()?; - transport.poll_resends()?; - } - if !transport.connected() { - bail!("Heimdall private CultNet connection timed out"); - } - let message_id = uuid::Uuid::new_v4().to_string(); - transport.send_schema_message(&CultNetMessage::OperationRequest { - message_id: message_id.clone(), - service_id: PRIVATE_SERVICE.into(), - operation: operation.into(), - payload_schema: PRIVATE_ENVELOPE_SCHEMA.into(), - payload_encoding: "messagepack-base64".into(), - payload, - source_runtime_id: Some(runtime_id.into()), - target_runtime_id: Some(target_runtime_id.into()), - })?; - while Instant::now() < deadline { - if let Some(response) = transport.receive_schema_message_once()? { - if matches!(&response, CultNetMessage::OperationResponse { message_id: value, .. } if value == &message_id) - { - return Ok(response); - } - } - transport.poll_resends()?; - } - bail!("Heimdall private command timed out") -} - -impl HeimdallBoundaryLocator { - fn resolve( - &self, - runtime_id: &str, - operation: &str, - ) -> anyhow::Result { - let boundary = match self { - Self::Odin { endpoint, expected } => { - let boundary = discover_heimdall_boundary(*endpoint, runtime_id)?; - if let Some(expected) = expected { - require_expected_heimdall_boundary(&boundary, expected)?; - } - boundary - } - #[cfg(test)] - Self::Fixed(boundary) => boundary.clone(), - }; - if !boundary - .operations - .iter() - .any(|candidate| candidate == operation) - { - bail!("Odin's Heimdall boundary does not advertise {operation}"); - } - Ok(boundary) - } -} - -fn require_expected_heimdall_boundary( - boundary: &ResolvedHeimdallBoundary, - expected: &HeimdallDependencyBinding, -) -> anyhow::Result<()> { - if boundary.daemon_id != expected.provider_id || boundary.endpoint != expected.endpoint { - bail!("discovered Heimdall boundary differs from Expected"); - } - Ok(()) -} - -fn discover_heimdall_boundary( - odin_endpoint: SocketAddr, - runtime_id: &str, -) -> anyhow::Result { - let store_path = std::env::temp_dir().join(format!( - "ghostlight-heimdall-discovery-{}.cc", - uuid::Uuid::new_v4() - )); - let result = (|| { - let mut node = CultMesh::create_node( - &store_path, - HeimdallDiscoveryDocuments, - CultMeshNodeOptions { - runtime_id: format!("{runtime_id}:heimdall-discovery"), - pull_on_start: false, - }, - )?; - let mut options = CultMeshRudpSnapshotOptions::odin( - odin_endpoint, - format!("{runtime_id}:heimdall-discovery"), - ); - options.schema_ids = Some(vec!["heimdall.command_boundary.v1".into()]); - options.record_keys = Some(vec!["heimdall:command-boundary".into()]); - let applied = node.pull_rudp_catalog_snapshot(options)?; - if applied != 1 { - bail!("Odin did not return exactly one Heimdall command boundary"); - } - let envelope = node - .cache() - .snapshot() - .into_iter() - .find(|entry| { - entry.key == "heimdall:command-boundary" - && entry.r#type == "heimdall.command_boundary" - && entry.schema_id.as_deref() == Some("heimdall.command_boundary.v1") - }) - .context("Odin omitted the exact Heimdall command boundary envelope")?; - validate_heimdall_boundary(rmp_serde::from_slice(&envelope.payload)?) - })(); - let _ = std::fs::remove_file(&store_path); - result -} - -fn validate_heimdall_boundary( - boundary: HeimdallCommandBoundaryRecord, -) -> anyhow::Result { - if boundary.schema != "heimdall.command_boundary.v1" - || boundary.boundary_id != "heimdall" - || boundary.provider_id != "heimdall" - || boundary.daemon_id.trim().is_empty() - || boundary.updated_at.trim().is_empty() - { - bail!("Odin returned a foreign or malformed Heimdall command boundary"); - } - if boundary.private_route.exposure != "loopback-only" - || boundary.private_route.secret_bearing - || boundary.private_route.authentication != "app-bound HMAC + AES-256-GCM envelope" - { - bail!("Odin returned an unsafe Heimdall private route"); - } - let endpoint_text = boundary - .private_route - .endpoint - .strip_prefix("rudp://") - .context("Heimdall's discovered private route is not RUDP")?; - let endpoint: SocketAddr = endpoint_text - .parse() - .context("Heimdall's discovered private route is not a socket address")?; - if !endpoint.ip().is_loopback() { - bail!("Heimdall's discovered private route is not loopback-only"); - } - if boundary.commands.is_empty() - || boundary.commands.iter().any(|command| { - command.operation.trim().is_empty() - || command.request_schema.trim().is_empty() - || command.response_schema.trim().is_empty() - }) - { - bail!("Heimdall's discovered command catalog is malformed"); - } - let operations = boundary - .commands - .into_iter() - .map(|command| command.operation) - .collect(); - Ok(ResolvedHeimdallBoundary { - endpoint, - daemon_id: boundary.daemon_id, - operations, - }) -} - -fn seal_envelope( - operation: &str, - content_schema: &str, - idempotency_key: &str, - secret: &str, - payload: &impl Serialize, -) -> anyhow::Result { - let issued = Utc::now(); - let expires = issued + chrono::Duration::seconds(30); - let mut nonce_bytes = [0_u8; 16]; - let mut iv = [0_u8; 12]; - rand::rng().fill_bytes(&mut nonce_bytes); - rand::rng().fill_bytes(&mut iv); - let mut envelope = PrivateEnvelope { - schema: PRIVATE_ENVELOPE_SCHEMA.into(), - app_slug: APP_SLUG.into(), - operation: operation.into(), - content_schema: content_schema.into(), - issued_at: issued.to_rfc3339_opts(SecondsFormat::Millis, true), - expires_at: expires.to_rfc3339_opts(SecondsFormat::Millis, true), - nonce: URL_SAFE_NO_PAD.encode(nonce_bytes), - idempotency_key: idempotency_key.into(), - iv: URL_SAFE_NO_PAD.encode(iv), - ciphertext: String::new(), - auth_tag: String::new(), - signature: String::new(), - }; - let key = Sha256::digest( - [ - b"heimdall.private-command.v1\0".as_slice(), - secret.as_bytes(), - ] - .concat(), - ); - let ciphertext_and_tag = Aes256Gcm::new_from_slice(&key)? - .encrypt( - (&iv).into(), - Payload { - msg: &rmp_serde::to_vec_named(payload)?, - aad: envelope.aad().as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to encrypt Heimdall private command"))?; - let split = ciphertext_and_tag - .len() - .checked_sub(16) - .context("AES-GCM output omitted its tag")?; - envelope.ciphertext = URL_SAFE_NO_PAD.encode(&ciphertext_and_tag[..split]); - envelope.auth_tag = URL_SAFE_NO_PAD.encode(&ciphertext_and_tag[split..]); - envelope.signature = sign(&envelope, secret)?; - Ok(envelope) -} - -fn open_envelope( - envelope: &PrivateEnvelope, - secret: &str, -) -> anyhow::Result { - if envelope.schema != PRIVATE_ENVELOPE_SCHEMA { - bail!("unsupported Heimdall private envelope"); - } - let now = Utc::now(); - let issued: DateTime = envelope.issued_at.parse()?; - let expires: DateTime = envelope.expires_at.parse()?; - if issued > now + chrono::Duration::seconds(30) - || expires <= now - || expires <= issued - || expires - issued > chrono::Duration::seconds(120) - { - bail!("Heimdall private response is outside its accepted clock window"); - } - let mut mac = as Mac>::new_from_slice(secret.as_bytes())?; - mac.update(envelope.authenticated_fields().as_bytes()); - mac.verify_slice(&URL_SAFE_NO_PAD.decode(&envelope.signature)?) - .map_err(|_| anyhow::anyhow!("Heimdall private response signature is invalid"))?; - let key = Sha256::digest( - [ - b"heimdall.private-command.v1\0".as_slice(), - secret.as_bytes(), - ] - .concat(), - ); - let iv = URL_SAFE_NO_PAD.decode(&envelope.iv)?; - let mut ciphertext = URL_SAFE_NO_PAD.decode(&envelope.ciphertext)?; - ciphertext.extend(URL_SAFE_NO_PAD.decode(&envelope.auth_tag)?); - let plaintext = Aes256Gcm::new_from_slice(&key)? - .decrypt( - iv.as_slice().into(), - Payload { - msg: &ciphertext, - aad: envelope.aad().as_bytes(), - }, - ) - .map_err(|_| anyhow::anyhow!("failed to decrypt Heimdall private response"))?; - Ok(rmp_serde::from_slice(&plaintext)?) -} - -fn sign(envelope: &PrivateEnvelope, secret: &str) -> anyhow::Result { - let mut mac = as Mac>::new_from_slice(secret.as_bytes())?; - mac.update(envelope.authenticated_fields().as_bytes()); - Ok(URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes())) -} - -impl PrivateEnvelope { - fn aad(&self) -> String { - [ - self.schema.as_str(), - self.app_slug.as_str(), - self.operation.as_str(), - self.content_schema.as_str(), - self.issued_at.as_str(), - self.expires_at.as_str(), - self.nonce.as_str(), - self.idempotency_key.as_str(), - ] - .join("\n") - } - - fn authenticated_fields(&self) -> String { - [ - self.aad(), - self.iv.clone(), - self.ciphertext.clone(), - self.auth_tag.clone(), - ] - .join("\n") - } -} - -fn required_env(name: &str) -> anyhow::Result { - let value = std::env::var(name).with_context(|| format!("{name} is required"))?; - let value = value.trim(); - if value.is_empty() { - bail!("{name} must not be empty"); - } - Ok(value.to_owned()) -} - -/// A local stand-in for Heimdall's issuer: it serves the key set (or a 5xx) -/// on a loopback port and counts the requests it receives. The key is the -/// RFC 8037 appendix A.1 Ed25519 test key. -#[cfg(test)] -pub(crate) mod test_issuer { - use super::*; - use std::sync::atomic::{AtomicUsize, Ordering}; - use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, - }; - - pub(crate) const KID: &str = "k1"; - const PRIVATE_D: &str = "nWGxne_9WmC6hEr0kuwsxERJxWl7MmkZcDusAxyuf2A"; - const PUBLIC_X: &str = "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"; - - #[derive(Clone, Copy)] - pub(crate) enum Answer { - KeySet, - ServerError, - } - - pub(crate) struct Issuer { - pub(crate) url: String, - fetches: Arc, - } - - impl Issuer { - pub(crate) async fn start(answer: Answer) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let url = format!("http://{}", listener.local_addr().unwrap()); - let fetches = Arc::new(AtomicUsize::new(0)); - let counted = fetches.clone(); - let key_set = serde_json::json!({ - "keys": [{"kty": "OKP", "crv": "Ed25519", "kid": KID, "x": PUBLIC_X}] - }) - .to_string(); - tokio::spawn(async move { - while let Ok((mut stream, _)) = listener.accept().await { - let mut request = Vec::new(); - let mut chunk = [0_u8; 1024]; - while !request.windows(4).any(|window| window == b"\r\n\r\n") { - match stream.read(&mut chunk).await { - Ok(0) | Err(_) => break, - Ok(read) => request.extend_from_slice(&chunk[..read]), - } - } - counted.fetch_add(1, Ordering::SeqCst); - let (status, body) = match answer { - Answer::KeySet => ("200 OK", key_set.as_str()), - Answer::ServerError => ("503 Service Unavailable", ""), - }; - let response = format!( - "HTTP/1.1 {status}\r\ncontent-type: application/json\r\n\ - content-length: {}\r\nconnection: close\r\n\r\n{body}", - body.len() - ); - let _ = stream.write_all(response.as_bytes()).await; - let _ = stream.shutdown().await; - } - }); - Self { url, fetches } - } - - pub(crate) fn fetches(&self) -> usize { - self.fetches.load(Ordering::SeqCst) - } - - /// The URL of a loopback port nothing listens on. - pub(crate) fn closed_url() -> String { - let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); - format!("http://{}", listener.local_addr().unwrap()) - } - } - - /// An access token Heimdall's key signs for `issuer`, with the claims - /// `verify_access` demands. - pub(crate) fn signed_token(issuer: &str) -> String { - let mut der = vec![ - 0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, - 0x04, 0x20, - ]; - der.extend(URL_SAFE_NO_PAD.decode(PRIVATE_D).unwrap()); - let pem = format!( - "-----BEGIN PRIVATE KEY-----\n{}\n-----END PRIVATE KEY-----\n", - STANDARD.encode(der) - ); - let mut header = jsonwebtoken::Header::new(Algorithm::EdDSA); - header.kid = Some(KID.into()); - let claims = serde_json::json!({ - "iss": issuer, "aud": APP_SLUG, "sub": "account-1", "sid": "session-1", - "exp": Utc::now().timestamp() + 300, "typ": "heimdall_access", - "account_id": "account-1", "access_revision": 1, - "capabilities": ["app_access"], "app": {"slug": APP_SLUG}, - }); - jsonwebtoken::encode( - &header, - &claims, - &jsonwebtoken::EncodingKey::from_ed_pem(pem.as_bytes()).unwrap(), - ) - .unwrap() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - use super::test_issuer::{Answer, Issuer, signed_token}; - - #[tokio::test] - async fn a_key_fetch_failure_is_typed_unavailable_and_names_no_url() { - let closed = Issuer::closed_url(); - let failing = Issuer::start(Answer::ServerError).await; - for (base, class) in [ - (closed, "could not connect"), - (failing.url.clone(), "returned an error status"), - ] { - let canary = format!("{base}/canary-q7x1"); - let client = HeimdallClient::fixture().with_issuer(canary.clone()); - let error = client - .verify_access(&signed_token(&canary)) - .await - .unwrap_err(); - assert!(error.downcast_ref::().is_some(), "{error:#}"); - for text in [format!("{error}"), format!("{error:#}"), format!("{error:?}")] { - assert!(text.contains("signing keys unavailable"), "{text}"); - assert!(text.contains(class), "{text}"); - for leaked in [canary.as_str(), base.as_str(), "canary-q7x1", "127.0.0.1"] { - assert!(!text.contains(leaked), "{leaked} leaked into {text}"); - } - } - } - } - - #[tokio::test] - async fn the_key_set_is_fetched_once_within_its_ttl_and_again_after_it() { - let issuer = Issuer::start(Answer::KeySet).await; - let client = HeimdallClient::fixture().with_issuer(issuer.url.clone()); - let token = signed_token(&issuer.url); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 1); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 1, "a second verify within the ttl reads the cache"); - let age = |seconds: u64| { - client.jwks.lock().unwrap().fetched.as_mut().unwrap().0 = - Instant::now().checked_sub(Duration::from_secs(seconds)).unwrap(); - }; - age(299); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 1, "299 s old is still fresh"); - age(301); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 2, "301 s old is fetched again"); - client.verify_access(&token).await.unwrap(); - assert_eq!(issuer.fetches(), 2, "and the new fetch is cached"); - } - - #[tokio::test] - async fn a_token_signed_under_any_other_algorithm_is_refused_before_any_key_fetch() { - let issuer = Issuer::start(Answer::KeySet).await; - let client = HeimdallClient::fixture().with_issuer(issuer.url.clone()); - let part = |text: &str| URL_SAFE_NO_PAD.encode(text); - for alg in [ - "none", "HS256", "HS384", "HS512", "RS256", "RS384", "PS256", "ES256", "ES384", - ] { - let token = format!( - "{}.{}.{}", - part(&format!(r#"{{"alg":"{alg}","typ":"JWT","kid":"k1"}}"#)), - part(r#"{"sub":"account-1"}"#), - URL_SAFE_NO_PAD.encode([0xAB_u8; 32]), - ); - let error = client.verify_access(&token).await.unwrap_err(); - assert!(error.downcast_ref::().is_none(), "{alg}"); - if alg != "none" { - assert!( - error.to_string().contains("unsupported signing algorithm"), - "{alg}: {error:#}" - ); - } - assert_eq!(issuer.fetches(), 0, "{alg} reached the key fetch"); - } - } - - fn boundary(endpoint: &str) -> HeimdallCommandBoundaryRecord { - HeimdallCommandBoundaryRecord { - schema: "heimdall.command_boundary.v1".into(), - boundary_id: "heimdall".into(), - daemon_id: "yggdrasil-heimdall".into(), - provider_id: "heimdall".into(), - updated_at: "2026-08-22T12:00:00.000Z".into(), - commands: vec![HeimdallBoundaryCommand { - operation: "heimdall.auth.begin".into(), - request_schema: "heimdall.private_command_envelope.v1".into(), - response_schema: "heimdall.auth_begin_receipt.v1".into(), - }], - private_route: HeimdallPrivateRoute { - endpoint: endpoint.into(), - exposure: "loopback-only".into(), - authentication: "app-bound HMAC + AES-256-GCM envelope".into(), - secret_bearing: false, - }, - } - } - - fn key_set(kid: &str) -> JwkSet { - serde_json::from_value(serde_json::json!({ - "keys": [{"kty": "OKP", "crv": "Ed25519", "kid": kid, - "x": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}] - })) - .unwrap() - } - - #[test] - fn a_cached_key_set_serves_until_its_ttl_and_misses_an_unpublished_kid() { - let start = Instant::now(); - let mut cache = JwksCache::default(); - assert!(cache.key("k1", start).is_none(), "an empty cache has no key"); - cache.store(key_set("k1"), start); - assert!(cache.key("k1", start + Duration::from_secs(299)).is_some()); - assert!(cache.key("k2", start).is_none(), "a rotated-in kid refetches"); - assert!(cache.key("k1", start + JWKS_TTL).is_none(), "stale at the ttl"); - } - - #[test] - fn a_denied_status_is_the_typed_denial_and_any_other_status_passes() { - let diagnostics = vec!["Idempotency key was reused".to_owned()]; - let denial = refuse_denied("denied", &diagnostics).unwrap_err(); - assert!(denial.downcast_ref::().is_some()); - assert!(denial.to_string().contains("Idempotency key was reused")); - refuse_denied("accepted", &diagnostics).unwrap(); - } - - #[test] - fn private_responses_are_bound_to_the_exact_app_and_request() { - let secret = "fixture-shared-secret"; - let operation = "heimdall.auth.begin"; - let content_schema = "heimdall.auth_begin_receipt.v1"; - let exact = seal_envelope(operation, content_schema, "request-a", secret, &"receipt") - .expect("fixture response should seal"); - validate_private_response_binding(&exact, operation, content_schema, "request-a") - .expect("exact response binding should be accepted"); - - let replayed_other_request = - seal_envelope(operation, content_schema, "request-b", secret, &"receipt") - .expect("other request should seal validly"); - assert!( - validate_private_response_binding( - &replayed_other_request, - operation, - content_schema, - "request-a", - ) - .unwrap_err() - .to_string() - .contains("another request") - ); - - let mut other_app = exact; - other_app.app_slug = "another-app".into(); - other_app.signature = sign(&other_app, secret).expect("other app response should sign"); - assert!( - validate_private_response_binding(&other_app, operation, content_schema, "request-a",) - .unwrap_err() - .to_string() - .contains("another app") - ); - } - - #[test] - fn reads_heimdalls_published_documents_with_fields_dungeon_does_not_use() { - // Shapes as Heimdall emits them: buildCommandBoundary in - // Heimdall/src/verse-state.ts and the receipts in - // Heimdall/src/private-command-plane.ts. - let boundary = rmp_serde::to_vec_named(&serde_json::json!({ - "schema": "heimdall.command_boundary.v1", - "boundaryId": "heimdall", - "daemonId": "yggdrasil-heimdall", - "providerId": "heimdall", - "updatedAt": "2026-09-23T12:00:00.000Z", - "owner": "Heimdall auth runtime", - "lifecycleAuthority": "idunn.yggdrasil-source-app.deploy", - "healthPublication": {"contract": "x", "transport": "cultcache-store"}, - "commands": [{ - "operation": "heimdall.auth.begin", - "requestSchema": "heimdall.private_command_envelope.v1", - "responseSchema": "heimdall.auth_begin_receipt.v1" - }], - "privateRoute": { - "endpoint": "rudp://127.0.0.1:4101", - "exposure": "loopback-only", - "authentication": "app-bound HMAC + AES-256-GCM envelope", - "secretBearing": false - }, - "forbiddenWriters": ["Odin and Idunn may observe Heimdall boundary state"], - "compatibility": {"httpHealth": "/healthz"} - })) - .unwrap(); - let boundary: HeimdallCommandBoundaryRecord = rmp_serde::from_slice(&boundary) - .expect("Heimdall's published boundary should decode"); - validate_heimdall_boundary(boundary).expect("Heimdall's published boundary should resolve"); - - let begin: AuthBeginReceipt = serde_json::from_value(serde_json::json!({ - "schema": "heimdall.auth_begin_receipt.v1", - "status": "pending", - "handle": "h", - "expiresAt": "2026-09-23T12:10:00.000Z", - "navigation": {"url": "https://discord.com/x", "allowedOrigins": ["https://discord.com"]} - })) - .expect("Heimdall's begin receipt should decode"); - assert_eq!(begin.status, "pending"); - let completion: AuthCompletionReceipt = serde_json::from_value(serde_json::json!({ - "schema": "heimdall.auth_completion_receipt.v1", - "status": "pending", - "handle": "h" - })) - .expect("Heimdall's completion receipt should decode"); - assert_eq!(completion.status, "pending"); - let logout: AuthLogoutReceipt = serde_json::from_value(serde_json::json!({ - "schema": "heimdall.auth_logout_receipt.v1", - "status": "revoked", - "sessionId": "s", - "accessRevision": 2, - "revokedAt": "2026-09-23T12:20:00.000Z" - })) - .expect("Heimdall's logout receipt should decode"); - assert_eq!(logout.access_revision, 2); - } - - #[test] - fn accepts_only_the_discovered_loopback_private_boundary() { - let resolved = validate_heimdall_boundary(boundary("rudp://127.0.0.1:4101")) - .expect("canonical boundary should resolve"); - assert_eq!(resolved.endpoint, "127.0.0.1:4101".parse().unwrap()); - assert_eq!(resolved.daemon_id, "yggdrasil-heimdall"); - assert_eq!(resolved.operations, vec!["heimdall.auth.begin"]); - let expected = HeimdallDependencyBinding { - provider_id: "yggdrasil-heimdall".into(), - endpoint: "127.0.0.1:4101".parse().unwrap(), - }; - require_expected_heimdall_boundary(&resolved, &expected) - .expect("exact Expected Heimdall should match discovery"); - let substitute = HeimdallDependencyBinding { - provider_id: "another-heimdall".into(), - endpoint: expected.endpoint, - }; - assert!(require_expected_heimdall_boundary(&resolved, &substitute).is_err()); - } - - #[test] - fn rejects_non_loopback_or_secret_bearing_routes() { - assert!( - validate_heimdall_boundary(boundary("rudp://10.77.0.2:4101")) - .unwrap_err() - .to_string() - .contains("not loopback-only") - ); - let mut secret_bearing = boundary("rudp://127.0.0.1:4101"); - secret_bearing.private_route.secret_bearing = true; - assert!( - validate_heimdall_boundary(secret_bearing) - .unwrap_err() - .to_string() - .contains("unsafe") - ); - } - - #[test] - fn refuses_operations_not_advertised_by_odin() { - let locator = HeimdallBoundaryLocator::Fixed( - validate_heimdall_boundary(boundary("rudp://127.0.0.1:4101")).unwrap(), - ); - assert!( - locator - .resolve("ghostlight-test", "heimdall.auth.logout") - .unwrap_err() - .to_string() - .contains("does not advertise") - ); - } - - #[test] - fn refresh_binding_uses_signed_claim_and_exact_session_without_account_summary() { - let claims = AccessClaims { - iss: "https://heimdall.invalid".into(), - aud: APP_SLUG.into(), - sub: "account-1".into(), - sid: "session-1".into(), - exp: 1, - typ: "heimdall_access".into(), - account_id: "account-1".into(), - access_revision: 4, - capabilities: vec!["app_access".into()], - app: AppClaim { - slug: APP_SLUG.into(), - }, - }; - let receipt = AuthCompletionReceipt { - status: "authenticated".into(), - handle: None, - error: None, - account: None, - session: Some(HeimdallSession { - account_id: "account-1".into(), - session_id: "session-1".into(), - app_slug: APP_SLUG.into(), - access_revision: 4, - expires_at: "2026-09-22T00:00:00Z".into(), - }), - access_token: Some("private".into()), - refresh_token: Some("private".into()), - refresh: Some(RefreshSummary { - expires_at: "2026-09-22T00:00:00Z".into(), - }), - shared_capabilities: vec!["app_access".into()], - }; - - verify_claim_binding(&receipt, &claims, false).unwrap(); - assert!( - verify_claim_binding(&receipt, &claims, true) - .unwrap_err() - .to_string() - .contains("omitted the account") - ); - } - - /// The shape Heimdall's `issueAccessClaim` returns: the access claim - /// expires first; session and refresh share the longer record expiry. - fn issued_completion(session_expiry: &str, refresh_expiry: &str) -> AuthCompletionReceipt { - AuthCompletionReceipt { - status: "authenticated".into(), - handle: None, - error: None, - account: None, - session: Some(HeimdallSession { - account_id: "account-1".into(), - session_id: "session-1".into(), - app_slug: APP_SLUG.into(), - access_revision: 1, - expires_at: session_expiry.into(), - }), - access_token: Some("private".into()), - refresh_token: Some("refresh".into()), - refresh: Some(RefreshSummary { - expires_at: refresh_expiry.into(), - }), - shared_capabilities: vec!["app_access".into()], - } - } - - fn access_claims(exp: u64) -> AccessClaims { - AccessClaims { - iss: "https://heimdall.invalid".into(), - aud: APP_SLUG.into(), - sub: "account-1".into(), - sid: "session-1".into(), - exp, - typ: "heimdall_access".into(), - account_id: "account-1".into(), - access_revision: 1, - capabilities: vec!["app_access".into()], - app: AppClaim { - slug: APP_SLUG.into(), - }, - } - } - - #[test] - fn a_session_as_heimdall_issues_it_is_admitted() { - // Access expires at 2026-09-23T20:00:00Z; the session record and its - // refresh token last thirty days. - let access_exp = 1_790_193_600; - let material = verified_session_material( - issued_completion("2026-10-23T20:00:00.000Z", "2026-10-23T20:00:00.000Z"), - access_claims(access_exp), - ) - .expect("Heimdall's issued session should be admitted"); - assert_eq!(material.access_expires_at.timestamp(), access_exp as i64); - assert!(material.refresh_expires_at > material.access_expires_at); - - assert!( - verified_session_material( - issued_completion("2026-10-22T20:00:00.000Z", "2026-10-23T20:00:00.000Z"), - access_claims(access_exp), - ) - .err() - .expect("Heimdall shape should be refused") - .to_string() - .contains("disagrees with its refresh expiry") - ); - assert!( - verified_session_material( - issued_completion("2026-09-23T19:00:00.000Z", "2026-09-23T19:00:00.000Z"), - access_claims(access_exp), - ) - .err() - .expect("Heimdall shape should be refused") - .to_string() - .contains("does not outlive access") - ); - } -} diff --git a/crates/ghostlight-dungeon/src/idunn_health.rs b/crates/ghostlight-dungeon/src/idunn_health.rs deleted file mode 100644 index f11f0f5..0000000 --- a/crates/ghostlight-dungeon/src/idunn_health.rs +++ /dev/null @@ -1,2001 +0,0 @@ -//! Service-owned runtime presence at the Idunn/Odin authority boundary. -//! -//! Idunn supplies one immutable Expected/activation bundle, an activation-only -//! signing credential, the candidate bind, and (after Odin observes Warming) -//! one process write lease. Ghostlight reports what this process actually is; -//! it never promotes its route or manufactures Ready. - -use ghostlight::{STATE_SCHEMA, state_schema_compatibility_tag}; -use anyhow::{Context, Result, anyhow, bail, ensure}; -#[cfg(test)] -use cultcache_rs::CacheBackingStore; -use cultcache_rs::{CultCacheEnvelope, DatabaseEntry, SingleFileMessagePackBackingStore}; -use cultnet_rs::{ - CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, - CultNetRudpReliableSendStatus, CultNetRudpSocketTransportConnection, - CultNetRudpSocketTransportOptions, CultNetWireContract, - GAMECULT_RUNTIME_PRESENCE_HEALTH_SCHEMA, GameCultProviderHealthIdentity, - GameCultRuntimeCapability, GameCultRuntimePresenceHealthPurpose, - GameCultRuntimePresenceHealthRecord, IDUNN_EXPECTED_INCARNATION_SCHEMA, - IDUNN_PROCESS_WRITE_LEASE_SCHEMA, IDUNN_RUNTIME_ACTIVATION_CREDENTIAL_NAME, - IDUNN_RUNTIME_ACTIVATION_SCHEMA, IdunnExpectedDependency, IdunnExpectedIncarnationRecord, - IdunnProcessWriteLeaseRecord, IdunnRuntimeActivationRecord, IdunnRuntimeActivationSigner, - ServiceIdentitySigner, encode_cultnet_message_to_vec, open_service_identity_credential_reader, -}; -use std::{ - collections::VecDeque, - fs::{self, File}, - net::{SocketAddr, UdpSocket}, - path::{Path, PathBuf}, - sync::mpsc, - thread, - time::{Duration, Instant}, -}; -#[cfg(target_os = "linux")] -use std::{ - os::fd::{AsRawFd, FromRawFd, RawFd}, - os::unix::fs::{MetadataExt, PermissionsExt}, -}; - -pub const GHOSTLIGHT_RUNTIME_PRESENCE_HEALTH_CONTRACT: &str = - "ghostlight.cultnet-rudp-service-health"; -pub const IDUNN_RUNTIME_BUNDLE_ENVIRONMENT: &str = "GAMECULT_IDUNN_RUNTIME_BUNDLE"; -pub const IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT: &str = "GAMECULT_IDUNN_CANDIDATE_BIND"; -pub const IDUNN_PROCESS_WRITE_LEASE_ENVIRONMENT: &str = "GAMECULT_IDUNN_PROCESS_WRITE_LEASE"; - -pub(crate) const TARGET: &str = "ghostlight"; -const STATE_SCHEMA_GENERATION: &str = "world-v3"; -const STATE_CONTRACT_SHA256: &str = - "sha256-4ac3d4ceabedc48b4fc5a3c143ece00ac495d8d756bdc24f10034bcc1231970f"; -/// The exact `[state]` + `[[state.slots]]` text in `deployment/idunn/recipe.toml` -/// that `STATE_CONTRACT_SHA256` above was computed from (Idunn's own -/// `canonical_state_contract_sha256`: msgpack-encode the recipe's own parsed -/// `[state]` struct, then sha256 it — run against the finalized recipe, not -/// hand-derived). These two constants change together: whoever edits the -/// recipe's `[state]` block must recompute `STATE_CONTRACT_SHA256` the same -/// way and update both, or `recipe_matches_the_daemon_it_deploys` (below) -/// fails on the text pin without saying the hash is wrong — which is the -/// point: the test cannot recompute the hash itself (no TOML parser or -/// msgpack encoder in this crate's dependency graph), so pinning the source -/// text is the cheapest honest proxy for "the hash still matches this text". -/// LF line endings: the committed git blob is LF-only; this constant matches -/// that, not whatever CRLF a Windows checkout's autocrlf produces locally. -#[cfg(test)] -const EXPECTED_STATE_AND_SLOTS_TEXT: &str = "[state]\nschema_generation = \"world-v3\"\n\n[[state.slots]]\nid = \"world\"\nrelative_path = \"world.cc\"\nkind = \"cultcache-file\"\nschema = \"ghostlight.world_state.consumer.v6\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"preserve\"\nstartup = \"create-or-open-after-write-lease\"\n\n[[state.slots]]\nid = \"app-sessions\"\nrelative_path = \"service/app-sessions-v2.cc\"\nkind = \"cultcache-file\"\nschema = \"ghostlight.app_session_store.v2\"\nwriter = \"process-bound-single-writer\"\nrecovery = \"preserve\"\nstartup = \"create-or-open-after-write-lease\"\n\n[[state.slots]]\nid = \"controller-work\"\nrelative_path = \"service/controller-work.cc\"\nkind = \"cultcache-file\"\nschema = \"ghostlight.controller_work.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 deleted file mode 100644 index 6f0faee..0000000 --- a/crates/ghostlight-dungeon/src/main.rs +++ /dev/null @@ -1,21 +0,0 @@ -mod app_session; -mod eve; -mod heimdall; -mod idunn_health; -mod mesh; -mod play; -mod runtime; - -use anyhow::bail; -use std::path::PathBuf; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let args = std::env::args_os().skip(1).collect::>(); - let state_root = match args.as_slice() { - [flag, path] if flag == "--state-root" => Some(PathBuf::from(path)), - [] => None, - _ => bail!("usage: ghostlight-dungeon [--state-root PATH]"), - }; - runtime::run(state_root).await -} diff --git a/crates/ghostlight-dungeon/src/mesh.rs b/crates/ghostlight-dungeon/src/mesh.rs deleted file mode 100644 index e230aa1..0000000 --- a/crates/ghostlight-dungeon/src/mesh.rs +++ /dev/null @@ -1,464 +0,0 @@ -//! Derived CultMesh and Eve projection. -//! -//! The mesh publishes the world owner's current surface. It never pulls or -//! repairs world state and it has no command authority. - -use crate::eve; -use ghostlight::{CONSUMER_PATCH_SCHEMA, CONSUMER_RECEIPT_SCHEMA, STATE_SCHEMA, WorldSnapshot}; -use anyhow::Result; -use chrono::{DateTime, Utc}; -use cultcache_rs::DatabaseEntry; -use cultmesh_rs::{ - CultMesh, CultMeshNode, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions, - publish_cultnet_messages_to_rudp_catalog, -}; -use serde_json::{Value, json}; -use std::{ - net::SocketAddr, - path::Path, - sync::{ - Arc, Condvar, Mutex, - atomic::{AtomicBool, Ordering}, - }, - thread, -}; - -pub(crate) const PROVIDER_ID: &str = "gamecult.ghostlight.dungeon"; -pub(crate) const SURFACE_ID: &str = "ghostlight.play"; -pub(crate) const COMMAND_BOUNDARY: &str = "ghostlight.eve.commands"; -pub(crate) const COMMAND_RESULT_SCHEMA: &str = "gamecult.eve.command_result.v1"; -pub(crate) const COMMAND_RECEIPT_SCHEMA: &str = "gamecult.eve.command_receipt.v1"; -/// The repository that owns this surface, as the command receipt contract -/// requires of every result. -pub(crate) const OWNER_REPO: &str = "GameCult/Ghostlight"; - -const HEALTH_KEY: &str = "ghostlight:dungeon:health"; -const ADVERTISEMENT_KEY: &str = "eve:provider:gamecult.ghostlight.dungeon"; -const SURFACE_KEY: &str = "eve:surface:ghostlight.play"; - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache(type = "gamecult.eve.surface", schema = "gamecult.eve.surface.v1")] -struct EveSurfaceRecord { - #[cultcache(key = 0)] - value: Value, -} - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache( - type = "gamecult.eve.provider_advertisement", - schema = "gamecult.eve.provider_advertisement.v1" -)] -struct EveProviderAdvertisementRecord { - #[cultcache(key = 0)] - value: Value, -} - -#[derive(Clone, Debug, PartialEq, DatabaseEntry)] -#[cultcache( - type = "ghostlight.service_health", - schema = "ghostlight.service_health.v2" -)] -struct ServiceHealthRecord { - #[cultcache(key = 0)] - value: Value, -} - -cultmesh_rs::cultmesh_documents!(GhostlightDocuments { - EveSurfaceRecord => "gamecult.eve.surface.v1", - EveProviderAdvertisementRecord => "gamecult.eve.provider_advertisement.v1", - ServiceHealthRecord => "ghostlight.service_health.v2", -}); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct MeshRuntimeIdentity { - pub(crate) runtime_id: String, - pub(crate) service_id: String, - pub(crate) located_service: String, -} - -impl Default for MeshRuntimeIdentity { - fn default() -> Self { - Self { - runtime_id: "ghostlight-dungeon".into(), - service_id: "ghostlight-dungeon".into(), - located_service: "local".into(), - } - } -} - -#[derive(Clone)] -pub(crate) struct MeshPublisher { - node: Arc>, - remote: Option, - identity: MeshRuntimeIdentity, -} - -#[derive(Clone)] -struct RemoteReplication { - target: SocketAddr, - runtime_id: String, - pending: Arc<(Mutex>>, Condvar)>, - alive: Arc, -} - -impl MeshPublisher { - pub(crate) fn open( - store_path: impl AsRef, - rudp_target: Option, - identity: MeshRuntimeIdentity, - ) -> Result { - if let Some(parent) = store_path.as_ref().parent() { - std::fs::create_dir_all(parent)?; - } - let node = CultMesh::create_node( - store_path, - GhostlightDocuments, - CultMeshNodeOptions { - runtime_id: identity.runtime_id.clone(), - // Ghostlight derives its own interface from the local owner. - // Stale remote surfaces must never become startup authority. - pull_on_start: false, - }, - )?; - let remote = rudp_target - .map(|target| RemoteReplication::start(target, identity.runtime_id.clone())) - .transpose()?; - Ok(Self { - node: Arc::new(Mutex::new(node)), - remote, - identity, - }) - } - - pub(crate) fn provider_advertisement(&self, updated_at: &str) -> Value { - provider_advertisement(&self.identity, updated_at) - } - - pub(crate) fn publish(&self, snapshot: Option<&WorldSnapshot>) -> Result { - let surface = eve::mesh_surface(snapshot); - let world_state = eve::world_state(snapshot); - let version = eve::surface_version(snapshot); - let updated_at = Utc::now().to_rfc3339(); - let health = json!({ - "schema":"ghostlight.service_health.v2", - "status":"ok", - "worldState":world_state, - "surfaceVersion":version, - "updatedAtUtc":updated_at, - "runtime":self.identity.runtime_id, - "commit":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development") - }); - let advertisement = provider_advertisement(&self.identity, &updated_at); - let mut node = self - .node - .lock() - .map_err(|_| anyhow::anyhow!("mesh lock poisoned"))?; - let mut remote_messages = Vec::new(); - self.put_and_stage( - &mut node, - ADVERTISEMENT_KEY, - &EveProviderAdvertisementRecord { - value: advertisement, - }, - &mut remote_messages, - )?; - self.put_and_stage( - &mut node, - SURFACE_KEY, - &EveSurfaceRecord { value: surface }, - &mut remote_messages, - )?; - // Health is the completion marker for this derived projection. A - // failed advertisement or surface write must leave the prior version - // visibly stale rather than publishing green first. - self.put_and_stage( - &mut node, - HEALTH_KEY, - &ServiceHealthRecord { - value: health.clone(), - }, - &mut remote_messages, - )?; - drop(node); - if let Some(remote) = &self.remote { - remote.enqueue(remote_messages); - } - Ok(health) - } - - pub(crate) fn health(&self) -> Result { - if self - .remote - .as_ref() - .is_some_and(|remote| !remote.is_alive()) - { - anyhow::bail!("Odin projection worker stopped"); - } - self.node - .try_lock() - .map_err(|_| anyhow::anyhow!("mesh projection is busy or its lock is poisoned"))? - .get_required::(HEALTH_KEY) - .map(|record| record.value) - } - - fn put_and_stage( - &self, - node: &mut CultMeshNode, - key: impl Into, - value: &T, - remote_messages: &mut Vec, - ) -> Result<()> - where - T: DatabaseEntry + serde::Serialize, - { - let key = key.into(); - node.put(&key, value)?; - if let Some(remote) = &self.remote { - remote_messages.push(node.create_rudp_document_message( - &key, - value, - &remote.options(), - )?); - } - Ok(()) - } -} - -pub(crate) fn provider_advertisement(identity: &MeshRuntimeIdentity, updated_at: &str) -> Value { - json!({ - "schema":"gamecult.eve.provider_advertisement.v1", - "providerId":PROVIDER_ID, - "serviceId":identity.service_id, - "verseId":"gamecult.private", - "rootVerse":"gamecult", - "canonicalService":"ghostlight-dungeon", - "locatedService":identity.located_service, - "cultMeshAddress":"cultmesh://gamecult.private/ghostlight/providers/dungeon", - "title":"Ghostlight Dungeon", - "kind":"narrative.simulation", - "updatedAtUtc":updated_at, - "freshness":{"state":"live","lastSeenAtUtc":updated_at,"maxAgeMs":360000}, - "schemas":[ - "gamecult.eve.surface.v1", - "gamecult.eve.command_invocation.v1", - COMMAND_RESULT_SCHEMA, - "heimdall.access_gate_state.v1", - STATE_SCHEMA, - CONSUMER_PATCH_SCHEMA, - CONSUMER_RECEIPT_SCHEMA - ], - "witnesses":[{ - "kind":"source-commit", - "ref":option_env!("GHOSTLIGHT_BUILD_COMMIT").unwrap_or("development") - }], - "surfaces":[{ - "surfaceId":SURFACE_ID, - "schema":"gamecult.eve.surface.v1", - "url":"/api/eve/surfaces/ghostlight.play", - "transport":"https-json", - "status":"available", - "surfaceKind":"subject-scoped-narrative", - "interactionModel":"typed-command-receipts", - "worldInteraction":{ - "projectionKind":"owner-derived", - "stateSchemas":[STATE_SCHEMA], - "commandBoundary":COMMAND_BOUNDARY, - "receiptSchema":COMMAND_RESULT_SCHEMA, - "loweringTargets":["browser","eve-native","tui"], - "ownership":"One Ghostlight WorldMailbox owns state; Eve lowers derived projections and commands." - }, - "requiresPlugins":[{ - "pluginId":"gamecult.heimdall.access", - "versionRange":"^1.0.0", - "availability":"required", - "requiredCapabilities":["auth.gate","auth.begin","auth.complete","auth.logout"] - }] - }], - "commands":[{ - "command":COMMAND_BOUNDARY, - "transport":"https-json", - "summary":"Canonical Ghostlight Eve command boundary." - }] - }) -} - -impl RemoteReplication { - fn start(target: SocketAddr, runtime_id: String) -> Result { - let pending = Arc::new(( - Mutex::new(None::>), - Condvar::new(), - )); - let worker_pending = pending.clone(); - let worker_runtime_id = runtime_id.clone(); - let alive = Arc::new(AtomicBool::new(true)); - let worker_alive = alive.clone(); - thread::Builder::new() - .name("ghostlight-odin-rudp".into()) - .spawn(move || { - struct Liveness(Arc); - impl Drop for Liveness { - fn drop(&mut self) { - self.0.store(false, Ordering::Release); - } - } - let _liveness = Liveness(worker_alive); - loop { - let messages = { - let (lock, ready) = &*worker_pending; - let mut pending = lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - while pending.is_none() { - pending = ready - .wait(pending) - .unwrap_or_else(|poisoned| poisoned.into_inner()); - } - pending.take().unwrap_or_default() - }; - if messages.is_empty() { - continue; - } - let options = remote_options(target, &worker_runtime_id); - if let Err(error) = - publish_cultnet_messages_to_rudp_catalog(&messages, options) - { - tracing::warn!( - document_count = messages.len(), - %target, - %error, - "Odin publication failed; retaining the newest derived projection for retry" - ); - let (lock, ready) = &*worker_pending; - let mut pending = lock - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if pending.is_none() { - *pending = Some(messages); - } - let _ = ready - .wait_timeout(pending, std::time::Duration::from_secs(2)) - .unwrap_or_else(|poisoned| poisoned.into_inner()); - } - } - })?; - Ok(Self { - target, - runtime_id, - pending, - alive, - }) - } - - fn is_alive(&self) -> bool { - self.alive.load(Ordering::Acquire) - } - - fn options(&self) -> CultMeshRudpDocumentPublishOptions { - remote_options(self.target, &self.runtime_id) - } - - fn enqueue(&self, messages: Vec) { - let (lock, ready) = &*self.pending; - let mut pending = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); - *pending = Some(messages); - ready.notify_one(); - } -} - -fn remote_options(target: SocketAddr, runtime_id: &str) -> CultMeshRudpDocumentPublishOptions { - let mut options = CultMeshRudpDocumentPublishOptions::odin(target, runtime_id); - options.source_agent_id = Some(PROVIDER_ID.into()); - options.source_role = Some("narrative-simulation".into()); - options.tags = vec!["ghostlight".into(), "eve".into(), "private".into()]; - options -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn publishes_only_owner_derived_surface_and_health() { - let directory = tempfile::tempdir().unwrap(); - let publisher = MeshPublisher::open( - directory.path().join("mesh-v2.cc"), - None, - MeshRuntimeIdentity::default(), - ) - .unwrap(); - let health = publisher.publish(None).unwrap(); - assert_eq!(health["worldState"], "empty"); - assert_eq!(health["surfaceVersion"], 0); - assert_eq!(publisher.health().unwrap(), health); - let node = publisher.node.lock().unwrap(); - let surface = node - .get_required::(SURFACE_KEY) - .unwrap() - .value; - assert_eq!(surface["type"], "surface-state"); - assert_eq!(surface["schema"], "gamecult.eve.surface.v1"); - assert_eq!(surface["providerId"], PROVIDER_ID); - assert_eq!(surface["version"], 0); - assert!( - surface["updatedAtUtc"] - .as_str() - .is_some_and(|value| value.parse::>().is_ok()) - ); - assert_eq!(surface["surface"], eve::mesh_surface(None)["surface"]); - assert_eq!(surface["commands"], eve::mesh_surface(None)["commands"]); - assert!( - node.get_required::(ADVERTISEMENT_KEY) - .is_ok() - ); - assert!(node.get_required::(HEALTH_KEY).is_ok()); - } - - #[test] - fn unavailable_odin_cannot_block_local_publication() { - let directory = tempfile::tempdir().unwrap(); - let publisher = MeshPublisher::open( - directory.path().join("mesh-v2.cc"), - Some("127.0.0.1:1".parse().unwrap()), - MeshRuntimeIdentity::default(), - ) - .unwrap(); - let started = std::time::Instant::now(); - publisher.publish(None).unwrap(); - assert!(started.elapsed() < std::time::Duration::from_millis(500)); - assert_eq!(publisher.health().unwrap()["worldState"], "empty"); - } - - #[test] - fn stopped_odin_worker_makes_runtime_health_non_green() { - let directory = tempfile::tempdir().unwrap(); - let publisher = MeshPublisher::open( - directory.path().join("mesh-v2.cc"), - Some("127.0.0.1:1".parse().unwrap()), - MeshRuntimeIdentity::default(), - ) - .unwrap(); - publisher.publish(None).unwrap(); - publisher - .remote - .as_ref() - .unwrap() - .alive - .store(false, Ordering::Release); - assert!(publisher.health().is_err()); - } - - #[test] - fn advertisement_has_one_surface_and_one_command_boundary() { - let directory = tempfile::tempdir().unwrap(); - let publisher = MeshPublisher::open( - directory.path().join("mesh-v2.cc"), - None, - MeshRuntimeIdentity::default(), - ) - .unwrap(); - let advertisement = publisher.provider_advertisement("2026-09-01T00:00:00Z"); - assert_eq!(advertisement["surfaces"].as_array().unwrap().len(), 1); - assert_eq!(advertisement["commands"].as_array().unwrap().len(), 1); - assert_eq!(advertisement["surfaces"][0]["surfaceId"], SURFACE_ID); - } -} diff --git a/crates/ghostlight-dungeon/src/play.rs b/crates/ghostlight-dungeon/src/play.rs deleted file mode 100644 index 6a08445..0000000 --- a/crates/ghostlight-dungeon/src/play.rs +++ /dev/null @@ -1,11900 +0,0 @@ -//! The play table's store and loop (Cut 8a). -//! -//! One play agent runs a role-playing game over a Ghostlight world: it reads -//! `table_view`, interprets the player's prose, dispatches Personas, commits -//! consequences through the kernel's own `table.rs` vocabulary, and ends the -//! turn or asks the player a question. The kernel is the only writer; this -//! table carries no world state between turns, only the turn's own inference -//! record. -//! -//! Cut 8b (`a99fdfe`) wired this into `AppState`, `GHOSTLIGHT_PLAY_MODEL`, -//! and `world.play` ingress: `runtime.rs` now holds one `Arc` per -//! world this process owns and routes `world.play` to it. The Eve schema — -//! a descriptor and a button for `world.play` — is not: `operation_schema` -//! carries the operation alone, so far advertised nowhere (Cut 9). - -use std::{ - collections::VecDeque, - path::{Path, PathBuf}, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; - -use anyhow::{Context, anyhow, bail}; -use base64::Engine; -use chrono::Utc; -use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; -use ghostlight::{ - CommandBody, CommandId, ControllerError, ControllerMode, ControllerPort, DecisionInvocation, - DecisionOpportunity, InferenceEvent, InferenceFault, InferenceFaultClass, InferenceFaultDisposition, - InferenceOutput, InferencePort, InferenceRequest, KernelError, MailboxError, PersonaLane, - PlayPort, PrincipalCommandIntent, Statement, SubjectId, TickMinutes, VerifiedPrincipalEvidence, - WorldMailbox, WorldPatch, WorldSnapshot, actor_tools, authoring_tools, decode_actor_call, - decode_authoring_calls, describe_refusal, describe_refusal_to_actor, id_text, id_text_matches, - table_view, -}; -use ghostlight_persona_projection::{PersonaTurn, SourceSpan}; - -/// What closes a turn on a fault: the stored detail text and the typed class -/// that says what happened. Every non-inference failure is `Unclassified`; an -/// inference fault keeps the class its port gave it (`InferenceFaultClass`, -/// the HTTP status included), so the warn line in `close_with_fault` can name -/// it without the detail text carrying it. -struct TurnFault { - detail: String, - class: InferenceFaultClass, -} - -impl From for TurnFault { - fn from(detail: String) -> Self { - Self { detail, class: InferenceFaultClass::Unclassified } - } -} -use codex_connector::{CodexInputItem, CodexToolDefinition}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use sha2::{Digest, Sha256}; -use tokio::sync::{Mutex, Semaphore}; - -const STORE_TYPE: &str = "ghostlight.play_turn_store.v1"; -/// PA.f172 bumped this from v1 to v2 when `question_surface_version` was -/// added to `PlayTurn` without `#[serde(default)]`. PA.f170 (Cut 13) bumps it -/// again, v2 to v3: `question_surface_version` is deleted outright (the -/// version-only staleness comparison it served is gone, replaced by the -/// opaque per-question `question_token`) and `question_token` carries -/// `#[serde(default)]` in its place, for exactly the same reason — a v1 or v2 -/// row's own bytes never had either field, so the same current struct decodes -/// either as `None`. `open` still gates strictly on the row's own declared -/// `schema_id` (a legacy branch recognizes `LEGACY_STORE_SCHEMA_V1` and -/// `LEGACY_STORE_SCHEMA_V2` by name, never silently), but once recognized, -/// upgrades the row to `STORE_SCHEMA` in place rather than discarding the -/// turn it names as unreadable. A row recovered this way whose question is -/// still open (`state == AwaitingPlayer`) mints a fresh token as part of the -/// upgrade — no earlier schema ever recorded one, and the card is re-rendered -/// with whatever the row holds, so a player returning to an upgraded session -/// can still answer. -const STORE_SCHEMA: &str = "ghostlight.play_turn_store.v3"; -/// The schema id every row written before `question_surface_version` existed -/// carries. Recognized by `PlayTurnStore::open` as legacy, not corrupt, and -/// upgraded to `STORE_SCHEMA` in place (PA.f172, PA.f170). -const LEGACY_STORE_SCHEMA_V1: &str = "ghostlight.play_turn_store.v1"; -/// The schema id every row written between PA.f172 and PA.f170 carries: it -/// has `question_surface_version` (now deleted) but not `question_token`. -/// Recognized by `PlayTurnStore::open` as legacy, not corrupt, and upgraded -/// to `STORE_SCHEMA` in place the same way a v1 row is (PA.f170). -const LEGACY_STORE_SCHEMA_V2: &str = "ghostlight.play_turn_store.v2"; -const STORE_KEY: &str = "primary"; - -/// The one round budget: exhausting it closes the turn and narrates, rather -/// than leaving it `Running` with nothing said (PA-Q8, Cut 8, PA.f66a/PA.f95). -const ROUND_BUDGET: usize = 12; - -/// Bounded so a single round cannot spend the whole response budget one -/// grouped-agent tool spree; matches the grouped agent lane's own order of -/// magnitude (`controllers.rs`'s `OperationalAgent` request). -const PLAY_MAX_OUTPUT_TOKENS: u32 = 8_000; - -/// How many `Retryable` inference faults one round's own request may absorb -/// (PA.f94): only `InferenceFaultDisposition::Retryable` retries at all — a -/// `RecoveryRequired` or `IntegrityViolation` fault closes the turn on its -/// first occurrence, through `close_with_fault`, since retrying either kind -/// again cannot succeed differently. -const ROUND_RETRY_BUDGET: usize = ROUND_BUDGET; - -/// The key ledger's own retention window (PA.f83): a request key survives in -/// the ledger for the most recent 64 closed turns after the one that carried -/// it, then is forgotten — the store keeps only the current turn's own full -/// record, so an ancient replay past the window opens a fresh turn rather -/// than being recognized. -const KEY_LEDGER_WINDOW: usize = 64; - -/// Dungeon's own subset of the library's `PATCH_TOOLS`, named in Cut 8's -/// table (PA-Q7 B). The library hard-codes none of these; this is policy, and -/// policy belongs to the caller. -const PLAY_TOOLS: &[&str] = &[ - "relocate", - "transfer", - "consume", - "mint", - "bind", - "release", - "witness", - "acquire_knowledge", - "forget", - "create_commitment", - "discharge_commitment", - "advance_pressure", - "reduce_pressure", - "open_route", - "close_route", - "declare_place", - "declare_route", - "declare_resource", - "declare_fact", - "declare_subject", - "set_persona_material", - "retire", - "grant_affordance", - "revoke_affordance", -]; - -const DISPATCH_TOOL: &str = "dispatch"; -const ADVANCE_TIME_TOOL: &str = "advance_time"; -const ASK_PLAYER_TOOL: &str = "ask_player"; -const END_TURN_TOOL: &str = "end_turn"; - -/// A handle names the player or a subject dispatched this turn; nothing else -/// carries the `__` separator into a tool name a model may call. -const HANDLE_SEPARATOR: &str = "__"; - -/// The table rules, sent once per round as the request's instructions. The -/// turn's own conversation is rebuilt each round from the recorded -/// `opening_prompt` — `table_view` of the snapshot as it stood the moment the -/// turn opened, captured once, not re-rendered on a later round — plus the -/// recorded `rounds` and their recorded results (PA.f111, S6): a resumed -/// round replays this exact recorded opening, never a fresh `table_view` of -/// whatever the world looks like by the time it resumes. -const PLAY_INSTRUCTIONS: &str = "You are the table: the one player-facing role-playing agent for this Ghostlight world. \ -The player writes prose; you read the whole-world table view and decide what happens.\n\ -\n\ -Rules:\n\ -- Speech and display are quotes: a `__` call's `text` and `display` must be an exact, \ -verbatim quote copied from the player's own words or from a dispatched Persona's own turn prose. A \ -paraphrase is refused.\n\ -- An actor acts only through its own tools: use `__` for the player's handle or a \ -subject you dispatched this turn, never another subject's tool under a borrowed handle.\n\ -- Rulings are authoring tools: mint, transfer, relocate, and the rest of the authoring catalog are \ -your own hand as the table, not an actor's act.\n\ -- Ask the player with `ask_player` when an outcome needs the player's own choice or negotiation, and \ -stop the turn there.\n\ -- A fact you invent uses `declare_fact` with `ruled` standing; you reach a subject's knowledge of it \ -through `witness` or `acquire_knowledge`.\n\ -- You may `mint` quantity into existence; nothing else may.\n\ -- Call `dispatch` with the subjects who should act this round before calling their tools, naming each \ -subject by its full id exactly as table_view prints it in brackets — never the short __ prefix \ -an actor tool's own name carries. An id naming no dispatchable subject is refused, quoting the text \ -given, rather than silently doing nothing.\n\ -- Call `end_turn` when the round's consequences are settled."; - -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) enum PlayTurnState { - Running, - AwaitingPlayer, - Closed, -} - -/// One `run` request. `answers` names the question this request answers, by -/// the same `QuestionId` an `AwaitingPlayer` turn currently exposes; `None` -/// is the plain continue/opening shape (PA.f84). `&str`/`String` still -/// convert directly (`answers: None`), so most call sites are unaffected by -/// this replacing the bare `text` parameter `run` used to take. -#[derive(Clone, Debug)] -pub(crate) struct PlayRequest { - pub(crate) text: String, - pub(crate) answers: Option, -} - -impl From<&str> for PlayRequest { - fn from(text: &str) -> Self { - Self { - text: text.to_owned(), - answers: None, - } - } -} - -impl From for PlayRequest { - fn from(text: String) -> Self { - Self { text, answers: None } - } -} - -/// The small typed value one open question is identified by (PA.f84): a -/// `run` request whose `answers` does not match the turn's own currently -/// open `QuestionId` is refused as stale rather than being applied to -/// whatever question happens to be open when the request is finally -/// processed — a late retry naming an already-answered or already-superseded -/// question must never land on a different one. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct QuestionId { - turn_id: String, - round: usize, - slot: usize, -} - -/// One tool call's recorded body, persisted before submission (PA-Q8): a -/// resumed call resubmits this exact value, never a value rebuilt from a -/// fresh snapshot (mutation M8.3). -#[derive(Clone, Debug, Serialize, Deserialize)] -enum RecordedCall { - Authoring(WorldPatch), - PlayerAct(DecisionOpportunity, DecisionInvocation), - PersonaAct(SubjectId, DecisionOpportunity, DecisionInvocation), - AdvanceTime(TickMinutes), - Dispatch(Vec), - AskPlayer(String), - EndTurn, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct CallRecord { - call_id: String, - round: usize, - slot: usize, - /// Absent only for a call this table refused before it ever reached a - /// door: a decode failure, an unknown tool, a span that would not - /// locate. Nothing with a body here was ever silently dropped; its - /// `result` is always the reason. - body: Option, - result: Option, -} - -/// The play turn row (PA-Q8): one row, over `service/play-turn-v1.cc`, -/// following `app_session.rs`'s pattern. The agent's conversation is -/// re-derived each round from `opening_prompt`, `rounds`, and the recorded -/// `calls`/`persona_turns` results — the connector posture — so nothing -/// carries forward between turns except world state. -#[derive(Clone, Debug, Serialize, Deserialize)] -struct PlayTurn { - /// Minted fresh (`Uuid::new_v4`) when the turn opens (PA.f83): never - /// taken from any request key. A request key and a turn's own identity - /// are different things — see `applied_keys` below and the store's own - /// `KeyLedger` — so a stale retry of the key that opened this turn can - /// never reproduce this turn's own command ids by opening a second turn - /// under the same id. - turn_id: String, - /// Every request key this open turn has already applied (PA.f62/PA.f63): - /// a key already here is a replay — a no-op — for as long as this turn - /// stays the store's current one. Once the turn closes, this list is - /// archived into the store's own `KeyLedger` (PA.f83), which is where a - /// replay of one of these keys is recognized after a new turn has opened - /// in this one's place. `#[serde(default)]` (PA.f177): PA.f95's original - /// reasoning covered only the *current*-schema (v3) strict path, where it - /// still holds — `to_vec_named` always emits this field, so a v3 row that - /// omitted it re-encodes to different bytes and the canonical check still - /// bails. It did not cover the *legacy* v1/v2 branches: a row older than - /// this field's own introduction still carries `LEGACY_STORE_SCHEMA_V1`, - /// so without a default `upgrade_legacy_row`'s decode itself failed with - /// "missing field `applied_keys`", `PlayTurnStore::open` returned `Err`, - /// and `state.play` went `None` — silently, forever, on every restart. - #[serde(default)] - applied_keys: Vec, - opening_prompt: String, - player_prose: Vec, - rounds: Vec, - calls: Vec, - persona_turns: Vec<(SubjectId, PersonaTurn)>, - question: Option, - /// PA.f170 (Cut 13): an opaque random handle minted fresh whenever - /// `execute_round`'s `ASK_PLAYER_TOOL` arm opens a question, set and - /// cleared alongside `question`. It carries no turn id, round, slot, - /// subject, or any other world handle — it is an answer's handle and - /// nothing else (invariant 8: reading it in devtools teaches nothing - /// about the world). This replaces PA.f161's `question_surface_version` - /// and the version-only staleness comparison it served: two questions - /// opened in the same turn carry the same surface version (asking is - /// conversational and never moves it), so a version comparison alone - /// cannot tell them apart — the gap Soul proved for PA.f170. Runtime's - /// `world.play` arm refuses an answer whose own token does not name this - /// one. `#[serde(default)]`, matching the earlier `question_surface_version` - /// reasoning it replaces: a row written under `LEGACY_STORE_SCHEMA_V1` or - /// `LEGACY_STORE_SCHEMA_V2` never had this field, so the same current - /// struct decodes it to `None` for either — exactly what "no question - /// ever recorded a token" means for a turn that old. `PlayTurnStore::open` - /// mints a fresh one for a recovered row whose question is still open, so - /// an upgraded session stays answerable; the canonical re-encode check - /// below still holds current-schema (v3) rows to their exact bytes. - #[serde(default)] - question_token: Option, - refusal: Option, - narration: Option, - /// Set when the turn closed on a fault rather than the player's own - /// consequences settling: an inference fault that does not retry, an - /// inference exhausted its retry budget (PA.f65/PA.f94), a request-build, - /// snapshot, or narrate failure after the turn opened (PA.f85/PA.f93), or - /// a kernel `Invariant` error poisoned the table (PA.f66b). `None` for a - /// turn that closed through `end_turn` or the round budget. - /// `#[serde(default)]` (PA.f177): same reasoning as `applied_keys` above - /// — the current-schema canonical check still holds a v3 row to it, and a - /// legacy row older than this field's own introduction now decodes it as - /// `None` instead of failing `upgrade_legacy_row` outright. - #[serde(default)] - fault: Option, - state: PlayTurnState, -} - -impl PlayTurn { - /// Every subject a dispatch call this turn's own record names has - /// *successfully* acted (PA.f90): a dispatch whose Persona turn faulted, - /// or found no live opportunity, never produced prose this turn can - /// quote, so it does not count as dispatched — offering its actor tools - /// next round would let the agent call them with nothing behind them. - /// `persona_turns` is the one record of an actually-recorded turn; a - /// subject's own presence there, not merely its presence in a - /// `Dispatch` call's own subject list, is what this reads. - fn dispatched_subjects(&self) -> Vec { - let mut subjects = Vec::new(); - for call in &self.calls { - if let Some(RecordedCall::Dispatch(list)) = &call.body { - for subject in list { - if !subjects.contains(subject) && self.persona_turns.iter().any(|(id, _)| id == subject) { - subjects.push(*subject); - } - } - } - } - subjects - } - - /// The `(round, slot)` of the earliest `Dispatch` call in this turn's - /// own record that names `subject`, if any (PA.f125): call order is - /// total, so exactly one call can be credited with producing a - /// subject's own `persona_turns` entry — the first one, in call order, - /// that ever named it. `execute_round` records and persists a dispatch - /// call's own body before `execute_dispatch` runs any persona turn, so - /// a crash-resumed call already appears here, under its own - /// `(round, slot)`, before this is ever consulted — letting - /// `execute_dispatch` recognize its own not-yet-finished work instead - /// of refusing it as `already acted` against a different call. - fn dispatch_owner(&self, subject: SubjectId) -> Option<(usize, usize)> { - self.calls - .iter() - .filter_map(|call| match &call.body { - Some(RecordedCall::Dispatch(list)) if list.contains(&subject) => Some((call.round, call.slot)), - _ => None, - }) - .min() - } - - fn call_record(&self, round: usize, slot: usize) -> Option<&CallRecord> { - self.calls - .iter() - .find(|call| call.round == round && call.slot == slot) - } - - fn call_record_mut(&mut self, round: usize, slot: usize) -> Option<&mut CallRecord> { - self.calls - .iter_mut() - .find(|call| call.round == round && call.slot == slot) - } - - /// The currently open question's own `QuestionId`, when the turn is - /// `AwaitingPlayer` (PA.f84): derived from the last round's own - /// unresolved `ask_player` call, the same slot `find_ask_player_slot` - /// locates to record an answer's result. - fn open_question_id(&self) -> Option { - if self.state != PlayTurnState::AwaitingPlayer { - return None; - } - let round = self.rounds.len().checked_sub(1)?; - let slot = find_ask_player_slot(self, round)?; - Some(QuestionId { - turn_id: self.turn_id.clone(), - round, - slot, - }) - } -} - -/// `turn.open_question_id()` paired with the question's own text and its -/// opaque answer token (PA.f134/PA.f170): `None` whenever `open_question_id` -/// is, since `turn.question` and `turn.question_token` are only ever set -/// alongside it (`execute_round`'s `ASK_PLAYER_TOOL` arm sets all three in -/// the same round, and `answer_turn` and `close_with_fault` both clear -/// `question`/`question_token` on their own way out of `AwaitingPlayer`, -/// PA.f173). -fn open_question_of(turn: &PlayTurn) -> Option { - let id = turn.open_question_id()?; - Some(OpenQuestion { - id, - text: turn.question.clone().unwrap_or_default(), - // PA.f179: `unwrap_or_default()` used to fabricate `""` here whenever - // `question_token` was `None` — normally unreachable, since - // `execute_round`'s `ASK_PLAYER_TOOL` arm sets `question_token` - // alongside `question` in the same round (see this function's own - // doc comment), but a mutation or a future bug that defeats minting - // must not quietly degrade the security invariant along with it. - // Collapsing "no token was ever minted" into the same `""` an - // attacker can literally type made the gate this feeds - // (`runtime.rs`'s `world.play` arm) accept a forged empty - // `answerToken` whenever that happened. Carried as `Option` - // now, so the gate can require both sides to be `Some` and equal — - // absence refuses, it does not degrade to a comparable value. - token: turn.question_token.clone(), - }) -} - -#[derive(Clone, Serialize, Deserialize)] -struct PlayTurnStoreState { - schema: String, - turn: Option, - /// A closed turn's own applied request keys, kept for `KEY_LEDGER_WINDOW` - /// more closed turns after it (PA.f83). No `#[serde(default)]`, matching - /// `PlayTurn::applied_keys`/`fault` (PA.f95): `PlayTurnStore::open`'s own - /// canonical re-encode check refuses any row missing a declared field - /// regardless, so a default here could never actually apply either. - ledger: KeyLedger, - /// The row's own monotonic counter (Cut 9): incremented exactly once by - /// `swap_in`, the row's one write door, on every committed write — - /// `commit`'s own mid-turn updates and `open_new_turn`'s own turn - /// replacements alike. It never resets, not on a new turn and not on a - /// restart (the store reads it back off disk like every other field), so - /// a reconnecting SSE client can never see it go backwards. This is what - /// lets `eve::authenticated_surface`'s own `surface_version` move on a - /// refusal or a question that commits nothing to the world — the one gap - /// a version derived only from already-existing row fields could not - /// close, because a refusal updates `turn.refusal` without growing - /// `turn.calls` or `turn.rounds` (`execute_actor_call`'s `ActionRejected` - /// arm updates an already-recorded call's own result in place). No - /// `#[serde(default)]`, matching `ledger` above: the store has never been - /// deployed, so no row anywhere is missing it, and the canonical - /// re-encode check stays strict (PA.f95). - revision: u64, -} - -/// One closed turn's own applied request keys, oldest closed turn first. -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -struct KeyLedger { - closed: VecDeque, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -struct ClosedTurnKeys { - turn_id: String, - keys: Vec, -} - -impl KeyLedger { - /// The `turn_id` of the closed turn `key` was applied to, if it is still - /// within the window (PA.f109): `ClosedTurnKeys::turn_id` is written on - /// every archive but was never read anywhere until this — a replay - /// recognized through the ledger can now say which turn it replayed. - fn turn_id_for(&self, key: &str) -> Option { - self.closed - .iter() - .find(|entry| entry.keys.iter().any(|existing| existing == key)) - .map(|entry| entry.turn_id.clone()) - } - - /// Archives one turn's own applied keys as it closes out of the store's - /// single current-turn row, then evicts down to `KEY_LEDGER_WINDOW` - /// closed turns, oldest first. - fn push_closed(&mut self, turn_id: String, keys: Vec) { - if keys.is_empty() { - return; - } - self.closed.push_back(ClosedTurnKeys { turn_id, keys }); - while self.closed.len() > KEY_LEDGER_WINDOW { - self.closed.pop_front(); - } - } -} - -/// `PlayTurnStore` over `service/play-turn-v1.cc`: one `OwnedRedbMessagePackBackingStore` -/// row, the same custody shape `app_session.rs`'s `AppSessionOwner` uses. -struct PlayTurnStore { - store: OwnedRedbMessagePackBackingStore, - row: CultCacheEnvelope, - state: PlayTurnStoreState, - healthy: bool, -} - -impl PlayTurnStore { - fn open(path: impl AsRef) -> anyhow::Result { - let store = OwnedRedbMessagePackBackingStore::new(path.as_ref())?; - store - .validate_path_identity() - .context("play-turn store path identity is invalid")?; - let rows = store.pull_all()?; - // PA.f193-E/PA.f194-E: the third element is the sidecar path a - // retirement wrote this call, `None` on every ordinary path (fresh - // init, a row that decoded/upgraded cleanly). Nothing on - // `PlayTurnStore` itself keeps it — `PlayTable::retired_row_sidecar` - // scans the store's own directory fresh on every `/health` read - // instead, so a retirement a *prior* process performed is still - // reported after a restart, not only by the process whose own `open` - // happened to perform it. - let (row, state, _retired_sidecar) = match rows.as_slice() { - [] => { - let state = PlayTurnStoreState { - schema: STORE_SCHEMA.into(), - turn: None, - ledger: KeyLedger::default(), - revision: 0, - }; - let row = envelope(&state)?; - if !store.compare_and_swap_batch(&[], vec![row.clone()])? { - bail!("play-turn store changed during initialization"); - } - (row, state, None) - } - // PA.f184: a row already at the current schema id can still fail - // to read — genuinely corrupt bytes, or (the realistic trigger) a - // future change to `PlayTurn`'s field set or order that lands - // without moving `STORE_SCHEMA`, which flips the canonical - // re-encode check below without touching `schema_id` at all. - // Before this fix every one of those three checks `bail!`ed - // unconditionally, so `open` failed the identical way on every - // future restart and the play card stayed gone until an operator - // deleted the store by hand — exactly the PA.f177 gap, but for a - // row this store itself is supposed to already understand. Now it - // retires through the same `retire_unreadable_row` path the - // legacy arms use, so a damaged or future-schema row loses its - // one turn instead of the whole play surface. - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(STORE_SCHEMA) => - { - match read_current_schema_row(row) { - Ok(state) => (row.clone(), state, None), - Err(CurrentSchemaRowError::Decode(error)) => { - let (row, state, sidecar) = - retire_unreadable_row(&store, path.as_ref(), row, STORE_SCHEMA, &error)?; - (row, state, Some(sidecar)) - } - // PA.f193-A/B: not a decode failure — the row's bytes - // parsed into a `PlayTurnStoreState` perfectly fine, and - // either the canonical re-encode check or the inner - // `schema` tag disagreed. The row is left untouched and - // `open` fails loudly, the same shape `upgrade_legacy_row` - // already uses for its own `Other` arm (PA.f185), instead - // of destroying a turn that decoded just fine. - Err(CurrentSchemaRowError::Other(error)) => { - return Err(error).context("reading a current-schema play-turn row failed"); - } - } - } - // PA.f172/PA.f170: a row written before `question_token` existed - // — named legacy, not corrupt, because it is neither: it is a - // real, previously-canonical shape this process itself wrote, - // under the schema id that was current when it did. - // `question_token` carries `#[serde(default)]`, so the very same - // `PlayTurnStoreState` decodes it correctly (the field reads back - // `None`, matching what "no question ever recorded a token" - // means for a turn this old). `upgrade_legacy_row` mints a fresh - // token when the recovered turn's own question is still open, - // upgrades the row to `STORE_SCHEMA` (v3) in place, and persists - // that upgrade immediately, so the legacy path is only ever taken - // once per row; the canonical re-encode check does not apply - // here — a legacy row's bytes were never canonical under v3 and - // were never meant to be — it resumes for every write this store - // makes from here on. - // PA.f177: `upgrade_legacy_row`'s own decode can still fail — a - // row older than `applied_keys`/`fault` themselves, or one that - // is genuinely damaged — even though the schema id names it - // recognizably legacy. That is no longer treated as "open must - // fail and every future open must fail on the exact same row"; - // the row is retired to a sidecar file and the store starts - // fresh instead (see `retire_unreadable_row`). - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V1) => - { - match upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V1) { - Ok((row, state)) => (row, state, None), - Err(LegacyRowError::Decode(error)) => { - let (row, state, sidecar) = retire_unreadable_row( - &store, - path.as_ref(), - row, - LEGACY_STORE_SCHEMA_V1, - &error, - )?; - (row, state, Some(sidecar)) - } - // PA.f185: not a decode failure — an inner `schema` tag - // that disagrees with the envelope, or a lost - // compare-and-swap race — so the row is left untouched - // and `open` fails loudly instead of destroying a turn - // that decoded just fine. - Err(LegacyRowError::Other(error)) => { - return Err(error).context("upgrading a legacy play-turn row failed"); - } - } - } - // PA.f170: a row written between PA.f172 and PA.f170 — it has - // `question_surface_version` (deleted, silently ignored on - // decode since nothing here reads it) but no `question_token`. - // Upgraded exactly like a v1 row above; PA.f177's own retirement - // fallback applies here too. - [row] - if row.r#type == STORE_TYPE - && row.key == STORE_KEY - && row.schema_id.as_deref() == Some(LEGACY_STORE_SCHEMA_V2) => - { - match upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V2) { - Ok((row, state)) => (row, state, None), - Err(LegacyRowError::Decode(error)) => { - let (row, state, sidecar) = retire_unreadable_row( - &store, - path.as_ref(), - row, - LEGACY_STORE_SCHEMA_V2, - &error, - )?; - (row, state, Some(sidecar)) - } - Err(LegacyRowError::Other(error)) => { - return Err(error).context("upgrading a legacy play-turn row failed"); - } - } - } - // PA.f184/PA.f194-A: our own key and type, but a schema id none - // of the arms above recognize — a future schema this build has - // never seen, not foreign data. That alone does not mean the row - // is unreadable: an Idunn rollback across a schema-id move can - // hand this build a row stamped with a schema id it has never - // seen whose bytes still decode cleanly under the current - // `PlayTurnStoreState` shape (nothing about the struct changed, - // only the id). Before this fix, every unrecognized schema id - // was retired on the id alone, with no decode attempted — a - // canonical, perfectly readable row lost its one open turn, and - // the sidecar's own `reason` claimed the row "could not be read" - // when nothing had tried. Attempt the decode first, exactly like - // the current- and legacy-schema arms above: retire only on a - // genuine decode failure; a row that decodes cleanly is left - // untouched and `open` fails loudly instead, so an operator sees - // the mismatch rather than losing the turn silently. Multiple - // rows, or a row under a different `type`/`key` entirely, still - // bail below: that is genuinely foreign content this store must - // not guess about destroying. - [row] if row.r#type == STORE_TYPE && row.key == STORE_KEY => { - match rmp_serde::from_slice::(&row.payload) { - Ok(_) => { - return Err(anyhow!( - "play-turn row declares unrecognized schema {:?} but its payload decodes \ - cleanly under the current PlayTurnStoreState shape; refusing to retire a \ - readable row (PA.f194-A) — open a build that recognizes this schema", - row.schema_id - )); - } - Err(decode_error) => { - let reason = anyhow::Error::new(decode_error).context(format!( - "play-turn row declares unrecognized schema {:?}", - row.schema_id - )); - let (row, state, sidecar) = retire_unreadable_row( - &store, - path.as_ref(), - row, - row.schema_id.as_deref().unwrap_or(""), - &reason, - )?; - (row, state, Some(sidecar)) - } - } - } - _ => bail!("play-turn store contains foreign or otherwise-unreadable records; start with a fresh store"), - }; - Ok(Self { - store, - row, - state, - healthy: true, - }) - } - - fn current(&self) -> Option<&PlayTurn> { - self.state.turn.as_ref() - } - - /// PA.f99's own test seam: re-reads the current turn through a fresh - /// `pull_all` against the *same already-owned* redb `Database` this - /// store already holds — never the cached `self.state` field a mutation - /// could leave stale, and never a second `OwnedRedbMessagePackBackingStore::new` - /// on the same path, which CultCache's own single-owner lock refuses - /// outright (`redb CultCache already has an active owner`): this - /// crate learned that the hard way building this very seam. `pull_all` - /// opens its own fresh redb read transaction against the one open - /// `Database`, so this reflects exactly what the last successful - /// `compare_and_swap_batch` durably committed, independent of whether - /// `self.state` was ever updated at all. - #[cfg(test)] - fn pull_current_from_disk_for_test(&self) -> Option { - let rows = self.store.pull_all().ok()?; - let row = rows.into_iter().find(|row| row.r#type == STORE_TYPE && row.key == STORE_KEY)?; - let state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload).ok()?; - state.turn - } - - /// The `turn_id` of the turn `key` was already applied to, if any - /// (PA.f109): the still-open current turn, when `key` is recorded on it, - /// or the ledger's own archived record of one of the most recent - /// `KEY_LEDGER_WINDOW` closed turns (PA.f83). Either way, `run` treats a - /// known key as a no-op — a `Replayed` outcome naming the turn it - /// replayed, not a fresh open. - fn turn_id_for_key(&self, key: &str) -> Option { - if let Some(turn) = &self.state.turn - && turn.applied_keys.iter().any(|existing| existing == key) - { - return Some(turn.turn_id.clone()); - } - self.state.ledger.turn_id_for(key) - } - - /// Persists `turn` as an update to the store's own still-open current - /// turn: the ledger is carried forward unchanged, because no turn is - /// closing out of the row here. - fn commit(&mut self, turn: PlayTurn) -> anyhow::Result<()> { - self.ensure_owned()?; - self.swap_in(Some(turn), self.state.ledger.clone()) - } - - /// Opens `turn` as the store's new current turn (PA.f83): whatever turn - /// currently occupies the row — always `Closed`, by `PlayTable::run`'s - /// own invariant that only a closed turn (or no turn) is ever replaced — - /// has its own applied keys archived into the ledger first, so a later - /// replay of one of them is still recognized after its full record is - /// gone. - fn open_new_turn(&mut self, turn: PlayTurn) -> anyhow::Result<()> { - self.ensure_owned()?; - let mut ledger = self.state.ledger.clone(); - if let Some(previous) = &self.state.turn { - ledger.push_closed(previous.turn_id.clone(), previous.applied_keys.clone()); - } - self.swap_in(Some(turn), ledger) - } - - /// The row's one write door (Cut 9): every committed write — `commit`'s - /// own mid-turn updates and `open_new_turn`'s own turn replacements alike - /// — passes through here, and `revision` moves exactly once per call, - /// here alone, so no call site can under- or double-count it. - fn swap_in(&mut self, turn: Option, ledger: KeyLedger) -> anyhow::Result<()> { - let next = PlayTurnStoreState { - schema: STORE_SCHEMA.into(), - turn, - ledger, - revision: self.state.revision + 1, - }; - let next_row = envelope(&next)?; - let swapped = self - .store - .compare_and_swap_batch(std::slice::from_ref(&self.row), vec![next_row.clone()]); - if !matches!(swapped, Ok(true)) { - self.healthy = false; - bail!("play-turn commit outcome is uncertain; sole ownership was lost"); - } - self.row = next_row; - self.state = next; - Ok(()) - } - - fn ensure_owned(&mut self) -> anyhow::Result<()> { - if !self.healthy { - bail!("play-turn custody is poisoned; reopen the owner"); - } - if self.store.validate_path_identity().is_err() { - self.healthy = false; - bail!("play-turn store ownership was lost"); - } - Ok(()) - } -} - -fn envelope(state: &PlayTurnStoreState) -> anyhow::Result { - Ok(CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(state)?, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }) -} - -/// Mints a fresh opaque per-question answer token (PA.f170): a random handle -/// with no world meaning of its own — not a `QuestionId`, not a subject, -/// turn, round, or slot, and forging one only answers the forger's own -/// already-open question (invariant 8). `execute_round`'s `ASK_PLAYER_TOOL` -/// arm mints one every time a question opens; `upgrade_legacy_row` mints one -/// for a recovered legacy row whose question is still open, since no earlier -/// schema ever recorded one. -fn mint_question_token() -> String { - uuid::Uuid::new_v4().to_string() -} - -/// `upgrade_legacy_row`'s own error, split by whether the row was actually -/// unreadable (PA.f185). Soul's probe: a row whose envelope declares -/// `LEGACY_STORE_SCHEMA_V1` and whose inner `schema` field says -/// `LEGACY_STORE_SCHEMA_V2` decodes its turn perfectly — `opening_prompt`, -/// `question`, every field reads back intact — but the inner-schema -/// consistency check below, and a lost `compare_and_swap_batch` race while -/// persisting the upgrade, both used to be indistinguishable from "the bytes -/// would not parse at all" once `PlayTurnStore::open` caught the `Err`: every -/// exit landed in `retire_unreadable_row`, destroying a readable turn under a -/// sidecar reason ("did not decode even leniently") that was false for -/// either case. Only `Decode` is that genuine case; `Other` propagates as an -/// ordinary `open` failure instead, so a schema-tag mismatch is diagnosable -/// and a transient CAS race can be retried, rather than both being treated as -/// permanent corruption and quietly erased. -enum LegacyRowError { - Decode(anyhow::Error), - Other(anyhow::Error), -} - -/// Shared by `PlayTurnStore::open`'s `LEGACY_STORE_SCHEMA_V1` and -/// `LEGACY_STORE_SCHEMA_V2` branches (PA.f170): decodes `row` under the -/// current `PlayTurnStoreState`/`PlayTurn` structs — both legacy schemas -/// decode through exactly the same `#[serde(default)]` fields a current- -/// schema row would — mints a fresh `question_token` when the recovered -/// turn's own question is still open (no earlier schema ever recorded one), -/// then upgrades the row to `STORE_SCHEMA` in place and persists that -/// upgrade immediately, so the legacy path is only ever taken once per row. -fn upgrade_legacy_row( - store: &OwnedRedbMessagePackBackingStore, - row: &CultCacheEnvelope, - legacy_schema: &str, -) -> Result<(CultCacheEnvelope, PlayTurnStoreState), LegacyRowError> { - let mut state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload) - .with_context(|| format!("play-turn legacy {legacy_schema} state is corrupt")) - .map_err(LegacyRowError::Decode)?; - if state.schema != legacy_schema { - return Err(LegacyRowError::Other(anyhow!( - "play-turn legacy {legacy_schema} state schema is invalid: payload itself declares {:?}", - state.schema - ))); - } - if let Some(turn) = state.turn.as_mut() - && turn.state == PlayTurnState::AwaitingPlayer - && turn.question.is_some() - && turn.question_token.is_none() - { - turn.question_token = Some(mint_question_token()); - } - state.schema = STORE_SCHEMA.into(); - let upgraded_row = envelope(&state).map_err(LegacyRowError::Other)?; - match store.compare_and_swap_batch(std::slice::from_ref(row), vec![upgraded_row.clone()]) { - Ok(true) => Ok((upgraded_row, state)), - Ok(false) => Err(LegacyRowError::Other(anyhow!( - "play-turn store changed while upgrading a legacy {legacy_schema} row" - ))), - Err(error) => Err(LegacyRowError::Other(error)), - } -} - -/// `read_current_schema_row`'s own error, split the same way -/// `upgrade_legacy_row`'s is (PA.f185, here PA.f193-A/B): only a genuine -/// decode failure means the row is actually unreadable. A row whose bytes -/// parse into a `PlayTurnStoreState` but fail the canonical re-encode check, -/// or whose inner `schema` field disagrees with the envelope's own -/// `STORE_SCHEMA`, has a perfectly readable turn inside it — before this fix -/// both cases reached `PlayTurnStore::open` as a plain `anyhow::Error` and -/// were routed into `retire_unreadable_row` exactly like a genuine decode -/// failure, destroying a turn that decoded fine. Only `Decode` is that -/// genuine case; `Other` propagates as an ordinary `open` failure instead, -/// leaving the row untouched. -enum CurrentSchemaRowError { - Decode(anyhow::Error), - Other(anyhow::Error), -} - -/// `PlayTurnStore::open`'s current-schema arm (PA.f184): a row already -/// declaring `STORE_SCHEMA` can still fail to read — genuinely corrupt bytes, -/// or a future change to `PlayTurn`'s own field set or order that lands -/// without moving `STORE_SCHEMA`, which the canonical re-encode check below -/// exists to catch. Unlike `upgrade_legacy_row` (PA.f185), there is no -/// upward-migration path for a current-schema row that fails one of these — -/// nothing upgrades it in place — but that does not make every exit here -/// retirement-worthy: the canonical check and the inner-schema check both run -/// only after the decode has already succeeded, so a row that fails either -/// one is readable, not corrupt (PA.f193-A/B). -fn read_current_schema_row(row: &CultCacheEnvelope) -> Result { - let state: PlayTurnStoreState = rmp_serde::from_slice(&row.payload) - .context("play-turn state is corrupt") - .map_err(CurrentSchemaRowError::Decode)?; - let reencoded = rmp_serde::to_vec_named(&state) - .context("re-encoding play-turn state failed") - .map_err(CurrentSchemaRowError::Other)?; - if reencoded != row.payload { - return Err(CurrentSchemaRowError::Other(anyhow!("play-turn state is not canonical"))); - } - if state.schema != STORE_SCHEMA { - return Err(CurrentSchemaRowError::Other(anyhow!("play-turn state schema is invalid"))); - } - Ok(state) -} - -/// A legacy row's payload, `type`, `key` and `schema_id`, preserved for -/// whoever the operator hands `retire_unreadable_row`'s sidecar file to -/// (Soul's owed item 5). This is the one deliberate exception to the GameCult -/// substrate doctrine's "no load-bearing JSON": the doctrine's own carve-out -/// is content that exists to communicate with "the Non-CultNet xenos", and an -/// operator opening a forensic dump by hand with a text editor is exactly -/// that boundary, not a CultCache consumer. The msgpack form this replaced -/// was preservation in name only — nothing in this Body could read it back, -/// an operator included, without writing a one-off decoder first. JSON here -/// is readable directly; `payload_base64` carries the row's original bytes -/// verbatim (standard, padded alphabet) so nothing is lost, proven by the -/// round-trip test below. -#[derive(Serialize)] -struct RetiredPlayTurnRow<'a> { - retired_at: String, - reason: String, - r#type: &'a str, - key: &'a str, - schema_id: Option<&'a str>, - stored_at: &'a str, - payload_base64: String, -} - -/// `PlayTurnStore::open`'s fallback when a row this build recognizes by its -/// `schema_id` (legacy or current) still cannot be turned into a usable -/// `PlayTurnStoreState` — a row older than `applied_keys`/`fault` themselves -/// (PA.f177), one genuinely damaged past what a schema-shape upgrade can -/// recover, a current-schema row whose bytes no longer round-trip -/// canonically (PA.f184), or a schema id no arm above recognizes at all. Before -/// PA.f177 this row made `open` return `Err` unconditionally: `open_play` -/// caught that, logged it, and left `state.play` `None` — every future `open` -/// hit the identical row and failed the identical way, so the play card -/// stayed gone until an operator deleted the store by hand. The invariant is -/// that a daemon never answers an unreadable row by quietly having no play -/// surface, so this writes the row, byte for byte, to a sidecar file beside -/// the store (named for what it is, so it reads as forensic evidence and not -/// as another store — PA.f192: the extension is `.json`, not `.cc`, since -/// this sidecar is a one-shot forensic dump, never a CultCache store some -/// future process might mistake it for and try to open; Soul's owed item 5: -/// the content really is JSON, an operator-readable text file with the row's -/// original bytes preserved verbatim as base64 — see `RetiredPlayTurnRow`'s -/// own doc comment for why JSON here does not violate the GameCult substrate -/// doctrine), clears the row -/// through the store's own compare-and-swap (the same guard `swap_in` uses -/// for every other write — this is not a second, unguarded writer), and -/// returns a fresh, empty state so `open` succeeds and play starts fresh. -/// Logged at `warn`, so the fact reaches the operator through the same log -/// stream `runtime.rs`'s own `open_play` failure warning already uses, next -/// to the `playStatus` the operator already checks (PA.f193-E: -/// `PlayTable::retired_row_sidecar` reports it structurally too, by scanning -/// the store's own directory fresh on every read rather than trusting a flag -/// cached at `open` — see that method's own doc comment for why a cached -/// per-process flag is not enough for `/health` to survive a restart). -/// -/// PA.f189: two orderings matter here and are both deliberate. -/// - The sidecar write happens *before* the destructive compare-and-swap. -/// If the write fails, this returns `Err` without ever touching the row — -/// `open` fails loudly and the original row is exactly where it was, so a -/// disk-full or permission failure never destroys evidence it could not -/// also preserve. -/// - If the write succeeds but the compare-and-swap below then fails — the -/// store changed identity mid-retirement, another writer raced this one, -/// or custody was lost — this still returns `Err`. The sidecar file it -/// already wrote is *not* cleaned up: nothing here can tell whether the -/// race means the original row is still there (safe to retry retirement, -/// which would then write a second, differently-timestamped sidecar) or -/// already gone some other way. An orphaned sidecar from a failed -/// retirement is therefore an expected, if rare, shape — a forensic -/// leftover with no corresponding successful retirement — and an operator -/// clearing them out should confirm the row it names is actually resolved -/// (upgraded, retired again, or the store rebuilt) before deleting one. -/// -/// **Precondition (PA.f194-A):** the caller must have already attempted to -/// decode `row`'s payload and that attempt must have failed — `decode_error` -/// is trusted verbatim into the sidecar's own `reason` field ("could not be -/// read: ..."), so a caller that has not actually tried to read the row makes -/// that claim false. This function performs no decode of its own to verify -/// it. An unrecognized `schema_id` alone is not evidence of unreadability: a -/// row can declare a schema this build has never seen and still decode -/// cleanly under the current `PlayTurnStoreState` shape (a future schema -/// built from an identical struct, surfaced by an Idunn rollback across a -/// schema-id move). `PlayTurnStore::open`'s own unrecognized-schema arm -/// attempts a decode first and only calls this on genuine decode failure, -/// exactly like its current- and legacy-schema siblings. -fn retire_unreadable_row( - store: &OwnedRedbMessagePackBackingStore, - store_path: &Path, - row: &CultCacheEnvelope, - declared_schema: &str, - decode_error: &anyhow::Error, -) -> anyhow::Result<(CultCacheEnvelope, PlayTurnStoreState, PathBuf)> { - let reason = format!("row declared schema {declared_schema} but could not be read: {decode_error:#}"); - let sidecar = retired_row_sidecar_path(store_path); - let record = RetiredPlayTurnRow { - retired_at: Utc::now().to_rfc3339(), - reason: reason.clone(), - r#type: &row.r#type, - key: &row.key, - schema_id: row.schema_id.as_deref(), - stored_at: &row.stored_at, - payload_base64: base64::engine::general_purpose::STANDARD.encode(&row.payload), - }; - // Pretty-printed (Soul's owed item 5): this file exists for an operator - // to open and read, not for a machine to round-trip efficiently, so - // legibility wins over byte count. - let bytes = serde_json::to_vec_pretty(&record).context("encoding the retired play-turn row failed")?; - std::fs::write(&sidecar, &bytes) - .with_context(|| format!("writing the retired play-turn row to {} failed", sidecar.display()))?; - - let fresh = PlayTurnStoreState { - schema: STORE_SCHEMA.into(), - turn: None, - ledger: KeyLedger::default(), - revision: 0, - }; - let fresh_row = envelope(&fresh)?; - if !store.compare_and_swap_batch(std::slice::from_ref(row), vec![fresh_row.clone()])? { - bail!("play-turn store changed while retiring an unreadable row (the sidecar at {} was already written and is left in place)", sidecar.display()); - } - - tracing::warn!( - sidecar = %sidecar.display(), - reason = %reason, - "a play-turn row could not be read; it was retired to a sidecar file and play starts fresh" - ); - - Ok((fresh_row, fresh, sidecar)) -} - -/// Beside the store, named for what it is: -/// `.retired-unreadable-row..json` (PA.f192, -/// extension renamed from `.msgpack` for Soul's owed item 5, since the -/// content is now genuinely JSON — see `RetiredPlayTurnRow`'s own doc -/// comment). Timestamped so a second unreadable row (a different corruption, -/// on a later run) never collides with or silently overwrites the first -/// one's evidence, and so `retired_row_sidecars_on_disk`'s own lexicographic -/// sort is also chronological. Not `.cc`: this file is a one-shot forensic -/// dump written through `std::fs::write`, never through a -/// `CacheBackingStore`, and never meant to be opened as a CultCache store. -fn retired_row_sidecar_path(store_path: &Path) -> PathBuf { - let stamp = Utc::now().format("%Y%m%dT%H%M%S%.6fZ"); - let stem = store_path - .file_stem() - .and_then(|stem| stem.to_str()) - .unwrap_or("play-turn"); - store_path.with_file_name(format!("{stem}.retired-unreadable-row.{stamp}.json")) -} - -/// PA.f193-E: every sidecar `retire_unreadable_row` has ever written beside -/// `store_path`, oldest first (the timestamp sorts lexicographically, since -/// `retired_row_sidecar_path`'s own stamp format is fixed-width and -/// zero-padded). A fresh directory scan on every call, not a fact cached once -/// at `open` — the whole point is that this survives a restart: a clean -/// reopen after a retirement finds no unreadable row of its own to retire, -/// but the *evidence* that a prior process retired one is still sitting on -/// disk until an operator clears it. Filters by this store's own filename -/// stem so a second, unrelated `.cc` store's sidecars in the same directory -/// are never mixed in. -fn retired_row_sidecars_on_disk(store_path: &Path) -> Vec { - let Some(directory) = store_path.parent() else { - return Vec::new(); - }; - let Some(stem) = store_path.file_stem().and_then(|stem| stem.to_str()) else { - return Vec::new(); - }; - let prefix = format!("{stem}.retired-unreadable-row."); - let Ok(entries) = std::fs::read_dir(directory) else { - return Vec::new(); - }; - let mut sidecars: Vec = entries - .filter_map(|entry| entry.ok()) - .map(|entry| entry.path()) - .filter(|path| { - path.file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".json")) - }) - .collect(); - sidecars.sort(); - sidecars -} - -#[derive(Debug, thiserror::Error)] -pub(crate) enum PlayError { - #[error("the play table is poisoned by a prior kernel invariant fault; restart to clear it")] - Poisoned, - #[error("world mailbox error: {0}")] - World(#[from] MailboxError), - #[error("controller error: {0}")] - Controller(#[from] ControllerError), - #[error("play store error: {0}")] - Store(#[from] anyhow::Error), - /// PA.f134: carries the turn's own still-open question — the same one - /// the caller was already answering, since this refusal never changes - /// it — so a consumer can retry without re-reading the store. - #[error("an empty or whitespace-only answer is refused")] - EmptyAnswer { turn_id: String, question: OpenQuestion }, - /// PA.f84: `request.answers` did not name the turn's own currently open - /// `QuestionId` — either it named a different, no-longer-open question - /// (a late retry), or no question is open at all while the turn is - /// `AwaitingPlayer`, which cannot actually happen but is refused the - /// same way rather than assumed away. - /// - /// PA.f134: carries the turn's own *actually* currently open question - /// (`None` only in the "cannot actually happen" case), so a caller who - /// named a stale question can retry against the real one without - /// re-reading the store. - #[error("the answer does not name the currently open question; it is refused as stale")] - StaleAnswer { - turn_id: String, - question: Option, - }, - /// PA.f84: `request.answers` was given while no question is open at all - /// — the turn is `Running`, or there is no open turn to answer. - #[error("an answer was given but no question is currently open")] - NoQuestionOpen, - /// PA.f193-D: `admit`'s own `run_lock` was already held by another call's - /// own round loop when this request arrived — refused before request - /// text or the turn's own state are even inspected. An empty continue - /// submitted into this exact window is refused the same way a non-empty - /// one is, which is what actually distinguishes this from - /// `TurnStillRunning` below: nothing about the request being empty makes - /// it succeed here. Before this split both cases shared `TurnStillRunning`'s - /// text, which told a player who had just submitted the empty continue - /// the card itself hinted at that only an empty continue is accepted — - /// while refusing that exact empty continue for an unrelated reason. - #[error("a prior request for this turn is still being processed; retry shortly")] - TurnBusy, - /// PA.f84: the turn is `Running`, `admit`'s own `run_lock` was free (this - /// call is the only one touching the turn), and `request.text` is - /// non-empty; only an empty continue is accepted while a prior request's - /// own rounds are still being resolved. PA.f193-D: this is the only - /// `PlayError` for which that sentence is actually true — a request that - /// instead finds `run_lock` itself held is refused as `TurnBusy` above, - /// before text is ever inspected. - #[error("the turn is still running its own prior request; only an empty continue is accepted")] - TurnStillRunning, - /// PA.f108: empty or whitespace-only text with no turn currently open to - /// continue (no turn at all, or the current one is `Closed`) used to - /// open a fresh turn anyway, with prose `[""]`, and run a full inference - /// over nothing the player actually said. A new turn needs the player's - /// own words. - #[error("a new turn needs non-empty text; an empty or whitespace-only opening is refused")] - EmptyOpening, -} - -/// A turn's own open question, and the identity a caller answers it by -/// (PA.f134): `id` is the exact `QuestionId` `PlayRequest::answers` expects -/// back; `text` is the question itself, so a consumer never has to read the -/// store to learn either. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct OpenQuestion { - pub(crate) id: QuestionId, - pub(crate) text: String, - /// PA.f170: the opaque per-question answer token minted when this - /// question opened — the value `runtime.rs`'s `world.play` arm compares - /// an answer's own `answerToken` against, refusing anything else as - /// stale. Replaces PA.f161's `surface_version`, which only ever told - /// apart questions that opened on different world revisions, not two - /// questions opened in the same turn. - /// - /// `Option`, not `String` (PA.f179): `None` means no token was - /// ever minted for this question — a state the gate must refuse - /// outright, never compare against an answer as if `""` were a real - /// token. Collapsing that case into `String::default()` is exactly what - /// let a defeated mint degrade the gate to accepting a forged empty - /// `answerToken`. - pub(crate) token: Option, -} - -/// One `run` call's own outcome (PA.f109): `Replayed` names the turn a -/// request key already known — recorded on the still-open current turn, or -/// archived in the store's own `KeyLedger` — replayed, so `ClosedTurnKeys`'s -/// own `turn_id` field, written on every archive, finally has a reader. -/// -/// PA.f134: both variants also carry the turn's own currently open question, -/// when one is open, so a caller who reaches `ask_player` can answer it -/// without a second, separate read of the store — `run`'s own return value -/// already names everything `PlayRequest::answers` needs. `world.play`'s own -/// route still cannot hand this back to its HTTP caller (it spawns `run` in -/// a task and answers before the turn even opens); `PlayTable::current_turn_view` -/// is what a later, separate read answers from instead. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) enum RunOutcome { - Ran { - turn_id: String, - question: Option, - }, - Replayed { - turn_id: String, - question: Option, - }, -} - -/// `PlayTable::admit`'s own outcome (Cut 10, PA.f153/PA.f154): a replay is -/// fully decided already — no round ever runs, so nothing is ever spawned -/// for it — while `Spawn` carries the built, already-persisted turn a caller -/// hands to `execute` in a background task. -pub(crate) enum Admission { - Replayed { - turn_id: String, - question: Option, - }, - Spawn(Admitted), -} - -/// An admitted turn, ready for `PlayTable::execute` (Cut 10). Carries the -/// table's own `run_lock` guard (owned, not borrowed — see `run_lock`'s own -/// doc comment) so the lock stays held from admission through the end of -/// execution even when the two happen in different tasks. -pub(crate) struct Admitted { - turn: PlayTurn, - _run_guard: tokio::sync::OwnedMutexGuard<()>, -} - -/// The current turn's own player-facing state (PA.f134), read through -/// `PlayTable::current_turn_view` — see its own doc comment for why this -/// exists beside `RunOutcome`. -#[derive(Clone, Debug, PartialEq, Eq)] -pub(crate) struct PlayTurnView { - pub(crate) turn_id: String, - pub(crate) state: PlayTurnState, - pub(crate) question: Option, - pub(crate) narration: Option, - pub(crate) refusal: Option, - /// The play row's own monotonic counter, read straight off the store. - /// `eve::authenticated_wake_version` folds it with `surface_version` to - /// wake the SSE path on a play-row-only change (a fresh refusal, a newly - /// open question, a closed turn's narration) that moves nothing on the - /// world itself. It is never folded into the surface document's own - /// `version` field, which names `surface_version` alone. - pub(crate) revision: u64, - /// PA.f193-D: whether `admit`'s own `run_lock` is held right now — a - /// non-blocking peek, not a fresh acquisition, taken by - /// `current_turn_view` alongside everything else it reads. Lets - /// `eve::authenticated_surface` show the "submit an empty message to - /// continue" hint only for a turn nothing is currently working on: - /// showing it while a round is genuinely in flight told a player to do - /// the exact thing `admit`'s own `try_lock_owned` was about to refuse as - /// `TurnBusy` anyway. - pub(crate) run_in_progress: bool, - /// Whether the turn closed on a fault (`PlayTurn::fault` is set). The - /// detail stays in the stored row and the log; the card shows a fixed - /// player-safe line, never the detail (invariant 8, PA.f187). - pub(crate) fault: bool, -} - -/// One outcome of executing a round's already-decided tool calls. -#[derive(Clone, Copy)] -enum RoundOutcome { - /// Every call in the round resolved; the loop should infer the next one. - Continue, - AwaitingPlayer, - Closed, - /// A kernel `Invariant` fault closed the turn and poisoned the table. - Poisoned, -} - -/// The play table (Cut 8): one operational model running a role-playing game -/// over a Ghostlight world. `play` is Dungeon's one `PlayPort`, minted here -/// and nowhere else in production (negative grep, matching `PlayPort`'s own -/// doc comment). -pub(crate) struct PlayTable { - play: PlayPort, - world: WorldMailbox, - /// `Arc`, not owned (PA.f13/P8.5): a round's own dispatched Personas run - /// concurrently, each in its own spawned task under a permit drawn from - /// `permits`, so every concurrent call needs its own handle to the same - /// lane rather than a borrow of one `self`. - personas: Arc, - inference: Arc, - model: String, - /// The shared controller/Persona concurrency pool (PA.f13, P8.5): - /// `execute_dispatch` and `close_turn`'s own narration draw from the - /// exact same `Arc` `runtime.rs` hands `AppState`'s - /// `controller_permits` — the one pool `runtime_readiness`'s - /// `controllerStatus` reads — never a second pool this table mints for - /// itself. - permits: Arc, - store: Mutex, - /// PA.f186/PA.f193-E: the store's own on-disk path, kept beside `store` - /// so `retired_row_sidecar` can scan for sidecars without taking - /// `store`'s lock. Not the fact of a retirement itself — that is derived - /// fresh from disk on every read, on purpose: a cached "did this - /// process's own `open` retire something" flag reported `ok` again after - /// any restart, even though the sidecar evidence a prior process wrote - /// was still sitting right there. - store_path: PathBuf, - /// One turn at a time for this table (PA.f68/PA.f112): held for the - /// whole body of `run`, so the key check and the turn open happen under - /// one acquisition and two concurrent `run` calls can never interleave. - /// This table is the one owner of one world's play authority, so this is - /// "one running turn per world" exactly as `AppState.play` holds one - /// `Arc` per world this process owns. - /// `Arc`-wrapped since Cut 10 (PA.f153/PA.f154): `admit` takes an owned - /// guard (`try_lock_owned`) so it can hand the guard into a spawned task - /// alongside the turn it built, rather than holding a borrowed guard for - /// a call that must return to its own caller before the turn's round loop - /// finishes. - run_lock: Arc>, - poisoned: AtomicBool, - /// `retry_delay`'s own base, in milliseconds (PA.f122): an ordinary - /// field, always compiled, defaulting to `RETRY_DELAY_BASE_MS` — the - /// same production base `backoff_delay` always scaled from before this - /// field existed. Only a test ever changes it, through - /// `set_retry_delay_base_ms`, so a test's own retries settle instantly - /// without a `#[cfg(test)]` swap over the delay formula itself. - retry_delay_base_ms: std::sync::atomic::AtomicU64, - /// PA.f99's own test seam: fired immediately before each of the four - /// doors that submit or persist a call's already-recorded state - /// (`commit_authoring_run`, `execute_advance_time`, `execute_actor_call`, - /// and, since PA.f123, `execute_dispatch` right after each dispatched - /// subject's own turn is persisted) moves on. It hands the hook the - /// current turn as a fresh `pull_all` against the store's own - /// already-owned redb `Database` finds it - /// (`PlayTurnStore::pull_current_from_disk_for_test`) — not the store's - /// cached `self.state`, and not a second, independently opened store, - /// which CultCache's own single-owner lock refuses outright. No - /// production call installs one. - #[cfg(test)] - before_submit_hook: std::sync::Mutex) + Send + Sync>>>, - /// PA.f127/PA.f133's own test seam: see `fetch_round_snapshot`. - #[cfg(test)] - forced_round_snapshot: std::sync::Mutex>, -} - -impl PlayTable { - pub(crate) fn new( - world: WorldMailbox, - personas: PersonaLane, - inference: Arc, - model: String, - permits: Arc, - store_path: impl AsRef, - ) -> anyhow::Result { - let play = PlayPort::new(world.clone()); - let store_path = store_path.as_ref().to_path_buf(); - let store = PlayTurnStore::open(&store_path)?; - Ok(Self { - play, - world, - personas: Arc::new(personas), - inference, - model, - permits, - store: Mutex::new(store), - store_path, - run_lock: Arc::new(Mutex::new(())), - poisoned: AtomicBool::new(false), - retry_delay_base_ms: std::sync::atomic::AtomicU64::new(RETRY_DELAY_BASE_MS), - #[cfg(test)] - before_submit_hook: std::sync::Mutex::new(None), - #[cfg(test)] - forced_round_snapshot: std::sync::Mutex::new(None), - }) - } - - /// PA.f186/PA.f193-E: the most recent sidecar this store's directory - /// carries, if any — not only one this process's own `open` wrote. - /// Scanned fresh from disk on every call (`retired_row_sidecars_on_disk`, - /// one directory listing), so `runtime.rs`'s `/health` surface reports a - /// retirement across a restart: a clean reopen after a retirement finds - /// no unreadable row of its own, but the sidecar a prior process wrote is - /// still on disk until an operator clears it, and this keeps naming it. - /// `None` once every sidecar for this store has actually been cleared — - /// a healthy daemon still reports plain `ok`. - pub(crate) fn retired_row_sidecar(&self) -> Option { - retired_row_sidecars_on_disk(&self.store_path).pop() - } - - /// Installs PA.f99's own before-submit test hook (see the field's own - /// doc comment). Test-only: no production call site exists. - #[cfg(test)] - fn set_before_submit_hook(&self, hook: impl Fn(Option) + Send + Sync + 'static) { - *self.before_submit_hook.lock().unwrap() = Some(Box::new(hook)); - } - - #[cfg(test)] - async fn fire_before_submit_hook(&self) { - let installed = self.before_submit_hook.lock().unwrap().is_some(); - if !installed { - return; - } - let durable_turn = self.store.lock().await.pull_current_from_disk_for_test(); - if let Some(hook) = self.before_submit_hook.lock().unwrap().as_ref() { - hook(durable_turn); - } - } - - #[cfg(not(test))] - async fn fire_before_submit_hook(&self) {} - - /// One `world.play` turn. `key` is the caller's own idempotency key - /// (PA-Q8, PA.f62/PA.f63/PA.f83): it is not the turn's identity — the - /// turn mints its own `turn_id` when it opens. A key already known — - /// recorded on the currently open turn, or archived in the store's own - /// `KeyLedger` from one of the most recent 64 closed turns — is a replay - /// and is a no-op, making zero inference calls. A fresh key reaching an - /// open turn continues it: as the player's own answer, when it is - /// `AwaitingPlayer` and `request.answers` names that turn's own currently - /// open question (PA.f84; any other `answers`, or none at all, is - /// refused as stale rather than being applied to whatever question - /// happens to be open), or as a plain continue when it is `Running` and - /// `request.text` is empty (non-empty text is refused: this turn is - /// still running its own prior request and drops nothing silently). A - /// fresh key reaching no turn, or a closed one, opens a new turn under - /// that key (Cut 8a's "one row": only the current turn's own record is - /// ever kept, so the closed turn's record is archived into the ledger - /// and then discarded). - pub(crate) async fn run( - &self, - principal: &VerifiedPrincipalEvidence, - key: String, - request: PlayRequest, - ) -> Result { - match self.admit(key, request).await? { - Admission::Replayed { turn_id, question } => Ok(RunOutcome::Replayed { turn_id, question }), - Admission::Spawn(admitted) => self.execute(admitted, principal).await, - } - } - - /// Cut 10 (PA.f153/PA.f154): the synchronous half of `run` — replay - /// detection, the stale-answer/empty-answer/no-question-open/ - /// turn-still-running/empty-opening refusals, and building (and - /// persisting) the turn a fresh request continues or opens. None of this - /// spends an inference budget, so `runtime.rs::execute_world`'s - /// `world.play` route now awaits this directly and answers `denied` with - /// the reason on `Err`, instead of discarding every refusal inside a - /// spawned task that had already answered `accepted`. Only - /// `Admission::Spawn`'s own turn actually runs a round loop, and only - /// that is spawned, via `execute`. - pub(crate) async fn admit(&self, key: String, request: PlayRequest) -> Result { - if self.poisoned.load(Ordering::SeqCst) { - return Err(PlayError::Poisoned); - } - // PA.f68/PA.f112: held from here through the end of `execute`, so - // the replay/key check below and the turn open happen under one - // acquisition and two concurrent `admit` calls for this table can - // never interleave — the second one always observes the first one's - // own already-open (or already-closed) turn, never a stale - // `None`/`Closed` read racing a still-in-flight `open_new_turn`. - // - // PA.f142: `try_lock_owned`, not `lock_owned().await` — a - // `world.play` route spawns the turn's own execution in a task and - // returns before it resolves, so a second request arriving while a - // turn's own round loop is still running (up to `ROUND_BUDGET` - // rounds of inference) must be refused at once, not queued behind it - // with no bound and no signal. A request that finds the lock free — - // including an answer to a question a prior call already left open - // and returned from — is never affected: nothing holds this lock - // between one turn's own execution finishing and the next admission - // starting. Owned (Cut 10), not borrowed, so the guard can move into - // the spawned task alongside the turn `admit` built. - let Ok(run_guard) = Arc::clone(&self.run_lock).try_lock_owned() else { - // PA.f193-D: `TurnBusy`, not `TurnStillRunning` — the lock itself - // is contended, before the turn's own state or the request's own - // text are inspected at all. An empty continue arriving in this - // exact window is refused too, which `TurnStillRunning`'s own - // text would misrepresent. - return Err(PlayError::TurnBusy); - }; - let PlayRequest { text, answers } = request; - let (stored, replayed_turn_id) = { - let store = self.store.lock().await; - (store.current().cloned(), store.turn_id_for_key(&key)) - }; - if let Some(turn_id) = replayed_turn_id { - // PA.f134: the replayed key's own turn, only if it is *still* - // the store's current one and still `AwaitingPlayer` — a replay - // of a key from an already-closed or already-superseded turn has - // no open question to report. - let question = stored - .as_ref() - .filter(|current| current.turn_id == turn_id && current.state == PlayTurnState::AwaitingPlayer) - .and_then(open_question_of); - return Ok(Admission::Replayed { turn_id, question }); - } - - let (turn, is_new_turn) = match stored { - Some(existing) if existing.state == PlayTurnState::AwaitingPlayer => { - let open_question = existing.open_question_id(); - // PA.f181 (no code change; narrowness recorded here, not - // fixed, because there is nothing to fix): in production, - // `answers` reaches here only from `runtime.rs`'s own - // `world.play` arm, which resolves it to `Some(question.id.clone())` - // *after* its own `answer_token_admits` gate already refused - // everything else — the server, not the caller, names the - // question being answered (PA.f151). So a real request can - // only ever land on the `(Some(given), Some(open)) if given - // == open` arm below, or on `None` (a plain continue/opening, - // no question open at all); the mismatch/stale case this - // match's own `_` arm refuses is, in production, reachable - // only through the race window between `runtime.rs`'s own - // `current_turn_view()` read (which built `question.id`) and - // this function's own fresh `stored` read a few lines above - // — the turn moved on in between, under a token that was - // genuinely valid for the question it was minted against a - // moment earlier. The one test that exercises a genuine - // mismatch here (`an_answer_naming_a_different_question_is_refused_as_stale`) - // reaches it by forging a `PlayRequest.answers` directly, at - // this layer, bypassing `runtime.rs`'s token gate entirely — - // there is no way to reach it end-to-end through the token - // gate on demand, only by winning the race. The check stays: - // it is still the one thing that closes that race window, - // and losing it silently would be exactly the kind of - // "it fixes itself after a reload" gap this project's own - // doctrine refuses to accept. - match (answers, open_question.clone()) { - (Some(given), Some(open)) if given == open => { - if text.trim().is_empty() { - // Refused before anything is recorded (PA.f65): - // the question stays open, the key is never - // marked applied, and nothing about the stored - // turn changes. PA.f134: the refusal carries the - // same still-open question the caller was - // already answering. - let question = open_question_of(&existing).expect( - "an EmptyAnswer refusal is only reached once open_question_id() already matched", - ); - return Err(PlayError::EmptyAnswer { - turn_id: existing.turn_id.clone(), - question, - }); - } - (self.answer_turn(existing, key, text), false) - } - // A missing or mismatched `answers` is refused as stale - // (PA.f84): a late retry naming an old question must - // never land on whatever question happens to be open now. - // PA.f134: carries the turn's own *actually* open - // question (from `open_question`, computed before the - // match consumed it), so the caller can retry against it. - _ => { - return Err(PlayError::StaleAnswer { - turn_id: existing.turn_id.clone(), - question: open_question.map(|id| OpenQuestion { - id, - text: existing.question.clone().unwrap_or_default(), - // PA.f179: kept as the real `Option`, - // not fabricated to `""` — see - // `OpenQuestion::token`'s own doc comment. - token: existing.question_token.clone(), - }), - }); - } - } - } - Some(existing) if existing.state == PlayTurnState::Running => { - if answers.is_some() { - return Err(PlayError::NoQuestionOpen); - } - if text.trim().is_empty() { - (self.continue_turn(existing, key), false) - } else { - return Err(PlayError::TurnStillRunning); - } - } - _ => { - if answers.is_some() { - return Err(PlayError::NoQuestionOpen); - } - // PA.f108: no turn is open to continue (none at all, or the - // current one is `Closed`), so this text opens a fresh one — - // and a fresh turn needs the player's own words, not an - // empty or whitespace-only prompt that would run a full - // inference over nothing said. - if text.trim().is_empty() { - return Err(PlayError::EmptyOpening); - } - (self.begin_turn(key, text).await?, true) - } - }; - if is_new_turn { - self.store.lock().await.open_new_turn(turn.clone())?; - } else { - self.persist(&turn).await?; - } - Ok(Admission::Spawn(Admitted { turn, _run_guard: run_guard })) - } - - /// Cut 10 (PA.f153/PA.f154): the round loop `run` used to run inline — - /// the part that can spend a real inference budget across up to - /// `ROUND_BUDGET` rounds. `runtime.rs::execute_world`'s `world.play` - /// route spawns this, never `admit`, so an admission refusal is never - /// silently swallowed behind an already-sent `accepted` response. - pub(crate) async fn execute( - &self, - admitted: Admitted, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let Admitted { mut turn, _run_guard } = admitted; - loop { - match turn.state { - PlayTurnState::AwaitingPlayer | PlayTurnState::Closed => break, - PlayTurnState::Running => {} - } - let round = turn.rounds.len().saturating_sub(1); - let round_is_open = !turn.rounds.is_empty() && round_incomplete(&turn, round); - if round_is_open { - match self.execute_round(&mut turn, round, principal).await? { - RoundOutcome::Continue => { - self.persist(&turn).await?; - continue; - } - RoundOutcome::AwaitingPlayer | RoundOutcome::Closed => { - self.persist(&turn).await?; - break; - } - RoundOutcome::Poisoned => { - // `execute_round` has already closed `turn` and - // recorded the fault (PA.f66b): this door only marks - // the table poisoned and persists that state. - self.poisoned.store(true, Ordering::SeqCst); - self.persist(&turn).await?; - return Err(PlayError::Poisoned); - } - } - } - if turn.rounds.len() >= ROUND_BUDGET { - // The round budget is exhausted (PA.f66a): the turn closes - // and narrates through the same door `end_turn` uses, rather - // than being left open with nothing said. A snapshot or - // narrate failure inside `close_turn` still closes the turn, - // with the fault recorded, through `close_with_fault` - // (PA.f85/PA.f93). - match self.close_turn(&turn, principal).await { - Ok(narration) => { - turn.narration = Some(narration); - turn.state = PlayTurnState::Closed; - self.persist(&turn).await?; - } - Err(error) => self.close_with_fault(&mut turn, error.to_string()).await?, - } - break; - } - let round = turn.rounds.len(); - match self.infer_round(&turn, round, principal).await { - Ok(output) => { - turn.rounds.push(output); - self.persist(&turn).await?; - } - Err(detail) => { - // PA.f85/PA.f93/PA.f94: a request-build failure, an - // inference fault whose own disposition never retries - // (`RecoveryRequired`/`IntegrityViolation`), or a - // `Retryable` fault that exhausted its retry budget, all - // close the turn with the fault recorded through the one - // shared door, rather than leaving it `Running`. - self.close_with_fault(&mut turn, detail).await?; - break; - } - } - } - // PA.f134: names the turn and, when the loop left it `AwaitingPlayer`, - // the question it is now waiting on — the same value `run`'s own - // `AwaitingPlayer` branches above already derive from `turn`, read - // once more here since `turn` was moved through the loop by value. - let question = open_question_of(&turn); - Ok(RunOutcome::Ran { - turn_id: turn.turn_id.clone(), - question, - }) - } - - /// The current turn's own player-facing state (PA.f134): its id, state, - /// open question (if any), last narration, and most recent refusal line - /// — everything a consumer needs to answer an open question or observe - /// what a turn did, without reading the store directly. `world.play`'s - /// own route spawns `run` in a task and answers `{"kind":"accepted"}` - /// before that call resolves, so nothing from a `run` call's own return - /// value ever reaches the caller that made it; this is the door a later, - /// separate read goes through instead. `None` when no turn has ever - /// opened on this table. - pub(crate) async fn current_turn_view(&self) -> Option { - let store = self.store.lock().await; - let turn = store.current()?; - // PA.f193-D: `try_lock`, not `try_lock_owned` — this never wants to - // hold the guard, only to observe whether one is currently held, so - // it is dropped immediately either way. This never contends with or - // delays a real `admit`: at worst it observes the lock a moment - // before or after `admit` itself would. - let run_in_progress = self.run_lock.try_lock().is_err(); - Some(PlayTurnView { - turn_id: turn.turn_id.clone(), - state: turn.state, - question: open_question_of(turn), - narration: turn.narration.clone(), - refusal: turn.refusal.clone(), - revision: store.state.revision, - run_in_progress, - fault: turn.fault.is_some(), - }) - } - - /// Opens a fresh turn. Takes no principal (PA.f95: the parameter was - /// never read) — the player's own subject is resolved per-round from the - /// live snapshot, not from the caller's own evidence. - async fn begin_turn(&self, key: String, text: String) -> Result { - let snapshot = self.play.snapshot().await?; - let opening_prompt = format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - text - ); - Ok(PlayTurn { - turn_id: uuid::Uuid::new_v4().to_string(), - applied_keys: vec![key], - opening_prompt, - player_prose: vec![text], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }) - } - - /// Applies the player's own answer to the currently open question, - /// already checked by `run` to name that question's own `QuestionId`. - fn answer_turn(&self, mut turn: PlayTurn, key: String, text: String) -> PlayTurn { - turn.applied_keys.push(key); - turn.player_prose.push(text.clone()); - let round = turn.rounds.len().saturating_sub(1); - if let Some(slot) = find_ask_player_slot(&turn, round) { - if let Some(record) = turn.call_record_mut(round, slot) { - record.result = Some(text); - } - } - turn.question = None; - turn.question_token = None; - turn.state = PlayTurnState::Running; - turn - } - - /// A plain continue of a `Running` turn under a fresh key: nothing about - /// the turn's own record changes beyond the key itself (PA.f84). - fn continue_turn(&self, mut turn: PlayTurn, key: String) -> PlayTurn { - turn.applied_keys.push(key); - turn - } - - /// Closes `turn` with `detail` as its recorded fault and persists it: - /// the one door every failure reachable only after a turn has already - /// opened goes through (PA.f85/PA.f93) — a request-build failure, a - /// snapshot or narrate failure, an inference fault whose disposition - /// does not retry or that exhausted its retry budget, or a kernel - /// `Invariant` fault. Distinct from poisoning the whole table: that - /// stays each caller's own decision (`RoundOutcome::Poisoned`), because - /// this helper only ever writes the one turn's own record. - async fn close_with_fault(&self, turn: &mut PlayTurn, fault: impl Into) -> Result<(), PlayError> { - let TurnFault { detail, class } = fault.into(); - turn.state = PlayTurnState::Closed; - // R2: the one place a fault closes a turn, so the one place it is - // logged, whole. The line names the typed class (for an HTTP reply, - // its status code, which is the server's answer and not an input - // value) beside the detail. An `InferenceFault`'s detail is a - // `&'static str`, which stops a port from handing it computed text by - // accident; it does not stop a deliberate `Box::leak` into the - // `pub` constructors, and a new port's canary test is what actually - // keeps endpoint, request and reply text out. The details of the - // other failures that reach here (kernel, request build, snapshot) - // are their callers' own error text, and nothing here constrains - // them. - tracing::warn!(turn_id = %turn.turn_id, class = ?class, detail = %detail, "play turn closed on a fault"); - turn.fault = Some(detail); - // PA.f173: a fault can close a turn that was `AwaitingPlayer`, and - // `answer_turn` is not the only door out of that state — clear the - // open question here too, the same way `answer_turn` does, so a - // closed turn never carries a question record alongside it. This was - // unobservable before this fix only because `open_question_id` gates - // on `state == AwaitingPlayer`, which a fault always leaves behind by - // setting `state = Closed` in the same write. - turn.question = None; - turn.question_token = None; - self.persist(turn).await - } - - /// Runs one round's own inference to completion, or returns the fault - /// detail the caller closes the turn with (PA.f85/PA.f93/PA.f94): a - /// request-build failure (the snapshot, `round_tools`, or - /// `InferenceRequest::play` itself), or an `InferenceFault` whose own - /// disposition does not retry, or that exhausted its bounded retry - /// budget. - async fn infer_round( - &self, - turn: &PlayTurn, - round: usize, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let snapshot = self.round_snapshot().await?; - let dispatched = turn.dispatched_subjects(); - let tools = self.round_tools(&snapshot, &dispatched, principal)?; - let conversation = rebuild_conversation(turn, round); - let command_id = CommandId::parse_uuid(&turn.turn_id).map_err(|error| error.to_string())?; - - let mut attempt = 0usize; - loop { - let request = InferenceRequest::play( - command_id, - round, - &self.model, - PLAY_INSTRUCTIONS, - conversation.clone(), - tools.clone(), - PLAY_MAX_OUTPUT_TOKENS, - ) - .map_err(|error| error.to_string())?; - let prepared = match self.inference.prepare(request) { - Ok(prepared) => prepared, - Err(fault) => { - self.absorb_or_close(fault, &mut attempt).await?; - continue; - } - }; - match self.inference.infer(prepared).await { - Ok(output) => return Ok(output), - Err(fault) => { - self.absorb_or_close(fault, &mut attempt).await?; - continue; - } - } - } - } - - /// One inference fault's own disposition decides its fate (PA.f94): only - /// `Retryable` retries at all, bounded by `ROUND_RETRY_BUDGET` and the - /// capped exponential backoff of `retry_delay` (100 ms doubling to a 20 s - /// cap, 105.5 s summed over the budget; zero under test); - /// `RecoveryRequired` and `IntegrityViolation` never retry, closing on - /// their first occurrence — retrying either again cannot succeed - /// differently. `Ok(())` means the caller's loop should try again; - /// `Err` carries the fault's own detail text and class, to close the turn with. - async fn absorb_or_close(&self, fault: InferenceFault, attempt: &mut usize) -> Result<(), TurnFault> { - match fault.disposition() { - InferenceFaultDisposition::Retryable if *attempt < ROUND_RETRY_BUDGET => { - let delay = self.retry_delay(*attempt); - *attempt += 1; - if !delay.is_zero() { - tokio::time::sleep(delay).await; - } - Ok(()) - } - InferenceFaultDisposition::Retryable - | InferenceFaultDisposition::RecoveryRequired - | InferenceFaultDisposition::IntegrityViolation => { - Err(TurnFault { detail: fault.to_string(), class: fault.class() }) - } - } - } - - /// The capped exponential backoff between retries of a `Retryable` - /// inference fault (PA.f94): 100 ms doubling to a 20 s cap, whose sum over - /// `ROUND_RETRY_BUDGET` retries must outlast a model server reload - /// (pinned by the summed-backoff test). PA.f122: the base is an ordinary field on - /// the table, not a `#[cfg(test)]` swap over the formula itself — - /// production and test both run `backoff_delay` (below, a free function, - /// compiled and directly unit-testable in every build), over the exact - /// same code path, differing only in the base a test explicitly sets - /// through `set_retry_delay_base_ms` (PA.f94's own zero delay, so a test - /// never actually sleeps). - fn retry_delay(&self, attempt: usize) -> Duration { - backoff_delay(self.retry_delay_base_ms.load(Ordering::Relaxed), attempt) - } - - /// Sets the base `backoff_delay` scales from. Test-only: no production - /// call site exists, matching `set_before_submit_hook`'s own shape — the - /// field itself stays ordinary and always compiled so production and - /// test run the identical `retry_delay`/`backoff_delay` code path. - #[cfg(test)] - pub(crate) fn set_retry_delay_base_ms(&self, base_ms: u64) { - self.retry_delay_base_ms.store(base_ms, Ordering::Relaxed); - } - - /// The one owner of round entry (PA.f127/PA.f133): fetches the live - /// snapshot and runs the handle-collision check exactly once, for - /// whichever caller is opening this round — `infer_round`, before - /// deciding the round's own tools, and `execute_round`, before resolving - /// a resumed round's already-decided calls. Both used to fetch their own - /// snapshot and run `collision_refusal` independently, which is two - /// owners for one rule; this is the one. - async fn round_snapshot(&self) -> Result { - let snapshot = self.fetch_round_snapshot().await?; - if let Some(refusal) = collision_refusal(&snapshot) { - return Err(refusal); - } - Ok(snapshot) - } - - /// The live fetch `round_snapshot` checks. Test-only: a forced snapshot, - /// when set through `force_round_snapshot`, stands in for the kernel's - /// own — the one seam this crate has for driving a *genuine*, real-id - /// handle collision through `round_snapshot` itself, matching - /// `before_submit_hook`'s and `set_retry_delay_base_ms`'s own shape. No - /// production call site ever sets it. - #[cfg(test)] - async fn fetch_round_snapshot(&self) -> Result { - if let Some(forced) = self.forced_round_snapshot.lock().unwrap().clone() { - return Ok(forced); - } - self.play.snapshot().await.map_err(|error| error.to_string()) - } - - #[cfg(not(test))] - async fn fetch_round_snapshot(&self) -> Result { - self.play.snapshot().await.map_err(|error| error.to_string()) - } - - /// Test-only: see `fetch_round_snapshot`. No production call site exists. - #[cfg(test)] - fn force_round_snapshot(&self, snapshot: WorldSnapshot) { - *self.forced_round_snapshot.lock().unwrap() = Some(snapshot); - } - - fn round_tools( - &self, - snapshot: &WorldSnapshot, - dispatched: &[SubjectId], - principal: &VerifiedPrincipalEvidence, - ) -> Result, String> { - let mut tools = authoring_tools(PLAY_TOOLS).map_err(|error| error.to_string())?; - tools.extend(control_tools()); - let Some(player) = player_subject(snapshot, principal) else { - return Ok(tools); - }; - let mut player_tools = actor_tools(&actor_prefix(player.id), snapshot, player.id); - annotate_actor_tools(&mut player_tools, snapshot, player.id); - tools.extend(player_tools); - for subject in dispatched { - let mut subject_tools = actor_tools(&actor_prefix(*subject), snapshot, *subject); - annotate_actor_tools(&mut subject_tools, snapshot, *subject); - tools.extend(subject_tools); - } - Ok(tools) - } - - /// Executes every call in `round` that has not yet resolved, in order. - async fn execute_round( - &self, - turn: &mut PlayTurn, - round: usize, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - let output = turn.rounds[round].clone(); - // PA.f103, PA.f118, PA.f127/PA.f133: this door's own `close_with_fault` - // still calls `persist` under the hood, so a failure right here has no - // lower layer left to record it in — it propagates through the `?` on - // `close_with_fault`'s own `Result` instead of being written into the - // turn, which is exactly the failure that would need writing. - // `round_snapshot` failing here *is* covered, by - // `a_snapshot_failure_mid_round_closes_with_the_fault_recorded` below - // (a live fetch failure) and by the forced-collision tests below (the - // handle-collision refusal): a round resumed after a crash executes - // its already-decided calls through this door directly, never through - // `round_tools` at all, so the same one owner both a fresh round and a - // resumed one share must run here too, rather than resolving calls - // against a handle that no longer names one subject unambiguously. - let snapshot = match self.round_snapshot().await { - Ok(snapshot) => snapshot, - Err(detail) => { - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Closed); - } - }; - let player = player_subject(&snapshot, principal); - - // Slotted, unresolved tool calls only: `slot` must still count a - // call this loop skips as already resolved, so slot numbering never - // shifts between a round's first pass and its resumed one. - let mut calls: Vec<(usize, &str, &str, &str)> = Vec::new(); - let mut slot = 0usize; - for event in output.events() { - let InferenceEvent::ToolCall { - call_id, - name, - arguments, - } = event - else { - continue; - }; - let this_slot = slot; - slot += 1; - if turn - .call_record(round, this_slot) - .is_some_and(|call| call.result.is_some()) - { - continue; - } - calls.push((this_slot, call_id.as_str(), name.as_str(), arguments.as_str())); - } - - let mut index = 0usize; - while index < calls.len() { - let (this_slot, call_id, name, arguments) = calls[index]; - - // A maximal run of consecutive authoring calls commits together - // as one shared patch under one command id (PA.f54): actor calls - // and table tools break a run. - if PLAY_TOOLS.contains(&name) { - let mut run = Vec::new(); - while index < calls.len() && PLAY_TOOLS.contains(&calls[index].2) { - let (slot, call_id, name, arguments) = calls[index]; - run.push((slot, call_id.to_owned(), name.to_owned(), arguments.to_owned())); - index += 1; - } - let results = self.commit_authoring_run(turn, round, &run, &snapshot).await?; - let mut fault = None; - for (slot, call_id, outcome, result) in results { - if matches!(outcome, RoundOutcome::Poisoned) { - fault = Some(result.clone()); - } - record_call(turn, &call_id, round, slot, None, Some(result)); - } - self.persist(turn).await?; - if let Some(detail) = fault { - // PA.f66b: a kernel `Invariant` fault closes the turn - // with the fault recorded, not left `Running`. - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Poisoned); - } - continue; - } - index += 1; - - if name == END_TURN_TOOL { - record_call(turn, call_id, round, this_slot, Some(RecordedCall::EndTurn), None); - match self.close_turn(turn, principal).await { - Ok(narration) => { - if let Some(record) = turn.call_record_mut(round, this_slot) { - record.result = Some("the turn ended".to_owned()); - } - turn.narration = Some(narration); - turn.state = PlayTurnState::Closed; - } - Err(error) => { - // PA.f85/PA.f93: a narrate failure still closes the - // turn, with the fault recorded, rather than leaving - // it stuck `Running` behind a `?`. - self.close_with_fault(turn, error.to_string()).await?; - } - } - return Ok(RoundOutcome::Closed); - } - - if name == ASK_PLAYER_TOOL { - let question = parse_ask_player(arguments); - record_call( - turn, - call_id, - round, - this_slot, - Some(RecordedCall::AskPlayer(question.clone())), - None, - ); - turn.question = Some(question); - // PA.f170: a fresh opaque token, minted the instant this - // question opens, binds an answer to *this* question rather - // than to a world revision — PA.f161's `surface_version` - // told apart questions that opened on different revisions, - // but two questions opened in the same turn share one - // revision (asking is conversational and never moves it), so - // that comparison alone could not tell them apart. Unlike - // the version it replaces, the token needs no snapshot: it - // carries no world handle at all. - turn.question_token = Some(mint_question_token()); - turn.state = PlayTurnState::AwaitingPlayer; - return Ok(RoundOutcome::AwaitingPlayer); - } - - if name == DISPATCH_TOOL { - // PA.f136: the recorded body decides, exactly like every - // other door's own `recorded` match - // (`commit_authoring_run`/`execute_advance_time`/ - // `execute_actor_call`) — a resumed call resubmits its own - // already-recorded subjects, never a value `parse_dispatch` - // re-derives against whatever the live snapshot now says. - // Before this, `subjects`/`refusals` were re-parsed against - // the *current* snapshot on every pass, recorded body or not: - // if the world changed between the crash and the resume so a - // dispatched subject no longer resolved (a retirement), the - // resumed call ran a shorter list and the record below - // silently overwrote the original `RecordedCall::Dispatch` - // with that shorter one — losing the subject from - // `dispatch_owner`/`dispatched_subjects` for the rest of the - // turn even though `persona_turns` still carried her prose. - let recorded = turn.call_record(round, this_slot).and_then(|call| call.body.clone()); - let (subjects, refusals) = match recorded { - Some(RecordedCall::Dispatch(subjects)) => (subjects, Vec::new()), - _ => { - let (subjects, refusals) = parse_dispatch(arguments, &snapshot); - // PA.f125: this call's own body — which subjects it - // intends to dispatch — is recorded and persisted - // before `execute_dispatch` runs any persona turn, - // the same "body durable before effects" order every - // other door already uses (PA-Q8, `persist`'s own - // doc comment). Without this, a crash between - // `execute_dispatch`'s own per-subject persist and - // this call's own record here left `persona_turns` - // durable with no call record naming it; a resume - // then mistook the already-durable Persona turn for - // a *different* call's completed dispatch ("already - // acted"), and the Persona's own prose never reached - // the agent (PA.f64). - record_call( - turn, - call_id, - round, - this_slot, - Some(RecordedCall::Dispatch(subjects.clone())), - None, - ); - self.persist(turn).await?; - (subjects, refusals) - } - }; - self.fire_before_submit_hook().await; - let (outcome, dispatch_summary) = - self.execute_dispatch(turn, round, this_slot, &subjects).await?; - // PA.f98, PA.f114: bad input — an unknown id, a non-string - // entry, a malformed body, or a missing `subjects` key — is - // refused in the tool result, quoting what was given, never - // dropped silently. - let result = if refusals.is_empty() { - dispatch_summary - } else { - let refusals = refusals.join("; "); - if dispatch_summary.is_empty() { - refusals - } else { - format!("{dispatch_summary}; {refusals}") - } - }; - record_call( - turn, - call_id, - round, - this_slot, - Some(RecordedCall::Dispatch(subjects)), - Some(result), - ); - if matches!(outcome, RoundOutcome::Closed) { - // PA.f85/PA.f93: `execute_dispatch` already closed the - // turn with its fault recorded, mid-loop; nothing later - // in this round resolves. - return Ok(RoundOutcome::Closed); - } - continue; - } - - if name == ADVANCE_TIME_TOOL { - let (outcome, result) = self - .execute_advance_time(turn, call_id, round, this_slot, arguments) - .await?; - let fault = matches!(outcome, RoundOutcome::Poisoned).then(|| result.clone()); - record_call(turn, call_id, round, this_slot, None, Some(result)); - self.persist(turn).await?; - if let Some(detail) = fault { - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Poisoned); - } - continue; - } - - // `__` actor call: `handle` alone picks the actor - // (Dungeon's own vocabulary — the library owns no notion of - // which subject was dispatched this turn); the full `name` is - // then handed to `execute_actor_call` unstripped, so the - // library's own `decode_actor_call` owns the prefix strip. - if let Some((handle, _)) = name.split_once(HANDLE_SEPARATOR) { - // PA.f104: derived fresh from the turn's own record right - // here, on every actor call, rather than snapshotted once - // before this loop began — an earlier call in this same - // round's own call order (including a dispatch this round - // already executed) must make its subject's actor tools - // valid on the first pass exactly as it does on a resume, - // since a resume recomputes this same derivation from the - // same persisted record and would otherwise disagree. - let dispatched = turn.dispatched_subjects(); - let actor = resolve_handle(handle, player.as_ref(), &dispatched); - let Some(actor) = actor else { - record_call( - turn, - call_id, - round, - this_slot, - None, - Some(format!("`{handle}` is not the player or a subject dispatched this turn")), - ); - continue; - }; - let (outcome, result) = self - .execute_actor_call( - turn, call_id, round, this_slot, actor, name, arguments, &snapshot, principal, - ) - .await?; - let fault = matches!(outcome, RoundOutcome::Poisoned).then(|| result.clone()); - record_call(turn, call_id, round, this_slot, None, Some(result)); - self.persist(turn).await?; - if let Some(detail) = fault { - self.close_with_fault(turn, detail).await?; - return Ok(RoundOutcome::Poisoned); - } - continue; - } - - record_call( - turn, - call_id, - round, - this_slot, - None, - Some(format!("`{name}` is not a tool this table offers")), - ); - } - - Ok(RoundOutcome::Continue) - } - - /// One maximal run of consecutive authoring calls (PA.f54): inside one - /// round, a run of authoring calls with no actor call or table tool - /// between them decodes as one shared patch, through the library's own - /// `decode_authoring_calls`, and commits atomically under one command id - /// derived from the run's own first (anchor) slot. A draft handle a - /// call in the run declares resolves against a later call in the same - /// run, because every call's item lands in that one shared `WorldPatch` - /// before any of it is submitted. - /// - /// PA.f61: a run whose shared patch would rewrite an existing subject's - /// persona material is refused whole — `WorldPatch::sets_persona_of_existing_subject` - /// is the one read-only query the library exposes for this, because - /// `WorldPatch`'s own fields are `pub(crate)` and Dungeon may not parse - /// its JSON to decide the question itself. `set_persona_material` still - /// stands for a subject this same run's own `declare_subject` call - /// declares (a `Ref::Draft`, not `Ref::Existing`). - async fn commit_authoring_run( - &self, - turn: &mut PlayTurn, - round: usize, - run: &[(usize, String, String, String)], - snapshot: &WorldSnapshot, - ) -> Result, PlayError> { - let anchor_slot = run[0].0; - let anchor_call_id = run[0].1.clone(); - let recorded = turn.call_record(round, anchor_slot).and_then(|call| call.body.clone()); - let (patch, batch) = match recorded { - Some(RecordedCall::Authoring(patch)) => (patch, None), - _ => { - let calls: Vec<(&str, &str)> = - run.iter().map(|(_, _, name, arguments)| (name.as_str(), arguments.as_str())).collect(); - let decoded = match decode_authoring_calls(&calls) { - Ok(decoded) => decoded, - Err(detail) => return Ok(refuse_run(run, format!("refused: {detail}"))), - }; - if decoded.patch.sets_persona_of_existing_subject() { - return Ok(refuse_run( - run, - "refused: set_persona_material may not target an existing subject; \ - only a subject this same run declares" - .to_owned(), - )); - } - record_call( - turn, - &anchor_call_id, - round, - anchor_slot, - Some(RecordedCall::Authoring(decoded.patch.clone())), - None, - ); - self.persist(turn).await?; - let patch = decoded.patch.clone(); - (patch, Some(decoded)) - } - }; - let command_id = derived_command_id(&turn.turn_id, round, anchor_slot); - self.fire_before_submit_hook().await; - let (outcome, result) = match self.play.submit_patch(command_id, patch).await { - Ok(_receipt) => (RoundOutcome::Continue, "applied".to_owned()), - Err(MailboxError::Kernel(KernelError::Invariant(detail))) => (RoundOutcome::Poisoned, detail), - Err(MailboxError::Kernel(kernel_error @ KernelError::PatchRejected(_))) => { - // PA.f54: the batch's own site map names the exact call a - // mismatch belongs to, when one decoded this call (a resumed - // resubmission has no fresh `DecodedBatch` and falls back to - // `describe_refusal`'s own raw-index wording). - let text = describe_refusal(snapshot, None, None, batch.as_ref(), &kernel_error); - (RoundOutcome::Continue, format!("refused: {text}")) - } - Err(error) => (RoundOutcome::Continue, format!("refused: {error}")), - }; - Ok(run - .iter() - .map(|(slot, call_id, _, _)| (*slot, call_id.clone(), outcome, result.clone())) - .collect()) - } - - /// Persists `turn` to the store: the one durable-write door every - /// `execute_*`/`commit_*` method uses to record a call's body *before* - /// attempting its submission (PA-Q8), so a crash between the two leaves - /// the exact submitted body on disk for `execute_round` to resubmit on - /// resume. - async fn persist(&self, turn: &PlayTurn) -> Result<(), PlayError> { - self.store.lock().await.commit(turn.clone())?; - Ok(()) - } - - async fn execute_advance_time( - &self, - turn: &mut PlayTurn, - call_id: &str, - round: usize, - slot: usize, - arguments: &str, - ) -> Result<(RoundOutcome, String), PlayError> { - let recorded = turn.call_record(round, slot).and_then(|call| call.body.clone()); - let minutes = match recorded { - Some(RecordedCall::AdvanceTime(minutes)) => minutes, - _ => match parse_minutes(arguments) { - Some(minutes) => { - record_call(turn, call_id, round, slot, Some(RecordedCall::AdvanceTime(minutes)), None); - self.persist(turn).await?; - minutes - } - None => { - return Ok(( - RoundOutcome::Continue, - "refused: minutes must be a positive integer within bounds".to_owned(), - )); - } - }, - }; - let command_id = derived_command_id(&turn.turn_id, round, slot); - self.fire_before_submit_hook().await; - match self.play.advance_time(command_id, minutes).await { - Ok(_) => Ok((RoundOutcome::Continue, "the clock advanced".to_owned())), - Err(MailboxError::Kernel(KernelError::Invariant(detail))) => Ok((RoundOutcome::Poisoned, detail)), - Err(error) => Ok((RoundOutcome::Continue, format!("refused: {error}"))), - } - } - - async fn execute_actor_call( - &self, - turn: &mut PlayTurn, - call_id: &str, - round: usize, - slot: usize, - actor: Actor, - name: &str, - arguments: &str, - snapshot: &WorldSnapshot, - principal: &VerifiedPrincipalEvidence, - ) -> Result<(RoundOutcome, String), PlayError> { - let recorded = turn.call_record(round, slot).and_then(|call| call.body.clone()); - let (opportunity, invocation) = match recorded { - Some(RecordedCall::PlayerAct(opportunity, invocation)) => (opportunity, invocation), - Some(RecordedCall::PersonaAct(_, opportunity, invocation)) => (opportunity, invocation), - _ => { - // The real handle prefix `round_tools` handed `actor_tools` - // when it generated this actor's own tools, paired with the - // full, unstripped call `name`, so `decode_actor_call`'s own - // prefix strip is the one and only place that decides what - // the prefix removes. - let prefix = actor_prefix(actor.subject); - let (opportunity, invocation) = - match decode_actor_call(snapshot, actor.subject, &prefix, name, arguments) { - Ok(decoded) => decoded, - Err(detail) => { - return Ok((RoundOutcome::Continue, format!("refused: {detail}"))); - } - }; - if !span_is_exact(&invocation, &actor, turn) { - return Ok(( - RoundOutcome::Continue, - "refused: the quoted text is not an exact quote from the acting subject's own prose" - .to_owned(), - )); - } - let body = if actor.is_player { - RecordedCall::PlayerAct(opportunity.clone(), invocation.clone()) - } else { - RecordedCall::PersonaAct(actor.subject, opportunity.clone(), invocation.clone()) - }; - record_call(turn, call_id, round, slot, Some(body), None); - self.persist(turn).await?; - (opportunity, invocation) - } - }; - - let command_id = derived_command_id(&turn.turn_id, round, slot); - self.fire_before_submit_hook().await; - let submission = if actor.is_player { - self.world - .submit_principal( - PrincipalCommandIntent { - id: command_id, - world_id: opportunity.world_id, - expected_revision: 0, - body: CommandBody::ExerciseDecision { - opportunity: opportunity.clone(), - invocation: invocation.clone(), - }, - }, - principal, - ) - .await - } else { - ControllerPort::new(self.world.clone()) - .submit_controller(command_id, &opportunity, invocation.clone()) - .await - }; - - match submission { - Ok(_) => { - // PA.f144: `turn.refusal` names the *most recent* player - // act's own refusal (invariant 8's actor-facing line) — a - // player act that now commits clears whatever an earlier - // round's refusal left standing, so a round-1 refusal is not - // still shown once a later act of the same player's own - // succeeds. A Persona's own act never set it, so this clears - // nothing a Persona commit should not touch either way. - if actor.is_player { - turn.refusal = None; - } - Ok((RoundOutcome::Continue, "applied".to_owned())) - } - Err(MailboxError::Kernel(KernelError::ActionRejected(mismatches))) => { - let error = KernelError::ActionRejected(mismatches); - // The entry the invocation actually named: found on the live - // snapshot alone, never from anything the caller supplied, - // matching `subject_opportunity`'s own rule that a consumer's - // claimed affordance is never trusted back. - let entry = snapshot - .affordances - .iter() - .find(|candidate| candidate.id == invocation.affordance); - // The agent's own surface: full detail, for the play agent to - // reason with. - let text = describe_refusal(snapshot, entry, Some(&invocation), None, &error); - if actor.is_player { - // Invariant 8: the player sees only the actor form — - // `describe_refusal_to_actor` names bare role names, never - // an id, a resolved label, or a fact's statement. A - // missing `entry` (which decode_actor_call's own success - // should make impossible) falls back to the same bare - // "refused" `describe_refusal_to_actor` itself uses for - // every non-`ActionRejected` error, rather than leaking - // the agent-facing text. - let actor_text = match entry { - Some(entry) => describe_refusal_to_actor(entry, &error), - None => "refused".to_owned(), - }; - turn.refusal = Some(actor_text); - } - Ok((RoundOutcome::Continue, format!("refused: {text}"))) - } - Err(MailboxError::Kernel(KernelError::Invariant(detail))) => Ok((RoundOutcome::Poisoned, detail)), - Err(error) => Ok((RoundOutcome::Continue, format!("refused: {error}"))), - } - } - - async fn execute_dispatch( - &self, - turn: &mut PlayTurn, - round: usize, - call_slot: usize, - subjects: &[SubjectId], - ) -> Result<(RoundOutcome, String), PlayError> { - // PA.f125: snapshotted before this call's own subjects run, so a - // subject *this* call adds to `persona_turns` later in this same - // loop (the same subject named twice in one `dispatch` call, - // PA.f124) is never mistaken for a subject this call is *resuming* - // after a crash — only a subject already durable *before* this - // invocation began can be that. - let already_durable: Vec = turn.persona_turns.iter().map(|(id, _)| *id).collect(); - // Indexed by the subject's own position in `subjects` (not the order - // any of this resolves in): every subject gets exactly one summary - // line, and the joined summary must read in the call's own JSON - // order whether that subject's line was decided synchronously below - // or by a concurrently dispatched Persona turn later. - let mut summary_slots: Vec> = vec![None; subjects.len()]; - let finished_summary = |slots: Vec>| slots.into_iter().flatten().collect::>().join("; "); - - /// One subject this call still needs a fresh Persona turn for, - /// carrying everything `personas.turn` and its own summary line - /// need — captured now, at the same point the old sequential loop - /// captured them, so concurrent dispatch changes nothing about - /// *which* snapshot or opportunity a subject's turn runs against. - struct Pending { - index: usize, - subject: SubjectId, - command_id: CommandId, - opportunity: DecisionOpportunity, - snapshot: WorldSnapshot, - } - let mut pending: Vec = Vec::new(); - // PA.f124: the same subject named twice in *this* call's own subject - // list is a collision even though neither occurrence has landed in - // `turn.persona_turns` yet — concurrent dispatch means both - // occurrences are seen before either one's Persona turn has run, so - // `turn.persona_turns` alone can no longer tell them apart the way - // the old strictly-sequential loop could (a first occurrence used to - // land in `turn.persona_turns` before the second was even read). - // This is the within-call twin of that same check. - let mut claimed_this_call: Vec = Vec::new(); - - for (index, subject) in subjects.iter().enumerate() { - if let Some(prose) = turn - .persona_turns - .iter() - .find(|(id, _)| id == subject) - .map(|(_, persona_turn)| persona_turn.source_prose().to_owned()) - { - let resuming_this_call = - already_durable.contains(subject) && turn.dispatch_owner(*subject) == Some((round, call_slot)); - if resuming_this_call { - // PA.f125: this call's own body is already durable - // before any persona turn ran (`execute_round` records - // and persists it before this door is entered), so a - // crash between one subject's own persist and this - // call's own final result finds its own prose here on - // resume, rather than refusing it as "already acted" - // against a call that never actually completed. - let snapshot = match self.play.snapshot().await { - Ok(snapshot) => snapshot, - Err(error) => { - self.close_with_fault(turn, error.to_string()).await?; - return Ok((RoundOutcome::Closed, finished_summary(summary_slots))); - } - }; - let label = subject_label(&snapshot, *subject); - summary_slots[index] = Some(format!("{label}: {prose}")); - } else { - // PA.f124: printed through the library's own `id_text`, - // the same printer `table_view` and `dispatch`'s own - // matching use — not a fifth hand-spelled debug-paren - // strip. - summary_slots[index] = Some(format!("{} already acted", id_text(*subject))); - } - continue; - } - if claimed_this_call.contains(subject) { - // PA.f124: a repeat within this same call's own list, ahead - // of its own first occurrence's Persona turn actually - // resolving — the same "already acted" text the sequential - // loop gave a repeat once its first occurrence had already - // landed in `turn.persona_turns`. - summary_slots[index] = Some(format!("{} already acted", id_text(*subject))); - continue; - } - let snapshot = match self.play.snapshot().await { - Ok(snapshot) => snapshot, - Err(error) => { - // PA.f85/PA.f93: a snapshot failure mid-dispatch still - // closes the turn with the fault recorded, rather than - // leaving it `Running` behind a `?`. - self.close_with_fault(turn, error.to_string()).await?; - return Ok((RoundOutcome::Closed, finished_summary(summary_slots))); - } - }; - let Some(opportunity) = snapshot - .opportunities - .iter() - .find(|opportunity| { - opportunity.scope.subject_id == *subject - && opportunity.controller_mode == ControllerMode::NarrativePersona - }) - .cloned() - else { - summary_slots[index] = Some(format!("{} holds no live Persona opportunity", id_text(*subject))); - continue; - }; - // Derived from the dispatch call's own slot and the subject, not - // from the subject's position in this call's own JSON array - // (PA.f66d): two subjects named by one dispatch call, or the - // same subject named by two dispatch calls in one round, must - // never collide, in whatever order either call lists them. - let command_id = dispatch_command_id(&turn.turn_id, round, call_slot, *subject); - claimed_this_call.push(*subject); - pending.push(Pending { - index, - subject: *subject, - command_id, - opportunity, - snapshot, - }); - } - - // The round's own freshly dispatched Personas run concurrently - // (PA.f13, P8.5): each spawned task draws its own permit from the - // shared `controller_permits` pool before it runs `personas.turn`, - // so the pool's own size bounds how many run cognition at once, not - // this call's own subject count. Spawned, not merely joined, so - // Tokio actually schedules every pending subject's turn as soon as - // its permit is free rather than only interleaving at await points - // inside one task. - let mut handles = Vec::with_capacity(pending.len()); - for entry in &pending { - let personas = self.personas.clone(); - let permits = self.permits.clone(); - let command_id = entry.command_id; - let opportunity = entry.opportunity.clone(); - handles.push(tokio::spawn(async move { - let permit = permits - .acquire_owned() - .await - .expect("permit semaphore is never closed"); - let result = personas.turn(command_id, &opportunity).await; - drop(permit); - result - })); - } - let mut results = Vec::with_capacity(handles.len()); - for handle in handles { - results.push( - handle - .await - .expect("a dispatched Persona's own turn task never panics"), - ); - } - - for (entry, result) in pending.into_iter().zip(results) { - match result { - Ok(persona_turn) => { - let label = subject_label(&entry.snapshot, entry.subject); - // PA.f64: the dispatched Persona's own prose reaches the - // agent as this call's own tool result, not a bare - // "acted" acknowledgement — `rebuild_conversation` is - // already generic over every call's recorded `result`, - // so recording the prose here is the whole fix. - let prose = persona_turn.source_prose().to_owned(); - summary_slots[entry.index] = Some(format!("{label}: {prose}")); - turn.persona_turns.push((entry.subject, persona_turn)); - // PA.f123: `execute_dispatch` was the only call kind that - // did not persist at its own site — every other tool - // call's own door (`commit_authoring_run`, - // `execute_advance_time`, `execute_actor_call`) persists - // its own recorded body before submitting, and - // `execute_round`'s per-call loop persists after every - // *other* branch, but a dispatch call's own `continue` - // reached none of that until the round itself finished. - // A crash between two subjects in one dispatch call, or - // between this dispatch call and the round's own later - // persist, would re-infer this subject's Persona prose - // on resume, and an actor call already checked against - // the recorded prose would be checked against the new - // (possibly different) prose instead — persisting as - // soon as each subject's own prose is recorded closes - // that window. Persisted here, sequentially, after every - // concurrent turn has already resolved, so two subjects' - // own persists of the same turn row never race each - // other's `compare_and_swap_batch`. - self.persist(turn).await?; - self.fire_before_submit_hook().await; - } - Err(error) => { - let label = subject_label(&entry.snapshot, entry.subject); - summary_slots[entry.index] = Some(format!("{label} could not act: {error}")); - } - } - } - Ok((RoundOutcome::Continue, finished_summary(summary_slots))) - } - - /// Narrates the turn's own close to the player. Takes no snapshot of its - /// own (PA.f95: it was never read) — it always re-fetches its own fresh - /// snapshot below, since the round's own calls may have committed since - /// any snapshot a caller might have held. Cut 10 (PA.f159) added - /// `principal` back: `player_subject` now resolves the player by whose - /// principal is actually running this turn, not merely "the" human - /// subject in the snapshot. - async fn close_turn( - &self, - turn: &PlayTurn, - principal: &VerifiedPrincipalEvidence, - ) -> Result { - // A fresh snapshot, not the round's own: this round's calls may have - // committed since it was taken, and `PersonaLane::narrate` refuses - // an opportunity that is not the kernel's own currently issued copy. - let snapshot = self.play.snapshot().await?; - let Some(player) = player_subject(&snapshot, principal) else { - return Ok(String::new()); - }; - let Some(opportunity) = snapshot - .opportunities - .iter() - .find(|opportunity| { - opportunity.scope.subject_id == player.id - && opportunity.controller_mode == ControllerMode::Human - }) - .cloned() - else { - return Ok(String::new()); - }; - let command_id = derived_command_id(&turn.turn_id, turn.rounds.len(), usize::MAX); - // Narration is cognition over the same shared pool a dispatched - // Persona's own turn draws from (PA.f13, P8.5): the turn's own close - // must queue behind an exhausted pool exactly like a dispatch would, - // rather than running unmetered because it happens to be the one - // Persona-lane call outside `execute_dispatch`. - let permit = self - .permits - .clone() - .acquire_owned() - .await - .expect("permit semaphore is never closed"); - let narration = self.personas.narrate(command_id, &opportunity).await; - drop(permit); - Ok(narration?) - } -} - -struct Actor { - subject: SubjectId, - is_player: bool, -} - -fn resolve_handle(handle: &str, player: Option<&SubjectRow>, dispatched: &[SubjectId]) -> Option { - if let Some(player) = player - && handle_for(player.id) == handle - { - return Some(Actor { - subject: player.id, - is_player: true, - }); - } - for subject in dispatched { - if handle_for(*subject) == handle { - return Some(Actor { - subject: *subject, - is_player: false, - }); - } - } - None -} - -struct SubjectRow { - id: SubjectId, -} - -/// Cut 10 (PA.f159): the subject *this principal's own turn* acts as, not -/// merely "the" human-controlled subject in the world. `WorldSnapshot` -/// already carries `human_controller: Option` on every -/// `SubjectSnapshot` (the library's own field, no library change needed); -/// this was the one production reader still ignoring it, matching by -/// `controller_mode` alone — correct only by accident, in the one-human- -/// subject worlds every fixture happens to build, and wrong the moment a -/// world has more than one. -fn player_subject(snapshot: &WorldSnapshot, principal: &VerifiedPrincipalEvidence) -> Option { - let owner = ghostlight::PrincipalId::new(principal.account_subject_hash()); - snapshot - .subjects - .iter() - .find(|subject| { - subject.controller_mode == Some(ControllerMode::Human) - && subject.human_controller.as_ref() == Some(&owner) - }) - .map(|subject| SubjectRow { id: subject.id }) -} - -fn subject_label(snapshot: &WorldSnapshot, subject: SubjectId) -> String { - snapshot - .subjects - .iter() - .find(|row| row.id == subject) - .map_or_else(|| "an unknown subject".to_owned(), |row| row.label.clone()) -} - -/// A subject's own short handle: the first 8 hex digits of its id -/// (PA.f97). Actor tool names are `__`, and a kind may run up -/// to 48 chars, so the full 36-char uuid text a handle used to carry could -/// reach 86 chars against a common 64-char function-name limit; 8 hex -/// digits plus the `__` separator keeps every name well under that limit -/// (`the_longest_possible_actor_tool_name_is_64_chars_or_fewer`) while still -/// reading as the same bracketed id prefix `table_view` prints. The id text -/// itself comes from the library's own `id_text` (PA.f113/PA.f129) — no -/// second, hand-written debug-paren strip here. -/// Collision between two subjects sharing a handle is astronomically -/// unlikely but not impossible; `round_tools` refuses the whole round as a -/// turn fault rather than silently addressing the wrong subject -/// (`find_handle_collision`). -fn handle_for(id: SubjectId) -> String { - id_text(id).chars().filter(char::is_ascii_hexdigit).take(8).collect() -} - -/// Appends one actor tool's own acting subject's label and full canonical id -/// to its description (PA.f98): `dispatch` takes the id exactly as -/// `table_view` prints it, never the short `__` prefix these tools' -/// own names carry, so every actor tool now names the id `dispatch` actually -/// expects, right where the agent reads it. `actor_tools` (`table.rs`) knows -/// nothing of a subject's label; annotating here, over the definitions it -/// already returned, is the smallest fix that does not teach the library -/// vocabulary a Dungeon-only presentation concern. The id itself comes from -/// the library's own `id_text` (PA.f113) — Dungeon no longer keeps its own -/// copy of the debug-paren strip `table_view` uses to print it. -fn annotate_actor_tools(tools: &mut [CodexToolDefinition], snapshot: &WorldSnapshot, subject: SubjectId) { - let label = subject_label(snapshot, subject); - let id = id_text(subject); - for tool in tools { - tool.description = format!("{} Acting subject: {label} [{id}].", tool.description); - } -} - -/// The first colliding handle among `handles`, if any (PA.f97): two entries -/// naming the *same* handle for the *same* subject is not a collision (a -/// subject may legitimately appear once as the player and, in principle, -/// once more via `dispatched`); two entries naming the same handle for -/// *different* subjects is. Pulled apart from `handle_for` itself so the -/// detection algorithm is directly testable without needing two real -/// subject ids that actually collide — vanishingly unlikely to construct -/// from genuine, randomly issued ids, and `SubjectId` mints nothing a -/// consumer crate can construct by hand. -fn find_handle_collision(handles: impl Iterator) -> Option { - let mut seen: std::collections::HashMap = std::collections::HashMap::new(); - for (handle, subject) in handles { - match seen.get(&handle) { - Some(existing) if *existing != subject => return Some(handle), - _ => { - seen.insert(handle, subject); - } - } - } - None -} - -/// The one collision-refusal rule `round_tools` (before inference offers -/// tools) and `execute_round` (resuming an already-decided round, which -/// never calls `round_tools` at all) both must enforce (PA.f97, PA.f105, -/// PA.f118): a handle collision anywhere in the snapshot's own subjects, -/// found by `find_handle_collision` over every subject the snapshot -/// carries, is a turn fault regardless of which subjects this round happens -/// to offer tools for. One shared owner so the two sites cannot drift the -/// way a verbatim copy invites. -fn collision_refusal(snapshot: &WorldSnapshot) -> Option { - find_handle_collision(snapshot.subjects.iter().map(|row| (handle_for(row.id), row.id))).map(|handle| { - format!("two subjects share the handle `{handle}`; the play table cannot address them safely") - }) -} - -/// The tool-name prefix one subject's own actor tools carry: exactly what -/// `round_tools` hands `actor_tools` to generate them, and what -/// `execute_actor_call` hands `decode_actor_call` to parse them back — one -/// prefix, shared, so generation and decode can never drift apart. -fn actor_prefix(subject: SubjectId) -> String { - format!("{}{HANDLE_SEPARATOR}", handle_for(subject)) -} - -fn control_tools() -> Vec { - vec![ - CodexToolDefinition { - name: DISPATCH_TOOL.into(), - description: "Dispatch one or more subjects to act this round, each named by its full id \ -exactly as table_view prints it in brackets — never the short __ prefix an actor tool's own \ -name carries. An id naming no dispatchable subject is refused, quoting the text given." - .into(), - parameters_json: r#"{"type":"object","properties":{"subjects":{"type":"array","items":{"type":"string"}}},"required":["subjects"]}"#.into(), - }, - CodexToolDefinition { - name: ADVANCE_TIME_TOOL.into(), - description: "Advance the world clock by a whole number of fictional minutes.".into(), - parameters_json: r#"{"type":"object","properties":{"minutes":{"type":"integer","minimum":1}},"required":["minutes"]}"#.into(), - }, - CodexToolDefinition { - name: ASK_PLAYER_TOOL.into(), - description: "Stop the turn and ask the player a question.".into(), - parameters_json: r#"{"type":"object","properties":{"question":{"type":"string"}},"required":["question"]}"#.into(), - }, - CodexToolDefinition { - name: END_TURN_TOOL.into(), - description: "End the turn: its consequences are settled.".into(), - parameters_json: r#"{"type":"object","properties":{},"required":[]}"#.into(), - }, - ] -} - -/// Resolves `dispatch`'s own `subjects` argument (PA.f98, PA.f114): each -/// entry is the *full* id `table_view` prints in brackets, matched through -/// the library's own `id_text_matches` — never the short `__` -/// actor-tool prefix, which exists only to keep a generated tool name under a -/// common function-name length limit and was never meant to be an id a caller -/// supplies back. -/// -/// `PLAY_INSTRUCTIONS` promises dispatch refuses bad input rather than -/// silently doing nothing with it, so every shape of bad input returns a -/// refusal in `refusals` that quotes what was given, instead of being -/// dropped: a body that is not valid JSON, a body that is not a JSON object, -/// a missing `subjects` key, a `subjects` that is not an array, a non-string -/// entry, and an entry naming no subject in `snapshot`. -fn parse_dispatch(arguments: &str, snapshot: &WorldSnapshot) -> (Vec, Vec) { - let value = match serde_json::from_str::(arguments) { - Ok(value) => value, - Err(_) => { - return ( - Vec::new(), - vec![format!("dispatch's arguments are not valid JSON: `{arguments}`")], - ); - } - }; - let Value::Object(fields) = value else { - return ( - Vec::new(), - vec![format!("dispatch's arguments are not a JSON object: `{arguments}`")], - ); - }; - let Some(subjects_value) = fields.get("subjects") else { - return ( - Vec::new(), - vec![format!("dispatch's arguments have no `subjects` key: `{arguments}`")], - ); - }; - let Value::Array(items) = subjects_value else { - return ( - Vec::new(), - vec![format!("dispatch's `subjects` is not an array: `{subjects_value}`")], - ); - }; - let mut subjects = Vec::new(); - let mut refusals = Vec::new(); - for item in items { - match item.as_str() { - Some(text) => match snapshot.subjects.iter().find(|subject| id_text_matches(subject.id, text)) { - Some(subject) => subjects.push(subject.id), - None => refusals.push(format!("`{text}` names no dispatchable subject")), - }, - None => refusals.push(format!("`{item}` in `subjects` is not a string")), - } - } - (subjects, refusals) -} - -fn parse_ask_player(arguments: &str) -> String { - serde_json::from_str::(arguments) - .ok() - .and_then(|value| value.get("question").and_then(Value::as_str).map(str::to_owned)) - .unwrap_or_default() -} - -fn parse_minutes(arguments: &str) -> Option { - let value = serde_json::from_str::(arguments).ok()?; - let minutes = value.get("minutes")?.as_u64()?; - TickMinutes::new(u32::try_from(minutes).ok()?) -} - -/// The derived command id for one dispatched Persona's own turn (PA.f66d): -/// hashed from the dispatch tool call's own round and slot plus the -/// subject's own handle, never from the subject's position in that call's -/// own JSON `subjects` array. Two subjects one dispatch call names, or the -/// same subject two dispatch calls in one round name, must never collide — -/// the old `dispatch_slot` collided exactly there, since it read only a -/// position within one call's own list and dropped the call's own identity -/// entirely. -fn dispatch_command_id(turn_id: &str, round: usize, call_slot: usize, subject: SubjectId) -> CommandId { - dispatch_command_id_for_handle(turn_id, round, call_slot, &handle_for(subject)) -} - -/// `dispatch_command_id`'s own hash, taking a bare handle so it is directly -/// testable without minting a `SubjectId` (Dungeon holds no public -/// constructor for one). -fn dispatch_command_id_for_handle(turn_id: &str, round: usize, call_slot: usize, handle: &str) -> CommandId { - let mut hasher = Sha256::new(); - hasher.update(b"ghostlight.dungeon.play.dispatch.v1\n"); - hasher.update(turn_id.as_bytes()); - hasher.update(b"\n"); - hasher.update(round.to_le_bytes()); - hasher.update(call_slot.to_le_bytes()); - hasher.update(b"\n"); - hasher.update(handle.as_bytes()); - let digest = hasher.finalize(); - let uuid = uuid::Uuid::from_slice(&digest[..16]).expect("sha256 digest is at least 16 bytes"); - CommandId::parse_uuid(&uuid.to_string()).expect("a formatted uuid always parses") -} - -/// `PlayTable::retry_delay`'s own production base, in milliseconds -/// (PA.f122): the default `retry_delay_base_ms` starts at, and the value -/// `backoff_delay`'s own doc-pinned formula (`backoff_delay_grows_then_caps`) -/// still assumes. -const RETRY_DELAY_BASE_MS: u64 = 100; - -/// `PlayTable::retry_delay`'s own capped exponential backoff formula -/// (PA.f102, PA.f122): `base_ms * 2^attempt` milliseconds, capped at `CAP`, -/// with `attempt` clamped before the shift so a very large attempt count can -/// never overflow or panic. A free function, always compiled — never hidden -/// behind `#[cfg(test)]` — so it is directly unit-testable in every build, -/// and `retry_delay` always calls exactly this, over `base_ms` alone, -/// whether that base is `RETRY_DELAY_BASE_MS` (production) or whatever a -/// test set it to. -fn backoff_delay(base_ms: u64, attempt: usize) -> Duration { - const CAP: u64 = 20_000; - Duration::from_millis(base_ms.saturating_mul(1u64 << attempt.min(8)).min(CAP)) -} - -/// A round is incomplete when it holds a tool call this table has not yet -/// resolved to a result. -fn round_incomplete(turn: &PlayTurn, round: usize) -> bool { - let Some(output) = turn.rounds.get(round) else { - return false; - }; - let mut slot = 0usize; - for event in output.events() { - if !matches!(event, InferenceEvent::ToolCall { .. }) { - continue; - } - let this_slot = slot; - slot += 1; - if !turn - .call_record(round, this_slot) - .is_some_and(|call| call.result.is_some()) - { - return true; - } - } - false -} - -fn find_ask_player_slot(turn: &PlayTurn, round: usize) -> Option { - turn.calls - .iter() - .find(|call| { - call.round == round && matches!(call.body, Some(RecordedCall::AskPlayer(_))) && call.result.is_none() - }) - .map(|call| call.slot) -} - -/// One refusal, attributed to every call in a run (PA.f54): the run commits -/// atomically, so a decode-time refusal — before any patch is even -/// assembled — is every one of the run's calls own result alike, not just -/// the anchor's. -fn refuse_run(run: &[(usize, String, String, String)], detail: String) -> Vec<(usize, String, RoundOutcome, String)> { - run.iter() - .map(|(slot, call_id, _, _)| (*slot, call_id.clone(), RoundOutcome::Continue, detail.clone())) - .collect() -} - -fn record_call( - turn: &mut PlayTurn, - call_id: &str, - round: usize, - slot: usize, - body: Option, - result: Option, -) { - if let Some(existing) = turn.call_record_mut(round, slot) { - if body.is_some() { - existing.body = body; - } - if result.is_some() { - existing.result = result; - } - return; - } - turn.calls.push(CallRecord { - call_id: call_id.to_owned(), - round, - slot, - body, - result, - }); -} - -/// The connector posture: `[UserText(opening_prompt), ...]` replaying every -/// fully-resolved round before `round` as `AssistantText`/`ToolCall`/`ToolResult` -/// items, exactly as `controllers.rs`'s own multi-round lanes rebuild their -/// conversation from `completed` outputs. -fn rebuild_conversation(turn: &PlayTurn, round: usize) -> Vec { - let mut conversation = vec![CodexInputItem::UserText { - text: turn.opening_prompt.clone(), - }]; - for (index, output) in turn.rounds.iter().enumerate() { - if index >= round { - break; - } - let mut slot = 0usize; - for event in output.events() { - match event { - InferenceEvent::Text(text) => { - if !text.is_empty() { - conversation.push(CodexInputItem::AssistantText { text: text.clone() }); - } - } - InferenceEvent::ToolCall { - call_id, - name, - arguments, - } => { - let this_slot = slot; - slot += 1; - conversation.push(CodexInputItem::ToolCall { - call_id: call_id.clone(), - name: name.clone(), - arguments: arguments.clone(), - }); - let output_text = turn - .call_record(index, this_slot) - .and_then(|call| call.result.clone()) - .unwrap_or_default(); - conversation.push(CodexInputItem::ToolResult { - call_id: call_id.clone(), - output: output_text, - }); - } - } - } - } - conversation -} - -/// Whether an actor call's `text`/`display` are exact quotes from the -/// acting subject's own preserved prose: the player's submitted texts, or a -/// dispatched Persona's own recorded turn (PA-Q8 invariant 4). A player's -/// display need not be spoken; an empty invocation (neither) is exact by -/// vacuity. -fn span_is_exact(invocation: &DecisionInvocation, actor: &Actor, turn: &PlayTurn) -> bool { - let sources: Vec<&str> = if actor.is_player { - turn.player_prose.iter().map(String::as_str).collect() - } else { - turn.persona_turns - .iter() - .filter(|(id, _)| *id == actor.subject) - .map(|(_, persona_turn)| persona_turn.source_prose()) - .collect() - }; - let quotes = [ - invocation.speech.as_ref().map(Statement::as_str), - invocation.display.as_ref().map(Statement::as_str), - ]; - for quote in quotes.into_iter().flatten() { - if !sources.iter().any(|source| SourceSpan::locate(source, quote).is_some()) { - return false; - } - } - true -} - -/// sha256 over the turn id, round, and slot; the first 16 bytes read back as -/// a UUID. Deterministic over `(turn_id, round, slot)`, so a resumed call -/// re-derives the identical command id the kernel's idempotency ledger -/// already knows (`AlreadyApplied`), never a fresh one. -fn derived_command_id(turn_id: &str, round: usize, slot: usize) -> CommandId { - let mut hasher = Sha256::new(); - hasher.update(b"ghostlight.dungeon.play.command.v1\n"); - hasher.update(turn_id.as_bytes()); - hasher.update(b"\n"); - hasher.update(round.to_le_bytes()); - hasher.update(slot.to_le_bytes()); - let digest = hasher.finalize(); - let uuid = uuid::Uuid::from_slice(&digest[..16]).expect("sha256 digest is at least 16 bytes"); - CommandId::parse_uuid(&uuid.to_string()).expect("a formatted uuid always parses") -} - -#[cfg(test)] -pub(crate) mod tests { - use super::*; - use ghostlight::{ - AffordanceId, CreateWorldIntent, InferenceFault, Lens, LensWeights, PreparedInference, - decode_authoring_call, - }; - use ghostlight_persona_projection::PersonaTurnBinding; - use std::{ - collections::{BTreeMap, VecDeque}, - sync::Mutex as StdMutex, - }; - use tokio::task::JoinHandle; - - /// PA.f186's own cross-module seam: `runtime.rs`'s readiness tests need - /// to prove a retirement during `PlayTable::new` reaches `/health` - /// end-to-end, but `STORE_TYPE`/`STORE_KEY`/`STORE_SCHEMA` and - /// `CultCacheEnvelope` are this module's own store internals. Rather than - /// re-importing `cultcache_rs` and duplicating this shape in - /// `runtime.rs`, this writes one row `PlayTurnStore::open` cannot read — - /// the exact same undecodable-bytes shape - /// `a_row_with_the_current_schema_id_and_undecodable_bytes_is_retired_to_a_sidecar_and_play_starts_fresh` - /// seeds below — at `store_path`, before a caller opens a `PlayTable` - /// over it. - pub(crate) fn seed_unreadable_row_for_test(store_path: &std::path::Path) { - let corrupt_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: vec![0xC1], // msgpack's own reserved "never used" marker byte - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }; - let raw_store = OwnedRedbMessagePackBackingStore::new(store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![corrupt_row]).unwrap(), - "seeding the corrupt row must succeed against an empty store" - ); - } - - /// Every `.rs` file under `src`, with everything a `#[cfg(test)]` item - /// removed, the same extent-by-indent scan `app_session.rs`'s own - /// forbidden-writer test uses. - fn production_sources() -> Vec<(std::path::PathBuf, String)> { - fn indent(line: &str) -> usize { - line.len() - line.trim_start().len() - } - fn strip_cfg_test(text: &str) -> String { - let lines: Vec<&str> = text.lines().collect(); - let mut kept: Vec<&str> = Vec::new(); - let mut index = 0; - while index < lines.len() { - let line = lines[index]; - if line.trim() != "#[cfg(test)]" { - kept.push(line); - index += 1; - continue; - } - let guard = indent(line); - index += 1; - let mut opened = lines - .get(index) - .is_some_and(|next| next.trim_end().ends_with('{')); - while index < lines.len() { - let body = lines[index]; - index += 1; - let trimmed = body.trim(); - if trimmed.is_empty() { - continue; - } - if opened { - if indent(body) == guard && (trimmed == "}" || trimmed == "};" || trimmed == "},") { - break; - } - } else if indent(body) == guard && (trimmed.ends_with(';') || trimmed.ends_with(',')) { - break; - } else if indent(body) == guard && trimmed.ends_with('{') { - opened = true; - } - } - } - kept.join("\n") - } - let mut sources = Vec::new(); - let mut pending = vec![std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src")]; - while let Some(directory) = pending.pop() { - for entry in std::fs::read_dir(&directory).expect("the source tree reads") { - let path = entry.expect("a readable entry").path(); - if path.is_dir() { - pending.push(path); - continue; - } - if path.extension().and_then(|value| value.to_str()) != Some("rs") { - continue; - } - let text = std::fs::read_to_string(&path) - .expect("the source reads") - .replace("\r\n", "\n"); - sources.push((path, strip_cfg_test(&text))); - } - } - sources - } - - /// Pin: Dungeon never builds a `ComponentOp`, `Declaration`, `RoleBinding`, - /// or `ProposedEffect` by hand; everything goes through `table.rs`. - #[test] - fn soul_no_forbidden_writer_name_appears_in_dungeons_own_source() { - for forbidden in ["ComponentOp", "Declaration", "RoleBinding", "ProposedEffect"] { - for (path, source) in production_sources() { - assert!( - !source.contains(forbidden), - "{} names `{forbidden}` directly; Dungeon must compose through table.rs", - path.display() - ); - } - } - } - - /// Pin: `PlayPort`'s constructor is minted in exactly one production - /// site, matching the library's own doc comment on `PlayPort`. The - /// needle is assembled from halves so this assertion's own source, and - /// this file's doc comments about the rule, are never themselves a - /// match — the same precedent `app_session.rs`'s own single-minter test - /// uses for `VerifiedPrincipalEvidence`. - #[test] - fn soul_exactly_one_production_site_mints_play_port() { - let needle = format!("{}::{}(", "PlayPort", "new"); - let mut sites = Vec::new(); - for (path, source) in production_sources() { - for _ in source.match_indices(needle.as_str()) { - sites.push(path.clone()); - } - } - assert_eq!(sites.len(), 1, "the play port constructor appears in: {sites:?}"); - assert!(sites[0].ends_with("play.rs")); - } - - /// Rule: `PLAY_TOOLS` is a real subset of the library's own catalog. - #[test] - fn play_tools_are_all_real_authoring_tools() { - authoring_tools(PLAY_TOOLS).expect("every PLAY_TOOLS name is a known authoring tool"); - assert!(!PLAY_TOOLS.contains(&"admit"), "admit is not a play tool"); - } - - pub(crate) fn test_turn_id(n: u128) -> String { - uuid::Uuid::from_u128(n).to_string() - } - - fn far_future() -> chrono::DateTime { - Utc::now() + chrono::Duration::hours(4) - } - - /// PA.f161: widened to `pub(crate)` (test-only, behind `#[cfg(test)]`) - /// so `runtime.rs`'s own test module can script a deterministic - /// `ask_player` question through the real HTTP route — the only way to - /// exercise `world.play`'s own staleness comparison against a question - /// that actually opened, rather than one hand-built. - pub(crate) struct ScriptedPort { - queue: StdMutex>>, - seen: StdMutex>, - } - - impl ScriptedPort { - pub(crate) fn new(outputs: Vec) -> Arc { - Arc::new(Self { - queue: StdMutex::new(outputs.into_iter().map(Ok).collect()), - seen: StdMutex::new(Vec::new()), - }) - } - - fn seen_requests(&self) -> Vec { - self.seen.lock().unwrap().clone() - } - } - - #[async_trait::async_trait] - impl InferencePort for ScriptedPort { - fn prepare(&self, request: InferenceRequest) -> Result { - self.seen.lock().unwrap().push(request.clone()); - PreparedInference::prepare("play-test-runtime", far_future_unix_ms(), request) - } - - async fn infer(&self, _request: PreparedInference) -> Result { - match self.queue.lock().unwrap().pop_front() { - Some(Ok(output)) => Ok(output), - Some(Err(detail)) => Err(InferenceFault::recovery_required(detail)), - None => Err(InferenceFault::recovery_required("scripted port exhausted")), - } - } - } - - fn far_future_unix_ms() -> u64 { - u64::try_from(far_future().timestamp_millis()).unwrap_or(u64::MAX) - } - - /// PA.f142's own test seam: an `InferencePort` whose `infer` blocks until - /// the test explicitly releases it, so a test can drive a `run` call to - /// a *known* point deep inside `infer_round` — genuinely holding - /// `run_lock` — before firing a second, racing call. Deterministic, - /// unlike relying on `tokio::join!`'s own scheduling to make a race land - /// a particular way. - pub(crate) struct GatedPort { - ready: tokio::sync::Notify, - release: tokio::sync::Notify, - output: StdMutex>, - } - - impl GatedPort { - pub(crate) fn new(output: InferenceOutput) -> Arc { - Arc::new(Self { - ready: tokio::sync::Notify::new(), - release: tokio::sync::Notify::new(), - output: StdMutex::new(Some(output)), - }) - } - - /// Resolves once `infer` has been entered (and is itself now - /// waiting on `release`). - async fn wait_ready(&self) { - self.ready.notified().await; - } - - /// Lets a blocked `infer` call proceed. - fn release(&self) { - self.release.notify_one(); - } - } - - #[async_trait::async_trait] - impl InferencePort for GatedPort { - fn prepare(&self, request: InferenceRequest) -> Result { - PreparedInference::prepare("play-test-runtime", far_future_unix_ms(), request) - } - - async fn infer(&self, _request: PreparedInference) -> Result { - self.ready.notify_one(); - self.release.notified().await; - Ok(self - .output - .lock() - .unwrap() - .take() - .expect("GatedPort's own infer is called exactly once in these tests")) - } - } - - pub(crate) fn output(receipt: &str, events: Vec) -> InferenceOutput { - InferenceOutput::new(events, receipt.to_owned()) - } - - pub(crate) fn text_event(text: &str) -> InferenceEvent { - InferenceEvent::Text(text.to_owned()) - } - - pub(crate) fn call_event(call_id: &str, name: &str, arguments: serde_json::Value) -> InferenceEvent { - InferenceEvent::ToolCall { - call_id: call_id.to_owned(), - name: name.to_owned(), - arguments: arguments.to_string(), - } - } - - struct WorldFixture { - _directory: tempfile::TempDir, - world: WorldMailbox, - principal: VerifiedPrincipalEvidence, - /// The mailbox's own background owner task (PA.f118): kept, not - /// discarded, so a test that needs `self.play.snapshot()` (or any - /// other mailbox call) to fail deterministically can `.abort()` it — - /// aborting drops the task's own channel receiver, so every request - /// already queued or sent afterward resolves `MailboxError::Unavailable` - /// rather than hanging or silently succeeding. Unused by a test that - /// never aborts it, which is most of them. - owner: JoinHandle<()>, - } - - /// A genesis world, activated: one Human "first-person" subject and, - /// when `persona_label` is given, one NarrativePersona subject, both - /// standing in the commons with the kernel's own `speak` grant. Built - /// entirely through `WorldMailbox`'s public door — the same one Cut 8's - /// own play agent uses — so no test fixture reaches into the kernel's - /// private typed vocabulary. - async fn play_world(persona_label: Option<&str>, account_hash: &str) -> WorldFixture { - let directory = tempfile::tempdir().unwrap(); - let (world, owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); - let principal = VerifiedPrincipalEvidence::new(account_hash, far_future()); - let receipt = world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Fixture World".into(), - brief: "A small table for Cut 8a's own tests.".into(), - human_subject_label: "Player".into(), - narrative_persona_label: persona_label.map(str::to_owned), - operational_agent_label: None, - targets: BTreeMap::new(), - jurisdictions: Vec::new(), - lens_weights: LensWeights::new(BTreeMap::from([(Lens::Hearth, 1)])), - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ActivateWorld, - }, - &principal, - ) - .await - .unwrap(); - WorldFixture { - _directory: directory, - world, - principal, - owner, - } - } - - /// PA.f159: `player_subject` must resolve the acting subject by *whose* - /// principal is calling, not merely by finding a `ControllerMode::Human` - /// subject at all. A mutation reverting the added - /// `human_controller.as_ref() == Some(&owner)` clause — leaving only the - /// old `controller_mode == Some(Human)` check — would still find the - /// fixture's own human subject here even though the calling principal is - /// a stranger to this world entirely, so this fails under exactly that - /// mutation. - #[tokio::test] - async fn the_player_subject_follows_the_calling_principal() { - let fixture = play_world(None, "the-real-owner").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - assert!( - player_subject(&snapshot, &fixture.principal).is_some(), - "the world's own owner/player principal must resolve to the human subject" - ); - let stranger = VerifiedPrincipalEvidence::new("a-stranger-principal", far_future()); - assert!( - player_subject(&snapshot, &stranger).is_none(), - "a principal that does not control any subject here must resolve to none, even though a \ - ControllerMode::Human subject exists in this world" - ); - } - - /// Test fixtures build exactly one human-controlled subject, so this - /// stays the older, principal-blind lookup deliberately (Cut 10, - /// PA.f159) rather than threading a principal through the 20-odd call - /// sites below: it is testing "which subject is the fixture's own - /// player", not the production selection rule `player_subject` now - /// enforces (that rule has its own direct coverage in - /// `the_player_subject_follows_the_calling_principal` below). - fn player_id(snapshot: &WorldSnapshot) -> SubjectId { - snapshot - .subjects - .iter() - .find(|subject| subject.controller_mode == Some(ControllerMode::Human)) - .expect("a play fixture's own single human subject") - .id - } - - fn persona_id(snapshot: &WorldSnapshot) -> SubjectId { - snapshot - .subjects - .iter() - .find(|subject| subject.controller_mode == Some(ControllerMode::NarrativePersona)) - .unwrap() - .id - } - - /// A subject's own full canonical id text, read out of a *real* - /// `table_view` render rather than a test-local copy of its printer - /// (PA.f113): a change to what `table_view` actually prints must break - /// every test that reads an id through this helper, which a fourth - /// hand-spelled debug-paren strip could not do — that copy passed every - /// dispatch test while `table_view`'s own print regressed and every real - /// dispatch an agent could make was refused. Distinct from `handle_for`'s - /// short 8-hex-digit actor handle (PA.f97). A `Ref::Existing` JSON value - /// needs the real id; only a `__` tool name needs the - /// truncated handle. - fn subject_id_text(snapshot: &WorldSnapshot, id: SubjectId) -> String { - let label = snapshot - .subjects - .iter() - .find(|subject| subject.id == id) - .map_or_else(|| "an unknown subject".to_owned(), |subject| subject.label.clone()); - let view = table_view(snapshot); - // The `Places:` section prints each place's occupants by the same - // "label [id]" shape (PA.f74) before the `Subjects:` section proper - // renders the subject's own canonical entry — the entry - // `PLAY_INSTRUCTIONS` and `dispatch` actually mean. Search only from - // the `Subjects:` header so a subject that also occupies a place - // does not read its id back out of the wrong section. - let subjects_section = view - .find("\n Subjects:") - .map_or(view.as_str(), |at| &view[at..]); - let marker = format!("{label} ["); - let start = subjects_section - .find(&marker) - .unwrap_or_else(|| panic!("{label} not found in table_view's Subjects section: {view}")) - + marker.len(); - let end = subjects_section[start..] - .find(']') - .unwrap_or_else(|| panic!("unterminated id bracket after {label} in table_view")); - subjects_section[start..start + end].to_owned() - } - - #[tokio::test] - async fn the_turn_closes_with_the_players_narration() { - let fixture = play_world(None, "player-closes").await; - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - // The player's own narration draws one Projector inference. - let projector = output("proj-0", vec![text_event("The room settles.")]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![projector]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(1), "I look around.".into()) - .await - .unwrap(); - - // PA.f69: the narration text itself, not merely its presence. - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.narration.as_deref(), Some("The room settles.")); - } - - /// Walks a rendered Eve surface for the first node whose own `id` matches - /// — the same small recursive search `eve.rs`'s own tests use, copied - /// rather than shared because it is test-local on both sides. - fn find_surface_node<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node["id"] == id { - return Some(node); - } - node.as_object()? - .values() - .flat_map(|value| match value { - Value::Array(items) => items.iter().collect::>(), - other => vec![other], - }) - .find_map(|child| find_surface_node(child, id)) - } - - /// Cut 9's own card test: the player's own already-committed speech is - /// durable in the world owner's own unscoped operator log — the - /// operator's surface, per that library method's own doc comment, which - /// "no subject-facing lane may reach" — while the turn's own narration is - /// a *different*, player-facing channel the play table produces. - /// `eve::authenticated_surface`'s play card must show the narration and - /// never the operator feed's own text, proving invariant 8's "nothing - /// else": the player sees a projection, the question, and the refusal of - /// their own act, never an unscoped event log. - /// - /// Mutation M9.1 named "render that feed into the card" as the mutant - /// this test kills. It is retired, not re-aimed (Cut 10, PA.f155): Cut 9 - /// had already deleted the operator-feed parameter `authenticated_surface` - /// once took, so a mutation that "renders the feed into the card" cannot - /// be expressed as a code change against the current signature — there is - /// no parameter left to wire in. The test's own assertions below (the - /// card renders narration/question/refusal and nothing else) remain a - /// live defense of invariant 8 against whatever *can* still leak — most - /// concretely, a future call site that starts threading operator-log text - /// through `narration`/`refusal` themselves. - #[tokio::test] - async fn the_play_surface_shows_only_the_projection() { - let fixture = play_world(None, "player-projection-only").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let opportunity = snapshot - .opportunities - .iter() - .find(|opportunity| opportunity.scope.subject_id == player) - .cloned() - .expect("the player's own speak opportunity"); - let affordance_id = snapshot - .affordances - .iter() - .find(|entry| { - entry.entry.kind.0 == "speak" - && snapshot - .subjects - .iter() - .any(|subject| subject.id == player && subject.affordances.contains(&entry.id)) - }) - .map(|entry| entry.id) - .expect("the player's own speak affordance"); - fixture - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: snapshot.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ExerciseDecision { - opportunity, - invocation: DecisionInvocation { - affordance: affordance_id, - bindings: Vec::new(), - proposed: Vec::new(), - speech: Some( - Statement::new("A voice the play table never narrated.".to_owned()) - .unwrap(), - ), - display: None, - }, - }, - }, - &fixture.principal, - ) - .await - .unwrap(); - let durable_log = fixture.world.operator_log().await.unwrap(); - assert_eq!(durable_log.len(), 1, "the committed speech must be durable in the world owner's own feed"); - - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The room settles into quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(90), "I look around.".into()) - .await - .unwrap(); - let view = table.current_turn_view().await.unwrap(); - assert_eq!(view.narration.as_deref(), Some("The room settles into quiet.")); - - let world = fixture.world.snapshot().await.unwrap(); - // `authenticated_surface` takes no operator feed at all any more - // (Cut 9's own deletion commit removed that parameter along with the - // card that once read it) — the leaking text above is durable and - // real, and this call has structurally no way to reach it. - let surface = - crate::eve::authenticated_surface("player-projection-only", Some(&world), Some(&view), true) - .unwrap(); - let play_card = find_surface_node(&surface, "world.play.card").expect("the play card"); - let play_card_encoded = serde_json::to_string(play_card).unwrap(); - assert!( - play_card_encoded.contains("The room settles into quiet."), - "the card must show the turn's own narration: {play_card_encoded}" - ); - assert!( - !play_card_encoded.contains("A voice the play table never narrated."), - "the play card must never render the operator log's own speech, even though it is durably \ - committed to the world owner's own unscoped operator log: {play_card_encoded}" - ); - // Every other `world.play.*` node too — the text control and the - // and the button — not only the card itself. - for id in ["world.play.answers", "world.play.text", "world.play"] { - if let Some(node) = find_surface_node(&surface, id) { - let encoded = serde_json::to_string(node).unwrap(); - assert!( - !encoded.contains("A voice the play table never narrated."), - "{id} must never carry the operator log's own speech: {encoded}" - ); - } - } - } - - /// PA.f193-D ("the interrupted turn"; renumbered from the PA.f188 - /// duplicate this comment used to carry — PA.f188 elsewhere in this file - /// names only `open_question_of`'s own construction-site token - /// coverage): a turn a process died mid-round leaves `Running` forever — - /// the store's own single-row shape never resumes it on its own, and the - /// admit arm at `run`'s own `Some(existing) if existing.state == - /// PlayTurnState::Running` match continues it only on an empty-text - /// submit (non-empty text is refused as `TurnStillRunning`). Before this - /// fix the card showed nothing at all in that state — no narration, no - /// question, no refusal, since none of those get set on a turn that - /// never reached a close, a question, or a refused act since it last - /// opened — so a player had no way to learn the empty-submit continue - /// exists. The card now names it explicitly while `Running` and nothing - /// is actively working on the turn (`run_in_progress` is false — see - /// `the_running_hint_does_not_appear_while_a_round_is_actually_in_flight`), - /// and says nothing extra once the turn is `AwaitingPlayer` or `Closed` - /// again. - /// - /// Mutation: delete the `if play.is_some_and(|view| view.state == - /// PlayTurnState::Running && !view.run_in_progress) { ... }` block from - /// `eve.rs`'s play-card construction — the `running_hint` lookup below - /// then finds nothing and the first assertion fails. - #[tokio::test] - async fn the_play_card_names_the_empty_submit_continue_while_the_turn_is_running() { - let fixture = play_world(None, "player-interrupted-turn").await; - let world = fixture.world.snapshot().await.unwrap(); - - let running_view = PlayTurnView { - turn_id: test_turn_id(9300), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-interrupted-turn", - Some(&world), - Some(&running_view), - true, - ) - .unwrap(); - let running_hint = find_surface_node(&surface, "world.play.running") - .expect("a Running turn nothing is working on must render a hint naming the empty-submit continue"); - let encoded = serde_json::to_string(running_hint).unwrap(); - assert!( - encoded.to_lowercase().contains("empty"), - "the hint must actually name the empty-submit continue, not just say something is \ - happening: {encoded}" - ); - - let awaiting_view = PlayTurnView { - turn_id: test_turn_id(9301), - state: PlayTurnState::AwaitingPlayer, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let awaiting_surface = crate::eve::authenticated_surface( - "player-interrupted-turn", - Some(&world), - Some(&awaiting_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&awaiting_surface, "world.play.running").is_none(), - "the running hint must not appear once the turn has left Running" - ); - } - - /// PA.f193-D: the hint the previous test proves for an idle `Running` - /// turn must not also render while something is actively working on that - /// turn — `admit`'s own `run_lock` is held, so `admit` refuses even an - /// empty continue with `TurnBusy` before it ever inspects the request, - /// directly contradicting a hint that told the player an empty submit - /// would be accepted. Soul measured this: the card rendered the hint for - /// every `Running` turn regardless of whether a round was genuinely in - /// flight. - /// - /// Mutation: change the play-card construction's gate back to `play - /// .is_some_and(|view| view.state == PlayTurnState::Running)` (drop the - /// `&& !view.run_in_progress`) — the assertion below then fails, because - /// the hint reappears for a turn something is actively running. - #[tokio::test] - async fn the_running_hint_does_not_appear_while_a_round_is_actually_in_flight() { - let fixture = play_world(None, "player-interrupted-turn-busy").await; - let world = fixture.world.snapshot().await.unwrap(); - - let busy_view = PlayTurnView { - turn_id: test_turn_id(9302), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: true, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-interrupted-turn-busy", - Some(&world), - Some(&busy_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&surface, "world.play.running").is_none(), - "the running hint must not appear while a round is actually in flight: it would tell \ - the player to submit the exact empty continue admit is about to refuse as TurnBusy" - ); - } - - /// PA.f194-D: with the running hint above correctly gated off while a - /// round is genuinely in flight, the card rendered nothing at all for the - /// whole span between the player submitting a turn and its own - /// narration/question/refusal landing — none of those are touched by the - /// round in progress until it closes or opens a question, so a player who - /// submits and watches an unchanged or empty card has no sign anything is - /// happening. This row renders for exactly the span the previous test - /// proves the running hint must not: whenever this player's own turn is - /// actively being worked on (`run_in_progress`), regardless of `state`. - /// - /// Mutation: delete the `if play.is_some_and(|view| view.run_in_progress) - /// { ... }` block that pushes `world.play.resolving` from `eve.rs`'s - /// play-card construction — the lookup below then finds nothing and the - /// first assertion fails. - #[tokio::test] - async fn the_card_shows_a_resolving_row_while_a_round_is_in_flight() { - let fixture = play_world(None, "player-round-in-flight").await; - let world = fixture.world.snapshot().await.unwrap(); - - let busy_view = PlayTurnView { - turn_id: test_turn_id(9303), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: true, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-round-in-flight", - Some(&world), - Some(&busy_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&surface, "world.play.resolving").is_some(), - "the card must render something while the player's own turn is actively being worked \ - on, not sit blank for the whole round" - ); - - let idle_view = PlayTurnView { - turn_id: test_turn_id(9304), - state: PlayTurnState::AwaitingPlayer, - question: None, - narration: Some("The hall falls quiet.".into()), - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let idle_surface = crate::eve::authenticated_surface( - "player-round-in-flight", - Some(&world), - Some(&idle_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&idle_surface, "world.play.resolving").is_none(), - "the resolving row must not linger once nothing is actively running" - ); - } - - /// PA.f194-D: the surviving running hint's own text used to claim "Your - /// last turn is still being resolved" — false in the only case it now - /// renders (`Running`, nothing actively working on it, PA.f193-D's own - /// `run_in_progress` gate). Its wording must describe reality: the turn - /// stalled, not that it is still in flight — "still being resolved" is - /// now what `world.play.resolving` above says instead. - /// - /// Mutation: revert the hint's text back to "Your last turn is still - /// being resolved..." — the assertion below fails. - #[tokio::test] - async fn the_running_hints_own_text_does_not_claim_the_turn_is_still_in_flight() { - let fixture = play_world(None, "player-stalled-turn-text").await; - let world = fixture.world.snapshot().await.unwrap(); - let running_view = PlayTurnView { - turn_id: test_turn_id(9305), - state: PlayTurnState::Running, - question: None, - narration: None, - refusal: None, - revision: 1, - run_in_progress: false, - fault: false, - }; - let surface = crate::eve::authenticated_surface( - "player-stalled-turn-text", - Some(&world), - Some(&running_view), - true, - ) - .unwrap(); - let running_hint = find_surface_node(&surface, "world.play.running") - .expect("a Running turn nothing is working on must still render the hint"); - let encoded = serde_json::to_string(running_hint).unwrap(); - assert!( - !encoded.to_lowercase().contains("still being resolved"), - "the hint must not claim the turn is still being resolved when nothing is working on \ - it: {encoded}" - ); - } - - /// PA.f134's own end-to-end proof: answering *through the turn view's own - /// `question.id`* — the id `current_turn_view` actually exposes, never - /// one this test built — resumes the turn, and the closed turn's - /// narration is then visible through the same card. Cut 10 (PA.f151) - /// deleted the card's own rendered id control (the lowering had no way - /// to carry it without leaking it); `runtime.rs::execute_world`'s - /// `world.play` arm is the one production caller that resolves this id - /// now, the same way this test does. Also proves the play row's own - /// revision claim: the world commits nothing across this whole exchange - /// (`ask_player`/`end_turn` are conversational, not kernel patches), so - /// `world.revision` — and so the surface document's own `version` - /// (PA.f148) — never moves, yet `PlayTurnView::revision` still does, on - /// both the question opening and the turn closing. - #[tokio::test] - async fn a_question_is_answered_through_the_cards_own_binding_and_the_turn_closes_with_narration() { - let fixture = play_world(None, "player-card-question").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("The hall falls quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask, end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) - .await - .unwrap(); - let asked_view = table.current_turn_view().await.unwrap(); - assert_eq!( - asked_view.question.as_ref().map(|question| question.text.as_str()), - Some("Which way?") - ); - - let world_before = fixture.world.snapshot().await.unwrap(); - let surface_before = crate::eve::authenticated_surface( - "player-card-question", - Some(&world_before), - Some(&asked_view), - true, - ) - .unwrap(); - - let question_row = find_surface_node(&surface_before, "world.play.question") - .expect("the question row"); - assert_eq!(question_row["props"]["value"], "Which way?"); - assert!( - find_surface_node(&surface_before, "world.play.answers").is_none(), - "Cut 10 deleted the id-carrying control; nothing on the surface names the question" - ); - // The exact id `current_turn_view` exposes — the one a production - // caller (`runtime.rs::execute_world`) resolves server-side now, - // never a value the player's own client supplies. - let answers: QuestionId = asked_view.question.as_ref().unwrap().id.clone(); - - table - .run( - &fixture.principal, - test_turn_id(92), - PlayRequest { - text: "I go left.".into(), - answers: Some(answers), - }, - ) - .await - .unwrap(); - - let closed_view = table.current_turn_view().await.unwrap(); - assert_eq!(closed_view.state, PlayTurnState::Closed); - assert_eq!(closed_view.question, None); - assert_eq!(closed_view.narration.as_deref(), Some("The hall falls quiet.")); - assert!( - closed_view.revision > asked_view.revision, - "the row's own revision must move again once the turn closes: {} -> {}", - asked_view.revision, - closed_view.revision - ); - - let world_after = fixture.world.snapshot().await.unwrap(); - assert_eq!( - world_after.revision, world_before.revision, - "asking and answering a question are conversational; neither commits to the world" - ); - - let surface_after = crate::eve::authenticated_surface( - "player-card-question", - Some(&world_after), - Some(&closed_view), - true, - ) - .unwrap(); - assert!( - find_surface_node(&surface_after, "world.play.question").is_none(), - "no question is open any more; the card must not still show one" - ); - // PA.f159: the subject census is gone from `world.summary` — phase - // and clock stay, but who else is in the world is not the player's - // own projection. - let summary = find_surface_node(&surface_after, "world.summary").expect("the summary card"); - assert_eq!(summary["children"], serde_json::json!([])); - let narration_row = find_surface_node(&surface_after, "world.play.narration") - .expect("the narration row"); - assert_eq!(narration_row["props"]["value"], "The hall falls quiet."); - assert_eq!( - surface_before["version"], surface_after["version"], - "the document's own version names world.revision alone, and it never moved" - ); - } - - /// PA.f170: `execute_round`'s `ASK_PLAYER_TOOL` arm mints a fresh - /// `question_token` every time a question opens, never reusing one - /// across two distinct questions in the same turn. The version it - /// replaces (PA.f161's `surface_version`) could not make this promise: - /// asking is conversational and never moves the world's own revision, so - /// two questions opened in the same turn — even in two different rounds, - /// with nothing committed between them — would have recorded the same - /// version. This is the token's own reason to exist: it, not a version, - /// is what tells the two questions apart at `runtime.rs`'s own boundary. - /// - /// Mutation: have `execute_round`'s `ASK_PLAYER_TOOL` arm reuse - /// `turn.question_token` when it is already `Some` instead of calling - /// `mint_question_token()` unconditionally — this test's two tokens - /// would then be equal. - #[tokio::test] - async fn two_questions_in_one_turn_mint_two_distinct_tokens() { - let fixture = play_world(None, "player-two-questions-distinct-tokens").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask_one = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let ask_two = output( - "r1", - vec![call_event( - "c1", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Are you sure?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask_one, ask_two]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) - .await - .unwrap(); - let first_view = table.current_turn_view().await.unwrap(); - let first_question = first_view.question.clone().expect("Q1 must be open"); - assert_eq!(first_question.text, "Which way?"); - - table - .run( - &fixture.principal, - test_turn_id(92), - PlayRequest { - text: "I go left.".into(), - answers: Some(first_question.id.clone()), - }, - ) - .await - .unwrap(); - let second_view = table.current_turn_view().await.unwrap(); - let second_question = second_view.question.clone().expect("Q2 must be open"); - assert_eq!(second_question.text, "Are you sure?"); - - assert_ne!( - first_question.token, second_question.token, - "two distinct questions in the same turn must never share an answer token" - ); - } - - /// PA.f173: `close_with_fault` used to leave `question` and - /// `question_token` set on the turn it closed, though a `Closed` turn - /// should never still carry an open question. This was unobservable - /// through `current_turn_view`/`open_question_id` only because both gate - /// on `state == AwaitingPlayer`, which closing always leaves behind in - /// the same write — so this test reaches past that gate, at the raw - /// persisted row. - /// - /// Mutation: remove the `turn.question = None; turn.question_token = - /// None;` lines from `close_with_fault` — the persisted row would then - /// still carry the question's own text and token after the turn closed, - /// and this test's assertions would fail. - #[tokio::test] - async fn close_with_fault_clears_the_open_question_off_the_persisted_row() { - let fixture = play_world(None, "player-fault-clears-question").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(91), "I stand at a crossroads.".into()) - .await - .unwrap(); - let asked_view = table.current_turn_view().await.unwrap(); - assert_eq!(asked_view.state, PlayTurnState::AwaitingPlayer); - - let mut turn = table.store.lock().await.current().unwrap().clone(); - assert!(turn.question.is_some()); - assert!(turn.question_token.is_some()); - table - .close_with_fault(&mut turn, "manufactured fault for PA.f173".to_owned()) - .await - .unwrap(); - - let persisted = table.store.lock().await.current().unwrap().clone(); - assert_eq!(persisted.state, PlayTurnState::Closed); - assert_eq!( - persisted.question, None, - "a closed turn must not still carry the question it closed on top of" - ); - assert_eq!( - persisted.question_token, None, - "a closed turn must not still carry the question's own answer token" - ); - } - - /// A pre-Cut-11 process's own `PlayTurn`, replicated field-for-field - /// minus `question_surface_version`/`question_token` — the shape every - /// row actually written under `LEGACY_STORE_SCHEMA_V1` carries (PA.f172). - /// Test-only: no production writer ever mints this shape again. - #[derive(Clone, Serialize)] - struct LegacyPlayTurnV1 { - turn_id: String, - applied_keys: Vec, - opening_prompt: String, - player_prose: Vec, - rounds: Vec, - calls: Vec, - persona_turns: Vec<(SubjectId, PersonaTurn)>, - question: Option, - refusal: Option, - narration: Option, - fault: Option, - state: PlayTurnState, - } - - #[derive(Clone, Serialize)] - struct LegacyPlayTurnStoreStateV1 { - schema: String, - turn: Option, - ledger: KeyLedger, - revision: u64, - } - - /// A between-PA.f172-and-PA.f170 process's own `PlayTurn`: it has - /// `question_surface_version` (deleted by this cut) but not - /// `question_token` — the shape every row actually written under - /// `LEGACY_STORE_SCHEMA_V2` carries. Test-only: no production writer - /// ever mints this shape again. - #[derive(Clone, Serialize)] - struct LegacyPlayTurnV2 { - turn_id: String, - applied_keys: Vec, - opening_prompt: String, - player_prose: Vec, - rounds: Vec, - calls: Vec, - persona_turns: Vec<(SubjectId, PersonaTurn)>, - question: Option, - question_surface_version: Option, - refusal: Option, - narration: Option, - fault: Option, - state: PlayTurnState, - } - - #[derive(Clone, Serialize)] - struct LegacyPlayTurnStoreStateV2 { - schema: String, - turn: Option, - ledger: KeyLedger, - revision: u64, - } - - /// PA.f172: a row written before `question_token` existed — under - /// `LEGACY_STORE_SCHEMA_V1`, the schema id every pre-Cut-11 process - /// actually wrote (it is exactly what `STORE_SCHEMA` used to be) — is - /// recognized by `PlayTurnStore::open` as legacy, not corrupt, and its - /// turn recovered rather than discarded: the missing field decodes to - /// `None`, exactly what "no question ever recorded a token" means for a - /// turn this old. The row is also upgraded to `STORE_SCHEMA` in place, - /// durably, so the legacy path is only ever taken once. - /// - /// Mutation: remove the `schema_id.as_deref() == - /// Some(LEGACY_STORE_SCHEMA_V1)` arm from `PlayTurnStore::open` — this - /// row would then fall through to the catch-all and `open` would return - /// `Err`, exactly PA.f172's own bug: a workstation's pre-Cut-11 row - /// silently disables play with "the play table is unavailable". - #[tokio::test] - async fn a_legacy_v1_row_is_recognized_and_upgraded_not_refused_as_corrupt() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV1 { - turn_id: "11111111-1111-1111-1111-111111111111".into(), - applied_keys: vec!["key-a".into()], - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let legacy_state = LegacyPlayTurnStoreStateV1 { - schema: LEGACY_STORE_SCHEMA_V1.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 3, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a genuine pre-Cut-11 row must open, not be refused as corrupt"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.turn_id, "11111111-1111-1111-1111-111111111111"); - assert_eq!(recovered.opening_prompt, "You stand at the gate."); - assert_eq!( - recovered.question_token, None, - "a pre-Cut-11 turn never recorded a token, and its own question is not open (Running); \ - the missing field must decode to None and no token must be minted for it" - ); - assert_eq!( - store.row.schema_id.as_deref(), - Some(STORE_SCHEMA), - "the legacy row must be upgraded to the current schema in place, not left at v1" - ); - - // Reopening must now take the strict current-schema path, proving - // the upgrade was actually durably persisted, not only held in - // memory on the first `open`. `store` must be dropped first: CultCache's - // own single-owner lock refuses a second `OwnedRedbMessagePackBackingStore::new` - // on the same path while the first is still held (the same custody - // rule `pull_current_from_disk_for_test`'s own doc comment names). - let recovered_turn_id = recovered.turn_id.clone(); - drop(store); - let reopened = PlayTurnStore::open(&store_path).unwrap(); - assert_eq!(reopened.current().unwrap().turn_id, recovered_turn_id); - } - - /// PA.f170: the same recognition and in-place upgrade `a_legacy_v1_row_*` - /// proves, for a row written between PA.f172 and PA.f170 instead — it - /// carries `question_surface_version` (now deleted, silently ignored on - /// decode) but not `question_token`. - /// - /// Mutation: remove the `schema_id.as_deref() == - /// Some(LEGACY_STORE_SCHEMA_V2)` arm from `PlayTurnStore::open` — this - /// row would then fall through to the catch-all and `open` would return - /// `Err`, silently disabling play on any workstation that ran Dungeon - /// between Cut 11 and Cut 13. - #[tokio::test] - async fn a_legacy_v2_row_is_recognized_and_upgraded_not_refused_as_corrupt() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV2 { - turn_id: "22222222-2222-2222-2222-222222222222".into(), - applied_keys: vec!["key-b".into()], - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_surface_version: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - let legacy_state = LegacyPlayTurnStoreStateV2 { - schema: LEGACY_STORE_SCHEMA_V2.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 4, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V2.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a genuine v2 row must open, not be refused as corrupt"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.turn_id, "22222222-2222-2222-2222-222222222222"); - assert_eq!( - recovered.question_token, None, - "a v2 turn never recorded a token, and its own question is not open (Running); \ - the missing field must decode to None and no token must be minted for it" - ); - assert_eq!( - store.row.schema_id.as_deref(), - Some(STORE_SCHEMA), - "the legacy row must be upgraded to the current schema in place, not left at v2" - ); - } - - /// PA.f170: an upgraded row whose question is still open gets a freshly - /// minted token as part of the upgrade — no earlier schema ever recorded - /// one, and the card is re-rendered with whatever the row holds, so a - /// player returning to an upgraded session (Raven's workstation, named in - /// the cut's own spec) must still be able to answer. - /// - /// Mutation: remove the mint-if-open branch from `upgrade_legacy_row` — - /// the recovered turn's own `question_token` would then stay `None` while - /// `AwaitingPlayer`, and the answer this test goes on to submit would be - /// refused as stale by `runtime.rs`'s own boundary (no token to match) - /// even though the question is genuinely still open. - #[tokio::test] - async fn an_upgraded_legacy_row_whose_question_is_still_open_mints_a_fresh_token() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV1 { - turn_id: "33333333-3333-3333-3333-333333333333".into(), - applied_keys: vec!["key-c".into()], - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: vec![output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - let legacy_state = LegacyPlayTurnStoreStateV1 { - schema: LEGACY_STORE_SCHEMA_V1.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 5, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a genuine pre-Cut-11 row must open, not be refused as corrupt"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.state, PlayTurnState::AwaitingPlayer); - let token = recovered - .question_token - .clone() - .expect("an upgraded row whose question is still open must mint a fresh token"); - assert!(!token.is_empty(), "a minted token must not be the empty-string fallback"); - } - - /// PA.f184: a row that matches the *current* schema id but whose bytes do - /// not decode at all used to leave `open` refusing forever — `open_play` - /// caught the `Err`, logged it, and `state.play` stayed `None` across - /// every future restart, exactly the PA.f177 gap this fix closes for the - /// arm PA.f177 itself did not touch. It is now retired to a sidecar file - /// beside the store, the same path a damaged legacy row already took, so - /// the play card comes back (empty) instead of staying gone. - /// - /// Mutation: change `read_current_schema_row`'s decode line back to a - /// bare `rmp_serde::from_slice(&row.payload)?` (or otherwise make this - /// arm `bail!` instead of calling `retire_unreadable_row`) — `open` then - /// returns `Err` for this row and `store.expect(...)` below panics. - #[tokio::test] - async fn a_row_with_the_current_schema_id_and_undecodable_bytes_is_retired_to_a_sidecar_and_play_starts_fresh() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - seed_unreadable_row_for_test(&store_path); - - let store = PlayTurnStore::open(&store_path) - .expect("a damaged current-schema row must be retired, not fail open outright"); - assert!( - store.current().is_none(), - "play must start fresh with no current turn once the old row is retired" - ); - - let sidecar = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .find(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")) - .expect("the damaged row must be written to a sidecar file beside the store"); - assert!( - sidecar.file_name().to_string_lossy().ends_with(".json"), - "the sidecar must be named for what it is (PA.f192), not `.cc`: {:?}", - sidecar.file_name() - ); - let sidecar_bytes = std::fs::read(sidecar.path()).unwrap(); - assert!(!sidecar_bytes.is_empty(), "the sidecar must actually carry the retired row's bytes"); - - // Soul's owed item 5: the sidecar must actually be readable, and the - // original bytes must actually be recoverable from it — not merely - // "the file is non-empty". Parse it as JSON, decode `payload_base64`, - // and prove the result is byte-for-byte `seed_unreadable_row_for_test`'s - // own `vec![0xC1]`. - let decoded: serde_json::Value = serde_json::from_slice(&sidecar_bytes) - .expect("the sidecar must be readable JSON, not another CultCache-shaped blob"); - let payload_base64 = decoded["payload_base64"] - .as_str() - .expect("the sidecar must carry the original payload as a base64 string field"); - let recovered_payload = base64::engine::general_purpose::STANDARD - .decode(payload_base64) - .expect("payload_base64 must actually decode as base64"); - assert_eq!( - recovered_payload, - vec![0xC1], - "the sidecar's payload_base64 must decode to the exact original bytes, not a lossy copy" - ); - - // Reopening must not hit the same row again: it was cleared from the - // store as part of retiring it, not merely skipped this once. - drop(store); - let reopened = PlayTurnStore::open(&store_path) - .expect("reopening after retirement must not refuse the store again"); - assert!(reopened.current().is_none()); - let sidecars_after_reopen = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .filter(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")) - .count(); - assert_eq!( - sidecars_after_reopen, 1, - "a clean reopen after retirement must not itself perform a fresh retirement and write a \ - second sidecar" - ); - } - - /// PA.f184: a single row under this store's own `type`/`key` but a - /// schema id no arm recognizes — a future schema this build has never - /// seen — is retired exactly like a damaged row, not left bailing - /// forever with "foreign or otherwise-unreadable records". - /// - /// Mutation: delete the catch-all `[row] if row.r#type == STORE_TYPE && - /// row.key == STORE_KEY => { ... }` arm from `PlayTurnStore::open` — this - /// row then falls through to the final `_ => bail!(...)` and - /// `store.expect(...)` below panics. - #[tokio::test] - async fn a_row_declaring_an_unrecognized_future_schema_is_retired_not_refused_forever() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let future_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: vec![1, 2, 3], - stored_at: Utc::now().to_rfc3339(), - schema_id: Some("ghostlight.play_turn_store.v4".into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![future_row]).unwrap(), - "seeding the future-schema row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("an unrecognized future schema must be retired, not fail open outright"); - assert!(store.current().is_none()); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .any(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "the future-schema row must be written to a sidecar file beside the store" - ); - } - - /// PA.f194-A: Soul's own probe — a row declaring a schema id this build - /// has never seen (an Idunn rollback across a schema-id move landing - /// this build in front of a row a newer build wrote) whose payload still - /// decodes cleanly into the current `PlayTurnStoreState` shape, holding - /// an open question and its token. Before this fix the catch-all arm - /// retired every unrecognized schema id on the id alone, with no decode - /// attempted — this canonical, perfectly readable row lost its one open - /// turn, and the sidecar's own `reason` field falsely claimed the row - /// "could not be read". Now `open` attempts the decode first, the decode - /// succeeds, and `open` fails loudly instead of destroying a readable - /// turn — the same shape the current- and legacy-schema arms already use - /// for their own `Other` cases. - /// - /// Mutation: delete the `match rmp_serde::from_slice::(&row.payload)` - /// decode attempt from the catch-all arm (or its `Ok(_) => return Err(...)` - /// case) and route straight to `retire_unreadable_row` again — `open` then - /// succeeds with `current().is_none()`, a sidecar appears, and the - /// assertions below fail. - #[tokio::test] - async fn a_readable_row_declaring_an_unrecognized_future_schema_is_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let turn = PlayTurn { - turn_id: "44444444-4444-4444-4444-444444444444".into(), - applied_keys: vec!["key-d".into()], - opening_prompt: "You stand at a crossroads.".into(), - player_prose: vec!["I look around.".into()], - rounds: vec![output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - )], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::AskPlayer("Which way?".into())), - result: None, - }], - persona_turns: Vec::new(), - question: Some("Which way?".into()), - question_token: Some("future-schema-token".into()), - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::AwaitingPlayer, - }; - let future_state = PlayTurnStoreState { - schema: "ghostlight.play_turn_store.v4".into(), - turn: Some(turn), - ledger: KeyLedger::default(), - revision: 7, - }; - let future_payload = rmp_serde::to_vec_named(&future_state).unwrap(); - // Sanity: the fixture really is readable under the current shape, so - // this test exercises the "decodes cleanly" branch and not a decode - // failure in disguise. - rmp_serde::from_slice::(&future_payload) - .expect("the test fixture must actually decode as a valid PlayTurnStoreState"); - - let future_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: future_payload, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some("ghostlight.play_turn_store.v4".into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![future_row]).unwrap(), - "seeding the readable future-schema row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("a readable future-schema row must not silently succeed, and must not be retired"), - Err(error) => error, - }; - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("decodes cleanly") || full_chain.contains("v4"), - "the failure must name that the row is readable, not claim it could not be read: {full_chain}" - ); - - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable future-schema row must not be retired to a sidecar" - ); - - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original readable row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some("ghostlight.play_turn_store.v4")); - } - - /// PA.f193-A: Soul's own probe — a row declaring the *current* - /// `STORE_SCHEMA` whose bytes decode into a perfectly valid - /// `PlayTurnStoreState` (every field reads back, including the turn - /// itself) but whose on-disk field order is not what this build's own - /// `to_vec_named` would produce, so the canonical re-encode check - /// disagrees. Before this fix `read_current_schema_row` returned a plain - /// `anyhow::Error` for this exactly like a genuine decode failure, and - /// `open` retired the row, destroying a readable turn. Now `open` returns - /// `Err` and leaves the row untouched. - /// - /// Mutation: change `Err(CurrentSchemaRowError::Other(error)) => return - /// Err(error)...` back to routing through `retire_unreadable_row` (or - /// collapse `CurrentSchemaRowError` back into a single `anyhow::Result` - /// the way it was before PA.f193-A) — the row is then retired, a sidecar - /// appears, and the assertions below fail. - #[tokio::test] - async fn a_current_schema_rows_non_canonical_bytes_are_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - // Field order alphabetical (ledger, revision, schema, turn) rather - // than `PlayTurnStoreState`'s own declared order (schema, turn, - // ledger, revision): `rmp_serde` decodes a named-struct map by key - // regardless of wire order, so this still parses into a valid state, - // but re-encoding it through the real struct produces different - // bytes than what is on disk. - let mut state_fields = std::collections::BTreeMap::new(); - state_fields.insert("ledger", serde_json::to_value(KeyLedger::default()).unwrap()); - state_fields.insert("revision", Value::Number(5.into())); - state_fields.insert("schema", Value::String(STORE_SCHEMA.into())); - state_fields.insert("turn", Value::Null); - let state_json = Value::Object(state_fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); - let payload = rmp_serde::to_vec_named(&state_json).unwrap(); - - // Sanity: this payload really does decode into a valid state, so the - // test is actually exercising the canonical check and not a decode - // failure in disguise. - let decoded: PlayTurnStoreState = rmp_serde::from_slice(&payload) - .expect("the hand-ordered payload must still decode as a valid PlayTurnStoreState"); - assert!(rmp_serde::to_vec_named(&decoded).unwrap() != payload, "the test fixture must actually be non-canonical"); - - let row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(STORE_SCHEMA.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![row]).unwrap(), - "seeding the non-canonical row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("a non-canonical current-schema row must not silently succeed either"), - Err(error) => error, - }; - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("canonical"), - "the failure must name the canonical mismatch: {full_chain}" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable turn must not be retired over a non-canonical re-encode alone" - ); - - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some(STORE_SCHEMA)); - } - - /// PA.f193-B: a row declaring the current `STORE_SCHEMA` in its envelope, - /// canonical on the wire, but whose inner `schema` field names something - /// else — the same class of disagreement PA.f185 already covers for the - /// legacy arms, here for the current-schema arm PA.f185 did not touch. - /// The row decodes fine; only the inner tag disagrees. Before this fix - /// that reached `retire_unreadable_row` exactly like a decode failure. - /// - /// Mutation: delete the `if state.schema != STORE_SCHEMA` check from - /// `read_current_schema_row` (or route its `Other` arm back through - /// `retire_unreadable_row`) — the assertions below fail: with the check - /// deleted `open` succeeds instead of refusing; with retirement restored - /// a sidecar appears. - #[tokio::test] - async fn a_current_schema_rows_inner_schema_disagreeing_with_its_envelope_is_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let state = PlayTurnStoreState { - schema: LEGACY_STORE_SCHEMA_V1.into(), - turn: None, - ledger: KeyLedger::default(), - revision: 7, - }; - let payload = rmp_serde::to_vec_named(&state).unwrap(); - let row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload, - stored_at: Utc::now().to_rfc3339(), - // Envelope says current schema; the payload's own inner `schema` - // field says otherwise (LEGACY_STORE_SCHEMA_V1 above) — the exact - // disagreement this arm exists to catch. - schema_id: Some(STORE_SCHEMA.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![row]).unwrap(), - "seeding the mismatched-inner-schema row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("an inner-schema mismatch must not silently succeed either"), - Err(error) => error, - }; - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("schema"), - "the failure must name the schema mismatch: {full_chain}" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable turn must not be retired over an inner-schema tag mismatch alone" - ); - - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some(STORE_SCHEMA)); - } - - /// PA.f185: Soul's own probe — a row whose envelope declares - /// `LEGACY_STORE_SCHEMA_V1` but whose inner `schema` field says - /// `LEGACY_STORE_SCHEMA_V2` decodes its turn perfectly - /// (`opening_prompt` reads back intact), yet before this fix `open` - /// routed the inner-schema mismatch into `retire_unreadable_row` anyway, - /// destroying a readable turn under a sidecar reason ("did not decode - /// even leniently") that was false. Now `open` returns `Err` and leaves - /// the row untouched: no sidecar, no destructive compare-and-swap, - /// because an inconsistent inner tag is not the same failure as bytes - /// that will not parse at all. - /// - /// Mutation: change `Err(LegacyRowError::Other(error)) => return - /// Err(error)...` (in either legacy arm) back to - /// `retire_unreadable_row(&store, path.as_ref(), row, - /// LEGACY_STORE_SCHEMA_V1, &error)?` — the row is then retired, a - /// sidecar appears, and the assertions below fail. - #[tokio::test] - async fn a_legacy_rows_inner_schema_disagreeing_with_its_envelope_is_not_retired() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let legacy_turn = LegacyPlayTurnV1 { - turn_id: "55555555-5555-5555-5555-555555555555".into(), - applied_keys: Vec::new(), - opening_prompt: "You stand at the gate.".into(), - player_prose: vec!["I knock.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - // Envelope's own `schema_id` says v1 (handled by this arm); the - // payload's own inner `schema` field says v2 — the exact disagreement - // Soul's probe found, constructed by hand since no production writer - // ever mints this shape. - let legacy_state = LegacyPlayTurnStoreStateV1 { - schema: LEGACY_STORE_SCHEMA_V2.into(), - turn: Some(legacy_turn), - ledger: KeyLedger::default(), - revision: 3, - }; - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: rmp_serde::to_vec_named(&legacy_state).unwrap(), - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store.compare_and_swap_batch(&[], vec![legacy_row]).unwrap(), - "seeding the mismatched-inner-schema row must succeed against an empty store" - ); - } - - let error = match PlayTurnStore::open(&store_path) { - Ok(_) => panic!("an inner-schema mismatch must not silently succeed either"), - Err(error) => error, - }; - // `{error:#}` (anyhow's alternate form) walks the whole `.context(...)` - // chain; `{error}` alone would only show `open`'s own outer wrapper - // ("upgrading a legacy play-turn row failed") and never actually - // check that the underlying cause names the schema mismatch at all. - let full_chain = format!("{error:#}"); - assert!( - full_chain.contains("schema"), - "the failure must name the schema mismatch, not read as a plain decode failure: {full_chain}" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a readable turn must not be retired over an inner-schema tag mismatch alone" - ); - - // The row itself is untouched: no destructive compare-and-swap ran. - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - let rows = raw_store.pull_all().unwrap(); - assert_eq!(rows.len(), 1, "the original row must still be the only row in the store"); - assert_eq!(rows[0].schema_id.as_deref(), Some(LEGACY_STORE_SCHEMA_V1)); - } - - /// PA.f177: a row older than `applied_keys`/`fault` themselves — written - /// before either field existed, still under `LEGACY_STORE_SCHEMA_V1`, the - /// schema id that never changed for them — must open and upgrade exactly - /// like any other legacy row, not fail `upgrade_legacy_row`'s decode with - /// "missing field `applied_keys`". Soul's probe row, named in PA.f177. - /// - /// Mutation: remove `#[serde(default)]` from `PlayTurn::applied_keys` (or - /// `fault`) — this row's msgpack bytes name neither field at all, so the - /// decode in `upgrade_legacy_row` fails, `open` falls into - /// `retire_unreadable_row` instead of recovering the turn, and the - /// `recovered.applied_keys`/`turn_id` assertions below fail because there - /// is no recovered turn to read. - #[tokio::test] - async fn a_legacy_v1_row_predating_applied_keys_and_fault_still_opens_and_upgrades() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - // Encoded by hand, not through `LegacyPlayTurnV1`: that struct itself - // already carries `applied_keys`/`fault`. This is the shape an even - // earlier process wrote, before either field was added to `PlayTurn` - // at all — the exact gap PA.f177 named. - let mut turn_fields = std::collections::BTreeMap::new(); - turn_fields.insert("turn_id", Value::String("44444444-4444-4444-4444-444444444444".into())); - turn_fields.insert("opening_prompt", Value::String("You stand at the gate.".into())); - turn_fields.insert("player_prose", Value::Array(vec![Value::String("I knock.".into())])); - turn_fields.insert("rounds", Value::Array(Vec::new())); - turn_fields.insert("calls", Value::Array(Vec::new())); - turn_fields.insert("persona_turns", Value::Array(Vec::new())); - turn_fields.insert("question", Value::Null); - turn_fields.insert("refusal", Value::Null); - turn_fields.insert("narration", Value::Null); - turn_fields.insert("state", Value::String("Running".into())); - let turn_json = Value::Object(turn_fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); - let mut state_fields = std::collections::BTreeMap::new(); - state_fields.insert("schema", Value::String(LEGACY_STORE_SCHEMA_V1.into())); - state_fields.insert("turn", turn_json); - state_fields.insert( - "ledger", - serde_json::to_value(KeyLedger::default()).unwrap(), - ); - state_fields.insert("revision", Value::Number(2.into())); - let state_json = Value::Object(state_fields.into_iter().map(|(k, v)| (k.to_string(), v)).collect()); - // Round-trip through `serde_json::Value` into msgpack (`rmp_serde` - // encodes any `Serialize` type, including a dynamic `Value`), so the - // wire bytes genuinely lack `applied_keys`/`fault` rather than - // merely being constructed from a Rust struct that happens to. - let payload = rmp_serde::to_vec_named(&state_json).unwrap(); - - let legacy_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![legacy_row]) - .unwrap(), - "seeding the pre-applied_keys/fault row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a row predating applied_keys/fault must still open leniently, not be refused"); - let recovered = store.current().expect("the legacy row's own turn must be recovered"); - assert_eq!(recovered.turn_id, "44444444-4444-4444-4444-444444444444"); - assert!( - recovered.applied_keys.is_empty(), - "a field the row never recorded at all must default, not fail the decode" - ); - assert_eq!(recovered.fault, None); - assert_eq!( - store.row.schema_id.as_deref(), - Some(STORE_SCHEMA), - "the row must be upgraded to the current schema in place" - ); - assert!( - std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .all(|entry| !entry.file_name().to_string_lossy().contains("retired-unreadable-row")), - "a row that decoded leniently must not also be retired to a sidecar" - ); - } - - /// PA.f177: a legacy-schema row that is genuinely damaged — not merely - /// missing a field the lenient reader now defaults — must not leave - /// `PlayTurnStore::open` returning `Err` forever. It is retired to a - /// sidecar file beside the store, and the store opens fresh with no - /// current turn, so `open_play` succeeds and the play card comes back - /// (empty) instead of staying gone across every future restart. - /// - /// Mutation: delete the `Err(error) => retire_unreadable_row(...)` arm - /// from `PlayTurnStore::open`'s `LEGACY_STORE_SCHEMA_V1` branch (leaving - /// only `upgrade_legacy_row(&store, row, LEGACY_STORE_SCHEMA_V1)?`) — - /// `open` then returns `Err` for this row, exactly PA.f177's own bug, and - /// `store.expect(...)` below panics. - #[tokio::test] - async fn a_genuinely_undecodable_legacy_row_is_retired_to_a_sidecar_and_play_starts_fresh() { - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - let damaged_row = CultCacheEnvelope { - key: STORE_KEY.into(), - r#type: STORE_TYPE.into(), - payload: vec![0xC1], // msgpack's own reserved "never used" marker byte - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(LEGACY_STORE_SCHEMA_V1.into()), - }; - { - let raw_store = OwnedRedbMessagePackBackingStore::new(&store_path).unwrap(); - assert!( - raw_store - .compare_and_swap_batch(&[], vec![damaged_row]) - .unwrap(), - "seeding the damaged legacy row must succeed against an empty store" - ); - } - - let store = PlayTurnStore::open(&store_path) - .expect("a damaged legacy row must be retired, not fail open outright"); - assert!( - store.current().is_none(), - "play must start fresh with no current turn once the old row is retired" - ); - - let sidecar = std::fs::read_dir(directory.path()) - .unwrap() - .filter_map(|entry| entry.ok()) - .find(|entry| entry.file_name().to_string_lossy().contains("retired-unreadable-row")) - .expect("the damaged row must be written to a sidecar file beside the store"); - let sidecar_bytes = std::fs::read(sidecar.path()).unwrap(); - assert!(!sidecar_bytes.is_empty(), "the sidecar must actually carry the retired row's bytes"); - - // Reopening must not hit the same row again: it was cleared from the - // store as part of retiring it, not merely skipped this once. - drop(store); - let reopened = PlayTurnStore::open(&store_path) - .expect("reopening after retirement must not refuse the store again"); - assert!(reopened.current().is_none()); - } - - /// The fork Self ruled on, isolated: a commit that changes only - /// `turn.refusal` — exactly the shape `execute_actor_call`'s - /// `ActionRejected` arm leaves a row in (the call's own record is - /// already persisted before submission, PA-Q8's "body durable before - /// effects" order; this second commit carries only the refusal, growing - /// neither `calls` nor `rounds`) — must still move the row's own - /// `revision`. A counter derived from `calls.len()` or `rounds.len()` - /// cannot see this commit at all, which is the exact gap that made a - /// persisted counter, not a derived one, the ruled answer. - /// - /// Mutation: move `PlayTurnStore::swap_in`'s own `revision + 1` onto a - /// condition keyed off `turn.calls.len()` growing (or any other derived - /// field) instead of firing unconditionally on every commit — this - /// commit, which grows nothing, would then leave `revision` exactly - /// where it started, failing the assertion below. - #[tokio::test] - async fn a_commit_changing_only_the_refusal_still_moves_the_rows_own_revision() { - let directory = tempfile::tempdir().unwrap(); - let mut store = PlayTurnStore::open(directory.path().join("play-turn-v1.cc")).unwrap(); - let base = PlayTurn { - turn_id: test_turn_id(9001), - applied_keys: vec!["k0".into()], - opening_prompt: "opening".into(), - player_prose: vec!["I try the act.".into()], - rounds: Vec::new(), - calls: Vec::new(), - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - store.open_new_turn(base.clone()).unwrap(); - let revision_before = store.state.revision; - - let mut refused = base; - refused.refusal = Some("refused".into()); - store.commit(refused).unwrap(); - let revision_after = store.state.revision; - - assert!( - revision_after > revision_before, - "a commit that changes only the refusal line must still move the row's own revision: \ - {revision_before} -> {revision_after}" - ); - } - - #[tokio::test] - async fn a_raw_op_is_journaled_as_play_and_never_as_an_actor() { - let fixture = play_world(None, "player-raw-op").await; - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_resource", - serde_json::json!({"handle": "gold", "label": "Gold"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(2), "Declare a resource.".into()) - .await - .unwrap(); - - // In Active phase, only the `Play` capability may admit an - // unanswered declare with no `PatchAnswer` (`require_answer` in - // `lib.rs`); every other caller is refused `AnswerRequired`. This - // resource's presence in the next snapshot is therefore possible - // only if the commit's caller was `System(Play)`. - let snapshot = fixture.world.snapshot().await.unwrap(); - assert!( - table_view(&snapshot).contains("Gold"), - "the declared resource did not commit through the Play door" - ); - } - - #[tokio::test] - async fn an_act_for_an_undispatched_subject_is_refused() { - let fixture = play_world(Some("Mara"), "player-undispatched").await; - let before = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&before)); - let round = output( - "r0", - vec![ - call_event( - "c0", - &format!("{mara}__speak"), - serde_json::json!({"text": "never dispatched"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Silence.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(3), "Hello?".into()) - .await - .unwrap(); - - // PA.f69 S2: `resolve_handle` refuses the undispatched handle before - // any door is touched — asserted, not merely "no panic" — and - // nothing about the world committed from it. - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - result.contains("not the player or a subject dispatched this turn"), - "{result}" - ); - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "an undispatched act must commit nothing"); - } - - #[tokio::test] - async fn a_paraphrased_quote_is_refused() { - let fixture = play_world(None, "player-paraphrase").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = handle_for(player_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - &format!("{player}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "a whispered WARNING"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-0", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(100), "a whispered warning".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result.contains("not an exact quote"), "{result}"); - } - - /// Mutation M8.1: if `span_is_exact` stopped checking a Persona's own - /// quotes, this would no longer hold — the paraphrase would commit. - #[tokio::test] - async fn a_paraphrased_quote_is_refused_for_a_persona() { - let fixture = play_world(Some("Mara"), "player-persona-paraphrase").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&snapshot)); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id]}), - )], - ); - let speak_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "I nod and SAY nothing"}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("I nod and say nothing")]), - output("proj-narrate", vec![text_event("The room settles.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round, speak_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(120), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result.contains("not an exact quote"), "{result}"); - } - - /// PA.f115 (invariant 4): `span_is_exact`'s own - /// `.filter(|(id, _)| *id == actor.subject)` restricts a Persona's - /// sources to turns recorded *for that subject*; deleting it would let - /// one dispatched Persona speak another's prose verbatim, uncaught, by - /// pooling every dispatched Persona's recorded turns as a shared source - /// list. Mara and Borin are dispatched together; Mara's own `speak` call - /// quotes Borin's own recorded turn prose exactly and must still be - /// refused as not her own exact quote. Mutation: delete the filter in - /// `span_is_exact` (`play.rs`) and this must fail. - #[tokio::test] - async fn a_persona_cannot_speak_another_dispatched_personas_exact_quote() { - let fixture = play_world(Some("Mara"), "player-persona-cross-quote").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - // A second Persona subject, declared through the play table's own - // authoring door — the same mechanism - // `dispatch_order_never_changes_the_request_id_a_subject_gets` uses. - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("declare-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(760), "Introduce Borin.".into()) - .await - .unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - let mara_handle = handle_for(mara); - let mara_text = subject_id_text(&snapshot, mara); - let borin_text = subject_id_text(&snapshot, borin); - - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text, borin_text]}), - )], - ); - let speak_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{mara_handle}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "Borin says hello to the player."}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-borin", vec![text_event("Borin waits his turn.")]), - output("persona-borin", vec![text_event("Borin says hello to the player.")]), - output("proj-narrate", vec![text_event("The room settles.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round, speak_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(761), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - result.contains("not an exact quote"), - "Mara speaking Borin's own exact prose must still be refused: {result}" - ); - } - - /// A second world fixture, owed since 8a (PA-Q8): genesis's own - /// `WorldMailbox::create` grants only the kernel's own zero-role `speak` - /// affordance, whose sole precondition — `CanBroadcast(Colocated)` — is - /// satisfied even by a subject alone in a room, and `declare_affordance` - /// (the only door to a custom precondition) is not in `PLAY_TOOLS`, so - /// the play agent itself can never author one. This fixture instead - /// declares one directly on `fixture.world`, through - /// `decode_authoring_call` and `CommandBody::AdmitPatch`, while the world - /// is still in Draft — exactly the door genesis's own authoring root - /// uses, never anything `PLAY_TOOLS` gates. The affordance's one - /// precondition, `holds`, is bound to a resource role the player is - /// never granted any of, so any attempt against it is refused - /// deterministically, with no movement, authority, or standing setup - /// needed. - async fn play_world_with_precondition_affordance(account_hash: &str) -> (WorldFixture, AffordanceId) { - let directory = tempfile::tempdir().unwrap(); - let (world, owner) = WorldMailbox::open(directory.path().join("world.cc")).unwrap(); - let principal = VerifiedPrincipalEvidence::new(account_hash, far_future()); - let receipt = world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Fixture World".into(), - brief: "A small table with one guarded affordance.".into(), - human_subject_label: "Player".into(), - narrative_persona_label: None, - operational_agent_label: None, - targets: BTreeMap::new(), - jurisdictions: Vec::new(), - lens_weights: LensWeights::new(BTreeMap::from([(Lens::Hearth, 1)])), - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - - let resource_patch = decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "guarded_resource", "label": "Guarded Resource"}).to_string(), - ) - .unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::AdmitPatch { - answers: None, - patch: resource_patch, - }, - }, - &principal, - ) - .await - .unwrap(); - - let snapshot = world.snapshot().await.unwrap(); - let affordance_patch = decode_authoring_call( - "declare_affordance", - &serde_json::json!({ - "handle": "guarded_act", - "kind": "guarded_act", - "roles": [ - {"role": "target", "kind": {"namespace": "entity", "kind": "resource"}}, - ], - "preconditions": [ - {"precondition": "holds", "resource": "target", "at_least": 999_999}, - ], - "effect_slots": [ - { - "op_kind": {"op": "consume"}, - "roles": ["actor", "target"], - "bounds": {"bound": "quantity", "max": 1}, - }, - ], - "outcome_bands": [{"weight": 1, "effects": []}], - "carries_speech": false, - }) - .to_string(), - ) - .unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::AdmitPatch { - answers: None, - patch: affordance_patch, - }, - }, - &principal, - ) - .await - .unwrap(); - - let snapshot = world.snapshot().await.unwrap(); - let affordance_id = snapshot - .affordances - .iter() - .find(|entry| entry.entry.kind.0 == "guarded_act") - .unwrap() - .id; - let grant_patch = decode_authoring_call( - "grant_affordance", - &serde_json::json!({ - "subject": {"ref": "existing", "value": subject_id_text(&snapshot, player)}, - "affordance": {"ref": "existing", "value": affordance_id_text(&snapshot, affordance_id)}, - }) - .to_string(), - ) - .unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::AdmitPatch { - answers: None, - patch: grant_patch, - }, - }, - &principal, - ) - .await - .unwrap(); - - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ActivateWorld, - }, - &principal, - ) - .await - .unwrap(); - - ( - WorldFixture { - _directory: directory, - world, - principal, - owner, - }, - affordance_id, - ) - } - - /// The canonical id text for an affordance, read out of a real - /// `table_view` render, on the same precedent `subject_id_text` already - /// uses for a subject: never a test-local copy of the printer. - fn affordance_id_text(snapshot: &WorldSnapshot, id: AffordanceId) -> String { - let entry = snapshot.affordances.iter().find(|entry| entry.id == id).unwrap(); - let view = table_view(snapshot); - let marker = format!("{} [", entry.entry.kind.0); - let start = view - .find(&marker) - .unwrap_or_else(|| panic!("{} not found in table_view: {view}", entry.entry.kind.0)) - + marker.len(); - let end = view[start..] - .find(']') - .unwrap_or_else(|| panic!("unterminated id bracket after {} in table_view", entry.entry.kind.0)); - view[start..start + end].to_owned() - } - - /// The canonical id text for a declared resource, by its own label, read - /// out of `table_view`'s own `Resources:` section — `resources` itself is - /// `pub(crate)` inside `ghostlight`, so this crate has no field to read - /// it from directly. - fn resource_id_text(snapshot: &WorldSnapshot, label: &str) -> String { - let view = table_view(snapshot); - let section = view - .find("\n Resources:") - .map_or(view.as_str(), |at| &view[at..]); - let marker = format!(" {label} ["); - let start = section - .find(&marker) - .unwrap_or_else(|| panic!("{label} not found in table_view's Resources section: {view}")) - + marker.len(); - let end = section[start..] - .find(']') - .unwrap_or_else(|| panic!("unterminated id bracket after {label} in table_view")); - section[start..start + end].to_owned() - } - - /// PA-Q8, owed since 8a: `execute_actor_call`'s `ActionRejected` arm sets - /// `turn.refusal` to the *actor* form (invariant 8) — bare role names, - /// no id, no resolved label, no fact statement — never the agent-facing - /// `describe_refusal` text the tool call's own result still carries. - #[tokio::test] - async fn a_refused_player_act_sets_the_refusal_line() { - let (fixture, _affordance_id) = play_world_with_precondition_affordance("player-refusal-line").await; - let directory = tempfile::tempdir().unwrap(); - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let handle = handle_for(player); - let resource_id = resource_id_text(&snapshot, "Guarded Resource"); - - let round = output( - "r0", - vec![call_event( - "c0", - &format!("{handle}{HANDLE_SEPARATOR}guarded_act"), - serde_json::json!({ - "target": resource_id, - "slot_0_qty": 1, - }), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(881), "I try the guarded act.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let refusal = turn - .refusal - .as_deref() - .expect("a refused player act must set the actor-facing refusal line"); - assert!( - !refusal.contains(&id_text(player)), - "the actor-facing refusal must carry no id: {refusal}" - ); - assert!( - !refusal.to_lowercase().contains("guarded_resource") && !refusal.to_lowercase().contains("guarded resource"), - "the actor-facing refusal must not resolve or name the role's target: {refusal}" - ); - } - - /// PA.f144: `turn.refusal` names the *most recent* player act, not - /// whichever one last refused — round 0 refuses (setting the line, as - /// `a_refused_player_act_sets_the_refusal_line` above proves), round 1's - /// player act then commits, and the line must be gone, not still - /// showing round 0's now-stale refusal. Mutation: dropping the - /// `turn.refusal = None` this fix adds to `execute_actor_call`'s own - /// success arm. - #[tokio::test] - async fn a_later_successful_player_act_clears_an_earlier_refusal_line() { - let (fixture, _affordance_id) = play_world_with_precondition_affordance("player-f144-clears").await; - let directory = tempfile::tempdir().unwrap(); - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let handle = handle_for(player); - let resource_id = resource_id_text(&snapshot, "Guarded Resource"); - const OPENING: &str = "I try the guarded act."; - - let refused_round = output( - "r0", - vec![call_event( - "c0", - &format!("{handle}{HANDLE_SEPARATOR}guarded_act"), - serde_json::json!({ - "target": resource_id, - "slot_0_qty": 1, - }), - )], - ); - // Round 1's own speak call quotes the turn's own opening prose - // exactly (`span_is_exact`'s own player source, PA.f84: a `Running` - // continue never adds fresh `player_prose`, so this is the only text - // a player act can quote mid-turn without a fresh `ask_player` - // round). - let successful_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{handle}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": OPENING}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-narrate", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![refused_round, successful_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(968), OPENING.into()).await.unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.refusal.is_none(), - "a later successful player act must clear an earlier round's own refusal line: {:?}", - turn.refusal - ); - let speak_result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(speak_result, "applied", "the later act must itself have actually committed: {speak_result}"); - } - - #[tokio::test] - async fn a_mint_and_a_ruled_fact_commit_as_play() { - let fixture = play_world(None, "player-mint").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-a", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_resource", - serde_json::json!({"handle": "gold", "label": "Gold"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(90), "Declare gold.".into()) - .await - .unwrap(); - } - - let snapshot = fixture.world.snapshot().await.unwrap(); - let view = table_view(&snapshot); - let resource_id = view - .split("Gold [") - .nth(1) - .and_then(|rest| rest.split(']').next()) - .expect("Gold's bracketed id is printed in table_view") - .to_owned(); - let player = subject_id_text(&snapshot, player_id(&snapshot)); - - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-b", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let round = output( - "r0", - vec![ - call_event( - "c0", - "mint", - serde_json::json!({ - "holder": {"ref": "existing", "value": player}, - "resource": {"ref": "existing", "value": resource_id}, - "qty": 5, - }), - ), - call_event( - "c1", - "declare_fact", - serde_json::json!({ - "handle": "ruling", - "label": "The Ruling", - "statement": "Five bars of gold sit in the vault.", - "standing": {"standing": "ruled"}, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &fixture.principal, - test_turn_id(91), - "Mint gold and rule it so.".into(), - ) - .await - .unwrap(); - } - - let snapshot = fixture.world.snapshot().await.unwrap(); - let view = table_view(&snapshot); - assert!(view.contains("5 of Gold"), "the mint did not commit: {view}"); - // `table_view`'s Facts section lists only facts some subject knows - // (built from `subject.knowledge`, PA-Q7's own doc comment on - // `table_view`); a `declare_fact` with nobody granted knowledge of - // it is invisible there by design, so this checks the call's own - // recorded result instead of the rendered view. - let stored = PlayTurnStore::open(&store_path).unwrap(); - let turn = stored.current().unwrap(); - let fact_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(fact_result, "applied", "the ruled fact did not commit: {fact_result}"); - assert!(!PLAY_TOOLS.contains(&"admit"), "admit is not a play tool"); - } - - #[tokio::test] - async fn a_persona_fault_becomes_a_tool_result_and_the_turn_continues() { - let fixture = play_world(Some("Mara"), "player-persona-fault").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas_port = ScriptedPort::new(vec![]); - // Faults the dispatch's own Projector inference; one clean output is - // reserved for the player's own closing narration. - personas_port - .queue - .lock() - .unwrap() - .push_back(Err("provider unavailable".into())); - personas_port - .queue - .lock() - .unwrap() - .push_back(Ok(output("proj-narrate", vec![text_event("Quiet.")]))); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - personas_port, - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(80), "Hello.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(dispatch_result.contains("could not act"), "{dispatch_result}"); - } - - #[tokio::test] - async fn persona_dispatch_draws_only_from_the_permit_pool() { - let fixture = play_world(Some("Mara"), "player-permits").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara nods.")]), - output("persona-mara", vec![text_event("I nod back.")]), - output("proj-narrate", vec![text_event("The room is quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let permits = Arc::new(Semaphore::new(3)); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - permits.clone(), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(70), "Who's there?".into()) - .await - .unwrap(); - - assert_eq!( - permits.available_permits(), - 3, - "a dispatch must release every permit it acquires" - ); - } - - /// PA.f98: `dispatch` takes the subject id exactly as `table_view` prints - /// it in brackets (the full canonical id), not the short `__` - /// actor-tool prefix. Mutation: reverting `parse_dispatch` to match - /// `handle_for` instead of `full_id_text` fails this, because the id - /// given here is longer than 8 hex digits. - #[tokio::test] - async fn dispatch_by_the_printed_full_id_runs_the_persona() { - let fixture = play_world(Some("Mara"), "player-dispatch-full-id").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-narrate", vec![text_event("Quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(71), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(dispatch_result.contains("Mara nods once."), "{dispatch_result}"); - } - - /// PA.f124: `execute_dispatch`'s own summaries print a subject through - /// the library's `id_text` — the same printer `table_view` and - /// `dispatch`'s own matching use — never `{subject:?}`'s raw - /// `SubjectId(...)` debug wrapper, a fifth hand-spelled id spelling. - /// Dispatching the same subject twice in one call reaches the - /// `"already acted"` branch, the one call site printing a subject - /// without a `snapshot` (`subject_label`) to fall back on. Mutation: - /// reverting either print in `execute_dispatch` to `{subject:?}` fails - /// this. - #[tokio::test] - async fn dispatch_summaries_print_the_subject_through_id_text_not_debug() { - let fixture = play_world(Some("Mara"), "player-dispatch-id-text-summary").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let mara_text = subject_id_text(&snapshot, mara); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text.clone(), mara_text.clone()]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-narrate", vec![text_event("Quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(922), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - dispatch_result.contains(&format!("{mara_text} already acted")), - "the repeated subject must be named by id_text's own spelling, not SubjectId(...) debug: {dispatch_result}" - ); - assert!( - !dispatch_result.contains("SubjectId("), - "the debug wrapper must never leak into a dispatch summary: {dispatch_result}" - ); - } - - /// PA.f98: an id naming no dispatchable subject is refused in the tool - /// result, quoting the text given, rather than being silently dropped. - #[tokio::test] - async fn an_unknown_dispatch_id_is_refused_naming_it() { - let fixture = play_world(Some("Mara"), "player-dispatch-unknown-id").await; - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": ["not-a-real-id"]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - // No queued output: if the unknown id were somehow dispatched - // anyway, the persona lane would fault trying to pop from an - // empty queue, and this test would fail loudly rather than - // silently accepting a dropped id. - ScriptedPort::new(vec![output("proj-narrate", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(72), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!( - dispatch_result.contains("`not-a-real-id` names no dispatchable subject"), - "{dispatch_result}" - ); - } - - /// PA.f98: a mixed dispatch list runs every valid id and names every - /// invalid one in the same call's result — neither is dropped, and the - /// valid one still actually acts. - #[tokio::test] - async fn a_mixed_dispatch_list_runs_the_valid_id_and_names_the_invalid_one() { - let fixture = play_world(Some("Mara"), "player-dispatch-mixed").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let round = output( - "r0", - vec![ - call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id, "not-a-real-id"]}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - output("proj-narrate", vec![text_event("Quiet.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(73), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let dispatch_result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(dispatch_result.contains("Mara nods once."), "{dispatch_result}"); - assert!( - dispatch_result.contains("`not-a-real-id` names no dispatchable subject"), - "{dispatch_result}" - ); - } - - /// PA.f114: `PLAY_INSTRUCTIONS` promises an id naming no dispatchable - /// subject is refused, quoting what was given, rather than silently - /// doing nothing. `parse_dispatch` used to drop a non-string entry with - /// `Value::as_str` filtering it out unseen; it is now refused by name. - /// Tested directly against `parse_dispatch`, the exact function the - /// dispatch tool call handler runs the arguments through. - #[tokio::test] - async fn a_non_string_dispatch_entry_is_refused_quoting_it() { - let fixture = play_world(Some("Mara"), "player-dispatch-non-string").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let (subjects, refusals) = parse_dispatch(r#"{"subjects": [42, true]}"#, &snapshot); - assert!(subjects.is_empty()); - assert_eq!( - refusals, - vec![ - "`42` in `subjects` is not a string".to_owned(), - "`true` in `subjects` is not a string".to_owned(), - ] - ); - } - - /// PA.f114: a body that isn't valid JSON, and a body whose JSON isn't an - /// object, are each refused quoting the text given — not the silent - /// empty dispatch `parse_dispatch` used to return for both. - #[tokio::test] - async fn a_malformed_dispatch_body_is_refused_quoting_it() { - let fixture = play_world(Some("Mara"), "player-dispatch-malformed").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - - let (subjects, refusals) = parse_dispatch("not json at all", &snapshot); - assert!(subjects.is_empty()); - assert_eq!(refusals.len(), 1, "{refusals:?}"); - assert!(refusals[0].contains("not valid JSON"), "{refusals:?}"); - assert!(refusals[0].contains("not json at all"), "{refusals:?}"); - - let (subjects, refusals) = parse_dispatch(r#"["a", "b"]"#, &snapshot); - assert!(subjects.is_empty()); - assert_eq!(refusals.len(), 1, "{refusals:?}"); - assert!(refusals[0].contains("not a JSON object"), "{refusals:?}"); - } - - /// PA.f114: `dispatch`'s arguments missing the `subjects` key entirely - /// are refused rather than read as an empty dispatch list. - #[tokio::test] - async fn a_dispatch_body_missing_subjects_key_is_refused() { - let fixture = play_world(Some("Mara"), "player-dispatch-missing-key").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let (subjects, refusals) = parse_dispatch("{}", &snapshot); - assert!(subjects.is_empty()); - assert_eq!(refusals.len(), 1, "{refusals:?}"); - assert!(refusals[0].contains("no `subjects` key"), "{refusals:?}"); - } - - #[tokio::test] - async fn a_question_holds_the_turn_open_across_a_restart() { - let fixture = play_world(None, "player-question").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let opening_key = test_turn_id(60); - - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - // PA.f155/PA.f156: the row's own revision counter must survive the - // restart exactly, neither reset nor re-derived from anything but - // what was last durably committed — checked at three points below: - // right after the question opens, right after the fresh table - // reopens the same store (nothing committed yet, so unchanged), and - // once more after the answer closes the turn (moved again). - let revision_before_restart; - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &fixture.principal, - opening_key.clone(), - "I stand at a crossroads.".into(), - ) - .await - .unwrap(); - revision_before_restart = table.current_turn_view().await.unwrap().revision; - } - // "Restart": a fresh `PlayTable` reopening the same store path. - // `PlayTable::new` is the one production door that mints `PlayPort`; - // this fixture reuses this table's own `play` field for the - // world-drift commit below rather than minting a second one. - let end = output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-after", vec![text_event("You choose left.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - assert_eq!( - table.current_turn_view().await.unwrap().revision, revision_before_restart, - "reopening the store must read the same revision back, not reset or recompute it" - ); - - let (opening_prompt_before, turn_id_before, question_id) = { - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - (turn.opening_prompt.clone(), turn.turn_id.clone(), turn.open_question_id().unwrap()) - }; - - // World drift between the question and the answer: a real snapshot - // taken now would print differently. Mutation M8.2 (rebuilding - // `opening_prompt` on resume) is caught by the assertion below. - table - .play - .submit_patch( - CommandId::new(), - decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "silver", "label": "Silver"}).to_string(), - ) - .unwrap(), - ) - .await - .unwrap(); - - // The answer's own request key (PA.f62/PA.f63): a fresh key, not the - // turn's own identity — the whole point of separating the two is - // that a caller answering a question need not carry the turn's - // original opening key forward. - let answer_key = test_turn_id(61); - table - .run( - &fixture.principal, - answer_key, - PlayRequest { - text: "left".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.opening_prompt, opening_prompt_before, - "opening_prompt must never be rebuilt on resume (mutation M8.2)" - ); - assert_eq!( - turn.turn_id, turn_id_before, - "the turn's own identity, minted once when it opened, never moves" - ); - assert_ne!( - turn.turn_id, opening_key, - "turn_id is minted fresh (PA.f83), never taken from the opening request key" - ); - assert_eq!(turn.state, PlayTurnState::Closed); - assert_eq!( - turn.player_prose, - vec!["I stand at a crossroads.".to_owned(), "left".to_owned()] - ); - assert!(turn.narration.is_some()); - drop(stored); - let revision_after_close = table.current_turn_view().await.unwrap().revision; - assert!( - revision_after_close > revision_before_restart, - "the row's own revision must move again once the answer closes the turn: {revision_before_restart} -> {revision_after_close}" - ); - } - - #[tokio::test] - async fn a_resumed_call_resubmits_its_recorded_body() { - let fixture = play_world(None, "player-resume-body").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(50); - - // `PlayTable::new` is the one production door that mints `PlayPort` - // (pinned by `soul_exactly_one_production_site_mints_play_port`); - // this fixture reuses the table's own `play` field for the - // out-of-band pre-crash commit below rather than minting a second - // one. - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-resume", vec![text_event("Still here.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let patch = decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "gold", "label": "Gold"}).to_string(), - ) - .unwrap(); - let command_id = derived_command_id(&turn_id, 0, 0); - // The exact crash window this rule protects: the kernel already - // committed the call, but the store never recorded its result. - table.play.submit_patch(command_id, patch.clone()).await.unwrap(); - - let snapshot = fixture.world.snapshot().await.unwrap(); - let opening_prompt = format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "Make gold." - ); - let call_id = "c0".to_owned(); - let round = output( - "r0", - vec![ - call_event(&call_id, "declare_resource", serde_json::json!({"handle": "gold", "label": "Gold"})), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt, - player_prose: vec!["Make gold.".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id, - round: 0, - slot: 0, - body: Some(RecordedCall::Authoring(patch)), - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let snapshot = fixture.world.snapshot().await.unwrap(); - let view = table_view(&snapshot); - assert_eq!( - view.matches("Gold").count(), - 1, - "the resumed call must resubmit its recorded body idempotently, not double-commit: {view}" - ); - } - - /// Mutation M8.3: rebuilding the body from a fresh snapshot on resume, - /// instead of resubmitting the recorded `(opportunity, invocation)` - /// verbatim, fails the kernel's own opportunity-identity check — - /// `PA.f41`'s rule that a caller's opportunity must equal the kernel's - /// currently issued copy exactly — the moment a second `declare_resource` - /// between the crash and the resume has changed the world's revision. - #[tokio::test] - async fn a_resumed_actor_call_resubmits_its_recorded_body() { - let fixture = play_world(None, "player-resume-actor").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(51); - - // `PlayTable::new` is the one production door that mints `PlayPort`; - // this fixture reuses the table's own `play` field below instead of - // minting a second one. - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-resume", vec![text_event("Still here.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let snapshot = fixture.world.snapshot().await.unwrap(); - let player = player_id(&snapshot); - let (opportunity, invocation) = - decode_actor_call(&snapshot, player, "", "speak", &serde_json::json!({"text": "hold fast"}).to_string()) - .unwrap(); - let command_id = derived_command_id(&turn_id, 0, 0); - // The crash window: the kernel already committed the exercise, but - // the store never recorded its result. The world then drifts - // (a second, unrelated commit) before the resume runs. - fixture - .world - .submit_principal( - PrincipalCommandIntent { - id: command_id, - world_id: opportunity.world_id, - expected_revision: 0, - body: CommandBody::ExerciseDecision { - opportunity: opportunity.clone(), - invocation: invocation.clone(), - }, - }, - &fixture.principal, - ) - .await - .unwrap(); - table - .play - .submit_patch( - CommandId::new(), - decode_authoring_call( - "declare_resource", - &serde_json::json!({"handle": "silver", "label": "Silver"}).to_string(), - ) - .unwrap(), - ) - .await - .unwrap(); - - let opening_prompt = format!( - "{}\n\nThe player writes:\n{}\n", - table_view(&snapshot), - "hold fast" - ); - let call_id = "c0".to_owned(); - let player_handle = handle_for(player); - let round = output( - "r0", - vec![ - call_event( - &call_id, - &format!("{player_handle}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "hold fast"}), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt, - player_prose: vec!["hold fast".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id, - round: 0, - slot: 0, - body: Some(RecordedCall::PlayerAct(opportunity, invocation)), - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - table - .run(&fixture.principal, turn_id, String::new().into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::Closed); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(result, "applied", "a resubmitted actor call must resolve to AlreadyApplied, not a fresh refusal: {result}"); - } - - /// PA.f118: `execute_round`'s own `self.play.snapshot()` failing was - /// reported as untested because no fixture tore the world mailbox down - /// independently of the `WorldMailbox` the whole test depends on — - /// false: `WorldFixture` keeps its own mailbox owner task rather than - /// discarding it, and `.abort()`ing it drops the owner's own channel - /// receiver, so the very next mailbox call resolves - /// `MailboxError::Unavailable` deterministically. A round is left - /// unresolved as if the process had crashed mid-round; resuming it with - /// the mailbox owner aborted must close the turn with the fault - /// recorded rather than propagate the error out of `run` unhandled. - /// Mutation: turning the match arm back into a bare - /// `self.play.snapshot().await?` makes `run` return `Err` instead of - /// `Ok` with the fault recorded, failing this. - #[tokio::test] - async fn a_snapshot_failure_mid_round_closes_with_the_fault_recorded() { - let fixture = play_world(None, "player-round-snapshot-fault").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(910); - - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let call_id = "c0".to_owned(); - let round = output("r0", vec![call_event(&call_id, END_TURN_TOOL, serde_json::json!({}))]); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt: "Opening.".into(), - player_prose: vec!["Who's there?".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id, - round: 0, - slot: 0, - body: None, - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - // The very next mailbox call — the `self.play.snapshot()` - // `execute_round` makes to resolve the unresolved round above — - // fails deterministically once the owner task is gone. - fixture.owner.abort(); - - let outcome = table.run(&fixture.principal, turn_id, String::new().into()).await.unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. })); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.fault.is_some(), - "a snapshot failure mid-round must close the turn with the fault recorded, not propagate: {:?}", - turn.fault - ); - assert_eq!(turn.state, PlayTurnState::Closed); - } - - /// A persona port wrapping `ScriptedPort` that aborts a world mailbox's - /// own owner task the moment its *first* `infer` call completes (PA.f118, - /// PA.f119): aborting only ever kills *future* mailbox calls — the - /// persona lane's own inference calls never touch `self.play` at all — - /// so a first dispatched subject's own Persona turn (two `infer` calls, - /// projector then persona) still finishes normally, and only a *second* - /// dispatched subject's own `execute_dispatch`-internal - /// `self.play.snapshot()` call, made after the first subject's turn - /// completes, is guaranteed to observe the aborted owner. Gives - /// deterministic, in-process control over exactly when the mailbox goes - /// down mid-dispatch, without timing races. - struct AbortAfterFirstPort { - inner: Arc, - owner: StdMutex>>, - } - - impl AbortAfterFirstPort { - fn new(inner: Arc, owner: JoinHandle<()>) -> Arc { - Arc::new(Self { - inner, - owner: StdMutex::new(Some(owner)), - }) - } - } - - #[async_trait::async_trait] - impl InferencePort for AbortAfterFirstPort { - fn prepare(&self, request: InferenceRequest) -> Result { - self.inner.prepare(request) - } - - async fn infer(&self, request: PreparedInference) -> Result { - let result = self.inner.infer(request).await; - if let Some(owner) = self.owner.lock().unwrap().take() { - owner.abort(); - } - result - } - } - - /// PA.f118, PA.f119: the same failure as - /// `a_snapshot_failure_mid_round_closes_with_the_fault_recorded`, at - /// `execute_dispatch`'s own separate `self.play.snapshot()` call site. - /// Mara and Borin are dispatched together in one call; the persona port - /// aborts the world mailbox's own owner task right after Mara's first - /// inference call, so her own dispatch still completes, and Borin's own - /// `execute_dispatch`-internal snapshot then fails deterministically. - /// Mutation: turning that match arm back into a bare - /// `self.play.snapshot().await?` makes `run` return `Err` instead of - /// `Ok` with the fault recorded and Mara's own successful dispatch - /// discarded, failing this. - #[tokio::test] - async fn a_snapshot_failure_mid_dispatch_closes_with_the_fault_recorded() { - let fixture = play_world(Some("Mara"), "player-dispatch-snapshot-fault").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = persona_id(&snapshot); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - - // A second Persona subject, declared through the play table's own - // authoring door — the same mechanism - // `a_persona_cannot_speak_another_dispatched_personas_exact_quote` uses. - let declare = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "borin", - "label": "Borin", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let directory = tempfile::tempdir().unwrap(); - { - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-declare", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![declare]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("declare-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(913), "Introduce Borin.".into()) - .await - .unwrap(); - } - let snapshot = fixture.world.snapshot().await.unwrap(); - let borin = snapshot.subjects.iter().find(|row| row.label == "Borin").unwrap().id; - let mara_text = subject_id_text(&snapshot, mara); - let borin_text = subject_id_text(&snapshot, borin); - - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_text, borin_text]}), - )], - ); - let scripted = ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("Mara nods once.")]), - ]); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - AbortAfterFirstPort::new(scripted, fixture.owner), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - let outcome = table - .run(&fixture.principal, test_turn_id(914), "Who's there?".into()) - .await - .unwrap(); - assert!(matches!(outcome, RunOutcome::Ran { .. })); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert!( - turn.fault.is_some(), - "a snapshot failure mid-dispatch must close the turn with the fault recorded, not propagate: {:?}", - turn.fault - ); - assert_eq!(turn.state, PlayTurnState::Closed); - assert!( - turn.persona_turns.iter().any(|(id, _)| *id == mara), - "Mara's own dispatch, already snapshot-successful before the abort, must still be recorded" - ); - assert!( - !turn.persona_turns.iter().any(|(id, _)| *id == borin), - "Borin's own dispatch must never have started" - ); - } - - // The old `soul_execute_round_and_round_tools_share_the_collision_refusal_call` - // structural test (PA.f118/PA.f119) counted `collision_refusal(` call - // sites in `play.rs` and pinned it at 2, standing in for behaviour it had - // no seam to observe directly. PA.f127/PA.f133 collapsed the duplicate - // fetch-and-check `infer_round` and `execute_round` each ran into one - // shared owner, `round_snapshot` (which itself now calls - // `collision_refusal` exactly once), so that count is 1 by design and the - // structural test is gone. `fetch_round_snapshot`'s own test seam - // (`force_round_snapshot`) now drives a genuine, real-id collision - // through `round_snapshot` directly — see - // `a_forced_handle_collision_refuses_the_round_through_round_snapshot` - // below — which is real behavioural coverage neither `infer_round` nor - // `execute_round` could get independently before this cut, and it covers - // both callers at once because there is only one implementation left to - // call. - - #[tokio::test] - async fn restart_equivalence_of_the_agent_input() { - let directory = tempfile::tempdir().unwrap(); - let world_path = directory.path().join("world.cc"); - let store_path = directory.path().join("play-turn-v1.cc"); - let backup_path = directory.path().join("play-turn-v1.backup.cc"); - let principal = VerifiedPrincipalEvidence::new("player-restart-equiv", far_future()); - - let (world, owner) = WorldMailbox::open(&world_path).unwrap(); - let receipt = world - .create( - CreateWorldIntent { - id: CommandId::new(), - title: "Restart Equivalence".into(), - brief: String::new(), - human_subject_label: "Player".into(), - narrative_persona_label: None, - operational_agent_label: None, - targets: BTreeMap::new(), - jurisdictions: Vec::new(), - lens_weights: LensWeights::new(BTreeMap::from([(Lens::Hearth, 1)])), - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - let snapshot = world.snapshot().await.unwrap(); - world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: receipt.world_id, - expected_revision: snapshot.revision, - body: CommandBody::ActivateWorld, - }, - &principal, - ) - .await - .unwrap(); - - { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output("proj-1", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let end = output("r0", vec![call_event("c0", END_TURN_TOOL, serde_json::json!({}))]); - let table = PlayTable::new( - world.clone(), - personas, - ScriptedPort::new(vec![end]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&principal, test_turn_id(30), "Turn one.".into()) - .await - .unwrap(); - } - // Turn two opens and asks a question, fixing its own `turn_id` in - // the store (PA.f83: minted fresh, not derived from the key) before - // the backup is taken — a fresh `begin_turn` mints a new random - // `turn_id` every time it runs, so two *independent* openings from - // the same prior state are no longer expected to produce identical - // requests. Resuming one *already-open* turn across a restart still - // must, and that is what this test now checks. - let (question_id, turn2_id) = { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let ask = output( - "r0", - vec![call_event("c0", ASK_PLAYER_TOOL, serde_json::json!({"question": "Which path?"}))], - ); - let table = PlayTable::new( - world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&principal, test_turn_id(31), "Turn two.".into()) - .await - .unwrap(); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - (turn.open_question_id().unwrap(), turn.turn_id.clone()) - }; - std::fs::copy(&store_path, &backup_path).unwrap(); - let answer_end = || output("r1", vec![call_event("c1", END_TURN_TOOL, serde_json::json!({}))]); - - // Path A: no restart, answer the already-open question directly. - let request_a = { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output("proj-2a", vec![text_event("Still fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = ScriptedPort::new(vec![answer_end()]); - let table = PlayTable::new( - world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &principal, - test_turn_id(32), - PlayRequest { - text: "left".into(), - answers: Some(question_id.clone()), - }, - ) - .await - .unwrap(); - port.seen_requests().into_iter().next().unwrap() - }; - - // Restart: drop every live clone of the world mailbox, join its - // owner task, restore the store to its post-question state, and - // reopen both. - std::fs::copy(&backup_path, &store_path).unwrap(); - drop(world); - owner.await.unwrap(); - let (world, _owner) = WorldMailbox::open(&world_path).unwrap(); - - let request_b = { - let personas = PersonaLane::new( - ControllerPort::new(world.clone()), - ScriptedPort::new(vec![output("proj-2b", vec![text_event("Still fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port = ScriptedPort::new(vec![answer_end()]); - let table = PlayTable::new( - world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run( - &principal, - test_turn_id(32), - PlayRequest { - text: "left".into(), - answers: Some(question_id.clone()), - }, - ) - .await - .unwrap(); - let stored = table.store.lock().await; - assert_eq!(stored.current().unwrap().turn_id, turn2_id, "the resumed turn's own id must survive the restart unchanged"); - drop(stored); - port.seen_requests().into_iter().next().unwrap() - }; - - assert_eq!(request_a, request_b); - } - - // --- Fix batch 8a-fix ------------------------------------------------ - - /// The bracketed canonical id following one label in `table_view`'s own - /// rendering, the same parsing `a_mint_and_a_ruled_fact_commit_as_play` - /// already relies on for a declared resource's id. - fn bracketed_id_after(view: &str, needle: &str) -> String { - view.split(needle) - .nth(1) - .and_then(|rest| rest.split(']').next()) - .unwrap_or_else(|| panic!("`{needle}` not found in table_view: {view}")) - .to_owned() - } - - /// PA.f61: `set_persona_material` refuses an existing subject. - #[tokio::test] - async fn set_persona_material_on_an_existing_subject_is_refused() { - let fixture = play_world(None, "player-persona-existing").await; - let before = fixture.world.snapshot().await.unwrap(); - let player = subject_id_text(&before, player_id(&before)); - let round = output( - "r0", - vec![ - call_event( - "c0", - "set_persona_material", - serde_json::json!({ - "subject": {"ref": "existing", "value": player}, - "values": ["steadfast"], - "voice": "flat and even", - "memories": [], - "reads": [], - }), - ), - call_event("c1", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(400), "Rewrite the player.".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result.contains("existing subject"), "{result}"); - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "a refused run must commit nothing"); - } - - /// PA.f61 + PA.f54: `set_persona_material` stands for a subject the same - /// authoring run declares — the draft handle resolves only because the - /// whole run decodes as one shared patch. - #[tokio::test] - async fn set_persona_material_on_a_same_run_declared_subject_commits() { - let fixture = play_world(None, "player-persona-declared").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let affordance_id = bracketed_id_after(&table_view(&snapshot), "speak ["); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_subject", - serde_json::json!({ - "handle": "npc", - "label": "A Stranger", - "kind": "person", - "controller": {"type": "narrative_persona"}, - "affordances": [{"ref": "existing", "value": affordance_id}], - "position": null, - }), - ), - call_event( - "c1", - "set_persona_material", - serde_json::json!({ - "subject": {"ref": "draft", "value": "npc"}, - "values": ["steadfast"], - "voice": "flat and even", - "memories": ["the first watch"], - "reads": [], - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(401), "Introduce a stranger.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - let view = table_view(&after); - assert!(view.contains("A Stranger"), "{view}"); - assert!(view.contains("flat and even"), "the persona material did not commit: {view}"); - } - - /// PA.f54: a maximal run of authoring calls commits atomically, as one - /// kernel command — a later call's declaration resolving an earlier - /// call's draft handle in the same run is possible only because both - /// land in one shared patch. - #[tokio::test] - async fn an_authoring_run_commits_as_one_patch() { - let fixture = play_world(None, "player-run-atomic").await; - let before = fixture.world.snapshot().await.unwrap(); - let commons_id = bracketed_id_after(&table_view(&before), "The Commons ["); - - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "declare_route", - serde_json::json!({ - "handle": "hall", - "label": "The Hall", - "from": {"ref": "existing", "value": commons_id}, - "to": {"ref": "draft", "value": "cell"}, - "access": {"access": "public"}, - "cost": 1, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(402), "Build a hall to a cell.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!( - after.revision, - before.revision + 1, - "the whole run must commit as one kernel command, not two" - ); - let view = table_view(&after); - assert!(view.contains("A Narrow Cell"), "{view}"); - assert!(view.contains("The Hall"), "{view}"); - } - - /// PA.f54: a refusal inside a run names the exact call that caused it, - /// through the run's own `DecodedBatch` site map, and nothing in the run - /// commits — not even an earlier call's own declaration. - #[tokio::test] - async fn a_refusal_in_a_run_names_the_failing_call_and_nothing_commits() { - let fixture = play_world(None, "player-run-refusal").await; - let before = fixture.world.snapshot().await.unwrap(); - let player = handle_for(player_id(&before)); - - // `declare_place` (call #0) decodes cleanly; `relocate` (call #1) — - // an *operation*, not a declaration — references a route draft - // handle nothing in this run ever declares. An operation mismatch's - // site is attributed by exact call index through the run's own - // `DecodedBatch`, unlike a declaration's own (handle-named) site. - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "relocate", - serde_json::json!({ - "subject": {"ref": "existing", "value": player}, - "via": {"ref": "draft", "value": "no_such_route"}, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(403), "Build a hall to nowhere.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "a refused run must commit nothing"); - assert!( - !table_view(&after).contains("A Narrow Cell"), - "the first call's own declaration must not commit either" - ); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result0 = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - let result1 = turn - .calls - .iter() - .find(|call| call.round == 0 && call.slot == 1) - .and_then(|call| call.result.clone()) - .unwrap(); - assert!(result1.contains("call #1"), "{result1}"); - assert_eq!(result0, result1, "every call in a run shares the run's own single outcome"); - } - - /// One half of PA.f87's replacement for the old source-text-position - /// pin: given a store that already holds a run's own recorded body from - /// before a crash (built directly here, the same "crash window" shape - /// `a_resumed_call_resubmits_its_recorded_body` uses for a single - /// call), resume must resubmit that exact recorded body idempotently — - /// one kernel commit, not two. This alone does not prove the record - /// happens *before* the submit in a first, non-resumed attempt; see - /// `commit_authoring_run_records_the_run_even_when_its_submit_is_refused` - /// for that half. - #[tokio::test] - async fn a_resumed_authoring_run_resubmits_its_recorded_shared_patch() { - let fixture = play_world(None, "player-resume-run").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(52); - - // `PlayTable::new` is the one production door that mints `PlayPort`; - // this fixture reuses the table's own `play` field for the - // out-of-band pre-crash commit below rather than minting a second - // one. - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-resume-run", vec![text_event("Still here.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - - let before = fixture.world.snapshot().await.unwrap(); - let commons_id = bracketed_id_after(&table_view(&before), "The Commons ["); - let declare_place_args = - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}).to_string(); - let declare_route_args = serde_json::json!({ - "handle": "hall", - "label": "The Hall", - "from": {"ref": "existing", "value": commons_id}, - "to": {"ref": "draft", "value": "cell"}, - "access": {"access": "public"}, - "cost": 1, - }) - .to_string(); - let decoded = decode_authoring_calls(&[ - ("declare_place", declare_place_args.as_str()), - ("declare_route", declare_route_args.as_str()), - ]) - .unwrap(); - let patch = decoded.patch.clone(); - let command_id = derived_command_id(&turn_id, 0, 0); - // The exact crash window this rule protects: the kernel already - // committed the run's own shared patch, but the store never - // recorded its result. - table.play.submit_patch(command_id, patch.clone()).await.unwrap(); - - let opening_prompt = format!("{}\n\nThe player writes:\n{}\n", table_view(&before), "Build a hall."); - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "declare_route", - serde_json::json!({ - "handle": "hall", - "label": "The Hall", - "from": {"ref": "existing", "value": commons_id}, - "to": {"ref": "draft", "value": "cell"}, - "access": {"access": "public"}, - "cost": 1, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let crashed_turn = PlayTurn { - turn_id: turn_id.clone(), - applied_keys: Vec::new(), - opening_prompt, - player_prose: vec!["Build a hall.".into()], - rounds: vec![round], - calls: vec![CallRecord { - call_id: "c0".into(), - round: 0, - slot: 0, - body: Some(RecordedCall::Authoring(patch)), - result: None, - }], - persona_turns: Vec::new(), - question: None, - question_token: None, - refusal: None, - narration: None, - fault: None, - state: PlayTurnState::Running, - }; - table.store.lock().await.commit(crashed_turn).unwrap(); - - table.run(&fixture.principal, turn_id, String::new().into()).await.unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - // One commit, not two: the world's own revision counter is the - // unambiguous signal — `table_view` prints a place's own label a - // second time wherever a route mentions it as an endpoint, so a - // substring count is not (this mirrors `an_authoring_run_commits_as_one_patch`'s - // own revision-based check). - assert_eq!( - after.revision, - before.revision + 1, - "the resumed run must resubmit its recorded shared patch idempotently, not double-commit" - ); - let view = table_view(&after); - assert!(view.contains("A Narrow Cell"), "{view}"); - assert!(view.contains("The Hall"), "{view}"); - } - - /// The other half of PA.f87: `commit_authoring_run` must record the - /// run's own decoded patch *before* it ever finds out whether - /// `submit_patch` will succeed — not only after a success, and not only - /// when a caller happens to resume a pre-crashed turn. Proven with a - /// run whose kernel submission is genuinely refused (the same - /// `relocate`-to-a-nonexistent-route shape - /// `a_refusal_in_a_run_names_the_failing_call_and_nothing_commits` - /// uses): the decode itself succeeds, so a correct `commit_authoring_run` - /// records the run's own patch regardless of what the kernel later says - /// about it. Mutations: delete - /// `record_call(..., Some(RecordedCall::Authoring(...)), ...)`, or move - /// it inside the `Ok(_receipt) =>` success arm of the `submit_patch` - /// match (making the record conditional on success); both leave the - /// anchor slot's own `body` unset after this refusal, failing this test. - #[tokio::test] - async fn commit_authoring_run_records_the_run_even_when_its_submit_is_refused() { - let fixture = play_world(None, "player-record-before-refused-submit").await; - let before = fixture.world.snapshot().await.unwrap(); - let player = subject_id_text(&before, player_id(&before)); - - let round = output( - "r0", - vec![ - call_event( - "c0", - "declare_place", - serde_json::json!({"handle": "cell", "label": "A Narrow Cell", "container": null}), - ), - call_event( - "c1", - "relocate", - serde_json::json!({ - "subject": {"ref": "existing", "value": player}, - "via": {"ref": "draft", "value": "no_such_route"}, - }), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Quiet.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(53), "Build a hall to nowhere.".into()) - .await - .unwrap(); - - let after = fixture.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision, "a refused run must commit nothing to the world"); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let anchor = turn.calls.iter().find(|call| call.round == 0 && call.slot == 0).unwrap(); - assert!( - matches!(anchor.body, Some(RecordedCall::Authoring(_))), - "the run's own decoded patch must be recorded even though the kernel refused it: {:?}", - anchor.body - ); - assert!( - anchor.result.as_deref().unwrap_or_default().contains("refused"), - "{:?}", - anchor.result - ); - } - - /// PA.f64: a dispatched Persona's own prose reaches the agent as the - /// dispatch call's own tool result, verbatim, both on the round it - /// happened and after a restart resumes a later round. - #[tokio::test] - async fn a_dispatched_personas_prose_reaches_the_agents_next_request() { - let fixture = play_world(Some("Mara"), "player-dispatch-prose").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = subject_id_text(&snapshot, persona_id(&snapshot)); - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara.clone()]}), - )], - ); - let ask_round = output( - "r1", - vec![call_event( - "c1", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Well?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("The bolt is thrown; nobody enters here.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let port = ScriptedPort::new(vec![dispatch_round, ask_round]); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(404), "Who's there?".into()) - .await - .unwrap(); - - let seen = port.seen_requests(); - let round1_request = format!("{:?}", seen.get(1).expect("round 1's own request was seen")); - assert!( - round1_request.contains("The bolt is thrown; nobody enters here."), - "round 1's request must carry Mara's own prose verbatim: {round1_request}" - ); - let question_id = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - // Release the store's exclusive custody before reopening it below — - // the same "restart" precedent `a_question_holds_the_turn_open_across_a_restart` - // relies on: a fresh `PlayTable` is the only production door onto a - // reopened store. - drop(table); - - // The same request, rebuilt after a restart reopening the same - // store, must carry the same prose (PA.f64's "including after a - // resume"). - let personas2 = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj-after", vec![text_event("The hall goes still.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let port2 = ScriptedPort::new(vec![ask_round_repeat()]); - let table2 = PlayTable::new( - fixture.world.clone(), - personas2, - port2.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - // Answering the still-open question forces a fresh round to be - // inferred, rebuilding the conversation from the stored record. - table2 - .run( - &fixture.principal, - test_turn_id(405), - PlayRequest { - text: "Answer.".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap(); - let seen2 = port2.seen_requests(); - let resumed_request = format!("{:?}", seen2.first().unwrap()); - assert!( - resumed_request.contains("The bolt is thrown; nobody enters here."), - "a resumed request must still carry the recorded Persona prose: {resumed_request}" - ); - } - - fn ask_round_repeat() -> InferenceOutput { - output( - "r2", - vec![call_event("c2", END_TURN_TOOL, serde_json::json!({}))], - ) - } - - /// PA.f69 S1: a Persona's own exact quote of its own prose is accepted — - /// mutating `span_is_exact` to check the *player's* prose instead of the - /// acting Persona's own recorded turn fails this, since the quote below - /// never appears in the player's own words. - #[tokio::test] - async fn a_persona_acting_on_its_own_exact_quote_is_accepted() { - let fixture = play_world(Some("Mara"), "player-persona-exact-quote").await; - let snapshot = fixture.world.snapshot().await.unwrap(); - let mara = handle_for(persona_id(&snapshot)); - let mara_id = subject_id_text(&snapshot, persona_id(&snapshot)); - let dispatch_round = output( - "r0", - vec![call_event( - "c0", - DISPATCH_TOOL, - serde_json::json!({"subjects": [mara_id]}), - )], - ); - let speak_round = output( - "r1", - vec![ - call_event( - "c1", - &format!("{mara}{HANDLE_SEPARATOR}speak"), - serde_json::json!({"text": "I nod and say nothing"}), - ), - call_event("c2", END_TURN_TOOL, serde_json::json!({})), - ], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![ - output("proj-mara", vec![text_event("Mara considers the player.")]), - output("persona-mara", vec![text_event("I nod and say nothing")]), - output("proj-narrate", vec![text_event("The room settles.")]), - ]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![dispatch_round, speak_round]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table - .run(&fixture.principal, test_turn_id(406), "Who's there?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - let result = turn - .calls - .iter() - .find(|call| call.round == 1 && call.slot == 0) - .and_then(|call| call.result.clone()) - .unwrap(); - assert_eq!(result, "applied", "an exact quote of the Persona's own prose must be accepted: {result}"); - } - - /// PA.f65: an empty or whitespace-only answer is refused before it is - /// recorded; the turn stays exactly as it was. - #[tokio::test] - async fn an_empty_answer_is_refused_before_it_is_recorded() { - let fixture = play_world(None, "player-empty-answer").await; - let directory = tempfile::tempdir().unwrap(); - let store_path = directory.path().join("play-turn-v1.cc"); - let turn_id = test_turn_id(410); - let ask = output( - "r0", - vec![call_event( - "c0", - ASK_PLAYER_TOOL, - serde_json::json!({"question": "Which way?"}), - )], - ); - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - ScriptedPort::new(vec![ask]), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - &store_path, - ) - .unwrap(); - table - .run(&fixture.principal, turn_id.clone(), "I stand at a crossroads.".into()) - .await - .unwrap(); - let question_id = table.store.lock().await.current().unwrap().open_question_id().unwrap(); - - let error = table - .run( - &fixture.principal, - test_turn_id(411), - PlayRequest { - text: " ".into(), - answers: Some(question_id), - }, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlayError::EmptyAnswer { .. }), "{error:?}"); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!(turn.state, PlayTurnState::AwaitingPlayer, "an empty answer must not resume the turn"); - assert_eq!(turn.question.as_deref(), Some("Which way?")); - } - - struct AlwaysFaultyPort { - calls: std::sync::atomic::AtomicUsize, - } - - impl AlwaysFaultyPort { - fn new() -> Self { - Self { - calls: std::sync::atomic::AtomicUsize::new(0), - } - } - - fn call_count(&self) -> usize { - self.calls.load(std::sync::atomic::Ordering::SeqCst) - } - } - - #[async_trait::async_trait] - impl InferencePort for AlwaysFaultyPort { - fn prepare(&self, _request: InferenceRequest) -> Result { - self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Err(InferenceFault::recovery_required("provider unavailable")) - } - - async fn infer(&self, _request: PreparedInference) -> Result { - Err(InferenceFault::recovery_required("provider unavailable")) - } - } - - /// PA.f102: `RecoveryRequired` is renamed honestly from the prior - /// "exhausted beyond retries" framing this test used to carry — it never - /// actually drove a `Retryable` fault's own retry-and-exhaust path at - /// all; every one of its faults was `RecoveryRequired`, which never - /// retries and closes on its first occurrence. Asserts the call count - /// directly: exactly one `prepare` call, not the whole `ROUND_RETRY_BUDGET` - /// a `Retryable` fault would have spent. Mutation: making - /// `RecoveryRequired` retry (see `a_retryable_fault_exhausts_its_budget_then_closes` - /// for the sibling budget-exhaustion mutation) fails this by driving the - /// call count above one. - #[tokio::test] - async fn a_recovery_required_fault_makes_one_call_then_closes_with_its_detail() { - let fixture = play_world(None, "player-recovery-required").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let port = Arc::new(AlwaysFaultyPort::new()); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - - table - .run(&fixture.principal, test_turn_id(420), "Hello?".into()) - .await - .unwrap(); - - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "a RecoveryRequired fault must close the turn, not leave it Running" - ); - assert_eq!( - turn.fault.as_deref(), - Some("provider unavailable"), - "the fault's own detail must be recorded" - ); - assert_eq!( - port.call_count(), - 1, - "RecoveryRequired must never retry: exactly one call, not the whole retry budget" - ); - } - - /// PA.f102: a persistent `Retryable` fault, unlike `RecoveryRequired`, - /// does retry — but only up to `ROUND_RETRY_BUDGET` times, then closes - /// with the fault's own detail. Total calls: the initial attempt plus - /// one retry per absorbed fault, `ROUND_RETRY_BUDGET + 1`. Mutations: - /// raising the budget or removing the cap both fail this by changing the - /// exact call count the test pins. - #[tokio::test] - async fn a_retryable_fault_exhausts_its_budget_then_closes() { - let fixture = play_world(None, "player-retryable-exhausted").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - // Pinned expectation, deliberately *not* expressed as - // `ROUND_RETRY_BUDGET + 1`: computing the expected count from the - // same symbol the production code reads would let a mutation that - // raises the budget silently drag this test's own expectation along - // with it. The queue below supplies comfortably more faults than - // today's real budget can consume, so a raised or uncapped budget - // shows up as a call count above the pinned `13`. - const PINNED_EXPECTED_CALLS: usize = ROUND_BUDGET + 1; - assert_eq!(ROUND_RETRY_BUDGET, ROUND_BUDGET, "this pin assumes today's retry budget; update it deliberately if that changes"); - let directory = tempfile::tempdir().unwrap(); - let port = DispositionPort::new( - std::iter::repeat_with(|| Err(InferenceFault::retryable("transient provider hiccup"))) - .take(PINNED_EXPECTED_CALLS + 10) - .collect(), - ); - let table = PlayTable::new( - fixture.world.clone(), - personas, - port.clone(), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - // PA.f122: production and test run the identical retry_delay code - // path — only the base a test explicitly sets differs, so this - // test's own dozen-plus retries settle instantly. Mutation: - // `retry_delay` ignoring `retry_delay_base_ms` and always scaling - // from the production `RETRY_DELAY_BASE_MS` instead pushes the - // dozen-plus real sleeps below past 30 real seconds; the bound here - // is generously above instant and nowhere near that. - table.set_retry_delay_base_ms(0); - let started = std::time::Instant::now(); - - table - .run(&fixture.principal, test_turn_id(421), "Hello?".into()) - .await - .unwrap(); - - assert!( - started.elapsed() < Duration::from_secs(2), - "a zero retry_delay_base_ms must make every retry settle instantly, not scale from the production base: {:?}", - started.elapsed() - ); - let stored = table.store.lock().await; - let turn = stored.current().unwrap(); - assert_eq!( - turn.state, - PlayTurnState::Closed, - "a Retryable fault exhausting its budget must close the turn" - ); - assert_eq!(turn.fault.as_deref(), Some("transient provider hiccup")); - assert_eq!( - port.call_count(), - PINNED_EXPECTED_CALLS, - "exactly the initial attempt plus ROUND_RETRY_BUDGET retries, no more and no fewer" - ); - } - - /// PA.f102: the production backoff formula itself, never the - /// `#[cfg(test)]`-hidden zero delay: it grows, and it caps. - #[test] - fn backoff_delay_grows_then_caps() { - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 0), Duration::from_millis(100)); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 1), Duration::from_millis(200)); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 2), Duration::from_millis(400)); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 5), Duration::from_millis(3_200), "not yet capped"); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 7), Duration::from_millis(12_800), "not yet capped"); - assert_eq!(backoff_delay(RETRY_DELAY_BASE_MS, 8), Duration::from_millis(20_000), "capped"); - assert_eq!( - backoff_delay(RETRY_DELAY_BASE_MS, 1_000_000), - Duration::from_millis(20_000), - "a very large attempt count must stay capped, never overflow or panic" - ); - assert_eq!( - backoff_delay(0, 6), - Duration::ZERO, - "a zero base (a test's own setting) must scale to zero at every attempt" - ); - } - - /// Captures what a tracing subscriber writes, for the test that reads the - /// warn line a fault closes a turn with. - #[derive(Clone, Default)] - struct LogCapture(Arc>>); - - impl std::io::Write for LogCapture { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture { - type Writer = LogCapture; - - fn make_writer(&'a self) -> Self::Writer { - self.clone() - } - } - - impl LogCapture { - fn text(&self) -> String { - String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() - } - } - - fn capture_logs() -> (LogCapture, tracing::subscriber::DefaultGuard) { - let capture = LogCapture::default(); - let guard = tracing::subscriber::set_default( - tracing_subscriber::fmt() - .with_writer(capture.clone()) - .with_ansi(false) - .finish(), - ); - (capture, guard) - } - - /// R1 and R2. A turn closed by a fault logs its id and the fault's - /// detail at warn, and the owner's card shows the fixed fault row until - /// the view stops reporting a fault. Mutations: dropping the `warn!` in - /// `close_with_fault` fails the log assertions; `fault: false` in - /// `current_turn_view` fails the row assertion. - #[tokio::test] - async fn a_fault_closed_turn_is_logged_and_shown_on_the_card() { - let (logs, _guard) = capture_logs(); - let fixture = play_world(None, "player-fault-card").await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let directory = tempfile::tempdir().unwrap(); - let table = PlayTable::new( - fixture.world.clone(), - personas, - Arc::new(AlwaysFaultyPort::new()), - "gpt-5.6-terra".into(), - Arc::new(Semaphore::new(2)), - directory.path().join("play-turn-v1.cc"), - ) - .unwrap(); - table.run(&fixture.principal, test_turn_id(430), "Hello?".into()).await.unwrap(); - let turn_id = table.current_turn_view().await.unwrap().turn_id; - - let line = logs - .text() - .lines() - .find(|line| line.contains("play turn closed on a fault")) - .map(str::to_owned) - .expect("closing a turn on a fault must log a line"); - assert!(line.contains("WARN"), "{line}"); - assert!(line.contains(&turn_id), "the line must name the turn: {line}"); - assert!(line.contains("provider unavailable"), "the line must carry the fault detail: {line}"); - - let world = fixture.world.snapshot().await.unwrap(); - let view = table.current_turn_view().await.unwrap(); - assert!(view.fault); - let surface = - crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&view), true).unwrap(); - let row = find_surface_node(&surface, "world.play.fault") - .expect("a fault-closed turn must show the fault row"); - let encoded = serde_json::to_string(row).unwrap(); - assert!(!encoded.contains("provider unavailable"), "the row must not carry the detail: {encoded}"); - - let healthy = PlayTurnView { fault: false, ..view }; - let surface = - crate::eve::authenticated_surface("player-fault-card", Some(&world), Some(&healthy), true).unwrap(); - assert!(find_surface_node(&surface, "world.play.fault").is_none()); - } - - /// A PlayTable over the real local port at `endpoint`, with the reply - /// timeout `timeout` (None is production's) and no retry sleep. - async fn local_play_table( - endpoint: std::net::SocketAddr, - timeout: Option, - account: &str, - directory: &std::path::Path, - ) -> (WorldFixture, PlayTable) { - let fixture = play_world(None, account).await; - let personas = PersonaLane::new( - ControllerPort::new(fixture.world.clone()), - ScriptedPort::new(vec![output("proj", vec![text_event("Fine.")])]), - "gpt-5.6-sol".into(), - "gpt-5.6-sol".into(), - ) - .unwrap(); - let binding = ghostlight::LocalBinding { - endpoint, - model_prefix: "local/".into(), - caller_runtime_id: "play-test-runtime".into(), - }; - let inference = match timeout { - Some(timeout) => ghostlight::open_local_port_with_timeout(binding, timeout).unwrap(), - None => ghostlight::open_inference(None, None, Some(binding), 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 deleted file mode 100644 index ae58076..0000000 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ /dev/null @@ -1,7429 +0,0 @@ -//! One-process runtime for the sealed world owner. - -use crate::{ - app_session::{AppSessionOwner, RefreshRefusal}, - eve::{self, EveCommandInvocation}, - heimdall::{self, HeimdallClient, HeimdallDenied, SessionRefresher, VerificationUnavailable}, - idunn_health::{ - IDUNN_RUNTIME_CANDIDATE_BIND_ENVIRONMENT, ProcessWriteLeaseGuard, RuntimePresencePublisher, - TARGET as GHOSTLIGHT_TARGET, - }, - mesh::{self, MeshPublisher, MeshRuntimeIdentity}, - play::{Admission, PlayRequest, PlayTable, PlayTurnState, PlayTurnView}, -}; -use ghostlight::{ - CONSUMER_BODY_LIMIT, CommandBody, CommandId, ConnectorBinding, ConsumerPort, - ConsumerRegistry, ControllerError, ControllerModels, ControllerPort, ControllerRunner, ControllerWorkCustody, - 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 sha2::{Digest, Sha256}; -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, -} - -/// What the owner states to begin a world: three fields, nothing else. Every -/// other choice a world is born with (no scale targets, no jurisdiction roots, -/// no persona or agent label, uniform lens weights) is decided in -/// `begin_intent`, so a client cannot author them and the surface does not -/// carry them. -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct BeginPayload { - title: String, - /// The world's premise in the owner's words, projected to every lane as - /// guidance. Required, may be empty. - brief: String, - subject_label: String, -} - -/// 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)) -} - -#[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, -} - -/// `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), - #[error("the begin stopped at its {step} step: {cause}")] - BeginStopped { step: &'static str, cause: String }, -} - -/// 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) -} - -/// What Begin does after the create, and nothing else decides it: the kernel -/// commands it submits in order, each as its own command. The kernel judges -/// each one; the sequence stops at the first refusal. Where a seed vault is -/// configured the sequence ends at the approved Draft, because seeding is the -/// owner's Draft-only lane and Begin must not close it by activating; the -/// card then offers the seed controls and Activate. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum BeginStep { - Approve, - Activate, -} - -impl BeginStep { - fn name(self) -> &'static str { - match self { - Self::Approve => "approve", - Self::Activate => "activate", - } - } - - fn reached(self) -> &'static str { - match self { - Self::Approve => "approved", - Self::Activate => "activated", - } - } - - fn body(self) -> CommandBody { - match self { - Self::Approve => CommandBody::ApproveDraft, - Self::Activate => CommandBody::ActivateWorld, - } - } -} - -fn begin_plan(seed_available: bool) -> &'static [BeginStep] { - if seed_available { - &[BeginStep::Approve] - } else { - &[BeginStep::Approve, BeginStep::Activate] - } -} - -/// The world a Begin creates: the owner's three fields and the defaults this -/// route owns (no scale targets, no jurisdiction roots, no persona or agent -/// label, Dungeon's uniform lens policy). -fn begin_intent(id: CommandId, payload: BeginPayload) -> CreateWorldIntent { - CreateWorldIntent { - id, - title: payload.title, - brief: payload.brief, - human_subject_label: payload.subject_label, - narrative_persona_label: None, - operational_agent_label: None, - targets: BTreeMap::new(), - jurisdictions: Vec::new(), - lens_weights: uniform_lens_weights(), - } -} - -/// The id of one of the kernel commands a Begin submits after its create: -/// derived from the Begin's own key and the step's name, so a replayed Begin -/// replays each of its commands through the kernel's own idempotency ledger -/// instead of submitting new ones. -fn begin_step_id(begin_key: &str, step: BeginStep) -> Result { - let key = uuid::Uuid::parse_str(begin_key).map_err(|_| KernelError::InvalidCommandId)?; - let digest = Sha256::new() - .chain_update(b"ghostlight.world.begin.v1") - .chain_update(key.as_bytes()) - .chain_update(step.name().as_bytes()) - .finalize(); - let mut bytes = [0_u8; 16]; - bytes.copy_from_slice(&digest[..16]); - CommandId::parse_uuid(&uuid::Uuid::from_bytes(bytes).to_string()) -} - -/// `world.begin`, the one way a world is made: the owner's one click. The -/// surface sequences, the kernel decides: create and the steps of -/// `begin_plan` reach it as separate commands, each admitted or refused on -/// its own, and the sequence stops at the first refusal. What the kernel -/// committed before it stays committed, and the card then offers the next -/// step's own button. When the owner is not the world's only required -/// approver the kernel refuses the activate step. -async fn begin_world( - state: &AppState, - verified_principal: &VerifiedPrincipalEvidence, - begin_key: &str, - command_id: CommandId, - payload: BeginPayload, -) -> Result { - let created = state - .world - .create(begin_intent(command_id, payload), verified_principal) - .await - .map_err(|error| stopped_at("create", map_mailbox(error)))?; - let mut reached = "created"; - for &step in begin_plan(state.seed_vault_root.is_some()) { - let snapshot = current_world(state) - .await - .ok() - .flatten() - .ok_or(RuntimeCommandError::BeginStopped { - step: step.name(), - cause: "the world could not be read".into(), - })?; - state - .world - .submit_principal( - PrincipalCommandIntent { - id: begin_step_id(begin_key, step).map_err(|error| stopped_at(step.name(), error.into()))?, - world_id: created.world_id, - expected_revision: snapshot.revision, - body: step.body(), - }, - verified_principal, - ) - .await - .map_err(|error| stopped_at(step.name(), map_mailbox(error)))?; - reached = step.reached(); - } - Ok(json!({ - "kind":"begun", - "reached":reached, - "worldId":serde_json::to_value(created.world_id).unwrap_or(Value::Null), - "commitDigest":created.commit_digest - })) -} - -/// A refusal at one step of a Begin: its own variant so the receipt names the -/// step, except an unknown outcome, which stays one (the caller must not read -/// it as a refusal). -fn stopped_at(step: &'static str, error: RuntimeCommandError) -> RuntimeCommandError { - match error { - RuntimeCommandError::OutcomeUnknown(detail) => RuntimeCommandError::OutcomeUnknown(format!("{step}: {detail}")), - other => RuntimeCommandError::BeginStopped { - step, - cause: other.to_string(), - }, - } -} - -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.begin" { - // The detail of a refused payload is not echoed: this route's fields - // are what the owner typed. - let payload: BeginPayload = unwrap_bindings(&invocation.payload) - .ok() - .and_then(|flat| serde_json::from_value(flat).ok()) - .ok_or_else(|| { - RuntimeCommandError::Payload("the begin payload does not satisfy world_begin.v1".into()) - })?; - let key = invocation.operation.idempotency_key.as_deref().unwrap_or(""); - return begin_world(state, verified_principal, key, command_id, payload).await; - } - 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::{CreateJurisdictionIntent, CreationReceipt, 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_begin_and_play_round_trip_through_the_real_client() { - if !eve_client_bridge_is_available() { - panic!( - "world_begin_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 begin_intents = client_intents( - &empty_surface, - &provider, - json!([ - {"set": { - "world.begin.title": "Bridge World", - "world.begin.brief": "A world built by the real client bridge.", - "world.begin.subject": "The Bridge Operator" - }}, - {"click": "world.begin"} - ]), - ); - assert_eq!(begin_intents.len(), 1); - let begun = post(&fixture.state, &fixture.cookie, begin_intents.into_iter().next().unwrap()).await; - assert_eq!(begun["receipt"]["state"], "accepted", "world.begin via the real client: {begun}"); - assert_eq!(begun["receipt"]["reached"], "activated", "{begun}"); - let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - assert!( - serde_json::to_string(&active_surface).unwrap().contains("world.play.card"), - "one Begin through the real client did not reach the play card" - ); - - // 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_begin_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 begin = steps.next().unwrap(); - assert_eq!(begin.label, "world.begin"); - let begun = post(&fixture.state, &fixture.cookie, begin.intent).await; - assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); - assert_eq!(begun["receipt"]["reached"], "activated", "{begun}"); - - 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 two 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_begin_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 begin_intent = client_intents( - &empty_surface, - &provider, - json!([ - {"set": { - "world.begin.title": "Bridge Fixture World", - "world.begin.brief": "A world captured for the committed Eve client bridge fixture.", - "world.begin.subject": "The Fixture Operator" - }}, - {"click": "world.begin"} - ]), - ) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, begin_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 = [begin_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" (`BeginPayload`) — 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_begin_form_filled_only_by_its_labelled_fields_is_still_accepted() { - if !eve_client_bridge_is_available() { - panic!( - "a_begin_form_filled_only_by_its_labelled_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.begin.title": "Untouched Fields World", - "world.begin.subject": "The Owner" - }}, - {"click": "world.begin"} - ]), - ); - 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"], ""); - // The button authors nothing beside `bindings`: the route owns every - // other choice a world is born with. - assert_eq!(intent["payload"].as_object().unwrap().keys().collect::>(), ["bindings"]); - - 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; - draft_world(&fixture.state, &fixture.cookie, "Sibling Field World").await; - - 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; - draft_world(&fixture.state, &fixture.cookie, "Colliding Field World").await; - 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; - draft_world(&fixture.state, &fixture.cookie, "Cutover World").await; - - 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; - draft_world(&fixture.state, &fixture.cookie, "Play Routing World").await; - - 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; - draft_world(&fixture.state, &fixture.cookie, "CAS World").await; - 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}" - ); - } - - async fn activated_fixture(title: &str) -> (Fixture, u64) { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, title).await; - 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; - draft_world(&fixture.state, &fixture.cookie, "Wake World").await; - 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; - draft_world(&fixture.state, &fixture.cookie, "Admission World").await; - 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; - draft_world(&fixture.state, &fixture.cookie, "Owner-Gated World").await; - 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; - draft_world(&fixture.state, &fixture.cookie, "No Play World").await; - - 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; - draft_world(&fixture.state, &fixture.cookie, "No Key World").await; - - 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" - ); - } - - // ---- World.begin ---------------------------------------------------- - - /// The payload the real client builds from the served Begin button, with - /// the three fields typed. Every input comes from the surface this process - /// served, never from a copy in the test. - fn begin_payload_from(surface: &Value, title: &str, brief: &str, subject: &str) -> Value { - untouched_intent( - surface, - "world.begin", - &[("title", json!(title)), ("brief", json!(brief)), ("subject_label", json!(subject))], - )["payload"] - .clone() - } - - /// What a client sends when the player types into the controls named by - /// id: each text lands under the binding that control declares on the - /// served surface. A control bound to the wrong field shows up here. - fn begin_payload_typed_into_controls(surface: &Value, typed: &[(&str, &str)]) -> Value { - fn control<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node["id"] == id { - return Some(node); - } - node["children"].as_array().into_iter().flatten().find_map(|child| control(child, id)) - } - let by_binding: Vec<(String, Value)> = typed - .iter() - .map(|(id, text)| { - let node = control(&surface["surface"]["root"], id).unwrap_or_else(|| panic!("no control {id}")); - (node["stateBindings"][0]["bindingName"].as_str().unwrap().to_owned(), json!(text)) - }) - .collect(); - let pairs: Vec<(&str, Value)> = by_binding.iter().map(|(name, value)| (name.as_str(), value.clone())).collect(); - untouched_intent(surface, "world.begin", &pairs)["payload"].clone() - } - - fn begin_invocation(payload: Value, key: &str) -> Value { - invocation("world.begin", "ghostlight.world_begin.v1", 0, payload, key) - } - - async fn served_surface(fixture: &Fixture) -> Value { - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await - } - - async fn owner_principal(fixture: &Fixture) -> VerifiedPrincipalEvidence { - fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap() - } - - fn typed_begin(title: &str, brief: &str, subject: &str) -> BeginPayload { - BeginPayload { title: title.into(), brief: brief.into(), subject_label: subject.into() } - } - - /// R1 and R2: one Begin reaches an Active world, and the kernel received - /// three separate commands, each admitted on its own. The ledger is the - /// witness: each step's own id replays as already applied at its own - /// revision, so none of them was merged into another or skipped. - /// - /// Mutations: submit no approve (the activate is then refused); give the - /// approve and activate commands the create's id (the kernel's ledger - /// answers the replay of their own ids with a conflict, not a receipt). - #[tokio::test] - async fn begin_commits_create_approve_and_activate_as_three_separate_commands() { - let fixture = fixture().await; - let key = uuid::Uuid::new_v4().to_string(); - let payload = begin_payload_from(&served_surface(&fixture).await, "Begun World", "A premise.", "The Owner"); - let begun = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; - assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); - assert_eq!(begun["receipt"]["kind"], "begun"); - assert_eq!(begun["receipt"]["reached"], "activated"); - - let world = current_world(&fixture.state).await.unwrap().unwrap(); - assert_eq!(world.phase, WorldPhase::Active); - assert_eq!(begun["receipt"]["worldId"], serde_json::to_value(world.world_id).unwrap(), "the receipt names the world it made"); - assert_eq!(world.revision, 2, "genesis, approval and activation are three commits"); - - let principal = owner_principal(&fixture).await; - for (step, revision) in [(BeginStep::Approve, 1), (BeginStep::Activate, 2)] { - let replay = fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: begin_step_id(&key, step).unwrap(), - world_id: world.world_id, - expected_revision: world.revision, - body: step.body(), - }, - &principal, - ) - .await - .unwrap(); - match replay { - SubmitReceipt::AlreadyApplied(receipt) => assert_eq!(receipt.resulting_revision, revision, "{}", step.name()), - SubmitReceipt::Applied(_) => panic!("the {} step was not already in the ledger", step.name()), - } - } - // The create is the command under the Begin's own key. - let create_replay = fixture - .state - .world - .create( - begin_intent(CommandId::parse_uuid(&key).unwrap(), typed_begin("Begun World", "A premise.", "The Owner")), - &principal, - ) - .await - .unwrap(); - assert_eq!(create_replay.commit_digest, begun["receipt"]["commitDigest"].as_str().unwrap()); - } - - /// The commands a Begin submits after its create have ids that are a - /// function of the Begin's whole key and the step's name: two keys that - /// differ only late in the string, or one key's two steps, never share an - /// id. - /// - /// Mutations: hash only a prefix of the key; hash no step name; drop the key's first byte. - #[test] - fn begin_step_ids_depend_on_the_whole_key_and_the_step() { - let key = "123e4567-e89b-12d3-a456-426614174000"; - let late = "123e4567-e89b-12d3-a456-426614174001"; - assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(late, BeginStep::Approve).unwrap()); - let early = "023e4567-e89b-12d3-a456-426614174000"; - assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(early, BeginStep::Approve).unwrap()); - assert_ne!(begin_step_id(key, BeginStep::Activate).unwrap(), begin_step_id(early, BeginStep::Activate).unwrap()); - assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(key, BeginStep::Activate).unwrap()); - assert_eq!(begin_step_id(key, BeginStep::Approve).unwrap(), begin_step_id(key, BeginStep::Approve).unwrap()); - assert_ne!(begin_step_id(key, BeginStep::Approve).unwrap(), CommandId::parse_uuid(key).unwrap()); - } - - /// A refusal at a step names that step, and an undecided outcome is never - /// read as a refusal: the owner must not be told to retry a Begin that may - /// have committed. - /// - /// Mutation: map every error, the unknown outcome included, to - /// `BeginStopped`. - #[test] - fn a_stopped_step_names_itself_and_an_unknown_outcome_stays_unknown() { - match stopped_at("approve", RuntimeCommandError::OutcomeUnknown("cmd".into())) { - RuntimeCommandError::OutcomeUnknown(detail) => assert_eq!(detail, "approve: cmd"), - other => panic!("an unknown outcome was read as {other}"), - } - let refused = stopped_at("activate", RuntimeCommandError::Payload("no".into())).to_string(); - assert!(refused.starts_with("the begin stopped at its activate step"), "{refused}"); - } - - /// A replayed Begin (the same key) replays its commands through the - /// kernel's ledger and commits nothing new. - #[tokio::test] - async fn a_replayed_begin_commits_nothing_new() { - let fixture = fixture().await; - let key = uuid::Uuid::new_v4().to_string(); - let payload = begin_payload_from(&served_surface(&fixture).await, "Replayed World", "", "The Owner"); - let first = post(&fixture.state, &fixture.cookie, begin_invocation(payload.clone(), &key)).await; - assert_eq!(first["receipt"]["state"], "accepted", "{first}"); - let second = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; - assert_eq!(second["receipt"]["state"], "accepted", "{second}"); - assert_eq!(second["receipt"]["reached"], "activated"); - assert_eq!(first["receipt"]["commitDigest"], second["receipt"]["commitDigest"]); - assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().revision, 2); - } - - /// R3: the sequence stops at the first refusal, and the world stays in the - /// phase the kernel committed. Here the kernel refuses the approve (the - /// draft was already approved by hand), so the Begin is denied naming that - /// step, the world stays an approved Draft, and the card offers the next - /// step's own button, which continues to an Active world. - /// - /// Mutations: carry on to the activate after a refused approve; report a - /// refused step as accepted. - #[tokio::test] - async fn begin_stops_at_the_first_refusal_and_the_card_offers_the_next_step() { - let fixture = fixture().await; - let key = uuid::Uuid::new_v4().to_string(); - let payload = begin_payload_from(&served_surface(&fixture).await, "Stopped World", "", "The Owner"); - let principal = owner_principal(&fixture).await; - let draft = fixture - .state - .world - .create( - begin_intent(CommandId::parse_uuid(&key).unwrap(), typed_begin("Stopped World", "", "The Owner")), - &principal, - ) - .await - .unwrap(); - fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: draft.world_id, - expected_revision: 0, - body: CommandBody::ApproveDraft, - }, - &principal, - ) - .await - .unwrap(); - - let refused = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; - assert_eq!(refused["receipt"]["state"], "denied", "{refused}"); - let message = refused["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.starts_with("the begin stopped at its approve step"), - "the refusal did not name its step: {message}" - ); - let world = current_world(&fixture.state).await.unwrap().unwrap(); - assert_eq!(world.phase, WorldPhase::Draft, "a refused step must leave the draft as the kernel committed it"); - assert_eq!(world.revision, 1); - - let mut buttons = Vec::new(); - surface_buttons(&served_surface(&fixture).await["surface"]["root"], &mut buttons); - assert!(buttons.contains(&"world.activate".to_owned()), "{buttons:?}"); - assert!(!buttons.contains(&"world.begin".to_owned()), "a world exists; Begin must not be offered"); - - let activated = post( - &fixture.state, - &fixture.cookie, - invocation( - "world.activate", - "ghostlight.world_activate.v0", - world.revision + 1, - json!({}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(activated["receipt"]["state"], "accepted", "{activated}"); - assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().phase, WorldPhase::Active); - } - - /// A Draft the kernel holds unapproved (a refused Begin can leave one) - /// offers Approve, then Activate, and no Begin. - #[tokio::test] - async fn an_unapproved_draft_offers_approve_and_no_begin() { - let fixture = fixture().await; - draft_world(&fixture.state, &fixture.cookie, "Plain Draft").await; - let mut buttons = Vec::new(); - surface_buttons(&served_surface(&fixture).await["surface"]["root"], &mut buttons); - assert!(buttons.contains(&"world.approve".to_owned()), "{buttons:?}"); - assert!(!buttons.contains(&"world.begin".to_owned()), "{buttons:?}"); - } - - /// Each control the player types into feeds the field it is named for: - /// the world that results carries the typed title, brief and name. - /// - /// Mutations: bind the "Your name" control to the title field; bind the - /// title control to the name field; hand the world's first person a label - /// other than the typed one. - #[tokio::test] - async fn the_text_typed_into_each_control_reaches_its_own_field() { - let fixture = fixture().await; - let payload = begin_payload_typed_into_controls( - &served_surface(&fixture).await, - &[ - ("world.begin.title", "Typed Title"), - ("world.begin.subject", "Typed Name"), - ("world.begin.brief", "Typed Brief"), - ], - ); - let begun = post( - &fixture.state, - &fixture.cookie, - begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); - let world = current_world(&fixture.state).await.unwrap().unwrap(); - assert_eq!(world.title, "Typed Title"); - assert_eq!(world.brief, "Typed Brief"); - assert_eq!(world.subjects.len(), 1); - assert_eq!(world.subjects[0].label, "Typed Name"); - } - - /// No refusal of a Begin echoes anything the owner typed or sent, and each - /// names the step it stopped at or the route's own fixed line. A blank - /// name or title is refused whatever whitespace it is made of, and no - /// refused Begin leaves a world. - /// - /// Mutations: put the payload in the unreadable-payload line; stop naming - /// the create step; keep a blank name by padding it with a letter. - #[tokio::test] - async fn a_refused_begin_never_echoes_what_was_sent_and_leaves_no_world() { - let fixture = fixture().await; - let canary = "CANARY-7f3a91-ZZ"; - let surface = served_surface(&fixture).await; - - let mut unreadable = begin_payload_from(&surface, "A World", canary, canary); - unreadable[canary] = json!(canary); - let mut missing_name = begin_payload_from(&surface, canary, canary, canary); - missing_name["bindings"].as_object_mut().unwrap().remove("subject_label"); - let mut authored_default = begin_payload_from(&surface, "A World", canary, canary); - authored_default["targets"] = json!(canary); - - for (what, payload, create_refusal) in [ - ("empty title", begin_payload_from(&surface, "", canary, canary), true), - ("blank title", begin_payload_from(&surface, " ", canary, canary), true), - ("empty name", begin_payload_from(&surface, canary, canary, ""), true), - ("blank name", begin_payload_from(&surface, canary, canary, " "), true), - ("tab name", begin_payload_from(&surface, canary, canary, "\t\n"), true), - ("unreadable payload", unreadable, false), - ("missing name", missing_name, false), - ("an authored default", authored_default, false), - ] { - let refused = post( - &fixture.state, - &fixture.cookie, - begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(refused["receipt"]["state"], "denied", "{what}: {refused}"); - assert!( - !refused.to_string().contains(canary), - "{what}: the refusal echoed an input value: {refused}" - ); - if create_refusal { - let message = refused["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.starts_with("the begin stopped at its create step"), - "{what}: the refusal did not name the create step: {message}" - ); - } - assert!( - current_world(&fixture.state).await.unwrap().is_none(), - "{what}: a refused Begin left a world" - ); - } - } - - /// Begin is the one way a world is made: `world.create` is not an - /// operation this server advertises, so naming it, under either payload - /// schema, is refused before any handler runs and makes no world. - /// - /// Mutation: advertise `world.create` again in `operation_schema`. - #[tokio::test] - async fn world_create_is_not_a_route() { - let fixture = fixture().await; - for schema in ["ghostlight.world_create.v4", "ghostlight.world_begin.v1"] { - let payload = json!({"title":"Bypass","brief":"","subject_label":"Operator"}); - let refused = post( - &fixture.state, - &fixture.cookie, - invocation("world.create", schema, 0, payload, &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(refused["receipt"]["state"], "denied", "{schema}: {refused}"); - assert!( - refused["receipt"]["message"].as_str().unwrap_or_default().contains("not advertised"), - "{schema}: {refused}" - ); - assert!(current_world(&fixture.state).await.unwrap().is_none()); - } - let served = served_surface(&fixture).await; - assert!( - served["commands"].as_array().unwrap().iter().all(|descriptor| descriptor["command"] != "world.create"), - "the surface advertises world.create" - ); - } - - /// The route owns the defaults. Another client's Begin states the three - /// fields and nothing else: it cannot author scale targets, roots, labels - /// or lens weights (each is refused as an unknown field, and no world is - /// made), and the world a three-field Begin makes has the route's - /// defaults: one person, uniform lens weights, no scale deficit. - /// - /// Mutations: drop `deny_unknown_fields` from `BeginPayload`; give one - /// lens weight 0 or a weight other than 1 in `uniform_lens_weights`; - /// declare a persona in `begin_intent`. - #[tokio::test] - async fn begin_owns_the_defaults_and_refuses_a_client_that_authors_them() { - let fixture = fixture().await; - let surface = served_surface(&fixture).await; - for (field, value) in [ - ("targets", json!("{}")), - ("jurisdictions", json!("[]")), - ("lens_weights", json!("{}")), - ("narrative_persona_label", json!("Persona")), - ("operational_agent_label", json!("Agent")), - ] { - let mut payload = begin_payload_from(&surface, "Authored", "", "Operator"); - payload[field] = value; - let refused = post( - &fixture.state, - &fixture.cookie, - begin_invocation(payload, &uuid::Uuid::new_v4().to_string()), - ) - .await; - assert_eq!(refused["receipt"]["state"], "denied", "{field}: {refused}"); - assert!(current_world(&fixture.state).await.unwrap().is_none(), "{field}: made a world"); - } - - let begun = post( - &fixture.state, - &fixture.cookie, - begin_invocation( - begin_payload_from(&surface, "Defaults", "", "Operator"), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); - let world = current_world(&fixture.state).await.unwrap().unwrap(); - assert_eq!(world.subjects.len(), 1, "a default declared a persona or an agent"); - assert!(world.scale_deficit.is_empty(), "a default declared a scale target"); - assert_eq!(world.lens_weights.iter().count(), ghostlight::Lens::ALL.len()); - assert!(world.lens_weights.iter().all(|(_, weight)| weight == 1), "the lens weights are not uniform"); - } - - /// Where a seed vault is configured, Begin stops at the approved Draft: - /// seeding is the Draft-only lane, so the owner then finds the seed - /// controls and Activate on the card, and no Begin. Without a vault the - /// same click reaches an Active world. - /// - /// Mutations: ignore the vault in `begin_plan`; leave the approve out of - /// the vault plan. - #[tokio::test] - async fn begin_stops_at_the_approved_draft_where_a_vault_is_configured() { - let fixture = fixture().await; - let mut configured = fixture.state.clone(); - configured.seed_vault_root = Some(PathBuf::from("/vault-for-begin")); - let payload = begin_payload_from(&served_surface(&fixture).await, "Seeded World", "", "The Owner"); - let begun = post(&configured, &fixture.cookie, begin_invocation(payload, &uuid::Uuid::new_v4().to_string())).await; - assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); - assert_eq!(begun["receipt"]["reached"], "approved"); - - let world = current_world(&configured).await.unwrap().unwrap(); - assert_eq!(world.phase, WorldPhase::Draft); - assert_eq!(world.draft_approvals, world.required_approvers, "the Draft is approved"); - - let served = get(&configured, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let mut buttons = Vec::new(); - surface_buttons(&served["surface"]["root"], &mut buttons); - for wanted in ["world.seed", "world.activate"] { - assert!(buttons.contains(&wanted.to_owned()), "{wanted} missing: {buttons:?}"); - } - assert!(!buttons.contains(&"world.begin".to_owned()), "{buttons:?}"); - assert_eq!(begin_plan(true), [BeginStep::Approve]); - assert_eq!(begin_plan(false), [BeginStep::Approve, BeginStep::Activate]); - } - - #[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 payload = begin_payload_from(&served_surface(&fixture).await, "Retry World", "", "Operator"); - let command = begin_invocation(payload, &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, - 2 - ); - } - - /// 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; - let payload = begin_payload_from(&served_surface(&fixture).await, "Retry Receipt World", "", "Operator"); - post( - &fixture.state, - &fixture.cookie, - begin_invocation(payload, &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 made through the kernel's own mailbox, as `begin_world` - /// makes one before it approves: tests that need a Draft (or the walk - /// from one) start here, since `world.begin` is the only route that - /// creates a world and it does not stop at a Draft. - async fn draft_world(state: &AppState, cookie: &str, title: &str) -> CreationReceipt { - let principal = state - .sessions - .lock() - .await - .account_for_cookie(cookie, Utc::now()) - .unwrap() - .expect("the fixture cookie names a live session"); - state - .world - .create( - begin_intent( - CommandId::new(), - BeginPayload { - title: title.into(), - brief: String::new(), - subject_label: "Operator".into(), - }, - ), - &principal, - ) - .await - .unwrap() - } - - /// A Draft world from the kernel's own create 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 ------------------------------------------------ - - /// 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)>); 3] = [ - ( - "world.begin", - vec![ - ("title", json!("Untouched Form World")), - ("subject_label", json!("The Operator")), - ], - ), - ("world.advance_time", vec![("minutes", json!(1))]), - ("world.play", vec![]), - ]; - for (command, typed) in steps { - let surface = - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let intent = untouched_intent(&surface, command, &typed); - let sent = intent["payload"].clone(); - let result = post(&fixture.state, &fixture.cookie, intent).await; - if command == "world.play" { - // No turn is running here, so the empty box is refused by the - // play table itself (a new turn needs text). That refusal is - // the proof: the payload reached the table as `text: ""` - // instead of dying at the route as a missing field. Continuing - // a Running turn on that empty text is the table's own rule - // (`empty_text_while_the_turn_is_running_continues_it`). - assert_eq!(sent["bindings"]["text"], "", "{sent}"); - let message = result["receipt"]["message"].as_str().unwrap_or_default(); - assert!( - message.contains("a new turn needs non-empty text"), - "an untouched Play box did not reach the play table: {result}" - ); - } else { - assert_eq!( - result["receipt"]["state"], "accepted", - "{command} with an untouched form ({sent}) was not admitted: {result}" - ); - } - exercised.push(command); - } - // The walk reaches every button that captures a binding on the Active - // surface, so a control added later without an authored value or a - // place in this walk fails here rather than in a player's hands. - let active = - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let mut buttons = Vec::new(); - surface_buttons(&active["surface"]["root"], &mut buttons); - for command in buttons { - let bound = button_for_command(&active["surface"]["root"], &command).unwrap()["props"] - ["captureBindings"] - .as_array() - .unwrap() - .len(); - assert!( - bound == 0 || exercised.contains(&command.as_str()), - "{command} captures bindings but the untouched-form walk never submitted it" - ); - } - } - - /// PA.f213: the seed controls, card and descriptor exist only where a vault - /// is configured; with none, pressing the button could only be refused. - #[tokio::test] - async fn the_seed_controls_are_offered_only_where_a_vault_is_configured() { - let fixture = fixture().await; - let owner = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .unwrap() - .account_subject_hash() - .to_owned(); - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - let seed_nodes = [ - "world.seed.vault_scope", - "world.seed.brief", - "world.seed", - "world.seed.card", - ]; - let node_ids = |surface: &Value| -> Vec { - fn walk(node: &Value, into: &mut Vec) { - if let Some(id) = node["id"].as_str() { - into.push(id.to_owned()); - } - for child in node["children"].as_array().into_iter().flatten() { - walk(child, into); - } - } - let mut ids = Vec::new(); - walk(&surface["surface"]["root"], &mut ids); - ids - }; - let described = |surface: &Value| { - surface["commands"] - .as_array() - .unwrap() - .iter() - .any(|descriptor| descriptor["command"] == "world.seed") - }; - - let without = eve::authenticated_surface(&owner, Some(&draft), None, false).unwrap(); - let ids = node_ids(&without); - for id in seed_nodes { - assert!(!ids.iter().any(|node| node == id), "{id} offered with no vault"); - } - assert!(!described(&without), "world.seed advertised with no vault"); - - let with = eve::authenticated_surface(&owner, Some(&draft), None, true).unwrap(); - let ids = node_ids(&with); - for id in seed_nodes { - assert!(ids.iter().any(|node| node == id), "{id} missing with a vault"); - } - assert!(described(&with), "world.seed not advertised with a vault"); - - // The served surface reads the root from the state it is served with: - // nothing configured offers nothing, and a configured root offers the - // lane without the root's own text ever reaching the card. - let served = - get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - assert!(!described(&served), "the served surface offered seeding with no vault"); - assert!(!node_ids(&served).iter().any(|id| id == "world.seed")); - - let mut configured = fixture.state.clone(); - configured.seed_vault_root = Some(PathBuf::from("/canary-vault-root-4f9a1c")); - let served = get(&configured, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - assert!(described(&served), "the served surface hid seeding with a vault"); - assert!(node_ids(&served).iter().any(|id| id == "world.seed")); - assert!( - !serde_json::to_string(&served).unwrap().contains("canary-vault-root"), - "the configured root reached the surface" - ); - } - - /// The root the server opens with comes from `GHOSTLIGHT_SEED_VAULT_ROOT` - /// and from nothing else the environment holds. - /// - /// Mutation: look up another name, or ignore the answer, in - /// `seed_vault_root_from_environment`. - #[test] - fn the_vault_root_is_read_from_the_seed_variable_only() { - use std::ffi::OsString; - let environment = |entries: &'static [(&'static str, &'static str)]| { - move |name: &str| { - entries - .iter() - .find(|(key, _)| *key == name) - .map(|(_, value)| OsString::from(*value)) - } - }; - assert_eq!( - seed_vault_root_from_environment(environment(&[( - "GHOSTLIGHT_SEED_VAULT_ROOT", - "/vault" - )])), - Some(PathBuf::from("/vault")) - ); - assert_eq!( - seed_vault_root_from_environment(environment(&[ - ("GHOSTLIGHT_VAULT_ROOT", "/other"), - ("HOME", "/home"), - ])), - None - ); - } - - /// What counts as a configured vault root is decided in one place. An - /// absent, empty or whitespace-only value is no configuration, so a - /// server started with a blank variable offers no seeding and refuses a - /// forged `world.seed` with the same fixed message as an unset one, which - /// never echoes the value it was started with. - /// - /// Mutation: drop the `.trim()` in `seed_vault_root_from_environment` and the - /// whitespace cases below answer `Some`. - #[tokio::test] - async fn a_blank_vault_root_is_no_configuration_and_its_refusal_echoes_nothing() { - use std::ffi::OsString; - assert_eq!(seed_vault_root_from_environment(|_| None), None); - for blank in ["", " ", " - "] { - assert_eq!( - seed_vault_root_from_environment(|_| Some(OsString::from(blank))), - None, - "{blank:?} counted as a configured vault root" - ); - } - assert_eq!( - seed_vault_root_from_environment(|_| Some(OsString::from("/vault"))), - Some(PathBuf::from("/vault")) - ); - - // A blank value in the state a server would build from it refuses a - // forged seed with the fixed message, never the value. - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::from([(SubjectKind::Person, 4)]), - vec![CreateJurisdictionIntent { - handle: "sere".into(), - label: "The Low Sere".into(), - permille: 1000, - }], - ) - .await; - let draft = fixture.state.world.snapshot().await.unwrap(); - let mut state = fixture.state.clone(); - state.seed_vault_root = seed_vault_root_from_environment(|_| Some(OsString::from(" "))); - let denied = post( - &state, - &fixture.cookie, - invocation( - "world.seed", - "ghostlight.world_seed.v1", - draft.revision + 1, - json!({"vault_scope":"canary-scope-7c2e","brief":""}), - &uuid::Uuid::new_v4().to_string(), - ), - ) - .await; - assert_eq!(denied["receipt"]["state"], "denied", "{denied}"); - let message = denied["receipt"]["message"].as_str().unwrap_or_default(); - assert_eq!( - message, "invalid command payload: seeding is not available on this world", - "{denied}" - ); - } - - /// `SeedPort` holds one `VerifiedPrincipalEvidence` for the whole session, - /// and a multi-round session can run long past the moment the evidence was - /// minted. `VerifiedPrincipalEvidence` now carries `valid_until`, minted - /// from the session's own `access_expires_at`, and `submit_principal` - /// reads the wall clock at ingress and refuses anything presented after - /// that moment — so evidence cannot outlive the session that vouched for - /// it, whatever the port itself still believes. - #[tokio::test] - async fn soul_verified_evidence_outlives_the_session_that_minted_it() { - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::new(), - Vec::new(), - ) - .await; - let live = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .expect("the fixture cookie names a live session"); - // Same account as the live evidence, but minted with an expiry that - // has already passed. - let expired = VerifiedPrincipalEvidence::new( - live.account_subject_hash().to_owned(), - Utc::now() - chrono::Duration::seconds(1), - ); - - let before = fixture.state.world.snapshot().await.unwrap(); - let committed = fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: before.world_id, - expected_revision: before.revision, - // `ApproveDraft`, because it is refused for anyone but a - // required approver and its effect is visible in the - // snapshot. What is under test is whether an expired - // claim is spent at all. - body: CommandBody::ApproveDraft, - }, - &expired, - ) - .await; - assert!( - committed.is_err(), - "evidence minted with a past valid_until still committed: {committed:?}" - ); - let after = fixture.state.world.snapshot().await.unwrap(); - assert_eq!( - after.revision, before.revision, - "an expired submission moved the world" - ); - assert!(after.draft_approvals.is_empty()); - } - - /// Sibling to the above: evidence still inside its own window is not - /// touched by the expiry gate, so the same account submitting the same - /// command before `valid_until` commits normally. - #[tokio::test] - async fn soul_verified_evidence_within_its_window_still_commits() { - let fixture = fixture().await; - two_cell_world( - &fixture.state, - &fixture.cookie, - BTreeMap::new(), - Vec::new(), - ) - .await; - let live = fixture - .state - .sessions - .lock() - .await - .account_for_cookie(&fixture.cookie, Utc::now()) - .unwrap() - .expect("the fixture cookie names a live session"); - - let before = fixture.state.world.snapshot().await.unwrap(); - let committed = fixture - .state - .world - .submit_principal( - PrincipalCommandIntent { - id: CommandId::new(), - world_id: before.world_id, - expected_revision: before.revision, - body: CommandBody::ApproveDraft, - }, - &live, - ) - .await; - assert!(committed.is_ok(), "{committed:?}"); - let after = fixture.state.world.snapshot().await.unwrap(); - assert_eq!(after.revision, before.revision + 1); - } -} - -/// The refresh loop against a scripted Heimdall: it answers each call from a -/// queue and records the claim and idempotency key it was sent. -#[cfg(test)] -mod refresh_loop_tests { - use super::*; - use crate::heimdall::{AuthCompletionReceipt, VerificationUnavailable, VerifiedSessionRefresh}; - use std::{collections::VecDeque, sync::Mutex as StdMutex}; - - enum Reply { - Deny, - Timeout, - Receipt { revision: u64, claim: &'static str }, - /// A receipt for the same claim but another Heimdall session. - OtherSession, - /// A receipt for the same claim but another account. - OtherAccount, - } - - /// What `verify_refresh` answers; an empty queue accepts the receipt. - enum Verdict { - /// The signing keys could not be fetched. - Unavailable, - /// The receipt positively fails verification. - Invalid, - } - - #[derive(Default)] - struct ScriptedHeimdall { - replies: StdMutex>, - verdicts: StdMutex>, - verifications: StdMutex, - /// While answering a refresh, apply a newer revision to this store, - /// as a concurrent refresh of the same session would. - moving: Option>>, - pending: StdMutex>, - calls: StdMutex>, - /// Verify receipts with this real client, against the access token - /// each receipt carries, instead of from the verdict queue. - real: Option<(HeimdallClient, String)>, - } - - impl ScriptedHeimdall { - fn answering(replies: Vec) -> Self { - Self { - replies: StdMutex::new(replies.into()), - ..Self::default() - } - } - - fn verifying(mut self, verdicts: Vec) -> Self { - self.verdicts = StdMutex::new(verdicts.into()); - self - } - - fn verifying_for_real(mut self, client: HeimdallClient, token: String) -> Self { - self.real = Some((client, token)); - self - } - - fn verifications(&self) -> usize { - *self.verifications.lock().unwrap() - } - - fn calls(&self) -> Vec<(String, String)> { - self.calls.lock().unwrap().clone() - } - } - - #[async_trait::async_trait] - impl SessionRefresher for ScriptedHeimdall { - async fn refresh( - &self, - refresh_token: &str, - idempotency_key: &str, - ) -> anyhow::Result { - self.calls - .lock() - .unwrap() - .push((refresh_token.into(), idempotency_key.into())); - if let Some(sessions) = &self.moving { - let mut sessions = sessions.lock().await; - let candidate = sessions - .sessions_due_for_refresh(Utc::now(), chrono::Duration::minutes(2)) - .unwrap() - .remove(0); - sessions - .apply_refresh( - &candidate.cookie_hash, - candidate.access_revision, - VerifiedSessionRefresh::fixture( - "operator-account", - "heimdall-session", - candidate.access_revision + 1, - Utc::now() + chrono::Duration::seconds(90), - Utc::now() + chrono::Duration::days(1), - "claim-moved", - ), - ) - .unwrap(); - } - match self.replies.lock().unwrap().pop_front().expect("scripted reply") { - Reply::Deny => Err(HeimdallDenied { - diagnostics: "Idempotency key was reused".into(), - } - .into()), - Reply::Timeout => bail!("Heimdall private command timed out"), - reply @ (Reply::Receipt { .. } | Reply::OtherSession | Reply::OtherAccount) => { - let (account, session, revision, claim) = match reply { - Reply::OtherSession => ("operator-account", "other-session", 1, "claim-1"), - Reply::OtherAccount => ("other-account", "heimdall-session", 1, "claim-1"), - Reply::Receipt { revision, claim } => { - ("operator-account", "heimdall-session", revision, claim) - } - _ => unreachable!("matched above"), - }; - *self.pending.lock().unwrap() = Some(VerifiedSessionRefresh::fixture( - account, - session, - revision, - Utc::now() + chrono::Duration::seconds(90), - Utc::now() + chrono::Duration::days(1), - claim, - )); - Ok(AuthCompletionReceipt { - status: "authenticated".into(), - handle: None, - error: None, - account: None, - session: None, - access_token: self.real.as_ref().map(|(_, token)| token.clone()), - refresh_token: None, - refresh: None, - shared_capabilities: Vec::new(), - }) - } - } - } - - async fn verify_refresh( - &self, - completion: AuthCompletionReceipt, - ) -> anyhow::Result { - *self.verifications.lock().unwrap() += 1; - if let Some((client, _)) = &self.real { - return client.verify_refresh(completion).await; - } - match self.verdicts.lock().unwrap().pop_front() { - Some(Verdict::Unavailable) => { - Err(VerificationUnavailable::new("timed out").into()) - } - Some(Verdict::Invalid) => bail!("Heimdall access token signature is invalid"), - None => Ok(self.pending.lock().unwrap().take().expect("scripted receipt")), - } - } - } - - /// A store holding one live session at `revision` whose access expires in - /// a minute, so every pass finds it due. - fn due_session( - revision: u64, - ) -> (tempfile::TempDir, std::sync::Arc>, String) { - let directory = tempfile::tempdir().unwrap(); - let key = directory.path().join("session.key"); - std::fs::write(&key, [23_u8; 32]).unwrap(); - let mut owner = AppSessionOwner::open(directory.path().join("sessions.cc"), &key).unwrap(); - let cookie = owner - .create_session(heimdall::VerifiedSessionAdmission::fixture( - "operator-account", - "heimdall-session", - revision, - Utc::now() + chrono::Duration::seconds(60), - Utc::now() + chrono::Duration::days(1), - "claim-0", - )) - .unwrap(); - (directory, std::sync::Arc::new(Mutex::new(owner)), cookie) - } - - /// One refresh pass with no receipt carried over from an earlier one. - async fn pass(sessions: &Mutex, heimdall: &ScriptedHeimdall) { - refresh_due_sessions(sessions, heimdall, &mut HashMap::new()) - .await - .unwrap(); - } - - async fn is_live(sessions: &Mutex, cookie: &str) -> bool { - sessions - .lock() - .await - .account_for_cookie(cookie, Utc::now()) - .unwrap() - .is_some() - } - - #[derive(Clone, Default)] - struct Captured(std::sync::Arc>>); - - impl std::io::Write for Captured { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } - } - - impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Captured { - type Writer = Captured; - - fn make_writer(&'a self) -> Captured { - self.clone() - } - } - - #[tokio::test] - async fn a_real_refresh_at_an_equal_revision_is_applied_and_the_session_stays_live() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]); - pass(&sessions, &heimdall).await; - assert!(is_live(&sessions, &cookie).await); - // The wrapped claim is the receipt's: the next refresh spends it. - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls()[1].0, "claim-1"); - assert!(is_live(&sessions, &cookie).await); - } - - #[tokio::test] - async fn a_retry_of_the_same_refresh_sends_the_same_key() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Timeout, Reply::Timeout]); - pass(&sessions, &heimdall).await; - pass(&sessions, &heimdall).await; - let calls = heimdall.calls(); - assert_eq!(calls.len(), 2, "a transport failure leaves the session offered"); - assert_eq!(calls[0], calls[1]); - assert!(is_live(&sessions, &cookie).await); - } - - #[tokio::test] - async fn the_refresh_after_a_successful_one_sends_a_new_key() { - let (_directory, sessions, _cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]); - pass(&sessions, &heimdall).await; - pass(&sessions, &heimdall).await; - let calls = heimdall.calls(); - assert_ne!(calls[0].1, calls[1].1); - assert!(calls.iter().all(|(claim, key)| !key.contains(claim))); - } - - #[tokio::test] - async fn a_receipt_with_a_lower_revision_is_refused_and_the_session_revoked() { - let (_directory, sessions, cookie) = due_session(2); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]); - pass(&sessions, &heimdall).await; - assert!(!is_live(&sessions, &cookie).await); - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls().len(), 1, "a revoked session is not offered again"); - } - - #[tokio::test] - async fn a_receipt_for_another_session_or_account_revokes_the_local_session() { - for reply in [Reply::OtherSession, Reply::OtherAccount] { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![reply]); - pass(&sessions, &heimdall).await; - assert!(!is_live(&sessions, &cookie).await); - } - } - - #[tokio::test] - async fn a_heimdall_denial_revokes_the_session_and_it_is_not_offered_again() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Deny, Reply::Timeout]); - pass(&sessions, &heimdall).await; - assert!(!is_live(&sessions, &cookie).await); - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls().len(), 1); - } - - #[tokio::test] - async fn a_local_refusal_logs_the_clause_that_fired() { - let (_directory, sessions, _cookie) = due_session(2); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]); - let captured = Captured::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(captured.clone()) - .with_ansi(false) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); - pass(&sessions, &heimdall).await; - let logged = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap(); - assert!( - logged.contains("refresh receipt revision went backwards"), - "{logged}" - ); - } - - #[tokio::test] - async fn a_receipt_whose_keys_were_unavailable_is_kept_and_applied_on_the_next_pass() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]) - .verifying(vec![Verdict::Unavailable]); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert!(is_live(&sessions, &cookie).await, "unavailable keys do not revoke"); - assert_eq!(unverified.len(), 1); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(heimdall.calls().len(), 1, "the spent claim is not sent again"); - assert_eq!(heimdall.verifications(), 2); - assert!(unverified.is_empty(), "an applied receipt is not kept"); - assert!(is_live(&sessions, &cookie).await); - // The applied receipt's claim is the one the next refresh spends. - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(heimdall.calls()[1].0, "claim-1"); - assert_eq!(heimdall.verifications(), 2, "the receipt was applied once"); - } - - #[tokio::test] - async fn a_receipt_that_positively_fails_verification_revokes_and_is_not_kept() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 1, claim: "claim-1" }, - Reply::Timeout, - ]) - .verifying(vec![Verdict::Invalid]); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert!(!is_live(&sessions, &cookie).await); - assert!(unverified.is_empty()); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(heimdall.calls().len(), 1); - assert_eq!(heimdall.verifications(), 1); - } - - #[tokio::test] - async fn a_kept_receipt_for_a_session_that_is_gone_is_dropped() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]) - .verifying(vec![Verdict::Unavailable]); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert_eq!(unverified.len(), 1); - let cookie_hash = unverified.keys().next().unwrap().clone(); - sessions.lock().await.revoke_cookie_hash(&cookie_hash).unwrap(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - assert!(unverified.is_empty()); - assert!(!is_live(&sessions, &cookie).await); - assert_eq!(heimdall.verifications(), 1); - } - - #[tokio::test] - async fn a_session_that_moved_while_refreshing_is_not_revoked() { - let (_directory, sessions, cookie) = due_session(1); - let mut heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]); - heimdall.moving = Some(sessions.clone()); - pass(&sessions, &heimdall).await; - assert!(is_live(&sessions, &cookie).await); - // The newer state wins: the next refresh spends its claim, not the - // stale receipt's. - heimdall.moving = None; - heimdall.replies.lock().unwrap().push_back(Reply::Timeout); - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls()[1].0, "claim-moved"); - } - - #[tokio::test] - async fn a_receipt_with_a_higher_revision_is_applied() { - let (_directory, sessions, cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering(vec![ - Reply::Receipt { revision: 2, claim: "claim-1" }, - Reply::Receipt { revision: 1, claim: "claim-2" }, - ]); - pass(&sessions, &heimdall).await; - assert!(is_live(&sessions, &cookie).await); - // The session now sits at revision 2, so a revision-1 receipt is - // backwards. - pass(&sessions, &heimdall).await; - assert_eq!(heimdall.calls()[1].0, "claim-1"); - assert!(!is_live(&sessions, &cookie).await); - } - - /// Lets the loop under test run everything that is ready, without moving - /// the paused clock. - async fn settle() { - for _ in 0..16 { - tokio::task::yield_now().await; - } - } - - #[tokio::test(start_paused = true)] - async fn the_refresh_loop_runs_a_pass_every_sixty_seconds_and_keeps_going() { - let (_directory, sessions, _cookie) = due_session(1); - let heimdall = ScriptedHeimdall::answering((0..64).map(|_| Reply::Timeout).collect()); - let observed = async { - settle().await; - assert_eq!(heimdall.calls().len(), 1, "the first pass runs at once"); - tokio::time::advance(Duration::from_millis(59_999)).await; - settle().await; - assert_eq!(heimdall.calls().len(), 1, "no pass before sixty seconds"); - tokio::time::advance(Duration::from_millis(1)).await; - settle().await; - assert_eq!(heimdall.calls().len(), 2, "the second pass at sixty seconds"); - for passes in 3..=12 { - tokio::time::advance(Duration::from_secs(60)).await; - settle().await; - assert_eq!(heimdall.calls().len(), passes); - } - }; - tokio::select! { - ran = run_refresh_loop(&sessions, &heimdall) => panic!("the loop returned: {ran:?}"), - () = observed => {} - } - } - - /// A refresh receipt verified by the real client against `issuer_url`, - /// whose key-set fetch fails. Returns whether the session stayed live, - /// how many receipts were kept, and the log the pass wrote. - async fn pass_through_real_verification(issuer_url: &str) -> (bool, usize, String) { - let (_directory, sessions, cookie) = due_session(1); - let client = HeimdallClient::fixture().with_issuer(issuer_url); - let token = heimdall::test_issuer::signed_token(issuer_url); - let heimdall = ScriptedHeimdall::answering(vec![Reply::Receipt { - revision: 1, - claim: "claim-1", - }]) - .verifying_for_real(client, token); - let captured = Captured::default(); - let subscriber = tracing_subscriber::fmt() - .with_writer(captured.clone()) - .with_ansi(false) - .finish(); - let _guard = tracing::subscriber::set_default(subscriber); - let mut unverified = HashMap::new(); - refresh_due_sessions(&sessions, &heimdall, &mut unverified).await.unwrap(); - let logged = String::from_utf8(captured.0.lock().unwrap().clone()).unwrap(); - (is_live(&sessions, &cookie).await, unverified.len(), logged) - } - - #[tokio::test] - async fn a_key_fetch_failure_in_the_real_verification_keeps_the_session_and_the_receipt() { - let failing = heimdall::test_issuer::Issuer::start(heimdall::test_issuer::Answer::ServerError) - .await; - for issuer_url in [heimdall::test_issuer::Issuer::closed_url(), failing.url.clone()] { - let (live, kept, logged) = pass_through_real_verification(&issuer_url).await; - assert!(live, "{issuer_url}: a key-fetch failure signed the session out"); - assert_eq!(kept, 1, "{issuer_url}: the spent receipt was not kept"); - assert!(logged.contains("could not be verified yet"), "{logged}"); - } - } - - #[tokio::test] - async fn the_unverified_warning_names_the_error_class_and_no_url() { - let canary = format!("{}/canary-q7x1", heimdall::test_issuer::Issuer::closed_url()); - let (_, _, logged) = pass_through_real_verification(&canary).await; - assert!(logged.contains("could not connect"), "{logged}"); - for leaked in [canary.as_str(), "canary-q7x1", "127.0.0.1", "jwks.json"] { - assert!(!logged.contains(leaked), "{leaked} leaked into the log: {logged}"); - } - } -} diff --git a/crates/ghostlight-dungeon/tests/build_provenance.rs b/crates/ghostlight-dungeon/tests/build_provenance.rs deleted file mode 100644 index 09a4ee1..0000000 --- a/crates/ghostlight-dungeon/tests/build_provenance.rs +++ /dev/null @@ -1,27 +0,0 @@ -use std::{fs, path::Path, process::Command}; - -#[test] -fn embedded_commit_matches_checkout_head() { - let repo_root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); - let output = Command::new("git") - .args(["rev-parse", "HEAD"]) - .current_dir(&repo_root) - .output() - .expect("git must be available for the provenance regression test"); - let checkout_head = if output.status.success() { - String::from_utf8(output.stdout) - .expect("git commit must be UTF-8") - .trim() - .to_owned() - } else { - fs::read_to_string(repo_root.join(".ghostlight-source-commit")) - .expect("frozen source archive must carry its exact commit witness") - .trim() - .to_owned() - }; - assert_eq!( - env!("GHOSTLIGHT_BUILD_COMMIT"), - checkout_head, - "the binary embedded stale Git provenance" - ); -} diff --git a/deployment/idunn/recipe.toml b/deployment/idunn/recipe.toml deleted file mode 100644 index 7a5b6cc..0000000 --- a/deployment/idunn/recipe.toml +++ /dev/null @@ -1,188 +0,0 @@ -schema = "gamecult.idunn.target_declaration.v1" -target = "ghostlight" -source_stamp_environment = "GHOSTLIGHT_BUILD_COMMIT" -required_gitlinks = ["vendor/eve"] - -[[steps]] -id = "test-persona-boundary" -phase = "test" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "test", "--locked", "-p", "ghostlight-persona-projection"] - -[[steps]] -id = "test-world-library" -phase = "test" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "test", "--locked", "-p", "ghostlight"] - -[[steps]] -id = "test-dungeon" -phase = "test" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "test", "--locked", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"] - -[[steps]] -id = "build-daemon" -phase = "build" -runner = "rust-build" -required_environment = ["CARGO_HOME", "HOME"] -argv = ["cargo", "build", "--locked", "--release", "-p", "ghostlight-dungeon", "--bin", "ghostlight-dungeon"] - -[[steps]] -id = "install-web-dependencies" -phase = "prepare" -runner = "web-build" -required_environment = ["HOME", "NPM_CONFIG_CACHE"] -argv = ["npm", "--prefix", "web", "ci"] - -[[steps]] -id = "test-web" -phase = "test" -runner = "web-build" -required_environment = ["HOME", "NPM_CONFIG_CACHE"] -argv = ["npm", "--prefix", "web", "test"] - -[[steps]] -id = "build-web" -phase = "build" -runner = "web-build" -required_environment = ["HOME", "NPM_CONFIG_CACHE"] -argv = ["npm", "--prefix", "web", "run", "build"] - -[[steps]] -id = "accept-real-persona-membrane" -phase = "acceptance" -runner = "rust-acceptance" -argv = [ - "cargo", - "test", - "--locked", - "-p", - "ghostlight", - "--lib", - "controllers::tests::real_local_model_cognition_modes_commit_speech", - "--", - "--ignored", - "--exact", -] -required_environment = [ - "CARGO_HOME", - "GHOSTLIGHT_ACCEPTANCE_PERSONA_PROSE_LOG", - "GHOSTLIGHT_ACCEPTANCE_RUNTIME_ID", - "GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL", - "GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL", - "GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL", - "GHOSTLIGHT_CONTROLLER_PERSONA_MODEL", - "GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL", - "HOME", - "GHOSTLIGHT_LOCAL_ENDPOINT", -] - -[[artifacts]] -id = "ghostlight-dungeon" -source_kind = "runner-output" -runner = "rust-build" -source = "target/release/ghostlight-dungeon" -destination = "ghostlight-dungeon" -executable = true - -[[artifacts]] -id = "web" -source_kind = "runner-output" -runner = "web-build" -source = "web/dist" -destination = "web" - -[service] -executable_artifact = "ghostlight-dungeon" -required_adjacent_artifacts = ["web"] -arguments = [ - { kind = "literal", value = "--state-root" }, - { kind = "binding", name = "state_root" }, -] -transport = "http" -route_required = true -required_environment = [ - "GAMECULT_IDUNN_CANDIDATE_BIND", - "GAMECULT_IDUNN_PROCESS_WRITE_LEASE", - "GAMECULT_IDUNN_RUNTIME_BUNDLE", - "GHOSTLIGHT_CONTROLLER_ELABORATOR_MODEL", - "GHOSTLIGHT_CONTROLLER_INTERPRETER_MODEL", - "GHOSTLIGHT_CONTROLLER_OPERATIONAL_MODEL", - "GHOSTLIGHT_CONTROLLER_PERSONA_MODEL", - "GHOSTLIGHT_CONTROLLER_PROJECTOR_MODEL", - "GHOSTLIGHT_DISCORD_GUILD_ID", - "GHOSTLIGHT_DISCORD_ROLE_ID", - "GHOSTLIGHT_HEIMDALL_APP_SECRET_FILE", - "GHOSTLIGHT_HEIMDALL_BASE_URL", - "GHOSTLIGHT_LOCAL_ENDPOINT", - "GHOSTLIGHT_LOCATED_SERVICE", - "GHOSTLIGHT_ODIN_RUDP", - "GHOSTLIGHT_PLAY_MODEL", - "GHOSTLIGHT_PUBLIC_APP_URL", - "GHOSTLIGHT_SERVICE_ID", - "GHOSTLIGHT_SESSION_WRAPPING_KEY_FILE", -] -optional_environment = ["RUST_LOG"] - -[service.health] -contract = "ghostlight.cultnet-rudp-service-health" - -[state] -schema_generation = "world-v3" - -[[state.slots]] -id = "world" -relative_path = "world.cc" -kind = "cultcache-file" -schema = "ghostlight.world_state.consumer.v6" -writer = "process-bound-single-writer" -recovery = "preserve" -startup = "create-or-open-after-write-lease" - -[[state.slots]] -id = "app-sessions" -relative_path = "service/app-sessions-v2.cc" -kind = "cultcache-file" -schema = "ghostlight.app_session_store.v2" -writer = "process-bound-single-writer" -recovery = "preserve" -startup = "create-or-open-after-write-lease" - -[[state.slots]] -id = "controller-work" -relative_path = "service/controller-work.cc" -kind = "cultcache-file" -schema = "ghostlight.controller_work.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-eve-native-interface.md b/docs/architecture/ghostlight-eve-native-interface.md deleted file mode 100644 index 3b84e29..0000000 --- a/docs/architecture/ghostlight-eve-native-interface.md +++ /dev/null @@ -1,100 +0,0 @@ -# Ghostlight Eve-native interface - -## Objective - -Ghostlight Dungeon exposes one logical surface, `ghostlight.play`, from the -provider `gamecult.ghostlight.dungeon` (`crates/ghostlight-dungeon/src/mesh.rs`). -Eve owns editable bindings, command invocation, receipts, plugin composition, -and browser lowering. Ghostlight owns world creation, seeding, activation, play, -and world admission; Heimdall owns authentication and entitlement decisions. - -```text -Ghostlight projection (eve.rs) - -> EveBrowserProviderHost - -> canonical Eve lowering - -> gamecult.eve.command_invocation.v1 - -> Ghostlight command ingress (runtime.rs) or Heimdall private command plane - -> gamecult.eve.command_result.v1 - -> authoritative surface refresh -``` - -## Authority map - -- Owner: the world kernel owns world changes (through `WorldMailbox`, `SeedPort` - and the play table), Heimdall owns OAuth attempts and claims, and Ghostlight's - app-session owner (`app_session.rs`) binds a verified Heimdall subject to a - local cookie. -- Inputs: the projector reads only the caller's app session, the world - snapshot, and the caller's play-turn view. Command ingress reads one canonical - Eve invocation and derives the principal server-side. -- Outputs: one `gamecult.eve.surface.v1` document and one - `gamecult.eve.command_receipt.v1` inside a command result. -- Derived state: browser drafts, focus, command status, and rendered HTML are - projections or local interaction state. None grants access or mutates - fiction. -- Forbidden writers: the browser, the Eve lowerer, the Heimdall plugin, and the - SSE stream cannot choose a principal or commit world state. -- Shared path: every operation resolves the authenticated account, then calls - the world mailbox, the seed port, or the play port. - SSE carries invalidation only and the host refetches the same surface. - -## The surface - -`GET /api/eve/surfaces/ghostlight.play` returns a different document by caller -and world phase (`anonymous_surface`, `authenticated_surface` in -`crates/ghostlight-dungeon/src/eve.rs`): - -| Caller and world | Content | Operations | -|---|---|---| -| Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` | -| Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) | -| Authenticated, no world | title, your name, optional brief, and one Begin button | `world.begin` (`ghostlight.world_begin.v1`: title, brief, subject_label): create, then approve and activate as separate kernel commands, stopping at the first refusal; where a seed vault is configured it stops at the approved Draft so the owner can seed before Activate | -| 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/package.json b/package.json index 0a3700c..287051d 100644 --- a/package.json +++ b/package.json @@ -2,11 +2,8 @@ "name": "ghostlight", "private": true, "version": "0.0.0", - "description": "Ghostlight: the Rust kernel for socially situated agents, and Ghostlight Dungeon, the single-player DM-led client.", + "description": "Ghostlight: the Rust kernel for socially situated agents.", "scripts": { - "dungeon:build": "cargo build --workspace && npm --prefix web run build", - "dungeon:test": "cargo test --workspace && npm --prefix web run build", - "dungeon:run": "cargo run -p ghostlight-dungeon", "sdk:build": "npm --prefix sidecar/claude-sdk run build", "sdk:test": "npm --prefix sidecar/claude-sdk test" }, diff --git a/tools/eve_client_bridge.mjs b/tools/eve_client_bridge.mjs deleted file mode 100644 index 63f17f2..0000000 --- a/tools/eve_client_bridge.mjs +++ /dev/null @@ -1,117 +0,0 @@ -// Cut 10 (PA.f149): drives the real vendored Eve browser lowering -// (`vendor/eve/packages/eve-browser-lowering`, submodule pin unmoved) over a -// surface document Dungeon actually served, so the command intent this -// prints is what a real client sends -- never a hand-built payload asserting -// its own shape is correct. -// -// stdin: {"surface": , "steps": [{"set": {node_id: text}}, {"click": node_id}]} -// stdout: one JSON line per intent the click(s) produced, in order. -// -// `set` types into a control by its Eve node id (the same id -// `eve.rs`'s own `json!({"id": ...})` calls carry), by finding the rendered -// element via `data-eve-node-id` (assignId) and dispatching a real `input` -// event, exactly as a user's keystroke would. `click` clicks a -// `control.button` node the same way. jsdom is resolved from the vendored -// package's own devDependencies (its own test suite uses the identical -// pattern in `test/host-isolation.test.mjs`) rather than adding a second copy -// to this repo. -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; -import { createRequire } from "node:module"; - -const here = dirname(fileURLToPath(import.meta.url)); -const loweringRoot = join(here, "..", "vendor", "eve", "packages", "eve-browser-lowering"); -const vendorRequire = createRequire(join(loweringRoot, "package.json")); -const { JSDOM } = vendorRequire("jsdom"); -const { EveBrowserDraftStore, renderEveSurface } = await import( - "file://" + join(loweringRoot, "dist", "index.js").replace(/\\/g, "/") -); - -function installDom() { - const dom = new JSDOM("
", { - url: "https://eve.bridge.test/", - pretendToBeVisual: true, - }); - const names = [ - "document", "window", "Element", "HTMLElement", "HTMLButtonElement", - "HTMLInputElement", "HTMLSelectElement", "HTMLOptionElement", - "HTMLTextAreaElement", "HTMLImageElement", "Event", "CustomEvent", - ]; - for (const name of names) globalThis[name] = dom.window[name]; - return dom; -} - -function cssEscape(value) { - return value.replace(/[^a-zA-Z0-9_-]/g, char => `\\${char}`); -} - -async function readStdin() { - return new Promise((resolve, reject) => { - let data = ""; - process.stdin.setEncoding("utf8"); - process.stdin.on("data", chunk => { data += chunk; }); - process.stdin.on("end", () => resolve(data)); - process.stdin.on("error", reject); - }); -} - -async function main() { - const { surface, steps, provider } = JSON.parse(await readStdin()); - - const dom = installDom(); - const host = dom.window.document.getElementById("host"); - const intents = []; - const draftStore = new EveBrowserDraftStore(); - const options = { - draftStore, - clientId: "eve.bridge.test", - commandSink: intent => { intents.push(intent); }, - // `commandBoundary`/`receiptSchema` are not carried on the surface - // document itself -- a real client learns them from the provider's own - // CultMesh advertisement (`mesh.rs::provider_advertisement`, - // `surfaces[].worldInteraction`), fetched separately. The caller passes - // that same shape through here. - provider, - }; - renderEveSurface(surface, host, options); - - for (const step of steps) { - if (step.set) { - for (const [nodeId, value] of Object.entries(step.set)) { - const wrapper = host.querySelector(`[data-eve-node-id="${cssEscape(nodeId)}"]`); - if (!wrapper) throw new Error(`bridge: no rendered control for node id ${nodeId}`); - // `assignId` stamps the Eve node id onto the field's own wrapper - // (`renderEditableControl`'s `