diff --git a/Cargo.lock b/Cargo.lock index a25480b..ba37627 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,16 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.6", + "generic-array", +] + [[package]] name = "ahash" version = "0.8.12" @@ -40,12 +50,68 @@ dependencies = [ "libc", ] +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + [[package]] name = "anyhow" version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + [[package]] name = "atoi" version = "2.0.0" @@ -55,6 +121,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -137,12 +212,29 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bech32" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -158,6 +250,43 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", + "hex-conservative 1.2.0", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + +[[package]] +name = "bitcoin-io" +version = "0.1.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5de036369d1ac59d3c1819ebc4d850f89466f5401c571a285b6ed564a4cb78" +dependencies = [ + "bitcoin-consensus-encoding", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bca4c7abb40c8817d77403c880988cfd484f23ab2365726afb2f798363e2c4a2" +dependencies = [ + "bitcoin-io", + "hex-conservative 0.2.2", + "serde", +] + [[package]] name = "bitflags" version = "2.13.1" @@ -185,6 +314,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -197,12 +335,68 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "buzz-core" +version = "0.1.0" +source = "git+https://github.com/block/buzz?rev=22be8bb35177e27efc2dca2534df9a8dd871eae0#22be8bb35177e27efc2dca2534df9a8dd871eae0" +dependencies = [ + "base64", + "chrono", + "hex", + "hmac 0.13.0", + "nostr", + "percent-encoding", + "rand 0.10.2", + "serde", + "serde_json", + "sha2 0.11.0", + "subtle", + "thiserror", + "url", + "uuid", + "zeroize", +] + +[[package]] +name = "buzz-sdk" +version = "0.1.0" +source = "git+https://github.com/block/buzz?rev=22be8bb35177e27efc2dca2534df9a8dd871eae0#22be8bb35177e27efc2dca2534df9a8dd871eae0" +dependencies = [ + "buzz-core", + "nostr", + "serde", + "serde_json", + "thiserror", + "uuid", +] + +[[package]] +name = "buzz-ws-client" +version = "0.1.0" +source = "git+https://github.com/block/buzz?rev=22be8bb35177e27efc2dca2534df9a8dd871eae0#22be8bb35177e27efc2dca2534df9a8dd871eae0" +dependencies = [ + "futures-util", + "nostr", + "serde_json", + "thiserror", + "tokio", + "tokio-tungstenite", + "tracing", + "url", +] + [[package]] name = "bytecount" version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + [[package]] name = "byteorder" version = "1.5.0" @@ -215,6 +409,15 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.4.0" @@ -231,6 +434,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "chacha20" version = "0.10.1" @@ -239,7 +453,20 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20 0.9.1", + "cipher", + "poly1305", + "zeroize", ] [[package]] @@ -256,12 +483,75 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.6", + "inout", + "zeroize", +] + +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "cmov" version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -323,6 +613,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -344,6 +635,12 @@ dependencies = [ "cmov", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "digest" version = "0.10.7" @@ -352,6 +649,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.6", + "subtle", ] [[package]] @@ -361,6 +659,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", + "const-oid", "crypto-common 0.2.2", "ctutils", ] @@ -459,6 +758,22 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "parking_lot", + "pear", + "serde", + "serde_yaml", + "tempfile", + "uncased", + "version_check", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -556,6 +871,17 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "futures-sink" version = "0.3.33" @@ -576,6 +902,7 @@ checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" dependencies = [ "futures-core", "futures-io", + "futures-macro", "futures-sink", "futures-task", "memchr", @@ -600,8 +927,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -625,7 +954,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -666,13 +995,40 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex-conservative" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" +dependencies = [ + "arrayvec", +] + [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -919,12 +1275,46 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "ipnet" version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + [[package]] name = "itoa" version = "1.0.18" @@ -1067,6 +1457,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "nostr" +version = "0.44.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e826dd648489de2c5b293920e20b92932ef820302007c1987c758d4d06eeb2cf" +dependencies = [ + "base64", + "bech32", + "bip39", + "bitcoin_hashes", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "instant", + "scrypt", + "secp256k1", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1160,6 +1574,18 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "outref" version = "0.5.2" @@ -1195,6 +1621,50 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest 0.10.7", + "hmac 0.12.1", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn 2.0.119", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1213,6 +1683,17 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -1222,6 +1703,15 @@ dependencies = [ "zerovec", ] +[[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" @@ -1231,6 +1721,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "version_check", + "yansi", +] + [[package]] name = "quote" version = "1.0.47" @@ -1252,15 +1755,74 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand" version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20", + "chacha20 0.10.1", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +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]] @@ -1448,17 +2010,35 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "scarce-studio" version = "0.1.0" dependencies = [ "anyhow", "axum", + "chrono", + "clap", + "figment", + "rustls", + "serde", + "sqlx", "studio-api", + "studio-buzz", "studio-store", + "studio-types", "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -1467,6 +2047,7 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ + "chrono", "dyn-clone", "ref-cast", "schemars_derive", @@ -1492,6 +2073,38 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2 0.10.9", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "rand 0.8.7", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + [[package]] name = "serde" version = "1.0.229" @@ -1569,6 +2182,30 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1" version = "0.11.0" @@ -1708,7 +2345,7 @@ dependencies = [ "tokio-stream", "tracing", "url", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -1770,7 +2407,7 @@ dependencies = [ "log", "percent-encoding", "serde", - "sha1", + "sha1 0.11.0", "sha2 0.11.0", "sqlx-core", "thiserror", @@ -1796,12 +2433,12 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.13.0", "itoa", "log", "md-5", "memchr", - "rand", + "rand 0.10.2", "serde", "serde_json", "sha2 0.11.0", @@ -1855,6 +2492,12 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "studio-api" version = "0.1.0" @@ -1878,8 +2521,15 @@ dependencies = [ name = "studio-buzz" version = "0.1.0" dependencies = [ + "buzz-sdk", + "buzz-ws-client", + "nostr", + "rustls", "studio-core", "thiserror", + "tokio", + "tracing", + "uuid", ] [[package]] @@ -1887,6 +2537,9 @@ name = "studio-core" version = "0.1.0" dependencies = [ "chrono", + "serde", + "serde_json", + "sha2 0.10.9", "studio-types", ] @@ -1903,6 +2556,7 @@ name = "studio-store" version = "0.1.0" dependencies = [ "chrono", + "serde", "serde_json", "sqlx", "studio-types", @@ -2066,6 +2720,16 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.19" @@ -2077,6 +2741,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tower" version = "0.5.3" @@ -2191,12 +2871,39 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1 0.10.7", + "thiserror", +] + [[package]] name = "typenum" version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -2224,6 +2931,22 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.6", + "subtle", +] + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -2240,6 +2963,7 @@ dependencies = [ "idna", "percent-encoding", "serde", + "serde_derive", ] [[package]] @@ -2248,6 +2972,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + [[package]] name = "uuid" version = "1.24.0" @@ -2256,6 +2986,7 @@ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -2383,6 +3114,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -2551,6 +3291,12 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 67a3060..eeedd8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -11,13 +11,25 @@ path = "src/main.rs" [dependencies] studio-api = { path = "crates/studio-api" } +studio-buzz = { path = "crates/studio-buzz" } studio-store = { path = "crates/studio-store" } +studio-types = { path = "crates/studio-types" } anyhow = { workspace = true } axum = { workspace = true } +chrono = { workspace = true } +clap = { workspace = true } +figment = { workspace = true } +rustls = { workspace = true } +serde = { workspace = true } +sqlx = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +figment = { workspace = true, features = ["test"] } [workspace] members = [ @@ -48,13 +60,29 @@ sqlx = { version = "0.9", features = ["runtime-tokio", "tls-rustls", "sqlite", " # Serialization — schemars pin matches the buzz workspace (version = "1") serde = { version = "1", features = ["derive"] } serde_json = "1" -schemars = "1" +schemars = { version = "1", features = ["chrono04"] } # Domain bech32 = "0.11" chrono = { version = "0.4", features = ["serde"] } +sha2 = "0.10" uuid = { version = "1", features = ["v4"] } +# Configuration — defaults ← YAML ← SCARCED_* env (ludovic, 2026-08-01) +clap = { version = "4", features = ["derive"] } +figment = { version = "0.10", features = ["yaml", "env"] } + +# Buzz coordination substrate — crates consumed directly from the buzz repo +# (ludovic, 2026-08-01). Pinned by rev: the repo is a workspace, not a +# registry; bump deliberately, in lockstep with the relay we run against. +buzz-sdk = { git = "https://github.com/block/buzz", rev = "22be8bb35177e27efc2dca2534df9a8dd871eae0" } +buzz-ws-client = { git = "https://github.com/block/buzz", rev = "22be8bb35177e27efc2dca2534df9a8dd871eae0" } +nostr = "0.44" +# WSS needs a process-level rustls CryptoProvider; every binary entry point +# installs ring explicitly (same pin/reason as buzz-cli) — relying on feature +# unification to pick one silently breaks when the dep graph shifts. +rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } + # Errors + observability anyhow = "1" thiserror = "2" diff --git a/README.md b/README.md index 361811b..e945581 100644 --- a/README.md +++ b/README.md @@ -26,17 +26,115 @@ crates: | `studio-pay` | `PayPort`: stub impl through M4, live MPP session impl in M5 | | `studio-api` | axum routes, auth, SSE | +Implemented so far: RFQ capture (M1) and quote issuance + gate-policy engine +(M2). The orchestrator loop and Buzz integration arrive in M3. + +## Install + +```bash +just install scarce # cargo-installs the `scarced` binary +``` + ## Run ```bash -just run # SCARCED_BIND (default 127.0.0.1:7380), - # SCARCED_DB (default sqlite://scarced.db) -curl http://127.0.0.1:7380/healthz +cp scarced.example.yaml scarced.yaml # points at wss://scarce.communities.buzz.xyz +scarced --config scarced.yaml # or `just run --config scarced.yaml` +``` + +Config precedence: defaults ← YAML ← `SCARCED_*` env (figment). Nested keys +join with `__` in env form. `--config` is optional — env-only also works: + +```bash +SCARCED_STUDIO_TOKEN=dev-token scarced +``` + +| Key | Env | Default | | +|---|---|---|---| +| `bind` | `SCARCED_BIND` | `127.0.0.1:7380` | HTTP bind address | +| `db` | `SCARCED_DB` | `sqlite://scarced.db` | projection store (droppable — rebuildable from substrates) | +| `studio_token` | `SCARCED_STUDIO_TOKEN` | unset | bearer token for quote issuance; unset disables those routes (fail-closed) | +| `sweep_seconds` | `SCARCED_SWEEP_SECONDS` | `30` | quote-expiry sweep cadence | +| `buzz.relay_url` | `SCARCED_BUZZ__RELAY_URL` | unset | community relay the M3 orchestrator connects to | + +## Try it + +The API is self-describing — start at the index: + +```bash +curl -s localhost:7380/api/v1 | jq # every endpoint + schema links +curl -s localhost:7380/api/v1/schemas/rfq | jq # JSON Schema of any wire type +``` + +Capture demand (open, no auth — this is the signal intake): + +```bash +RFQ_ID=$(curl -s localhost:7380/api/v1/rfqs --json '{ + "query": "solana priority fee forecast api", + "buyer_npub": "npub1vadgs8qfwsgf7ak3jqvsys6dprae6eyyzzwr8v345l39yz77af4s7eg4zn" +}' | jq -r .id) ``` +Invalid input returns `422` with `{ "errors": [{ "field", "message" }] }`. + +Issue the quote (studio-authenticated; one per RFQ — a second POST is `409`): + +```bash +curl -s localhost:7380/api/v1/rfqs/$RFQ_ID/quote \ + -H 'authorization: Bearer dev-token' --json '{ + "price": { "amount": 250000000, "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, + "milestones": [ + { "title": "Forecast model", "description": "p50/p90 per program id", "amount": 150000000 }, + { "title": "Gated endpoint", "description": "pay.sh-gated REST endpoint", "amount": 100000000 } + ], + "timeline": "2 weeks, weekly demos", + "payout_destination": { "kind": "splits", "splits": [ + { "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 } + ]}, + "channel": { "idle_timeout_seconds": 604800 }, + "expires_at": "2026-09-01T00:00:00Z" +}' | jq +``` + +The response carries the defaulted studio gate policy and its `policy_hash` +commitment. The buyer read is free: `GET /api/v1/rfqs/$RFQ_ID/quote` — status +is computed fail-closed against `expires_at`, so a lapsed quote reads +`LAPSED` even before the sweep stamps it. + +Accept the quote (buyer, free, once — a second POST is `409`, and a lapsed +quote refuses): + +```bash +curl -s -X POST localhost:7380/api/v1/rfqs/$RFQ_ID/quote/accept | jq .status +``` + +Acceptance stands in for funding while payments are stubbed (PLAN.md §6 +override path): the contract starts. + +## Watch it in Buzz + +With the `buzz` config section present (see `scarced.example.yaml`), every +lifecycle beat is mirrored to the community relay: demand captured, quote +issued, and quote accepted post to the ops channel, and acceptance creates a +per-project workroom channel (`proj--`) where the +contract-starting post lands. The workroom's channel-create event id is +stored as the FUNDED → WORKROOM_ACTIVE evidence. + +Channels are **private**: workrooms carry commercial terms, so only members +see them. The mirror adds the RFQ's buyer to the workroom on creation; the +studio identity is the channel owner. Operator one-offs (flip visibility, +add a member by npub) live in +`cargo run -p studio-buzz --example channel_admin`. + +The daemon signs as the studio identity (`buzz.private_key`); a managed-agent +identity also needs the NIP-OA tag (`buzz.auth_tag`, env +`SCARCED_BUZZ__AUTH_TAG`). Omit the whole `buzz` section for a ledger-only +run. + ## Develop ```bash just ci # fmt + clippy -D warnings + test — what CI runs +just schemas # regenerate schemas/*.json from studio-types (drift-tested in CI) just --list # everything else ``` diff --git a/crates/studio-api/Cargo.toml b/crates/studio-api/Cargo.toml index 0e16c45..63737a8 100644 --- a/crates/studio-api/Cargo.toml +++ b/crates/studio-api/Cargo.toml @@ -15,6 +15,7 @@ chrono = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } sqlx = { workspace = true } +tokio = { workspace = true } tracing = { workspace = true } uuid = { workspace = true } diff --git a/crates/studio-api/src/endpoints/accept_quote.rs b/crates/studio-api/src/endpoints/accept_quote.rs new file mode 100644 index 0000000..907054c --- /dev/null +++ b/crates/studio-api/src/endpoints/accept_quote.rs @@ -0,0 +1,105 @@ +//! `POST /api/v1/rfqs/{id}/quote/accept` — buyer acceptance. Free like every +//! buyer surface (buyers never authenticate — ARCHITECTURE.md §2.1); the +//! buyer-signed upgrade rides the same reserved-signature path as the RFQ. +//! ACCEPTED stands in for FUNDED while payments are stubbed (PLAN.md §6 +//! override path): accepting a live quote starts the contract. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; +use studio_core::quote::AcceptError; + +use crate::{AppState, LifecycleBeat}; + +pub async fn handler( + State(state): State>, + Path(rfq_id): Path, +) -> impl IntoResponse { + let quote = match studio_store::quotes::get_by_rfq(&state.db, &rfq_id).await { + Ok(Some(quote)) => quote, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "no quote exists for this rfq" })), + ) + } + Err(e) => { + tracing::error!(error = %e, rfq_id = %rfq_id, "quote lookup failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "storage failure" })), + ); + } + }; + + // Pure decision first (shared with any CLI/MCP), atomic guard second — + // the UPDATE's WHERE clause re-checks the same rule, so a race loses + // instead of double-accepting. + let now = chrono::Utc::now(); + if let Err(refusal) = studio_core::quote::accept("e, now) { + return refuse(refusal, "e); + } + match studio_store::quotes::mark_accepted(&state.db, &rfq_id, now).await { + Ok(1) => {} + Ok(_) => { + // Lost the race between read and update; re-derive the refusal. + let refusal = studio_core::quote::accept("e.clone().at(now), now) + .err() + .unwrap_or(AcceptError::AlreadyAccepted); + return refuse(refusal, "e); + } + Err(e) => { + tracing::error!(error = %e, rfq_id = %rfq_id, "accept update failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "storage failure" })), + ); + } + } + + let accepted = match studio_store::quotes::get_by_rfq(&state.db, &rfq_id).await { + Ok(Some(quote)) => quote, + other => { + tracing::error!(?other, rfq_id = %rfq_id, "accepted quote re-read failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "storage failure" })), + ); + } + }; + tracing::info!(quote_id = %accepted.id, rfq_id = %rfq_id, "quote accepted — contract starting"); + + match studio_store::rfqs::get(&state.db, &rfq_id).await { + Ok(Some(rfq)) => state.emit(LifecycleBeat::QuoteAccepted { + rfq: Box::new(rfq), + quote: Box::new(accepted.clone()), + }), + other => { + tracing::error!(?other, rfq_id = %rfq_id, "rfq re-read failed; accept beat not mirrored") + } + } + + (StatusCode::OK, Json(serde_json::json!(accepted))) +} + +fn refuse( + refusal: AcceptError, + quote: &studio_types::Quote, +) -> (StatusCode, Json) { + let message = match refusal { + AcceptError::AlreadyAccepted => "quote already accepted".to_string(), + AcceptError::Lapsed => format!( + "quote lapsed at {} and can no longer be accepted", + quote.expires_at.to_rfc3339() + ), + }; + ( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": message })), + ) +} diff --git a/crates/studio-api/src/endpoints/api_index.rs b/crates/studio-api/src/endpoints/api_index.rs index d48e4a8..06e1fbe 100644 --- a/crates/studio-api/src/endpoints/api_index.rs +++ b/crates/studio-api/src/endpoints/api_index.rs @@ -21,6 +21,9 @@ pub async fn handler() -> impl IntoResponse { { "method": "POST", "path": "/api/v1/rfqs", "description": "capture a demand record (schema: rfq)" }, { "method": "GET", "path": "/api/v1/rfqs", "description": "list captured RFQs, oldest first (?since=)" }, { "method": "GET", "path": "/api/v1/rfqs/{id}", "description": "fetch one captured RFQ" }, + { "method": "POST", "path": "/api/v1/rfqs/{id}/quote", "description": "issue the quote for an RFQ (studio bearer token; schema: quote)" }, + { "method": "GET", "path": "/api/v1/rfqs/{id}/quote", "description": "fetch the quote for an RFQ (status fail-closed against expiry)" }, + { "method": "POST", "path": "/api/v1/rfqs/{id}/quote/accept", "description": "accept a live quote (buyer, free; once) — starts the contract" }, ], "schemas": schemas, "errors": "validation failures return 422 with { errors: [{ field, message }] }", diff --git a/crates/studio-api/src/endpoints/create_quote.rs b/crates/studio-api/src/endpoints/create_quote.rs new file mode 100644 index 0000000..f1440f1 --- /dev/null +++ b/crates/studio-api/src/endpoints/create_quote.rs @@ -0,0 +1,130 @@ +//! `POST /api/v1/rfqs/{id}/quote` — studio-authenticated quote issuance (PLAN.md +//! M2). The quote is authored by a human/agent for now; the bearer token is +//! the studio's own door, not a buyer surface (buyers never authenticate — +//! ARCHITECTURE.md §2.1). Fail-closed: with no token configured the route is +//! disabled, never open. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::{header, HeaderMap, StatusCode}, + response::IntoResponse, + Json, +}; +use studio_store::StoreError; +use studio_types::NewQuote; + +use crate::AppState; + +pub async fn handler( + State(state): State>, + Path(rfq_id): Path, + headers: HeaderMap, + Json(body): Json, +) -> impl IntoResponse { + let Some(expected) = state.studio_token.as_deref() else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(serde_json::json!({ + "error": "quote issuance disabled: SCARCED_STUDIO_TOKEN is not configured" + })), + ); + }; + if !bearer_matches(&headers, expected) { + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ "error": "studio bearer token required" })), + ); + } + + match studio_store::rfqs::get(&state.db, &rfq_id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "rfq not found" })), + ) + } + Err(e) => { + tracing::error!(error = %e, rfq_id = %rfq_id, "rfq lookup failed"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "storage failure" })), + ); + } + } + + // Deserialize by hand so shape errors come back as 422 field errors, + // matching the validation contract, instead of axum's opaque rejection. + let new_quote: NewQuote = match serde_json::from_value(body) { + Ok(quote) => quote, + Err(e) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({ + "errors": [{ "field": "body", "message": e.to_string() }] + })), + ) + } + }; + + // The handler only supplies identity and time; validation, the policy + // commitment hash, and assembly are the core's single issuance path, + // shared with any future CLI/MCP. + let quote = match studio_core::quote::issue( + new_quote, + rfq_id, + uuid::Uuid::new_v4().to_string(), + chrono::Utc::now(), + ) { + Ok(quote) => quote, + Err(errors) => { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(serde_json::json!({ "errors": errors })), + ) + } + }; + + match studio_store::quotes::insert(&state.db, "e).await { + Ok(()) => { + tracing::info!( + quote_id = %quote.id, rfq_id = %quote.rfq_id, + policy_hash = %quote.policy_hash, "quote issued" + ); + state.emit(crate::LifecycleBeat::QuoteIssued { + quote: Box::new(quote.clone()), + }); + (StatusCode::CREATED, Json(serde_json::json!(quote))) + } + Err(StoreError::Conflict(message)) => ( + StatusCode::CONFLICT, + Json(serde_json::json!({ "error": message })), + ), + Err(e) => { + tracing::error!(error = %e, "quote insert failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "storage failure" })), + ) + } + } +} + +/// Constant-time bearer comparison (modulo length, which the token's own +/// randomness makes uninformative). +fn bearer_matches(headers: &HeaderMap, expected: &str) -> bool { + let presented = headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")); + let Some(presented) = presented else { + return false; + }; + let (a, b) = (presented.as_bytes(), expected.as_bytes()); + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} diff --git a/crates/studio-api/src/endpoints/create_rfq.rs b/crates/studio-api/src/endpoints/create_rfq.rs index 9569366..f4abb52 100644 --- a/crates/studio-api/src/endpoints/create_rfq.rs +++ b/crates/studio-api/src/endpoints/create_rfq.rs @@ -45,6 +45,9 @@ pub async fn handler( match studio_store::rfqs::insert(&state.db, &rfq).await { Ok(()) => { tracing::info!(rfq_id = %rfq.id, buyer = %rfq.buyer_npub, "rfq captured"); + state.emit(crate::LifecycleBeat::DemandCaptured { + rfq: Box::new(rfq.clone()), + }); (StatusCode::CREATED, Json(serde_json::json!(rfq))) } Err(e) => { diff --git a/crates/studio-api/src/endpoints/get_quote.rs b/crates/studio-api/src/endpoints/get_quote.rs new file mode 100644 index 0000000..532e9a4 --- /dev/null +++ b/crates/studio-api/src/endpoints/get_quote.rs @@ -0,0 +1,38 @@ +//! `GET /api/v1/rfqs/{id}/quote` — the buyer's free read (ARCHITECTURE.md §4: +//! status reads never taxed). The served status is fail-closed against +//! sweep lag: a quote past its expiry reads LAPSED even before the sweep +//! stamps the row. + +use std::sync::Arc; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; + +use crate::AppState; + +pub async fn handler( + State(state): State>, + Path(rfq_id): Path, +) -> impl IntoResponse { + match studio_store::quotes::get_by_rfq(&state.db, &rfq_id).await { + Ok(Some(quote)) => ( + StatusCode::OK, + Json(serde_json::json!(quote.at(chrono::Utc::now()))), + ), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(serde_json::json!({ "error": "no quote for this rfq" })), + ), + Err(e) => { + tracing::error!(error = %e, rfq_id = %rfq_id, "quote read failed"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "storage failure" })), + ) + } + } +} diff --git a/crates/studio-api/src/endpoints/mod.rs b/crates/studio-api/src/endpoints/mod.rs index 312365a..98e6ded 100644 --- a/crates/studio-api/src/endpoints/mod.rs +++ b/crates/studio-api/src/endpoints/mod.rs @@ -1,8 +1,11 @@ //! One module per endpoint (SF API conventions). `/healthz` lives in the //! crate root. +pub mod accept_quote; pub mod api_index; +pub mod create_quote; pub mod create_rfq; +pub mod get_quote; pub mod get_rfq; pub mod get_schema; pub mod list_rfqs; diff --git a/crates/studio-api/src/lib.rs b/crates/studio-api/src/lib.rs index 8f77c17..f7e1cc5 100644 --- a/crates/studio-api/src/lib.rs +++ b/crates/studio-api/src/lib.rs @@ -15,13 +15,41 @@ use axum::{ Json, Router, }; use sqlx::SqlitePool; +use studio_types::{Quote, Rfq}; pub mod endpoints; +/// A lifecycle moment worth mirroring to the coordination substrate. The API +/// emits these post-commit; the daemon's mirror task turns them into Buzz +/// posts (and, on acceptance, the workroom channel). Best-effort by design in +/// this slice — the projection row is already durable when a beat is emitted. +#[derive(Debug, Clone)] +pub enum LifecycleBeat { + DemandCaptured { rfq: Box }, + QuoteIssued { quote: Box }, + QuoteAccepted { rfq: Box, quote: Box }, +} + /// Shared state for all handlers. The pool is the projection store — /// rebuildable, never authoritative. pub struct AppState { pub db: SqlitePool, + /// Bearer token for studio-authenticated routes (quote issuance). + /// `None` disables those routes — fail-closed, never fail-open. + pub studio_token: Option, + /// Lifecycle beat sink, consumed by the daemon's Buzz mirror task. + /// `None` (tests, ledger-only runs) simply drops the beats. + pub lifecycle: Option>, +} + +impl AppState { + pub(crate) fn emit(&self, beat: LifecycleBeat) { + if let Some(tx) = &self.lifecycle { + if tx.send(beat).is_err() { + tracing::warn!("lifecycle mirror receiver dropped; beat not mirrored"); + } + } + } } pub fn router(state: Arc) -> Router { @@ -39,6 +67,14 @@ pub fn router(state: Arc) -> Router { post(endpoints::create_rfq::handler).get(endpoints::list_rfqs::handler), ) .route("/api/v1/rfqs/{id}", get(endpoints::get_rfq::handler)) + .route( + "/api/v1/rfqs/{id}/quote", + post(endpoints::create_quote::handler).get(endpoints::get_quote::handler), + ) + .route( + "/api/v1/rfqs/{id}/quote/accept", + post(endpoints::accept_quote::handler), + ) .with_state(state) } @@ -75,7 +111,11 @@ mod tests { #[tokio::test] async fn healthz_returns_200_with_live_store() { let db = studio_store::open("sqlite::memory:").await.unwrap(); - let app = router(Arc::new(AppState { db })); + let app = router(Arc::new(AppState { + db, + studio_token: None, + lifecycle: None, + })); let response = app .oneshot( @@ -97,7 +137,11 @@ mod tests { async fn healthz_returns_503_when_store_is_gone() { let db = studio_store::open("sqlite::memory:").await.unwrap(); db.close().await; - let app = router(Arc::new(AppState { db })); + let app = router(Arc::new(AppState { + db, + studio_token: None, + lifecycle: None, + })); let response = app .oneshot( diff --git a/crates/studio-api/tests/quote_api.rs b/crates/studio-api/tests/quote_api.rs new file mode 100644 index 0000000..ca35b4d --- /dev/null +++ b/crates/studio-api/tests/quote_api.rs @@ -0,0 +1,376 @@ +//! M2 contract tests: quote issuance (studio-authenticated), the buyer's +//! free read, and RFQ→QUOTED→LAPSED observable via the API (PLAN.md M2 +//! done-when). + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{header, Request, StatusCode}; +use http_body_util::BodyExt; +use studio_api::{router, AppState}; +use tower::ServiceExt; + +const GOOD_NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy"; +const TOKEN: &str = "test-studio-token"; + +async fn app_with_token(token: Option<&str>) -> axum::Router { + let db = studio_store::open("sqlite::memory:").await.unwrap(); + router(Arc::new(AppState { + db, + studio_token: token.map(String::from), + lifecycle: None, + })) +} + +async fn send( + app: &axum::Router, + method: &str, + uri: &str, + bearer: Option<&str>, + body: Option, +) -> (StatusCode, serde_json::Value) { + let mut builder = Request::builder().method(method).uri(uri); + if let Some(token) = bearer { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + let request = match body { + Some(json) => builder + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(json.to_string())) + .unwrap(), + None => builder.body(Body::empty()).unwrap(), + }; + let response = app.clone().oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = response.into_body().collect().await.unwrap().to_bytes(); + let json = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + (status, json) +} + +async fn capture_rfq(app: &axum::Router) -> String { + let (status, created) = send( + app, + "POST", + "/api/v1/rfqs", + None, + Some(serde_json::json!({ + "query": "solana priority fee forecast api", + "buyer_npub": GOOD_NPUB + })), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + created["id"].as_str().unwrap().to_string() +} + +fn quote_body(expires_at: &str) -> serde_json::Value { + serde_json::json!({ + "price": { "amount": 250_000_000, "mint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v" }, + "milestones": [ + { "title": "Forecast model", "description": "p50/p90 per program id", "amount": 150_000_000 }, + { "title": "Gated endpoint", "description": "pay.sh-gated REST endpoint", "amount": 100_000_000 } + ], + "timeline": "2 weeks, weekly demos", + "payout_destination": { "kind": "splits", "splits": [ + { "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 } + ]}, + "channel": { "idle_timeout_seconds": 604_800 }, + "expires_at": expires_at + }) +} + +const FAR_FUTURE: &str = "2199-01-01T00:00:00Z"; + +#[tokio::test] +async fn quote_round_trip_with_defaults_applied() { + let app = app_with_token(Some(TOKEN)).await; + let rfq_id = capture_rfq(&app).await; + + let (status, created) = send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + Some(TOKEN), + Some(quote_body(FAR_FUTURE)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + assert_eq!(created["status"], "QUOTED"); + assert_eq!(created["rfq_id"], serde_json::json!(rfq_id)); + assert_eq!(created["channel"]["grace_seconds"], 172_800); + assert_eq!( + created["policy_hash"].as_str().map(str::len), + Some(64), + "policy hash recorded at issue" + ); + // the defaulted studio policy rides in the quote, hash-committed + assert!(created["gate_policy"]["edges"]["QUOTED->FUNDED"].is_array()); + assert!(created["created_at"].is_string()); + assert_eq!(created["lapsed_at"], serde_json::Value::Null); + + // buyer read is free (no bearer) and identical + let (status, fetched) = send( + &app, + "GET", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched, created); +} + +#[tokio::test] +async fn expired_quote_reads_lapsed_with_timestamps() { + let app = app_with_token(Some(TOKEN)).await; + let rfq_id = capture_rfq(&app).await; + + // expires_at must be in the future at issue; one second is enough to + // land in the past by read time without slowing the suite. + let expires_at = (chrono::Utc::now() + chrono::Duration::seconds(1)).to_rfc3339(); + let (status, created) = send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + Some(TOKEN), + Some(quote_body(&expires_at)), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{created}"); + assert_eq!(created["status"], "QUOTED"); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + + // RFQ→QUOTED→LAPSED observable with timestamps: no sweep ran in this + // test, so this also proves the read side is fail-closed on its own. + let (status, fetched) = send( + &app, + "GET", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(fetched["status"], "LAPSED"); + assert_eq!(fetched["lapsed_at"], fetched["expires_at"]); + assert_eq!(fetched["created_at"], created["created_at"]); +} + +#[tokio::test] +async fn second_quote_conflicts() { + let app = app_with_token(Some(TOKEN)).await; + let rfq_id = capture_rfq(&app).await; + let uri = format!("/api/v1/rfqs/{rfq_id}/quote"); + + let (status, _) = send( + &app, + "POST", + &uri, + Some(TOKEN), + Some(quote_body(FAR_FUTURE)), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let (status, body) = send( + &app, + "POST", + &uri, + Some(TOKEN), + Some(quote_body(FAR_FUTURE)), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); +} + +#[tokio::test] +async fn quoting_a_missing_rfq_is_404() { + let app = app_with_token(Some(TOKEN)).await; + let (status, _) = send( + &app, + "POST", + "/api/v1/rfqs/definitely-not-there/quote", + Some(TOKEN), + Some(quote_body(FAR_FUTURE)), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn invalid_quote_gets_422_with_field_errors() { + let app = app_with_token(Some(TOKEN)).await; + let rfq_id = capture_rfq(&app).await; + + let mut body = quote_body(FAR_FUTURE); + body["milestones"][1]["amount"] = serde_json::json!(1); // sum ≠ price + let (status, response) = send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + Some(TOKEN), + Some(body), + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(response["errors"][0]["field"], "milestones"); +} + +#[tokio::test] +async fn issuance_requires_the_right_bearer_token() { + let app = app_with_token(Some(TOKEN)).await; + let rfq_id = capture_rfq(&app).await; + let uri = format!("/api/v1/rfqs/{rfq_id}/quote"); + + for bad in [None, Some("wrong-token")] { + let (status, _) = send(&app, "POST", &uri, bad, Some(quote_body(FAR_FUTURE))).await; + assert_eq!(status, StatusCode::UNAUTHORIZED, "bearer: {bad:?}"); + } +} + +#[tokio::test] +async fn issuance_is_disabled_without_a_configured_token() { + // Fail-closed: no token in config can never mean "no auth required". + let app = app_with_token(None).await; + let rfq_id = capture_rfq(&app).await; + let (status, body) = send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + Some("anything"), + Some(quote_body(FAR_FUTURE)), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}"); +} + +#[tokio::test] +async fn missing_quote_is_404_and_reads_stay_free() { + let app = app_with_token(Some(TOKEN)).await; + let rfq_id = capture_rfq(&app).await; + let (status, _) = send( + &app, + "GET", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn accept_flow_once_free_and_conflict_after() { + let app = app_with_token(Some(TOKEN)).await; + let rfq_id = capture_rfq(&app).await; + let (status, _) = send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + Some(TOKEN), + Some(quote_body(FAR_FUTURE)), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + + // buyer accepts — free, no bearer + let accept_uri = format!("/api/v1/rfqs/{rfq_id}/quote/accept"); + let (status, accepted) = send(&app, "POST", &accept_uri, None, None).await; + assert_eq!(status, StatusCode::OK, "{accepted}"); + assert_eq!(accepted["status"], "ACCEPTED"); + assert!(accepted["accepted_at"].is_string()); + + // second accept conflicts, with the reason named + let (status, body) = send(&app, "POST", &accept_uri, None, None).await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert!( + body["error"].as_str().unwrap().contains("already accepted"), + "{body}" + ); + + // the read reflects acceptance and never lapses it + let (status, read) = send( + &app, + "GET", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(read["status"], "ACCEPTED"); +} + +#[tokio::test] +async fn accepting_a_missing_or_lapsed_quote_refuses() { + let app = app_with_token(Some(TOKEN)).await; + let rfq_id = capture_rfq(&app).await; + + // no quote yet + let accept_uri = format!("/api/v1/rfqs/{rfq_id}/quote/accept"); + let (status, _) = send(&app, "POST", &accept_uri, None, None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + + // issue a quote that expires almost immediately, then let it pass + let expires = (chrono::Utc::now() + chrono::Duration::milliseconds(50)).to_rfc3339(); + let (status, _) = send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + Some(TOKEN), + Some(quote_body(&expires)), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + tokio::time::sleep(std::time::Duration::from_millis(80)).await; + + let (status, body) = send(&app, "POST", &accept_uri, None, None).await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert!(body["error"].as_str().unwrap().contains("lapsed"), "{body}"); +} + +#[tokio::test] +async fn lifecycle_beats_are_emitted_in_order() { + let db = studio_store::open("sqlite::memory:").await.unwrap(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let app = router(Arc::new(AppState { + db, + studio_token: Some(TOKEN.into()), + lifecycle: Some(tx), + })); + + let rfq_id = capture_rfq(&app).await; + send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote"), + Some(TOKEN), + Some(quote_body(FAR_FUTURE)), + ) + .await; + send( + &app, + "POST", + &format!("/api/v1/rfqs/{rfq_id}/quote/accept"), + None, + None, + ) + .await; + + use studio_api::LifecycleBeat; + assert!(matches!( + rx.try_recv().unwrap(), + LifecycleBeat::DemandCaptured { rfq } if rfq.id == rfq_id + )); + assert!(matches!( + rx.try_recv().unwrap(), + LifecycleBeat::QuoteIssued { quote } if quote.rfq_id == rfq_id + )); + assert!(matches!( + rx.try_recv().unwrap(), + LifecycleBeat::QuoteAccepted { rfq, quote } + if rfq.id == rfq_id && quote.accepted_at.is_some() + )); + assert!(rx.try_recv().is_err(), "no extra beats"); +} diff --git a/crates/studio-api/tests/rfq_api.rs b/crates/studio-api/tests/rfq_api.rs index c298bd6..4e3f297 100644 --- a/crates/studio-api/tests/rfq_api.rs +++ b/crates/studio-api/tests/rfq_api.rs @@ -12,7 +12,11 @@ const GOOD_NPUB: &str = "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0 async fn app() -> axum::Router { let db = studio_store::open("sqlite::memory:").await.unwrap(); - router(Arc::new(AppState { db })) + router(Arc::new(AppState { + db, + studio_token: None, + lifecycle: None, + })) } async fn send( diff --git a/crates/studio-buzz/Cargo.toml b/crates/studio-buzz/Cargo.toml index 69e8498..d92978b 100644 --- a/crates/studio-buzz/Cargo.toml +++ b/crates/studio-buzz/Cargo.toml @@ -8,3 +8,13 @@ publish = false [dependencies] studio-core = { workspace = true } thiserror = { workspace = true } + +buzz-sdk = { workspace = true } +buzz-ws-client = { workspace = true } +nostr = { workspace = true } +rustls = { workspace = true } +tracing = { workspace = true } +uuid = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true } diff --git a/crates/studio-buzz/examples/channel_admin.rs b/crates/studio-buzz/examples/channel_admin.rs new file mode 100644 index 0000000..e4aaeab --- /dev/null +++ b/crates/studio-buzz/examples/channel_admin.rs @@ -0,0 +1,38 @@ +//! Operator one-offs against a community relay, speaking as the studio +//! identity. Kept as an example so it never ships in the daemon. +//! +//! ```sh +//! export STUDIO_KEY= # and STUDIO_AUTH_TAG for managed identities +//! cargo run -p studio-buzz --example channel_admin -- \ +//! wss://scarce.communities.buzz.xyz set-visibility private +//! cargo run -p studio-buzz --example channel_admin -- \ +//! wss://scarce.communities.buzz.xyz add-member +//! ``` + +use studio_buzz::{BuzzPort, RelayBuzz}; + +#[tokio::main] +async fn main() -> Result<(), Box> { + studio_buzz::install_crypto_provider(); + let args: Vec = std::env::args().skip(1).collect(); + let key = std::env::var("STUDIO_KEY").map_err(|_| "STUDIO_KEY env var is required")?; + let auth_tag = std::env::var("STUDIO_AUTH_TAG").ok(); + + let usage = "usage: channel_admin set-visibility | add-member "; + let [relay, cmd, channel, value] = args.as_slice() else { + return Err(usage.into()); + }; + let channel = uuid::Uuid::parse_str(channel)?; + let buzz = RelayBuzz::new(relay, &key, auth_tag.as_deref())?; + + let event_id = match cmd.as_str() { + "set-visibility" => buzz.set_visibility(channel, value).await?, + "add-member" => { + buzz.add_member(channel, &studio_buzz::pubkey_hex(value)?) + .await? + } + _ => return Err(usage.into()), + }; + println!("{{\"accepted\":true,\"event_id\":\"{event_id}\"}}"); + Ok(()) +} diff --git a/crates/studio-buzz/src/lib.rs b/crates/studio-buzz/src/lib.rs index cfd355a..d413d66 100644 --- a/crates/studio-buzz/src/lib.rs +++ b/crates/studio-buzz/src/lib.rs @@ -1,8 +1,253 @@ //! `BuzzPort` — the studio's hands on the coordination substrate. //! -//! Trait + crate-backed impl (buzz-sdk / buzz-core / buzz-ws-client / -//! buzz-workflow, per PLAN.md §1) + mock, built in M3. The orchestrator only -//! ever sees the trait, so the state machine is testable without a relay. +//! Trait + crate-backed impl (buzz-sdk builders + buzz-ws-client transport, +//! PLAN.md §1) + a recording mock. The orchestrator only ever sees the +//! trait, so the lifecycle mirror is testable without a relay. //! -//! M0 ships the empty shell so the workspace shape is fixed from the first -//! commit. +//! The relay impl signs with the studio key and publishes over NIP-42 +//! authenticated websocket, one connection per publish — the mirror's volume +//! is a handful of events per engagement, so connection reuse buys nothing +//! yet. Every publish waits for the relay's OK: an event id this crate +//! returns is one the relay has accepted, which is what lets callers store +//! it as transition evidence (ARCHITECTURE.md §1). + +use std::future::Future; + +use nostr::{EventBuilder, Keys, Tag}; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +pub enum BuzzError { + #[error("invalid studio key: {0}")] + Key(String), + #[error("invalid auth tag: {0}")] + AuthTag(String), + #[error("event build failed: {0}")] + Build(String), + #[error("relay transport: {0}")] + Transport(#[from] buzz_ws_client::WsClientError), + #[error("relay rejected event {event_id}: {message}")] + Rejected { event_id: String, message: String }, +} + +/// A channel the port created, with the relay-accepted create event id — +/// the FUNDED → WORKROOM_ACTIVE evidence when the channel is a workroom. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreatedChannel { + pub channel_id: Uuid, + pub create_event_id: String, +} + +pub trait BuzzPort: Send + Sync + 'static { + /// Create a **private** stream channel (ludovic, 2026-08-01: workrooms + /// carry commercial terms — members only); returns ids only after the + /// relay OK. Private means invisible to non-members: every party that + /// should see the channel must be added via [`BuzzPort::add_member`]. + fn create_channel( + &self, + name: &str, + about: &str, + ) -> impl Future> + Send; + + /// Post a message into a channel; returns the relay-accepted event id. + fn post( + &self, + channel_id: Uuid, + content: &str, + ) -> impl Future> + Send; + + /// Add a member (64-char hex pubkey); returns the relay-accepted event id. + fn add_member( + &self, + channel_id: Uuid, + pubkey_hex: &str, + ) -> impl Future> + Send; +} + +/// Install ring as the process-level rustls CryptoProvider — required before +/// the first WSS connection (same pattern and pin as buzz-cli). Call once at +/// every binary entry point; relying on cargo feature unification to select +/// a provider silently breaks when the dependency graph shifts. Idempotent: +/// a second install attempt is a swallowed no-op. +pub fn install_crypto_provider() { + let _ = rustls::crypto::ring::default_provider().install_default(); +} + +/// Decode an npub (or pass through hex) to the 64-char hex pubkey the +/// membership events carry. Full bech32 decode — a shape-valid but corrupt +/// npub fails here, not at the relay. +pub fn pubkey_hex(npub_or_hex: &str) -> Result { + nostr::PublicKey::parse(npub_or_hex) + .map(|pk| pk.to_hex()) + .map_err(|e| BuzzError::Key(format!("invalid pubkey `{npub_or_hex}`: {e}"))) +} + +/// The real thing: studio key + community relay. +pub struct RelayBuzz { + relay_url: String, + keys: Keys, + /// NIP-OA authorization tag (owner-granted membership). Required for + /// managed-agent identities; rides the AUTH event and every published + /// event, matching the buzz CLI's behavior. + auth_tag: Option, +} + +impl RelayBuzz { + /// `private_key` is hex or nsec; `auth_tag` is the NIP-OA tag JSON + /// (`BUZZ_AUTH_TAG` shape). No connection happens here — each publish + /// dials, authenticates (NIP-42), publishes, and hangs up. + pub fn new( + relay_url: &str, + private_key: &str, + auth_tag: Option<&str>, + ) -> Result { + let keys = Keys::parse(private_key).map_err(|e| BuzzError::Key(e.to_string()))?; + let auth_tag = auth_tag + .map(|json| { + buzz_sdk::nip_oa::parse_auth_tag(json) + .map_err(|e| BuzzError::AuthTag(e.to_string())) + }) + .transpose()?; + Ok(Self { + relay_url: relay_url.to_string(), + keys, + auth_tag, + }) + } + + /// The studio identity's public key (hex) — logged at startup so an + /// operator can add it to the community before the first publish. + pub fn public_key_hex(&self) -> String { + self.keys.public_key().to_hex() + } + + /// Admin op, not orchestrator-facing: flip a channel's visibility + /// (`"open"` / `"private"`). Kind-9002 metadata edit. + pub async fn set_visibility( + &self, + channel_id: Uuid, + visibility: &str, + ) -> Result { + let builder = + buzz_sdk::build_update_channel(channel_id, None, None, Some(visibility), None) + .map_err(|e| BuzzError::Build(e.to_string()))?; + self.publish(builder).await + } + + async fn publish(&self, builder: EventBuilder) -> Result { + let builder = match &self.auth_tag { + Some(tag) => builder.tags([tag.clone()]), + None => builder, + }; + let event = builder + .sign_with_keys(&self.keys) + .map_err(|e| BuzzError::Build(e.to_string()))?; + let event_id = event.id.to_hex(); + + let mut conn = buzz_ws_client::NostrWsConnection::connect_authenticated( + &self.relay_url, + &self.keys, + self.auth_tag.as_ref(), + ) + .await?; + let ok = conn.send_event(event).await?; + // Best-effort close; the OK already landed. + let _ = conn.disconnect().await; + + if !ok.accepted { + return Err(BuzzError::Rejected { + event_id, + message: ok.message, + }); + } + Ok(event_id) + } +} + +impl BuzzPort for RelayBuzz { + async fn create_channel(&self, name: &str, about: &str) -> Result { + let channel_id = Uuid::new_v4(); + let builder = buzz_sdk::build_create_channel( + channel_id, + name, + Some(buzz_sdk::Visibility::Private), + Some(buzz_sdk::ChannelKind::Stream), + Some(about), + None, + ) + .map_err(|e| BuzzError::Build(e.to_string()))?; + let create_event_id = self.publish(builder).await?; + Ok(CreatedChannel { + channel_id, + create_event_id, + }) + } + + async fn post(&self, channel_id: Uuid, content: &str) -> Result { + let builder = buzz_sdk::build_message(channel_id, content, None, &[], false, &[]) + .map_err(|e| BuzzError::Build(e.to_string()))?; + self.publish(builder).await + } + + async fn add_member(&self, channel_id: Uuid, pubkey_hex: &str) -> Result { + let builder = + buzz_sdk::build_add_member(channel_id, pubkey_hex, Some(buzz_sdk::MemberRole::Member)) + .map_err(|e| BuzzError::Build(e.to_string()))?; + self.publish(builder).await + } +} + +/// Recording mock for orchestrator tests: deterministic ids, captured calls. +#[derive(Default)] +pub struct MockBuzz { + pub calls: std::sync::Mutex>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MockCall { + CreateChannel { + name: String, + about: String, + }, + Post { + channel_id: Uuid, + content: String, + }, + AddMember { + channel_id: Uuid, + pubkey_hex: String, + }, +} + +impl BuzzPort for MockBuzz { + async fn create_channel(&self, name: &str, about: &str) -> Result { + let mut calls = self.calls.lock().unwrap(); + calls.push(MockCall::CreateChannel { + name: name.to_string(), + about: about.to_string(), + }); + let n = calls.len(); + Ok(CreatedChannel { + channel_id: Uuid::from_u128(n as u128), + create_event_id: format!("mock-create-event-{n}"), + }) + } + + async fn post(&self, channel_id: Uuid, content: &str) -> Result { + let mut calls = self.calls.lock().unwrap(); + calls.push(MockCall::Post { + channel_id, + content: content.to_string(), + }); + Ok(format!("mock-post-event-{}", calls.len())) + } + + async fn add_member(&self, channel_id: Uuid, pubkey_hex: &str) -> Result { + let mut calls = self.calls.lock().unwrap(); + calls.push(MockCall::AddMember { + channel_id, + pubkey_hex: pubkey_hex.to_string(), + }); + Ok(format!("mock-member-event-{}", calls.len())) + } +} diff --git a/crates/studio-core/Cargo.toml b/crates/studio-core/Cargo.toml index 74bd26b..a1de7ae 100644 --- a/crates/studio-core/Cargo.toml +++ b/crates/studio-core/Cargo.toml @@ -9,3 +9,6 @@ publish = false studio-types = { workspace = true } chrono = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } diff --git a/crates/studio-core/src/gate.rs b/crates/studio-core/src/gate.rs new file mode 100644 index 0000000..b71bef0 --- /dev/null +++ b/crates/studio-core/src/gate.rs @@ -0,0 +1,1117 @@ +//! Gate engine (PLAN.md §2.1). Evidence says a transition *happened*; gates +//! say whether it is *allowed to happen*. The policy wire types live in +//! `studio-types::gate`; this module owns evaluation and the commitment +//! hash. +//! +//! `eval` is a pure function: the orchestrator collects evidence and asks; +//! it never decides. Hard semantics, in order of precedence per gate: +//! +//! 1. A valid **operator override** satisfies its gate — loudly: the outcome +//! names the operator and reason, and the override event stays in the +//! evidence trail forever. It is the only escape hatch. +//! 2. An explicit **denial** is a first-class outcome, not an absence — it +//! terminates the attempt (the caller rolls back and requires fresh +//! evidence for the next attempt). +//! 3. Everything else is **fail-closed**: missing, ambiguous, or expired +//! evidence blocks, and the block says exactly what is missing so a +//! blocked project is self-explanatory. +//! +//! Evidence sets are scoped to one edge *attempt*. After a denial rolls a +//! milestone back, the next attempt starts with a fresh set — which is why a +//! set containing both an approval and a denial from the same principal is +//! ambiguous (fail-closed), not last-write-wins. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use studio_types::{Edge, EdgePattern, GatePolicy, GateSpec}; + +/// The gates guarding a concrete edge: the exact `FROM->TO` entry first, +/// then any `any->TO` entry — both apply (a wildcard strengthens, never +/// replaces). Gate indexes in outcomes and overrides refer to positions +/// in this concatenated list. An edge with no entry has no gates: gates +/// are opt-in per policy; evidence requirements still apply at the +/// state-machine layer. +pub fn gates_for(policy: &GatePolicy, edge: Edge) -> Vec<&GateSpec> { + let mut exact = Vec::new(); + let mut wildcard = Vec::new(); + for (key, gates) in &policy.edges { + // Keys are validated before a policy is accepted; an unparseable + // key at eval time is treated as matching nothing (fail-closed: + // it cannot silently gate or un-gate an edge it doesn't name). + match EdgePattern::parse(key) { + Ok(EdgePattern::Exact(e)) if e == edge => exact.extend(gates), + Ok(EdgePattern::AnyTo(to)) if to == edge.to => wildcard.extend(gates), + _ => {} + } + } + exact.extend(wildcard); + exact +} + +/// SHA-256 over the policy's canonical JSON serialization (BTreeMap key +/// order, fixed field order), hex-encoded. Recorded at the FUNDED +/// transition; any later policy must hash identically or carry both +/// parties' signed consent (PLAN.md §2.1 semantics — enforcement wires in +/// with M3). +pub fn commitment_hash(policy: &GatePolicy) -> String { + let canonical = serde_json::to_vec(policy).expect("GatePolicy serializes"); + let digest = Sha256::digest(&canonical); + digest.iter().fold(String::with_capacity(64), |mut s, b| { + use std::fmt::Write; + write!(s, "{b:02x}").expect("writing to a String cannot fail"); + s + }) +} + +/// One piece of collected evidence. Every variant cites its substrate +/// reference (`evidence_ref`: signed event id, approval token id, or tx +/// signature) — the engine never accepts an uncited fact. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum GateEvidence { + /// An approval or denial from a principal (workflow token resolution or + /// signed channel message). + HumanDecision { + principal: String, + approved: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + note: Option, + evidence_ref: String, + at: DateTime, + }, + /// A signed sign-off message from a crew agent. + AgentSignoff { + agent: String, + evidence_ref: String, + at: DateTime, + }, + /// A machine-check run result. `evidence_ref` is the CI run URL, probe + /// transcript pointer, etc. + MachineCheck { + check: String, + passed: bool, + evidence_ref: String, + at: DateTime, + }, + /// `PayPort` funding/acceptance evidence (operator record or tx sig). + Payment { + evidence_ref: String, + at: DateTime, + }, + /// The loud escape hatch: authenticated, reason-required, permanently + /// visible. Addresses one gate by its index on the attempted edge. + OperatorOverride { + gate: usize, + operator: String, + reason: String, + evidence_ref: String, + at: DateTime, + }, +} + +/// Everything the orchestrator has collected for one edge attempt. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct EvidenceSet { + /// When the edge became attemptable (e.g. the demo was posted). Anchors + /// timelocks and escalation windows; without it both fail closed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub eligible_since: Option>, + #[serde(default)] + pub items: Vec, +} + +/// How one gate was satisfied — kept loud so `GET /projects/{id}` can show +/// exactly what let a transition through, override included. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "how", rename_all = "snake_case")] +pub enum GateSatisfaction { + Evidence { + gate: usize, + refs: Vec, + }, + Overridden { + gate: usize, + operator: String, + reason: String, + evidence_ref: String, + }, + Elapsed { + gate: usize, + eligible_since: DateTime, + min_seconds: u64, + }, +} + +/// One unsatisfied gate, self-explanatory by contract: a blocked project must +/// tell the caller exactly what is missing. +#[derive(Debug, Clone, PartialEq, Serialize)] +pub struct GateBlock { + pub gate: usize, + pub spec: GateSpec, + pub why: String, + /// Blocked past the gate's escalation window — notify the principal, + /// then the studio owner. Never auto-passes. + pub escalate: bool, +} + +/// The engine's verdict on one edge attempt. +#[derive(Debug, Clone, PartialEq, Serialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +pub enum GateOutcome { + /// Every gate satisfied; per-gate account of how. + Pass { satisfied: Vec }, + /// Fail-closed: at least one gate lacks evidence. + Blocked { + missing: Vec, + satisfied: Vec, + }, + /// A principal explicitly denied. Terminal for this attempt: the caller + /// records the denial as history, rolls back, and requires fresh + /// evidence to retry. + Denied { + gate: usize, + principal: String, + #[serde(skip_serializing_if = "Option::is_none")] + note: Option, + evidence_ref: String, + }, +} + +enum SingleGate { + Satisfied(GateSatisfaction), + Missing { + why: String, + }, + Denied { + principal: String, + note: Option, + evidence_ref: String, + }, +} + +/// Evaluate one edge attempt. Pure: same inputs, same verdict. +pub fn eval( + policy: &GatePolicy, + edge: Edge, + evidence: &EvidenceSet, + now: DateTime, +) -> GateOutcome { + let gates = gates_for(policy, edge); + let mut satisfied = Vec::new(); + let mut missing = Vec::new(); + + for (index, spec) in gates.iter().enumerate() { + if let Some(s) = valid_override(evidence, index) { + satisfied.push(s); + continue; + } + match eval_gate(spec, index, evidence, now) { + SingleGate::Satisfied(s) => satisfied.push(s), + SingleGate::Denied { + principal, + note, + evidence_ref, + } => { + return GateOutcome::Denied { + gate: index, + principal, + note, + evidence_ref, + } + } + SingleGate::Missing { why } => missing.push(GateBlock { + gate: index, + spec: (*spec).clone(), + why, + escalate: escalation_due(spec, evidence, now), + }), + } + } + + if missing.is_empty() { + GateOutcome::Pass { satisfied } + } else { + GateOutcome::Blocked { missing, satisfied } + } +} + +/// An override satisfies its gate only when fully formed: right gate index, +/// named operator, non-empty reason, cited event. A half-formed override is +/// ignored — it cannot quietly pass a gate. +fn valid_override(evidence: &EvidenceSet, index: usize) -> Option { + evidence.items.iter().find_map(|item| match item { + GateEvidence::OperatorOverride { + gate, + operator, + reason, + evidence_ref, + .. + } if *gate == index + && !operator.trim().is_empty() + && !reason.trim().is_empty() + && !evidence_ref.trim().is_empty() => + { + Some(GateSatisfaction::Overridden { + gate: index, + operator: operator.clone(), + reason: reason.clone(), + evidence_ref: evidence_ref.clone(), + }) + } + _ => None, + }) +} + +fn escalation_due(spec: &GateSpec, evidence: &EvidenceSet, now: DateTime) -> bool { + match (spec.escalate_after_seconds(), evidence.eligible_since) { + (Some(window), Some(since)) => { + now.signed_duration_since(since).num_seconds() >= window as i64 + } + _ => false, + } +} + +fn eval_gate( + spec: &GateSpec, + index: usize, + evidence: &EvidenceSet, + now: DateTime, +) -> SingleGate { + match spec { + GateSpec::HumanApproval { principal, .. } => { + let decisions: Vec<_> = evidence + .items + .iter() + .filter_map(|item| match item { + GateEvidence::HumanDecision { + principal: p, + approved, + note, + evidence_ref, + at, + } if p == principal => Some((*approved, note, evidence_ref, at)), + _ => None, + }) + .collect(); + let approvals: Vec<_> = decisions.iter().filter(|(a, ..)| *a).collect(); + let denials: Vec<_> = decisions.iter().filter(|(a, ..)| !*a).collect(); + match (approvals.is_empty(), denials.is_empty()) { + (true, true) => SingleGate::Missing { + why: format!("awaiting approval from `{principal}`"), + }, + (false, false) => SingleGate::Missing { + why: format!( + "ambiguous: `{principal}` has both an approval and a denial in \ + this attempt — fail-closed" + ), + }, + (false, true) => SingleGate::Satisfied(GateSatisfaction::Evidence { + gate: index, + refs: approvals.iter().map(|(.., r, _)| (*r).clone()).collect(), + }), + (true, false) => { + // Latest denial carries the note the caller records. + let (_, note, evidence_ref, _) = denials + .iter() + .max_by_key(|(.., at)| **at) + .expect("denials is non-empty"); + SingleGate::Denied { + principal: principal.clone(), + note: (*note).clone(), + evidence_ref: (*evidence_ref).clone(), + } + } + } + } + GateSpec::AgentSignoff { agents, k, .. } => { + let mut refs = Vec::new(); + let mut signers = std::collections::BTreeSet::new(); + for item in &evidence.items { + if let GateEvidence::AgentSignoff { + agent, + evidence_ref, + .. + } = item + { + // Sign-offs from agents the gate does not name are + // ignored; the same agent counts once. + if agents.contains(agent) && signers.insert(agent.clone()) { + refs.push(evidence_ref.clone()); + } + } + } + if signers.len() >= *k as usize { + SingleGate::Satisfied(GateSatisfaction::Evidence { gate: index, refs }) + } else { + SingleGate::Missing { + why: format!( + "agent sign-off {}/{} (named: {})", + signers.len(), + k, + agents.join(", ") + ), + } + } + } + GateSpec::MachineCheck { + check, + max_age_seconds, + } => { + let latest = evidence + .items + .iter() + .filter_map(|item| match item { + GateEvidence::MachineCheck { + check: c, + passed, + evidence_ref, + at, + } if c == check => Some((*passed, evidence_ref, *at)), + _ => None, + }) + .max_by_key(|(.., at)| *at); + match latest { + None => SingleGate::Missing { + why: format!("machine check `{check}` has not run"), + }, + Some((false, evidence_ref, _)) => SingleGate::Missing { + why: format!("machine check `{check}` failed (see {evidence_ref})"), + }, + Some((true, evidence_ref, at)) => { + let age = now.signed_duration_since(at).num_seconds(); + match max_age_seconds { + Some(max) if age > *max as i64 => SingleGate::Missing { + why: format!( + "machine check `{check}` evidence expired \ + ({age}s old, max {max}s) — rerun it" + ), + }, + _ => SingleGate::Satisfied(GateSatisfaction::Evidence { + gate: index, + refs: vec![evidence_ref.clone()], + }), + } + } + } + } + GateSpec::PaymentEvidence => { + let refs: Vec<_> = evidence + .items + .iter() + .filter_map(|item| match item { + GateEvidence::Payment { evidence_ref, .. } => Some(evidence_ref.clone()), + _ => None, + }) + .collect(); + if refs.is_empty() { + SingleGate::Missing { + why: "awaiting payment evidence from PayPort".into(), + } + } else { + SingleGate::Satisfied(GateSatisfaction::Evidence { gate: index, refs }) + } + } + GateSpec::Timelock { min_seconds } => match evidence.eligible_since { + None => SingleGate::Missing { + why: "timelock: edge eligibility timestamp missing — fail-closed".into(), + }, + Some(since) => { + let elapsed = now.signed_duration_since(since).num_seconds(); + if elapsed >= *min_seconds as i64 { + SingleGate::Satisfied(GateSatisfaction::Elapsed { + gate: index, + eligible_since: since, + min_seconds: *min_seconds, + }) + } else { + SingleGate::Missing { + why: format!( + "timelock: {}s of {min_seconds}s review window remain", + *min_seconds as i64 - elapsed + ), + } + } + } + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use studio_types::ProjectState; + + fn ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + const NOW: &str = "2026-08-01T18:00:00Z"; + + fn edge(from: ProjectState, to: ProjectState) -> Edge { + Edge { from, to } + } + + fn demoed_accepted() -> Edge { + edge(ProjectState::Demoed, ProjectState::Accepted) + } + + fn policy(key: &str, gates: Vec) -> GatePolicy { + GatePolicy { + edges: BTreeMap::from([(key.to_string(), gates)]), + } + } + + fn approval(principal: &str) -> GateSpec { + GateSpec::HumanApproval { + principal: principal.into(), + escalate_after_seconds: None, + } + } + + fn decision(principal: &str, approved: bool, note: Option<&str>, at: &str) -> GateEvidence { + GateEvidence::HumanDecision { + principal: principal.into(), + approved, + note: note.map(String::from), + evidence_ref: format!("event:{principal}:{at}"), + at: ts(at), + } + } + + fn with_items(items: Vec) -> EvidenceSet { + EvidenceSet { + eligible_since: Some(ts("2026-08-01T12:00:00Z")), + items, + } + } + + fn eval_now(policy: &GatePolicy, edge: Edge, evidence: &EvidenceSet) -> GateOutcome { + eval(policy, edge, evidence, ts(NOW)) + } + + // ── ungated edges ──────────────────────────────────────────────────── + + #[test] + fn edge_with_no_policy_entry_passes_with_no_gates() { + let p = policy("QUOTED->FUNDED", vec![GateSpec::PaymentEvidence]); + let outcome = eval_now( + &p, + edge(ProjectState::Funded, ProjectState::WorkroomActive), + &EvidenceSet::default(), + ); + assert_eq!(outcome, GateOutcome::Pass { satisfied: vec![] }); + } + + // ── payment_evidence ───────────────────────────────────────────────── + + #[test] + fn payment_evidence_blocks_without_payment_and_passes_with_it() { + let p = policy("QUOTED->FUNDED", vec![GateSpec::PaymentEvidence]); + let e = edge(ProjectState::Quoted, ProjectState::Funded); + + match eval_now(&p, e, &EvidenceSet::default()) { + GateOutcome::Blocked { missing, satisfied } => { + assert!(satisfied.is_empty()); + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].gate, 0); + assert!(missing[0].why.contains("payment"), "{}", missing[0].why); + assert!(!missing[0].escalate); + } + other => panic!("expected Blocked, got {other:?}"), + } + + let funded = with_items(vec![GateEvidence::Payment { + evidence_ref: "tx:5oo…sig".into(), + at: ts("2026-08-01T13:00:00Z"), + }]); + assert_eq!( + eval_now(&p, e, &funded), + GateOutcome::Pass { + satisfied: vec![GateSatisfaction::Evidence { + gate: 0, + refs: vec!["tx:5oo…sig".into()] + }] + } + ); + } + + // ── human_approval ─────────────────────────────────────────────────── + + #[test] + fn approval_missing_blocks_and_names_the_principal() { + let p = policy("DEMOED->ACCEPTED", vec![approval("buyer")]); + match eval_now(&p, demoed_accepted(), &with_items(vec![])) { + GateOutcome::Blocked { missing, .. } => { + assert!(missing[0].why.contains("`buyer`"), "{}", missing[0].why); + } + other => panic!("expected Blocked, got {other:?}"), + } + } + + #[test] + fn approval_from_the_wrong_principal_does_not_count() { + let p = policy("DEMOED->ACCEPTED", vec![approval("buyer")]); + let evidence = with_items(vec![decision("intern", true, None, "2026-08-01T13:00:00Z")]); + assert!(matches!( + eval_now(&p, demoed_accepted(), &evidence), + GateOutcome::Blocked { .. } + )); + } + + #[test] + fn approval_passes_and_cites_its_event() { + let p = policy("DEMOED->ACCEPTED", vec![approval("buyer")]); + let evidence = with_items(vec![decision("buyer", true, None, "2026-08-01T13:00:00Z")]); + match eval_now(&p, demoed_accepted(), &evidence) { + GateOutcome::Pass { satisfied } => { + assert_eq!( + satisfied, + vec![GateSatisfaction::Evidence { + gate: 0, + refs: vec!["event:buyer:2026-08-01T13:00:00Z".into()] + }] + ); + } + other => panic!("expected Pass, got {other:?}"), + } + } + + #[test] + fn denial_is_a_first_class_outcome_with_the_latest_note() { + let p = policy("DEMOED->ACCEPTED", vec![approval("buyer")]); + let evidence = with_items(vec![ + decision("buyer", false, Some("first pass"), "2026-08-01T13:00:00Z"), + decision( + "buyer", + false, + Some("latency still 4s"), + "2026-08-01T14:00:00Z", + ), + ]); + assert_eq!( + eval_now(&p, demoed_accepted(), &evidence), + GateOutcome::Denied { + gate: 0, + principal: "buyer".into(), + note: Some("latency still 4s".into()), + evidence_ref: "event:buyer:2026-08-01T14:00:00Z".into(), + } + ); + } + + #[test] + fn conflicting_approval_and_denial_are_ambiguous_and_block() { + let p = policy("DEMOED->ACCEPTED", vec![approval("buyer")]); + let evidence = with_items(vec![ + decision("buyer", true, None, "2026-08-01T13:00:00Z"), + decision( + "buyer", + false, + Some("changed my mind"), + "2026-08-01T14:00:00Z", + ), + ]); + match eval_now(&p, demoed_accepted(), &evidence) { + GateOutcome::Blocked { missing, .. } => { + assert!(missing[0].why.contains("ambiguous"), "{}", missing[0].why); + } + other => panic!("expected Blocked (fail-closed), got {other:?}"), + } + } + + #[test] + fn denial_takes_precedence_over_other_missing_gates() { + let p = policy( + "DEMOED->ACCEPTED", + vec![GateSpec::PaymentEvidence, approval("buyer")], + ); + let evidence = with_items(vec![decision( + "buyer", + false, + Some("not what we agreed"), + "2026-08-01T13:00:00Z", + )]); + assert!(matches!( + eval_now(&p, demoed_accepted(), &evidence), + GateOutcome::Denied { gate: 1, .. } + )); + } + + // ── agent_signoff ──────────────────────────────────────────────────── + + fn signoff_gate(agents: &[&str], k: u32) -> GateSpec { + GateSpec::AgentSignoff { + agents: agents.iter().map(|a| a.to_string()).collect(), + k, + escalate_after_seconds: None, + } + } + + fn signoff(agent: &str, r: &str) -> GateEvidence { + GateEvidence::AgentSignoff { + agent: agent.into(), + evidence_ref: r.into(), + at: ts("2026-08-01T13:00:00Z"), + } + } + + #[test] + fn signoff_k_of_n_counts_distinct_named_agents_only() { + let p = policy( + "DEMOED->ACCEPTED", + vec![signoff_gate(&["reviewer", "qa"], 2)], + ); + let e = demoed_accepted(); + + // 0 of 2 + match eval_now(&p, e, &with_items(vec![])) { + GateOutcome::Blocked { missing, .. } => { + assert!(missing[0].why.contains("0/2"), "{}", missing[0].why) + } + other => panic!("expected Blocked, got {other:?}"), + } + + // duplicate signer counts once; outsider never counts + let evidence = with_items(vec![ + signoff("reviewer", "ev1"), + signoff("reviewer", "ev2"), + signoff("stranger", "ev3"), + ]); + match eval_now(&p, e, &evidence) { + GateOutcome::Blocked { missing, .. } => { + assert!(missing[0].why.contains("1/2"), "{}", missing[0].why) + } + other => panic!("expected Blocked, got {other:?}"), + } + + // 2 of 2 passes with both refs + let evidence = with_items(vec![signoff("reviewer", "ev1"), signoff("qa", "ev4")]); + match eval_now(&p, e, &evidence) { + GateOutcome::Pass { satisfied } => match &satisfied[0] { + GateSatisfaction::Evidence { refs, .. } => { + assert_eq!(refs, &vec!["ev1".to_string(), "ev4".to_string()]) + } + other => panic!("expected Evidence, got {other:?}"), + }, + other => panic!("expected Pass, got {other:?}"), + } + } + + #[test] + fn signoff_one_of_two_needs_only_one() { + let p = policy( + "DEMOED->ACCEPTED", + vec![signoff_gate(&["reviewer", "qa"], 1)], + ); + let evidence = with_items(vec![signoff("qa", "ev1")]); + assert!(matches!( + eval_now(&p, demoed_accepted(), &evidence), + GateOutcome::Pass { .. } + )); + } + + // ── machine_check ──────────────────────────────────────────────────── + + fn check_evidence(check: &str, passed: bool, at: &str) -> GateEvidence { + GateEvidence::MachineCheck { + check: check.into(), + passed, + evidence_ref: format!("ci:{at}"), + at: ts(at), + } + } + + #[test] + fn machine_check_missing_failed_and_rerun_semantics() { + let p = policy( + "ACCEPTED->DELIVERED", + vec![GateSpec::MachineCheck { + check: "endpoint-live".into(), + max_age_seconds: None, + }], + ); + let e = edge(ProjectState::Accepted, ProjectState::Delivered); + + // never ran + match eval_now(&p, e, &with_items(vec![])) { + GateOutcome::Blocked { missing, .. } => { + assert!(missing[0].why.contains("has not run"), "{}", missing[0].why) + } + other => panic!("expected Blocked, got {other:?}"), + } + + // failed + let failed = with_items(vec![check_evidence( + "endpoint-live", + false, + "2026-08-01T13:00:00Z", + )]); + match eval_now(&p, e, &failed) { + GateOutcome::Blocked { missing, .. } => { + assert!(missing[0].why.contains("failed"), "{}", missing[0].why) + } + other => panic!("expected Blocked, got {other:?}"), + } + + // fail then pass: latest run wins + let recovered = with_items(vec![ + check_evidence("endpoint-live", false, "2026-08-01T13:00:00Z"), + check_evidence("endpoint-live", true, "2026-08-01T14:00:00Z"), + ]); + assert!(matches!( + eval_now(&p, e, &recovered), + GateOutcome::Pass { .. } + )); + + // pass then fail: latest run wins, fail-closed + let regressed = with_items(vec![ + check_evidence("endpoint-live", true, "2026-08-01T13:00:00Z"), + check_evidence("endpoint-live", false, "2026-08-01T14:00:00Z"), + ]); + assert!(matches!( + eval_now(&p, e, ®ressed), + GateOutcome::Blocked { .. } + )); + + // a run for a different check never counts + let unrelated = with_items(vec![check_evidence( + "ci-green", + true, + "2026-08-01T14:00:00Z", + )]); + assert!(matches!( + eval_now(&p, e, &unrelated), + GateOutcome::Blocked { .. } + )); + } + + #[test] + fn stale_machine_check_evidence_expires_and_blocks() { + let p = policy( + "ACCEPTED->DELIVERED", + vec![GateSpec::MachineCheck { + check: "endpoint-live".into(), + max_age_seconds: Some(3_600), + }], + ); + let e = edge(ProjectState::Accepted, ProjectState::Delivered); + + // 5h old > 1h max → expired + let stale = with_items(vec![check_evidence( + "endpoint-live", + true, + "2026-08-01T13:00:00Z", + )]); + match eval_now(&p, e, &stale) { + GateOutcome::Blocked { missing, .. } => { + assert!(missing[0].why.contains("expired"), "{}", missing[0].why) + } + other => panic!("expected Blocked, got {other:?}"), + } + + // 30min old ≤ 1h max → fresh + let fresh = with_items(vec![check_evidence( + "endpoint-live", + true, + "2026-08-01T17:30:00Z", + )]); + assert!(matches!(eval_now(&p, e, &fresh), GateOutcome::Pass { .. })); + } + + // ── timelock ───────────────────────────────────────────────────────── + + #[test] + fn timelock_needs_an_eligibility_anchor_and_the_full_window() { + let p = policy( + "any->CLOSED_BY_BUYER", + vec![GateSpec::Timelock { + min_seconds: 172_800, + }], + ); + let e = edge(ProjectState::WorkroomActive, ProjectState::ClosedByBuyer); + + // no anchor → fail-closed + let anchorless = EvidenceSet { + eligible_since: None, + items: vec![], + }; + match eval_now(&p, e, &anchorless) { + GateOutcome::Blocked { missing, .. } => { + assert!(missing[0].why.contains("fail-closed"), "{}", missing[0].why) + } + other => panic!("expected Blocked, got {other:?}"), + } + + // 6h elapsed of 48h → blocked with remaining time + match eval_now(&p, e, &with_items(vec![])) { + GateOutcome::Blocked { missing, .. } => { + assert!(missing[0].why.contains("timelock"), "{}", missing[0].why) + } + other => panic!("expected Blocked, got {other:?}"), + } + + // window elapsed → pass, citing the anchor + let elapsed = EvidenceSet { + eligible_since: Some(ts("2026-07-29T12:00:00Z")), + items: vec![], + }; + assert_eq!( + eval_now(&p, e, &elapsed), + GateOutcome::Pass { + satisfied: vec![GateSatisfaction::Elapsed { + gate: 0, + eligible_since: ts("2026-07-29T12:00:00Z"), + min_seconds: 172_800, + }] + } + ); + } + + // ── operator override ──────────────────────────────────────────────── + + fn override_evidence(gate: usize, operator: &str, reason: &str) -> GateEvidence { + GateEvidence::OperatorOverride { + gate, + operator: operator.into(), + reason: reason.into(), + evidence_ref: "event:override".into(), + at: ts("2026-08-01T14:00:00Z"), + } + } + + #[test] + fn override_satisfies_its_gate_loudly() { + let p = policy("DEMOED->ACCEPTED", vec![approval("buyer")]); + let evidence = with_items(vec![override_evidence( + 0, + "ludovic", + "buyer confirmed by phone, key ceremony pending", + )]); + match eval_now(&p, demoed_accepted(), &evidence) { + GateOutcome::Pass { satisfied } => assert_eq!( + satisfied, + vec![GateSatisfaction::Overridden { + gate: 0, + operator: "ludovic".into(), + reason: "buyer confirmed by phone, key ceremony pending".into(), + evidence_ref: "event:override".into(), + }] + ), + other => panic!("expected Pass, got {other:?}"), + } + } + + #[test] + fn override_beats_a_denial_but_stays_visible_as_an_override() { + // The escape hatch works even against an explicit denial — but the + // outcome says Overridden, and the denial stays in the evidence set. + let p = policy("DEMOED->ACCEPTED", vec![approval("buyer")]); + let evidence = with_items(vec![ + decision("buyer", false, Some("hold"), "2026-08-01T13:00:00Z"), + override_evidence(0, "ludovic", "buyer retracted the hold out-of-band"), + ]); + assert!(matches!( + eval_now(&p, demoed_accepted(), &evidence), + GateOutcome::Pass { satisfied } if matches!(satisfied[0], GateSatisfaction::Overridden { .. }) + )); + } + + #[test] + fn half_formed_overrides_never_satisfy() { + let p = policy("DEMOED->ACCEPTED", vec![approval("buyer")]); + for bad in [ + override_evidence(0, "ludovic", " "), // reason required + override_evidence(0, "", "reason"), // operator required + override_evidence(1, "ludovic", "wrong gate index"), + GateEvidence::OperatorOverride { + gate: 0, + operator: "ludovic".into(), + reason: "uncited".into(), + evidence_ref: "".into(), // citation required + at: ts("2026-08-01T14:00:00Z"), + }, + ] { + let evidence = with_items(vec![bad.clone()]); + assert!( + matches!( + eval_now(&p, demoed_accepted(), &evidence), + GateOutcome::Blocked { .. } + ), + "should stay blocked with {bad:?}" + ); + } + } + + #[test] + fn override_on_one_gate_leaves_the_others_gated() { + let p = policy( + "ACCEPTED->DELIVERED", + vec![ + GateSpec::MachineCheck { + check: "endpoint-live".into(), + max_age_seconds: None, + }, + approval("buyer"), + ], + ); + let e = edge(ProjectState::Accepted, ProjectState::Delivered); + let evidence = with_items(vec![override_evidence(0, "ludovic", "probe rig is down")]); + match eval_now(&p, e, &evidence) { + GateOutcome::Blocked { missing, satisfied } => { + assert_eq!(missing.len(), 1); + assert_eq!(missing[0].gate, 1); + assert!(matches!( + satisfied[0], + GateSatisfaction::Overridden { gate: 0, .. } + )); + } + other => panic!("expected Blocked, got {other:?}"), + } + } + + // ── escalation ─────────────────────────────────────────────────────── + + #[test] + fn blocked_past_the_escalation_window_escalates_never_auto_passes() { + let p = policy( + "DEMOED->ACCEPTED", + vec![GateSpec::HumanApproval { + principal: "buyer".into(), + escalate_after_seconds: Some(3_600), + }], + ); + // eligible since 12:00, now 18:00 → 6h blocked, window 1h + match eval_now(&p, demoed_accepted(), &with_items(vec![])) { + GateOutcome::Blocked { missing, .. } => assert!(missing[0].escalate), + other => panic!("expected Blocked, got {other:?}"), + } + + // inside the window → no escalation yet + let p_wide = policy( + "DEMOED->ACCEPTED", + vec![GateSpec::HumanApproval { + principal: "buyer".into(), + escalate_after_seconds: Some(86_400), + }], + ); + match eval_now(&p_wide, demoed_accepted(), &with_items(vec![])) { + GateOutcome::Blocked { missing, .. } => assert!(!missing[0].escalate), + other => panic!("expected Blocked, got {other:?}"), + } + + // no eligibility anchor → cannot measure → no escalation flag + let anchorless = EvidenceSet::default(); + match eval_now(&p, demoed_accepted(), &anchorless) { + GateOutcome::Blocked { missing, .. } => assert!(!missing[0].escalate), + other => panic!("expected Blocked, got {other:?}"), + } + } + + // ── multi-gate edges & wildcards ───────────────────────────────────── + + #[test] + fn blocked_lists_exactly_the_missing_gates() { + let p = policy( + "ACCEPTED->DELIVERED", + vec![ + GateSpec::MachineCheck { + check: "endpoint-live".into(), + max_age_seconds: None, + }, + approval("buyer"), + GateSpec::Timelock { min_seconds: 60 }, + ], + ); + let e = edge(ProjectState::Accepted, ProjectState::Delivered); + let evidence = with_items(vec![check_evidence( + "endpoint-live", + true, + "2026-08-01T17:00:00Z", + )]); + match eval_now(&p, e, &evidence) { + GateOutcome::Blocked { missing, satisfied } => { + assert_eq!(missing.iter().map(|m| m.gate).collect::>(), [1]); + // check passed, timelock elapsed (6h ≥ 60s) + assert_eq!(satisfied.len(), 2); + } + other => panic!("expected Blocked, got {other:?}"), + } + } + + #[test] + fn exact_and_wildcard_entries_both_apply_in_order() { + let p = GatePolicy { + edges: BTreeMap::from([ + ( + "WORKROOM_ACTIVE->CLOSED_BY_BUYER".to_string(), + vec![approval("buyer")], + ), + ( + "any->CLOSED_BY_BUYER".to_string(), + vec![GateSpec::Timelock { + min_seconds: 172_800, + }], + ), + ]), + }; + let e = edge(ProjectState::WorkroomActive, ProjectState::ClosedByBuyer); + match eval_now(&p, e, &with_items(vec![])) { + GateOutcome::Blocked { missing, .. } => { + // gate 0 = exact entry's approval, gate 1 = wildcard timelock + assert_eq!(missing.iter().map(|m| m.gate).collect::>(), [0, 1]); + assert!(missing[0].why.contains("buyer")); + assert!(missing[1].why.contains("timelock")); + } + other => panic!("expected Blocked, got {other:?}"), + } + } + + // ── default policy through the engine ──────────────────────────────── + + #[test] + fn default_policy_gates_match_the_plan() { + let p = GatePolicy::studio_default(); + assert_eq!( + gates_for(&p, edge(ProjectState::Quoted, ProjectState::Funded)), + vec![&GateSpec::PaymentEvidence] + ); + assert_eq!( + gates_for(&p, edge(ProjectState::Accepted, ProjectState::Delivered)).len(), + 3 + ); + } + + // ── commitment hash ────────────────────────────────────────────────── + + #[test] + fn commitment_hash_is_deterministic_and_insertion_order_independent() { + let a = GatePolicy::studio_default(); + // Rebuild the same policy inserting edges in reverse order. + let mut reversed = GatePolicy { + edges: BTreeMap::new(), + }; + for (k, v) in a.edges.iter().rev() { + reversed.edges.insert(k.clone(), v.clone()); + } + assert_eq!(commitment_hash(&a), commitment_hash(&reversed)); + assert_eq!(commitment_hash(&a).len(), 64); + } + + #[test] + fn commitment_hash_changes_when_the_policy_changes() { + let a = GatePolicy::studio_default(); + let mut weakened = a.clone(); + weakened.edges.remove("ACCEPTED->DELIVERED"); + assert_ne!(commitment_hash(&a), commitment_hash(&weakened)); + + let mut tightened = a.clone(); + tightened + .edges + .get_mut("QUOTED->FUNDED") + .unwrap() + .push(GateSpec::Timelock { min_seconds: 60 }); + assert_ne!(commitment_hash(&a), commitment_hash(&tightened)); + } +} diff --git a/crates/studio-core/src/lib.rs b/crates/studio-core/src/lib.rs index f04ea1d..0ea1372 100644 --- a/crates/studio-core/src/lib.rs +++ b/crates/studio-core/src/lib.rs @@ -1,12 +1,22 @@ //! Business logic over the wire types. **No I/O.** //! -//! Everything in this crate is a pure function over data: the state machine -//! (PLAN.md §2), the gate engine (PLAN.md §2.1), and record assembly. The -//! types themselves live in `studio-types` (re-exported here); orchestration, -//! storage, and transport live in the sibling crates. This crate must stay -//! testable without a relay, a chain, or a database — and callable from any -//! surface (HTTP, CLI, MCP) without duplicating logic. +//! Everything in this crate is a pure function over data: the gate engine +//! (PLAN.md §2.1), record assembly (RFQ capture, quote issuance), and — with +//! M3 — the full transition machine. The types themselves live in +//! `studio-types` (re-exported here); orchestration, storage, and transport +//! live in the sibling crates. This crate must stay testable without a +//! relay, a chain, or a database — and callable from any surface (HTTP, CLI, +//! MCP) without duplicating logic. +pub mod gate; +pub mod quote; pub mod rfq; -pub use studio_types::{Amount, FieldError, NewRfq, Rfq}; +pub use gate::{ + commitment_hash, eval, gates_for, EvidenceSet, GateBlock, GateEvidence, GateOutcome, + GateSatisfaction, +}; +pub use studio_types::{ + Amount, ChannelParams, Edge, EdgePattern, FieldError, GatePolicy, GateSpec, MilestoneSpec, + NewQuote, NewRfq, PayoutDestination, ProjectState, Quote, QuoteStatus, Rfq, Split, +}; diff --git a/crates/studio-core/src/quote.rs b/crates/studio-core/src/quote.rs new file mode 100644 index 0000000..f176f1f --- /dev/null +++ b/crates/studio-core/src/quote.rs @@ -0,0 +1,148 @@ +//! Quote issuance — validate a submission and assemble the issued quote. +//! +//! Pure, like `rfq::capture`: the caller supplies identity and time, and +//! `now` anchors both the expiry validation and `created_at`, so every +//! surface (HTTP handler, CLI, MCP tool) issues bit-identical quotes. + +use chrono::{DateTime, Utc}; +use studio_types::{FieldError, NewQuote, Quote, QuoteStatus}; + +use crate::gate::commitment_hash; + +/// Validate `new` against `now` and assemble the issued quote, including the +/// gate-policy commitment hash (PLAN.md §2.1(4)). The single path from +/// submission to `Quote` — handlers only supply `rfq_id`, `id`, and `now`. +pub fn issue( + new: NewQuote, + rfq_id: String, + id: String, + now: DateTime, +) -> Result> { + new.validate(now)?; + Ok(Quote { + id, + rfq_id, + policy_hash: commitment_hash(&new.gate_policy), + price: new.price, + milestones: new.milestones, + timeline: new.timeline, + payout_destination: new.payout_destination, + channel: new.channel, + gate_policy: new.gate_policy, + expires_at: new.expires_at, + status: QuoteStatus::Quoted, + created_at: now, + lapsed_at: None, + accepted_at: None, + }) +} + +/// Why an acceptance was refused. Fail-closed like the gate engine: anything +/// that is not a live QUOTED quote refuses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AcceptError { + AlreadyAccepted, + Lapsed, +} + +/// Decide acceptance for `quote` as of `now` — pure; the store's atomic +/// UPDATE guard enforces the same rule against races. ACCEPTED stands in for +/// FUNDED while payments are stubbed (PLAN.md §6 override path). +pub fn accept(quote: &Quote, now: DateTime) -> Result, AcceptError> { + match quote.clone().at(now).status { + QuoteStatus::Accepted => Err(AcceptError::AlreadyAccepted), + QuoteStatus::Lapsed => Err(AcceptError::Lapsed), + QuoteStatus::Quoted => Ok(now), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use studio_types::{ + Amount, ChannelParams, GatePolicy, MilestoneSpec, PayoutDestination, Split, + }; + + fn ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + + fn valid() -> NewQuote { + NewQuote { + price: Amount { + amount: 100, + mint: USDC.into(), + }, + milestones: vec![MilestoneSpec { + title: "all of it".into(), + description: "one milestone".into(), + amount: 100, + }], + timeline: "3 days".into(), + payout_destination: PayoutDestination::Splits { + splits: vec![Split { + recipient: "CrewAgentA111111111111111111111111111111111".into(), + bps: 10_000, + }], + }, + channel: ChannelParams { + grace_seconds: 172_800, + idle_timeout_seconds: 3_600, + }, + gate_policy: GatePolicy::studio_default(), + expires_at: ts("2026-08-08T15:00:00Z"), + } + } + + #[test] + fn issue_assigns_identity_time_status_and_policy_hash() { + let now = ts("2026-08-01T15:00:00Z"); + let quote = issue(valid(), "rfq-1".into(), "q-1".into(), now).unwrap(); + assert_eq!(quote.id, "q-1"); + assert_eq!(quote.rfq_id, "rfq-1"); + assert_eq!(quote.status, QuoteStatus::Quoted); + assert_eq!(quote.created_at, now); + assert_eq!(quote.lapsed_at, None); + assert_eq!( + quote.policy_hash, + commitment_hash(&GatePolicy::studio_default()) + ); + } + + #[test] + fn issue_refuses_invalid_submissions_anchored_at_now() { + // Valid shape, but expired relative to `now` — validation and + // assembly share the same clock by construction. + let errors = issue( + valid(), + "rfq-1".into(), + "q-1".into(), + ts("2026-08-09T00:00:00Z"), + ) + .unwrap_err(); + assert_eq!(errors[0].field, "expires_at"); + } + + #[test] + fn accept_only_live_quoted_and_fail_closed_on_expiry() { + let issued_at = ts("2026-08-01T15:00:00Z"); + let quote = issue(valid(), "rfq-1".into(), "q-1".into(), issued_at).unwrap(); + + // live: accepts at `now` + let now = ts("2026-08-02T00:00:00Z"); + assert_eq!(accept("e, now), Ok(now)); + + // past expiry: refused even though the row still says QUOTED — + // same fail-closed derivation as reads + let late = ts("2026-09-01T00:00:00Z"); + assert_eq!(accept("e, late), Err(AcceptError::Lapsed)); + + // already accepted: sticky, refused forever after + let mut accepted = quote.clone(); + accepted.status = QuoteStatus::Accepted; + accepted.accepted_at = Some(now); + assert_eq!(accept(&accepted, late), Err(AcceptError::AlreadyAccepted)); + } +} diff --git a/crates/studio-core/src/rfq.rs b/crates/studio-core/src/rfq.rs index a4f3c4c..dc54057 100644 --- a/crates/studio-core/src/rfq.rs +++ b/crates/studio-core/src/rfq.rs @@ -19,6 +19,7 @@ pub fn capture(new: NewRfq, id: String, now: DateTime) -> Result/migrations/`. pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); @@ -26,6 +28,10 @@ pub enum StoreError { /// user input. #[error("corrupt projection row: {0}")] Corrupt(String), + /// A uniqueness rule refused the write (e.g. the one-quote-per-RFQ + /// singleton) — the caller's 409, not a server fault. + #[error("conflict: {0}")] + Conflict(String), } pub type Result = std::result::Result; diff --git a/crates/studio-store/src/quotes.rs b/crates/studio-store/src/quotes.rs new file mode 100644 index 0000000..195f3d8 --- /dev/null +++ b/crates/studio-store/src/quotes.rs @@ -0,0 +1,326 @@ +//! Quote rows. Pure persistence — validation happened upstream +//! (`studio-core::quote::issue`) before anything reaches here. One quote per +//! RFQ in v0 (see migration 0004); the singleton is enforced by the primary +//! key, surfaced as `StoreError::Conflict`. + +use chrono::{DateTime, Utc}; +use sqlx::{Row, SqlitePool}; +use studio_types::{Amount, Quote, QuoteStatus}; + +use crate::{Result, StoreError}; + +pub async fn insert(pool: &SqlitePool, quote: &Quote) -> Result<()> { + let result = sqlx::query( + "INSERT INTO quotes (rfq_id, id, price_amount, price_mint, milestones, + timeline, payout_destination, grace_seconds, + idle_timeout_seconds, gate_policy, policy_hash, + expires_at, status, created_at, lapsed_at, + accepted_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)", + ) + .bind("e.rfq_id) + .bind("e.id) + .bind(quote.price.amount as i64) + .bind("e.price.mint) + .bind(serde_json::to_string("e.milestones).expect("milestones serialize")) + .bind("e.timeline) + .bind(serde_json::to_string("e.payout_destination).expect("payout serializes")) + .bind(quote.channel.grace_seconds as i64) + .bind(quote.channel.idle_timeout_seconds as i64) + .bind(serde_json::to_string("e.gate_policy).expect("gate policy serializes")) + .bind("e.policy_hash) + .bind(quote.expires_at.to_rfc3339()) + .bind(status_str(quote.status)) + .bind(quote.created_at.to_rfc3339()) + .bind(quote.lapsed_at.map(|t| t.to_rfc3339())) + .bind(quote.accepted_at.map(|t| t.to_rfc3339())) + .execute(pool) + .await; + + match result { + Ok(_) => Ok(()), + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(StoreError::Conflict( + format!("a quote already exists for rfq `{}`", quote.rfq_id), + )), + Err(e) => Err(e.into()), + } +} + +pub async fn get_by_rfq(pool: &SqlitePool, rfq_id: &str) -> Result> { + let row = sqlx::query("SELECT * FROM quotes WHERE rfq_id = ?1") + .bind(rfq_id) + .fetch_optional(pool) + .await?; + row.map(from_row).transpose() +} + +/// Stamp LAPSED on every QUOTED row whose expiry has passed. Returns the +/// number of quotes lapsed. Reads are already fail-closed against sweep lag +/// (`Quote::at` derives LAPSED past expiry); the sweep makes the projection +/// row itself catch up. +pub async fn sweep_lapsed(pool: &SqlitePool, now: DateTime) -> Result { + let now = now.to_rfc3339(); + let result = sqlx::query( + "UPDATE quotes SET status = 'LAPSED', lapsed_at = ?1 + WHERE status = 'QUOTED' AND expires_at <= ?1", + ) + .bind(&now) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + +/// Stamp ACCEPTED, atomically guarded: only a live QUOTED row can accept. +/// Returns the number of rows updated — 0 means the quote was already +/// accepted, already lapsed, or past expiry (the caller re-derives which for +/// its error message); the guard makes double-accept a lost race, not a +/// second acceptance. +pub async fn mark_accepted(pool: &SqlitePool, rfq_id: &str, now: DateTime) -> Result { + let now = now.to_rfc3339(); + let result = sqlx::query( + "UPDATE quotes SET status = 'ACCEPTED', accepted_at = ?1 + WHERE rfq_id = ?2 AND status = 'QUOTED' AND expires_at > ?1", + ) + .bind(&now) + .bind(rfq_id) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} + +fn status_str(status: QuoteStatus) -> &'static str { + match status { + QuoteStatus::Quoted => "QUOTED", + QuoteStatus::Lapsed => "LAPSED", + QuoteStatus::Accepted => "ACCEPTED", + } +} + +fn corrupt(what: &str, detail: String) -> StoreError { + StoreError::Corrupt(format!("quotes.{what}: {detail}")) +} + +fn json(what: &'static str, raw: String) -> Result { + serde_json::from_str(&raw).map_err(|e| corrupt(what, e.to_string())) +} + +fn ts(what: &'static str, raw: String) -> Result> { + DateTime::parse_from_rfc3339(&raw) + .map(|t| t.with_timezone(&Utc)) + .map_err(|e| corrupt(what, e.to_string())) +} + +fn from_row(row: sqlx::sqlite::SqliteRow) -> Result { + let status: String = row.get("status"); + let lapsed_at: Option = row.get("lapsed_at"); + let accepted_at: Option = row.get("accepted_at"); + Ok(Quote { + id: row.get("id"), + rfq_id: row.get("rfq_id"), + price: Amount { + amount: row.get::("price_amount") as u64, + mint: row.get("price_mint"), + }, + milestones: json("milestones", row.get("milestones"))?, + timeline: row.get("timeline"), + payout_destination: json("payout_destination", row.get("payout_destination"))?, + channel: studio_types::ChannelParams { + grace_seconds: row.get::("grace_seconds") as u64, + idle_timeout_seconds: row.get::("idle_timeout_seconds") as u64, + }, + gate_policy: json("gate_policy", row.get("gate_policy"))?, + policy_hash: row.get("policy_hash"), + expires_at: ts("expires_at", row.get("expires_at"))?, + status: match status.as_str() { + "QUOTED" => QuoteStatus::Quoted, + "LAPSED" => QuoteStatus::Lapsed, + "ACCEPTED" => QuoteStatus::Accepted, + other => return Err(corrupt("status", format!("unknown status `{other}`"))), + }, + created_at: ts("created_at", row.get("created_at"))?, + lapsed_at: lapsed_at.map(|raw| ts("lapsed_at", raw)).transpose()?, + accepted_at: accepted_at.map(|raw| ts("accepted_at", raw)).transpose()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use studio_types::{ChannelParams, GatePolicy, MilestoneSpec, PayoutDestination, Rfq, Split}; + + fn ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + async fn seeded_pool() -> SqlitePool { + let pool = crate::open("sqlite::memory:").await.unwrap(); + // quotes reference rfqs; seed the parent rows. + for id in ["rfq-1", "rfq-2"] { + crate::rfqs::insert( + &pool, + &Rfq { + id: id.into(), + query: "solana priority fee forecast api".into(), + product: None, + monetization: None, + competition: vec![], + budget_ceiling: None, + buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy" + .into(), + buyer_signature: None, + created_at: ts("2026-08-01T14:00:00Z"), + }, + ) + .await + .unwrap(); + } + pool + } + + fn sample(id: &str, rfq_id: &str, expires_at: &str) -> Quote { + let gate_policy = GatePolicy::studio_default(); + Quote { + id: id.into(), + rfq_id: rfq_id.into(), + price: Amount { + amount: 250_000_000, + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + }, + milestones: vec![ + MilestoneSpec { + title: "Forecast model".into(), + description: "p50/p90 per program id".into(), + amount: 150_000_000, + }, + MilestoneSpec { + title: "Gated endpoint".into(), + description: "pay.sh-gated REST endpoint".into(), + amount: 100_000_000, + }, + ], + timeline: "2 weeks, weekly demos".into(), + payout_destination: PayoutDestination::Splits { + splits: vec![Split { + recipient: "CrewAgentA111111111111111111111111111111111".into(), + bps: 10_000, + }], + }, + channel: ChannelParams { + grace_seconds: 172_800, + idle_timeout_seconds: 604_800, + }, + policy_hash: "policy-hash-set-at-issue-time".into(), + gate_policy, + expires_at: ts(expires_at), + status: QuoteStatus::Quoted, + created_at: ts("2026-08-01T15:00:00Z"), + lapsed_at: None, + accepted_at: None, + } + } + + #[tokio::test] + async fn round_trips_through_sqlite() { + let pool = seeded_pool().await; + let quote = sample("q-1", "rfq-1", "2026-08-08T15:00:00Z"); + insert(&pool, "e).await.unwrap(); + + let back = get_by_rfq(&pool, "rfq-1") + .await + .unwrap() + .expect("row exists"); + assert_eq!( + serde_json::to_value(&back).unwrap(), + serde_json::to_value("e).unwrap() + ); + assert!(get_by_rfq(&pool, "rfq-2").await.unwrap().is_none()); + } + + #[tokio::test] + async fn second_quote_for_the_same_rfq_conflicts() { + let pool = seeded_pool().await; + insert(&pool, &sample("q-1", "rfq-1", "2026-08-08T15:00:00Z")) + .await + .unwrap(); + let err = insert(&pool, &sample("q-2", "rfq-1", "2026-08-09T15:00:00Z")) + .await + .unwrap_err(); + assert!(matches!(err, StoreError::Conflict(_)), "got {err:?}"); + } + + #[tokio::test] + async fn sweep_lapses_exactly_the_expired_quoted_rows() { + let pool = seeded_pool().await; + insert(&pool, &sample("q-1", "rfq-1", "2026-08-02T00:00:00Z")) + .await + .unwrap(); + insert(&pool, &sample("q-2", "rfq-2", "2026-08-09T00:00:00Z")) + .await + .unwrap(); + + // before either expiry: nothing to do + assert_eq!( + sweep_lapsed(&pool, ts("2026-08-01T23:59:59Z")) + .await + .unwrap(), + 0 + ); + + // past q-1's expiry only + let now = ts("2026-08-02T00:00:30Z"); + assert_eq!(sweep_lapsed(&pool, now).await.unwrap(), 1); + + let lapsed = get_by_rfq(&pool, "rfq-1").await.unwrap().unwrap(); + assert_eq!(lapsed.status, QuoteStatus::Lapsed); + assert_eq!(lapsed.lapsed_at, Some(now)); + + let live = get_by_rfq(&pool, "rfq-2").await.unwrap().unwrap(); + assert_eq!(live.status, QuoteStatus::Quoted); + assert_eq!(live.lapsed_at, None); + + // idempotent: an already-lapsed row is not re-stamped + assert_eq!( + sweep_lapsed(&pool, ts("2026-08-02T01:00:00Z")) + .await + .unwrap(), + 0 + ); + let unchanged = get_by_rfq(&pool, "rfq-1").await.unwrap().unwrap(); + assert_eq!(unchanged.lapsed_at, Some(now)); + } + + #[tokio::test] + async fn accept_stamps_once_and_only_live_quoted_rows() { + let pool = seeded_pool().await; + insert(&pool, &sample("q-1", "rfq-1", "2026-08-08T15:00:00Z")) + .await + .unwrap(); + insert(&pool, &sample("q-2", "rfq-2", "2026-08-02T00:00:00Z")) + .await + .unwrap(); + + let now = ts("2026-08-01T16:00:00Z"); + assert_eq!(mark_accepted(&pool, "rfq-1", now).await.unwrap(), 1); + let accepted = get_by_rfq(&pool, "rfq-1").await.unwrap().unwrap(); + assert_eq!(accepted.status, QuoteStatus::Accepted); + assert_eq!(accepted.accepted_at, Some(now)); + + // double-accept loses the guard + assert_eq!(mark_accepted(&pool, "rfq-1", now).await.unwrap(), 0); + + // past expiry: no acceptance, even before the sweep stamps LAPSED + let late = ts("2026-08-02T00:00:30Z"); + assert_eq!(mark_accepted(&pool, "rfq-2", late).await.unwrap(), 0); + + // the sweep never touches an accepted row + assert_eq!( + sweep_lapsed(&pool, ts("2026-09-01T00:00:00Z")) + .await + .unwrap(), + 1 + ); + let still = get_by_rfq(&pool, "rfq-1").await.unwrap().unwrap(); + assert_eq!(still.status, QuoteStatus::Accepted); + assert_eq!(still.lapsed_at, None); + } +} diff --git a/crates/studio-store/src/rfqs.rs b/crates/studio-store/src/rfqs.rs index 8adf96b..6f5def2 100644 --- a/crates/studio-store/src/rfqs.rs +++ b/crates/studio-store/src/rfqs.rs @@ -10,8 +10,9 @@ use crate::Result; pub async fn insert(pool: &SqlitePool, rfq: &Rfq) -> Result<()> { sqlx::query( "INSERT INTO rfqs (id, query, product, monetization, competition, - budget_amount, budget_mint, buyer_npub, created_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + budget_amount, budget_mint, buyer_npub, + buyer_signature, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", ) .bind(&rfq.id) .bind(&rfq.query) @@ -21,6 +22,7 @@ pub async fn insert(pool: &SqlitePool, rfq: &Rfq) -> Result<()> { .bind(rfq.budget_ceiling.as_ref().map(|b| b.amount as i64)) .bind(rfq.budget_ceiling.as_ref().map(|b| b.mint.clone())) .bind(&rfq.buyer_npub) + .bind(&rfq.buyer_signature) .bind(rfq.created_at.to_rfc3339()) .execute(pool) .await?; @@ -79,6 +81,7 @@ fn from_row(row: sqlx::sqlite::SqliteRow) -> Result { } }, buyer_npub: row.get("buyer_npub"), + buyer_signature: row.get("buyer_signature"), created_at: DateTime::parse_from_rfc3339(&created_at) .map_err(|e| crate::StoreError::Corrupt(format!("rfqs.created_at: {e}")))? .with_timezone(&Utc), @@ -101,6 +104,7 @@ mod tests { mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), }), buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + buyer_signature: Some("recorded-not-verified".into()), created_at: DateTime::parse_from_rfc3339(created_at) .unwrap() .with_timezone(&Utc), diff --git a/crates/studio-store/src/workrooms.rs b/crates/studio-store/src/workrooms.rs new file mode 100644 index 0000000..45488ea --- /dev/null +++ b/crates/studio-store/src/workrooms.rs @@ -0,0 +1,109 @@ +//! Workroom rows — the projection of the FUNDED → WORKROOM_ACTIVE +//! transition. `create_event_id` is the Buzz channel-create event id: the +//! transition's evidence (ARCHITECTURE.md §evidence table), which is why the +//! row is written only after the relay accepted the event. + +use chrono::{DateTime, Utc}; +use sqlx::{Row, SqlitePool}; + +use crate::{Result, StoreError}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Workroom { + pub rfq_id: String, + pub channel_id: String, + pub create_event_id: String, + pub created_at: DateTime, +} + +pub async fn record(pool: &SqlitePool, workroom: &Workroom) -> Result<()> { + let result = sqlx::query( + "INSERT INTO workrooms (rfq_id, channel_id, create_event_id, created_at) + VALUES (?1, ?2, ?3, ?4)", + ) + .bind(&workroom.rfq_id) + .bind(&workroom.channel_id) + .bind(&workroom.create_event_id) + .bind(workroom.created_at.to_rfc3339()) + .execute(pool) + .await; + + match result { + Ok(_) => Ok(()), + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => Err(StoreError::Conflict( + format!("a workroom already exists for rfq `{}`", workroom.rfq_id), + )), + Err(e) => Err(e.into()), + } +} + +pub async fn get_by_rfq(pool: &SqlitePool, rfq_id: &str) -> Result> { + let row = sqlx::query("SELECT * FROM workrooms WHERE rfq_id = ?1") + .bind(rfq_id) + .fetch_optional(pool) + .await?; + row.map(|row| { + let raw: String = row.get("created_at"); + let created_at = DateTime::parse_from_rfc3339(&raw) + .map(|t| t.with_timezone(&Utc)) + .map_err(|e| StoreError::Corrupt(format!("workrooms.created_at: {e}")))?; + Ok(Workroom { + rfq_id: row.get("rfq_id"), + channel_id: row.get("channel_id"), + create_event_id: row.get("create_event_id"), + created_at, + }) + }) + .transpose() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + async fn seeded_pool() -> SqlitePool { + let pool = crate::open("sqlite::memory:").await.unwrap(); + crate::rfqs::insert( + &pool, + &studio_types::Rfq { + id: "rfq-1".into(), + query: "solana priority fee forecast api".into(), + product: None, + monetization: None, + competition: vec![], + budget_ceiling: None, + buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy" + .into(), + buyer_signature: None, + created_at: ts("2026-08-01T14:00:00Z"), + }, + ) + .await + .unwrap(); + pool + } + + #[tokio::test] + async fn round_trips_and_is_a_singleton_per_rfq() { + let pool = seeded_pool().await; + let workroom = Workroom { + rfq_id: "rfq-1".into(), + channel_id: "0b5b7a86-6a45-4f7f-9207-3e069b7f0b0e".into(), + create_event_id: "57fc8b6149f1c5d3ba5f3e801fc2219f92159311062c5876a4403d24ff98c431" + .into(), + created_at: ts("2026-08-01T17:00:00Z"), + }; + record(&pool, &workroom).await.unwrap(); + + let back = get_by_rfq(&pool, "rfq-1").await.unwrap().unwrap(); + assert_eq!(back, workroom); + assert!(get_by_rfq(&pool, "rfq-2").await.unwrap().is_none()); + + let err = record(&pool, &workroom).await.unwrap_err(); + assert!(matches!(err, StoreError::Conflict(_)), "got {err:?}"); + } +} diff --git a/crates/studio-types/src/gate.rs b/crates/studio-types/src/gate.rs new file mode 100644 index 0000000..f0cad22 --- /dev/null +++ b/crates/studio-types/src/gate.rs @@ -0,0 +1,337 @@ +//! Gate policy — the wire types of a project's procedural law (PLAN.md +//! §2.1). Wire shape: `schemas/gate-policy.json` — generated from these +//! types. The engine that *evaluates* a policy against evidence lives in +//! `studio-core::gate`; this module owns the shape, its field validation, +//! and the studio default. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::rfq::FieldError; +use crate::state::{EdgePattern, ProjectState}; + +/// One gate on a state-machine edge (internally tagged on `type`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum GateSpec { + /// A named principal must approve. Primary mechanism: native Buzz + /// workflow approval tokens; fallback: signed channel message with fixed + /// grammar. The principal is an opaque label (npub or a role like + /// `buyer`) — evidence must carry the same label. + HumanApproval { + /// Opaque principal label — an npub or a role like `buyer`. + /// Evidence must carry the same label. + #[schemars(length(min = 1))] + principal: String, + /// Blocked longer than this (from edge eligibility) → escalate to + /// the gate's principal, then the studio owner. Never auto-passes. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + escalate_after_seconds: Option, + }, + /// k-of-n signed sign-offs from named crew agents. + AgentSignoff { + #[schemars(length(min = 1))] + agents: Vec, + #[schemars(range(min = 1))] + k: u32, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + escalate_after_seconds: Option, + }, + /// A named verifiable predicate (CI green, endpoint answers its 402 + /// challenge, schema validation passes). Reruns are natural, so the + /// latest result wins; a stale pass is expired evidence and blocks. + MachineCheck { + #[schemars(length(min = 1))] + check: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1))] + max_age_seconds: Option, + }, + /// `PayPort` status: operator record in stub mode, tx signature live. + PaymentEvidence, + /// Minimum elapsed review window since the edge became eligible. + Timelock { + #[schemars(range(min = 1))] + min_seconds: u64, + }, +} + +impl GateSpec { + /// The escalation window, for gate kinds that have one. + pub fn escalate_after_seconds(&self) -> Option { + match self { + GateSpec::HumanApproval { + escalate_after_seconds, + .. + } + | GateSpec::AgentSignoff { + escalate_after_seconds, + .. + } => *escalate_after_seconds, + _ => None, + } + } + + fn validate(&self, field: &str, errors: &mut Vec) { + let mut push = |suffix: &str, message: &str| { + errors.push(FieldError { + field: format!("{field}.{suffix}"), + message: message.into(), + }); + }; + match self { + GateSpec::HumanApproval { principal, .. } => { + if principal.trim().is_empty() { + push("principal", "must be a non-empty principal"); + } + } + GateSpec::AgentSignoff { agents, k, .. } => { + if agents.is_empty() || agents.iter().any(|a| a.trim().is_empty()) { + push( + "agents", + "must be a non-empty list of non-empty agent names", + ); + } + let distinct: std::collections::BTreeSet<_> = agents.iter().collect(); + if distinct.len() != agents.len() { + push("agents", "must not contain duplicates"); + } + if *k == 0 { + push("k", "must be at least 1"); + } else if *k as usize > agents.len() { + push("k", "cannot exceed the number of named agents"); + } + } + GateSpec::MachineCheck { + check, + max_age_seconds, + } => { + if check.trim().is_empty() { + push("check", "must be a non-empty check name"); + } + if max_age_seconds == &Some(0) { + push("max_age_seconds", "must be at least 1 when present"); + } + } + GateSpec::PaymentEvidence => {} + GateSpec::Timelock { min_seconds } => { + if *min_seconds == 0 { + push("min_seconds", "must be at least 1"); + } + } + } + } +} + +/// A project's procedural law: edge key (`FROM->TO` or `any->TO`) → ordered +/// gates. Negotiated in the Quote, hash-committed at FUNDED +/// (`studio-core::gate::commitment_hash`) — weakening it mid-engagement is +/// impossible by construction. +/// +/// `BTreeMap` keeps serialization order-independent of authoring order, so +/// the commitment hash is deterministic. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars( + title = "Gate policy", + description = "A project's procedural law (PLAN.md §2.1): state-machine edge → ordered hard gates. Negotiated in the Quote, hash-committed at FUNDED, tamper-evident thereafter. Semantics enforced by the engine, not expressible here: fail-closed evaluation, denial-with-note as recorded history, loud audited overrides. The implementation is additionally stricter than this schema: self-loop edge keys are rejected, agent lists must be duplicate-free, and k must not exceed the number of named agents." +)] +pub struct GatePolicy { + /// Edge key `FROM->TO` (or wildcard `any->TO`) → ordered gates. A + /// concrete edge is guarded by its exact entry followed by any matching + /// wildcard entry — both apply. + #[schemars(schema_with = "edges_schema")] + pub edges: BTreeMap>, +} + +/// JSON Schema for `edges`: `patternProperties` keyed by the state-name +/// pattern (built from `ProjectState::ALL`, so it cannot drift from the +/// enum), everything else rejected. A plain map schema would silently accept +/// unknown edge keys the code refuses. +fn edges_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + let states = ProjectState::ALL.map(|s| s.name()).join("|"); + let gates = generator.subschema_for::>(); + let mut schema = serde_json::json!({ + "type": "object", + "patternProperties": {}, + "additionalProperties": false, + }); + schema["patternProperties"][format!("^(any|{states})->({states})$")] = + serde_json::to_value(gates).expect("subschema serializes"); + schemars::Schema::try_from(schema).expect("edges schema is a valid JSON Schema") +} + +impl GatePolicy { + /// The studio default (PLAN.md §2.1); buyers may strengthen per-project + /// in the Quote. Buyer approvals escalate after 48h — the same window as + /// the session grace period — so a stalled gate is never silent. + pub fn studio_default() -> GatePolicy { + const ESCALATE_48H: Option = Some(172_800); + GatePolicy { + edges: BTreeMap::from([ + ("QUOTED->FUNDED".into(), vec![GateSpec::PaymentEvidence]), + ( + "DEMOED->ACCEPTED".into(), + vec![GateSpec::HumanApproval { + principal: "buyer".into(), + escalate_after_seconds: ESCALATE_48H, + }], + ), + ( + "ACCEPTED->DELIVERED".into(), + vec![ + GateSpec::MachineCheck { + check: "endpoint-live".into(), + max_age_seconds: None, + }, + GateSpec::HumanApproval { + principal: "buyer".into(), + escalate_after_seconds: ESCALATE_48H, + }, + GateSpec::Timelock { + min_seconds: 86_400, + }, + ], + ), + ( + "any->CLOSED_BY_BUYER".into(), + vec![GateSpec::Timelock { + min_seconds: 172_800, + }], + ), + ]), + } + } + + /// Structural validation. `field` prefixes error paths (e.g. + /// `gate_policy` when embedded in a quote). + pub fn validate(&self, field: &str) -> Result<(), Vec> { + let mut errors = Vec::new(); + for (key, gates) in &self.edges { + if let Err(message) = EdgePattern::parse(key) { + errors.push(FieldError { + field: format!("{field}.edges[{key}]"), + message, + }); + continue; + } + for (i, gate) in gates.iter().enumerate() { + gate.validate(&format!("{field}.edges[{key}][{i}]"), &mut errors); + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_policy_is_valid() { + let p = GatePolicy::studio_default(); + assert!(p.validate("gate_policy").is_ok()); + assert_eq!(p.edges.len(), 4); + } + + #[test] + fn invalid_specs_are_rejected_with_precise_paths() { + fn signoff(agents: &[&str], k: u32) -> GateSpec { + GateSpec::AgentSignoff { + agents: agents.iter().map(|a| a.to_string()).collect(), + k, + escalate_after_seconds: None, + } + } + let cases: Vec<(GateSpec, &str)> = vec![ + ( + GateSpec::HumanApproval { + principal: " ".into(), + escalate_after_seconds: None, + }, + "principal", + ), + (signoff(&[], 1), "agents"), + (signoff(&["a", "a"], 1), "agents"), + (signoff(&["a", "b"], 0), "k"), + (signoff(&["a", "b"], 3), "k"), + ( + GateSpec::MachineCheck { + check: "".into(), + max_age_seconds: None, + }, + "check", + ), + ( + GateSpec::MachineCheck { + check: "ci".into(), + max_age_seconds: Some(0), + }, + "max_age_seconds", + ), + (GateSpec::Timelock { min_seconds: 0 }, "min_seconds"), + ]; + for (spec, field_suffix) in cases { + let p = GatePolicy { + edges: BTreeMap::from([("QUOTED->FUNDED".to_string(), vec![spec.clone()])]), + }; + let errors = p.validate("gate_policy").unwrap_err(); + assert!( + errors.iter().any(|e| e.field.ends_with(field_suffix) + && e.field.starts_with("gate_policy.edges[QUOTED->FUNDED][0]")), + "{spec:?} should fail on {field_suffix}, got {errors:?}" + ); + } + } + + #[test] + fn bad_edge_keys_are_rejected() { + for bad in ["QUOTED", "NOPE->FUNDED", "QUOTED->QUOTED", "any->any"] { + let p = GatePolicy { + edges: BTreeMap::from([(bad.to_string(), vec![GateSpec::PaymentEvidence])]), + }; + assert!(p.validate("gate_policy").is_err(), "should reject `{bad}`"); + } + } + + #[test] + fn policy_json_round_trips_and_rejects_unknown_fields() { + let p = GatePolicy::studio_default(); + let json = serde_json::to_value(&p).unwrap(); + let back: GatePolicy = serde_json::from_value(json.clone()).unwrap(); + assert_eq!(p, back); + + // internally tagged spec shape, as schemas/gate-policy.json documents + assert_eq!( + json["edges"]["QUOTED->FUNDED"][0], + serde_json::json!({ "type": "payment_evidence" }) + ); + + for bad in [ + serde_json::json!({ "edges": {}, "surprise": 1 }), + serde_json::json!({ "edges": { "QUOTED->FUNDED": [ + { "type": "timelock", "min_seconds": 5, "surprise": 1 } + ]}}), + // cross-variant field bleed must be rejected too + serde_json::json!({ "edges": { "QUOTED->FUNDED": [ + { "type": "timelock", "min_seconds": 5, "principal": "buyer" } + ]}}), + serde_json::json!({ "edges": { "QUOTED->FUNDED": [ + { "type": "not_a_gate" } + ]}}), + ] { + assert!( + serde_json::from_value::(bad.clone()).is_err(), + "should reject {bad}" + ); + } + } +} diff --git a/crates/studio-types/src/lib.rs b/crates/studio-types/src/lib.rs index 031fd36..a7ac104 100644 --- a/crates/studio-types/src/lib.rs +++ b/crates/studio-types/src/lib.rs @@ -7,7 +7,15 @@ //! drift from the code. Field validation lives next to the types; state //! machines and orchestration logic stay in `studio-core`. +pub mod gate; +pub mod quote; pub mod rfq; pub mod schemas; +pub mod state; +pub use gate::{GatePolicy, GateSpec}; +pub use quote::{ + ChannelParams, MilestoneSpec, NewQuote, PayoutDestination, Quote, QuoteStatus, Split, +}; pub use rfq::{Amount, FieldError, NewRfq, Rfq}; +pub use state::{Edge, EdgePattern, ProjectState}; diff --git a/crates/studio-types/src/quote.rs b/crates/studio-types/src/quote.rs new file mode 100644 index 0000000..c8b6fda --- /dev/null +++ b/crates/studio-types/src/quote.rs @@ -0,0 +1,515 @@ +//! Quote — the studio's priced answer to an RFQ (PLAN.md M2, DESIGN.md §3). +//! +//! Milestone granularity is the entire dispute system, so the milestone +//! schedule must account for every unit of the price. The gate policy rides +//! in the quote because it is negotiated there: defaults from studio config, +//! buyers may strengthen. `payout_destination` is enum-shaped so the v1 +//! vault slots in without a breaking change (ARCHITECTURE.md §2.3). + +use chrono::{DateTime, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::gate::GatePolicy; +use crate::rfq::{Amount, FieldError}; + +/// A studio-authored quote, before the studio assigns identity and time. +/// Wire shape: `schemas/quote.json` — generated from this type. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +#[schemars( + title = "Quote submission", + description = "The studio's priced answer to an RFQ (PLAN.md M2, DESIGN.md §3). Wire shape of POST /api/v1/rfqs/{id}/quote; the studio assigns id, created_at, status, and policy_hash. The implementation is additionally stricter than this schema: milestone amounts must sum to price.amount (milestone granularity is the dispute system), split bps must sum to exactly 10000, expires_at must be in the future at issue time, and the gate policy's structural rules (see gate-policy.json) are enforced." +)] +pub struct NewQuote { + /// Total engagement price. Must equal the sum of milestone amounts. + pub price: Amount, + #[schemars(length(min = 1))] + pub milestones: Vec, + /// Human-negotiated schedule description (e.g. "3 weeks, weekly demos"). + #[schemars(length(min = 1))] + pub timeline: String, + pub payout_destination: PayoutDestination, + pub channel: ChannelParams, + /// Defaults from studio config; buyers may strengthen per-project. + #[serde(default = "GatePolicy::studio_default")] + pub gate_policy: GatePolicy, + /// Past this instant the quote is LAPSED and cannot be accepted. + pub expires_at: DateTime, +} + +/// One milestone: a demoable, acceptable, priced unit of work. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MilestoneSpec { + #[schemars(length(min = 1))] + pub title: String, + #[schemars(length(min = 1))] + pub description: String, + /// Minor units of the quote's `price.mint`. + #[schemars(range(min = 1))] + pub amount: u64, +} + +/// Where settled funds go. v0: direct channel splits, exactly DESIGN.md +/// §4.3(a) — escrow pays the crew, no studio custody. The `vault` variant +/// (v1, ARCHITECTURE.md §2.3) will slot in beside `splits`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PayoutDestination { + Splits { + #[schemars(length(min = 1))] + splits: Vec, + }, +} + +/// One recipient's share, in basis points. Splits must sum to exactly +/// 10_000 bps — every lamport of a settlement is accounted for. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Split { + /// Solana address. Free-form here, like `Amount::mint`; enforced when + /// the live PayPort builds real session terms (M5). + #[schemars(length(min = 1))] + pub recipient: String, + #[schemars(range(min = 1, max = 10_000))] + pub bps: u32, +} + +/// MPP session channel parameters the quote commits to. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChannelParams { + /// Buyer-exit grace window, seconds. Default 172800 (48h) per PLAN.md M2. + #[serde(default = "default_grace_seconds")] + #[schemars(range(min = 1))] + pub grace_seconds: u64, + /// Idle window after which the studio settles at watermark and closes. + /// No default on purpose: the quote must commit to it explicitly. + #[schemars(range(min = 1))] + pub idle_timeout_seconds: u64, +} + +fn default_grace_seconds() -> u64 { + 172_800 +} + +/// Quote lifecycle (PLAN.md §2): issued → QUOTED; expiry sweep or read-side +/// derivation → LAPSED; buyer acceptance → ACCEPTED (sticky — an accepted +/// quote never lapses; the engagement it started owns the clock from there). +/// ACCEPTED stands in for FUNDED while payments are stubbed (PLAN.md §6 +/// override path, ludovic 2026-08-01). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum QuoteStatus { + Quoted, + Lapsed, + Accepted, +} + +/// An issued quote — what the quote endpoints return. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Quote { + pub id: String, + pub rfq_id: String, + pub price: Amount, + pub milestones: Vec, + pub timeline: String, + pub payout_destination: PayoutDestination, + pub channel: ChannelParams, + pub gate_policy: GatePolicy, + /// `studio-core::gate::commitment_hash(&gate_policy)`, precomputed at + /// issue time. Recorded again (and enforced) at the FUNDED transition — + /// PLAN.md §2.1(4). + pub policy_hash: String, + pub expires_at: DateTime, + pub status: QuoteStatus, + pub created_at: DateTime, + /// Set by the expiry sweep; `expires_at` when derived at read time. + pub lapsed_at: Option>, + /// Buyer acceptance instant. Set exactly once; never on a lapsed quote. + pub accepted_at: Option>, +} + +impl Quote { + /// The status as of `now`, fail-closed against sweep lag: a quote past + /// `expires_at` reads LAPSED even if the sweep has not stamped it yet. + /// ACCEPTED is sticky — acceptance beat expiry, so expiry is moot. + pub fn at(mut self, now: DateTime) -> Quote { + if self.status == QuoteStatus::Quoted && now >= self.expires_at { + self.status = QuoteStatus::Lapsed; + self.lapsed_at = Some(self.expires_at); + } + self + } +} + +impl NewQuote { + /// Full structural validation. `now` anchors the expiry check — pure, + /// like everything in this crate. + pub fn validate(&self, now: DateTime) -> Result<(), Vec> { + let mut errors = Vec::new(); + let mut push = |field: &str, message: &str| { + errors.push(FieldError { + field: field.into(), + message: message.into(), + }); + }; + + if self.price.amount == 0 { + push("price.amount", "must be greater than zero"); + } + if self.price.mint.trim().is_empty() { + push("price.mint", "must be a non-empty mint address"); + } + + if self.milestones.is_empty() { + push("milestones", "must contain at least one milestone"); + } + for (i, m) in self.milestones.iter().enumerate() { + if m.title.trim().is_empty() { + push(&format!("milestones[{i}].title"), "must be non-empty"); + } + if m.description.trim().is_empty() { + push(&format!("milestones[{i}].description"), "must be non-empty"); + } + if m.amount == 0 { + push( + &format!("milestones[{i}].amount"), + "must be greater than zero", + ); + } + } + let milestone_sum: u128 = self.milestones.iter().map(|m| u128::from(m.amount)).sum(); + if !self.milestones.is_empty() && milestone_sum != u128::from(self.price.amount) { + push( + "milestones", + &format!( + "amounts must sum to price.amount — the milestone schedule is the \ + dispute system, every unit must be accounted for \ + (sum {milestone_sum}, price {})", + self.price.amount + ), + ); + } + + if self.timeline.trim().is_empty() { + push("timeline", "must be non-empty"); + } + + match &self.payout_destination { + PayoutDestination::Splits { splits } => { + if splits.is_empty() { + push( + "payout_destination.splits", + "must contain at least one recipient", + ); + } + for (i, s) in splits.iter().enumerate() { + if s.recipient.trim().is_empty() { + push( + &format!("payout_destination.splits[{i}].recipient"), + "must be a non-empty address", + ); + } + if s.bps == 0 { + push( + &format!("payout_destination.splits[{i}].bps"), + "must be at least 1", + ); + } + } + let bps_sum: u64 = splits.iter().map(|s| u64::from(s.bps)).sum(); + if !splits.is_empty() && bps_sum != 10_000 { + push( + "payout_destination.splits", + &format!("bps must sum to exactly 10000, got {bps_sum}"), + ); + } + } + } + + if self.channel.grace_seconds == 0 { + push("channel.grace_seconds", "must be at least 1"); + } + if self.channel.idle_timeout_seconds == 0 { + push("channel.idle_timeout_seconds", "must be at least 1"); + } + + if let Err(policy_errors) = self.gate_policy.validate("gate_policy") { + errors.extend(policy_errors); + } + + if self.expires_at <= now { + errors.push(FieldError { + field: "expires_at".into(), + message: "must be in the future".into(), + }); + } + + if errors.is_empty() { + Ok(()) + } else { + Err(errors) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ts(s: &str) -> DateTime { + DateTime::parse_from_rfc3339(s).unwrap().with_timezone(&Utc) + } + + const NOW: &str = "2026-08-01T15:00:00Z"; + const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; + + fn valid() -> NewQuote { + NewQuote { + price: Amount { + amount: 250_000_000, + mint: USDC.into(), + }, + milestones: vec![ + MilestoneSpec { + title: "Forecast model".into(), + description: "p50/p90 fee forecast per program id, backtested".into(), + amount: 150_000_000, + }, + MilestoneSpec { + title: "Gated endpoint".into(), + description: "pay.sh-gated REST endpoint answering its 402 challenge".into(), + amount: 100_000_000, + }, + ], + timeline: "2 weeks, weekly demos".into(), + payout_destination: PayoutDestination::Splits { + splits: vec![ + Split { + recipient: "CrewAgentA111111111111111111111111111111111".into(), + bps: 7_000, + }, + Split { + recipient: "CrewAgentB111111111111111111111111111111111".into(), + bps: 3_000, + }, + ], + }, + channel: ChannelParams { + grace_seconds: 172_800, + idle_timeout_seconds: 604_800, + }, + gate_policy: GatePolicy::studio_default(), + expires_at: ts("2026-08-08T15:00:00Z"), + } + } + + fn errors_of(quote: NewQuote) -> Vec { + quote + .validate(ts(NOW)) + .unwrap_err() + .into_iter() + .map(|e| e.field) + .collect() + } + + #[test] + fn valid_quote_passes() { + assert!(valid().validate(ts(NOW)).is_ok()); + } + + #[test] + fn milestones_must_sum_to_the_price() { + let mut q = valid(); + q.milestones[1].amount = 99_000_000; + assert!(errors_of(q).contains(&"milestones".to_string())); + } + + #[test] + fn milestone_sum_does_not_overflow() { + let mut q = valid(); + q.milestones[0].amount = u64::MAX; + q.milestones[1].amount = u64::MAX; + assert!(errors_of(q).contains(&"milestones".to_string())); + } + + #[test] + fn empty_milestones_are_rejected() { + let mut q = valid(); + q.milestones.clear(); + assert!(errors_of(q).contains(&"milestones".to_string())); + } + + #[test] + fn milestone_fields_are_validated_with_indexed_paths() { + let mut q = valid(); + q.milestones[1] = MilestoneSpec { + title: " ".into(), + description: "".into(), + amount: 0, + }; + let fields = errors_of(q); + for expected in [ + "milestones[1].title", + "milestones[1].description", + "milestones[1].amount", + ] { + assert!(fields.contains(&expected.to_string()), "missing {expected}"); + } + } + + #[test] + fn splits_must_sum_to_10000_bps() { + let mut q = valid(); + q.payout_destination = PayoutDestination::Splits { + splits: vec![Split { + recipient: "CrewAgentA111111111111111111111111111111111".into(), + bps: 9_999, + }], + }; + assert!(errors_of(q).contains(&"payout_destination.splits".to_string())); + } + + #[test] + fn zero_bps_and_empty_recipient_are_rejected() { + let mut q = valid(); + q.payout_destination = PayoutDestination::Splits { + splits: vec![ + Split { + recipient: "".into(), + bps: 0, + }, + Split { + recipient: "CrewAgentA111111111111111111111111111111111".into(), + bps: 10_000, + }, + ], + }; + let fields = errors_of(q); + assert!(fields.contains(&"payout_destination.splits[0].recipient".to_string())); + assert!(fields.contains(&"payout_destination.splits[0].bps".to_string())); + } + + #[test] + fn empty_splits_are_rejected() { + let mut q = valid(); + q.payout_destination = PayoutDestination::Splits { splits: vec![] }; + assert!(errors_of(q).contains(&"payout_destination.splits".to_string())); + } + + #[test] + fn expired_expiry_zero_price_blank_timeline_and_zero_windows_are_rejected() { + let mut q = valid(); + q.expires_at = ts("2026-08-01T14:59:59Z"); + q.price = Amount { + amount: 0, + mint: " ".into(), + }; + q.timeline = " ".into(); + q.channel.grace_seconds = 0; + q.channel.idle_timeout_seconds = 0; + q.milestones = vec![MilestoneSpec { + title: "m".into(), + description: "d".into(), + amount: 1, + }]; + let fields = errors_of(q); + for expected in [ + "price.amount", + "price.mint", + "timeline", + "channel.grace_seconds", + "channel.idle_timeout_seconds", + "expires_at", + ] { + assert!(fields.contains(&expected.to_string()), "missing {expected}"); + } + } + + #[test] + fn invalid_gate_policy_fails_the_quote_with_prefixed_paths() { + let mut q = valid(); + q.gate_policy.edges.insert("QUOTED->QUOTED".into(), vec![]); + let fields = errors_of(q); + assert!(fields + .iter() + .any(|f| f.starts_with("gate_policy.edges[QUOTED->QUOTED]"))); + } + + #[test] + fn omitted_gate_policy_and_grace_default_correctly() { + let json = serde_json::json!({ + "price": { "amount": 100, "mint": USDC }, + "milestones": [ + { "title": "all of it", "description": "one milestone", "amount": 100 } + ], + "timeline": "3 days", + "payout_destination": { "kind": "splits", "splits": [ + { "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 10000 } + ]}, + "channel": { "idle_timeout_seconds": 3600 }, + "expires_at": "2026-08-08T15:00:00Z" + }); + let q: NewQuote = serde_json::from_value(json).unwrap(); + assert_eq!(q.channel.grace_seconds, 172_800); + assert_eq!(q.gate_policy, GatePolicy::studio_default()); + assert!(q.validate(ts(NOW)).is_ok()); + } + + #[test] + fn unknown_fields_are_rejected_everywhere() { + for bad in [ + serde_json::json!({ "price": { "amount": 1, "mint": USDC }, "surprise": 1 }), + serde_json::json!({ "payout_destination": { "kind": "splits", "splits": [], "vault": "x" } }), + serde_json::json!({ "channel": { "idle_timeout_seconds": 1, "surprise": 1 } }), + ] { + assert!( + serde_json::from_value::(bad.clone()).is_err(), + "should reject {bad}" + ); + } + } + + #[test] + fn effective_status_derives_lapsed_past_expiry() { + let q = Quote { + id: "q-1".into(), + rfq_id: "r-1".into(), + price: Amount { + amount: 1, + mint: USDC.into(), + }, + milestones: vec![], + timeline: "t".into(), + payout_destination: PayoutDestination::Splits { splits: vec![] }, + channel: ChannelParams { + grace_seconds: 1, + idle_timeout_seconds: 1, + }, + gate_policy: GatePolicy::studio_default(), + policy_hash: "policy-hash-set-at-issue-time".into(), + expires_at: ts("2026-08-02T00:00:00Z"), + status: QuoteStatus::Quoted, + created_at: ts("2026-08-01T00:00:00Z"), + lapsed_at: None, + accepted_at: None, + }; + + let live = q.clone().at(ts("2026-08-01T23:59:59Z")); + assert_eq!(live.status, QuoteStatus::Quoted); + assert_eq!(live.lapsed_at, None); + + let lapsed = q.clone().at(ts("2026-08-02T00:00:00Z")); + assert_eq!(lapsed.status, QuoteStatus::Lapsed); + assert_eq!(lapsed.lapsed_at, Some(ts("2026-08-02T00:00:00Z"))); + + // an already-swept quote keeps its stamped lapsed_at + let mut swept = q.clone(); + swept.status = QuoteStatus::Lapsed; + swept.lapsed_at = Some(ts("2026-08-02T00:00:30Z")); + let read = swept.at(ts("2026-08-03T00:00:00Z")); + assert_eq!(read.lapsed_at, Some(ts("2026-08-02T00:00:30Z"))); + } +} diff --git a/crates/studio-types/src/rfq.rs b/crates/studio-types/src/rfq.rs index 8747395..07b4e38 100644 --- a/crates/studio-types/src/rfq.rs +++ b/crates/studio-types/src/rfq.rs @@ -41,6 +41,14 @@ pub struct NewRfq { /// Buyer identity — the Nostr npub it will later pay with. #[schemars(regex(pattern = NPUB_PATTERN))] pub buyer_npub: String, + /// Reserved for the buyer-authored upgrade path (archy, 2026-08-01 M1 + /// boundary): a SIWX-style signature over the submission by + /// `buyer_npub`, making the RFQ counterparty-signed substrate instead + /// of studio self-attestation (ARCHITECTURE.md §1). Recorded, not yet + /// verified — like the delivery attestation field (PLAN.md §0). + #[serde(default)] + #[schemars(length(min = 1))] + pub buyer_signature: Option, } /// Token amount in minor units of `mint`. @@ -65,14 +73,17 @@ pub struct Rfq { pub competition: Vec, pub budget_ceiling: Option, pub buyer_npub: String, + /// Reserved (see `NewRfq::buyer_signature`); recorded, not verified. + pub buyer_signature: Option, /// RFC 3339, UTC, server-assigned at capture. pub created_at: chrono::DateTime, } /// One field-level validation failure — serialized into 422 bodies. +/// `field` is a path (e.g. `milestones[1].amount`), so it is owned. #[derive(Debug, Clone, Serialize, PartialEq, Eq)] pub struct FieldError { - pub field: &'static str, + pub field: String, pub message: String, } @@ -84,14 +95,14 @@ impl NewRfq { if self.query.trim().is_empty() { errors.push(FieldError { - field: "query", + field: "query".into(), message: "must be a non-empty string".into(), }); } if let Err(message) = validate_npub(&self.buyer_npub) { errors.push(FieldError { - field: "buyer_npub", + field: "buyer_npub".into(), message, }); } @@ -99,18 +110,27 @@ impl NewRfq { if let Some(budget) = &self.budget_ceiling { if budget.amount == 0 { errors.push(FieldError { - field: "budget_ceiling.amount", + field: "budget_ceiling.amount".into(), message: "must be greater than zero when present".into(), }); } if budget.mint.trim().is_empty() { errors.push(FieldError { - field: "budget_ceiling.mint", + field: "budget_ceiling.mint".into(), message: "must be a non-empty mint address when present".into(), }); } } + if let Some(sig) = &self.buyer_signature { + if sig.trim().is_empty() { + errors.push(FieldError { + field: "buyer_signature".into(), + message: "must be non-empty when present (omit it instead)".into(), + }); + } + } + if errors.is_empty() { Ok(()) } else { @@ -149,6 +169,7 @@ mod tests { competition: vec![], budget_ceiling: None, buyer_npub: GOOD_NPUB.into(), + buyer_signature: None, } } @@ -211,9 +232,10 @@ mod tests { mint: "".into(), }), buyer_npub: "nope".into(), + buyer_signature: None, }; let errors = rfq.validate().unwrap_err(); - let fields: Vec<_> = errors.iter().map(|e| e.field).collect(); + let fields: Vec<_> = errors.iter().map(|e| e.field.as_str()).collect(); assert_eq!( fields, vec![ diff --git a/crates/studio-types/src/schemas.rs b/crates/studio-types/src/schemas.rs index a4860f9..439aa36 100644 --- a/crates/studio-types/src/schemas.rs +++ b/crates/studio-types/src/schemas.rs @@ -13,7 +13,11 @@ const ID_BASE: &str = "https://scarce.studio/schemas"; /// Every published schema, by wire name (= filename stem under `schemas/`). pub fn all() -> Vec<(&'static str, serde_json::Value)> { - vec![("rfq", rfq())] + vec![ + ("rfq", rfq()), + ("quote", quote()), + ("gate-policy", gate_policy()), + ] } /// Look up one published schema by wire name. @@ -26,6 +30,19 @@ pub fn rfq() -> serde_json::Value { finalize("rfq", schema_for!(crate::rfq::NewRfq)) } +/// `schemas/quote.json` — the quote submission contract +/// (`POST /api/v1/rfqs/{id}/quote`). Self-contained: the gate policy is +/// inlined under `$defs` rather than `$ref`'d across files. +pub fn quote() -> serde_json::Value { + finalize("quote", schema_for!(crate::quote::NewQuote)) +} + +/// `schemas/gate-policy.json` — the standalone gate-policy contract, for +/// consumers that exchange policies outside a quote. +pub fn gate_policy() -> serde_json::Value { + finalize("gate-policy", schema_for!(crate::gate::GatePolicy)) +} + /// Stamp the registry-level `$id` onto a generated schema. `$schema`, title, /// and descriptions come from the type derives. fn finalize(name: &str, schema: schemars::Schema) -> serde_json::Value { diff --git a/crates/studio-types/src/state.rs b/crates/studio-types/src/state.rs new file mode 100644 index 0000000..2a79d0e --- /dev/null +++ b/crates/studio-types/src/state.rs @@ -0,0 +1,182 @@ +//! Project states and edges (PLAN.md §2). The full transition machine (which +//! edges are legal, what evidence each records) arrives with the orchestrator +//! in M3; the gate engine needs the vocabulary now. + +use serde::{Deserialize, Serialize}; + +/// Every state a project can occupy (PLAN.md §2). Wire names are +/// SCREAMING_SNAKE_CASE, matching the plan's diagrams and the gate-policy +/// edge keys. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ProjectState { + RfqCaptured, + Quoted, + Lapsed, + Funded, + WorkroomActive, + Building, + Demoed, + Accepted, + Delivered, + Operating, + ClosedByBuyer, + ClosedIdle, +} + +impl ProjectState { + pub const ALL: [ProjectState; 12] = [ + ProjectState::RfqCaptured, + ProjectState::Quoted, + ProjectState::Lapsed, + ProjectState::Funded, + ProjectState::WorkroomActive, + ProjectState::Building, + ProjectState::Demoed, + ProjectState::Accepted, + ProjectState::Delivered, + ProjectState::Operating, + ProjectState::ClosedByBuyer, + ProjectState::ClosedIdle, + ]; + + /// The wire / edge-key name, e.g. `WORKROOM_ACTIVE`. + pub fn name(self) -> &'static str { + match self { + ProjectState::RfqCaptured => "RFQ_CAPTURED", + ProjectState::Quoted => "QUOTED", + ProjectState::Lapsed => "LAPSED", + ProjectState::Funded => "FUNDED", + ProjectState::WorkroomActive => "WORKROOM_ACTIVE", + ProjectState::Building => "BUILDING", + ProjectState::Demoed => "DEMOED", + ProjectState::Accepted => "ACCEPTED", + ProjectState::Delivered => "DELIVERED", + ProjectState::Operating => "OPERATING", + ProjectState::ClosedByBuyer => "CLOSED_BY_BUYER", + ProjectState::ClosedIdle => "CLOSED_IDLE", + } + } + + pub fn parse(name: &str) -> Option { + Self::ALL.into_iter().find(|s| s.name() == name) + } +} + +impl std::fmt::Display for ProjectState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.name()) + } +} + +/// A concrete state-machine edge — what the orchestrator attempts and the +/// gate engine guards. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Edge { + pub from: ProjectState, + pub to: ProjectState, +} + +impl std::fmt::Display for Edge { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}->{}", self.from, self.to) + } +} + +/// A gate-policy edge key: either a concrete edge or an `any->TO` wildcard +/// (PLAN.md §2.1 default policy uses `any->CLOSED_BY_BUYER`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EdgePattern { + Exact(Edge), + AnyTo(ProjectState), +} + +impl EdgePattern { + /// Parse an edge key like `QUOTED->FUNDED` or `any->CLOSED_BY_BUYER`. + /// Self-loops and `any->any` are rejected: a gate on an impossible edge + /// is a policy bug, and policy bugs must be loud (fail-closed). + pub fn parse(key: &str) -> Result { + let (from, to) = key + .split_once("->") + .ok_or_else(|| format!("edge key `{key}` must be `FROM->TO`"))?; + let to = ProjectState::parse(to) + .ok_or_else(|| format!("edge key `{key}`: unknown target state `{to}`"))?; + if from == "any" { + return Ok(EdgePattern::AnyTo(to)); + } + let from = ProjectState::parse(from) + .ok_or_else(|| format!("edge key `{key}`: unknown source state `{from}`"))?; + if from == to { + return Err(format!("edge key `{key}` is a self-loop")); + } + Ok(EdgePattern::Exact(Edge { from, to })) + } + + pub fn matches(self, edge: Edge) -> bool { + match self { + EdgePattern::Exact(e) => e == edge, + EdgePattern::AnyTo(to) => to == edge.to, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn state_names_round_trip() { + for state in ProjectState::ALL { + assert_eq!(ProjectState::parse(state.name()), Some(state)); + let json = serde_json::to_value(state).unwrap(); + assert_eq!(json, serde_json::Value::String(state.name().into())); + } + assert_eq!(ProjectState::parse("NOPE"), None); + } + + #[test] + fn edge_patterns_parse() { + assert_eq!( + EdgePattern::parse("QUOTED->FUNDED").unwrap(), + EdgePattern::Exact(Edge { + from: ProjectState::Quoted, + to: ProjectState::Funded + }) + ); + assert_eq!( + EdgePattern::parse("any->CLOSED_BY_BUYER").unwrap(), + EdgePattern::AnyTo(ProjectState::ClosedByBuyer) + ); + for bad in [ + "QUOTED", + "QUOTED->", + "->FUNDED", + "NOPE->FUNDED", + "QUOTED->NOPE", + "QUOTED->QUOTED", + "any->any", + ] { + assert!(EdgePattern::parse(bad).is_err(), "should reject `{bad}`"); + } + } + + #[test] + fn wildcard_matches_only_target() { + let close = EdgePattern::parse("any->CLOSED_BY_BUYER").unwrap(); + let from_active = Edge { + from: ProjectState::WorkroomActive, + to: ProjectState::ClosedByBuyer, + }; + let from_building = Edge { + from: ProjectState::Building, + to: ProjectState::ClosedByBuyer, + }; + let unrelated = Edge { + from: ProjectState::Quoted, + to: ProjectState::Funded, + }; + assert!(close.matches(from_active)); + assert!(close.matches(from_building)); + assert!(!close.matches(unrelated)); + } +} diff --git a/crates/studio-types/tests/quote_conformance.rs b/crates/studio-types/tests/quote_conformance.rs new file mode 100644 index 0000000..eaa9515 --- /dev/null +++ b/crates/studio-types/tests/quote_conformance.rs @@ -0,0 +1,280 @@ +//! Keeps `schemas/quote.json` + `schemas/gate-policy.json` (the interoperable +//! wire contract, DESIGN.md §8) and the Rust validation honest with each +//! other, exactly like the RFQ suite: shared payloads must be accepted or +//! rejected by *both* validators. +//! +//! One deliberate asymmetry, stated in both schemas' descriptions: the code +//! enforces relational rules JSON Schema cannot express (milestone amounts +//! sum to the price, split bps sum to 10000, k ≤ n, no self-loop edges, +//! expiry in the future). For those, `code_stricter` asserts the divergence +//! explicitly — schema-valid, code-invalid — so the boundary is pinned by +//! tests rather than left to drift. + +use chrono::{DateTime, Utc}; +use studio_types::{GatePolicy, NewQuote}; + +const QUOTE_SCHEMA: &str = include_str!("../../../schemas/quote.json"); +const GATE_POLICY_SCHEMA: &str = include_str!("../../../schemas/gate-policy.json"); + +const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"; +/// Validation is anchored at issue time; expiry vectors are relative to this. +const NOW: &str = "2026-08-01T15:00:00Z"; + +fn now() -> DateTime { + DateTime::parse_from_rfc3339(NOW) + .unwrap() + .with_timezone(&Utc) +} + +/// The generated quote schema is self-contained (the gate policy is inlined +/// under `$defs`), so no cross-file resource registration is needed. +fn quote_validator() -> jsonschema::Validator { + let quote: serde_json::Value = + serde_json::from_str(QUOTE_SCHEMA).expect("schemas/quote.json parses"); + jsonschema::validator_for("e).expect("schemas/quote.json compiles") +} + +fn gate_policy_validator() -> jsonschema::Validator { + let gate_policy: serde_json::Value = + serde_json::from_str(GATE_POLICY_SCHEMA).expect("schemas/gate-policy.json parses"); + jsonschema::validator_for(&gate_policy).expect("schemas/gate-policy.json compiles") +} + +fn quote_code_verdict(payload: &serde_json::Value) -> bool { + serde_json::from_value::(payload.clone()) + .ok() + .is_some_and(|q| q.validate(now()).is_ok()) +} + +fn agree_quote(payload: serde_json::Value, expect_valid: bool) { + assert_eq!( + quote_validator().is_valid(&payload), + expect_valid, + "schema verdict diverged for {payload}" + ); + assert_eq!( + quote_code_verdict(&payload), + expect_valid, + "code verdict diverged for {payload}" + ); +} + +/// The documented one-way divergence: schema accepts, code rejects. +fn code_stricter_quote(payload: serde_json::Value) { + assert!( + quote_validator().is_valid(&payload), + "expected schema-valid for {payload}" + ); + assert!( + !quote_code_verdict(&payload), + "expected code-invalid for {payload}" + ); +} + +fn agree_policy(payload: serde_json::Value, expect_valid: bool) { + let code_verdict = serde_json::from_value::(payload.clone()) + .ok() + .is_some_and(|p| p.validate("gate_policy").is_ok()); + assert_eq!( + gate_policy_validator().is_valid(&payload), + expect_valid, + "schema verdict diverged for {payload}" + ); + assert_eq!( + code_verdict, expect_valid, + "code verdict diverged for {payload}" + ); +} + +fn valid_quote() -> serde_json::Value { + serde_json::json!({ + "price": { "amount": 250_000_000, "mint": USDC }, + "milestones": [ + { "title": "Forecast model", "description": "p50/p90 per program id", "amount": 150_000_000 }, + { "title": "Gated endpoint", "description": "pay.sh-gated REST endpoint", "amount": 100_000_000 } + ], + "timeline": "2 weeks, weekly demos", + "payout_destination": { "kind": "splits", "splits": [ + { "recipient": "CrewAgentA111111111111111111111111111111111", "bps": 7000 }, + { "recipient": "CrewAgentB111111111111111111111111111111111", "bps": 3000 } + ]}, + "channel": { "grace_seconds": 172_800, "idle_timeout_seconds": 604_800 }, + "expires_at": "2026-08-08T15:00:00Z" + }) +} + +fn with( + mut payload: serde_json::Value, + patch: impl FnOnce(&mut serde_json::Value), +) -> serde_json::Value { + patch(&mut payload); + payload +} + +// ── agreement: quote ───────────────────────────────────────────────────── + +#[test] +fn minimal_quote_without_gate_policy_is_valid_in_both() { + // gate_policy omitted → studio default; grace_seconds omitted → 48h. + agree_quote( + with(valid_quote(), |q| { + q["channel"] = serde_json::json!({ "idle_timeout_seconds": 3600 }); + }), + true, + ); +} + +#[test] +fn quote_with_explicit_default_gate_policy_is_valid_in_both() { + agree_quote( + with(valid_quote(), |q| { + q["gate_policy"] = serde_json::to_value(GatePolicy::studio_default()).unwrap(); + }), + true, + ); +} + +#[test] +fn rejects_agree_empty_milestones() { + agree_quote( + with(valid_quote(), |q| q["milestones"] = serde_json::json!([])), + false, + ); +} + +#[test] +fn rejects_agree_zero_milestone_amount() { + agree_quote( + with(valid_quote(), |q| { + q["milestones"][0]["amount"] = serde_json::json!(0) + }), + false, + ); +} + +#[test] +fn rejects_agree_missing_channel() { + agree_quote( + with(valid_quote(), |q| { + q.as_object_mut().unwrap().remove("channel"); + }), + false, + ); +} + +#[test] +fn rejects_agree_unknown_payout_kind() { + agree_quote( + with(valid_quote(), |q| { + q["payout_destination"] = serde_json::json!({ "kind": "vault", "address": "x" }) + }), + false, + ); +} + +#[test] +fn rejects_agree_unknown_top_level_field() { + agree_quote( + with(valid_quote(), |q| q["surprise"] = serde_json::json!(true)), + false, + ); +} + +#[test] +fn rejects_agree_bad_gate_spec_in_policy() { + agree_quote( + with(valid_quote(), |q| { + q["gate_policy"] = serde_json::json!({ "edges": { + "QUOTED->FUNDED": [ { "type": "timelock" } ] // min_seconds missing + }}); + }), + false, + ); +} + +// ── code stricter than schema (documented, pinned) ─────────────────────── + +#[test] +fn code_stricter_milestone_sum_must_equal_price() { + code_stricter_quote(with(valid_quote(), |q| { + q["milestones"][1]["amount"] = serde_json::json!(99_000_000) + })); +} + +#[test] +fn code_stricter_split_bps_must_sum_to_10000() { + code_stricter_quote(with(valid_quote(), |q| { + q["payout_destination"]["splits"][1]["bps"] = serde_json::json!(2999) + })); +} + +#[test] +fn code_stricter_expiry_must_be_in_the_future() { + code_stricter_quote(with(valid_quote(), |q| { + q["expires_at"] = serde_json::json!("2026-08-01T14:00:00Z") + })); +} + +#[test] +fn code_stricter_self_loop_edge_is_rejected() { + code_stricter_quote(with(valid_quote(), |q| { + q["gate_policy"] = serde_json::json!({ "edges": { + "QUOTED->QUOTED": [ { "type": "payment_evidence" } ] + }}); + })); +} + +#[test] +fn code_stricter_k_cannot_exceed_named_agents() { + code_stricter_quote(with(valid_quote(), |q| { + q["gate_policy"] = serde_json::json!({ "edges": { + "DEMOED->ACCEPTED": [ { "type": "agent_signoff", "agents": ["reviewer"], "k": 2 } ] + }}); + })); +} + +// ── agreement: gate-policy standalone ──────────────────────────────────── + +#[test] +fn default_policy_is_valid_in_both() { + agree_policy( + serde_json::to_value(GatePolicy::studio_default()).unwrap(), + true, + ); +} + +#[test] +fn rejects_agree_unknown_edge_key() { + agree_policy( + serde_json::json!({ "edges": { "NOPE->FUNDED": [ { "type": "payment_evidence" } ] } }), + false, + ); +} + +#[test] +fn rejects_agree_unknown_gate_type() { + agree_policy( + serde_json::json!({ "edges": { "QUOTED->FUNDED": [ { "type": "vibes" } ] } }), + false, + ); +} + +#[test] +fn rejects_agree_cross_variant_field_bleed() { + agree_policy( + serde_json::json!({ "edges": { "QUOTED->FUNDED": [ + { "type": "timelock", "min_seconds": 5, "principal": "buyer" } + ] } }), + false, + ); +} + +#[test] +fn rejects_agree_zero_k() { + agree_policy( + serde_json::json!({ "edges": { "DEMOED->ACCEPTED": [ + { "type": "agent_signoff", "agents": ["reviewer"], "k": 0 } + ] } }), + false, + ); +} diff --git a/crates/studio-types/tests/schema_conformance.rs b/crates/studio-types/tests/schema_conformance.rs index c4c162a..820c51b 100644 --- a/crates/studio-types/tests/schema_conformance.rs +++ b/crates/studio-types/tests/schema_conformance.rs @@ -76,6 +76,26 @@ fn rejects_agree_zero_budget() { ); } +#[test] +fn reserved_buyer_signature_is_accepted_when_present_in_both() { + agree( + serde_json::json!({ + "query": "x", + "buyer_npub": GOOD_NPUB, + "buyer_signature": "sig-recorded-not-verified" + }), + true, + ); +} + +#[test] +fn rejects_agree_empty_buyer_signature() { + agree( + serde_json::json!({ "query": "x", "buyer_npub": GOOD_NPUB, "buyer_signature": "" }), + false, + ); +} + #[test] fn rejects_agree_unknown_field() { agree( diff --git a/justfile b/justfile index 24f7ea5..107da19 100644 --- a/justfile +++ b/justfile @@ -21,8 +21,37 @@ test: ci: fmt lint test -run: - cargo run --bin scarced +# `just run` or `just run --config scarced.yaml` +run *args: + cargo run --bin scarced -- {{args}} + +# Install a target: `just install scarce [cargo install args...]` +[positional-arguments] +install *args: + #!/usr/bin/env bash + set -euo pipefail + + if [ "$#" -eq 0 ]; then + echo "Usage: just install scarce [cargo install args...]" + exit 1 + fi + target="$1" + shift + + case "${target}" in + scarce) + if [ "$#" -gt 0 ]; then + cargo install "$@" + else + cargo install --path . --locked + fi + ;; + *) + echo "Unknown target: ${target}" + echo "Usage: just install scarce [cargo install args...]" + exit 1 + ;; + esac # Regenerate schemas/*.json from the studio-types derives. CI fails (drift # test) when a type change lands without re-running this. diff --git a/migrations/0003_rfq_buyer_signature.sql b/migrations/0003_rfq_buyer_signature.sql new file mode 100644 index 0000000..2587169 --- /dev/null +++ b/migrations/0003_rfq_buyer_signature.sql @@ -0,0 +1,4 @@ +-- Reserved buyer-signature field (M2, per archy at the M1 boundary): +-- upgrade path (b) makes RFQs buyer-authored substrate. Recorded when +-- supplied, not yet verified — verification arrives with the orchestrator. +ALTER TABLE rfqs ADD COLUMN buyer_signature TEXT; diff --git a/migrations/0004_quotes.sql b/migrations/0004_quotes.sql new file mode 100644 index 0000000..3b246cd --- /dev/null +++ b/migrations/0004_quotes.sql @@ -0,0 +1,29 @@ +-- Quote ledger (M2). At most one quote per RFQ in v0 (the PLAN.md §4 routes +-- are singular: POST/GET /rfqs/{id}/quote); re-quoting after a lapse is an +-- open pricing question (ARCHITECTURE.md §8.3) and deliberately unsupported +-- until answered. +-- +-- Projection caveat, same standing as rfqs (see 0002): quotes are +-- API-authored until the orchestrator mirrors them to the studio ops channel +-- (M3, decided at the M1 boundary). Structured columns carry what the sweep +-- and reads filter on; the negotiated payload (milestones, splits, gate +-- policy) stays canonical JSON so the projection round-trips exactly. +CREATE TABLE quotes ( + rfq_id TEXT PRIMARY KEY REFERENCES rfqs (id), + id TEXT NOT NULL UNIQUE, + price_amount INTEGER NOT NULL, + price_mint TEXT NOT NULL, + milestones TEXT NOT NULL, -- JSON array of milestone specs + timeline TEXT NOT NULL, + payout_destination TEXT NOT NULL, -- JSON, kind-tagged + grace_seconds INTEGER NOT NULL, + idle_timeout_seconds INTEGER NOT NULL, + gate_policy TEXT NOT NULL, -- JSON, hash-committed via policy_hash + policy_hash TEXT NOT NULL, -- sha256 hex of canonical gate_policy + expires_at TEXT NOT NULL, -- RFC 3339, UTC + status TEXT NOT NULL CHECK (status IN ('QUOTED', 'LAPSED')), + created_at TEXT NOT NULL, -- RFC 3339, UTC + lapsed_at TEXT +) STRICT; + +CREATE INDEX idx_quotes_status_expires_at ON quotes (status, expires_at); diff --git a/migrations/0005_quote_accept_workrooms.sql b/migrations/0005_quote_accept_workrooms.sql new file mode 100644 index 0000000..c897b61 --- /dev/null +++ b/migrations/0005_quote_accept_workrooms.sql @@ -0,0 +1,48 @@ +-- Buyer acceptance on the quote row (stands in for FUNDED while payments are +-- stubbed — PLAN.md §6 override path) and the workroom projection. The +-- workroom row carries the Buzz channel-create event id: the evidence for +-- the FUNDED → WORKROOM_ACTIVE transition (ARCHITECTURE.md §evidence table). +-- +-- The quotes table is rebuilt rather than altered: SQLite cannot widen the +-- status CHECK in place, and the table is a projection — rebuildable by +-- design, so the copy is cheap and safe. + +CREATE TABLE quotes_new ( + rfq_id TEXT PRIMARY KEY REFERENCES rfqs (id), + id TEXT NOT NULL UNIQUE, + price_amount INTEGER NOT NULL, + price_mint TEXT NOT NULL, + milestones TEXT NOT NULL, -- JSON array of milestone specs + timeline TEXT NOT NULL, + payout_destination TEXT NOT NULL, -- JSON, kind-tagged + grace_seconds INTEGER NOT NULL, + idle_timeout_seconds INTEGER NOT NULL, + gate_policy TEXT NOT NULL, -- JSON, hash-committed via policy_hash + policy_hash TEXT NOT NULL, -- sha256 hex of canonical gate_policy + expires_at TEXT NOT NULL, -- RFC 3339, UTC + status TEXT NOT NULL CHECK (status IN ('QUOTED', 'LAPSED', 'ACCEPTED')), + created_at TEXT NOT NULL, -- RFC 3339, UTC + lapsed_at TEXT, + accepted_at TEXT -- RFC 3339, UTC; set exactly once +) STRICT; + +INSERT INTO quotes_new (rfq_id, id, price_amount, price_mint, milestones, + timeline, payout_destination, grace_seconds, + idle_timeout_seconds, gate_policy, policy_hash, + expires_at, status, created_at, lapsed_at) +SELECT rfq_id, id, price_amount, price_mint, milestones, + timeline, payout_destination, grace_seconds, + idle_timeout_seconds, gate_policy, policy_hash, + expires_at, status, created_at, lapsed_at +FROM quotes; + +DROP TABLE quotes; +ALTER TABLE quotes_new RENAME TO quotes; +CREATE INDEX idx_quotes_status_expires_at ON quotes (status, expires_at); + +CREATE TABLE workrooms ( + rfq_id TEXT PRIMARY KEY REFERENCES rfqs (id), + channel_id TEXT NOT NULL, + create_event_id TEXT NOT NULL, + created_at TEXT NOT NULL -- RFC 3339, UTC +) STRICT; diff --git a/scarced.example.yaml b/scarced.example.yaml new file mode 100644 index 0000000..3ca0548 --- /dev/null +++ b/scarced.example.yaml @@ -0,0 +1,19 @@ +# scarced config — `scarced --config scarced.yaml`. Every key can be +# overridden by a SCARCED_* env var (nested keys join with `__`: +# SCARCED_BUZZ__RELAY_URL). Dev values only; real secrets come from env. + +bind: 127.0.0.1:7380 +db: sqlite://scarced.db +studio_token: dev-token +sweep_seconds: 30 + +# Buzz community the studio operates in. All three keys are required when +# the section is present; omit the whole section for a ledger-only run. +buzz: + relay_url: wss://scarce.communities.buzz.xyz + # Studio identity (hex or nsec). Dev only here — use the env var + # SCARCED_BUZZ__PRIVATE_KEY in anything that is not a laptop. + private_key: REPLACE_WITH_STUDIO_KEY + # Lifecycle mirror target: demand / quote / accepted beats post here + # (private channel — members only). + ops_channel: cdc47f86-91d3-4485-945a-75266269918a diff --git a/schemas/gate-policy.json b/schemas/gate-policy.json new file mode 100644 index 0000000..6c859d7 --- /dev/null +++ b/schemas/gate-policy.json @@ -0,0 +1,159 @@ +{ + "$defs": { + "GateSpec": { + "description": "One gate on a state-machine edge (internally tagged on `type`).", + "oneOf": [ + { + "additionalProperties": false, + "description": "A named principal must approve. Primary mechanism: native Buzz\nworkflow approval tokens; fallback: signed channel message with fixed\ngrammar. The principal is an opaque label (npub or a role like\n`buyer`) — evidence must carry the same label.", + "properties": { + "escalate_after_seconds": { + "description": "Blocked longer than this (from edge eligibility) → escalate to\nthe gate's principal, then the studio owner. Never auto-passes.", + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "principal": { + "description": "Opaque principal label — an npub or a role like `buyer`.\nEvidence must carry the same label.", + "minLength": 1, + "type": "string" + }, + "type": { + "const": "human_approval", + "type": "string" + } + }, + "required": [ + "type", + "principal" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "k-of-n signed sign-offs from named crew agents.", + "properties": { + "agents": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "escalate_after_seconds": { + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "k": { + "format": "uint32", + "minimum": 1, + "type": "integer" + }, + "type": { + "const": "agent_signoff", + "type": "string" + } + }, + "required": [ + "type", + "agents", + "k" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A named verifiable predicate (CI green, endpoint answers its 402\nchallenge, schema validation passes). Reruns are natural, so the\nlatest result wins; a stale pass is expired evidence and blocks.", + "properties": { + "check": { + "minLength": 1, + "type": "string" + }, + "max_age_seconds": { + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "type": { + "const": "machine_check", + "type": "string" + } + }, + "required": [ + "type", + "check" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "`PayPort` status: operator record in stub mode, tx signature live.", + "properties": { + "type": { + "const": "payment_evidence", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Minimum elapsed review window since the edge became eligible.", + "properties": { + "min_seconds": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "type": { + "const": "timelock", + "type": "string" + } + }, + "required": [ + "type", + "min_seconds" + ], + "type": "object" + } + ] + } + }, + "$id": "https://scarce.studio/schemas/gate-policy.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "A project's procedural law (PLAN.md §2.1): state-machine edge → ordered hard gates. Negotiated in the Quote, hash-committed at FUNDED, tamper-evident thereafter. Semantics enforced by the engine, not expressible here: fail-closed evaluation, denial-with-note as recorded history, loud audited overrides. The implementation is additionally stricter than this schema: self-loop edge keys are rejected, agent lists must be duplicate-free, and k must not exceed the number of named agents.", + "properties": { + "edges": { + "additionalProperties": false, + "description": "Edge key `FROM->TO` (or wildcard `any->TO`) → ordered gates. A\nconcrete edge is guarded by its exact entry followed by any matching\nwildcard entry — both apply.", + "patternProperties": { + "^(any|RFQ_CAPTURED|QUOTED|LAPSED|FUNDED|WORKROOM_ACTIVE|BUILDING|DEMOED|ACCEPTED|DELIVERED|OPERATING|CLOSED_BY_BUYER|CLOSED_IDLE)->(RFQ_CAPTURED|QUOTED|LAPSED|FUNDED|WORKROOM_ACTIVE|BUILDING|DEMOED|ACCEPTED|DELIVERED|OPERATING|CLOSED_BY_BUYER|CLOSED_IDLE)$": { + "items": { + "$ref": "#/$defs/GateSpec" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "required": [ + "edges" + ], + "title": "Gate policy", + "type": "object" +} diff --git a/schemas/quote.json b/schemas/quote.json new file mode 100644 index 0000000..fb8b097 --- /dev/null +++ b/schemas/quote.json @@ -0,0 +1,362 @@ +{ + "$defs": { + "Amount": { + "additionalProperties": false, + "description": "Token amount in minor units of `mint`.", + "properties": { + "amount": { + "description": "Token amount in minor units of `mint`.", + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "mint": { + "description": "SPL mint address (e.g. USDC). Free-form here; enforced at quote time.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "amount", + "mint" + ], + "type": "object" + }, + "ChannelParams": { + "additionalProperties": false, + "description": "MPP session channel parameters the quote commits to.", + "properties": { + "grace_seconds": { + "default": 172800, + "description": "Buyer-exit grace window, seconds. Default 172800 (48h) per PLAN.md M2.", + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "idle_timeout_seconds": { + "description": "Idle window after which the studio settles at watermark and closes.\nNo default on purpose: the quote must commit to it explicitly.", + "format": "uint64", + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "idle_timeout_seconds" + ], + "type": "object" + }, + "GatePolicy": { + "additionalProperties": false, + "description": "A project's procedural law (PLAN.md §2.1): state-machine edge → ordered hard gates. Negotiated in the Quote, hash-committed at FUNDED, tamper-evident thereafter. Semantics enforced by the engine, not expressible here: fail-closed evaluation, denial-with-note as recorded history, loud audited overrides. The implementation is additionally stricter than this schema: self-loop edge keys are rejected, agent lists must be duplicate-free, and k must not exceed the number of named agents.", + "properties": { + "edges": { + "additionalProperties": false, + "description": "Edge key `FROM->TO` (or wildcard `any->TO`) → ordered gates. A\nconcrete edge is guarded by its exact entry followed by any matching\nwildcard entry — both apply.", + "patternProperties": { + "^(any|RFQ_CAPTURED|QUOTED|LAPSED|FUNDED|WORKROOM_ACTIVE|BUILDING|DEMOED|ACCEPTED|DELIVERED|OPERATING|CLOSED_BY_BUYER|CLOSED_IDLE)->(RFQ_CAPTURED|QUOTED|LAPSED|FUNDED|WORKROOM_ACTIVE|BUILDING|DEMOED|ACCEPTED|DELIVERED|OPERATING|CLOSED_BY_BUYER|CLOSED_IDLE)$": { + "items": { + "$ref": "#/$defs/GateSpec" + }, + "type": "array" + } + }, + "type": "object" + } + }, + "required": [ + "edges" + ], + "title": "Gate policy", + "type": "object" + }, + "GateSpec": { + "description": "One gate on a state-machine edge (internally tagged on `type`).", + "oneOf": [ + { + "additionalProperties": false, + "description": "A named principal must approve. Primary mechanism: native Buzz\nworkflow approval tokens; fallback: signed channel message with fixed\ngrammar. The principal is an opaque label (npub or a role like\n`buyer`) — evidence must carry the same label.", + "properties": { + "escalate_after_seconds": { + "description": "Blocked longer than this (from edge eligibility) → escalate to\nthe gate's principal, then the studio owner. Never auto-passes.", + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "principal": { + "description": "Opaque principal label — an npub or a role like `buyer`.\nEvidence must carry the same label.", + "minLength": 1, + "type": "string" + }, + "type": { + "const": "human_approval", + "type": "string" + } + }, + "required": [ + "type", + "principal" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "k-of-n signed sign-offs from named crew agents.", + "properties": { + "agents": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "escalate_after_seconds": { + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "k": { + "format": "uint32", + "minimum": 1, + "type": "integer" + }, + "type": { + "const": "agent_signoff", + "type": "string" + } + }, + "required": [ + "type", + "agents", + "k" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "A named verifiable predicate (CI green, endpoint answers its 402\nchallenge, schema validation passes). Reruns are natural, so the\nlatest result wins; a stale pass is expired evidence and blocks.", + "properties": { + "check": { + "minLength": 1, + "type": "string" + }, + "max_age_seconds": { + "format": "uint64", + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "type": { + "const": "machine_check", + "type": "string" + } + }, + "required": [ + "type", + "check" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "`PayPort` status: operator record in stub mode, tx signature live.", + "properties": { + "type": { + "const": "payment_evidence", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "Minimum elapsed review window since the edge became eligible.", + "properties": { + "min_seconds": { + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "type": { + "const": "timelock", + "type": "string" + } + }, + "required": [ + "type", + "min_seconds" + ], + "type": "object" + } + ] + }, + "MilestoneSpec": { + "additionalProperties": false, + "description": "One milestone: a demoable, acceptable, priced unit of work.", + "properties": { + "amount": { + "description": "Minor units of the quote's `price.mint`.", + "format": "uint64", + "minimum": 1, + "type": "integer" + }, + "description": { + "minLength": 1, + "type": "string" + }, + "title": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "title", + "description", + "amount" + ], + "type": "object" + }, + "PayoutDestination": { + "description": "Where settled funds go. v0: direct channel splits, exactly DESIGN.md\n§4.3(a) — escrow pays the crew, no studio custody. The `vault` variant\n(v1, ARCHITECTURE.md §2.3) will slot in beside `splits`.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "splits", + "type": "string" + }, + "splits": { + "items": { + "$ref": "#/$defs/Split" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "kind", + "splits" + ], + "type": "object" + } + ] + }, + "Split": { + "additionalProperties": false, + "description": "One recipient's share, in basis points. Splits must sum to exactly\n10_000 bps — every lamport of a settlement is accounted for.", + "properties": { + "bps": { + "format": "uint32", + "maximum": 10000, + "minimum": 1, + "type": "integer" + }, + "recipient": { + "description": "Solana address. Free-form here, like `Amount::mint`; enforced when\nthe live PayPort builds real session terms (M5).", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "recipient", + "bps" + ], + "type": "object" + } + }, + "$id": "https://scarce.studio/schemas/quote.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "description": "The studio's priced answer to an RFQ (PLAN.md M2, DESIGN.md §3). Wire shape of POST /api/v1/rfqs/{id}/quote; the studio assigns id, created_at, status, and policy_hash. The implementation is additionally stricter than this schema: milestone amounts must sum to price.amount (milestone granularity is the dispute system), split bps must sum to exactly 10000, expires_at must be in the future at issue time, and the gate policy's structural rules (see gate-policy.json) are enforced.", + "properties": { + "channel": { + "$ref": "#/$defs/ChannelParams" + }, + "expires_at": { + "description": "Past this instant the quote is LAPSED and cannot be accepted.", + "format": "date-time", + "type": "string" + }, + "gate_policy": { + "$ref": "#/$defs/GatePolicy", + "default": { + "edges": { + "ACCEPTED->DELIVERED": [ + { + "check": "endpoint-live", + "type": "machine_check" + }, + { + "escalate_after_seconds": 172800, + "principal": "buyer", + "type": "human_approval" + }, + { + "min_seconds": 86400, + "type": "timelock" + } + ], + "DEMOED->ACCEPTED": [ + { + "escalate_after_seconds": 172800, + "principal": "buyer", + "type": "human_approval" + } + ], + "QUOTED->FUNDED": [ + { + "type": "payment_evidence" + } + ], + "any->CLOSED_BY_BUYER": [ + { + "min_seconds": 172800, + "type": "timelock" + } + ] + } + }, + "description": "Defaults from studio config; buyers may strengthen per-project." + }, + "milestones": { + "items": { + "$ref": "#/$defs/MilestoneSpec" + }, + "minItems": 1, + "type": "array" + }, + "payout_destination": { + "$ref": "#/$defs/PayoutDestination" + }, + "price": { + "$ref": "#/$defs/Amount", + "description": "Total engagement price. Must equal the sum of milestone amounts." + }, + "timeline": { + "description": "Human-negotiated schedule description (e.g. \"3 weeks, weekly demos\").", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "price", + "milestones", + "timeline", + "payout_destination", + "channel", + "expires_at" + ], + "title": "Quote submission", + "type": "object" +} diff --git a/schemas/rfq.json b/schemas/rfq.json index 954d0d9..7e57e98 100644 --- a/schemas/rfq.json +++ b/schemas/rfq.json @@ -45,6 +45,15 @@ "pattern": "^npub1[02-9ac-hj-np-z]{58}$", "type": "string" }, + "buyer_signature": { + "default": null, + "description": "Reserved for the buyer-authored upgrade path (archy, 2026-08-01 M1\nboundary): a SIWX-style signature over the submission by\n`buyer_npub`, making the RFQ counterparty-signed substrate instead\nof studio self-attestation (ARCHITECTURE.md §1). Recorded, not yet\nverified — like the delivery attestation field (PLAN.md §0).", + "minLength": 1, + "type": [ + "string", + "null" + ] + }, "competition": { "default": [], "description": "Competing or adjacent offerings the buyer knows about.", diff --git a/src/config.rs b/src/config.rs index f4e9ee6..56f292c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,20 +1,233 @@ -//! Configuration from environment (PLAN.md M0). Secrets stay out of here — -//! the studio keys arrive in M3 and come from the environment via the -//! deployment's secret manager, never from files in the repo. +//! Configuration: defaults ← YAML file (`--config`) ← `SCARCED_*` env. +//! Nested keys join with `__` in env form (`SCARCED_BUZZ__RELAY_URL`). +//! Secrets stay out of the repo — the studio keys arrive in M3 and come from +//! the environment via the deployment's secret manager, never from files +//! checked in here (`scarced.example.yaml` carries dev values only). -#[derive(Debug, Clone)] +use std::path::Path; + +use figment::{ + providers::{Env, Format, Serialized, Yaml}, + Figment, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct Config { /// Socket address the HTTP surface binds, e.g. `127.0.0.1:7380`. pub bind: String, /// SQLite URL of the projection store, e.g. `sqlite://scarced.db`. - pub db_url: String, + /// Created if missing; droppable and rebuildable by design. + pub db: String, + /// Bearer token for studio-authenticated routes (quote issuance). + /// Unset or empty disables those routes — fail-closed, never a default + /// credential. + #[serde(default)] + pub studio_token: Option, + /// Quote-expiry sweep cadence, seconds. Reads are fail-closed against + /// sweep lag either way; the sweep keeps the projection rows honest. + pub sweep_seconds: u64, + /// Buzz community the studio operates in. Recorded now, consumed by the + /// M3 orchestrator (PLAN.md M3). + #[serde(default)] + pub buzz: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BuzzConfig { + /// Community relay websocket URL, e.g. `wss://scarce.communities.buzz.xyz`. + pub relay_url: String, + /// Studio identity key (hex or nsec) the daemon signs with. Prefer the + /// env form (`SCARCED_BUZZ__PRIVATE_KEY`) outside dev. + pub private_key: String, + /// Ops channel uuid — the lifecycle mirror posts demand/quote/accept + /// beats here. Workroom channels are created per accepted engagement. + pub ops_channel: String, + /// NIP-OA auth tag JSON (`BUZZ_AUTH_TAG` shape). Required when the + /// studio key is a managed-agent identity; owner-key runs omit it. + #[serde(default)] + pub auth_tag: Option, +} + +impl Default for Config { + fn default() -> Self { + Self { + bind: "127.0.0.1:7380".into(), + db: "sqlite://scarced.db".into(), + studio_token: None, + sweep_seconds: 30, + buzz: None, + } + } } impl Config { - pub fn from_env() -> anyhow::Result { - Ok(Self { - bind: std::env::var("SCARCED_BIND").unwrap_or_else(|_| "127.0.0.1:7380".into()), - db_url: std::env::var("SCARCED_DB").unwrap_or_else(|_| "sqlite://scarced.db".into()), - }) + pub fn load(path: Option<&Path>) -> anyhow::Result { + let mut figment = Figment::from(Serialized::defaults(Config::default())); + if let Some(path) = path { + anyhow::ensure!(path.is_file(), "config file not found: {}", path.display()); + figment = figment.merge(Yaml::file_exact(path)); + } + let mut config: Config = figment + .merge( + Env::prefixed("SCARCED_") + .split("__") + // The auth tag value is JSON; figment's lenient env + // parsing would decode it into a sequence and fail the + // string field. It bypasses figment below, read raw. + .ignore(&["buzz.auth_tag", "buzz__auth_tag"]), + ) + .extract() + .map_err(|e| anyhow::anyhow!("invalid config: {e}"))?; + + if let (Some(buzz), Ok(raw)) = ( + config.buzz.as_mut(), + std::env::var("SCARCED_BUZZ__AUTH_TAG"), + ) { + if !raw.trim().is_empty() { + buzz.auth_tag = Some(raw); + } + } + + config.studio_token = config.studio_token.filter(|t| !t.trim().is_empty()); + anyhow::ensure!( + config.sweep_seconds > 0, + "sweep_seconds (SCARCED_SWEEP_SECONDS) must be a positive integer" + ); + if let Some(buzz) = &config.buzz { + anyhow::ensure!( + buzz.relay_url.starts_with("wss://") || buzz.relay_url.starts_with("ws://"), + "buzz.relay_url must be a ws:// or wss:// URL, got `{}`", + buzz.relay_url + ); + anyhow::ensure!( + !buzz.private_key.trim().is_empty(), + "buzz.private_key (SCARCED_BUZZ__PRIVATE_KEY) must be set when buzz is configured" + ); + anyhow::ensure!( + uuid::Uuid::parse_str(&buzz.ops_channel).is_ok(), + "buzz.ops_channel must be a channel uuid, got `{}`", + buzz.ops_channel + ); + } + Ok(config) + } +} + +#[cfg(test)] +// figment::Jail's closure signature returns its large Error type by design. +#[allow(clippy::result_large_err)] +mod tests { + use super::*; + + #[test] + fn defaults_without_file_or_env() { + figment::Jail::expect_with(|_| { + let config = Config::load(None).expect("defaults load"); + assert_eq!(config.bind, "127.0.0.1:7380"); + assert_eq!(config.db, "sqlite://scarced.db"); + assert_eq!(config.studio_token, None); + assert_eq!(config.sweep_seconds, 30); + assert!(config.buzz.is_none()); + Ok(()) + }); + } + + #[test] + fn yaml_sets_and_env_overrides() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "scarced.yaml", + "bind: 0.0.0.0:9999\nstudio_token: from-yaml\nbuzz:\n relay_url: wss://example.communities.buzz.xyz\n private_key: from-yaml-key\n ops_channel: 8f99f8e4-ae12-4397-bc65-7b0f8a69688f\n", + )?; + jail.set_env("SCARCED_STUDIO_TOKEN", "from-env"); + jail.set_env("SCARCED_BUZZ__RELAY_URL", "wss://override.example"); + // JSON array value: must arrive as the raw string, not a + // figment-parsed sequence. + jail.set_env( + "SCARCED_BUZZ__AUTH_TAG", + r#"["auth","aa","{\"cap\":1}","sig"]"#, + ); + let config = Config::load(Some(Path::new("scarced.yaml"))).expect("load"); + assert_eq!(config.bind, "0.0.0.0:9999"); // yaml over default + assert_eq!(config.studio_token.as_deref(), Some("from-env")); // env over yaml + let buzz = config.buzz.unwrap(); + assert_eq!(buzz.relay_url, "wss://override.example"); + assert_eq!(buzz.private_key, "from-yaml-key"); + assert_eq!( + buzz.auth_tag.as_deref(), + Some(r#"["auth","aa","{\"cap\":1}","sig"]"#) + ); + assert_eq!(config.sweep_seconds, 30); // default survives partial yaml + Ok(()) + }); + } + + #[test] + fn missing_file_is_an_error() { + figment::Jail::expect_with(|_| { + let err = Config::load(Some(Path::new("nope.yaml"))).unwrap_err(); + assert!(err.to_string().contains("nope.yaml"), "{err}"); + Ok(()) + }); + } + + #[test] + fn unknown_key_is_rejected_by_name() { + figment::Jail::expect_with(|jail| { + jail.create_file("scarced.yaml", "bindd: 1.2.3.4:80\n")?; + let err = Config::load(Some(Path::new("scarced.yaml"))).unwrap_err(); + assert!(err.to_string().contains("bindd"), "{err}"); + Ok(()) + }); + } + + #[test] + fn zero_sweep_rejected_and_empty_token_disables() { + figment::Jail::expect_with(|jail| { + jail.create_file("scarced.yaml", "sweep_seconds: 0\n")?; + let err = Config::load(Some(Path::new("scarced.yaml"))).unwrap_err(); + assert!(err.to_string().contains("sweep_seconds"), "{err}"); + + jail.set_env("SCARCED_STUDIO_TOKEN", " "); + let config = Config::load(None).expect("load"); + assert_eq!(config.studio_token, None); + Ok(()) + }); + } + + #[test] + fn non_websocket_relay_url_rejected() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "scarced.yaml", + "buzz:\n relay_url: https://not-a-relay\n private_key: k\n ops_channel: 8f99f8e4-ae12-4397-bc65-7b0f8a69688f\n", + )?; + let err = Config::load(Some(Path::new("scarced.yaml"))).unwrap_err(); + assert!(err.to_string().contains("relay_url"), "{err}"); + Ok(()) + }); + } + + #[test] + fn buzz_section_requires_key_and_channel_uuid() { + figment::Jail::expect_with(|jail| { + jail.create_file( + "scarced.yaml", + "buzz:\n relay_url: wss://r\n private_key: \" \"\n ops_channel: 8f99f8e4-ae12-4397-bc65-7b0f8a69688f\n", + )?; + let err = Config::load(Some(Path::new("scarced.yaml"))).unwrap_err(); + assert!(err.to_string().contains("private_key"), "{err}"); + + jail.create_file( + "scarced2.yaml", + "buzz:\n relay_url: wss://r\n private_key: k\n ops_channel: not-a-uuid\n", + )?; + let err = Config::load(Some(Path::new("scarced2.yaml"))).unwrap_err(); + assert!(err.to_string().contains("ops_channel"), "{err}"); + Ok(()) + }); } } diff --git a/src/main.rs b/src/main.rs index 1e5b932..141108f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,26 +5,71 @@ use std::sync::Arc; use anyhow::Context; +use clap::Parser; use studio_api::AppState; mod config; +mod mirror; + +#[derive(Parser)] +#[command(version, about = "scarced — the scarce-studio daemon")] +struct Args { + /// YAML config file; SCARCED_* env vars override its keys + /// (nested keys join with `__`, e.g. SCARCED_BUZZ__RELAY_URL) + #[arg(long, value_name = "PATH")] + config: Option, +} #[tokio::main] async fn main() -> anyhow::Result<()> { + let args = Args::parse(); + studio_buzz::install_crypto_provider(); + tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), ) .init(); - let config = config::Config::from_env()?; - tracing::info!(bind = %config.bind, db = %config.db_url, "scarced starting"); + let config = config::Config::load(args.config.as_deref())?; + tracing::info!(bind = %config.bind, db = %config.db, "scarced starting"); - let db = studio_store::open(&config.db_url) + let db = studio_store::open(&config.db) .await - .with_context(|| format!("opening projection store at {}", config.db_url))?; + .with_context(|| format!("opening projection store at {}", config.db))?; + + spawn_quote_expiry_sweep(db.clone(), config.sweep_seconds); - let app = studio_api::router(Arc::new(AppState { db })); + // Lifecycle mirror: config-gated. Fail-closed at startup — a bad key or + // channel id refuses to boot rather than silently running ledger-only. + let lifecycle = match &config.buzz { + Some(buzz) => { + let port = studio_buzz::RelayBuzz::new( + &buzz.relay_url, + &buzz.private_key, + buzz.auth_tag.as_deref(), + ) + .context("building buzz relay port")?; + let ops_channel = uuid::Uuid::parse_str(&buzz.ops_channel) + .context("buzz.ops_channel is not a uuid")?; + tracing::info!(relay = %buzz.relay_url, ops_channel = %ops_channel, + studio_pubkey = %port.public_key_hex(), + "buzz lifecycle mirror enabled"); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + mirror::spawn(port, ops_channel, db.clone(), rx); + Some(tx) + } + None => { + tracing::warn!("no buzz section configured — studio runs ledger-only"); + None + } + }; + + let app = studio_api::router(Arc::new(AppState { + db, + studio_token: config.studio_token, + lifecycle, + })); let listener = tokio::net::TcpListener::bind(&config.bind) .await .with_context(|| format!("binding {}", config.bind))?; @@ -38,6 +83,24 @@ async fn main() -> anyhow::Result<()> { Ok(()) } +/// QUOTED → LAPSED, on a timer (PLAN.md M2). Reads derive LAPSED past +/// expiry on their own; the sweep stamps the projection rows so the ledger +/// itself carries the transition timestamps. +fn spawn_quote_expiry_sweep(db: sqlx::SqlitePool, interval_seconds: u64) { + tokio::spawn(async move { + let mut tick = tokio::time::interval(std::time::Duration::from_secs(interval_seconds)); + tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tick.tick().await; + match studio_store::quotes::sweep_lapsed(&db, chrono::Utc::now()).await { + Ok(0) => {} + Ok(lapsed) => tracing::info!(lapsed, "quote expiry sweep"), + Err(e) => tracing::error!(error = %e, "quote expiry sweep failed"), + } + } + }); +} + async fn shutdown_signal() { let ctrl_c = async { tokio::signal::ctrl_c() diff --git a/src/mirror.rs b/src/mirror.rs new file mode 100644 index 0000000..4ef65b5 --- /dev/null +++ b/src/mirror.rs @@ -0,0 +1,356 @@ +//! Lifecycle mirror — turns API beats into Buzz posts (PLAN.md M3, shortcut +//! per ludovic 2026-08-01: buzz visibility first, pay.sh intake later). +//! +//! demand captured / quote issued / quote accepted post to the studio ops +//! channel; acceptance additionally creates the per-project workroom channel +//! (`proj--`) and posts "contract starting" there. The +//! workroom's channel-create event id is stored as the FUNDED → +//! WORKROOM_ACTIVE evidence (ARCHITECTURE.md §evidence table). +//! +//! Best-effort by design in this slice: the projection row committed before +//! the beat was emitted, so a failed mirror never loses ledger state — it +//! loses a post, and logs loudly. Durable publish-then-commit arrives with +//! the substrate work (archy's replay design). + +use sqlx::SqlitePool; +use studio_api::LifecycleBeat; +use studio_buzz::BuzzPort; +use studio_types::{Quote, Rfq}; +use tokio::sync::mpsc::UnboundedReceiver; +use uuid::Uuid; + +pub fn spawn( + buzz: B, + ops_channel: Uuid, + db: SqlitePool, + mut rx: UnboundedReceiver, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + while let Some(beat) = rx.recv().await { + if let Err(e) = mirror_one(&buzz, ops_channel, &db, beat).await { + tracing::error!(error = %e, "lifecycle beat not mirrored to buzz"); + } + } + tracing::info!("lifecycle mirror stopped (sender closed)"); + }) +} + +async fn mirror_one( + buzz: &B, + ops_channel: Uuid, + db: &SqlitePool, + beat: LifecycleBeat, +) -> anyhow::Result<()> { + match beat { + LifecycleBeat::DemandCaptured { rfq } => { + let event_id = buzz.post(ops_channel, &demand_post(&rfq)).await?; + tracing::info!(rfq_id = %rfq.id, %event_id, "demand mirrored to ops channel"); + } + LifecycleBeat::QuoteIssued { quote } => { + let event_id = buzz.post(ops_channel, "e_post("e)).await?; + tracing::info!(rfq_id = %quote.rfq_id, %event_id, "quote mirrored to ops channel"); + } + LifecycleBeat::QuoteAccepted { rfq, quote } => { + buzz.post(ops_channel, &accepted_post(&rfq, "e)).await?; + + // Redelivery guard (restart replay): one workroom per rfq, ever. + if let Some(existing) = studio_store::workrooms::get_by_rfq(db, &rfq.id).await? { + tracing::info!(rfq_id = %rfq.id, channel_id = %existing.channel_id, + "workroom already exists; not recreating"); + return Ok(()); + } + + let name = workroom_name(&rfq); + let created = buzz + .create_channel(&name, &workroom_about(&rfq, "e)) + .await?; + studio_store::workrooms::record( + db, + &studio_store::workrooms::Workroom { + rfq_id: rfq.id.clone(), + channel_id: created.channel_id.to_string(), + create_event_id: created.create_event_id.clone(), + created_at: chrono::Utc::now(), + }, + ) + .await?; + // The workroom is private — the buyer must be a member to see + // it. Best-effort with its own loud log: a failed add must not + // lose the contract post or the evidence row above. + match studio_buzz::pubkey_hex(&rfq.buyer_npub) { + Ok(buyer_hex) => { + if let Err(e) = buzz.add_member(created.channel_id, &buyer_hex).await { + tracing::error!(error = %e, rfq_id = %rfq.id, + channel_id = %created.channel_id, "buyer not added to workroom"); + } + } + Err(e) => tracing::error!(error = %e, rfq_id = %rfq.id, + "buyer npub does not decode; not added to workroom"), + } + buzz.post(created.channel_id, &contract_post(&rfq, "e)) + .await?; + buzz.post( + ops_channel, + &format!( + "workroom `{name}` opened for rfq `{}` — channel {} (create event `{}`)", + rfq.id, created.channel_id, created.create_event_id + ), + ) + .await?; + tracing::info!(rfq_id = %rfq.id, channel_id = %created.channel_id, + create_event_id = %created.create_event_id, "workroom created — contract starting"); + } + } + Ok(()) +} + +fn workroom_about(rfq: &Rfq, quote: &Quote) -> String { + format!( + "Workroom for rfq {} — {} · {} (mint {}) · {}", + rfq.id, rfq.query, quote.price.amount, quote.price.mint, quote.timeline + ) +} + +/// `proj--`: slug from the demand query, short id for +/// uniqueness (channel names are not unique on the relay; the uuid is). +pub fn workroom_name(rfq: &Rfq) -> String { + let slug: String = rfq + .query + .to_lowercase() + .chars() + .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) + .collect::() + .split('-') + .filter(|s| !s.is_empty()) + .collect::>() + .join("-"); + let slug = slug.chars().take(32).collect::(); + let slug = slug.trim_end_matches('-'); + let short = rfq.id.chars().take(8).collect::(); + format!("proj-{slug}-{short}") +} + +fn budget_line(rfq: &Rfq) -> String { + match &rfq.budget_ceiling { + Some(amount) => format!("{} (mint `{}`)", amount.amount, amount.mint), + None => "unstated".into(), + } +} + +fn demand_post(rfq: &Rfq) -> String { + format!( + "📥 demand captured — rfq `{}`\n> {}\nbuyer `{}` · budget {}", + rfq.id, + rfq.query, + rfq.buyer_npub, + budget_line(rfq), + ) +} + +fn quote_post(quote: &Quote) -> String { + format!( + "💰 quote issued — rfq `{}`\nprice {} (mint `{}`) · {} milestone(s) · timeline: {}\nexpires {} · policy `{}`", + quote.rfq_id, + quote.price.amount, + quote.price.mint, + quote.milestones.len(), + quote.timeline, + quote.expires_at.to_rfc3339(), + quote.policy_hash, + ) +} + +fn accepted_post(rfq: &Rfq, quote: &Quote) -> String { + format!( + "✅ quote accepted — rfq `{}` at {}\nbuyer `{}` accepted {} (mint `{}`); funding stubbed (PLAN §6 override) — opening the workroom", + rfq.id, + quote + .accepted_at + .map(|t| t.to_rfc3339()) + .unwrap_or_else(|| "?".into()), + rfq.buyer_npub, + quote.price.amount, + quote.price.mint, + ) +} + +fn contract_post(rfq: &Rfq, quote: &Quote) -> String { + let milestones = quote + .milestones + .iter() + .enumerate() + .map(|(i, m)| format!("{}. {} — {} ({})", i + 1, m.title, m.description, m.amount)) + .collect::>() + .join("\n"); + format!( + "🚀 contract starting — rfq `{}`\n> {}\n\nmilestones:\n{}\n\ntimeline: {} · policy `{}`\nThis channel is the workroom: demos, decisions, and delivery land here.", + rfq.id, rfq.query, milestones, quote.timeline, quote.policy_hash, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use studio_buzz::{MockBuzz, MockCall}; + use studio_types::{Amount, QuoteStatus}; + + fn ts(s: &str) -> chrono::DateTime { + chrono::DateTime::parse_from_rfc3339(s) + .unwrap() + .with_timezone(&chrono::Utc) + } + + fn rfq() -> Rfq { + Rfq { + id: "9e342a83-429b-4887-9cae-6ddecd78f7c5".into(), + query: "Solana priority-fee forecast API!".into(), + product: None, + monetization: None, + competition: vec![], + budget_ceiling: None, + buyer_npub: "npub1cscv4empnwmfyurd6utlwmq3h3dzpesjyhtttt6rk69hndk9w0nqr65xpy".into(), + buyer_signature: None, + created_at: ts("2026-08-01T14:00:00Z"), + } + } + + fn quote() -> Quote { + Quote { + id: "q-1".into(), + rfq_id: rfq().id, + price: Amount { + amount: 250_000_000, + mint: "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".into(), + }, + milestones: vec![studio_types::MilestoneSpec { + title: "all of it".into(), + description: "one milestone".into(), + amount: 250_000_000, + }], + timeline: "2 weeks".into(), + payout_destination: studio_types::PayoutDestination::Splits { + splits: vec![studio_types::Split { + recipient: "CrewAgentA111111111111111111111111111111111".into(), + bps: 10_000, + }], + }, + channel: studio_types::ChannelParams { + grace_seconds: 172_800, + idle_timeout_seconds: 604_800, + }, + gate_policy: studio_types::GatePolicy::studio_default(), + policy_hash: "hash".into(), + expires_at: ts("2026-09-01T00:00:00Z"), + status: QuoteStatus::Accepted, + created_at: ts("2026-08-01T15:00:00Z"), + lapsed_at: None, + accepted_at: Some(ts("2026-08-01T16:00:00Z")), + } + } + + #[test] + fn workroom_name_is_slugged_and_bounded() { + assert_eq!( + workroom_name(&rfq()), + "proj-solana-priority-fee-forecast-api-9e342a83" + ); + let mut long = rfq(); + long.query = "x".repeat(500); + let name = workroom_name(&long); + assert!(name.len() <= "proj-".len() + 32 + 1 + 8); + } + + #[tokio::test] + async fn accepted_beat_creates_workroom_once_with_evidence() { + let db = studio_store::open("sqlite::memory:").await.unwrap(); + studio_store::rfqs::insert(&db, &rfq()).await.unwrap(); + let buzz = MockBuzz::default(); + let ops = Uuid::from_u128(999); + + mirror_one( + &buzz, + ops, + &db, + LifecycleBeat::QuoteAccepted { + rfq: Box::new(rfq()), + quote: Box::new(quote()), + }, + ) + .await + .unwrap(); + + let calls = buzz.calls.lock().unwrap().clone(); + assert_eq!(calls.len(), 5, "{calls:#?}"); + assert!(matches!(&calls[0], MockCall::Post { channel_id, .. } if *channel_id == ops)); + assert!( + matches!(&calls[1], MockCall::CreateChannel { name, .. } if name.starts_with("proj-solana-priority")) + ); + // the buyer is added to the (private) workroom before the contract post + let expected_buyer = studio_buzz::pubkey_hex(&rfq().buyer_npub).unwrap(); + assert!( + matches!(&calls[2], MockCall::AddMember { channel_id, pubkey_hex } + if *channel_id != ops && *pubkey_hex == expected_buyer) + ); + // contract post lands in the NEW channel, not ops + assert!(matches!(&calls[3], MockCall::Post { channel_id, .. } if *channel_id != ops)); + assert!(matches!(&calls[4], MockCall::Post { channel_id, .. } if *channel_id == ops)); + + // evidence row: channel-create event id recorded + let workroom = studio_store::workrooms::get_by_rfq(&db, &rfq().id) + .await + .unwrap() + .expect("workroom recorded"); + assert_eq!(workroom.create_event_id, "mock-create-event-2"); + + // redelivery: no second channel + drop(calls); + mirror_one( + &buzz, + ops, + &db, + LifecycleBeat::QuoteAccepted { + rfq: Box::new(rfq()), + quote: Box::new(quote()), + }, + ) + .await + .unwrap(); + let calls = buzz.calls.lock().unwrap(); + assert_eq!(calls.len(), 6, "only the ops accepted-post repeats"); + assert!(matches!(&calls[4], MockCall::Post { .. })); + } + + #[tokio::test] + async fn demand_and_quote_beats_post_to_ops() { + let db = studio_store::open("sqlite::memory:").await.unwrap(); + let buzz = MockBuzz::default(); + let ops = Uuid::from_u128(999); + + mirror_one( + &buzz, + ops, + &db, + LifecycleBeat::DemandCaptured { + rfq: Box::new(rfq()), + }, + ) + .await + .unwrap(); + mirror_one( + &buzz, + ops, + &db, + LifecycleBeat::QuoteIssued { + quote: Box::new(quote()), + }, + ) + .await + .unwrap(); + + let calls = buzz.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + for call in calls.iter() { + assert!(matches!(call, MockCall::Post { channel_id, .. } if *channel_id == ops)); + } + } +}