commit 6e11763abfd8796558230e92f12a9fd390cfe0ee
parent 758ab117f9d94130389d2f0372117625a1ec8563
Author: triesap <tyson@radroots.org>
Date: Mon, 24 Aug 2026 21:25:33 +0000
runtime: execute the governed RHI daemon
Diffstat:
41 files changed, 9876 insertions(+), 534 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -94,7 +94,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -105,7 +105,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -121,6 +121,37 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
[[package]]
+name = "async-utility"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "188f83b9a198af8c336e505611edb00d6d2ac5c694241c5a4f9a12316938cfe9"
+dependencies = [
+ "futures-util",
+ "gloo-timers",
+ "tokio",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "async-wsocket"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069"
+dependencies = [
+ "async-utility",
+ "futures",
+ "futures-util",
+ "js-sys",
+ "tokio",
+ "tokio-rustls",
+ "tokio-socks",
+ "tokio-tungstenite",
+ "url",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
name = "atoi"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -130,6 +161,12 @@ dependencies = [
]
[[package]]
+name = "atomic-destructor"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4"
+
+[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -423,7 +460,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
"zeroize",
]
@@ -435,7 +472,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [
"generic-array",
- "rand_core",
+ "rand_core 0.6.4",
"typenum",
]
@@ -503,7 +540,7 @@ dependencies = [
"ff",
"generic-array",
"group",
- "rand_core",
+ "rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
@@ -531,7 +568,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -567,7 +604,7 @@ version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
]
@@ -811,13 +848,25 @@ dependencies = [
]
[[package]]
+name = "gloo-timers"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
]
@@ -1270,6 +1319,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
+name = "lru"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
+
+[[package]]
name = "mediatype"
version = "0.21.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1305,10 +1360,16 @@ checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
dependencies = [
"libc",
"wasi",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
+name = "negentropy"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81c353b400a5503efdcf398f11a83fb7aa84f59f5d76fc4bf5bbc1e4f5366caa"
+
+[[package]]
name = "nostr"
version = "0.44.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1335,6 +1396,59 @@ dependencies = [
]
[[package]]
+name = "nostr-database"
+version = "0.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1"
+dependencies = [
+ "lru",
+ "nostr",
+ "tokio",
+]
+
+[[package]]
+name = "nostr-gossip"
+version = "0.44.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6"
+dependencies = [
+ "nostr",
+]
+
+[[package]]
+name = "nostr-relay-pool"
+version = "0.44.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1"
+dependencies = [
+ "async-utility",
+ "async-wsocket",
+ "atomic-destructor",
+ "hex",
+ "lru",
+ "negentropy",
+ "nostr",
+ "nostr-database",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "nostr-sdk"
+version = "0.44.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393"
+dependencies = [
+ "async-utility",
+ "nostr",
+ "nostr-database",
+ "nostr-gossip",
+ "nostr-relay-pool",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
name = "num"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1472,7 +1586,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
- "rand_core",
+ "rand_core 0.6.4",
"subtle",
]
@@ -1760,14 +1874,44 @@ dependencies = [
]
[[package]]
+name = "radroots_transport_nostr"
+version = "0.1.0-alpha"
+source = "git+https://github.com/radrootslabs/lib?rev=21b11e7a5120ea949f7ad0838c746873fc73aac2#21b11e7a5120ea949f7ad0838c746873fc73aac2"
+dependencies = [
+ "async-wsocket",
+ "futures",
+ "nostr-relay-pool",
+ "nostr-sdk",
+ "radroots_event_codec",
+ "radroots_nostr",
+ "radroots_protocol",
+ "radroots_transport",
+ "serde_json",
+ "sha2",
+ "tokio",
+ "tokio-tungstenite",
+ "url",
+]
+
+[[package]]
name = "rand"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
dependencies = [
"libc",
- "rand_chacha",
- "rand_core",
+ "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]]
@@ -1777,7 +1921,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [
"ppv-lite86",
- "rand_core",
+ "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]]
@@ -1790,6 +1944,15 @@ dependencies = [
]
[[package]]
+name = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1868,9 +2031,11 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
name = "rhi"
version = "0.1.0"
dependencies = [
+ "base64",
"chacha20poly1305",
"clap",
"futures-executor",
+ "hmac",
"jsonschema",
"nostr",
"radroots_event",
@@ -1884,6 +2049,7 @@ dependencies = [
"radroots_storage",
"radroots_trade",
"radroots_transport",
+ "radroots_transport_nostr",
"rustix",
"serde",
"serde_json",
@@ -1893,6 +2059,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"toml",
+ "tungstenite",
"url",
"zeroize",
]
@@ -1913,6 +2080,20 @@ dependencies = [
]
[[package]]
+name = "ring"
+version = "0.17.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
+dependencies = [
+ "cc",
+ "cfg-if",
+ "getrandom 0.2.17",
+ "libc",
+ "untrusted",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
name = "rust_decimal"
version = "1.40.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1933,7 +2114,41 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
- "windows-sys",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
]
[[package]]
@@ -1988,7 +2203,7 @@ version = "0.29.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113"
dependencies = [
- "rand",
+ "rand 0.8.5",
"secp256k1-sys",
"serde",
]
@@ -2061,6 +2276,17 @@ dependencies = [
]
[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
name = "sha2"
version = "0.10.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2078,6 +2304,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
+name = "signal-hook-registry"
+version = "1.4.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
+dependencies = [
+ "errno",
+ "libc",
+]
+
+[[package]]
name = "simd-adler32"
version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2102,7 +2338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2311,7 +2547,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2389,9 +2625,10 @@ dependencies = [
"libc",
"mio",
"pin-project-lite",
+ "signal-hook-registry",
"socket2",
"tokio-macros",
- "windows-sys",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -2406,6 +2643,28 @@ dependencies = [
]
[[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-socks"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7e2948f60dbe26b35f2c7fb74ac2854c1fddded0fe9d7548fcc674a246f7615"
+dependencies = [
+ "either",
+ "futures-util",
+ "thiserror 1.0.69",
+ "tokio",
+]
+
+[[package]]
name = "tokio-stream"
version = "0.1.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2417,6 +2676,22 @@ dependencies = [
]
[[package]]
+name = "tokio-tungstenite"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084"
+dependencies = [
+ "futures-util",
+ "log",
+ "rustls",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls",
+ "tungstenite",
+ "webpki-roots 0.26.11",
+]
+
+[[package]]
name = "tokio-util"
version = "0.7.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2510,6 +2785,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
+name = "tungstenite"
+version = "0.26.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http",
+ "httparse",
+ "log",
+ "rand 0.9.5",
+ "rustls",
+ "rustls-pki-types",
+ "sha1",
+ "thiserror 2.0.18",
+ "utf-8",
+]
+
+[[package]]
name = "typenum"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2559,6 +2853,12 @@ dependencies = [
]
[[package]]
+name = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2584,6 +2884,12 @@ dependencies = [
]
[[package]]
+name = "utf-8"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
+
+[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2670,6 +2976,20 @@ dependencies = [
]
[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.64"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2746,6 +3066,24 @@ dependencies = [
]
[[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"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
name = "winapi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2775,6 +3113,15 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[package]]
+name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
@@ -2783,6 +3130,70 @@ dependencies = [
]
[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
name = "winnow"
version = "0.7.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/Cargo.toml b/Cargo.toml
@@ -20,7 +20,7 @@ service = "rhi"
host_feature_profile = "service-host"
nix_material = "absent"
config_contract_version = 1
-state_contract_version = 7
+state_contract_version = 11
admin_contract_version = 1
status_contract_version = 1
provider_contract_version = 1
@@ -51,6 +51,7 @@ service-host = []
workspace = true
[dependencies]
+base64 = "0.22"
radroots_event = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", features = ["serde"] }
radroots_event_codec = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", features = ["json"] }
radroots_nostr = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", features = ["events"] }
@@ -60,12 +61,14 @@ radroots_service_host = { git = "https://github.com/radrootslabs/lib", rev = "21
radroots_service_sqlite = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha" }
radroots_storage = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", default-features = false }
radroots_transport = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha", default-features = false, features = ["std"] }
+radroots_transport_nostr = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha" }
radroots_secrets = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha" }
radroots_trade = { git = "https://github.com/radrootslabs/lib", rev = "21b11e7a5120ea949f7ad0838c746873fc73aac2", version = "=0.1.0-alpha" }
chacha20poly1305 = { version = "0.10" }
clap = { version = "4", features = ["derive"] }
futures-executor = { version = "0.3" }
+hmac = { version = "0.12" }
jsonschema = { version = "0.48.1", default-features = false }
nostr = { version = "0.44.7" }
rustix = { version = "1", features = ["fs", "process", "std"] }
@@ -74,14 +77,14 @@ serde_json = { version = "1", default-features = false, features = ["raw_value"]
sha2 = { version = "0.10" }
sqlx = { version = "0.9.0", default-features = false, features = ["sqlite-bundled"] }
thiserror = { version = "2" }
-tokio = { version = "1", default-features = false, features = ["time"] }
+tokio = { version = "1", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] }
tempfile = { version = "3" }
toml = { version = "0.8" }
url = "2"
zeroize = { version = "1" }
[dev-dependencies]
-tokio = { version = "1", default-features = false, features = ["io-util", "macros", "net", "rt-multi-thread"] }
+tungstenite = "0.26"
[profile.release]
lto = "thin"
diff --git a/contracts/api_baselines/rhi.txt b/contracts/api_baselines/rhi.txt
@@ -120,6 +120,7 @@ pub rhi::RhiCliPrimaryAuthorityV1::LiveUnixAdmin
pub rhi::RhiCliPrimaryAuthorityV1::Offline
pub enum rhi::RhiCliV1ErrorKind
pub rhi::RhiCliV1ErrorKind::InvalidArguments
+pub rhi::RhiCliV1ErrorKind::InvalidCommandInput
pub rhi::RhiCliV1ErrorKind::InvalidConfigPath
pub rhi::RhiCliV1ErrorKind::InvalidInstance
pub rhi::RhiCliV1ErrorKind::InvalidRepoLocalRoot
@@ -137,6 +138,8 @@ pub rhi::RhiCommandV1::Sources(rhi::RhiSourcesCommandV1)
pub rhi::RhiCommandV1::State(rhi::RhiStateCommandV1)
pub rhi::RhiCommandV1::Status
pub rhi::RhiCommandV1::Trade(rhi::RhiTradeCommandV1)
+impl core::fmt::Debug for rhi::RhiCommandV1
+pub fn rhi::RhiCommandV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub enum rhi::RhiConfigApplyErrorKind
pub rhi::RhiConfigApplyErrorKind::Binding
pub rhi::RhiConfigApplyErrorKind::Close
@@ -147,7 +150,7 @@ pub rhi::RhiConfigApplyErrorKind::Transaction
impl rhi::RhiConfigApplyErrorKind
pub const fn rhi::RhiConfigApplyErrorKind::code(self) -> &'static str
pub enum rhi::RhiConfigCommandV1
-pub rhi::RhiConfigCommandV1::Apply
+pub rhi::RhiConfigCommandV1::Apply(rhi::RhiConfigApplyArgsV1)
pub rhi::RhiConfigCommandV1::Init
pub rhi::RhiConfigCommandV1::Schema
pub rhi::RhiConfigCommandV1::Show
@@ -159,6 +162,16 @@ pub rhi::RhiConfigDefaultAuthority::RadrootsEvent
pub rhi::RhiConfigDefaultAuthority::RadrootsServiceHost
pub rhi::RhiConfigDefaultAuthority::RadrootsServiceSqlite
pub rhi::RhiConfigDefaultAuthority::RhiEvidencePolicy
+pub enum rhi::RhiConfigLoadErrorKind
+pub rhi::RhiConfigLoadErrorKind::AlreadyExists
+pub rhi::RhiConfigLoadErrorKind::InsecureArtifact
+pub rhi::RhiConfigLoadErrorKind::InsecureParent
+pub rhi::RhiConfigLoadErrorKind::InvalidDocument
+pub rhi::RhiConfigLoadErrorKind::InvalidPath
+pub rhi::RhiConfigLoadErrorKind::Io
+pub rhi::RhiConfigLoadErrorKind::Missing
+pub rhi::RhiConfigLoadErrorKind::TooLarge
+pub rhi::RhiConfigLoadErrorKind::UnsupportedPlatform
pub enum rhi::RhiConfigProfile
pub rhi::RhiConfigProfile::Production
pub rhi::RhiConfigProfile::RepoLocal
@@ -271,6 +284,22 @@ pub enum rhi::RhiIntegrityStateV1
pub rhi::RhiIntegrityStateV1::Failed
pub rhi::RhiIntegrityStateV1::VerificationRequired
pub rhi::RhiIntegrityStateV1::Verified
+pub enum rhi::RhiLogEvent
+pub rhi::RhiLogEvent::CriticalTaskFailed
+pub rhi::RhiLogEvent::Lifecycle
+pub rhi::RhiLogEvent::ProcessResult
+pub rhi::RhiLogEvent::ShutdownForced
+pub rhi::RhiLogEvent::ShutdownRequested
+impl rhi::RhiLogEvent
+pub const fn rhi::RhiLogEvent::as_str(self) -> &'static str
+pub enum rhi::RhiLogLevel
+pub rhi::RhiLogLevel::Debug
+pub rhi::RhiLogLevel::Error
+pub rhi::RhiLogLevel::Info
+pub rhi::RhiLogLevel::Trace
+pub rhi::RhiLogLevel::Warn
+impl rhi::RhiLogLevel
+pub const fn rhi::RhiLogLevel::as_str(self) -> &'static str
pub enum rhi::RhiMetricsCommandV1
pub rhi::RhiMetricsCommandV1::Snapshot
pub enum rhi::RhiOperationsErrorKind
@@ -299,8 +328,8 @@ impl rhi::RhiPresenceAttemptOutcome
pub const fn rhi::RhiPresenceAttemptOutcome::code(self) -> &'static str
pub enum rhi::RhiPresenceCommandV1
pub rhi::RhiPresenceCommandV1::Desired
-pub rhi::RhiPresenceCommandV1::Refresh
-pub rhi::RhiPresenceCommandV1::Render
+pub rhi::RhiPresenceCommandV1::Refresh(rhi::RhiPresenceMutationArgsV1)
+pub rhi::RhiPresenceCommandV1::Render(rhi::RhiPresenceMutationArgsV1)
pub enum rhi::RhiPresenceDesiredErrorKind
pub rhi::RhiPresenceDesiredErrorKind::Binding
pub rhi::RhiPresenceDesiredErrorKind::CommitOutcomeUnknown
@@ -368,6 +397,13 @@ impl rhi::RhiProcessResult
pub const fn rhi::RhiProcessResult::code(self) -> &'static str
pub fn rhi::RhiProcessResult::exit_code(self) -> std::process::ExitCode
pub const fn rhi::RhiProcessResult::exit_code_u8(self) -> u8
+pub enum rhi::RhiProcessSignal
+pub rhi::RhiProcessSignal::Interrupt
+pub rhi::RhiProcessSignal::Terminate
+impl rhi::RhiProcessSignal
+pub const fn rhi::RhiProcessSignal::as_str(self) -> &'static str
+impl core::fmt::Display for rhi::RhiProcessSignal
+pub fn rhi::RhiProcessSignal::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub enum rhi::RhiProviderHealthV1
pub rhi::RhiProviderHealthV1::Ready
pub rhi::RhiProviderHealthV1::Unavailable
@@ -389,9 +425,9 @@ impl rhi::RhiPublicationAttemptOutcome
pub const fn rhi::RhiPublicationAttemptOutcome::code(self) -> &'static str
pub const fn rhi::RhiPublicationAttemptOutcome::target_state(self) -> rhi::RhiPublicationTargetState
pub enum rhi::RhiPublicationCommandV1
-pub rhi::RhiPublicationCommandV1::Backlog
-pub rhi::RhiPublicationCommandV1::Retry
-pub rhi::RhiPublicationCommandV1::Targets
+pub rhi::RhiPublicationCommandV1::Backlog(rhi::RhiPageQueryArgsV1)
+pub rhi::RhiPublicationCommandV1::Retry(rhi::RhiPublicationRetryArgsV1)
+pub rhi::RhiPublicationCommandV1::Targets(rhi::RhiPageQueryArgsV1)
pub enum rhi::RhiPublicationErrorKind
pub rhi::RhiPublicationErrorKind::InvalidConfiguration
pub rhi::RhiPublicationErrorKind::TargetInventory
@@ -459,8 +495,8 @@ pub rhi::RhiReconciliationAttestationErrorKind::VerificationFailed
impl rhi::RhiReconciliationAttestationErrorKind
pub const fn rhi::RhiReconciliationAttestationErrorKind::code(self) -> &'static str
pub enum rhi::RhiReconciliationCommandV1
-pub rhi::RhiReconciliationCommandV1::Jobs
-pub rhi::RhiReconciliationCommandV1::Refresh
+pub rhi::RhiReconciliationCommandV1::Jobs(rhi::RhiPageQueryArgsV1)
+pub rhi::RhiReconciliationCommandV1::Refresh(rhi::RhiReconciliationRefreshArgsV1)
pub rhi::RhiReconciliationCommandV1::Status
pub enum rhi::RhiReconciliationCommitErrorKind
pub rhi::RhiReconciliationCommitErrorKind::CommitOutcomeUnknown
@@ -605,7 +641,7 @@ pub rhi::RhiServicePhase::Starting
pub rhi::RhiServicePhase::Stopping
pub rhi::RhiServicePhase::Unready
pub enum rhi::RhiSourcesCommandV1
-pub rhi::RhiSourcesCommandV1::List
+pub rhi::RhiSourcesCommandV1::List(rhi::RhiPageQueryArgsV1)
pub enum rhi::RhiStateCatalogErrorKind
pub rhi::RhiStateCatalogErrorKind::CatalogMismatch
pub rhi::RhiStateCatalogErrorKind::MigrationCatalog
@@ -613,10 +649,10 @@ pub rhi::RhiStateCatalogErrorKind::SchemaCatalog
impl rhi::RhiStateCatalogErrorKind
pub const fn rhi::RhiStateCatalogErrorKind::code(self) -> &'static str
pub enum rhi::RhiStateCommandV1
-pub rhi::RhiStateCommandV1::Backup
+pub rhi::RhiStateCommandV1::Backup(rhi::RhiStateBackupArgsV1)
pub rhi::RhiStateCommandV1::Init
pub rhi::RhiStateCommandV1::Migrate
-pub rhi::RhiStateCommandV1::Restore
+pub rhi::RhiStateCommandV1::Restore(rhi::RhiStateRestoreArgsV1)
pub rhi::RhiStateCommandV1::Status
pub rhi::RhiStateCommandV1::Verify
pub enum rhi::RhiStateHostErrorKind
@@ -728,9 +764,9 @@ impl rhi::RhiStatusReasonCode
pub const fn rhi::RhiStatusReasonCode::as_str(self) -> &'static str
pub fn rhi::RhiStatusReasonCode::new(impl core::convert::AsRef<str>) -> core::result::Result<Self, rhi::RhiStatusError>
pub enum rhi::RhiTradeCommandV1
-pub rhi::RhiTradeCommandV1::Projection
-pub rhi::RhiTradeCommandV1::ReportCurrent
-pub rhi::RhiTradeCommandV1::Reports
+pub rhi::RhiTradeCommandV1::Projection(rhi::RhiTradeArgsV1)
+pub rhi::RhiTradeCommandV1::ReportCurrent(rhi::RhiTradeArgsV1)
+pub rhi::RhiTradeCommandV1::Reports(rhi::RhiTradePageArgsV1)
pub enum rhi::RhiTradeEvidencePersistenceErrorKind
pub rhi::RhiTradeEvidencePersistenceErrorKind::CommitOutcomeUnknown
pub rhi::RhiTradeEvidencePersistenceErrorKind::Encoding
@@ -915,7 +951,7 @@ impl core::fmt::Debug for rhi::RhiCliExecutionPlanV1
pub fn rhi::RhiCliExecutionPlanV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiCliInvocationV1
impl rhi::RhiCliInvocationV1
-pub const fn rhi::RhiCliInvocationV1::command(&self) -> rhi::RhiCommandV1
+pub const fn rhi::RhiCliInvocationV1::command(&self) -> &rhi::RhiCommandV1
pub fn rhi::RhiCliInvocationV1::config_path(&self) -> core::option::Option<&std::path::Path>
pub const fn rhi::RhiCliInvocationV1::instance(&self) -> &radroots_runtime_paths::identifier::InstanceId
pub const fn rhi::RhiCliInvocationV1::output_mode(&self) -> rhi::RhiCliOutputModeV1
@@ -939,6 +975,11 @@ pub const fn rhi::RhiCommittedPublication::exact_signed_event_bytes(&self) -> &[
pub const fn rhi::RhiCommittedPublication::outbox_id(&self) -> rhi::RhiPublicationOutboxId
impl core::fmt::Debug for rhi::RhiCommittedPublication
pub fn rhi::RhiCommittedPublication::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiConfigApplyArgsV1
+impl rhi::RhiConfigApplyArgsV1
+pub fn rhi::RhiConfigApplyArgsV1::candidate_config(&self) -> &std::path::Path
+impl core::fmt::Debug for rhi::RhiConfigApplyArgsV1
+pub fn rhi::RhiConfigApplyArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiConfigApplyError
impl rhi::RhiConfigApplyError
pub const fn rhi::RhiConfigApplyError::code(self) -> &'static str
@@ -965,6 +1006,14 @@ pub const fn rhi::RhiConfigDocumentV1::schema(&self) -> &'static str
pub const fn rhi::RhiConfigDocumentV1::schema_version(&self) -> u32
impl core::fmt::Debug for rhi::RhiConfigDocumentV1
pub fn rhi::RhiConfigDocumentV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiConfigLoadError
+impl rhi::RhiConfigLoadError
+pub const fn rhi::RhiConfigLoadError::kind(self) -> rhi::RhiConfigLoadErrorKind
+impl core::error::Error for rhi::RhiConfigLoadError
+impl core::fmt::Debug for rhi::RhiConfigLoadError
+pub fn rhi::RhiConfigLoadError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::fmt::Display for rhi::RhiConfigLoadError
+pub fn rhi::RhiConfigLoadError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiConfigV1Error
impl rhi::RhiConfigV1Error
pub const fn rhi::RhiConfigV1Error::kind(self) -> rhi::RhiConfigV1ErrorKind
@@ -1111,6 +1160,21 @@ pub struct rhi::RhiJitterMilliseconds(_)
impl rhi::RhiJitterMilliseconds
pub const fn rhi::RhiJitterMilliseconds::duration(self) -> core::time::Duration
pub const fn rhi::RhiJitterMilliseconds::get(self) -> u64
+pub struct rhi::RhiLogRecord
+impl rhi::RhiLogRecord
+pub const fn rhi::RhiLogRecord::code(&self) -> &'static str
+pub const fn rhi::RhiLogRecord::critical_task_failed() -> Self
+pub const fn rhi::RhiLogRecord::event(&self) -> rhi::RhiLogEvent
+pub const fn rhi::RhiLogRecord::level(&self) -> rhi::RhiLogLevel
+pub const fn rhi::RhiLogRecord::lifecycle(rhi::RhiServicePhase) -> Self
+pub const fn rhi::RhiLogRecord::process_exit(&self) -> core::option::Option<rhi::RhiProcessResult>
+pub const fn rhi::RhiLogRecord::process_result(rhi::RhiProcessResult) -> Self
+pub const fn rhi::RhiLogRecord::shutdown_forced() -> Self
+pub const fn rhi::RhiLogRecord::shutdown_requested() -> Self
+impl core::fmt::Debug for rhi::RhiLogRecord
+pub fn rhi::RhiLogRecord::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+impl core::fmt::Display for rhi::RhiLogRecord
+pub fn rhi::RhiLogRecord::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiMutationRepository<'host>
impl rhi::RhiMutationRepository<'_>
pub const fn rhi::RhiMutationRepository<'_>::descriptor(&self) -> rhi::RhiStateRepositoryDescriptor
@@ -1144,6 +1208,12 @@ pub async fn rhi::RhiOperationsServer::bind(self) -> core::result::Result<rhi::R
pub fn rhi::RhiOperationsServer::new(&rhi::RhiConfigDocumentV1, &rhi::RhiStatusReader) -> core::result::Result<Self, rhi::RhiOperationsError>
impl core::fmt::Debug for rhi::RhiOperationsServer
pub fn rhi::RhiOperationsServer::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiPageQueryArgsV1
+impl rhi::RhiPageQueryArgsV1
+pub fn rhi::RhiPageQueryArgsV1::cursor(&self) -> core::option::Option<&str>
+pub const fn rhi::RhiPageQueryArgsV1::limit(&self) -> u16
+impl core::fmt::Debug for rhi::RhiPageQueryArgsV1
+pub fn rhi::RhiPageQueryArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiPersistenceStatusV1
impl rhi::RhiPersistenceStatusV1
pub fn rhi::RhiPersistenceStatusV1::new(rhi::RhiPersistenceHealthV1, u32, u64, rhi::RhiIntegrityStateV1, rhi::RhiStatusReasonCodes) -> core::result::Result<Self, rhi::RhiStatusError>
@@ -1240,6 +1310,12 @@ impl rhi::RhiPresenceLeaseOwner
pub fn rhi::RhiPresenceLeaseOwner::from_bytes([u8; 16]) -> core::result::Result<Self, rhi::RhiPresencePublicationError>
impl core::fmt::Debug for rhi::RhiPresenceLeaseOwner
pub fn rhi::RhiPresenceLeaseOwner::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiPresenceMutationArgsV1
+impl rhi::RhiPresenceMutationArgsV1
+pub const fn rhi::RhiPresenceMutationArgsV1::expected_generation(&self) -> u64
+pub fn rhi::RhiPresenceMutationArgsV1::operation_id(&self) -> &str
+impl core::fmt::Debug for rhi::RhiPresenceMutationArgsV1
+pub fn rhi::RhiPresenceMutationArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiPresenceOutboxId(_)
impl rhi::RhiPresenceOutboxId
pub const fn rhi::RhiPresenceOutboxId::as_bytes(&self) -> &[u8; 32]
@@ -1416,6 +1492,13 @@ impl rhi::RhiPublicationOutboxRepository<'_>
pub async fn rhi::RhiPublicationOutboxRepository<'_>::read_committed_publication(&self, rhi::RhiPublicationOutboxId) -> core::result::Result<rhi::RhiCommittedPublication, rhi::RhiPublicationSubmissionError>
impl core::fmt::Debug for rhi::RhiPublicationOutboxRepository<'_>
pub fn rhi::RhiPublicationOutboxRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiPublicationRetryArgsV1
+impl rhi::RhiPublicationRetryArgsV1
+pub const fn rhi::RhiPublicationRetryArgsV1::expected_generation(&self) -> u64
+pub fn rhi::RhiPublicationRetryArgsV1::operation_id(&self) -> &str
+pub fn rhi::RhiPublicationRetryArgsV1::workflow_id(&self) -> &str
+impl core::fmt::Debug for rhi::RhiPublicationRetryArgsV1
+pub fn rhi::RhiPublicationRetryArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiPublicationRetryDelayMilliseconds(_)
impl rhi::RhiPublicationRetryDelayMilliseconds
pub const fn rhi::RhiPublicationRetryDelayMilliseconds::get(self) -> u64
@@ -1671,6 +1754,13 @@ impl core::fmt::Debug for rhi::RhiReconciliationReducerError
pub fn rhi::RhiReconciliationReducerError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
impl core::fmt::Display for rhi::RhiReconciliationReducerError
pub fn rhi::RhiReconciliationReducerError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiReconciliationRefreshArgsV1
+impl rhi::RhiReconciliationRefreshArgsV1
+pub const fn rhi::RhiReconciliationRefreshArgsV1::expected_dirty_generation(&self) -> u64
+pub fn rhi::RhiReconciliationRefreshArgsV1::operation_id(&self) -> &str
+pub fn rhi::RhiReconciliationRefreshArgsV1::trade_id(&self) -> &str
+impl core::fmt::Debug for rhi::RhiReconciliationRefreshArgsV1
+pub fn rhi::RhiReconciliationRefreshArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiReconciliationReplayError
impl rhi::RhiReconciliationReplayError
pub const fn rhi::RhiReconciliationReplayError::code(self) -> &'static str
@@ -1820,7 +1910,7 @@ impl core::fmt::Display for rhi::RhiRuntimeContextError
pub fn rhi::RhiRuntimeContextError::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiRuntimeFoundation
impl rhi::RhiRuntimeFoundation
-pub const fn rhi::RhiRuntimeFoundation::configuration(&self) -> &rhi::RhiConfigDocumentV1
+pub fn rhi::RhiRuntimeFoundation::configuration(&self) -> &rhi::RhiConfigDocumentV1
pub const fn rhi::RhiRuntimeFoundation::metadata(&self) -> &rhi::RhiStateMetadata
pub const fn rhi::RhiRuntimeFoundation::readiness(&self) -> &rhi::RhiRuntimeReadiness
pub const fn rhi::RhiRuntimeFoundation::runtime_context(&self) -> &rhi::RhiRuntimeContext
@@ -1911,6 +2001,13 @@ pub fn rhi::RhiSourceRepository<'_>::fmt(&self, &mut core::fmt::Formatter<'_>) -
pub struct rhi::RhiStagedStateRestore
impl core::fmt::Debug for rhi::RhiStagedStateRestore
pub fn rhi::RhiStagedStateRestore::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiStateBackupArgsV1
+impl rhi::RhiStateBackupArgsV1
+pub const fn rhi::RhiStateBackupArgsV1::expected_generation(&self) -> u64
+pub fn rhi::RhiStateBackupArgsV1::operation_id(&self) -> &str
+pub fn rhi::RhiStateBackupArgsV1::target(&self) -> &std::path::Path
+impl core::fmt::Debug for rhi::RhiStateBackupArgsV1
+pub fn rhi::RhiStateBackupArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiStateCatalogError
impl rhi::RhiStateCatalogError
pub const fn rhi::RhiStateCatalogError::code(self) -> &'static str
@@ -2010,6 +2107,14 @@ pub const fn rhi::RhiStateRepositoryDescriptor::kind(self) -> rhi::RhiStateRepos
pub const fn rhi::RhiStateRepositoryDescriptor::write_class(self) -> rhi::RhiStateRepositoryWriteClass
impl core::fmt::Debug for rhi::RhiStateRepositoryDescriptor
pub fn rhi::RhiStateRepositoryDescriptor::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiStateRestoreArgsV1
+impl rhi::RhiStateRestoreArgsV1
+pub fn rhi::RhiStateRestoreArgsV1::bundle(&self) -> &std::path::Path
+pub fn rhi::RhiStateRestoreArgsV1::manifest(&self) -> &std::path::Path
+pub fn rhi::RhiStateRestoreArgsV1::manifest_sha256(&self) -> &str
+pub const fn rhi::RhiStateRestoreArgsV1::maximum_state_bytes(&self) -> u64
+impl core::fmt::Debug for rhi::RhiStateRestoreArgsV1
+pub fn rhi::RhiStateRestoreArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiStatusBuildInfoV1
impl rhi::RhiStatusBuildInfoV1
pub fn rhi::RhiStatusBuildInfoV1::new(rhi::RhiStatusBuildMode, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>, core::option::Option<&str>) -> core::result::Result<Self, rhi::RhiStatusError>
@@ -2083,8 +2188,15 @@ pub fn rhi::RhiTimeEntropyAdapters::now_monotonic(&self) -> radroots_service_hos
pub fn rhi::RhiTimeEntropyAdapters::now_utc(&self) -> core::result::Result<radroots_service_host::time::UnixTimeSeconds, rhi::RhiRuntimeAdapterError>
pub fn rhi::RhiTimeEntropyAdapters::sample_full_jitter(&self, rhi::RhiJitterBoundMilliseconds) -> core::result::Result<rhi::RhiJitterMilliseconds, rhi::RhiRuntimeAdapterError>
pub fn rhi::RhiTimeEntropyAdapters::system() -> Self
+impl core::clone::Clone for rhi::RhiTimeEntropyAdapters
+pub fn rhi::RhiTimeEntropyAdapters::clone(&self) -> Self
impl core::fmt::Debug for rhi::RhiTimeEntropyAdapters
pub fn rhi::RhiTimeEntropyAdapters::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
+pub struct rhi::RhiTradeArgsV1
+impl rhi::RhiTradeArgsV1
+pub fn rhi::RhiTradeArgsV1::trade_id(&self) -> &str
+impl core::fmt::Debug for rhi::RhiTradeArgsV1
+pub fn rhi::RhiTradeArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiTradeDirtyGeneration(_)
impl rhi::RhiTradeDirtyGeneration
pub const fn rhi::RhiTradeDirtyGeneration::get(self) -> u64
@@ -2131,6 +2243,12 @@ pub struct rhi::RhiTradeMutationObservedAtUnixSeconds(_)
impl rhi::RhiTradeMutationObservedAtUnixSeconds
pub const fn rhi::RhiTradeMutationObservedAtUnixSeconds::get(self) -> u64
pub fn rhi::RhiTradeMutationObservedAtUnixSeconds::new(u64) -> core::result::Result<Self, rhi::RhiTradeMutationAdmissionError>
+pub struct rhi::RhiTradePageArgsV1
+impl rhi::RhiTradePageArgsV1
+pub const fn rhi::RhiTradePageArgsV1::page(&self) -> &rhi::RhiPageQueryArgsV1
+pub fn rhi::RhiTradePageArgsV1::trade_id(&self) -> &str
+impl core::fmt::Debug for rhi::RhiTradePageArgsV1
+pub fn rhi::RhiTradePageArgsV1::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiTradeSourceAttempt
impl rhi::RhiTradeSourceAttempt
pub fn rhi::RhiTradeSourceAttempt::new(impl core::convert::AsRef<str>, radroots_service_host::time::UnixTimeSeconds, rhi::RhiTradeMutationObservedAtUnixSeconds, rhi::RhiTradeMutationAuthoredTimePolicy) -> core::result::Result<Self, rhi::RhiTradeSourceIngestError>
@@ -2175,6 +2293,8 @@ pub fn rhi::RhiTradeSourceObservation::fmt(&self, &mut core::fmt::Formatter<'_>)
pub struct rhi::RhiTransportAdapters
impl rhi::RhiTransportAdapters
pub fn rhi::RhiTransportAdapters::new(alloc::sync::Arc<dyn radroots_transport::source::EventSource>, alloc::sync::Arc<dyn radroots_transport::source::EventSubscriber>, alloc::sync::Arc<dyn radroots_transport::sink::EventSink>) -> Self
+impl core::clone::Clone for rhi::RhiTransportAdapters
+pub fn rhi::RhiTransportAdapters::clone(&self) -> Self
impl core::fmt::Debug for rhi::RhiTransportAdapters
pub fn rhi::RhiTransportAdapters::fmt(&self, &mut core::fmt::Formatter<'_>) -> core::fmt::Result
pub struct rhi::RhiVerifiedStateBackup
@@ -2243,6 +2363,7 @@ pub const rhi::RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES: usize
pub const rhi::RHI_CONFIG_SCHEMA: &str
pub const rhi::RHI_CONFIG_SCHEMA_VERSION: u32
pub const rhi::RHI_DETAILED_STATUS_MAX_UTF8_BYTES: usize
+pub const rhi::RHI_DIAGNOSTICS_CONTRACT_VERSION: u32
pub const rhi::RHI_DOCTOR_CHECK_COUNT: usize
pub const rhi::RHI_DOCTOR_CONTRACT_VERSION: u32
pub const rhi::RHI_DOCTOR_REPORT_MAX_UTF8_BYTES: usize
@@ -2251,6 +2372,7 @@ pub const rhi::RHI_ENCRYPTED_IDENTITY_BACKUP_INCLUDED: bool
pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_CONTRACT_VERSION: u32
pub const rhi::RHI_ENCRYPTED_IDENTITY_ENVELOPE_MAX_BYTES: usize
pub const rhi::RHI_LIVEZ_PATH: &str
+pub const rhi::RHI_LOG_RECORD_MAX_UTF8_BYTES: usize
pub const rhi::RHI_METRICS_PATH: &str
pub const rhi::RHI_MIGRATION_CATALOG_SHA256: [u8; 32]
pub const rhi::RHI_OPERATIONS_CONTRACT_VERSION: u32
@@ -2295,6 +2417,9 @@ pub const rhi::RHI_STATE_SCHEMA_VERSION: u32
pub const rhi::RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256: [u8; 32]
pub const rhi::RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT: u32
pub const rhi::RHI_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32]
+pub const rhi::RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256: [u8; 32]
+pub const rhi::RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT: u32
+pub const rhi::RHI_STATE_SCHEMA_VERSION_11_SHA256: [u8; 32]
pub const rhi::RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32
pub const rhi::RHI_STATE_SCHEMA_VERSION_1_SHA256: [u8; 32]
pub const rhi::RHI_STATE_SCHEMA_VERSION_2_MIGRATION_SHA256: [u8; 32]
@@ -2352,6 +2477,8 @@ pub trait rhi::RhiIdentityAccess: core::marker::Send + core::marker::Sync
pub fn rhi::RhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError>
impl rhi::RhiIdentityAccess for rhi::CanonicalRhiIdentityAccess
pub fn rhi::CanonicalRhiIdentityAccess::open_existing(&self, &rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError>
+pub trait rhi::RhiProcessSignalSource: core::marker::Send
+pub fn rhi::RhiProcessSignalSource::next_signal(&mut self) -> rhi::RhiProcessSignalFuture<'_>
pub fn rhi::admit_rhi_trade_mutation_event(rhi::RhiTradeMutationAdmissionLimits, &[u8], rhi::RhiTradeMutationObservedAtUnixSeconds, rhi::RhiTradeMutationAuthoredTimePolicy) -> core::result::Result<rhi::RhiAdmittedTradeMutationEvent, rhi::RhiTradeMutationAdmissionError>
pub async fn rhi::apply_rhi_configuration(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, &rhi::RhiConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiConfigApplyOutcome, rhi::RhiConfigApplyError>
pub fn rhi::attest_projection_claim(&radroots_trade::trade_contract_v1::RadrootsTradeProjectionV1, &radroots_event::id::MutationId, &rhi::TradeAgreementAttestationPolicy) -> core::result::Result<rhi::TradeAgreementAttestationReportV1, rhi::TradeAgreementAttestationError>
@@ -2359,12 +2486,18 @@ pub fn rhi::build_rhi_admin_router<H>(alloc::sync::Arc<H>) -> core::result::Resu
pub fn rhi::build_rhi_signed_evidence_attestation(rhi::RhiReconciliationFinalizationFence, &rhi::RhiDecryptedIdentity, radroots_service_host::time::UnixTimeSeconds, &dyn radroots_service_host::entropy::EntropySource, core::option::Option<rhi::RhiEvidenceAttestationSupersession>) -> core::result::Result<rhi::RhiSignedEvidenceAttestation, rhi::RhiReconciliationAttestationError>
pub fn rhi::build_rhi_signed_presence_documents(rhi::RhiPresenceDesiredCommitOutcome, &rhi::RhiPresenceDesiredAuthority, &rhi::RhiDecryptedIdentity, radroots_service_host::time::UnixTimeSeconds, &dyn radroots_service_host::entropy::EntropySource) -> core::result::Result<rhi::RhiSignedPresenceDocuments, rhi::RhiPresencePublicationError>
pub fn rhi::evaluate_rhi_reconciliation_claim(rhi::RhiReconciliationProjection, radroots_event::id::MutationId) -> rhi::RhiReconciliationEvaluation
+pub fn rhi::execute_rhi_cli_v1(rhi::RhiCliInvocationV1) -> rhi::RhiProcessResult
+pub fn rhi::execute_rhi_cli_v1_with_signal_source<F, S>(rhi::RhiCliInvocationV1, F) -> rhi::RhiProcessResult where F: core::ops::function::FnOnce() -> core::option::Option<S>, S: rhi::RhiProcessSignalSource + 'static
pub async fn rhi::finalize_rhi_state_restore(rhi::RhiStagedStateRestore) -> core::result::Result<(), rhi::RhiStateMaintenanceError>
pub async fn rhi::ingest_rhi_trade_source(&rhi::RhiStateRepositories<'_>, &rhi::RhiTransportAdapters, &rhi::RhiConfigDocumentV1, &str, radroots_event::id::TradeId, rhi::RhiTradeSourceAttempt) -> core::result::Result<rhi::RhiTradeSourceIngestOutcome, rhi::RhiTradeSourceIngestError>
+pub fn rhi::initialize_rhi_config_document(&rhi::RhiRuntimeContext, &[u8]) -> core::result::Result<rhi::RhiConfigDocumentV1, rhi::RhiConfigLoadError>
pub async fn rhi::initialize_rhi_state(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<(), rhi::RhiStateHostError>
+pub fn rhi::load_rhi_config_candidate(&rhi::RhiRuntimeContext, &std::path::Path) -> core::result::Result<rhi::RhiConfigDocumentV1, rhi::RhiConfigLoadError>
+pub fn rhi::load_rhi_config_document(&rhi::RhiRuntimeContext) -> core::result::Result<rhi::RhiConfigDocumentV1, rhi::RhiConfigLoadError>
pub fn rhi::open_rhi_encrypted_identity(&rhi::RhiIdentityEnvelopeBinding, &rhi::RhiWrappingCredential) -> core::result::Result<rhi::RhiDecryptedIdentity, rhi::RhiEncryptedIdentityEnvelopeError>
pub async fn rhi::open_rhi_runtime_foundation(rhi::RhiRuntimeContext, rhi::RhiConfigDocumentV1, rhi::RhiRuntimeAdapters, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiRuntimeFoundation, rhi::RhiRuntimeFoundationError>
pub async fn rhi::open_rhi_state_inspection(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError>
+pub async fn rhi::open_rhi_state_inspection_from_config(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError>
pub async fn rhi::open_rhi_state_read_write(&rhi::RhiRuntimeContext, &rhi::RhiStateMetadata, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError>
pub async fn rhi::open_rhi_state_read_write_from_config(&rhi::RhiRuntimeContext, &rhi::RhiConfigDocumentV1, radroots_service_sqlite::migration::MigrationAppliedAtUnixSeconds, &radroots_service_sqlite::migration::MigrationBuildIdentity) -> core::result::Result<rhi::RhiStateHost, rhi::RhiStateHostError>
pub fn rhi::parse_rhi_cli_v1_from<I, T>(I) -> core::result::Result<rhi::RhiCliInvocationV1, rhi::RhiCliV1Error> where I: core::iter::traits::collect::IntoIterator<Item = T>, T: core::convert::Into<std::ffi::os_str::OsString> + core::clone::Clone
@@ -2388,5 +2521,6 @@ pub fn rhi::validate_rhi_state_catalogs(&radroots_service_sqlite::migration::Mig
pub fn rhi::verify_rhi_state_backup(&[u8], radroots_service_sqlite::backup::manifest::BackupManifestSha256, &std::path::Path, &rhi::RhiStateMetadata, core::num::nonzero::NonZeroU64) -> core::result::Result<rhi::RhiVerifiedStateBackup, rhi::RhiStateMaintenanceError>
pub type rhi::RhiAdminFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = core::result::Result<rhi::RhiAdminResponseDocument, rhi::RhiAdminHandlerError>> + core::marker::Send + 'a)>>
pub type rhi::RhiDoctorFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = rhi::RhiDoctorObservation> + core::marker::Send + 'a)>>
+pub type rhi::RhiProcessSignalFuture<'a> = core::pin::Pin<alloc::boxed::Box<(dyn core::future::future::Future<Output = core::option::Option<rhi::RhiProcessSignal>> + core::marker::Send + 'a)>>
pub type rhi::RhiReconciliationCoverage = radroots_trade::evidence::RadrootsTradeEvidenceCoverageV1
pub type rhi::RhiReconciliationOutcome = radroots_trade::evidence::RadrootsTradeEvidenceOutcomeV1
diff --git a/contracts/services_hardening/operator_contract.v1.json b/contracts/services_hardening/operator_contract.v1.json
@@ -90,9 +90,9 @@
"persistence_state": { "kind": "closed_object", "fields": { "health": "persistence_health", "schema_version": "positive_u32", "generation": "u64", "integrity": "integrity_state", "reason_codes": "reason_codes" } },
"provider_health": { "kind": "enum", "values": ["ready", "degraded", "unavailable"] },
"identity_health": { "kind": "closed_object", "fields": { "configured": "bool", "available": "bool", "reason_codes": "reason_codes" } },
- "provider_state": { "kind": "closed_object", "fields": { "health": "provider_health", "service": "identity_health", "reason_codes": "reason_codes" } },
+ "provider_state": { "kind": "closed_object", "fields": { "health": "provider_health", "identity": "identity_health", "reason_codes": "reason_codes" } },
"transport_health": { "kind": "enum", "values": ["ready", "degraded", "unavailable"] },
- "transport_state": { "kind": "closed_object", "fields": { "health": "transport_health", "required_sources_ready": "bool", "required_publication_targets_ready": "bool", "connected_relay_count": "u64", "reason_codes": "reason_codes" } },
+ "transport_state": { "kind": "closed_object", "fields": { "health": "transport_health", "required_sources_ready": "bool", "subscriber_active": "bool", "configured_source_count": "u64", "reachable_source_count": "u64", "reason_codes": "reason_codes" } },
"coverage": { "kind": "enum", "values": ["Missing", "Partial", "ScopeSatisfied", "Unsupported"] },
"outcome": { "kind": "enum", "values": ["Valid", "Invalid", "Indeterminate"] },
"job_state": { "kind": "enum", "values": ["pending", "leased", "retry_scheduled", "completed", "failed"] },
@@ -109,7 +109,10 @@
"redacted_config": { "kind": "canonical_json_object", "schema": "radroots.rhi.config@1", "maximum_utf8_bytes": 786432, "protected_material": "redacted_or_omitted" },
"build_info": { "kind": "closed_object", "fields": { "version": "bounded_id", "revision": "git_revision", "toolchain": "bounded_id", "contract_versions": "safe_counts" } },
"git_revision": { "kind": "string", "utf8_bytes": 40, "pattern": "^[0-9a-f]{40}$" },
- "rhi_status": { "kind": "closed_object", "fields": { "policy_digest": "sha256_hex", "dirty_trade_count": "u64", "job_counts": "safe_counts", "coverage_counts": "safe_counts", "outcome_counts": "safe_counts", "publication_counts": "safe_counts", "presence": "presence_state" } },
+ "reconciliation_status": { "kind": "closed_object", "fields": { "pending": "u64", "leased": "u64", "exhausted": "u64", "oldest_pending_at_utc": "optional_utc_seconds" } },
+ "publication_status": { "kind": "closed_object", "fields": { "pending": "u64", "unknown": "u64", "oldest_pending_at_utc": "optional_utc_seconds" } },
+ "presence_status": { "kind": "closed_object", "fields": { "pending": "u64", "unknown": "u64" } },
+ "rhi_status": { "kind": "closed_object", "fields": { "identity": "identity_health", "reconciliation": "reconciliation_status", "publication": "publication_status", "presence": "presence_status" } },
"optional_utc_seconds": { "kind": "optional", "representation": "absent_parent_field", "value": "utc_seconds" },
"optional_event_id": { "kind": "optional", "representation": "absent_parent_field", "value": "event_id" },
"optional_report_id": { "kind": "optional", "representation": "absent_parent_field", "value": "report_id" },
diff --git a/radroots.service.source-lock.v2.toml b/radroots.service.source-lock.v2.toml
@@ -7,7 +7,7 @@ architecture = "radroots.crates.release.v2"
workspace_catalog_sha256 = "deca0c080deae187ff8186c0708903e42f41ea57f77c5f91581e23aa561164a4"
version = "0.1.0-alpha"
source_archive_sha256 = "7e584a4b679264620d7bb6cf0a7028cc7651b33977b263c213f4e7b29c0e5a19"
-cargo_lock_sha256 = "b85bee310965fc4c4da8f6641007840f0d736c194d3d0fb74ea3db757e7f1433"
+cargo_lock_sha256 = "7a79bb19dc9275f65b86decff5136c5433b0858c5da5135625745194a074c021"
rust_version = "1.97.1"
host_feature_profile = "service-host"
@@ -16,7 +16,7 @@ material = "absent"
[contract_versions]
config = 1
-state = 7
+state = 11
admin = 1
status = 1
provider = 1
diff --git a/src/admin_v1.rs b/src/admin_v1.rs
@@ -1,19 +1,21 @@
//! Exact RHI v1 Unix-admin route and model boundary.
-use core::{fmt, future::Future, pin::Pin, time::Duration};
-use std::{
- collections::BTreeSet,
- error::Error,
- sync::{Arc, OnceLock},
-};
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use core::time::Duration;
+use core::{fmt, future::Future, pin::Pin};
+use std::{collections::BTreeSet, error::Error, sync::OnceLock};
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use std::sync::Arc;
+use radroots_service_host::{AdminCorrelationId, AdminOperationId, CancellationToken};
+#[cfg(any(target_os = "linux", target_os = "macos"))]
use radroots_service_host::{
- AdminCorrelationId, AdminError, AdminErrorCode, AdminErrorMessage, AdminHttpMethod,
- AdminMutationRequest, AdminOperationId, AdminRequest, AdminRouteFailure,
- AdminRouteFailureStatus, AdminRouteOutcome, AdminRouter as SharedAdminRouter,
- AdminServer as SharedAdminServer, AdminServerError as SharedAdminServerError,
- AdminTransportLimitValues, AdminTransportLimits, CancellationToken, UnixAdminSocketBinding,
- UnixAdminSocketWriterAuthority,
+ AdminError, AdminErrorCode, AdminErrorMessage, AdminHttpMethod, AdminMutationRequest,
+ AdminRequest, AdminRouteFailure, AdminRouteFailureStatus, AdminRouteOutcome,
+ AdminRouter as SharedAdminRouter, AdminServer as SharedAdminServer,
+ AdminServerError as SharedAdminServerError, AdminTransportLimitValues, AdminTransportLimits,
+ UnixAdminSocketBinding, UnixAdminSocketWriterAuthority,
};
use serde::de::{self, DeserializeSeed, MapAccess, SeqAccess, Visitor};
use serde_json::{Map, Value};
@@ -248,6 +250,7 @@ impl RhiAdminRoute {
matches!(self.method(), RhiAdminMethod::Post)
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
const fn host_method(self) -> AdminHttpMethod {
match self.method() {
RhiAdminMethod::Get => AdminHttpMethod::Get,
@@ -255,6 +258,7 @@ impl RhiAdminRoute {
}
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
const fn parameter_binding(self) -> Option<(&'static str, &'static str)> {
match self {
Self::TradeProjection | Self::TradeReportCurrent | Self::TradeReports => {
@@ -337,6 +341,7 @@ impl fmt::Debug for RhiAdminRequestDocument {
pub struct RhiAdminResponseDocument {
route: RhiAdminRoute,
canonical_bytes: Box<[u8]>,
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
value: Value,
}
@@ -368,6 +373,7 @@ impl RhiAdminResponseDocument {
Ok(Self {
route,
canonical_bytes: canonical.into_boxed_slice(),
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
value,
})
}
@@ -509,10 +515,12 @@ impl Error for RhiAdminRouterError {}
///
/// let _ = RhiAdminRouter { inner: todo!() };
/// ```
+#[cfg(any(target_os = "linux", target_os = "macos"))]
pub struct RhiAdminRouter {
inner: SharedAdminRouter,
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
impl fmt::Debug for RhiAdminRouter {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self { inner } = self;
@@ -521,6 +529,7 @@ impl fmt::Debug for RhiAdminRouter {
}
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
impl RhiAdminRouter {
fn into_inner(self) -> SharedAdminRouter {
self.inner
@@ -595,6 +604,7 @@ pub struct RhiAdminServerError {
}
impl RhiAdminServerError {
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
const fn new(kind: RhiAdminServerErrorKind) -> Self {
Self { kind }
}
@@ -633,10 +643,12 @@ impl Error for RhiAdminServerError {}
/// the exact route inventory around the supplied domain handler, and uses the
/// shared host's system entropy. The raw shared router and server never cross
/// this boundary.
+#[cfg(any(target_os = "linux", target_os = "macos"))]
pub struct RhiAdminServer {
inner: SharedAdminServer,
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
impl RhiAdminServer {
pub fn new<H>(
configuration: &crate::RhiConfigDocumentV1,
@@ -673,6 +685,7 @@ impl RhiAdminServer {
}
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
impl fmt::Debug for RhiAdminServer {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("RhiAdminServer([sealed])")
@@ -680,11 +693,13 @@ impl fmt::Debug for RhiAdminServer {
}
/// Bound final RHI Unix-admin server through Step 209.
+#[cfg(any(target_os = "linux", target_os = "macos"))]
pub struct RhiBoundAdminServer {
inner: SharedAdminServer,
binding: UnixAdminSocketBinding,
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
impl RhiBoundAdminServer {
/// Serves until supervisor cancellation and then drains bounded connection work.
pub async fn serve(
@@ -698,6 +713,7 @@ impl RhiBoundAdminServer {
}
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
impl fmt::Debug for RhiBoundAdminServer {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("RhiBoundAdminServer([sealed])")
@@ -707,6 +723,7 @@ impl fmt::Debug for RhiBoundAdminServer {
/// Registers the final seven common and thirteen domain routes through Step 209.
///
/// Live identity rekey and replace are absent by final offline-only policy.
+#[cfg(any(target_os = "linux", target_os = "macos"))]
pub fn build_rhi_admin_router<H>(handler: Arc<H>) -> Result<RhiAdminRouter, RhiAdminRouterError>
where
H: RhiAdminHandler,
@@ -727,6 +744,7 @@ where
Ok(RhiAdminRouter { inner: router })
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
pub(crate) fn admin_transport_limits(
configuration: &crate::RhiConfigDocumentV1,
) -> Result<AdminTransportLimits, RhiAdminServerError> {
@@ -747,6 +765,18 @@ pub(crate) fn admin_transport_limits(
AdminTransportLimits::new(values).map_err(|_| invalid_admin_configuration())
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) fn admit_admin_response_value(
+ route: RhiAdminRoute,
+ value: &Value,
+) -> Result<Box<[u8]>, RhiAdminDocumentError> {
+ let bytes = serde_json::to_vec(value)
+ .map_err(|_| RhiAdminDocumentError::new(RhiAdminDocumentErrorKind::Malformed))?;
+ RhiAdminResponseDocument::from_canonical_bytes(route, &bytes)
+ .map(|document| document.canonical_bytes)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn admin_u64(value: &Value, pointer: &str) -> Result<u64, RhiAdminServerError> {
value
.pointer(pointer)
@@ -754,14 +784,17 @@ fn admin_u64(value: &Value, pointer: &str) -> Result<u64, RhiAdminServerError> {
.ok_or_else(invalid_admin_configuration)
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn admin_u32(value: &Value, pointer: &str) -> Result<u32, RhiAdminServerError> {
u32::try_from(admin_u64(value, pointer)?).map_err(|_| invalid_admin_configuration())
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
const fn invalid_admin_configuration() -> RhiAdminServerError {
RhiAdminServerError::new(RhiAdminServerErrorKind::InvalidConfiguration)
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
const fn map_admin_server_error(error: SharedAdminServerError) -> RhiAdminServerError {
let kind = match error {
SharedAdminServerError::ListenerClone { .. }
@@ -774,6 +807,7 @@ const fn map_admin_server_error(error: SharedAdminServerError) -> RhiAdminServer
RhiAdminServerError::new(kind)
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
async fn dispatch_route<H>(
route: RhiAdminRoute,
handler: Arc<H>,
@@ -796,6 +830,7 @@ where
}
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn failure(kind: RhiAdminHandlerErrorKind, invalid_request: bool) -> AdminRouteOutcome {
let (status, code, message) = if invalid_request {
(
@@ -845,6 +880,7 @@ fn failure(kind: RhiAdminHandlerErrorKind, invalid_request: bool) -> AdminRouteO
))
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn request_document(
route: RhiAdminRoute,
request: &AdminRequest,
@@ -885,6 +921,7 @@ fn request_document(
})
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn query_model(route: RhiAdminRoute, query: Option<&str>) -> Result<Value, RhiAdminDocumentError> {
let Some(query) = query else {
return Ok(Value::Object(Map::new()));
@@ -927,6 +964,7 @@ fn query_model(route: RhiAdminRoute, query: Option<&str>) -> Result<Value, RhiAd
Ok(Value::Object(output))
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn query_scalar(type_name: &str, value: String) -> Result<Value, RhiAdminDocumentError> {
let descriptor = type_descriptor(type_name)?;
match descriptor.get("kind").and_then(Value::as_str) {
@@ -956,6 +994,7 @@ fn query_scalar(type_name: &str, value: String) -> Result<Value, RhiAdminDocumen
}
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn percent_decode(value: &str) -> Result<String, RhiAdminDocumentError> {
let bytes = value.as_bytes();
let mut decoded = Vec::with_capacity(bytes.len());
@@ -981,6 +1020,7 @@ fn percent_decode(value: &str) -> Result<String, RhiAdminDocumentError> {
String::from_utf8(decoded).map_err(|_| invalid_model_error())
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
const fn hex_nibble(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
@@ -990,6 +1030,7 @@ const fn hex_nibble(byte: u8) -> Option<u8> {
}
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn valid_percent_encoding(value: &str) -> bool {
let bytes = value.as_bytes();
let mut index = 0;
@@ -1017,6 +1058,7 @@ fn operator_contract() -> Result<&'static Value, RhiAdminDocumentError> {
.ok_or_else(invalid_model_error)
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
fn operator_route_inventory_is_exact() -> bool {
let Ok(contract) = operator_contract() else {
return false;
@@ -1637,7 +1679,7 @@ impl<'de> Visitor<'de> for StrictValueVisitor {
}
}
-#[cfg(test)]
+#[cfg(all(test, any(target_os = "linux", target_os = "macos")))]
mod tests {
use super::*;
diff --git a/src/cli_bootstrap.rs b/src/cli_bootstrap.rs
@@ -0,0 +1,96 @@
+//! Fixed, zeroizing bootstrap documents consumed only from standard input.
+
+use std::io::Read;
+
+use zeroize::Zeroizing;
+
+use crate::RhiEncryptedIdentityProvisioningMaterial;
+
+pub(crate) const RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES: usize = 117;
+const RHI_IDENTITY_PROVISIONING_MAGIC: &[u8; 4] = b"RHIP";
+const RHI_IDENTITY_PROVISIONING_VERSION: u8 = 1;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum RhiBootstrapDocumentError {
+ Io,
+ InvalidLength,
+ InvalidHeader,
+ InvalidMaterial,
+}
+
+pub(crate) fn read_identity_provisioning_document(
+ mut reader: impl Read,
+) -> Result<RhiEncryptedIdentityProvisioningMaterial, RhiBootstrapDocumentError> {
+ let mut document = Zeroizing::new([0_u8; RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES + 1]);
+ let mut length = 0_usize;
+ while length < document.len() {
+ match reader.read(&mut document[length..]) {
+ Ok(0) => break,
+ Ok(read) => length = length.saturating_add(read),
+ Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
+ Err(_) => return Err(RhiBootstrapDocumentError::Io),
+ }
+ }
+ if length != RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES {
+ return Err(RhiBootstrapDocumentError::InvalidLength);
+ }
+ if &document[..4] != RHI_IDENTITY_PROVISIONING_MAGIC
+ || document[4] != RHI_IDENTITY_PROVISIONING_VERSION
+ {
+ return Err(RhiBootstrapDocumentError::InvalidHeader);
+ }
+ let mut identity_secret = [0_u8; 32];
+ let mut data_key = [0_u8; 32];
+ let mut envelope_nonce = [0_u8; 24];
+ let mut wrapping_nonce = [0_u8; 24];
+ identity_secret.copy_from_slice(&document[5..37]);
+ data_key.copy_from_slice(&document[37..69]);
+ envelope_nonce.copy_from_slice(&document[69..93]);
+ wrapping_nonce.copy_from_slice(&document[93..117]);
+ RhiEncryptedIdentityProvisioningMaterial::new(
+ identity_secret,
+ data_key,
+ envelope_nonce,
+ wrapping_nonce,
+ )
+ .map_err(|_| RhiBootstrapDocumentError::InvalidMaterial)
+}
+
+#[cfg(test)]
+mod tests {
+ use std::io::Cursor;
+
+ use super::*;
+
+ fn document() -> [u8; RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES] {
+ let mut document = [0_u8; RHI_IDENTITY_PROVISIONING_DOCUMENT_BYTES];
+ document[..4].copy_from_slice(RHI_IDENTITY_PROVISIONING_MAGIC);
+ document[4] = RHI_IDENTITY_PROVISIONING_VERSION;
+ document[5..37].copy_from_slice(&[1; 32]);
+ document[37..69].copy_from_slice(&[2; 32]);
+ document[69..93].copy_from_slice(&[3; 24]);
+ document[93..117].copy_from_slice(&[4; 24]);
+ document
+ }
+
+ #[test]
+ fn exact_document_is_admitted_and_bounds_are_fail_closed() {
+ let valid = document();
+ let material = read_identity_provisioning_document(Cursor::new(valid))
+ .expect("exact provisioning document");
+ assert_eq!(
+ format!("{material:?}"),
+ "RhiEncryptedIdentityProvisioningMaterial([redacted])"
+ );
+ assert_eq!(
+ read_identity_provisioning_document(Cursor::new(&valid[..116])).unwrap_err(),
+ RhiBootstrapDocumentError::InvalidLength
+ );
+ let mut trailing = valid.to_vec();
+ trailing.push(0);
+ assert_eq!(
+ read_identity_provisioning_document(Cursor::new(trailing)).unwrap_err(),
+ RhiBootstrapDocumentError::InvalidLength
+ );
+ }
+}
diff --git a/src/cli_v1.rs b/src/cli_v1.rs
@@ -25,7 +25,7 @@ pub enum RhiCliOutputModeV1 {
}
/// The exact governed top-level RHI command inventory.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum RhiCommandV1 {
Run,
Config(RhiConfigCommandV1),
@@ -42,22 +42,22 @@ pub enum RhiCommandV1 {
}
/// Governed configuration commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum RhiConfigCommandV1 {
Init,
Validate,
Show,
Schema,
- Apply,
+ Apply(RhiConfigApplyArgsV1),
}
/// Governed state commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum RhiStateCommandV1 {
Init,
Status,
- Backup,
- Restore,
+ Backup(RhiStateBackupArgsV1),
+ Restore(RhiStateRestoreArgsV1),
Verify,
Migrate,
}
@@ -77,41 +77,273 @@ pub enum RhiMetricsCommandV1 {
}
/// Governed reconciliation commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum RhiReconciliationCommandV1 {
Status,
- Jobs,
- Refresh,
+ Jobs(RhiPageQueryArgsV1),
+ Refresh(RhiReconciliationRefreshArgsV1),
}
/// Governed evidence-source commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum RhiSourcesCommandV1 {
- List,
+ List(RhiPageQueryArgsV1),
}
/// Governed trade-query commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum RhiTradeCommandV1 {
- Projection,
- ReportCurrent,
- Reports,
+ Projection(RhiTradeArgsV1),
+ ReportCurrent(RhiTradeArgsV1),
+ Reports(RhiTradePageArgsV1),
}
/// Governed publication commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum RhiPublicationCommandV1 {
- Backlog,
- Targets,
- Retry,
+ Backlog(RhiPageQueryArgsV1),
+ Targets(RhiPageQueryArgsV1),
+ Retry(RhiPublicationRetryArgsV1),
}
/// Governed desired-presence commands.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+#[derive(PartialEq, Eq)]
pub enum RhiPresenceCommandV1 {
Desired,
- Render,
- Refresh,
+ Render(RhiPresenceMutationArgsV1),
+ Refresh(RhiPresenceMutationArgsV1),
+}
+
+/// Exact offline configuration-apply input.
+#[derive(PartialEq, Eq)]
+pub struct RhiConfigApplyArgsV1 {
+ candidate_config: PathBuf,
+}
+
+impl RhiConfigApplyArgsV1 {
+ #[must_use]
+ pub fn candidate_config(&self) -> &Path {
+ &self.candidate_config
+ }
+}
+
+/// Exact live state-backup input.
+#[derive(PartialEq, Eq)]
+pub struct RhiStateBackupArgsV1 {
+ operation_id: Box<str>,
+ target: PathBuf,
+ expected_generation: u64,
+}
+
+impl RhiStateBackupArgsV1 {
+ #[must_use]
+ pub fn operation_id(&self) -> &str {
+ &self.operation_id
+ }
+
+ #[must_use]
+ pub fn target(&self) -> &Path {
+ &self.target
+ }
+
+ #[must_use]
+ pub const fn expected_generation(&self) -> u64 {
+ self.expected_generation
+ }
+}
+
+/// Exact offline state-restore input.
+#[derive(PartialEq, Eq)]
+pub struct RhiStateRestoreArgsV1 {
+ manifest: PathBuf,
+ manifest_sha256: Box<str>,
+ bundle: PathBuf,
+ maximum_state_bytes: u64,
+}
+
+impl RhiStateRestoreArgsV1 {
+ #[must_use]
+ pub fn manifest(&self) -> &Path {
+ &self.manifest
+ }
+
+ #[must_use]
+ pub fn manifest_sha256(&self) -> &str {
+ &self.manifest_sha256
+ }
+
+ #[must_use]
+ pub fn bundle(&self) -> &Path {
+ &self.bundle
+ }
+
+ #[must_use]
+ pub const fn maximum_state_bytes(&self) -> u64 {
+ self.maximum_state_bytes
+ }
+}
+
+/// Bounded stable pagination input shared by list commands.
+#[derive(PartialEq, Eq)]
+pub struct RhiPageQueryArgsV1 {
+ limit: u16,
+ cursor: Option<Box<str>>,
+}
+
+impl RhiPageQueryArgsV1 {
+ #[must_use]
+ pub const fn limit(&self) -> u16 {
+ self.limit
+ }
+
+ #[must_use]
+ pub fn cursor(&self) -> Option<&str> {
+ self.cursor.as_deref()
+ }
+}
+
+/// Exact trade selection for one live query.
+#[derive(PartialEq, Eq)]
+pub struct RhiTradeArgsV1 {
+ trade_id: Box<str>,
+}
+
+impl RhiTradeArgsV1 {
+ #[must_use]
+ pub fn trade_id(&self) -> &str {
+ &self.trade_id
+ }
+}
+
+/// Exact trade selection plus bounded report pagination.
+#[derive(PartialEq, Eq)]
+pub struct RhiTradePageArgsV1 {
+ trade_id: Box<str>,
+ page: RhiPageQueryArgsV1,
+}
+
+impl RhiTradePageArgsV1 {
+ #[must_use]
+ pub fn trade_id(&self) -> &str {
+ &self.trade_id
+ }
+
+ #[must_use]
+ pub const fn page(&self) -> &RhiPageQueryArgsV1 {
+ &self.page
+ }
+}
+
+/// Exact refresh request and idempotency identity.
+#[derive(PartialEq, Eq)]
+pub struct RhiReconciliationRefreshArgsV1 {
+ operation_id: Box<str>,
+ trade_id: Box<str>,
+ expected_dirty_generation: u64,
+}
+
+impl RhiReconciliationRefreshArgsV1 {
+ #[must_use]
+ pub fn operation_id(&self) -> &str {
+ &self.operation_id
+ }
+
+ #[must_use]
+ pub fn trade_id(&self) -> &str {
+ &self.trade_id
+ }
+
+ #[must_use]
+ pub const fn expected_dirty_generation(&self) -> u64 {
+ self.expected_dirty_generation
+ }
+}
+
+/// Exact publication retry request and idempotency identity.
+#[derive(PartialEq, Eq)]
+pub struct RhiPublicationRetryArgsV1 {
+ operation_id: Box<str>,
+ workflow_id: Box<str>,
+ expected_generation: u64,
+}
+
+impl RhiPublicationRetryArgsV1 {
+ #[must_use]
+ pub fn operation_id(&self) -> &str {
+ &self.operation_id
+ }
+
+ #[must_use]
+ pub fn workflow_id(&self) -> &str {
+ &self.workflow_id
+ }
+
+ #[must_use]
+ pub const fn expected_generation(&self) -> u64 {
+ self.expected_generation
+ }
+}
+
+/// Exact presence mutation request and idempotency identity.
+#[derive(PartialEq, Eq)]
+pub struct RhiPresenceMutationArgsV1 {
+ operation_id: Box<str>,
+ expected_generation: u64,
+}
+
+impl RhiPresenceMutationArgsV1 {
+ #[must_use]
+ pub fn operation_id(&self) -> &str {
+ &self.operation_id
+ }
+
+ #[must_use]
+ pub const fn expected_generation(&self) -> u64 {
+ self.expected_generation
+ }
+}
+
+macro_rules! redacted_debug {
+ ($($type:ty),+ $(,)?) => {
+ $(
+ impl fmt::Debug for $type {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(concat!(stringify!($type), "([redacted])"))
+ }
+ }
+ )+
+ };
+}
+
+redacted_debug!(
+ RhiConfigApplyArgsV1,
+ RhiStateBackupArgsV1,
+ RhiStateRestoreArgsV1,
+ RhiPageQueryArgsV1,
+ RhiTradeArgsV1,
+ RhiTradePageArgsV1,
+ RhiReconciliationRefreshArgsV1,
+ RhiPublicationRetryArgsV1,
+ RhiPresenceMutationArgsV1,
+);
+
+impl fmt::Debug for RhiCommandV1 {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::Run => "RhiCommandV1::Run",
+ Self::Config(_) => "RhiCommandV1::Config([redacted])",
+ Self::State(_) => "RhiCommandV1::State([redacted])",
+ Self::Identity(_) => "RhiCommandV1::Identity([redacted])",
+ Self::Status => "RhiCommandV1::Status",
+ Self::Metrics(_) => "RhiCommandV1::Metrics([redacted])",
+ Self::Reconciliation(_) => "RhiCommandV1::Reconciliation([redacted])",
+ Self::Sources(_) => "RhiCommandV1::Sources([redacted])",
+ Self::Trade(_) => "RhiCommandV1::Trade([redacted])",
+ Self::Publication(_) => "RhiCommandV1::Publication([redacted])",
+ Self::Presence(_) => "RhiCommandV1::Presence([redacted])",
+ Self::Doctor => "RhiCommandV1::Doctor",
+ })
+ }
}
/// The only three process authorities selected by the hardened CLI.
@@ -247,6 +479,7 @@ pub enum RhiCliV1ErrorKind {
InvalidRepoLocalRoot,
UnexpectedRepoLocalRoot,
InvalidConfigPath,
+ InvalidCommandInput,
}
impl RhiCliV1ErrorKind {
@@ -259,6 +492,7 @@ impl RhiCliV1ErrorKind {
"repo-local root is forbidden outside the repo-local profile"
}
Self::InvalidConfigPath => "configuration path must be absolute without traversal",
+ Self::InvalidCommandInput => "command input is invalid",
}
}
}
@@ -341,8 +575,8 @@ impl RhiCliInvocationV1 {
/// Returns the exact governed command selection.
#[must_use]
- pub const fn command(&self) -> RhiCommandV1 {
- self.command
+ pub const fn command(&self) -> &RhiCommandV1 {
+ &self.command
}
}
@@ -399,7 +633,7 @@ where
repo_local_root: parsed.repo_local_root,
config_path: parsed.config,
output_mode: parsed.output.into(),
- command: parsed.command.into(),
+ command: admit_command(parsed.command)?,
})
}
@@ -411,19 +645,19 @@ where
/// authority.
#[must_use]
pub const fn plan_rhi_cli_v1(invocation: &RhiCliInvocationV1) -> RhiCliExecutionPlanV1 {
- match invocation.command {
+ match &invocation.command {
RhiCommandV1::Run => daemon_plan(),
RhiCommandV1::Config(RhiConfigCommandV1::Init)
| RhiCommandV1::Config(RhiConfigCommandV1::Validate)
| RhiCommandV1::Config(RhiConfigCommandV1::Schema)
- | RhiCommandV1::Config(RhiConfigCommandV1::Apply) => {
+ | RhiCommandV1::Config(RhiConfigCommandV1::Apply(_)) => {
offline_plan(RhiCliOfflineOperationV1::Config)
}
RhiCommandV1::Config(RhiConfigCommandV1::Show) => {
admin_plan(RhiCliAdminOperationV1::EffectiveConfig)
}
RhiCommandV1::State(RhiStateCommandV1::Init)
- | RhiCommandV1::State(RhiStateCommandV1::Restore)
+ | RhiCommandV1::State(RhiStateCommandV1::Restore(_))
| RhiCommandV1::State(RhiStateCommandV1::Verify)
| RhiCommandV1::State(RhiStateCommandV1::Migrate) => {
offline_plan(RhiCliOfflineOperationV1::StateExclusive)
@@ -431,7 +665,7 @@ pub const fn plan_rhi_cli_v1(invocation: &RhiCliInvocationV1) -> RhiCliExecution
RhiCommandV1::State(RhiStateCommandV1::Status) => {
admin_plan(RhiCliAdminOperationV1::StateStatus)
}
- RhiCommandV1::State(RhiStateCommandV1::Backup) => {
+ RhiCommandV1::State(RhiStateCommandV1::Backup(_)) => {
admin_plan(RhiCliAdminOperationV1::StateBackup)
}
RhiCommandV1::Identity(RhiIdentityCommandV1::Init) => {
@@ -450,40 +684,40 @@ pub const fn plan_rhi_cli_v1(invocation: &RhiCliInvocationV1) -> RhiCliExecution
RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status) => {
admin_plan(RhiCliAdminOperationV1::ReconciliationStatus)
}
- RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs) => {
+ RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs(_)) => {
admin_plan(RhiCliAdminOperationV1::ReconciliationJobs)
}
- RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh) => {
+ RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh(_)) => {
admin_plan(RhiCliAdminOperationV1::ReconciliationRefresh)
}
- RhiCommandV1::Sources(RhiSourcesCommandV1::List) => {
+ RhiCommandV1::Sources(RhiSourcesCommandV1::List(_)) => {
admin_plan(RhiCliAdminOperationV1::Sources)
}
- RhiCommandV1::Trade(RhiTradeCommandV1::Projection) => {
+ RhiCommandV1::Trade(RhiTradeCommandV1::Projection(_)) => {
admin_plan(RhiCliAdminOperationV1::TradeProjection)
}
- RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent) => {
+ RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent(_)) => {
admin_plan(RhiCliAdminOperationV1::TradeReportCurrent)
}
- RhiCommandV1::Trade(RhiTradeCommandV1::Reports) => {
+ RhiCommandV1::Trade(RhiTradeCommandV1::Reports(_)) => {
admin_plan(RhiCliAdminOperationV1::TradeReports)
}
- RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog) => {
+ RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog(_)) => {
admin_plan(RhiCliAdminOperationV1::PublicationBacklog)
}
- RhiCommandV1::Publication(RhiPublicationCommandV1::Targets) => {
+ RhiCommandV1::Publication(RhiPublicationCommandV1::Targets(_)) => {
admin_plan(RhiCliAdminOperationV1::PublicationTargets)
}
- RhiCommandV1::Publication(RhiPublicationCommandV1::Retry) => {
+ RhiCommandV1::Publication(RhiPublicationCommandV1::Retry(_)) => {
admin_plan(RhiCliAdminOperationV1::PublicationRetry)
}
RhiCommandV1::Presence(RhiPresenceCommandV1::Desired) => {
admin_plan(RhiCliAdminOperationV1::PresenceDesired)
}
- RhiCommandV1::Presence(RhiPresenceCommandV1::Render) => {
+ RhiCommandV1::Presence(RhiPresenceCommandV1::Render(_)) => {
admin_plan(RhiCliAdminOperationV1::PresenceRender)
}
- RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh) => {
+ RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh(_)) => {
admin_plan(RhiCliAdminOperationV1::PresenceRefresh)
}
RhiCommandV1::Doctor => offline_plan(RhiCliOfflineOperationV1::Doctor),
@@ -642,25 +876,6 @@ enum RawCommand {
Doctor,
}
-impl From<RawCommand> for RhiCommandV1 {
- fn from(value: RawCommand) -> Self {
- match value {
- RawCommand::Run => Self::Run,
- RawCommand::Config { command } => Self::Config(command.into()),
- RawCommand::State { command } => Self::State(command.into()),
- RawCommand::Identity { command } => Self::Identity(command.into()),
- RawCommand::Status => Self::Status,
- RawCommand::Metrics { command } => Self::Metrics(command.into()),
- RawCommand::Reconciliation { command } => Self::Reconciliation(command.into()),
- RawCommand::Sources { command } => Self::Sources(command.into()),
- RawCommand::Trade { command } => Self::Trade(command.into()),
- RawCommand::Publication { command } => Self::Publication(command.into()),
- RawCommand::Presence { command } => Self::Presence(command.into()),
- RawCommand::Doctor => Self::Doctor,
- }
- }
-}
-
macro_rules! command_enum {
($raw:ident, $public:ident, { $($variant:ident),+ $(,)? }) => {
#[derive(Subcommand)]
@@ -678,48 +893,312 @@ macro_rules! command_enum {
};
}
-command_enum!(RawConfigCommand, RhiConfigCommandV1, {
+#[derive(Subcommand)]
+enum RawConfigCommand {
Init,
Validate,
Show,
Schema,
- Apply,
-});
-command_enum!(RawStateCommand, RhiStateCommandV1, {
+ Apply {
+ #[arg(long = "candidate-config")]
+ candidate_config: PathBuf,
+ },
+}
+
+#[derive(Subcommand)]
+enum RawStateCommand {
Init,
Status,
- Backup,
- Restore,
+ Backup {
+ #[arg(long = "operation-id")]
+ operation_id: String,
+ #[arg(long)]
+ target: PathBuf,
+ #[arg(long = "expected-generation")]
+ expected_generation: u64,
+ #[arg(long, required = true)]
+ confirm: bool,
+ },
+ Restore {
+ #[arg(long)]
+ manifest: PathBuf,
+ #[arg(long = "manifest-sha256")]
+ manifest_sha256: String,
+ #[arg(long)]
+ bundle: PathBuf,
+ #[arg(long = "maximum-state-bytes")]
+ maximum_state_bytes: u64,
+ #[arg(long, required = true)]
+ confirm: bool,
+ },
Verify,
Migrate,
-});
+}
+
command_enum!(RawIdentityCommand, RhiIdentityCommandV1, {
Init,
Status,
ExportPublic,
});
command_enum!(RawMetricsCommand, RhiMetricsCommandV1, { Snapshot });
-command_enum!(RawReconciliationCommand, RhiReconciliationCommandV1, {
+
+#[derive(Subcommand)]
+enum RawReconciliationCommand {
Status,
- Jobs,
- Refresh,
-});
-command_enum!(RawSourcesCommand, RhiSourcesCommandV1, { List });
-command_enum!(RawTradeCommand, RhiTradeCommandV1, {
- Projection,
- ReportCurrent,
- Reports,
-});
-command_enum!(RawPublicationCommand, RhiPublicationCommandV1, {
- Backlog,
- Targets,
- Retry,
-});
-command_enum!(RawPresenceCommand, RhiPresenceCommandV1, {
+ Jobs {
+ #[command(flatten)]
+ page: RawPageQuery,
+ },
+ Refresh {
+ #[arg(long = "operation-id")]
+ operation_id: String,
+ #[arg(long = "trade-id")]
+ trade_id: String,
+ #[arg(long = "expected-dirty-generation")]
+ expected_dirty_generation: u64,
+ },
+}
+
+#[derive(Subcommand)]
+enum RawSourcesCommand {
+ List {
+ #[command(flatten)]
+ page: RawPageQuery,
+ },
+}
+
+#[derive(Subcommand)]
+enum RawTradeCommand {
+ Projection {
+ #[arg(long = "trade-id")]
+ trade_id: String,
+ },
+ ReportCurrent {
+ #[arg(long = "trade-id")]
+ trade_id: String,
+ },
+ Reports {
+ #[arg(long = "trade-id")]
+ trade_id: String,
+ #[command(flatten)]
+ page: RawPageQuery,
+ },
+}
+
+#[derive(Subcommand)]
+enum RawPublicationCommand {
+ Backlog {
+ #[command(flatten)]
+ page: RawPageQuery,
+ },
+ Targets {
+ #[command(flatten)]
+ page: RawPageQuery,
+ },
+ Retry {
+ #[arg(long = "operation-id")]
+ operation_id: String,
+ #[arg(long = "workflow-id")]
+ workflow_id: String,
+ #[arg(long = "expected-generation")]
+ expected_generation: u64,
+ },
+}
+
+#[derive(Subcommand)]
+enum RawPresenceCommand {
Desired,
- Render,
- Refresh,
-});
+ Render {
+ #[arg(long = "operation-id")]
+ operation_id: String,
+ #[arg(long = "expected-generation")]
+ expected_generation: u64,
+ },
+ Refresh {
+ #[arg(long = "operation-id")]
+ operation_id: String,
+ #[arg(long = "expected-generation")]
+ expected_generation: u64,
+ },
+}
+
+#[derive(clap::Args)]
+struct RawPageQuery {
+ #[arg(long, default_value_t = 100)]
+ limit: u16,
+ #[arg(long)]
+ cursor: Option<String>,
+}
+
+fn admit_command(command: RawCommand) -> Result<RhiCommandV1, RhiCliV1Error> {
+ let invalid = || RhiCliV1Error::new(RhiCliV1ErrorKind::InvalidCommandInput);
+ let page = |value: RawPageQuery| {
+ if !(1..=200).contains(&value.limit)
+ || value.cursor.as_deref().is_some_and(|cursor| {
+ cursor.is_empty()
+ || cursor.len() > 512
+ || cursor != cursor.trim()
+ || cursor.chars().any(char::is_control)
+ })
+ {
+ return Err(invalid());
+ }
+ Ok(RhiPageQueryArgsV1 {
+ limit: value.limit,
+ cursor: value.cursor.map(String::into_boxed_str),
+ })
+ };
+ let bounded_id = |value: String| {
+ if value.is_empty()
+ || value.len() > 128
+ || value != value.trim()
+ || value.chars().any(char::is_control)
+ {
+ Err(invalid())
+ } else {
+ Ok(value.into_boxed_str())
+ }
+ };
+ let trade_id = |value: String| match radroots_event::id::TradeId::parse(&value) {
+ Ok(parsed) if parsed.to_hex() == value => Ok(value.into_boxed_str()),
+ Ok(_) | Err(_) => Err(invalid()),
+ };
+ Ok(match command {
+ RawCommand::Run => RhiCommandV1::Run,
+ RawCommand::Config { command } => RhiCommandV1::Config(match command {
+ RawConfigCommand::Init => RhiConfigCommandV1::Init,
+ RawConfigCommand::Validate => RhiConfigCommandV1::Validate,
+ RawConfigCommand::Show => RhiConfigCommandV1::Show,
+ RawConfigCommand::Schema => RhiConfigCommandV1::Schema,
+ RawConfigCommand::Apply { candidate_config }
+ if valid_absolute_path(&candidate_config, true) =>
+ {
+ RhiConfigCommandV1::Apply(RhiConfigApplyArgsV1 { candidate_config })
+ }
+ RawConfigCommand::Apply { .. } => return Err(invalid()),
+ }),
+ RawCommand::State { command } => RhiCommandV1::State(match command {
+ RawStateCommand::Init => RhiStateCommandV1::Init,
+ RawStateCommand::Status => RhiStateCommandV1::Status,
+ RawStateCommand::Backup {
+ operation_id,
+ target,
+ expected_generation,
+ confirm: true,
+ } if valid_absolute_path(&target, true) => {
+ RhiStateCommandV1::Backup(RhiStateBackupArgsV1 {
+ operation_id: bounded_id(operation_id)?,
+ target,
+ expected_generation,
+ })
+ }
+ RawStateCommand::Backup { .. } => return Err(invalid()),
+ RawStateCommand::Restore {
+ manifest,
+ manifest_sha256,
+ bundle,
+ maximum_state_bytes,
+ confirm: true,
+ } if valid_absolute_path(&manifest, true)
+ && valid_absolute_path(&bundle, true)
+ && maximum_state_bytes != 0
+ && is_lower_hex(&manifest_sha256, 64) =>
+ {
+ RhiStateCommandV1::Restore(RhiStateRestoreArgsV1 {
+ manifest,
+ manifest_sha256: manifest_sha256.into_boxed_str(),
+ bundle,
+ maximum_state_bytes,
+ })
+ }
+ RawStateCommand::Restore { .. } => return Err(invalid()),
+ RawStateCommand::Verify => RhiStateCommandV1::Verify,
+ RawStateCommand::Migrate => RhiStateCommandV1::Migrate,
+ }),
+ RawCommand::Identity { command } => RhiCommandV1::Identity(command.into()),
+ RawCommand::Status => RhiCommandV1::Status,
+ RawCommand::Metrics { command } => RhiCommandV1::Metrics(command.into()),
+ RawCommand::Reconciliation { command } => RhiCommandV1::Reconciliation(match command {
+ RawReconciliationCommand::Status => RhiReconciliationCommandV1::Status,
+ RawReconciliationCommand::Jobs { page: value } => {
+ RhiReconciliationCommandV1::Jobs(page(value)?)
+ }
+ RawReconciliationCommand::Refresh {
+ operation_id,
+ trade_id: selected_trade,
+ expected_dirty_generation,
+ } => RhiReconciliationCommandV1::Refresh(RhiReconciliationRefreshArgsV1 {
+ operation_id: bounded_id(operation_id)?,
+ trade_id: trade_id(selected_trade)?,
+ expected_dirty_generation,
+ }),
+ }),
+ RawCommand::Sources { command } => RhiCommandV1::Sources(match command {
+ RawSourcesCommand::List { page: value } => RhiSourcesCommandV1::List(page(value)?),
+ }),
+ RawCommand::Trade { command } => RhiCommandV1::Trade(match command {
+ RawTradeCommand::Projection { trade_id: value } => {
+ RhiTradeCommandV1::Projection(RhiTradeArgsV1 {
+ trade_id: trade_id(value)?,
+ })
+ }
+ RawTradeCommand::ReportCurrent { trade_id: value } => {
+ RhiTradeCommandV1::ReportCurrent(RhiTradeArgsV1 {
+ trade_id: trade_id(value)?,
+ })
+ }
+ RawTradeCommand::Reports {
+ trade_id: value,
+ page: selected_page,
+ } => RhiTradeCommandV1::Reports(RhiTradePageArgsV1 {
+ trade_id: trade_id(value)?,
+ page: page(selected_page)?,
+ }),
+ }),
+ RawCommand::Publication { command } => RhiCommandV1::Publication(match command {
+ RawPublicationCommand::Backlog { page: value } => {
+ RhiPublicationCommandV1::Backlog(page(value)?)
+ }
+ RawPublicationCommand::Targets { page: value } => {
+ RhiPublicationCommandV1::Targets(page(value)?)
+ }
+ RawPublicationCommand::Retry {
+ operation_id,
+ workflow_id,
+ expected_generation,
+ } => RhiPublicationCommandV1::Retry(RhiPublicationRetryArgsV1 {
+ operation_id: bounded_id(operation_id)?,
+ workflow_id: bounded_id(workflow_id)?,
+ expected_generation,
+ }),
+ }),
+ RawCommand::Presence { command } => RhiCommandV1::Presence(match command {
+ RawPresenceCommand::Desired => RhiPresenceCommandV1::Desired,
+ RawPresenceCommand::Render {
+ operation_id,
+ expected_generation,
+ } => RhiPresenceCommandV1::Render(RhiPresenceMutationArgsV1 {
+ operation_id: bounded_id(operation_id)?,
+ expected_generation,
+ }),
+ RawPresenceCommand::Refresh {
+ operation_id,
+ expected_generation,
+ } => RhiPresenceCommandV1::Refresh(RhiPresenceMutationArgsV1 {
+ operation_id: bounded_id(operation_id)?,
+ expected_generation,
+ }),
+ }),
+ RawCommand::Doctor => RhiCommandV1::Doctor,
+ })
+}
+
+fn is_lower_hex(value: &str, length: usize) -> bool {
+ value.len() == length
+ && value
+ .bytes()
+ .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
+}
#[cfg(test)]
mod tests {
@@ -733,125 +1212,101 @@ mod tests {
#[test]
fn exact_command_inventory_parses() {
+ let trade = "00000000000000000000000000000000";
let vectors = [
- (&["run"][..], RhiCommandV1::Run),
- (
- &["config", "init"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Init),
- ),
- (
- &["config", "validate"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Validate),
- ),
- (
- &["config", "show"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Show),
- ),
- (
- &["config", "schema"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Schema),
- ),
- (
- &["config", "apply"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Apply),
- ),
- (
- &["state", "init"][..],
- RhiCommandV1::State(RhiStateCommandV1::Init),
- ),
- (
- &["state", "status"][..],
- RhiCommandV1::State(RhiStateCommandV1::Status),
- ),
- (
- &["state", "backup"][..],
- RhiCommandV1::State(RhiStateCommandV1::Backup),
- ),
- (
- &["state", "restore"][..],
- RhiCommandV1::State(RhiStateCommandV1::Restore),
- ),
- (
- &["state", "verify"][..],
- RhiCommandV1::State(RhiStateCommandV1::Verify),
- ),
- (
- &["state", "migrate"][..],
- RhiCommandV1::State(RhiStateCommandV1::Migrate),
- ),
- (
- &["identity", "init"][..],
- RhiCommandV1::Identity(RhiIdentityCommandV1::Init),
- ),
- (
- &["identity", "status"][..],
- RhiCommandV1::Identity(RhiIdentityCommandV1::Status),
- ),
- (
- &["identity", "export-public"][..],
- RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic),
- ),
- (&["status"][..], RhiCommandV1::Status),
- (
- &["metrics", "snapshot"][..],
- RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot),
- ),
- (
- &["reconciliation", "status"][..],
- RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status),
- ),
- (
- &["reconciliation", "jobs"][..],
- RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs),
- ),
- (
- &["reconciliation", "refresh"][..],
- RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh),
- ),
- (
- &["sources", "list"][..],
- RhiCommandV1::Sources(RhiSourcesCommandV1::List),
- ),
- (
- &["trade", "projection"][..],
- RhiCommandV1::Trade(RhiTradeCommandV1::Projection),
- ),
- (
- &["trade", "report-current"][..],
- RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent),
- ),
- (
- &["trade", "reports"][..],
- RhiCommandV1::Trade(RhiTradeCommandV1::Reports),
- ),
- (
- &["publication", "backlog"][..],
- RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog),
- ),
- (
- &["publication", "targets"][..],
- RhiCommandV1::Publication(RhiPublicationCommandV1::Targets),
- ),
- (
- &["publication", "retry"][..],
- RhiCommandV1::Publication(RhiPublicationCommandV1::Retry),
- ),
- (
- &["presence", "desired"][..],
- RhiCommandV1::Presence(RhiPresenceCommandV1::Desired),
- ),
- (
- &["presence", "render"][..],
- RhiCommandV1::Presence(RhiPresenceCommandV1::Render),
- ),
- (
- &["presence", "refresh"][..],
- RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh),
- ),
- (&["doctor"][..], RhiCommandV1::Doctor),
+ vec!["run"],
+ vec!["config", "init"],
+ vec!["config", "validate"],
+ vec!["config", "show"],
+ vec!["config", "schema"],
+ vec![
+ "config",
+ "apply",
+ "--candidate-config",
+ "/tmp/candidate.toml",
+ ],
+ vec!["state", "init"],
+ vec!["state", "status"],
+ vec![
+ "state",
+ "backup",
+ "--operation-id",
+ "backup-1",
+ "--target",
+ "/tmp/backup",
+ "--expected-generation",
+ "1",
+ "--confirm",
+ ],
+ vec![
+ "state",
+ "restore",
+ "--manifest",
+ "/tmp/manifest.json",
+ "--manifest-sha256",
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ "--bundle",
+ "/tmp/backup",
+ "--maximum-state-bytes",
+ "1048576",
+ "--confirm",
+ ],
+ vec!["state", "verify"],
+ vec!["state", "migrate"],
+ vec!["identity", "init"],
+ vec!["identity", "status"],
+ vec!["identity", "export-public"],
+ vec!["status"],
+ vec!["metrics", "snapshot"],
+ vec!["reconciliation", "status"],
+ vec!["reconciliation", "jobs"],
+ vec![
+ "reconciliation",
+ "refresh",
+ "--operation-id",
+ "refresh-1",
+ "--trade-id",
+ trade,
+ "--expected-dirty-generation",
+ "1",
+ ],
+ vec!["sources", "list"],
+ vec!["trade", "projection", "--trade-id", trade],
+ vec!["trade", "report-current", "--trade-id", trade],
+ vec!["trade", "reports", "--trade-id", trade],
+ vec!["publication", "backlog"],
+ vec!["publication", "targets"],
+ vec![
+ "publication",
+ "retry",
+ "--operation-id",
+ "retry-1",
+ "--workflow-id",
+ "workflow-1",
+ "--expected-generation",
+ "1",
+ ],
+ vec!["presence", "desired"],
+ vec![
+ "presence",
+ "render",
+ "--operation-id",
+ "render-1",
+ "--expected-generation",
+ "1",
+ ],
+ vec![
+ "presence",
+ "refresh",
+ "--operation-id",
+ "presence-1",
+ "--expected-generation",
+ "1",
+ ],
+ vec!["doctor"],
];
- for (arguments, expected) in vectors {
- assert_eq!(parse(arguments).expect("command").command(), expected);
+ for arguments in vectors {
+ parse(&arguments).expect("command");
}
}
diff --git a/src/config_loader.rs b/src/config_loader.rs
@@ -0,0 +1,650 @@
+//! Secure descriptor-bound configuration loading and create-new initialization.
+
+use core::fmt;
+use std::{error::Error, path::Path};
+
+use crate::{
+ RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RhiConfigDocumentV1, RhiConfigProfile, RhiConfigV1Error,
+ RhiRuntimeContext, parse_rhi_config_v1,
+};
+
+/// Stable source-free secure configuration I/O failure classification.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiConfigLoadErrorKind {
+ InvalidPath,
+ Missing,
+ AlreadyExists,
+ InsecureParent,
+ InsecureArtifact,
+ TooLarge,
+ Io,
+ InvalidDocument,
+ UnsupportedPlatform,
+}
+
+/// One path- and content-free secure configuration failure.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct RhiConfigLoadError {
+ kind: RhiConfigLoadErrorKind,
+}
+
+impl RhiConfigLoadError {
+ const fn new(kind: RhiConfigLoadErrorKind) -> Self {
+ Self { kind }
+ }
+
+ #[must_use]
+ pub const fn kind(self) -> RhiConfigLoadErrorKind {
+ self.kind
+ }
+}
+
+impl fmt::Debug for RhiConfigLoadError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiConfigLoadError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for RhiConfigLoadError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ RhiConfigLoadErrorKind::InvalidPath => "configuration path is invalid",
+ RhiConfigLoadErrorKind::Missing => "configuration document is missing",
+ RhiConfigLoadErrorKind::AlreadyExists => "configuration document already exists",
+ RhiConfigLoadErrorKind::InsecureParent => "configuration parent is insecure",
+ RhiConfigLoadErrorKind::InsecureArtifact => "configuration artifact is insecure",
+ RhiConfigLoadErrorKind::TooLarge => "configuration document exceeds its size limit",
+ RhiConfigLoadErrorKind::Io => "configuration storage failed",
+ RhiConfigLoadErrorKind::InvalidDocument => "configuration document is invalid",
+ RhiConfigLoadErrorKind::UnsupportedPlatform => {
+ "secure configuration storage is unsupported"
+ }
+ })
+ }
+}
+
+impl Error for RhiConfigLoadError {}
+
+/// Loads one selected configuration through the governed descriptor boundary.
+pub fn load_rhi_config_document(
+ runtime: &RhiRuntimeContext,
+) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> {
+ load_rhi_config_document_at(runtime.selected_config_path(), profile(runtime))
+}
+
+/// Loads one absolute candidate document without changing the selected path.
+pub fn load_rhi_config_candidate(
+ runtime: &RhiRuntimeContext,
+ candidate: &Path,
+) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> {
+ load_rhi_config_document_at(candidate, profile(runtime))
+}
+
+/// Validates and creates the selected non-secret configuration exactly once.
+pub fn initialize_rhi_config_document(
+ runtime: &RhiRuntimeContext,
+ bytes: &[u8],
+) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> {
+ if bytes.len() > RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES {
+ return Err(RhiConfigLoadError::new(RhiConfigLoadErrorKind::TooLarge));
+ }
+ let document = parse_rhi_config_v1(bytes, profile(runtime)).map_err(map_document)?;
+ persist_create_new(runtime.selected_config_path(), bytes)?;
+ Ok(document)
+}
+
+fn profile(runtime: &RhiRuntimeContext) -> RhiConfigProfile {
+ match runtime.profile() {
+ crate::RhiBootstrapProfileV1::RepoLocal => RhiConfigProfile::RepoLocal,
+ crate::RhiBootstrapProfileV1::ServiceHost | crate::RhiBootstrapProfileV1::Interactive => {
+ RhiConfigProfile::Production
+ }
+ }
+}
+
+fn map_document(_: RhiConfigV1Error) -> RhiConfigLoadError {
+ RhiConfigLoadError::new(RhiConfigLoadErrorKind::InvalidDocument)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn load_rhi_config_document_at(
+ path: &Path,
+ profile: RhiConfigProfile,
+) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> {
+ let bytes = native::read_existing(path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES)?;
+ parse_rhi_config_v1(&bytes, profile).map_err(map_document)
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn load_rhi_config_document_at(
+ _path: &Path,
+ _profile: RhiConfigProfile,
+) -> Result<RhiConfigDocumentV1, RhiConfigLoadError> {
+ Err(RhiConfigLoadError::new(
+ RhiConfigLoadErrorKind::UnsupportedPlatform,
+ ))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn persist_create_new(path: &Path, bytes: &[u8]) -> Result<(), RhiConfigLoadError> {
+ native::persist_create_new(path, bytes)
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn persist_create_new(_path: &Path, _bytes: &[u8]) -> Result<(), RhiConfigLoadError> {
+ Err(RhiConfigLoadError::new(
+ RhiConfigLoadErrorKind::UnsupportedPlatform,
+ ))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) fn read_secure_bounded_file(
+ path: &Path,
+ maximum: usize,
+) -> Result<Vec<u8>, RhiConfigLoadError> {
+ native::read_existing(path, maximum)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod native {
+ use std::ffi::OsString;
+ use std::fs::File;
+ use std::io::{Read, Write};
+ use std::os::unix::ffi::OsStrExt;
+ use std::path::{Component, Path, PathBuf};
+
+ use rustix::fs::{FileType, Mode, OFlags, fchmod, fstat, open, openat, unlinkat};
+ use rustix::process::geteuid;
+
+ use super::{RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RhiConfigLoadError, RhiConfigLoadErrorKind};
+
+ #[derive(Clone, Copy, PartialEq, Eq)]
+ struct Identity {
+ device: u64,
+ inode: u64,
+ }
+
+ struct SelectedPath {
+ parent: PathBuf,
+ name: OsString,
+ }
+
+ impl SelectedPath {
+ fn parse(path: &Path) -> Result<Self, RhiConfigLoadError> {
+ if !path.is_absolute()
+ || path.as_os_str().as_bytes().len() > 4_096
+ || path.components().any(|component| {
+ !matches!(component, Component::RootDir | Component::Normal(_))
+ })
+ {
+ return Err(error(RhiConfigLoadErrorKind::InvalidPath));
+ }
+ let name = match path.components().next_back() {
+ Some(Component::Normal(name)) if !name.as_bytes().is_empty() => name.to_os_string(),
+ _ => return Err(error(RhiConfigLoadErrorKind::InvalidPath)),
+ };
+ let parent = path
+ .parent()
+ .filter(|parent| parent.is_absolute())
+ .ok_or_else(|| error(RhiConfigLoadErrorKind::InvalidPath))?;
+ Ok(Self {
+ parent: parent.to_path_buf(),
+ name,
+ })
+ }
+ }
+
+ pub(super) fn read_existing(
+ path: &Path,
+ maximum: usize,
+ ) -> Result<Vec<u8>, RhiConfigLoadError> {
+ let selected = SelectedPath::parse(path)?;
+ let parent = open_parent(&selected.parent)?;
+ let parent_identity = directory_identity(&parent)?;
+ let descriptor = openat(
+ &parent,
+ &selected.name,
+ OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
+ Mode::empty(),
+ )
+ .map_err(|source| {
+ error(if source == rustix::io::Errno::NOENT {
+ RhiConfigLoadErrorKind::Missing
+ } else {
+ RhiConfigLoadErrorKind::InsecureArtifact
+ })
+ })?;
+ let mut file = File::from(descriptor);
+ let status = fstat(&file).map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?;
+ let (identity, length) = file_identity(&status, maximum)?;
+ let mut bytes = Vec::with_capacity(length);
+ Read::by_ref(&mut file)
+ .take(u64::try_from(length).unwrap_or(u64::MAX).saturating_add(1))
+ .read_to_end(&mut bytes)
+ .map_err(|_| error(RhiConfigLoadErrorKind::Io))?;
+ if bytes.len() != length {
+ return Err(error(RhiConfigLoadErrorKind::InsecureArtifact));
+ }
+ validate_current(
+ &selected,
+ &parent,
+ parent_identity,
+ &file,
+ identity,
+ length,
+ maximum,
+ )?;
+ Ok(bytes)
+ }
+
+ pub(super) fn persist_create_new(path: &Path, bytes: &[u8]) -> Result<(), RhiConfigLoadError> {
+ let selected = SelectedPath::parse(path)?;
+ let parent = open_parent(&selected.parent)?;
+ let parent_identity = directory_identity(&parent)?;
+ let descriptor = openat(
+ &parent,
+ &selected.name,
+ OFlags::WRONLY
+ | OFlags::CREATE
+ | OFlags::EXCL
+ | OFlags::NOFOLLOW
+ | OFlags::CLOEXEC
+ | OFlags::NONBLOCK,
+ Mode::RUSR | Mode::WUSR,
+ )
+ .map_err(|source| {
+ error(if source == rustix::io::Errno::EXIST {
+ RhiConfigLoadErrorKind::AlreadyExists
+ } else {
+ RhiConfigLoadErrorKind::Io
+ })
+ })?;
+ let mut file = File::from(descriptor);
+ let status = fstat(&file).map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?;
+ let identity = status_identity(&status)?;
+ let result = (|| {
+ fchmod(&file, Mode::RUSR | Mode::WUSR)
+ .map_err(|_| error(RhiConfigLoadErrorKind::Io))?;
+ file.write_all(bytes)
+ .and_then(|()| file.sync_all())
+ .map_err(|_| error(RhiConfigLoadErrorKind::Io))?;
+ validate_current(
+ &selected,
+ &parent,
+ parent_identity,
+ &file,
+ identity,
+ bytes.len(),
+ RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES,
+ )?;
+ parent
+ .sync_all()
+ .map_err(|_| error(RhiConfigLoadErrorKind::Io))?;
+ validate_current(
+ &selected,
+ &parent,
+ parent_identity,
+ &file,
+ identity,
+ bytes.len(),
+ RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES,
+ )
+ })();
+ if result.is_err() {
+ cleanup(&parent, &selected.name, identity);
+ }
+ result
+ }
+
+ fn open_parent(path: &Path) -> Result<File, RhiConfigLoadError> {
+ let mut components = path.components();
+ if !matches!(components.next(), Some(Component::RootDir)) {
+ return Err(error(RhiConfigLoadErrorKind::InvalidPath));
+ }
+ let flags = OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC;
+ let mut parent = File::from(
+ open(Path::new("/"), flags, Mode::empty())
+ .map_err(|_| error(RhiConfigLoadErrorKind::InsecureParent))?,
+ );
+ for component in components {
+ let Component::Normal(name) = component else {
+ return Err(error(RhiConfigLoadErrorKind::InvalidPath));
+ };
+ parent = File::from(
+ openat(&parent, name, flags, Mode::empty())
+ .map_err(|_| error(RhiConfigLoadErrorKind::InsecureParent))?,
+ );
+ }
+ directory_identity(&parent)?;
+ Ok(parent)
+ }
+
+ fn directory_identity(directory: &File) -> Result<Identity, RhiConfigLoadError> {
+ let status = fstat(directory).map_err(|_| error(RhiConfigLoadErrorKind::InsecureParent))?;
+ let mode = normalize_mode(status.st_mode);
+ if !FileType::from_raw_mode(status.st_mode).is_dir()
+ || status.st_uid != geteuid().as_raw()
+ || mode & 0o022 != 0
+ {
+ return Err(error(RhiConfigLoadErrorKind::InsecureParent));
+ }
+ status_identity(&status)
+ }
+
+ fn file_identity(
+ status: &rustix::fs::Stat,
+ maximum: usize,
+ ) -> Result<(Identity, usize), RhiConfigLoadError> {
+ let mode = normalize_mode(status.st_mode);
+ let length =
+ usize::try_from(status.st_size).map_err(|_| error(RhiConfigLoadErrorKind::TooLarge))?;
+ if !FileType::from_raw_mode(status.st_mode).is_file()
+ || normalize_link_count(status.st_nlink) != 1
+ || status.st_uid != geteuid().as_raw()
+ || mode & 0o400 == 0
+ || mode & 0o022 != 0
+ {
+ return Err(error(RhiConfigLoadErrorKind::InsecureArtifact));
+ }
+ if length > maximum {
+ return Err(error(RhiConfigLoadErrorKind::TooLarge));
+ }
+ Ok((status_identity(status)?, length))
+ }
+
+ fn status_identity(status: &rustix::fs::Stat) -> Result<Identity, RhiConfigLoadError> {
+ Ok(Identity {
+ device: normalize_device(status.st_dev)
+ .map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?,
+ inode: status.st_ino,
+ })
+ }
+
+ pub(super) fn normalize_mode<T: Into<u32>>(raw: T) -> u32 {
+ raw.into()
+ }
+
+ pub(super) fn normalize_link_count<T: Into<u64>>(raw: T) -> u64 {
+ raw.into()
+ }
+
+ pub(super) fn normalize_device<T: TryInto<u64>>(raw: T) -> Result<u64, T::Error> {
+ raw.try_into()
+ }
+
+ fn validate_current(
+ selected: &SelectedPath,
+ parent: &File,
+ parent_identity: Identity,
+ held: &File,
+ held_identity: Identity,
+ length: usize,
+ maximum: usize,
+ ) -> Result<(), RhiConfigLoadError> {
+ if directory_identity(parent)? != parent_identity {
+ return Err(error(RhiConfigLoadErrorKind::InsecureParent));
+ }
+ let current_parent = open_parent(&selected.parent)?;
+ if directory_identity(¤t_parent)? != parent_identity {
+ return Err(error(RhiConfigLoadErrorKind::InsecureParent));
+ }
+ let held_status =
+ fstat(held).map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?;
+ let (current_held, current_length) = file_identity(&held_status, maximum)?;
+ if current_held != held_identity || current_length != length {
+ return Err(error(RhiConfigLoadErrorKind::InsecureArtifact));
+ }
+ let current = File::from(
+ openat(
+ ¤t_parent,
+ &selected.name,
+ OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
+ Mode::empty(),
+ )
+ .map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?,
+ );
+ let status =
+ fstat(¤t).map_err(|_| error(RhiConfigLoadErrorKind::InsecureArtifact))?;
+ let (current_identity, current_length) = file_identity(&status, maximum)?;
+ if current_identity != held_identity || current_length != length {
+ return Err(error(RhiConfigLoadErrorKind::InsecureArtifact));
+ }
+ Ok(())
+ }
+
+ fn cleanup(parent: &File, name: &std::ffi::OsStr, identity: Identity) {
+ let current = openat(
+ parent,
+ name,
+ OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
+ Mode::empty(),
+ )
+ .ok()
+ .map(File::from);
+ if current.as_ref().is_some_and(|file| {
+ fstat(file)
+ .ok()
+ .and_then(|status| status_identity(&status).ok())
+ == Some(identity)
+ }) {
+ let _ = unlinkat(parent, name, rustix::fs::AtFlags::empty());
+ let _ = parent.sync_all();
+ }
+ }
+
+ const fn error(kind: RhiConfigLoadErrorKind) -> RhiConfigLoadError {
+ RhiConfigLoadError::new(kind)
+ }
+
+ #[cfg(test)]
+ mod tests {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ use super::*;
+
+ #[test]
+ fn validation_rejects_a_replaced_parent_path() {
+ let root = tempfile::tempdir().expect("temporary root");
+ let parent_path = root.path().join("selected");
+ std::fs::create_dir(&parent_path).expect("selected parent");
+ std::fs::set_permissions(&parent_path, std::fs::Permissions::from_mode(0o700))
+ .expect("secure selected parent");
+ let path = parent_path.join("config.toml");
+ std::fs::write(&path, b"config").expect("selected config");
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
+ .expect("secure selected config");
+
+ let selected = SelectedPath::parse(&path).expect("selected path");
+ let parent = open_parent(&selected.parent).expect("held parent");
+ let parent_identity = directory_identity(&parent).expect("parent identity");
+ let held = File::from(
+ openat(
+ &parent,
+ &selected.name,
+ OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::CLOEXEC | OFlags::NONBLOCK,
+ Mode::empty(),
+ )
+ .expect("held config"),
+ );
+ let status = fstat(&held).expect("held status");
+ let (identity, length) = file_identity(&status, 16).expect("held identity");
+
+ let moved = root.path().join("moved");
+ std::fs::rename(&parent_path, &moved).expect("move held parent");
+ std::fs::create_dir(&parent_path).expect("replacement parent");
+ std::fs::set_permissions(&parent_path, std::fs::Permissions::from_mode(0o700))
+ .expect("secure replacement parent");
+ std::fs::write(parent_path.join("config.toml"), b"config").expect("replacement config");
+
+ assert_eq!(
+ validate_current(
+ &selected,
+ &parent,
+ parent_identity,
+ &held,
+ identity,
+ length,
+ 16,
+ )
+ .expect_err("parent replacement")
+ .kind(),
+ RhiConfigLoadErrorKind::InsecureParent
+ );
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn errors_are_source_free_and_path_free() {
+ for kind in [
+ RhiConfigLoadErrorKind::InvalidPath,
+ RhiConfigLoadErrorKind::Missing,
+ RhiConfigLoadErrorKind::AlreadyExists,
+ RhiConfigLoadErrorKind::InsecureParent,
+ RhiConfigLoadErrorKind::InsecureArtifact,
+ RhiConfigLoadErrorKind::TooLarge,
+ RhiConfigLoadErrorKind::Io,
+ RhiConfigLoadErrorKind::InvalidDocument,
+ RhiConfigLoadErrorKind::UnsupportedPlatform,
+ ] {
+ let error = RhiConfigLoadError::new(kind);
+ assert_eq!(error.kind(), kind);
+ let rendered = format!("{error} {error:?}");
+ assert!(!rendered.contains('/'));
+ assert!(Error::source(&error).is_none());
+ }
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn native_create_read_permissions_and_collision_are_exact() {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let directory = tempfile::tempdir().expect("temporary directory");
+ std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("secure directory");
+ let path = directory.path().join("config.toml");
+ let bytes = b"schema = \"radroots.rhi.config\"\n";
+ native::persist_create_new(&path, bytes).expect("create-new config");
+ assert_eq!(
+ std::fs::metadata(&path)
+ .expect("metadata")
+ .permissions()
+ .mode()
+ & 0o777,
+ 0o600
+ );
+ assert_eq!(
+ native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES).expect("secure read"),
+ bytes
+ );
+ assert_eq!(
+ native::persist_create_new(&path, bytes)
+ .expect_err("collision")
+ .kind(),
+ RhiConfigLoadErrorKind::AlreadyExists
+ );
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn native_reader_rejects_insecure_parent_artifact_links_and_oversize() {
+ use std::os::unix::fs::{PermissionsExt as _, symlink};
+
+ let directory = tempfile::tempdir().expect("temporary directory");
+ std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("secure directory");
+ let path = directory.path().join("config.toml");
+ std::fs::write(&path, b"config").expect("config");
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o620))
+ .expect("insecure mode");
+ assert_eq!(
+ native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("group-write rejection")
+ .kind(),
+ RhiConfigLoadErrorKind::InsecureArtifact
+ );
+
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
+ .expect("secure mode");
+ let hardlink = directory.path().join("hardlink.toml");
+ std::fs::hard_link(&path, &hardlink).expect("hard link");
+ assert_eq!(
+ native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("single-link rejection")
+ .kind(),
+ RhiConfigLoadErrorKind::InsecureArtifact
+ );
+ std::fs::remove_file(&hardlink).expect("remove link");
+
+ let symlink_path = directory.path().join("symlink.toml");
+ symlink(&path, &symlink_path).expect("symlink");
+ assert_eq!(
+ native::read_existing(&symlink_path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("no-follow rejection")
+ .kind(),
+ RhiConfigLoadErrorKind::InsecureArtifact
+ );
+
+ std::fs::write(&path, vec![b'x'; RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES + 1])
+ .expect("oversize");
+ assert_eq!(
+ native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("oversize rejection")
+ .kind(),
+ RhiConfigLoadErrorKind::TooLarge
+ );
+
+ std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o720))
+ .expect("insecure parent");
+ assert_eq!(
+ native::read_existing(&path, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES)
+ .expect_err("parent rejection")
+ .kind(),
+ RhiConfigLoadErrorKind::InsecureParent
+ );
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn native_metadata_normalization_preserves_width_and_signed_device_rejection() {
+ assert_eq!(native::normalize_mode(0o600_u16), 0o600);
+ assert_eq!(native::normalize_mode(0o700_u32), 0o700);
+ assert_eq!(native::normalize_link_count(1_u16), 1);
+ assert_eq!(native::normalize_link_count(1_u64), 1);
+ assert_eq!(native::normalize_device(7_i32), Ok(7));
+ assert!(native::normalize_device(-1_i32).is_err());
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[test]
+ fn generic_secure_reader_enforces_the_callers_exact_bound() {
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let directory = tempfile::tempdir().expect("temporary directory");
+ std::fs::set_permissions(directory.path(), std::fs::Permissions::from_mode(0o700))
+ .expect("secure directory");
+ let path = directory.path().join("artifact");
+ std::fs::write(&path, b"four").expect("artifact");
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
+ .expect("secure artifact");
+
+ assert_eq!(
+ read_secure_bounded_file(&path, 4).expect("exact bound"),
+ b"four"
+ );
+ assert_eq!(
+ read_secure_bounded_file(&path, 3)
+ .expect_err("just over bound")
+ .kind(),
+ RhiConfigLoadErrorKind::TooLarge
+ );
+ }
+}
diff --git a/src/diagnostics_v1.rs b/src/diagnostics_v1.rs
@@ -0,0 +1,189 @@
+//! Closed process-result and structured stderr diagnostic contract.
+
+use crate::{RhiProcessResult, RhiServicePhase};
+use core::fmt;
+
+/// Exact Rhi diagnostics contract version.
+pub const RHI_DIAGNOSTICS_CONTRACT_VERSION: u32 = 1;
+
+/// Hard maximum for one canonical Rhi structured log record, excluding newline.
+pub const RHI_LOG_RECORD_MAX_UTF8_BYTES: usize = 512;
+
+const LOG_SCHEMA: &str = "radroots.rhi.log.v1";
+
+/// Closed structured-log severity vocabulary admitted by Rhi.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiLogLevel {
+ Trace,
+ Debug,
+ Info,
+ Warn,
+ Error,
+}
+
+impl RhiLogLevel {
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Trace => "trace",
+ Self::Debug => "debug",
+ Self::Info => "info",
+ Self::Warn => "warn",
+ Self::Error => "error",
+ }
+ }
+}
+
+/// Closed structured-log event vocabulary required by the current runtime plan.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiLogEvent {
+ ProcessResult,
+ Lifecycle,
+ CriticalTaskFailed,
+ ShutdownRequested,
+ ShutdownForced,
+}
+
+impl RhiLogEvent {
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::ProcessResult => "process_result",
+ Self::Lifecycle => "lifecycle",
+ Self::CriticalTaskFailed => "critical_task_failed",
+ Self::ShutdownRequested => "shutdown_requested",
+ Self::ShutdownForced => "shutdown_forced",
+ }
+ }
+}
+
+/// One sealed canonical structured log record containing only governed values.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct RhiLogRecord {
+ level: RhiLogLevel,
+ event: RhiLogEvent,
+ code: &'static str,
+ process_result: Option<RhiProcessResult>,
+}
+
+impl RhiLogRecord {
+ /// Builds the exact terminal record for one governed process result.
+ #[must_use]
+ pub const fn process_result(result: RhiProcessResult) -> Self {
+ let level = match result {
+ RhiProcessResult::Success => RhiLogLevel::Info,
+ RhiProcessResult::OperationRejectedOrConflict => RhiLogLevel::Warn,
+ RhiProcessResult::UnexpectedInternal
+ | RhiProcessResult::InputOrConfiguration
+ | RhiProcessResult::ServiceOrDependencyUnavailable
+ | RhiProcessResult::StateOrIdentityUnavailable
+ | RhiProcessResult::DoctorRequiredCheckFailed => RhiLogLevel::Error,
+ };
+ Self {
+ level,
+ event: RhiLogEvent::ProcessResult,
+ code: result.code(),
+ process_result: Some(result),
+ }
+ }
+
+ /// Builds the exact phase record from an already-published lifecycle value.
+ #[must_use]
+ pub const fn lifecycle(phase: RhiServicePhase) -> Self {
+ let (level, code) = match phase {
+ RhiServicePhase::Starting => (RhiLogLevel::Info, "starting"),
+ RhiServicePhase::Ready => (RhiLogLevel::Info, "ready"),
+ RhiServicePhase::Degraded => (RhiLogLevel::Warn, "degraded"),
+ RhiServicePhase::Unready => (RhiLogLevel::Warn, "unready"),
+ RhiServicePhase::Stopping => (RhiLogLevel::Info, "stopping"),
+ RhiServicePhase::Failed => (RhiLogLevel::Error, "failed"),
+ };
+ Self {
+ level,
+ event: RhiLogEvent::Lifecycle,
+ code,
+ process_result: None,
+ }
+ }
+
+ #[must_use]
+ pub const fn critical_task_failed() -> Self {
+ Self {
+ level: RhiLogLevel::Error,
+ event: RhiLogEvent::CriticalTaskFailed,
+ code: "critical_task_failed",
+ process_result: None,
+ }
+ }
+
+ #[must_use]
+ pub const fn shutdown_requested() -> Self {
+ Self {
+ level: RhiLogLevel::Info,
+ event: RhiLogEvent::ShutdownRequested,
+ code: "first_signal",
+ process_result: None,
+ }
+ }
+
+ #[must_use]
+ pub const fn shutdown_forced() -> Self {
+ Self {
+ level: RhiLogLevel::Error,
+ event: RhiLogEvent::ShutdownForced,
+ code: "second_signal",
+ process_result: None,
+ }
+ }
+
+ #[must_use]
+ pub const fn level(&self) -> RhiLogLevel {
+ self.level
+ }
+
+ #[must_use]
+ pub const fn event(&self) -> RhiLogEvent {
+ self.event
+ }
+
+ #[must_use]
+ pub const fn code(&self) -> &'static str {
+ self.code
+ }
+
+ #[must_use]
+ pub const fn process_exit(&self) -> Option<RhiProcessResult> {
+ self.process_result
+ }
+}
+
+impl fmt::Debug for RhiLogRecord {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiLogRecord")
+ .field("level", &self.level)
+ .field("event", &self.event)
+ .field("code", &self.code)
+ .field(
+ "exit_code",
+ &self.process_result.map(RhiProcessResult::exit_code_u8),
+ )
+ .finish()
+ }
+}
+
+impl fmt::Display for RhiLogRecord {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ formatter,
+ "{{\"schema\":\"{LOG_SCHEMA}\",\"contract_version\":{RHI_DIAGNOSTICS_CONTRACT_VERSION},\"service\":\"rhi\",\"level\":\"{}\",\"event\":\"{}\",\"code\":\"{}\"",
+ self.level.as_str(),
+ self.event.as_str(),
+ self.code,
+ )?;
+ if let Some(result) = self.process_result {
+ write!(formatter, ",\"exit_code\":{}", result.exit_code_u8())?;
+ }
+ formatter.write_str("}")
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
@@ -4,8 +4,11 @@
mod adapters;
mod admin_v1;
+mod cli_bootstrap;
mod cli_v1;
+mod config_loader;
mod config_v1;
+mod diagnostics_v1;
mod doctor_v1;
mod features;
mod identity_credential;
@@ -14,6 +17,7 @@ mod operations_v1;
mod presence_desired;
mod presence_publication;
mod process_result_v1;
+mod process_v1;
mod publication;
mod publication_attempt;
mod publication_execution;
@@ -28,9 +32,16 @@ mod reconciliation_manifest;
mod reconciliation_reducer;
mod reconciliation_replay;
mod runtime_adapters;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod runtime_admin;
mod runtime_context;
mod runtime_foundation;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod runtime_graph;
+mod runtime_signal;
mod source_ingest;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod state_admin;
mod state_catalog;
mod state_config;
mod state_host;
@@ -39,22 +50,34 @@ mod state_metadata;
mod state_repository;
mod state_trade;
mod status_v1;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod system_doctor;
mod trade_ingest;
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+mod transport_nostr_adapter;
pub use adapters::nostr::event::NostrEventAdapter;
pub use admin_v1::{
RhiAdminCancellationToken, RhiAdminDocumentError, RhiAdminDocumentErrorKind, RhiAdminFuture,
RhiAdminHandler, RhiAdminHandlerError, RhiAdminHandlerErrorKind, RhiAdminMethod,
- RhiAdminRequestDocument, RhiAdminResponseDocument, RhiAdminRoute, RhiAdminRouter,
- RhiAdminRouterError, RhiAdminServer, RhiAdminServerError, RhiAdminServerErrorKind,
- RhiBoundAdminServer, build_rhi_admin_router,
+ RhiAdminRequestDocument, RhiAdminResponseDocument, RhiAdminRoute, RhiAdminRouterError,
+ RhiAdminServerError, RhiAdminServerErrorKind,
};
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub use admin_v1::{RhiAdminRouter, RhiAdminServer, RhiBoundAdminServer, build_rhi_admin_router};
pub use cli_v1::{
RhiBootstrapProfileV1, RhiCliAdminOperationV1, RhiCliExecutionPlanV1, RhiCliInvocationV1,
RhiCliOfflineOperationV1, RhiCliOutputModeV1, RhiCliPrimaryAuthorityV1, RhiCliV1Error,
- RhiCliV1ErrorKind, RhiCommandV1, RhiConfigCommandV1, RhiIdentityCommandV1, RhiMetricsCommandV1,
- RhiPresenceCommandV1, RhiPublicationCommandV1, RhiReconciliationCommandV1, RhiSourcesCommandV1,
- RhiStateCommandV1, RhiTradeCommandV1, parse_rhi_cli_v1_from, plan_rhi_cli_v1,
+ RhiCliV1ErrorKind, RhiCommandV1, RhiConfigApplyArgsV1, RhiConfigCommandV1,
+ RhiIdentityCommandV1, RhiMetricsCommandV1, RhiPageQueryArgsV1, RhiPresenceCommandV1,
+ RhiPresenceMutationArgsV1, RhiPublicationCommandV1, RhiPublicationRetryArgsV1,
+ RhiReconciliationCommandV1, RhiReconciliationRefreshArgsV1, RhiSourcesCommandV1,
+ RhiStateBackupArgsV1, RhiStateCommandV1, RhiStateRestoreArgsV1, RhiTradeArgsV1,
+ RhiTradeCommandV1, RhiTradePageArgsV1, parse_rhi_cli_v1_from, plan_rhi_cli_v1,
+};
+pub use config_loader::{
+ RhiConfigLoadError, RhiConfigLoadErrorKind, initialize_rhi_config_document,
+ load_rhi_config_candidate, load_rhi_config_document,
};
pub use config_v1::{
RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RHI_CONFIG_EFFECTIVE_MAX_UTF8_BYTES, RHI_CONFIG_SCHEMA,
@@ -62,6 +85,10 @@ pub use config_v1::{
RhiConfigV1Error, RhiConfigV1ErrorKind, RhiConfigValueSource, RhiEffectiveConfigV1,
RhiRuntimeThreadLimitsV1, parse_rhi_config_v1,
};
+pub use diagnostics_v1::{
+ RHI_DIAGNOSTICS_CONTRACT_VERSION, RHI_LOG_RECORD_MAX_UTF8_BYTES, RhiLogEvent, RhiLogLevel,
+ RhiLogRecord,
+};
pub use doctor_v1::{
RHI_DOCTOR_CHECK_COUNT, RHI_DOCTOR_CONTRACT_VERSION, RHI_DOCTOR_REPORT_MAX_UTF8_BYTES,
RHI_DOCTOR_SUMMARY_MAX_UTF8_BYTES, RhiDoctorAggregateStatus, RhiDoctorCheckDefinition,
@@ -113,6 +140,7 @@ pub use presence_publication::{
validate_rhi_signed_presence_documents,
};
pub use process_result_v1::RhiProcessResult;
+pub use process_v1::{execute_rhi_cli_v1, execute_rhi_cli_v1_with_signal_source};
pub use publication::{
RHI_PUBLICATION_CONTRACT_VERSION, RHI_PUBLICATION_MAX_ATTEMPTS, RHI_PUBLICATION_MAX_TARGETS,
RhiPublicationAuthority, RhiPublicationError, RhiPublicationErrorKind, RhiPublicationMode,
@@ -212,6 +240,7 @@ pub use runtime_foundation::{
RhiRuntimeFoundationErrorKind, RhiRuntimePrerequisite, RhiRuntimeReadiness,
RhiRuntimeReadinessReason, open_rhi_runtime_foundation,
};
+pub use runtime_signal::{RhiProcessSignal, RhiProcessSignalFuture, RhiProcessSignalSource};
pub use source_ingest::{
RHI_TRADE_SOURCE_INGEST_CONTRACT_VERSION, RHI_TRADE_SOURCE_RESULT_MAX_BYTES,
RHI_TRADE_SOURCE_RESULT_MAX_EVENTS, RhiTradeDirtyGeneration, RhiTradeSourceAttempt,
@@ -235,8 +264,9 @@ pub use state_catalog::{
RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT,
RHI_STATE_SCHEMA_VERSION_9_SHA256, RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256,
RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_10_SHA256,
- RhiStateCatalogError, RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog,
- validate_rhi_state_catalogs,
+ RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT,
+ RHI_STATE_SCHEMA_VERSION_11_SHA256, RhiStateCatalogError, RhiStateCatalogErrorKind,
+ rhi_migration_catalog, rhi_schema_catalog, validate_rhi_state_catalogs,
};
pub use state_config::{
RHI_CONFIG_BINDING_MAX_GENERATIONS, RhiConfigApplyError, RhiConfigApplyErrorKind,
@@ -245,7 +275,8 @@ pub use state_config::{
pub use state_host::{
RhiStateHost, RhiStateHostError, RhiStateHostErrorKind, RhiStateHostMode,
apply_rhi_configuration, initialize_rhi_state, open_rhi_state_inspection,
- open_rhi_state_read_write, open_rhi_state_read_write_from_config,
+ open_rhi_state_inspection_from_config, open_rhi_state_read_write,
+ open_rhi_state_read_write_from_config,
};
pub use state_maintenance::{
RhiStagedStateRestore, RhiStateMaintenanceError, RhiStateMaintenanceErrorKind,
diff --git a/src/main.rs b/src/main.rs
@@ -1,96 +1,71 @@
-#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
+#![forbid(unsafe_code)]
-use std::path::PathBuf;
use std::process::ExitCode;
-use rhi::{
- RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiProcessResult,
- parse_rhi_cli_v1_from, plan_rhi_cli_v1, resolve_rhi_runtime_context,
-};
+use rhi::{RhiLogRecord, RhiProcessResult};
-fn main() -> ExitCode {
- let invocation = match parse_rhi_cli_v1_from(std::env::args_os()) {
- Ok(invocation) => invocation,
- Err(_) => return emit_failure(RhiProcessResult::InputOrConfiguration),
- };
- exit_code_from_run(execute(invocation))
+struct RhiOsSignalSource {
+ #[cfg(unix)]
+ interrupt: tokio::signal::unix::Signal,
+ #[cfg(unix)]
+ terminate: tokio::signal::unix::Signal,
}
-fn exit_code_from_run(result: Result<(), RhiProcessResult>) -> ExitCode {
- match result {
- Ok(()) => RhiProcessResult::Success.exit_code(),
- Err(result) => emit_failure(result),
+impl RhiOsSignalSource {
+ fn new() -> Option<Self> {
+ #[cfg(unix)]
+ {
+ let interrupt =
+ tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt()).ok()?;
+ let terminate =
+ tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).ok()?;
+ Some(Self {
+ interrupt,
+ terminate,
+ })
+ }
+ #[cfg(not(unix))]
+ {
+ Some(Self {})
+ }
}
}
-fn emit_failure(result: RhiProcessResult) -> ExitCode {
- eprintln!("RHI command failed: {}", result.code());
- result.exit_code()
-}
-
-fn execute(invocation: rhi::RhiCliInvocationV1) -> Result<(), RhiProcessResult> {
- let _plan = plan_rhi_cli_v1(&invocation);
- let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), host_environment());
- let _context = resolve_rhi_runtime_context(&resolver, &invocation)
- .map_err(|_| RhiProcessResult::InputOrConfiguration)?;
- Err(RhiProcessResult::InputOrConfiguration)
-}
-
-fn host_environment() -> RadrootsHostEnvironment {
- let path = |name| {
- std::env::var_os(name)
- .filter(|value| !value.is_empty())
- .map(PathBuf::from)
- };
- RadrootsHostEnvironment {
- home_dir: path("HOME"),
- xdg_config_home: path("XDG_CONFIG_HOME"),
- xdg_data_home: path("XDG_DATA_HOME"),
- xdg_state_home: path("XDG_STATE_HOME"),
- xdg_cache_home: path("XDG_CACHE_HOME"),
- xdg_runtime_dir: path("XDG_RUNTIME_DIR"),
- appdata_dir: path("APPDATA"),
- localappdata_dir: path("LOCALAPPDATA"),
+impl rhi::RhiProcessSignalSource for RhiOsSignalSource {
+ fn next_signal(&mut self) -> rhi::RhiProcessSignalFuture<'_> {
+ #[cfg(unix)]
+ {
+ Box::pin(async move {
+ tokio::select! {
+ observed = self.interrupt.recv() => observed.map(|()| rhi::RhiProcessSignal::Interrupt),
+ observed = self.terminate.recv() => observed.map(|()| rhi::RhiProcessSignal::Terminate),
+ }
+ })
+ }
+ #[cfg(not(unix))]
+ {
+ Box::pin(async move {
+ tokio::signal::ctrl_c()
+ .await
+ .ok()
+ .map(|()| rhi::RhiProcessSignal::Interrupt)
+ })
+ }
}
}
-#[cfg(test)]
-mod tests {
- use super::{execute, exit_code_from_run};
- use rhi::{RhiProcessResult, parse_rhi_cli_v1_from};
- use std::process::ExitCode;
-
- #[test]
- fn process_result_is_stable() {
- assert_eq!(exit_code_from_run(Ok(())), ExitCode::SUCCESS);
- assert_eq!(
- exit_code_from_run(Err(RhiProcessResult::UnexpectedInternal)),
- ExitCode::FAILURE
- );
- }
-
- #[test]
- fn admitted_command_fails_closed_without_creating_runtime_state() {
- let root = tempfile::tempdir().expect("temporary repo-local root");
- let invocation = parse_rhi_cli_v1_from([
- "rhi",
- "--profile",
- "repo-local",
- "--instance",
- "default",
- "--repo-local-root",
- root.path().to_str().expect("UTF-8 test root"),
- "run",
- ])
- .expect("valid invocation");
-
- assert_eq!(
- execute(invocation),
- Err(RhiProcessResult::InputOrConfiguration)
- );
- assert_eq!(
- std::fs::read_dir(root.path()).expect("read root").count(),
- 0
- );
+fn main() -> ExitCode {
+ match rhi::parse_rhi_cli_v1_from(std::env::args_os()) {
+ Ok(invocation) => {
+ let result =
+ rhi::execute_rhi_cli_v1_with_signal_source(invocation, RhiOsSignalSource::new);
+ eprintln!("{}", RhiLogRecord::process_result(result));
+ result.exit_code()
+ }
+ Err(_) => {
+ let result = RhiProcessResult::InputOrConfiguration;
+ eprintln!("{}", RhiLogRecord::process_result(result));
+ result.exit_code()
+ }
}
}
diff --git a/src/process_v1.rs b/src/process_v1.rs
@@ -0,0 +1,768 @@
+//! Binary-owned execution for one already-admitted command invocation.
+
+use std::env;
+use std::io::{Read, Write};
+use std::num::NonZeroU64;
+use std::path::{Path, PathBuf};
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use radroots_service_host::{
+ AdminClient, AdminClientErrorKind, AdminClientTarget, AdminOperationId,
+};
+use radroots_service_host::{
+ ContractVersions, EntropySource, SystemEntropy, SystemWallClock, WallClock,
+};
+use radroots_service_sqlite::{
+ BACKUP_MANIFEST_CANONICAL_MAX_BYTES, BackupManifestSha256, IntegrityCheckedAtUnixMs,
+ MigrationAppliedAtUnixSeconds, MigrationBuildIdentity,
+};
+use radroots_storage::event::SourceGeneration;
+use serde_json::{Value, json};
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use crate::admin_v1::{admin_transport_limits, admit_admin_response_value};
+use crate::cli_bootstrap::read_identity_provisioning_document;
+use crate::{
+ RHI_ADMIN_CONTRACT_VERSION, RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES, RHI_CONFIG_SCHEMA_VERSION,
+ RHI_PROVIDER_CONTRACT_VERSION, RHI_STATE_SCHEMA_VERSION, RHI_STATUS_CONTRACT_VERSION,
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, RhiCliInvocationV1,
+ RhiCliOutputModeV1, RhiCliPrimaryAuthorityV1, RhiCommandV1, RhiConfigCommandV1,
+ RhiIdentityCommandV1, RhiProcessResult, RhiRuntimeContext, RhiStateCommandV1, RhiStateMetadata,
+ apply_rhi_configuration, finalize_rhi_state_restore, initialize_rhi_config_document,
+ initialize_rhi_state, load_rhi_config_candidate, load_rhi_config_document,
+ open_rhi_state_read_write_from_config, plan_rhi_cli_v1, provision_rhi_encrypted_identity,
+ resolve_rhi_runtime_context, resolve_rhi_wrapping_credential, stage_rhi_state_restore,
+ verify_rhi_state_backup,
+};
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+use crate::{
+ RhiMetricsCommandV1, RhiPresenceCommandV1, RhiPublicationCommandV1, RhiReconciliationCommandV1,
+ RhiSourcesCommandV1, RhiTradeCommandV1,
+};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct ProcessFailure(RhiProcessResult);
+
+type ProcessResult<T> = Result<T, ProcessFailure>;
+
+/// Executes one admitted RHI invocation without reparsing process arguments.
+#[must_use]
+pub fn execute_rhi_cli_v1(invocation: RhiCliInvocationV1) -> RhiProcessResult {
+ execute(invocation).unwrap_or_else(|failure| failure.0)
+}
+
+/// Executes one admitted invocation with a binary-owned process-signal source.
+#[must_use]
+pub fn execute_rhi_cli_v1_with_signal_source<F, S>(
+ invocation: RhiCliInvocationV1,
+ make_signal_source: F,
+) -> RhiProcessResult
+where
+ F: FnOnce() -> Option<S>,
+ S: crate::RhiProcessSignalSource + 'static,
+{
+ if !matches!(invocation.command(), RhiCommandV1::Run) {
+ return execute_rhi_cli_v1(invocation);
+ }
+ execute_run(invocation, make_signal_source).unwrap_or_else(|failure| failure.0)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn execute_run<F, S>(
+ invocation: RhiCliInvocationV1,
+ make_signal_source: F,
+) -> ProcessResult<RhiProcessResult>
+where
+ F: FnOnce() -> Option<S>,
+ S: crate::RhiProcessSignalSource + 'static,
+{
+ let runtime = resolve_runtime(&invocation)?;
+ let configuration = load_rhi_config_document(&runtime).map_err(|_| input_failure())?;
+ let applied_at = migration_time()?;
+ let build = migration_build_identity()?;
+ let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?;
+ tokio.block_on(async move {
+ let signals = make_signal_source().ok_or(ProcessFailure(
+ RhiProcessResult::ServiceOrDependencyUnavailable,
+ ))?;
+ Ok(crate::runtime_graph::run_rhi_daemon(
+ runtime,
+ configuration,
+ applied_at,
+ &build,
+ signals,
+ )
+ .await)
+ })
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn execute_run<F, S>(
+ _invocation: RhiCliInvocationV1,
+ _make_signal_source: F,
+) -> ProcessResult<RhiProcessResult>
+where
+ F: FnOnce() -> Option<S>,
+ S: crate::RhiProcessSignalSource + 'static,
+{
+ Err(ProcessFailure(
+ RhiProcessResult::ServiceOrDependencyUnavailable,
+ ))
+}
+
+fn execute(invocation: RhiCliInvocationV1) -> ProcessResult<RhiProcessResult> {
+ let plan = plan_rhi_cli_v1(&invocation);
+ if plan.primary_authority() == RhiCliPrimaryAuthorityV1::Daemon {
+ return Err(ProcessFailure(
+ RhiProcessResult::ServiceOrDependencyUnavailable,
+ ));
+ }
+ let runtime = resolve_runtime(&invocation)?;
+ let output = invocation.output_mode();
+ match (plan.primary_authority(), invocation.command()) {
+ (RhiCliPrimaryAuthorityV1::Offline, RhiCommandV1::Config(command)) => {
+ execute_config(output, &runtime, command)
+ }
+ (RhiCliPrimaryAuthorityV1::Offline, RhiCommandV1::State(command)) => {
+ execute_state(output, &runtime, command)
+ }
+ (RhiCliPrimaryAuthorityV1::Offline, RhiCommandV1::Identity(command)) => {
+ execute_identity(output, &runtime, *command)
+ }
+ (RhiCliPrimaryAuthorityV1::Offline, RhiCommandV1::Doctor) => {
+ execute_doctor(output, &runtime)
+ }
+ (RhiCliPrimaryAuthorityV1::LiveUnixAdmin, command) => {
+ execute_live(output, &runtime, command)
+ }
+ _ => Err(ProcessFailure(RhiProcessResult::UnexpectedInternal)),
+ }
+}
+
+fn execute_config(
+ output: RhiCliOutputModeV1,
+ runtime: &RhiRuntimeContext,
+ command: &RhiConfigCommandV1,
+) -> ProcessResult<RhiProcessResult> {
+ match command {
+ RhiConfigCommandV1::Init => {
+ let bytes = read_bounded_stdin(RHI_CONFIG_DOCUMENT_MAX_UTF8_BYTES)?;
+ initialize_rhi_config_document(runtime, &bytes).map_err(|_| input_failure())?;
+ emit_simple_success(output, "config_initialized")
+ }
+ RhiConfigCommandV1::Validate => {
+ load_rhi_config_document(runtime).map_err(|_| input_failure())?;
+ emit_simple_success(output, "config_valid")
+ }
+ RhiConfigCommandV1::Schema => emit_bytes(include_bytes!(
+ "../contracts/services_hardening/config.v1.schema.json"
+ )),
+ RhiConfigCommandV1::Apply(arguments) => {
+ let current = load_rhi_config_document(runtime).map_err(|_| input_failure())?;
+ let candidate = load_rhi_config_candidate(runtime, arguments.candidate_config())
+ .map_err(|_| input_failure())?;
+ let tokio = build_tokio_runtime(current.runtime_thread_limits())?;
+ let outcome = tokio
+ .block_on(apply_rhi_configuration(
+ runtime,
+ ¤t,
+ &candidate,
+ migration_time()?,
+ &migration_build_identity()?,
+ ))
+ .map_err(|_| conflict_failure())?;
+ emit_value(
+ output,
+ "config_applied",
+ json!({"generation": outcome.generation()}),
+ )
+ }
+ RhiConfigCommandV1::Show => Err(ProcessFailure(RhiProcessResult::UnexpectedInternal)),
+ }
+}
+
+fn execute_state(
+ output: RhiCliOutputModeV1,
+ runtime: &RhiRuntimeContext,
+ command: &RhiStateCommandV1,
+) -> ProcessResult<RhiProcessResult> {
+ let configuration = load_rhi_config_document(runtime).map_err(|_| input_failure())?;
+ let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?;
+ match command {
+ RhiStateCommandV1::Init => {
+ let metadata = RhiStateMetadata::new(
+ runtime,
+ &configuration,
+ source_generation()?,
+ wall_time_millis()?,
+ )
+ .map_err(|_| state_failure())?;
+ tokio
+ .block_on(initialize_rhi_state(
+ runtime,
+ &metadata,
+ migration_time()?,
+ &migration_build_identity()?,
+ ))
+ .map_err(|_| state_failure())?;
+ emit_simple_success(output, "state_initialized")
+ }
+ RhiStateCommandV1::Restore(arguments) => {
+ let state = tokio
+ .block_on(open_rhi_state_read_write_from_config(
+ runtime,
+ &configuration,
+ migration_time()?,
+ &migration_build_identity()?,
+ ))
+ .map_err(|_| state_failure())?;
+ let metadata = state.metadata().clone();
+ tokio.block_on(state.close()).map_err(|_| state_failure())?;
+ let manifest =
+ read_bounded_file(arguments.manifest(), BACKUP_MANIFEST_CANONICAL_MAX_BYTES)?;
+ let digest = decode_hex_32(arguments.manifest_sha256())?;
+ let verified = verify_rhi_state_backup(
+ &manifest,
+ BackupManifestSha256::from_bytes(digest),
+ arguments.bundle(),
+ &metadata,
+ NonZeroU64::new(arguments.maximum_state_bytes()).ok_or_else(input_failure)?,
+ )
+ .map_err(|_| state_failure())?;
+ let staged = tokio
+ .block_on(stage_rhi_state_restore(runtime, &metadata, verified))
+ .map_err(|_| state_failure())?;
+ tokio
+ .block_on(finalize_rhi_state_restore(staged))
+ .map_err(|_| state_failure())?;
+ emit_simple_success(output, "state_restore_finalized")
+ }
+ RhiStateCommandV1::Verify | RhiStateCommandV1::Migrate => {
+ let state = tokio
+ .block_on(open_rhi_state_read_write_from_config(
+ runtime,
+ &configuration,
+ migration_time()?,
+ &migration_build_identity()?,
+ ))
+ .map_err(|_| state_failure())?;
+ if matches!(command, RhiStateCommandV1::Verify) {
+ let checked_at =
+ IntegrityCheckedAtUnixMs::new(wall_time_millis()?).ok_or_else(state_failure)?;
+ tokio
+ .block_on(state.inspect_integrity(checked_at))
+ .map_err(|_| state_failure())?;
+ }
+ tokio.block_on(state.close()).map_err(|_| state_failure())?;
+ emit_simple_success(
+ output,
+ if matches!(command, RhiStateCommandV1::Verify) {
+ "state_verified"
+ } else {
+ "state_migrated"
+ },
+ )
+ }
+ RhiStateCommandV1::Status | RhiStateCommandV1::Backup(_) => {
+ Err(ProcessFailure(RhiProcessResult::UnexpectedInternal))
+ }
+ }
+}
+
+fn execute_identity(
+ output: RhiCliOutputModeV1,
+ runtime: &RhiRuntimeContext,
+ command: RhiIdentityCommandV1,
+) -> ProcessResult<RhiProcessResult> {
+ if command != RhiIdentityCommandV1::Init {
+ return Err(ProcessFailure(RhiProcessResult::UnexpectedInternal));
+ }
+ let configuration = load_rhi_config_document(runtime).map_err(|_| input_failure())?;
+ let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?;
+ let state = tokio
+ .block_on(open_rhi_state_read_write_from_config(
+ runtime,
+ &configuration,
+ migration_time()?,
+ &migration_build_identity()?,
+ ))
+ .map_err(|_| state_failure())?;
+ let metadata = state.metadata().clone();
+ tokio.block_on(state.close()).map_err(|_| state_failure())?;
+ let binding = crate::RhiIdentityEnvelopeBinding::from_configuration(&configuration, &metadata)
+ .map_err(|_| state_failure())?;
+ let credential =
+ resolve_rhi_wrapping_credential(runtime, &binding).map_err(|_| state_failure())?;
+ let material = read_identity_provisioning_document(std::io::stdin().lock())
+ .map_err(|_| input_failure())?;
+ let identity = provision_rhi_encrypted_identity(&binding, &credential, material)
+ .map_err(|_| state_failure())?;
+ emit_value(
+ output,
+ "identity_initialized",
+ json!({"generation": 0, "public_key": identity.public_identity().as_hex(), "role": "service"}),
+ )
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn execute_doctor(
+ _output: RhiCliOutputModeV1,
+ runtime: &RhiRuntimeContext,
+) -> ProcessResult<RhiProcessResult> {
+ let configuration = load_rhi_config_document(runtime).map_err(|_| input_failure())?;
+ let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?;
+ let report = tokio
+ .block_on(crate::run_rhi_doctor(
+ runtime,
+ &crate::system_doctor::RhiSystemDoctorProbe::new(runtime, &configuration),
+ ))
+ .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?;
+ emit_bytes(report.canonical_json())?;
+ if report.exit_code() == 0 {
+ Ok(RhiProcessResult::Success)
+ } else {
+ Err(ProcessFailure(RhiProcessResult::DoctorRequiredCheckFailed))
+ }
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn execute_doctor(
+ _output: RhiCliOutputModeV1,
+ _runtime: &RhiRuntimeContext,
+) -> ProcessResult<RhiProcessResult> {
+ Err(ProcessFailure(
+ RhiProcessResult::ServiceOrDependencyUnavailable,
+ ))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn execute_live(
+ _output: RhiCliOutputModeV1,
+ runtime: &RhiRuntimeContext,
+ command: &RhiCommandV1,
+) -> ProcessResult<RhiProcessResult> {
+ let configuration = load_rhi_config_document(runtime).map_err(|_| input_failure())?;
+ let tokio = build_tokio_runtime(configuration.runtime_thread_limits())?;
+ let bytes = tokio.block_on(live_command(runtime, &configuration, command))?;
+ emit_bytes(&bytes)
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn execute_live(
+ _output: RhiCliOutputModeV1,
+ _runtime: &RhiRuntimeContext,
+ _command: &RhiCommandV1,
+) -> ProcessResult<RhiProcessResult> {
+ Err(ProcessFailure(
+ RhiProcessResult::ServiceOrDependencyUnavailable,
+ ))
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+async fn live_command(
+ runtime: &RhiRuntimeContext,
+ configuration: &crate::RhiConfigDocumentV1,
+ command: &RhiCommandV1,
+) -> ProcessResult<Box<[u8]>> {
+ use crate::RhiAdminRoute as Route;
+ let (route, target, mutation) = match command {
+ RhiCommandV1::Config(RhiConfigCommandV1::Show) => (
+ Route::EffectiveConfig,
+ Route::EffectiveConfig.path().to_owned(),
+ None,
+ ),
+ RhiCommandV1::State(RhiStateCommandV1::Status) => (
+ Route::StateStatus,
+ Route::StateStatus.path().to_owned(),
+ None,
+ ),
+ RhiCommandV1::State(RhiStateCommandV1::Backup(arguments)) => (
+ Route::StateBackup,
+ Route::StateBackup.path().to_owned(),
+ Some((
+ arguments.operation_id(),
+ json!({
+ "confirmation": "confirm",
+ "expected_generation": arguments.expected_generation(),
+ "target_path": arguments.target().to_str().ok_or_else(input_failure)?,
+ }),
+ )),
+ ),
+ RhiCommandV1::Identity(RhiIdentityCommandV1::Status) => (
+ Route::IdentityStatus,
+ format!("{}?role=service", Route::IdentityStatus.path()),
+ None,
+ ),
+ RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic) => (
+ Route::IdentityPublic,
+ format!("{}?role=service", Route::IdentityPublic.path()),
+ None,
+ ),
+ RhiCommandV1::Status => (Route::Status, Route::Status.path().to_owned(), None),
+ RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot) => (
+ Route::MetricsSnapshot,
+ Route::MetricsSnapshot.path().to_owned(),
+ None,
+ ),
+ RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status) => (
+ Route::ReconciliationStatus,
+ Route::ReconciliationStatus.path().to_owned(),
+ None,
+ ),
+ RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs(page)) => (
+ Route::ReconciliationJobs,
+ paged_target(Route::ReconciliationJobs.path(), page),
+ None,
+ ),
+ RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh(arguments)) => (
+ Route::ReconciliationRefresh,
+ Route::ReconciliationRefresh.path().to_owned(),
+ Some((
+ arguments.operation_id(),
+ json!({
+ "expected_dirty_generation": arguments.expected_dirty_generation(),
+ "trade_id": arguments.trade_id(),
+ }),
+ )),
+ ),
+ RhiCommandV1::Sources(RhiSourcesCommandV1::List(page)) => (
+ Route::Sources,
+ paged_target(Route::Sources.path(), page),
+ None,
+ ),
+ RhiCommandV1::Trade(RhiTradeCommandV1::Projection(arguments)) => (
+ Route::TradeProjection,
+ Route::TradeProjection
+ .path()
+ .replace("{trade_id}", arguments.trade_id()),
+ None,
+ ),
+ RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent(arguments)) => (
+ Route::TradeReportCurrent,
+ Route::TradeReportCurrent
+ .path()
+ .replace("{trade_id}", arguments.trade_id()),
+ None,
+ ),
+ RhiCommandV1::Trade(RhiTradeCommandV1::Reports(arguments)) => (
+ Route::TradeReports,
+ paged_target(
+ &Route::TradeReports
+ .path()
+ .replace("{trade_id}", arguments.trade_id()),
+ arguments.page(),
+ ),
+ None,
+ ),
+ RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog(page)) => (
+ Route::PublicationBacklog,
+ paged_target(Route::PublicationBacklog.path(), page),
+ None,
+ ),
+ RhiCommandV1::Publication(RhiPublicationCommandV1::Targets(page)) => (
+ Route::PublicationTargets,
+ paged_target(Route::PublicationTargets.path(), page),
+ None,
+ ),
+ RhiCommandV1::Publication(RhiPublicationCommandV1::Retry(arguments)) => (
+ Route::PublicationRetry,
+ Route::PublicationRetry.path().to_owned(),
+ Some((
+ arguments.operation_id(),
+ json!({
+ "expected_generation": arguments.expected_generation(),
+ "workflow_id": arguments.workflow_id(),
+ }),
+ )),
+ ),
+ RhiCommandV1::Presence(RhiPresenceCommandV1::Desired) => (
+ Route::PresenceDesired,
+ Route::PresenceDesired.path().to_owned(),
+ None,
+ ),
+ RhiCommandV1::Presence(RhiPresenceCommandV1::Render(arguments)) => (
+ Route::PresenceRender,
+ Route::PresenceRender.path().to_owned(),
+ Some((
+ arguments.operation_id(),
+ json!({"expected_generation": arguments.expected_generation()}),
+ )),
+ ),
+ RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh(arguments)) => (
+ Route::PresenceRefresh,
+ Route::PresenceRefresh.path().to_owned(),
+ Some((
+ arguments.operation_id(),
+ json!({"expected_generation": arguments.expected_generation()}),
+ )),
+ ),
+ _ => return Err(ProcessFailure(RhiProcessResult::UnexpectedInternal)),
+ };
+ let client = AdminClient::new(
+ runtime.artifacts().admin_socket(),
+ admin_transport_limits(configuration).map_err(|_| input_failure())?,
+ )
+ .map_err(|_| input_failure())?;
+ let target = AdminClientTarget::new(target).map_err(|_| input_failure())?;
+ let result = if let Some((operation_id, request)) = mutation {
+ let operation_id = AdminOperationId::new(operation_id).map_err(|_| input_failure())?;
+ client
+ .mutate::<_, Value>(&target, operation_id, None, request)
+ .await
+ .map(|response| response.result().clone())
+ } else {
+ client
+ .get::<Value>(&target)
+ .await
+ .map(|response| response.result().clone())
+ };
+ match result {
+ Ok(value) => admit_admin_response_value(route, &value)
+ .map_err(|_| ProcessFailure(RhiProcessResult::ServiceOrDependencyUnavailable)),
+ Err(error) if error.kind() == AdminClientErrorKind::ServerFailure => {
+ Err(conflict_failure())
+ }
+ Err(_) => Err(ProcessFailure(
+ RhiProcessResult::ServiceOrDependencyUnavailable,
+ )),
+ }
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn paged_target(path: &str, page: &crate::RhiPageQueryArgsV1) -> String {
+ let mut target = format!("{path}?limit={}", page.limit());
+ if let Some(cursor) = page.cursor() {
+ target.push_str("&cursor=");
+ target.push_str(cursor);
+ }
+ target
+}
+
+fn resolve_runtime(invocation: &RhiCliInvocationV1) -> ProcessResult<RhiRuntimeContext> {
+ let resolver = RadrootsPathResolver::new(RadrootsPlatform::current(), host_environment());
+ resolve_rhi_runtime_context(&resolver, invocation).map_err(|_| input_failure())
+}
+
+fn migration_build_identity() -> ProcessResult<MigrationBuildIdentity> {
+ let versions = ContractVersions::new(
+ RHI_CONFIG_SCHEMA_VERSION,
+ RHI_STATE_SCHEMA_VERSION,
+ RHI_ADMIN_CONTRACT_VERSION,
+ RHI_STATUS_CONTRACT_VERSION,
+ RHI_PROVIDER_CONTRACT_VERSION,
+ )
+ .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?;
+ let build = radroots_service_host::compile_time_build_info!(
+ feature_profile: "service-host",
+ contract_versions: versions,
+ )
+ .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?;
+ MigrationBuildIdentity::new(
+ build.service_version(),
+ build.service_commit(),
+ build.lib_revision(),
+ build.rust_version(),
+ build.target(),
+ build.feature_profile(),
+ versions.config(),
+ versions.state(),
+ versions.admin(),
+ versions.status(),
+ versions.provider(),
+ )
+ .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))
+}
+
+fn source_generation() -> ProcessResult<SourceGeneration> {
+ for _ in 0..4 {
+ let mut bytes = [0_u8; 32];
+ SystemEntropy
+ .fill_bytes(&mut bytes)
+ .map_err(|_| state_failure())?;
+ if let Ok(generation) = SourceGeneration::new(bytes) {
+ return Ok(generation);
+ }
+ }
+ Err(state_failure())
+}
+
+fn wall_time_seconds() -> ProcessResult<u64> {
+ SystemWallClock
+ .now_utc()
+ .map(|time| time.get())
+ .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))
+}
+
+fn wall_time_millis() -> ProcessResult<u64> {
+ wall_time_seconds()?
+ .checked_mul(1_000)
+ .filter(|value| *value <= i64::MAX as u64)
+ .ok_or(ProcessFailure(RhiProcessResult::UnexpectedInternal))
+}
+
+fn migration_time() -> ProcessResult<MigrationAppliedAtUnixSeconds> {
+ MigrationAppliedAtUnixSeconds::new(wall_time_seconds()?)
+ .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))
+}
+
+fn build_tokio_runtime(
+ limits: crate::RhiRuntimeThreadLimitsV1,
+) -> ProcessResult<tokio::runtime::Runtime> {
+ if tokio::runtime::Handle::try_current().is_ok() {
+ return Err(ProcessFailure(RhiProcessResult::UnexpectedInternal));
+ }
+ tokio::runtime::Builder::new_multi_thread()
+ .worker_threads(limits.worker_threads())
+ .max_blocking_threads(limits.blocking_threads())
+ .enable_all()
+ .build()
+ .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))
+}
+
+fn host_environment() -> RadrootsHostEnvironment {
+ let path = |name| {
+ env::var_os(name)
+ .filter(|value| !value.is_empty())
+ .map(PathBuf::from)
+ };
+ RadrootsHostEnvironment {
+ home_dir: path("HOME"),
+ xdg_config_home: path("XDG_CONFIG_HOME"),
+ xdg_data_home: path("XDG_DATA_HOME"),
+ xdg_state_home: path("XDG_STATE_HOME"),
+ xdg_cache_home: path("XDG_CACHE_HOME"),
+ xdg_runtime_dir: path("XDG_RUNTIME_DIR"),
+ appdata_dir: path("APPDATA"),
+ localappdata_dir: path("LOCALAPPDATA"),
+ }
+}
+
+fn read_bounded_stdin(maximum: usize) -> ProcessResult<Vec<u8>> {
+ let mut reader = std::io::stdin().lock();
+ let mut bytes = Vec::with_capacity(maximum.min(64 * 1_024).saturating_add(1));
+ Read::by_ref(&mut reader)
+ .take(u64::try_from(maximum).unwrap_or(u64::MAX).saturating_add(1))
+ .read_to_end(&mut bytes)
+ .map_err(|_| input_failure())?;
+ if bytes.len() > maximum {
+ return Err(input_failure());
+ }
+ Ok(bytes)
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn read_bounded_file(path: &Path, maximum: usize) -> ProcessResult<Vec<u8>> {
+ crate::config_loader::read_secure_bounded_file(path, maximum).map_err(|_| state_failure())
+}
+
+#[cfg(not(any(target_os = "linux", target_os = "macos")))]
+fn read_bounded_file(_path: &Path, _maximum: usize) -> ProcessResult<Vec<u8>> {
+ Err(state_failure())
+}
+
+fn decode_hex_32(value: &str) -> ProcessResult<[u8; 32]> {
+ if value.len() != 64 {
+ return Err(input_failure());
+ }
+ let mut output = [0_u8; 32];
+ for (index, pair) in value.as_bytes().chunks_exact(2).enumerate() {
+ output[index] = (hex_nibble(pair[0])? << 4) | hex_nibble(pair[1])?;
+ }
+ Ok(output)
+}
+
+const fn hex_nibble(value: u8) -> ProcessResult<u8> {
+ match value {
+ b'0'..=b'9' => Ok(value - b'0'),
+ b'a'..=b'f' => Ok(value - b'a' + 10),
+ _ => Err(input_failure()),
+ }
+}
+
+fn emit_simple_success(
+ output: RhiCliOutputModeV1,
+ code: &'static str,
+) -> ProcessResult<RhiProcessResult> {
+ emit_value(output, code, json!({"ok": true}))
+}
+
+fn emit_value(
+ output: RhiCliOutputModeV1,
+ code: &'static str,
+ value: Value,
+) -> ProcessResult<RhiProcessResult> {
+ let bytes = match output {
+ RhiCliOutputModeV1::Json => serde_json::to_vec(&value)
+ .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?,
+ RhiCliOutputModeV1::Human => code.as_bytes().to_vec(),
+ };
+ emit_bytes(&bytes)
+}
+
+fn emit_bytes(bytes: &[u8]) -> ProcessResult<RhiProcessResult> {
+ let mut stdout = std::io::stdout().lock();
+ stdout
+ .write_all(bytes)
+ .and_then(|()| {
+ if bytes.ends_with(b"\n") {
+ Ok(())
+ } else {
+ stdout.write_all(b"\n")
+ }
+ })
+ .and_then(|()| stdout.flush())
+ .map_err(|_| ProcessFailure(RhiProcessResult::UnexpectedInternal))?;
+ Ok(RhiProcessResult::Success)
+}
+
+const fn input_failure() -> ProcessFailure {
+ ProcessFailure(RhiProcessResult::InputOrConfiguration)
+}
+
+const fn state_failure() -> ProcessFailure {
+ ProcessFailure(RhiProcessResult::StateOrIdentityUnavailable)
+}
+
+const fn conflict_failure() -> ProcessFailure {
+ ProcessFailure(RhiProcessResult::OperationRejectedOrConflict)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn runtime_limits_are_explicit_and_digest_decoding_is_strict() {
+ let source = include_str!("process_v1.rs")
+ .split("#[cfg(test)]")
+ .next()
+ .expect("production source");
+ assert!(source.contains("worker_threads(limits.worker_threads())"));
+ assert!(source.contains("max_blocking_threads(limits.blocking_threads())"));
+ assert!(!source.contains("available_parallelism"));
+ assert_eq!(
+ decode_hex_32(&"ab".repeat(32)).expect("lowercase digest"),
+ [0xab; 32]
+ );
+ assert!(decode_hex_32(&"AB".repeat(32)).is_err());
+ }
+
+ #[test]
+ fn nested_tokio_runtime_creation_fails_closed_without_panicking() {
+ let configuration = crate::parse_rhi_config_v1(
+ include_bytes!("../contracts/services_hardening/config.v1.example.toml"),
+ crate::RhiConfigProfile::Production,
+ )
+ .expect("configuration fixture");
+ let outer = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .expect("outer runtime");
+ let result =
+ outer.block_on(async { build_tokio_runtime(configuration.runtime_thread_limits()) });
+ assert_eq!(
+ result.expect_err("nested runtime must be rejected"),
+ ProcessFailure(RhiProcessResult::UnexpectedInternal)
+ );
+ }
+}
diff --git a/src/reconciliation_attestation.rs b/src/reconciliation_attestation.rs
@@ -157,6 +157,52 @@ impl RhiEvidenceAttestationSupersession {
event_id: EventId::from_bytes(attestation.event_id),
}
}
+
+ #[cfg(any(test, target_os = "linux", target_os = "macos"))]
+ pub(crate) fn from_persisted(
+ trade_id: &radroots_event::id::TradeId,
+ statement_sha256: [u8; 32],
+ event_id: [u8; 32],
+ canonical_report: &[u8],
+ canonical_event_json: &[u8],
+ ) -> Result<Self, RhiReconciliationAttestationError> {
+ let report = RadrootsRhiEvidenceReportV1::from_canonical_content(canonical_report)
+ .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?;
+ if report.trade_id() != trade_id
+ || report.statement_digest().as_bytes() != &statement_sha256
+ {
+ return Err(failure(
+ RhiReconciliationAttestationErrorKind::SupersessionInvalid,
+ ));
+ }
+ let source = core::str::from_utf8(canonical_event_json)
+ .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?;
+ let wire = Nip01EventWire::parse_json_unverified_with_limits(source, signed_event_limits())
+ .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?;
+ let event = wire
+ .into_unverified_envelope()
+ .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?;
+ if verify_id(&event) != Verification::IdVerified
+ || verify(&event) != Verification::Verified
+ || event.id().as_bytes() != &event_id
+ || *event.author() != report.issuer_public_key()
+ {
+ return Err(failure(
+ RhiReconciliationAttestationErrorKind::SupersessionInvalid,
+ ));
+ }
+ let typed = rhi_evidence_attestation_from_event(&event)
+ .map_err(|_| failure(RhiReconciliationAttestationErrorKind::SupersessionInvalid))?;
+ if typed.canonical_content() != report.canonical_content() {
+ return Err(failure(
+ RhiReconciliationAttestationErrorKind::SupersessionInvalid,
+ ));
+ }
+ Ok(Self {
+ report,
+ event_id: EventId::from_bytes(event_id),
+ })
+ }
}
impl fmt::Debug for RhiEvidenceAttestationSupersession {
@@ -548,6 +594,46 @@ mod tests {
}
#[test]
+ fn persisted_supersession_revalidates_canonical_report_and_signature() {
+ let value: serde_json::Value = serde_json::from_str(SIGNED_VECTOR).expect("signed vector");
+ let content = value["content"].as_str().expect("report content");
+ let report = RadrootsRhiEvidenceReportV1::from_canonical_content(content.as_bytes())
+ .expect("canonical report");
+ let event = verify_signed_event_plan(&vector_plan(), SIGNED_VECTOR.trim_end().as_bytes())
+ .expect("verified event");
+ let supersession = RhiEvidenceAttestationSupersession::from_persisted(
+ report.trade_id(),
+ *report.statement_digest().as_bytes(),
+ *event.id().as_bytes(),
+ content.as_bytes(),
+ SIGNED_VECTOR.trim_end().as_bytes(),
+ )
+ .expect("verified persisted supersession");
+ assert_eq!(
+ format!("{supersession:?}"),
+ "RhiEvidenceAttestationSupersession([redacted])"
+ );
+
+ for (statement, event_id) in [
+ ([0; 32], *event.id().as_bytes()),
+ (*report.statement_digest().as_bytes(), [0; 32]),
+ ] {
+ assert_eq!(
+ RhiEvidenceAttestationSupersession::from_persisted(
+ report.trade_id(),
+ statement,
+ event_id,
+ content.as_bytes(),
+ SIGNED_VECTOR.trim_end().as_bytes(),
+ )
+ .expect_err("mismatched persisted identity")
+ .kind(),
+ RhiReconciliationAttestationErrorKind::SupersessionInvalid
+ );
+ }
+ }
+
+ #[test]
fn every_public_error_class_is_source_free_and_redacted() {
for kind in [
RhiReconciliationAttestationErrorKind::InvalidInput,
diff --git a/src/reconciliation_replay.rs b/src/reconciliation_replay.rs
@@ -143,6 +143,44 @@ impl RhiReconciliationSourceCursorEvidence {
}
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) async fn read_committed_reconciliation_cursor(
+ repositories: &crate::RhiStateRepositories<'_>,
+ request: &crate::RhiReconciliationSourceRequest,
+ policy: crate::RhiEvidencePolicyDigest,
+) -> Result<Option<RhiReconciliationSourceCursorEvidence>, ()> {
+ let source_id: Box<str> = request.source_id().into();
+ let trade_id = request.trade_id();
+ let selector_digest = *request.selector_digest().as_bytes();
+ repositories
+ .host()
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ crate::source_ingest::read_checkpoint(
+ transaction,
+ source_id.as_ref(),
+ policy,
+ trade_id,
+ )
+ .await
+ .map(|checkpoint| {
+ checkpoint.map(|checkpoint| {
+ committed_cursor_evidence(
+ source_id,
+ trade_id,
+ *policy.as_bytes(),
+ selector_digest,
+ checkpoint.cursor,
+ )
+ })
+ })
+ })
+ })
+ .await
+ .map_err(|_| ())
+}
+
impl fmt::Debug for RhiReconciliationSourceCursorEvidence {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
diff --git a/src/runtime_adapters.rs b/src/runtime_adapters.rs
@@ -154,6 +154,16 @@ pub struct RhiTimeEntropyAdapters {
entropy: Arc<dyn EntropySource>,
}
+impl Clone for RhiTimeEntropyAdapters {
+ fn clone(&self) -> Self {
+ Self {
+ wall: Arc::clone(&self.wall),
+ monotonic: Arc::clone(&self.monotonic),
+ entropy: Arc::clone(&self.entropy),
+ }
+ }
+}
+
impl RhiTimeEntropyAdapters {
/// Owns injected adapters without reading a clock or entropy source.
pub fn new<W, M, E>(wall: W, monotonic: M, entropy: E) -> Self
@@ -192,6 +202,11 @@ impl RhiTimeEntropyAdapters {
})
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) fn entropy(&self) -> &dyn EntropySource {
+ self.entropy.as_ref()
+ }
+
/// Reads one observation from the injected process-local monotonic domain.
#[must_use]
pub fn now_monotonic(&self) -> MonotonicTime {
@@ -251,8 +266,18 @@ impl fmt::Debug for RhiTimeEntropyAdapters {
/// do not become public runtime authority.
pub struct RhiTransportAdapters {
evidence_source: Arc<dyn EventSource>,
- _evidence_subscriber: Arc<dyn EventSubscriber>,
- _publication_sink: Arc<dyn EventSink>,
+ evidence_subscriber: Arc<dyn EventSubscriber>,
+ publication_sink: Arc<dyn EventSink>,
+}
+
+impl Clone for RhiTransportAdapters {
+ fn clone(&self) -> Self {
+ Self {
+ evidence_source: Arc::clone(&self.evidence_source),
+ evidence_subscriber: Arc::clone(&self.evidence_subscriber),
+ publication_sink: Arc::clone(&self.publication_sink),
+ }
+ }
}
impl RhiTransportAdapters {
@@ -265,14 +290,19 @@ impl RhiTransportAdapters {
) -> Self {
Self {
evidence_source,
- _evidence_subscriber: evidence_subscriber,
- _publication_sink: publication_sink,
+ evidence_subscriber,
+ publication_sink,
}
}
pub(crate) fn evidence_source(&self) -> &dyn EventSource {
self.evidence_source.as_ref()
}
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) fn evidence_subscriber(&self) -> &dyn EventSubscriber {
+ self.evidence_subscriber.as_ref()
+ }
}
impl fmt::Debug for RhiTransportAdapters {
@@ -388,7 +418,7 @@ impl fmt::Debug for RhiIdentityCredentialAdapters {
#[must_use = "runtime adapters retain join-owned task authority"]
pub struct RhiRuntimeAdapters {
time_entropy: RhiTimeEntropyAdapters,
- _transport: RhiTransportAdapters,
+ transport: RhiTransportAdapters,
identity_credential: RhiIdentityCredentialAdapters,
supervisor: TaskSupervisor,
}
@@ -402,7 +432,7 @@ impl RhiRuntimeAdapters {
) -> Self {
Self {
time_entropy,
- _transport: transport,
+ transport,
identity_credential,
supervisor: TaskSupervisor::new(),
}
@@ -420,6 +450,11 @@ impl RhiRuntimeAdapters {
&self.identity_credential
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) const fn transport(&self) -> &RhiTransportAdapters {
+ &self.transport
+ }
+
/// Returns the number of join-owned tasks currently registered.
#[must_use]
pub fn supervised_task_count(&self) -> usize {
@@ -435,7 +470,7 @@ impl RhiRuntimeAdapters {
.map_err(|_| ())
}
- #[cfg(test)]
+ #[cfg(any(test, target_os = "linux", target_os = "macos"))]
pub(crate) fn supervisor_mut(&mut self) -> &mut TaskSupervisor {
&mut self.supervisor
}
@@ -443,6 +478,7 @@ impl RhiRuntimeAdapters {
impl fmt::Debug for RhiRuntimeAdapters {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let _ = &self.transport;
formatter
.debug_struct("RhiRuntimeAdapters")
.field("time_entropy", &"[injected]")
diff --git a/src/runtime_admin.rs b/src/runtime_admin.rs
@@ -0,0 +1,2023 @@
+//! State-backed implementation of the final RHI Unix-admin contract.
+
+use core::sync::atomic::{AtomicBool, Ordering};
+use std::{path::PathBuf, sync::Arc};
+
+use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
+use hmac::{Hmac, Mac as _};
+use radroots_event::id::TradeId;
+use radroots_service_sqlite::BackupCreatedAtUnixMs;
+use serde_json::{Map, Value, json};
+use sha2::{Digest as _, Sha256};
+use sqlx::Row;
+
+use crate::{
+ RhiAdminFuture, RhiAdminHandler, RhiAdminHandlerError, RhiAdminHandlerErrorKind,
+ RhiAdminRequestDocument, RhiAdminResponseDocument, RhiAdminRoute, RhiConfigDocumentV1,
+ RhiDecryptedIdentity, RhiPresenceDesiredAuthority, RhiPresenceUnixMilliseconds,
+ RhiPublicationAuthority, RhiReconciliationJobErrorKind, RhiReconciliationJobPolicy,
+ RhiReconciliationUnixMilliseconds, RhiStateHost, RhiStatusReader, RhiTimeEntropyAdapters,
+ build_rhi_signed_presence_documents,
+ state_admin::{
+ AdminJournalOperationError, RhiAdminOperationAdmission, RhiAdminOperationError,
+ RhiAdminOperationErrorKind, RhiAdminOperationJournalPolicy, RhiAdminOperationRepository,
+ RhiAdminOperationTimeUnixMs,
+ },
+ state_config,
+};
+
+const REPORT_SELECT: &str = r#"SELECT
+ length(report.statement_sha256) AS statement_bytes,
+ substr(report.statement_sha256, 1, 33) AS statement_sha256,
+ length(report.manifest_sha256) AS manifest_bytes,
+ substr(report.manifest_sha256, 1, 33) AS manifest_sha256,
+ length(report.projection_sha256) AS projection_bytes,
+ substr(report.projection_sha256, 1, 33) AS projection_sha256,
+ length(report.trade_id) AS trade_id_bytes,
+ substr(report.trade_id, 1, 17) AS trade_id,
+ length(report.claim_mutation_id) AS claim_bytes,
+ substr(report.claim_mutation_id, 1, 33) AS claim_mutation_id,
+ length(report.issuer_public_key) AS issuer_bytes,
+ substr(report.issuer_public_key, 1, 33) AS issuer_public_key,
+ report.outcome,
+ length(report.canonical_report) AS canonical_report_bytes,
+ substr(report.canonical_report, 1, 16385) AS canonical_report,
+ report.observed_at_unix_s,
+ CASE WHEN report.supersedes_statement_sha256 IS NULL THEN NULL
+ ELSE length(report.supersedes_statement_sha256) END AS supersedes_bytes,
+ CASE WHEN report.supersedes_statement_sha256 IS NULL THEN NULL
+ ELSE substr(report.supersedes_statement_sha256, 1, 33) END AS supersedes_statement_sha256,
+ length(event.event_id) AS event_id_bytes,
+ substr(event.event_id, 1, 33) AS event_id,
+ manifest.trade_generation,
+ length(manifest.evidence_policy_sha256) AS policy_bytes,
+ substr(manifest.evidence_policy_sha256, 1, 33) AS evidence_policy_sha256,
+ projection.reducer_contract,
+ projection.reducer_contract_version,
+ (SELECT COUNT(*) FROM evidence_reconciliation_sources AS source
+ WHERE source.attempt_id = manifest.attempt_id) AS source_count,
+ (SELECT COUNT(*) FROM evidence_reconciliation_sources AS source
+ WHERE source.attempt_id = manifest.attempt_id AND source.required = 1
+ AND source.completion != 'complete') AS incomplete_required,
+ (SELECT COUNT(*) FROM evidence_reconciliation_sources AS source
+ WHERE source.attempt_id = manifest.attempt_id AND source.required = 1
+ AND source.completion = 'unsupported') AS unsupported_required,
+ (SELECT COALESCE(SUM(source.accepted_event_count), 0)
+ FROM evidence_reconciliation_sources AS source
+ WHERE source.attempt_id = manifest.attempt_id) AS accepted_event_count
+FROM attestation_reports AS report
+JOIN signed_attestation_events AS event
+ ON event.statement_sha256 = report.statement_sha256
+JOIN evidence_manifests AS manifest
+ ON manifest.manifest_sha256 = report.manifest_sha256
+JOIN trade_projections AS projection
+ ON projection.projection_sha256 = report.projection_sha256
+"#;
+
+const REPORT_ROWS_SUFFIX: &str = r#"WHERE report.trade_id = ? AND report.observed_at_unix_s <= ?
+ AND (? IS NULL OR report.outcome = ?)
+ ORDER BY report.observed_at_unix_s DESC, report.statement_sha256 DESC
+ LIMIT ? OFFSET ?"#;
+
+const CURRENT_REPORT_SUFFIX: &str = r#"WHERE report.trade_id = ?
+ AND NOT EXISTS (
+ SELECT 1 FROM attestation_reports AS successor
+ WHERE successor.supersedes_statement_sha256 = report.statement_sha256
+ )
+ ORDER BY report.observed_at_unix_s DESC, report.statement_sha256 DESC
+ LIMIT 2"#;
+
+const PUBLICATION_BACKLOG_SQL: &str = r#"SELECT
+ length(outbox.outbox_id) AS outbox_id_bytes,
+ substr(outbox.outbox_id, 1, 33) AS outbox_id,
+ length(event.statement_sha256) AS statement_bytes,
+ substr(event.statement_sha256, 1, 33) AS statement_sha256,
+ length(outbox.event_id) AS event_id_bytes,
+ substr(outbox.event_id, 1, 33) AS event_id,
+ length(outbox.event_sha256) AS event_sha256_bytes,
+ substr(outbox.event_sha256, 1, 33) AS event_sha256,
+ outbox.state AS outbox_state,
+ outbox.next_attempt_unix_ms,
+ COALESCE(MAX(target.attempt_count), 0) AS attempt_count,
+ CASE
+ WHEN outbox.state = 'pending' THEN 'pending'
+ WHEN outbox.state = 'leased' THEN 'submitted'
+ WHEN outbox.state = 'complete' THEN 'accepted'
+ WHEN SUM(CASE WHEN target.state = 'auth_required' THEN 1 ELSE 0 END) > 0 THEN 'auth_required'
+ WHEN SUM(CASE WHEN target.state = 'rate_limited' THEN 1 ELSE 0 END) > 0 THEN 'rate_limited'
+ WHEN SUM(CASE WHEN target.state = 'rejected' THEN 1 ELSE 0 END) > 0 THEN 'rejected'
+ WHEN SUM(CASE WHEN target.state = 'failed' THEN 1 ELSE 0 END) > 0 THEN 'failed'
+ ELSE 'unknown'
+ END AS public_state
+FROM publication_outbox AS outbox
+JOIN signed_attestation_events AS event ON event.event_id = outbox.event_id
+JOIN publication_targets AS target ON target.outbox_id = outbox.outbox_id
+WHERE outbox.updated_at_unix_ms <= ?
+GROUP BY outbox.outbox_id
+HAVING (? IS NULL OR public_state = ?)
+ORDER BY outbox.updated_at_unix_ms DESC, outbox.outbox_id DESC
+LIMIT ? OFFSET ?"#;
+
+const PUBLICATION_TARGETS_SQL: &str = r#"SELECT
+ length(target.outbox_id) AS outbox_id_bytes,
+ substr(target.outbox_id, 1, 33) AS outbox_id,
+ target.relay_id, target.state, target.attempt_count,
+ (SELECT MAX(attempt.finished_at_unix_ms)
+ FROM publication_attempts AS attempt
+ WHERE attempt.outbox_id = target.outbox_id
+ AND attempt.target_ordinal = target.target_ordinal) AS last_attempt_unix_ms
+FROM publication_targets AS target
+WHERE target.updated_at_unix_ms <= ?
+ AND (? IS NULL OR target.outbox_id = ?)
+ AND (? IS NULL OR target.state = ?)
+ORDER BY target.updated_at_unix_ms DESC, target.outbox_id DESC, target.target_ordinal
+LIMIT ? OFFSET ?"#;
+
+pub(crate) struct RuntimeAdminHandler {
+ pub(crate) state: Arc<RhiStateHost>,
+ pub(crate) configuration: Arc<RhiConfigDocumentV1>,
+ pub(crate) identity: Arc<RhiDecryptedIdentity>,
+ pub(crate) publication: Arc<RhiPublicationAuthority>,
+ pub(crate) presence: Arc<RhiPresenceDesiredAuthority>,
+ pub(crate) status: RhiStatusReader,
+ pub(crate) accepting_mutations: Arc<AtomicBool>,
+ pub(crate) cursor_key: [u8; 32],
+ pub(crate) time_entropy: RhiTimeEntropyAdapters,
+}
+
+impl RuntimeAdminHandler {
+ async fn handle_inner(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ if request.route().is_mutation() && !self.accepting_mutations.load(Ordering::Acquire) {
+ return Err(failure(RhiAdminHandlerErrorKind::Unavailable));
+ }
+ match request.route() {
+ RhiAdminRoute::Status => {
+ let snapshot = self.status.snapshot();
+ let value = serde_json::from_slice(snapshot.detailed_status_json())
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ response(request.route(), value)
+ }
+ RhiAdminRoute::EffectiveConfig => RhiAdminResponseDocument::from_canonical_bytes(
+ request.route(),
+ self.configuration.effective().canonical_json().as_bytes(),
+ )
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)),
+ RhiAdminRoute::IdentityStatus | RhiAdminRoute::IdentityPublic => {
+ self.identity(&request).await
+ }
+ RhiAdminRoute::StateStatus => self.state_status(request.route()).await,
+ RhiAdminRoute::StateBackup => self.backup(request).await,
+ RhiAdminRoute::MetricsSnapshot => self.metrics_snapshot(request.route()),
+ RhiAdminRoute::ReconciliationStatus => self.reconciliation_status(request).await,
+ RhiAdminRoute::ReconciliationJobs => self.reconciliation_jobs(request).await,
+ RhiAdminRoute::ReconciliationRefresh => self.reconciliation_refresh(request).await,
+ RhiAdminRoute::Sources => self.sources(request).await,
+ RhiAdminRoute::TradeProjection => self.trade_projection(request).await,
+ RhiAdminRoute::TradeReportCurrent => self.trade_report_current(request).await,
+ RhiAdminRoute::TradeReports => self.trade_reports(request).await,
+ RhiAdminRoute::PublicationBacklog => self.publication_backlog(request).await,
+ RhiAdminRoute::PublicationTargets => self.publication_targets(request).await,
+ RhiAdminRoute::PublicationRetry => self.publication_retry(request).await,
+ RhiAdminRoute::PresenceDesired => self.presence_desired(request.route()).await,
+ RhiAdminRoute::PresenceRender => self.presence_render(request).await,
+ RhiAdminRoute::PresenceRefresh => self.presence_refresh(request).await,
+ }
+ }
+
+ async fn identity(
+ &self,
+ request: &RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(request)?;
+ if model.pointer("/role").and_then(Value::as_str) != Some("service") {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ let generation = current_generation(&self.state).await?;
+ let value = if request.route() == RhiAdminRoute::IdentityPublic {
+ json!({
+ "generation": generation,
+ "public_key": self.identity.public_identity().as_hex(),
+ "role": "service",
+ })
+ } else {
+ json!({
+ "available": true,
+ "configured": true,
+ "generation": generation,
+ "provider": "encrypted_file",
+ "public_key": self.identity.public_identity().as_hex(),
+ "reason_codes": [],
+ "role": "service",
+ })
+ };
+ response(request.route(), value)
+ }
+
+ async fn state_status(
+ &self,
+ route: RhiAdminRoute,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ response(
+ route,
+ json!({
+ "backup_eligible": true,
+ "generation": current_generation(&self.state).await?,
+ "integrity": "verified",
+ "reason_codes": [],
+ "schema_version": self.state.metadata().initial_database_metadata().state_schema_version().get(),
+ "writer_lock": "held_by_daemon",
+ }),
+ )
+ }
+
+ fn metrics_snapshot(
+ &self,
+ route: RhiAdminRoute,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let snapshot = self.status.operations_cache().snapshot();
+ let mut metrics = Map::new();
+ for sample in snapshot.metrics().samples() {
+ let mut key = sample.name().as_str().to_owned();
+ for label in sample.labels() {
+ key.push('_');
+ key.push_str(label.value());
+ }
+ let value = match sample.value() {
+ radroots_service_host::MetricValue::Counter(value) => value,
+ radroots_service_host::MetricValue::Gauge(value) => {
+ u64::try_from(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?
+ }
+ };
+ if metrics.insert(key, Value::from(value)).is_some() {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ }
+ response(
+ route,
+ json!({
+ "captured_at_utc": self.now_seconds()?,
+ "metrics": metrics,
+ }),
+ )
+ }
+
+ async fn backup(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let expected_generation = required_u64(&model, "/expected_generation")?;
+ let generation = current_generation(&self.state).await?;
+ if expected_generation != generation {
+ return Err(failure(RhiAdminHandlerErrorKind::Conflict));
+ }
+ let now_ms = self.now_millis()?;
+ let prepared = match RhiAdminOperationRepository::new(&self.state)
+ .prepare_admin_operation(&request, operation_time(now_ms)?)
+ .await
+ .map_err(map_journal_error)?
+ {
+ RhiAdminOperationAdmission::ExactReplay(response) => return Ok(response),
+ RhiAdminOperationAdmission::Prepared(prepared) => prepared,
+ };
+ let target = model
+ .pointer("/target_path")
+ .and_then(Value::as_str)
+ .map(PathBuf::from)
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let manifest = self
+ .state
+ .capture_online_backup(
+ &target,
+ BackupCreatedAtUnixMs::new(now_ms)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?,
+ )
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable))?;
+ let completed_ms = self.now_millis()?;
+ let completed_seconds = completed_ms / 1_000;
+ let completed = response(
+ request.route(),
+ json!({
+ "completed_at_utc": completed_seconds,
+ "manifest_digest": lower_hex(manifest.digest().as_bytes()),
+ "operation_id": required_operation_id(&request)?,
+ "snapshot_generation": generation,
+ }),
+ )?;
+ RhiAdminOperationRepository::new(&self.state)
+ .complete_admin_operation(
+ &prepared,
+ &completed,
+ operation_time(completed_ms)?,
+ RhiAdminOperationJournalPolicy::seven_days(),
+ )
+ .await
+ .map_err(map_journal_error)?;
+ Ok(completed)
+ }
+
+ async fn presence_desired(
+ &self,
+ route: RhiAdminRoute,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let desired = self
+ .state
+ .repositories()
+ .desired_presence()
+ .current()
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::NotFound))?;
+ let (state, digests) = self.presence_state(desired.generation()).await?;
+ response(
+ route,
+ json!({
+ "document_digests": digests,
+ "generation": desired.generation(),
+ "reason_codes": [],
+ "state": if desired.mode().code() == "disabled" { "disabled" } else { state },
+ }),
+ )
+ }
+
+ async fn presence_render(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let expected = required_u64(&model, "/expected_generation")?;
+ let desired = self
+ .state
+ .repositories()
+ .desired_presence()
+ .commit(&self.presence)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ if desired.state().generation() != expected {
+ return Err(failure(RhiAdminHandlerErrorKind::Conflict));
+ }
+ let now_ms = self.now_millis()?;
+ let prepared = match RhiAdminOperationRepository::new(&self.state)
+ .prepare_admin_operation(&request, operation_time(now_ms)?)
+ .await
+ .map_err(map_journal_error)?
+ {
+ RhiAdminOperationAdmission::ExactReplay(response) => return Ok(response),
+ RhiAdminOperationAdmission::Prepared(prepared) => prepared,
+ };
+ let documents = build_rhi_signed_presence_documents(
+ desired,
+ &self.presence,
+ &self.identity,
+ self.time_entropy
+ .now_utc()
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable))?,
+ self.time_entropy.entropy(),
+ )
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable))?;
+ let digests = presence_document_digests(&documents);
+ self.state
+ .repositories()
+ .presence_outbox()
+ .commit_signed_presence(
+ &documents,
+ RhiPresenceUnixMilliseconds::new(now_ms)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?,
+ )
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let completed_ms = self.now_millis()?;
+ let completed = response(
+ request.route(),
+ json!({
+ "document_digests": digests,
+ "generation": expected,
+ "operation_id": required_operation_id(&request)?,
+ }),
+ )?;
+ RhiAdminOperationRepository::new(&self.state)
+ .complete_admin_operation(
+ &prepared,
+ &completed,
+ operation_time(completed_ms)?,
+ RhiAdminOperationJournalPolicy::seven_days(),
+ )
+ .await
+ .map_err(map_journal_error)?;
+ Ok(completed)
+ }
+
+ fn now_millis(&self) -> Result<u64, RhiAdminHandlerError> {
+ self.time_entropy
+ .now_utc_milliseconds()
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable))
+ }
+
+ fn now_seconds(&self) -> Result<u64, RhiAdminHandlerError> {
+ self.time_entropy
+ .now_utc()
+ .map(|value| value.get())
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Unavailable))
+ }
+
+ async fn read_dirty_generation(&self, trade: TradeId) -> Result<u64, RhiAdminHandlerError> {
+ self.state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let rows = sqlx::query(
+ "SELECT generation FROM trade_dirty_generations WHERE trade_id = ? LIMIT 2",
+ )
+ .bind(trade.as_bytes().as_slice())
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ if rows.len() != 1 {
+ return Err(if rows.is_empty() {
+ failure(RhiAdminHandlerErrorKind::NotFound)
+ } else {
+ failure(RhiAdminHandlerErrorKind::Internal)
+ });
+ }
+ row_u64(&rows[0], "generation")
+ })
+ })
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ }
+
+ async fn current_report_row(
+ &self,
+ trade: TradeId,
+ ) -> Result<sqlx::sqlite::SqliteRow, RhiAdminHandlerError> {
+ self.state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let sql = format!("{REPORT_SELECT}{CURRENT_REPORT_SUFFIX}");
+ // Both fragments are private compile-time constants; no caller data enters SQL.
+ let rows = sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
+ .bind(trade.as_bytes().as_slice())
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ if rows.len() != 1 {
+ return Err(if rows.is_empty() {
+ failure(RhiAdminHandlerErrorKind::NotFound)
+ } else {
+ failure(RhiAdminHandlerErrorKind::Internal)
+ });
+ }
+ Ok(rows.into_iter().next().expect("one row was established"))
+ })
+ })
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ }
+
+ fn cursor_or_new(
+ &self,
+ model: &Value,
+ route: RhiAdminRoute,
+ query_digest: [u8; 32],
+ new_snapshot: u64,
+ ) -> Result<(u64, u32), RhiAdminHandlerError> {
+ model
+ .pointer("/cursor")
+ .and_then(Value::as_str)
+ .map(|cursor| self.decode_cursor(route, cursor, query_digest))
+ .transpose()
+ .map(|cursor| cursor.unwrap_or((new_snapshot, 0)))
+ }
+
+ fn encode_cursor(
+ &self,
+ route: RhiAdminRoute,
+ snapshot: u64,
+ offset: u32,
+ query_digest: [u8; 32],
+ ) -> String {
+ encode_runtime_cursor(&self.cursor_key, route, snapshot, offset, query_digest)
+ }
+
+ fn decode_cursor(
+ &self,
+ route: RhiAdminRoute,
+ encoded: &str,
+ query_digest: [u8; 32],
+ ) -> Result<(u64, u32), RhiAdminHandlerError> {
+ decode_runtime_cursor(&self.cursor_key, route, encoded, query_digest)
+ }
+}
+
+impl RhiAdminHandler for RuntimeAdminHandler {
+ fn handle<'a>(&'a self, request: RhiAdminRequestDocument) -> RhiAdminFuture<'a> {
+ Box::pin(async move { self.handle_inner(request).await })
+ }
+}
+
+fn presence_document_digests(documents: &crate::RhiSignedPresenceDocuments) -> Map<String, Value> {
+ documents
+ .documents()
+ .iter()
+ .map(|document| {
+ (
+ document.kind().code().to_owned(),
+ Value::String(lower_hex(document.signed_event_sha256())),
+ )
+ })
+ .collect()
+}
+
+fn request_model(request: &RhiAdminRequestDocument) -> Result<Value, RhiAdminHandlerError> {
+ serde_json::from_slice(request.model_bytes())
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+}
+
+fn required_u64(value: &Value, pointer: &str) -> Result<u64, RhiAdminHandlerError> {
+ value
+ .pointer(pointer)
+ .and_then(Value::as_u64)
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))
+}
+
+fn required_operation_id(request: &RhiAdminRequestDocument) -> Result<&str, RhiAdminHandlerError> {
+ request
+ .operation_id()
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))
+}
+
+fn operation_time(value: u64) -> Result<RhiAdminOperationTimeUnixMs, RhiAdminHandlerError> {
+ RhiAdminOperationTimeUnixMs::new(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+}
+
+async fn current_generation(state: &RhiStateHost) -> Result<u64, RhiAdminHandlerError> {
+ state_config::current_generation(state)
+ .await
+ .map(u64::from)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+}
+
+fn response(
+ route: RhiAdminRoute,
+ value: Value,
+) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let bytes =
+ serde_json::to_vec(&value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ RhiAdminResponseDocument::from_canonical_bytes(route, &bytes)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+}
+
+fn map_journal_error(error: RhiAdminOperationError) -> RhiAdminHandlerError {
+ failure(match error.kind() {
+ RhiAdminOperationErrorKind::OperationConflict => {
+ RhiAdminHandlerErrorKind::OperationIdConflict
+ }
+ RhiAdminOperationErrorKind::ResourceExhausted => RhiAdminHandlerErrorKind::Unavailable,
+ RhiAdminOperationErrorKind::OperationOutcomeUnknown
+ | RhiAdminOperationErrorKind::CommitOutcomeUnknown => RhiAdminHandlerErrorKind::Unavailable,
+ RhiAdminOperationErrorKind::InvalidMode
+ | RhiAdminOperationErrorKind::InvalidInput
+ | RhiAdminOperationErrorKind::Binding
+ | RhiAdminOperationErrorKind::Transaction => RhiAdminHandlerErrorKind::Internal,
+ })
+}
+
+const fn failure(kind: RhiAdminHandlerErrorKind) -> RhiAdminHandlerError {
+ RhiAdminHandlerError::new(kind)
+}
+
+fn lower_hex(bytes: &[u8]) -> String {
+ use core::fmt::Write as _;
+
+ let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
+ for byte in bytes {
+ write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
+ }
+ encoded
+}
+
+fn parse_trade_id(value: &str) -> Result<TradeId, RhiAdminHandlerError> {
+ TradeId::parse(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+}
+
+fn parse_digest(value: &str) -> Result<[u8; 32], RhiAdminHandlerError> {
+ if value.len() != 64
+ || value
+ .as_bytes()
+ .iter()
+ .any(|byte| !byte.is_ascii_hexdigit() || byte.is_ascii_uppercase())
+ {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ let mut bytes = [0_u8; 32];
+ for (index, chunk) in value.as_bytes().chunks_exact(2).enumerate() {
+ let high =
+ hex_nibble(chunk[0]).ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let low =
+ hex_nibble(chunk[1]).ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?;
+ bytes[index] = (high << 4) | low;
+ }
+ Ok(bytes)
+}
+
+const fn hex_nibble(byte: u8) -> Option<u8> {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ _ => None,
+ }
+}
+
+fn page_limit(model: &Value) -> Result<u16, RhiAdminHandlerError> {
+ model
+ .pointer("/limit")
+ .and_then(Value::as_u64)
+ .and_then(|value| u16::try_from(value).ok())
+ .filter(|value| (1..=200).contains(value))
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))
+}
+
+fn query_digest(model: &Value) -> Result<[u8; 32], RhiAdminHandlerError> {
+ let mut model = model.clone();
+ model
+ .as_object_mut()
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?
+ .remove("cursor");
+ let bytes =
+ serde_json::to_vec(&model).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ Ok(Sha256::digest(bytes).into())
+}
+
+fn cursor_authenticator(key: &[u8; 32], payload: &[u8]) -> Hmac<Sha256> {
+ let mut digest = Hmac::<Sha256>::new_from_slice(key).expect("SHA-256 HMAC accepts every key");
+ digest.update(b"radroots.rhi.admin_cursor.v1\0");
+ digest.update(
+ &u64::try_from(payload.len())
+ .expect("cursor payload length fits u64")
+ .to_be_bytes(),
+ );
+ digest.update(payload);
+ digest
+}
+
+fn cursor_tag(key: &[u8; 32], payload: &[u8]) -> [u8; 32] {
+ cursor_authenticator(key, payload)
+ .finalize()
+ .into_bytes()
+ .into()
+}
+
+fn encode_runtime_cursor(
+ key: &[u8; 32],
+ route: RhiAdminRoute,
+ snapshot: u64,
+ offset: u32,
+ query_digest: [u8; 32],
+) -> String {
+ let mut bytes = Vec::with_capacity(78);
+ bytes.push(1);
+ bytes.push(route_code(route));
+ bytes.extend_from_slice(&snapshot.to_be_bytes());
+ bytes.extend_from_slice(&offset.to_be_bytes());
+ bytes.extend_from_slice(&query_digest);
+ let tag = cursor_tag(key, &bytes);
+ bytes.extend_from_slice(&tag);
+ URL_SAFE_NO_PAD.encode(bytes)
+}
+
+fn decode_runtime_cursor(
+ key: &[u8; 32],
+ route: RhiAdminRoute,
+ encoded: &str,
+ query_digest: [u8; 32],
+) -> Result<(u64, u32), RhiAdminHandlerError> {
+ let bytes = URL_SAFE_NO_PAD
+ .decode(encoded)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::InvalidCursor))?;
+ if bytes.len() != 78
+ || bytes[0] != 1
+ || bytes[1] != route_code(route)
+ || bytes[14..46] != query_digest
+ {
+ return Err(failure(RhiAdminHandlerErrorKind::InvalidCursor));
+ }
+ cursor_authenticator(key, &bytes[..46])
+ .verify_slice(&bytes[46..])
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::InvalidCursor))?;
+ let snapshot = u64::from_be_bytes(
+ bytes[2..10]
+ .try_into()
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::InvalidCursor))?,
+ );
+ let offset = u32::from_be_bytes(
+ bytes[10..14]
+ .try_into()
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::InvalidCursor))?,
+ );
+ Ok((snapshot, offset))
+}
+
+const fn route_code(route: RhiAdminRoute) -> u8 {
+ match route {
+ RhiAdminRoute::Status => 0,
+ RhiAdminRoute::EffectiveConfig => 1,
+ RhiAdminRoute::IdentityStatus => 2,
+ RhiAdminRoute::IdentityPublic => 3,
+ RhiAdminRoute::StateStatus => 4,
+ RhiAdminRoute::StateBackup => 5,
+ RhiAdminRoute::MetricsSnapshot => 6,
+ RhiAdminRoute::ReconciliationStatus => 7,
+ RhiAdminRoute::ReconciliationJobs => 8,
+ RhiAdminRoute::ReconciliationRefresh => 9,
+ RhiAdminRoute::Sources => 10,
+ RhiAdminRoute::TradeProjection => 11,
+ RhiAdminRoute::TradeReportCurrent => 12,
+ RhiAdminRoute::TradeReports => 13,
+ RhiAdminRoute::PublicationBacklog => 14,
+ RhiAdminRoute::PublicationTargets => 15,
+ RhiAdminRoute::PublicationRetry => 16,
+ RhiAdminRoute::PresenceDesired => 17,
+ RhiAdminRoute::PresenceRender => 18,
+ RhiAdminRoute::PresenceRefresh => 19,
+ }
+}
+
+fn row_u64(row: &sqlx::sqlite::SqliteRow, name: &str) -> Result<u64, RhiAdminHandlerError> {
+ row.try_get::<i64, _>(name)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ .and_then(|value| {
+ u64::try_from(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ })
+}
+
+fn optional_nonnegative_i64(
+ row: &sqlx::sqlite::SqliteRow,
+ name: &str,
+) -> Result<Option<u64>, RhiAdminHandlerError> {
+ row.try_get::<Option<i64>, _>(name)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?
+ .map(|value| u64::try_from(value).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)))
+ .transpose()
+}
+
+fn exact_blob<const N: usize>(
+ row: &sqlx::sqlite::SqliteRow,
+ value: &str,
+ length: &str,
+) -> Result<[u8; N], RhiAdminHandlerError> {
+ if row_u64(row, length)? != N as u64 {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ row.try_get::<Vec<u8>, _>(value)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?
+ .try_into()
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+}
+
+fn optional_exact_blob<const N: usize>(
+ row: &sqlx::sqlite::SqliteRow,
+ value: &str,
+ length: &str,
+) -> Result<Option<[u8; N]>, RhiAdminHandlerError> {
+ let length = row
+ .try_get::<Option<i64>, _>(length)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ match length {
+ None => {
+ if row
+ .try_get::<Option<Vec<u8>>, _>(value)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?
+ .is_some()
+ {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ Ok(None)
+ }
+ Some(length) if length == N as i64 => row
+ .try_get::<Option<Vec<u8>>, _>(value)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?
+ .try_into()
+ .map(Some)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal)),
+ Some(_) => Err(failure(RhiAdminHandlerErrorKind::Internal)),
+ }
+}
+
+fn bounded_blob(
+ row: &sqlx::sqlite::SqliteRow,
+ value: &str,
+ length: &str,
+ maximum: usize,
+) -> Result<Box<[u8]>, RhiAdminHandlerError> {
+ let length = row_u64(row, length)?;
+ if length == 0 || length > maximum as u64 {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ let bytes = row
+ .try_get::<Vec<u8>, _>(value)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ if bytes.len() as u64 != length {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ Ok(bytes.into_boxed_slice())
+}
+
+fn job_summary(row: &sqlx::sqlite::SqliteRow) -> Result<Value, RhiAdminHandlerError> {
+ let job_id = exact_blob::<32>(row, "job_id", "job_id_bytes")?;
+ let trade_id = exact_blob::<16>(row, "trade_id", "trade_id_bytes")?;
+ let attempt_count = row_u64(row, "attempt_count")?;
+ let state = row
+ .try_get::<&str, _>("state")
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let state = match state {
+ "ready" if attempt_count == 0 => "pending",
+ "ready" => "retry_scheduled",
+ "leased" => "leased",
+ "completed" => "completed",
+ "exhausted" | "superseded" => "failed",
+ _ => return Err(failure(RhiAdminHandlerErrorKind::Internal)),
+ };
+ let mut value = json!({
+ "attempt_count": attempt_count,
+ "dirty_generation": row_u64(row, "input_generation")?,
+ "job_id": lower_hex(&job_id),
+ "reason_codes": [],
+ "scheduled_at_utc": row_u64(row, "created_at_unix_ms")? / 1_000,
+ "state": state,
+ "trade_id": lower_hex(&trade_id),
+ });
+ if let Some(expires) = optional_nonnegative_i64(row, "lease_expires_unix_ms")? {
+ value["lease_expires_at_utc"] = Value::from(expires / 1_000);
+ }
+ Ok(value)
+}
+
+struct ConfiguredSourceSummary {
+ source_id: Box<str>,
+ required: bool,
+}
+
+fn configured_source_summaries(
+ configuration: &RhiConfigDocumentV1,
+) -> Result<Vec<ConfiguredSourceSummary>, RhiAdminHandlerError> {
+ let sources = configuration
+ .normalized()
+ .pointer("/evidence/sources")
+ .and_then(Value::as_array)
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?;
+ if sources.is_empty() || sources.len() > 16 {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ sources
+ .iter()
+ .map(|source| {
+ let source_id = source
+ .pointer("/source_id")
+ .and_then(Value::as_str)
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let required = source
+ .pointer("/required")
+ .and_then(Value::as_bool)
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?;
+ Ok(ConfiguredSourceSummary {
+ source_id: source_id.into(),
+ required,
+ })
+ })
+ .collect()
+}
+
+fn request_trade_id(request: &RhiAdminRequestDocument) -> Result<TradeId, RhiAdminHandlerError> {
+ request
+ .parameter("trade_id")
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))
+ .and_then(parse_trade_id)
+}
+
+fn contract_outcome_to_db(value: &str) -> Result<&'static str, RhiAdminHandlerError> {
+ match value {
+ "Valid" => Ok("valid"),
+ "Invalid" => Ok("invalid"),
+ "Indeterminate" => Ok("indeterminate"),
+ _ => Err(failure(RhiAdminHandlerErrorKind::Internal)),
+ }
+}
+
+fn db_outcome_to_contract(value: &str) -> Result<&'static str, RhiAdminHandlerError> {
+ match value {
+ "valid" => Ok("Valid"),
+ "invalid" => Ok("Invalid"),
+ "indeterminate" => Ok("Indeterminate"),
+ _ => Err(failure(RhiAdminHandlerErrorKind::Internal)),
+ }
+}
+
+fn report_coverage(row: &sqlx::sqlite::SqliteRow) -> Result<&'static str, RhiAdminHandlerError> {
+ let source_count = row_u64(row, "source_count")?;
+ let incomplete = row_u64(row, "incomplete_required")?;
+ let unsupported = row_u64(row, "unsupported_required")?;
+ let accepted = row_u64(row, "accepted_event_count")?;
+ if source_count == 0 {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ Ok(if unsupported > 0 {
+ "Unsupported"
+ } else if incomplete > 0 && accepted == 0 {
+ "Missing"
+ } else if incomplete > 0 {
+ "Partial"
+ } else {
+ "ScopeSatisfied"
+ })
+}
+
+fn report_detail(row: &sqlx::sqlite::SqliteRow) -> Result<Value, RhiAdminHandlerError> {
+ let canonical = bounded_blob(row, "canonical_report", "canonical_report_bytes", 16_384)?;
+ let mut report: Value = serde_json::from_slice(&canonical)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let object = report
+ .as_object_mut()
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let statement = lower_hex(&exact_blob::<32>(
+ row,
+ "statement_sha256",
+ "statement_bytes",
+ )?);
+ let manifest = lower_hex(&exact_blob::<32>(row, "manifest_sha256", "manifest_bytes")?);
+ let projection = lower_hex(&exact_blob::<32>(
+ row,
+ "projection_sha256",
+ "projection_bytes",
+ )?);
+ let trade = lower_hex(&exact_blob::<16>(row, "trade_id", "trade_id_bytes")?);
+ let claim = lower_hex(&exact_blob::<32>(row, "claim_mutation_id", "claim_bytes")?);
+ let issuer = lower_hex(&exact_blob::<32>(row, "issuer_public_key", "issuer_bytes")?);
+ let policy = lower_hex(&exact_blob::<32>(
+ row,
+ "evidence_policy_sha256",
+ "policy_bytes",
+ )?);
+ let observed = row_u64(row, "observed_at_unix_s")?;
+ let generation = row_u64(row, "trade_generation")?;
+ let outcome = row
+ .try_get::<&str, _>("outcome")
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let reducer_contract = row
+ .try_get::<&str, _>("reducer_contract")
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let reducer_version = row_u64(row, "reducer_contract_version")?;
+ let required_matches = [
+ ("report_id", statement.as_str()),
+ ("evidence_manifest_digest", manifest.as_str()),
+ ("projection_digest", projection.as_str()),
+ ("trade_id", trade.as_str()),
+ ("claim_mutation_id", claim.as_str()),
+ ("issuer_pubkey", issuer.as_str()),
+ ("evidence_policy_digest", policy.as_str()),
+ ("reducer_contract_id", reducer_contract),
+ ]
+ .into_iter()
+ .all(|(field, expected)| object.get(field).and_then(Value::as_str) == Some(expected));
+ if !required_matches
+ || object.get("trade_generation").and_then(Value::as_u64) != Some(generation)
+ || object.get("observed_at_unix_s").and_then(Value::as_u64) != Some(observed)
+ || object
+ .get("reducer_contract_version")
+ .and_then(Value::as_u64)
+ != Some(reducer_version)
+ || object.get("outcome").and_then(Value::as_str) != Some(outcome)
+ || object.get("statement_digest").and_then(Value::as_str) != Some(statement.as_str())
+ {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ let supersedes =
+ optional_exact_blob::<32>(row, "supersedes_statement_sha256", "supersedes_bytes")?;
+ match supersedes {
+ Some(value)
+ if object.get("supersedes_report_id").and_then(Value::as_str)
+ == Some(lower_hex(&value).as_str()) => {}
+ None if object
+ .get("supersedes_report_id")
+ .is_some_and(Value::is_null) =>
+ {
+ object.remove("supersedes_report_id");
+ object.remove("supersedes_event_id");
+ }
+ _ => return Err(failure(RhiAdminHandlerErrorKind::Internal)),
+ }
+ object.remove("statement_digest");
+ object.insert(
+ "attestation_event_id".to_owned(),
+ Value::String(lower_hex(&exact_blob::<32>(
+ row,
+ "event_id",
+ "event_id_bytes",
+ )?)),
+ );
+ object.insert(
+ "coverage".to_owned(),
+ Value::String(report_coverage(row)?.to_owned()),
+ );
+ object.insert(
+ "outcome".to_owned(),
+ Value::String(db_outcome_to_contract(outcome)?.to_owned()),
+ );
+ Ok(report)
+}
+
+fn report_summary(row: &sqlx::sqlite::SqliteRow) -> Result<Value, RhiAdminHandlerError> {
+ let supersedes =
+ optional_exact_blob::<32>(row, "supersedes_statement_sha256", "supersedes_bytes")?;
+ let outcome = row
+ .try_get::<&str, _>("outcome")
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let mut value = json!({
+ "attestation_event_id": lower_hex(&exact_blob::<32>(row, "event_id", "event_id_bytes")?),
+ "claim_mutation_id": lower_hex(&exact_blob::<32>(row, "claim_mutation_id", "claim_bytes")?),
+ "coverage": report_coverage(row)?,
+ "observed_at_utc": row_u64(row, "observed_at_unix_s")?,
+ "outcome": db_outcome_to_contract(outcome)?,
+ "report_id": lower_hex(&exact_blob::<32>(row, "statement_sha256", "statement_bytes")?),
+ "trade_id": lower_hex(&exact_blob::<16>(row, "trade_id", "trade_id_bytes")?),
+ });
+ if let Some(supersedes) = supersedes {
+ value["supersedes_report_id"] = Value::String(lower_hex(&supersedes));
+ }
+ Ok(value)
+}
+
+fn publication_summary(row: &sqlx::sqlite::SqliteRow) -> Result<Value, RhiAdminHandlerError> {
+ let state = row
+ .try_get::<&str, _>("public_state")
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ if !matches!(
+ state,
+ "pending"
+ | "submitted"
+ | "accepted"
+ | "rejected"
+ | "rate_limited"
+ | "auth_required"
+ | "failed"
+ | "unknown"
+ ) {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ let mut value = json!({
+ "attempt_count": row_u64(row, "attempt_count")?,
+ "event_id": lower_hex(&exact_blob::<32>(row, "event_id", "event_id_bytes")?),
+ "exact_bytes_digest": lower_hex(&exact_blob::<32>(row, "event_sha256", "event_sha256_bytes")?),
+ "reason_codes": if state == "failed" || state == "unknown" { vec!["publication_blocked"] } else { Vec::<&str>::new() },
+ "report_id": lower_hex(&exact_blob::<32>(row, "statement_sha256", "statement_bytes")?),
+ "state": state,
+ "workflow_id": lower_hex(&exact_blob::<32>(row, "outbox_id", "outbox_id_bytes")?),
+ });
+ if let Some(next) = optional_nonnegative_i64(row, "next_attempt_unix_ms")? {
+ value["next_attempt_at_utc"] = Value::from(next / 1_000);
+ }
+ Ok(value)
+}
+
+fn publication_target_summary(
+ row: &sqlx::sqlite::SqliteRow,
+) -> Result<Value, RhiAdminHandlerError> {
+ let target = row
+ .try_get::<&str, _>("relay_id")
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let state = row
+ .try_get::<&str, _>("state")
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ if target.is_empty()
+ || target.len() > 64
+ || !matches!(
+ state,
+ "pending"
+ | "submitted"
+ | "accepted"
+ | "rejected"
+ | "rate_limited"
+ | "auth_required"
+ | "failed"
+ | "unknown"
+ )
+ {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ let mut value = json!({
+ "attempt_count": row_u64(row, "attempt_count")?,
+ "reason_codes": if state == "failed" || state == "unknown" { vec!["publication_target_failed"] } else { Vec::<&str>::new() },
+ "state": state,
+ "target_id": target,
+ "workflow_id": lower_hex(&exact_blob::<32>(row, "outbox_id", "outbox_id_bytes")?),
+ });
+ if let Some(last) = optional_nonnegative_i64(row, "last_attempt_unix_ms")? {
+ value["last_attempt_at_utc"] = Value::from(last / 1_000);
+ }
+ Ok(value)
+}
+
+fn operation_row_u64(
+ row: &sqlx::sqlite::SqliteRow,
+ name: &str,
+) -> Result<u64, AdminJournalOperationError> {
+ row.try_get::<i64, _>(name)
+ .ok()
+ .and_then(|value| u64::try_from(value).ok())
+ .ok_or(AdminJournalOperationError::Binding)
+}
+
+fn operation_exact_blob<const N: usize>(
+ row: &sqlx::sqlite::SqliteRow,
+ value: &str,
+ length: &str,
+) -> Result<[u8; N], AdminJournalOperationError> {
+ if operation_row_u64(row, length)? != N as u64 {
+ return Err(AdminJournalOperationError::Binding);
+ }
+ row.try_get::<Vec<u8>, _>(value)
+ .map_err(|_| AdminJournalOperationError::Binding)?
+ .try_into()
+ .map_err(|_| AdminJournalOperationError::Binding)
+}
+
+fn presence_kind(row: &sqlx::sqlite::SqliteRow) -> Result<&str, RhiAdminHandlerError> {
+ match row
+ .try_get::<&str, _>("document_kind")
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?
+ {
+ kind @ ("service_profile" | "application_handler") => Ok(kind),
+ _ => Err(failure(RhiAdminHandlerErrorKind::Internal)),
+ }
+}
+
+fn operation_presence_kind(
+ row: &sqlx::sqlite::SqliteRow,
+) -> Result<&str, AdminJournalOperationError> {
+ match row
+ .try_get::<&str, _>("document_kind")
+ .map_err(|_| AdminJournalOperationError::Binding)?
+ {
+ kind @ ("service_profile" | "application_handler") => Ok(kind),
+ _ => Err(AdminJournalOperationError::Binding),
+ }
+}
+
+// Domain-route methods are kept below the transport-independent common boundary.
+impl RuntimeAdminHandler {
+ async fn reconciliation_status(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let policy_digest = lower_hex(self.state.metadata().evidence_policy_digest().as_bytes());
+ let value = self
+ .state
+ .sqlite_host()
+ .transaction(|transaction| {
+ Box::pin(async move {
+ let row = sqlx::query(
+ r#"SELECT
+ (SELECT COUNT(*) FROM trade_dirty_generations) AS dirty_count,
+ COUNT(CASE WHEN state = 'ready' AND attempt_count = 0 THEN 1 END) AS pending_count,
+ COUNT(CASE WHEN state = 'ready' AND attempt_count > 0 THEN 1 END) AS retry_count,
+ COUNT(CASE WHEN state = 'leased' THEN 1 END) AS leased_count,
+ COUNT(CASE WHEN state = 'completed' THEN 1 END) AS completed_count,
+ COUNT(CASE WHEN state IN ('exhausted', 'superseded') THEN 1 END) AS failed_count,
+ MIN(CASE WHEN state = 'ready' THEN created_at_unix_ms END) AS oldest_pending_ms,
+ (SELECT COUNT(*) FROM evidence_reconciliation_sources
+ WHERE required = 1 AND completion != 'complete') AS required_failures
+ FROM reconciliation_jobs"#,
+ )
+ .fetch_one(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let mut counts = Map::new();
+ for (code, column) in [
+ ("pending", "pending_count"),
+ ("retry_scheduled", "retry_count"),
+ ("leased", "leased_count"),
+ ("completed", "completed_count"),
+ ("failed", "failed_count"),
+ ] {
+ counts.insert(code.to_owned(), Value::from(row_u64(&row, column)?));
+ }
+ let mut value = json!({
+ "dirty_trade_count": row_u64(&row, "dirty_count")?,
+ "job_counts": counts,
+ "policy_digest": policy_digest,
+ "required_source_failures": row_u64(&row, "required_failures")?,
+ });
+ if let Some(value_ms) = optional_nonnegative_i64(&row, "oldest_pending_ms")? {
+ value["oldest_pending_at_utc"] = Value::from(value_ms / 1_000);
+ }
+ Ok::<Value, RhiAdminHandlerError>(value)
+ })
+ })
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ response(request.route(), value)
+ }
+
+ async fn reconciliation_jobs(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let limit = page_limit(&model)?;
+ let state_filter = model
+ .pointer("/state")
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ let trade_filter = model
+ .pointer("/trade_id")
+ .and_then(Value::as_str)
+ .map(parse_trade_id)
+ .transpose()?
+ .map(|trade| trade.as_bytes().to_vec());
+ let query_digest = query_digest(&model)?;
+ let (snapshot, offset) =
+ self.cursor_or_new(&model, request.route(), query_digest, self.now_millis()?)?;
+ let fetch_limit = i64::from(limit) + 1;
+ let offset_i64 = i64::from(offset);
+ let rows = self
+ .state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ sqlx::query(
+ r#"SELECT
+ length(job_id) AS job_id_bytes, substr(job_id, 1, 33) AS job_id,
+ length(trade_id) AS trade_id_bytes, substr(trade_id, 1, 17) AS trade_id,
+ state, attempt_count, input_generation, created_at_unix_ms,
+ lease_expires_unix_ms
+ FROM reconciliation_jobs
+ WHERE updated_at_unix_ms <= ?
+ AND (? IS NULL OR
+ (? = 'pending' AND state = 'ready' AND attempt_count = 0) OR
+ (? = 'retry_scheduled' AND state = 'ready' AND attempt_count > 0) OR
+ (? = 'leased' AND state = 'leased') OR
+ (? = 'completed' AND state = 'completed') OR
+ (? = 'failed' AND state IN ('exhausted', 'superseded')))
+ AND (? IS NULL OR trade_id = ?)
+ ORDER BY updated_at_unix_ms DESC, job_id DESC
+ LIMIT ? OFFSET ?"#,
+ )
+ .bind(
+ i64::try_from(snapshot)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?,
+ )
+ .bind(state_filter.as_deref())
+ .bind(state_filter.as_deref())
+ .bind(state_filter.as_deref())
+ .bind(state_filter.as_deref())
+ .bind(state_filter.as_deref())
+ .bind(state_filter.as_deref())
+ .bind(trade_filter.as_deref())
+ .bind(trade_filter.as_deref())
+ .bind(fetch_limit)
+ .bind(offset_i64)
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ })
+ })
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let has_more = rows.len() > usize::from(limit);
+ let items = rows
+ .iter()
+ .take(usize::from(limit))
+ .map(job_summary)
+ .collect::<Result<Vec<_>, _>>()?;
+ let mut value = json!({"items":items,"snapshot_generation":snapshot});
+ if has_more {
+ value["next_cursor"] = Value::String(
+ self.encode_cursor(
+ request.route(),
+ snapshot,
+ offset
+ .checked_add(u32::from(limit))
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?,
+ query_digest,
+ ),
+ );
+ }
+ response(request.route(), value)
+ }
+
+ async fn reconciliation_refresh(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let trade = model
+ .pointer("/trade_id")
+ .and_then(Value::as_str)
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))
+ .and_then(parse_trade_id)?;
+ let expected = required_u64(&model, "/expected_dirty_generation")?;
+ let actual = self.read_dirty_generation(trade).await?;
+ if actual != expected {
+ return Err(failure(RhiAdminHandlerErrorKind::Conflict));
+ }
+ let now_ms = self.now_millis()?;
+ let prepared = match RhiAdminOperationRepository::new(&self.state)
+ .prepare_admin_operation(&request, operation_time(now_ms)?)
+ .await
+ .map_err(map_journal_error)?
+ {
+ RhiAdminOperationAdmission::ExactReplay(response) => return Ok(response),
+ RhiAdminOperationAdmission::Prepared(prepared) => prepared,
+ };
+ let policy = RhiReconciliationJobPolicy::from_configuration(&self.configuration)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let outcome = self
+ .state
+ .repositories()
+ .reconciliation_jobs()
+ .schedule_trade(
+ trade,
+ policy,
+ RhiReconciliationUnixMilliseconds::new(now_ms)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?,
+ )
+ .await
+ .map_err(|error| match error.kind() {
+ RhiReconciliationJobErrorKind::DirtyGenerationConflict => {
+ failure(RhiAdminHandlerErrorKind::Conflict)
+ }
+ RhiReconciliationJobErrorKind::QueueFull => {
+ failure(RhiAdminHandlerErrorKind::Unavailable)
+ }
+ _ => failure(RhiAdminHandlerErrorKind::Internal),
+ })?;
+ let job = outcome.job();
+ let completed_ms = self.now_millis()?;
+ let completed = response(
+ request.route(),
+ json!({
+ "dirty_generation": job.input_generation(),
+ "job_id": lower_hex(job.id().as_bytes()),
+ "operation_id": required_operation_id(&request)?,
+ "trade_id": lower_hex(job.trade_id().as_bytes()),
+ }),
+ )?;
+ RhiAdminOperationRepository::new(&self.state)
+ .complete_admin_operation(
+ &prepared,
+ &completed,
+ operation_time(completed_ms)?,
+ RhiAdminOperationJournalPolicy::seven_days(),
+ )
+ .await
+ .map_err(map_journal_error)?;
+ Ok(completed)
+ }
+ async fn sources(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let limit = page_limit(&model)?;
+ let required_filter = model.pointer("/required").and_then(Value::as_bool);
+ let completion_filter = model
+ .pointer("/completion")
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ let sources = configured_source_summaries(&self.configuration)?;
+ let query_digest = query_digest(&model)?;
+ let (snapshot, offset) =
+ self.cursor_or_new(&model, request.route(), query_digest, self.now_millis()?)?;
+ let snapshot_sql =
+ i64::try_from(snapshot).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let rows = self
+ .state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let mut items = Vec::with_capacity(sources.len());
+ for source in sources {
+ let attempt = sqlx::query(
+ r#"SELECT completion, finished_unix_ms
+ FROM evidence_reconciliation_sources
+ WHERE source_id = ? AND finished_unix_ms <= ?
+ ORDER BY finished_unix_ms DESC, attempt_id DESC, request_id DESC
+ LIMIT 1"#,
+ )
+ .bind(source.source_id.as_ref())
+ .bind(snapshot_sql)
+ .fetch_optional(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let completion = attempt
+ .as_ref()
+ .map(|row| {
+ row.try_get::<&str, _>("completion")
+ .map(str::to_owned)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ })
+ .transpose()?
+ .unwrap_or_else(|| "incomplete_unknown".to_owned());
+ if required_filter.is_some_and(|required| required != source.required)
+ || completion_filter
+ .as_deref()
+ .is_some_and(|expected| expected != completion)
+ {
+ continue;
+ }
+ let checkpoint = sqlx::query(
+ r#"SELECT cursor_created_at_unix_s,
+ length(cursor_event_id) AS event_id_bytes,
+ substr(cursor_event_id, 1, 33) AS cursor_event_id
+ FROM relay_checkpoints
+ WHERE source_id = ? AND completed_at_unix_s <= ?
+ ORDER BY completed_at_unix_s DESC, trade_id DESC
+ LIMIT 1"#,
+ )
+ .bind(source.source_id.as_ref())
+ .bind(snapshot_sql / 1_000)
+ .fetch_optional(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let mut value = json!({
+ "completion": completion,
+ "reason_codes": [],
+ "required": source.required,
+ "source_id": source.source_id,
+ });
+ if let Some(row) = attempt.as_ref() {
+ value["last_attempt_at_utc"] =
+ Value::from(row_u64(row, "finished_unix_ms")? / 1_000);
+ }
+ if let Some(row) = checkpoint.as_ref() {
+ value["cursor"] = json!({
+ "created_at_unix_seconds": row_u64(row, "cursor_created_at_unix_s")?,
+ "event_id_lowercase_hex": lower_hex(&exact_blob::<32>(row, "cursor_event_id", "event_id_bytes")?),
+ });
+ }
+ items.push(value);
+ }
+ Ok::<Vec<Value>, RhiAdminHandlerError>(items)
+ })
+ })
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let start =
+ usize::try_from(offset).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ if start > rows.len() {
+ return Err(failure(RhiAdminHandlerErrorKind::InvalidCursor));
+ }
+ let end = start.saturating_add(usize::from(limit)).min(rows.len());
+ let items = rows[start..end].to_vec();
+ let mut value = json!({"items":items,"snapshot_generation":snapshot});
+ if end < rows.len() {
+ value["next_cursor"] = Value::String(self.encode_cursor(
+ request.route(),
+ snapshot,
+ u32::try_from(end).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?,
+ query_digest,
+ ));
+ }
+ response(request.route(), value)
+ }
+ async fn trade_projection(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let trade = request_trade_id(&request)?;
+ let row = self.current_report_row(trade).await?;
+ let report = report_detail(&row)?;
+ response(
+ request.route(),
+ json!({
+ "coverage": report["coverage"].clone(),
+ "dirty_generation": report["trade_generation"].clone(),
+ "manifest_digest": report["evidence_manifest_digest"].clone(),
+ "observed_at_utc": report["observed_at_unix_s"].clone(),
+ "outcome": report["outcome"].clone(),
+ "policy_digest": report["evidence_policy_digest"].clone(),
+ "projection_digest": report["projection_digest"].clone(),
+ "reason_codes": report["reason_codes"].clone(),
+ "trade_id": report["trade_id"].clone(),
+ }),
+ )
+ }
+ async fn trade_report_current(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let row = self.current_report_row(request_trade_id(&request)?).await?;
+ response(request.route(), report_detail(&row)?)
+ }
+ async fn trade_reports(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let limit = page_limit(&model)?;
+ let trade = request_trade_id(&request)?;
+ let outcome = model
+ .pointer("/outcome")
+ .and_then(Value::as_str)
+ .map(contract_outcome_to_db)
+ .transpose()?
+ .map(str::to_owned);
+ let query_digest = query_digest(&model)?;
+ let (snapshot, offset) =
+ self.cursor_or_new(&model, request.route(), query_digest, self.now_seconds()?)?;
+ let trade_bytes = trade.as_bytes().to_vec();
+ let fetch_limit = i64::from(limit) + 1;
+ let offset_i64 = i64::from(offset);
+ let snapshot_sql =
+ i64::try_from(snapshot).map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let rows = self
+ .state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let sql = format!("{REPORT_SELECT}{REPORT_ROWS_SUFFIX}");
+ // Both fragments are private compile-time constants; all filters remain bound.
+ sqlx::query(sqlx::AssertSqlSafe(sql.as_str()))
+ .bind(trade_bytes)
+ .bind(snapshot_sql)
+ .bind(outcome.as_deref())
+ .bind(outcome.as_deref())
+ .bind(fetch_limit)
+ .bind(offset_i64)
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ })
+ })
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let has_more = rows.len() > usize::from(limit);
+ let items = rows
+ .iter()
+ .take(usize::from(limit))
+ .map(report_summary)
+ .collect::<Result<Vec<_>, _>>()?;
+ let mut value = json!({"items":items,"snapshot_generation":snapshot});
+ if has_more {
+ value["next_cursor"] = Value::String(
+ self.encode_cursor(
+ request.route(),
+ snapshot,
+ offset
+ .checked_add(u32::from(limit))
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?,
+ query_digest,
+ ),
+ );
+ }
+ response(request.route(), value)
+ }
+ async fn publication_backlog(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let limit = page_limit(&model)?;
+ let state_filter = model
+ .pointer("/state")
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ let query_digest = query_digest(&model)?;
+ let (snapshot, offset) =
+ self.cursor_or_new(&model, request.route(), query_digest, self.now_millis()?)?;
+ let rows = self
+ .state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ sqlx::query(PUBLICATION_BACKLOG_SQL)
+ .bind(
+ i64::try_from(snapshot)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?,
+ )
+ .bind(state_filter.as_deref())
+ .bind(state_filter.as_deref())
+ .bind(i64::from(limit) + 1)
+ .bind(i64::from(offset))
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ })
+ })
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let has_more = rows.len() > usize::from(limit);
+ let items = rows
+ .iter()
+ .take(usize::from(limit))
+ .map(publication_summary)
+ .collect::<Result<Vec<_>, _>>()?;
+ let mut value = json!({"items":items,"snapshot_generation":snapshot});
+ if has_more {
+ value["next_cursor"] = Value::String(
+ self.encode_cursor(
+ request.route(),
+ snapshot,
+ offset
+ .checked_add(u32::from(limit))
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?,
+ query_digest,
+ ),
+ );
+ }
+ response(request.route(), value)
+ }
+ async fn publication_targets(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let limit = page_limit(&model)?;
+ let state_filter = model
+ .pointer("/state")
+ .and_then(Value::as_str)
+ .map(str::to_owned);
+ let workflow = model
+ .pointer("/workflow_id")
+ .and_then(Value::as_str)
+ .map(parse_digest)
+ .transpose()?
+ .map(Vec::from);
+ let query_digest = query_digest(&model)?;
+ let (snapshot, offset) =
+ self.cursor_or_new(&model, request.route(), query_digest, self.now_millis()?)?;
+ let rows = self
+ .state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ sqlx::query(PUBLICATION_TARGETS_SQL)
+ .bind(
+ i64::try_from(snapshot)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?,
+ )
+ .bind(workflow.as_deref())
+ .bind(workflow.as_deref())
+ .bind(state_filter.as_deref())
+ .bind(state_filter.as_deref())
+ .bind(i64::from(limit) + 1)
+ .bind(i64::from(offset))
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ })
+ })
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ let has_more = rows.len() > usize::from(limit);
+ let items = rows
+ .iter()
+ .take(usize::from(limit))
+ .map(publication_target_summary)
+ .collect::<Result<Vec<_>, _>>()?;
+ let mut value = json!({"items":items,"snapshot_generation":snapshot});
+ if has_more {
+ value["next_cursor"] = Value::String(
+ self.encode_cursor(
+ request.route(),
+ snapshot,
+ offset
+ .checked_add(u32::from(limit))
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))?,
+ query_digest,
+ ),
+ );
+ }
+ response(request.route(), value)
+ }
+ async fn publication_retry(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let workflow = model
+ .pointer("/workflow_id")
+ .and_then(Value::as_str)
+ .ok_or_else(|| failure(RhiAdminHandlerErrorKind::Internal))
+ .and_then(parse_digest)?;
+ let expected = required_u64(&model, "/expected_generation")?;
+ let now_ms = self.now_millis()?;
+ let operation_id = required_operation_id(&request)?.to_owned();
+ let route = request.route();
+ let expected_target_count = self.publication.targets().len();
+ RhiAdminOperationRepository::new(&self.state)
+ .execute_database_admin_operation(
+ &request,
+ operation_time(now_ms)?,
+ RhiAdminOperationJournalPolicy::seven_days(),
+ move |transaction| {
+ Box::pin(async move {
+ let rows = sqlx::query(
+ r#"SELECT state, revision, target_count,
+ length(event_sha256) AS event_sha256_bytes,
+ substr(event_sha256, 1, 33) AS event_sha256
+ FROM publication_outbox WHERE outbox_id = ? LIMIT 2"#,
+ )
+ .bind(workflow.as_slice())
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ if rows.len() != 1 {
+ return Err(if rows.is_empty() {
+ AdminJournalOperationError::Conflict
+ } else {
+ AdminJournalOperationError::Binding
+ });
+ }
+ let row = &rows[0];
+ let state = row
+ .try_get::<&str, _>("state")
+ .map_err(|_| AdminJournalOperationError::Binding)?;
+ let revision = operation_row_u64(row, "revision")?;
+ let target_count = operation_row_u64(row, "target_count")?;
+ let event_sha256 = operation_exact_blob::<32>(
+ row,
+ "event_sha256",
+ "event_sha256_bytes",
+ )?;
+ if state != "blocked"
+ || revision != expected
+ || usize::try_from(target_count).ok() != Some(expected_target_count)
+ {
+ return Err(AdminJournalOperationError::Conflict);
+ }
+ sqlx::query(
+ r#"UPDATE publication_targets
+ SET state = 'pending', revision = revision + 1,
+ next_attempt_unix_ms = ?, updated_at_unix_ms = ?
+ WHERE outbox_id = ? AND state != 'accepted'"#,
+ )
+ .bind(i64::try_from(now_ms).map_err(|_| AdminJournalOperationError::InvalidInput)?)
+ .bind(i64::try_from(now_ms).map_err(|_| AdminJournalOperationError::InvalidInput)?)
+ .bind(workflow.as_slice())
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ let changed = sqlx::query(
+ r#"UPDATE publication_outbox
+ SET state = 'pending', revision = revision + 1,
+ next_attempt_unix_ms = ?, updated_at_unix_ms = ?
+ WHERE outbox_id = ? AND state = 'blocked' AND revision = ?"#,
+ )
+ .bind(i64::try_from(now_ms).map_err(|_| AdminJournalOperationError::InvalidInput)?)
+ .bind(i64::try_from(now_ms).map_err(|_| AdminJournalOperationError::InvalidInput)?)
+ .bind(workflow.as_slice())
+ .bind(i64::try_from(expected).map_err(|_| AdminJournalOperationError::InvalidInput)?)
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ if changed.rows_affected() != 1 {
+ return Err(AdminJournalOperationError::Conflict);
+ }
+ response(
+ route,
+ json!({
+ "exact_bytes_digest": lower_hex(&event_sha256),
+ "generation": expected.checked_add(1).ok_or(AdminJournalOperationError::InvalidInput)?,
+ "operation_id": operation_id,
+ "target_count": target_count,
+ "workflow_id": lower_hex(&workflow),
+ }),
+ )
+ .map_err(|_| AdminJournalOperationError::Binding)
+ })
+ },
+ )
+ .await
+ .map_err(map_journal_error)
+ }
+ async fn presence_refresh(
+ &self,
+ request: RhiAdminRequestDocument,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminHandlerError> {
+ let model = request_model(&request)?;
+ let expected = required_u64(&model, "/expected_generation")?;
+ let operation_id = required_operation_id(&request)?.to_owned();
+ let now_ms = self.now_millis()?;
+ let route = request.route();
+ RhiAdminOperationRepository::new(&self.state)
+ .execute_database_admin_operation(
+ &request,
+ operation_time(now_ms)?,
+ RhiAdminOperationJournalPolicy::seven_days(),
+ move |transaction| {
+ Box::pin(async move {
+ let desired = sqlx::query(
+ r#"SELECT generation, enabled, target_count
+ FROM presence_desired_state WHERE singleton = 1 LIMIT 2"#,
+ )
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ if desired.len() != 1 {
+ return Err(AdminJournalOperationError::Binding);
+ }
+ let generation = operation_row_u64(&desired[0], "generation")?;
+ let enabled = operation_row_u64(&desired[0], "enabled")?;
+ let target_count = operation_row_u64(&desired[0], "target_count")?;
+ if generation != expected || enabled != 1 {
+ return Err(AdminJournalOperationError::Conflict);
+ }
+ let rows = sqlx::query(
+ r#"SELECT document_kind,
+ length(event_sha256) AS event_sha256_bytes,
+ substr(event_sha256, 1, 33) AS event_sha256,
+ state
+ FROM presence_outbox
+ WHERE desired_generation = ?
+ ORDER BY document_kind LIMIT 3"#,
+ )
+ .bind(
+ i64::try_from(expected)
+ .map_err(|_| AdminJournalOperationError::InvalidInput)?,
+ )
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ if rows.is_empty() || rows.len() > 2 {
+ return Err(AdminJournalOperationError::Conflict);
+ }
+ let mut digests = Map::new();
+ for row in &rows {
+ let kind = operation_presence_kind(row)?;
+ let digest = operation_exact_blob::<32>(
+ row,
+ "event_sha256",
+ "event_sha256_bytes",
+ )?;
+ if digests
+ .insert(kind.to_owned(), Value::String(lower_hex(&digest)))
+ .is_some()
+ {
+ return Err(AdminJournalOperationError::Binding);
+ }
+ }
+ let changed = sqlx::query(
+ r#"UPDATE presence_outbox
+ SET state = 'pending', revision = revision + 1,
+ next_attempt_unix_ms = ?, updated_at_unix_ms = ?
+ WHERE desired_generation = ? AND state = 'blocked'"#,
+ )
+ .bind(
+ i64::try_from(now_ms)
+ .map_err(|_| AdminJournalOperationError::InvalidInput)?,
+ )
+ .bind(
+ i64::try_from(now_ms)
+ .map_err(|_| AdminJournalOperationError::InvalidInput)?,
+ )
+ .bind(
+ i64::try_from(expected)
+ .map_err(|_| AdminJournalOperationError::InvalidInput)?,
+ )
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ if changed.rows_affected() == 0
+ || changed.rows_affected() as usize > rows.len()
+ {
+ return Err(AdminJournalOperationError::Conflict);
+ }
+ response(
+ route,
+ json!({
+ "document_digests": digests,
+ "generation": expected,
+ "operation_id": operation_id,
+ "state": "pending",
+ "target_count": target_count,
+ }),
+ )
+ .map_err(|_| AdminJournalOperationError::Binding)
+ })
+ },
+ )
+ .await
+ .map_err(map_journal_error)
+ }
+
+ async fn presence_state(
+ &self,
+ generation: u64,
+ ) -> Result<(&'static str, Map<String, Value>), RhiAdminHandlerError> {
+ self.state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let rows = sqlx::query(
+ r#"SELECT document_kind, state,
+ length(event_sha256) AS event_sha256_bytes,
+ substr(event_sha256, 1, 33) AS event_sha256
+ FROM presence_outbox
+ WHERE desired_generation = ?
+ ORDER BY document_kind LIMIT 3"#,
+ )
+ .bind(
+ i64::try_from(generation)
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?,
+ )
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?;
+ if rows.len() > 2 {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ let mut digests = Map::new();
+ let mut states = Vec::with_capacity(rows.len());
+ for row in &rows {
+ let kind = presence_kind(row)?;
+ let digest = exact_blob::<32>(row, "event_sha256", "event_sha256_bytes")?;
+ if digests
+ .insert(kind.to_owned(), Value::String(lower_hex(&digest)))
+ .is_some()
+ {
+ return Err(failure(RhiAdminHandlerErrorKind::Internal));
+ }
+ states.push(
+ row.try_get::<&str, _>("state")
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))?,
+ );
+ }
+ let state = if rows.is_empty() {
+ "dirty"
+ } else if states.contains(&"leased") {
+ "submitted"
+ } else if states.contains(&"pending") {
+ "pending"
+ } else if states.contains(&"blocked") {
+ "failed"
+ } else if states.iter().all(|state| *state == "complete") {
+ "accepted"
+ } else if states.iter().all(|state| *state == "superseded") {
+ "dirty"
+ } else {
+ "unknown"
+ };
+ Ok::<(&'static str, Map<String, Value>), RhiAdminHandlerError>((state, digests))
+ })
+ })
+ .await
+ .map_err(|_| failure(RhiAdminHandlerErrorKind::Internal))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn cursors_are_bounded_authenticated_and_bound_to_route_filters_and_key() {
+ let key = [0x11; 32];
+ let query = [0x22; 32];
+ let cursor = encode_runtime_cursor(
+ &key,
+ RhiAdminRoute::ReconciliationJobs,
+ u64::MAX,
+ u32::MAX,
+ query,
+ );
+ assert_eq!(cursor.len(), 104);
+ assert!(!cursor.contains('='));
+ assert_eq!(
+ decode_runtime_cursor(&key, RhiAdminRoute::ReconciliationJobs, &cursor, query)
+ .expect("exact cursor"),
+ (u64::MAX, u32::MAX)
+ );
+
+ for result in [
+ decode_runtime_cursor(
+ &[0x12; 32],
+ RhiAdminRoute::ReconciliationJobs,
+ &cursor,
+ query,
+ ),
+ decode_runtime_cursor(&key, RhiAdminRoute::Sources, &cursor, query),
+ decode_runtime_cursor(&key, RhiAdminRoute::ReconciliationJobs, &cursor, [0x23; 32]),
+ ] {
+ assert_eq!(
+ result.expect_err("mismatched cursor binding").kind(),
+ RhiAdminHandlerErrorKind::InvalidCursor
+ );
+ }
+
+ let mut tampered = cursor.into_bytes();
+ tampered[20] = if tampered[20] == b'A' { b'B' } else { b'A' };
+ let tampered = String::from_utf8(tampered).expect("ASCII cursor");
+ assert_eq!(
+ decode_runtime_cursor(&key, RhiAdminRoute::ReconciliationJobs, &tampered, query,)
+ .expect_err("tampered cursor")
+ .kind(),
+ RhiAdminHandlerErrorKind::InvalidCursor
+ );
+ }
+}
diff --git a/src/runtime_foundation.rs b/src/runtime_foundation.rs
@@ -1,7 +1,7 @@
//! Existing-state-only RHI runtime foundation.
use core::fmt;
-use std::error::Error;
+use std::{error::Error, sync::Arc};
use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
@@ -228,10 +228,10 @@ impl Error for RhiRuntimeFoundationError {}
#[must_use = "the runtime foundation must be shut down so state and tasks are joined"]
pub struct RhiRuntimeFoundation {
runtime: RhiRuntimeContext,
- configuration: RhiConfigDocumentV1,
+ configuration: Arc<RhiConfigDocumentV1>,
metadata: RhiStateMetadata,
- state: RhiStateHost,
- _identity: RhiDecryptedIdentity,
+ state: Arc<RhiStateHost>,
+ identity: Arc<RhiDecryptedIdentity>,
adapters: RhiRuntimeAdapters,
readiness: RhiRuntimeReadiness,
}
@@ -245,8 +245,8 @@ impl RhiRuntimeFoundation {
/// Returns the admitted immutable configuration.
#[must_use]
- pub const fn configuration(&self) -> &RhiConfigDocumentV1 {
- &self.configuration
+ pub fn configuration(&self) -> &RhiConfigDocumentV1 {
+ self.configuration.as_ref()
}
/// Returns metadata discovered and proven under retained state authority.
@@ -261,10 +261,43 @@ impl RhiRuntimeFoundation {
&self.readiness
}
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) fn state(&self) -> Arc<RhiStateHost> {
+ Arc::clone(&self.state)
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) fn configuration_arc(&self) -> Arc<RhiConfigDocumentV1> {
+ Arc::clone(&self.configuration)
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) const fn adapters(&self) -> &RhiRuntimeAdapters {
+ &self.adapters
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) fn identity(&self) -> &RhiDecryptedIdentity {
+ self.identity.as_ref()
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) fn identity_arc(&self) -> Arc<RhiDecryptedIdentity> {
+ Arc::clone(&self.identity)
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ pub(crate) fn supervisor_mut(&mut self) -> &mut radroots_service_host::TaskSupervisor {
+ self.adapters.supervisor_mut()
+ }
+
/// Requests cancellation, joins owned tasks, and explicitly closes state.
pub async fn shutdown(mut self) -> Result<(), RhiRuntimeFoundationError> {
let supervised = self.adapters.shutdown().await;
- let closed = self.state.close().await;
+ let state = Arc::try_unwrap(self.state).map_err(|_| {
+ RhiRuntimeFoundationError::new(RhiRuntimeFoundationErrorKind::TaskFailure)
+ })?;
+ let closed = state.close().await;
if supervised.is_err() {
Err(RhiRuntimeFoundationError::new(
RhiRuntimeFoundationErrorKind::TaskFailure,
@@ -281,6 +314,7 @@ impl RhiRuntimeFoundation {
impl fmt::Debug for RhiRuntimeFoundation {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ let _ = &self.identity;
formatter
.debug_struct("RhiRuntimeFoundation")
.field("runtime", &"[redacted]")
@@ -332,10 +366,10 @@ pub async fn open_rhi_runtime_foundation(
};
Ok(RhiRuntimeFoundation {
runtime,
- configuration,
+ configuration: Arc::new(configuration),
metadata,
- state,
- _identity: identity,
+ state: Arc::new(state),
+ identity: Arc::new(identity),
adapters,
readiness,
})
diff --git a/src/runtime_graph.rs b/src/runtime_graph.rs
@@ -0,0 +1,1716 @@
+//! Binary-invoked RHI daemon graph with fixed task ownership and bounded shutdown.
+
+use core::{fmt, future::pending, time::Duration};
+use std::{
+ collections::{BTreeMap, BTreeSet},
+ error::Error,
+ sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ },
+};
+
+use radroots_event::id::TradeId;
+use radroots_service_host::{
+ GracefulShutdown, HostError, HostErrorKind, ProcessSignal, ProcessSignalAdapter,
+ ProcessSignalFuture, ProcessSignalSource, ShutdownDisposition, ShutdownPhase,
+ ShutdownPhaseFuture, ShutdownPhaseHandler, SupervisedTaskExitStatus, TaskClassification,
+ TaskMetadata, TaskName,
+};
+use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
+use radroots_transport::{
+ FetchRequest, SubscriptionNext, SubscriptionRequest, Target, TargetSet,
+ outcome::FetchTargetState,
+ source::{
+ FETCH_PAGE_MAX_EVENTS, FetchBounds, FetchCursor, FetchSelector, NextPage,
+ SubscriptionBounds,
+ },
+};
+use serde_json::Value;
+use sha2::{Digest as _, Sha256};
+use sqlx::Row;
+
+use crate::transport_nostr_adapter::{RhiNostrExactSink, build_rhi_nostr_adapters};
+use crate::{
+ RhiAdminCancellationToken, RhiAdminServer, RhiAdmittedTradeMutationEvent, RhiBoundAdminServer,
+ RhiBoundOperationsServer, RhiConfigDocumentV1, RhiEvidenceAttestationSupersession,
+ RhiEvidenceTransportStatusV1, RhiIdentityHealthV1, RhiIntegrityStateV1,
+ RhiJitterBoundMilliseconds, RhiOperationsCancellationToken, RhiOperationsServer,
+ RhiPersistenceHealthV1, RhiPersistenceStatusV1, RhiPresenceDesiredAuthority,
+ RhiPresenceLeaseOwner, RhiPresenceStatusV1, RhiProcessResult, RhiProcessSignal,
+ RhiProcessSignalSource, RhiProviderStatusV1, RhiPublicationAuthority, RhiPublicationLeaseOwner,
+ RhiPublicationStatusV1, RhiReconciliationAttemptPlan, RhiReconciliationJobPolicy,
+ RhiReconciliationLease, RhiReconciliationLeaseOwner, RhiReconciliationRetryDelayMilliseconds,
+ RhiReconciliationScopePrerequisites, RhiReconciliationSourceReplay,
+ RhiReconciliationSourceReplayPlan, RhiReconciliationSourceRequest, RhiReconciliationStatusV1,
+ RhiReconciliationUnixMilliseconds, RhiRuntimeFoundation, RhiServicePhase, RhiStateHost,
+ RhiStatusBuildInfoV1, RhiStatusBuildMode, RhiStatusCommonV1, RhiStatusConfigurationIdentityV1,
+ RhiStatusConfigurationSource, RhiStatusObservationV1, RhiStatusPublisher, RhiStatusReasonCode,
+ RhiStatusReasonCodes, RhiStatusUnixSeconds, RhiTimeEntropyAdapters,
+ RhiTradeMutationAdmissionLimits, RhiTradeMutationAuthoredTimePolicy,
+ RhiTradeMutationObservedAtUnixSeconds, RhiTradeSourceAttempt, RhiTradeSourceCompletion,
+ RhiTransportHealthV1, admit_rhi_trade_mutation_event, build_rhi_signed_evidence_attestation,
+ build_rhi_signed_presence_documents, evaluate_rhi_reconciliation_claim,
+ open_rhi_runtime_foundation, reduce_rhi_reconciliation_manifest, rhi_status_cache,
+};
+use crate::{reconciliation_replay, source_ingest, state_config};
+
+const TASK_ADMIN_SERVER: &str = "admin_server";
+const TASK_OPERATIONS_SERVER: &str = "operations_server";
+const TASK_SOURCE_SUBSCRIPTION: &str = "source_subscription";
+const TASK_RECONCILIATION_WORKER: &str = "reconciliation_worker";
+const TASK_PUBLICATION_WORKER: &str = "publication_worker";
+const TASK_PRESENCE_WORKER: &str = "presence_worker";
+const TRADE_EVENT_KINDS: [u32; 5] = [3470, 3471, 3472, 3473, 3474];
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum RhiDaemonErrorKind {
+ State,
+ Identity,
+ Transport,
+ Admin,
+ Operations,
+ Runtime,
+}
+
+struct RhiDaemonError {
+ kind: RhiDaemonErrorKind,
+}
+
+impl RhiDaemonError {
+ const fn new(kind: RhiDaemonErrorKind) -> Self {
+ Self { kind }
+ }
+
+ const fn process_result(&self) -> RhiProcessResult {
+ match self.kind {
+ RhiDaemonErrorKind::State | RhiDaemonErrorKind::Identity => {
+ RhiProcessResult::StateOrIdentityUnavailable
+ }
+ RhiDaemonErrorKind::Transport
+ | RhiDaemonErrorKind::Admin
+ | RhiDaemonErrorKind::Operations => RhiProcessResult::ServiceOrDependencyUnavailable,
+ RhiDaemonErrorKind::Runtime => RhiProcessResult::UnexpectedInternal,
+ }
+ }
+}
+
+impl fmt::Debug for RhiDaemonError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiDaemonError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for RhiDaemonError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("RHI daemon failed")
+ }
+}
+
+impl Error for RhiDaemonError {}
+
+struct HostSignalSource<S> {
+ inner: S,
+}
+
+impl<S> HostSignalSource<S> {
+ const fn new(inner: S) -> Self {
+ Self { inner }
+ }
+}
+
+impl<S> ProcessSignalSource for HostSignalSource<S>
+where
+ S: RhiProcessSignalSource,
+{
+ fn next_signal(&mut self) -> ProcessSignalFuture<'_> {
+ Box::pin(async move {
+ self.inner.next_signal().await.map(|signal| match signal {
+ RhiProcessSignal::Interrupt => ProcessSignal::Interrupt,
+ #[cfg(unix)]
+ RhiProcessSignal::Terminate => ProcessSignal::Terminate,
+ })
+ })
+ }
+}
+
+struct RuntimeShutdownHandler {
+ accepting_mutations: Arc<AtomicBool>,
+ status: RhiStatusPublisher,
+ status_context: RuntimeStatusContext,
+ transport_ready: bool,
+ operations_ready: bool,
+}
+
+impl RuntimeShutdownHandler {
+ fn publish(&mut self, phase: RhiServicePhase) -> Result<(), HostError> {
+ self.status
+ .publish(self.status_context.observation(
+ phase,
+ self.transport_ready,
+ self.operations_ready,
+ )?)
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))
+ }
+
+ fn running_phase(&self) -> RhiServicePhase {
+ if self.transport_ready && self.operations_ready {
+ RhiServicePhase::Ready
+ } else {
+ RhiServicePhase::Degraded
+ }
+ }
+}
+
+impl ShutdownPhaseHandler for RuntimeShutdownHandler {
+ fn enter(&mut self, phase: ShutdownPhase) -> ShutdownPhaseFuture<'_> {
+ Box::pin(async move {
+ if phase == ShutdownPhase::RejectNewMutations {
+ self.accepting_mutations.store(false, Ordering::Release);
+ self.publish(RhiServicePhase::Stopping)?;
+ }
+ Ok(())
+ })
+ }
+}
+
+struct RuntimeStatusContext {
+ configuration_digest: String,
+ configuration_source: RhiStatusConfigurationSource,
+ schema_version: u32,
+ generation: u64,
+ source_count: u64,
+ started_at: radroots_service_host::MonotonicTime,
+ time_entropy: RhiTimeEntropyAdapters,
+ reconciliation: RhiReconciliationStatusV1,
+ publication: RhiPublicationStatusV1,
+ presence: RhiPresenceStatusV1,
+}
+
+impl RuntimeStatusContext {
+ async fn refresh_state(&mut self, state: &RhiStateHost) -> Result<(), HostError> {
+ let summary = state
+ .sqlite_host()
+ .transaction(|transaction| {
+ Box::pin(async move {
+ let row = sqlx::query(RUNTIME_STATUS_SQL)
+ .fetch_one(&mut *transaction)
+ .await
+ .map_err(|_| ())?;
+ Ok::<_, ()>((
+ row.try_get::<i64, _>("reconciliation_pending")
+ .map_err(|_| ())?,
+ row.try_get::<i64, _>("reconciliation_leased")
+ .map_err(|_| ())?,
+ row.try_get::<i64, _>("reconciliation_exhausted")
+ .map_err(|_| ())?,
+ row.try_get::<Option<i64>, _>("reconciliation_oldest")
+ .map_err(|_| ())?,
+ row.try_get::<i64, _>("publication_pending")
+ .map_err(|_| ())?,
+ row.try_get::<i64, _>("publication_unknown")
+ .map_err(|_| ())?,
+ row.try_get::<Option<i64>, _>("publication_oldest")
+ .map_err(|_| ())?,
+ row.try_get::<i64, _>("presence_pending").map_err(|_| ())?,
+ row.try_get::<i64, _>("presence_unknown").map_err(|_| ())?,
+ ))
+ })
+ })
+ .await
+ .map_err(|_| HostError::new(HostErrorKind::Lifecycle))?;
+ self.reconciliation = RhiReconciliationStatusV1::new(
+ status_count(summary.0)?,
+ status_count(summary.1)?,
+ status_count(summary.2)?,
+ status_time(summary.3)?,
+ );
+ self.publication = RhiPublicationStatusV1::new(
+ status_count(summary.4)?,
+ status_count(summary.5)?,
+ status_time(summary.6)?,
+ );
+ self.presence =
+ RhiPresenceStatusV1::new(status_count(summary.7)?, status_count(summary.8)?);
+ Ok(())
+ }
+
+ fn observation(
+ &self,
+ phase: RhiServicePhase,
+ transport_ready: bool,
+ operations_ready: bool,
+ ) -> Result<RhiStatusObservationV1, HostError> {
+ let ready =
+ matches!(phase, RhiServicePhase::Ready | RhiServicePhase::Degraded) && transport_ready;
+ let mut transport_reasons = Vec::new();
+ if !transport_ready {
+ transport_reasons.push(RhiStatusReasonCode::SourceUnavailable);
+ transport_reasons.push(RhiStatusReasonCode::SubscriptionInactive);
+ }
+ let transport_reasons = RhiStatusReasonCodes::new(transport_reasons)
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?;
+ let mut lifecycle_reasons = transport_reasons.as_slice().to_vec();
+ if !operations_ready {
+ lifecycle_reasons.push(RhiStatusReasonCode::OperationsListenerFailed);
+ }
+ if phase == RhiServicePhase::Stopping {
+ lifecycle_reasons.push(RhiStatusReasonCode::ShutdownInProgress);
+ }
+ let lifecycle_reasons = RhiStatusReasonCodes::new(lifecycle_reasons)
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?;
+ let transport_health = if transport_ready {
+ RhiTransportHealthV1::Ready
+ } else {
+ RhiTransportHealthV1::Unavailable
+ };
+ let uptime = self
+ .time_entropy
+ .now_monotonic()
+ .duration_since_origin()
+ .saturating_sub(self.started_at.duration_since_origin())
+ .as_millis();
+ let uptime = u64::try_from(uptime).map_err(|_| HostError::new(HostErrorKind::Lifecycle))?;
+ let build = runtime_build_info()?;
+ let configuration = RhiStatusConfigurationIdentityV1::new(
+ &self.configuration_digest,
+ self.configuration_source,
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?;
+ let persistence = RhiPersistenceStatusV1::new(
+ RhiPersistenceHealthV1::Ready,
+ self.schema_version,
+ self.generation,
+ RhiIntegrityStateV1::Verified,
+ RhiStatusReasonCodes::empty(),
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?;
+ let identity = RhiIdentityHealthV1::new(true, true, RhiStatusReasonCodes::empty())
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?;
+ let provider = RhiProviderStatusV1::new(identity, RhiStatusReasonCodes::empty())
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?;
+ let transport = RhiEvidenceTransportStatusV1::new(
+ transport_health,
+ transport_ready,
+ transport_ready,
+ self.source_count,
+ if transport_ready {
+ self.source_count
+ } else {
+ 0
+ },
+ transport_reasons,
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?;
+ let common = RhiStatusCommonV1::new(
+ phase,
+ ready,
+ lifecycle_reasons,
+ uptime,
+ build,
+ configuration,
+ persistence,
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))?;
+ Ok(RhiStatusObservationV1::new(
+ common,
+ provider,
+ transport,
+ self.reconciliation,
+ self.publication,
+ self.presence,
+ ))
+ }
+}
+
+const RUNTIME_STATUS_SQL: &str = r#"SELECT
+ (SELECT COUNT(*) FROM reconciliation_jobs WHERE state = 'ready')
+ AS reconciliation_pending,
+ (SELECT COUNT(*) FROM reconciliation_jobs WHERE state = 'leased')
+ AS reconciliation_leased,
+ (SELECT COUNT(*) FROM reconciliation_jobs WHERE state = 'exhausted')
+ AS reconciliation_exhausted,
+ (SELECT MIN(created_at_unix_ms / 1000) FROM reconciliation_jobs WHERE state = 'ready')
+ AS reconciliation_oldest,
+ (SELECT COUNT(*) FROM publication_outbox WHERE state IN ('pending', 'leased'))
+ AS publication_pending,
+ (SELECT COUNT(*) FROM publication_outbox AS outbox
+ WHERE outbox.state = 'blocked' OR EXISTS (
+ SELECT 1 FROM publication_targets AS target
+ WHERE target.outbox_id = outbox.outbox_id AND target.state = 'unknown'))
+ AS publication_unknown,
+ (SELECT MIN(created_at_unix_ms / 1000) FROM publication_outbox
+ WHERE state IN ('pending', 'leased')) AS publication_oldest,
+ (SELECT COUNT(*) FROM presence_outbox WHERE state IN ('pending', 'leased'))
+ AS presence_pending,
+ (SELECT COUNT(*) FROM presence_outbox AS outbox
+ WHERE outbox.state = 'blocked' OR EXISTS (
+ SELECT 1 FROM presence_targets AS target
+ WHERE target.outbox_id = outbox.outbox_id AND target.state = 'unknown'))
+ AS presence_unknown"#;
+
+fn status_count(value: i64) -> Result<u64, HostError> {
+ u64::try_from(value).map_err(|_| HostError::new(HostErrorKind::Lifecycle))
+}
+
+fn status_time(value: Option<i64>) -> Result<Option<RhiStatusUnixSeconds>, HostError> {
+ value
+ .map(|value| {
+ u64::try_from(value)
+ .map_err(|_| HostError::new(HostErrorKind::Lifecycle))
+ .and_then(|value| {
+ RhiStatusUnixSeconds::new(value)
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))
+ })
+ })
+ .transpose()
+}
+
+struct SourceBinding {
+ source_id: Box<str>,
+ target: Target,
+}
+
+struct InitialSubscription {
+ request: SubscriptionRequest,
+ subscription: radroots_transport::BoxSubscription,
+ sources: Box<[SourceBinding]>,
+}
+
+/// Executes the real RHI daemon and converts every failure to the frozen process result.
+pub(crate) async fn run_rhi_daemon<S>(
+ runtime: crate::RhiRuntimeContext,
+ configuration: RhiConfigDocumentV1,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+ signals: S,
+) -> RhiProcessResult
+where
+ S: RhiProcessSignalSource + 'static,
+{
+ run_rhi_daemon_inner(runtime, configuration, applied_at, build, signals)
+ .await
+ .unwrap_or_else(|error| error.process_result())
+}
+
+async fn run_rhi_daemon_inner<S>(
+ runtime: crate::RhiRuntimeContext,
+ configuration: RhiConfigDocumentV1,
+ applied_at: MigrationAppliedAtUnixSeconds,
+ build: &MigrationBuildIdentity,
+ signals: S,
+) -> Result<RhiProcessResult, RhiDaemonError>
+where
+ S: RhiProcessSignalSource + 'static,
+{
+ let (transport, exact_sink) = build_rhi_nostr_adapters(&configuration)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Transport))?;
+ let adapters = crate::RhiRuntimeAdapters::new(
+ crate::RhiTimeEntropyAdapters::system(),
+ transport,
+ crate::RhiIdentityCredentialAdapters::canonical(),
+ );
+ let mut foundation =
+ open_rhi_runtime_foundation(runtime, configuration, adapters, applied_at, build)
+ .await
+ .map_err(|error| match error.kind() {
+ crate::RhiRuntimeFoundationErrorKind::IdentityAccess
+ | crate::RhiRuntimeFoundationErrorKind::IdentityBinding => {
+ RhiDaemonError::new(RhiDaemonErrorKind::Identity)
+ }
+ _ => RhiDaemonError::new(RhiDaemonErrorKind::State),
+ })?;
+
+ let configuration = foundation.configuration_arc();
+ let state = foundation.state();
+ let publication = Arc::new(
+ RhiPublicationAuthority::from_config(&configuration)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?,
+ );
+ let presence = Arc::new(
+ RhiPresenceDesiredAuthority::from_config(&configuration)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?,
+ );
+ initialize_presence(&foundation, &presence)
+ .await
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?;
+
+ let initial_subscription = open_initial_subscription(&foundation, &configuration)
+ .await
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Transport))?;
+ let generation = u64::from(
+ state_config::current_generation(&state)
+ .await
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?,
+ );
+ let source_count = u64::try_from(initial_subscription.sources.len())
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+ let time_entropy = foundation.adapters().time_entropy().clone();
+ let started_at = time_entropy.now_monotonic();
+ let mut status_context = RuntimeStatusContext {
+ configuration_digest: lower_hex(state.metadata().configuration_digest().as_bytes()),
+ configuration_source: if foundation.runtime_context().profile()
+ == crate::RhiBootstrapProfileV1::RepoLocal
+ {
+ RhiStatusConfigurationSource::DerivedRepoLocal
+ } else {
+ RhiStatusConfigurationSource::ExplicitConfig
+ },
+ schema_version: crate::RHI_STATE_SCHEMA_VERSION,
+ generation,
+ source_count,
+ started_at,
+ time_entropy: time_entropy.clone(),
+ reconciliation: RhiReconciliationStatusV1::default(),
+ publication: RhiPublicationStatusV1::default(),
+ presence: RhiPresenceStatusV1::default(),
+ };
+ status_context
+ .refresh_state(&state)
+ .await
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?;
+ let (status, status_reader) = rhi_status_cache(
+ foundation.runtime_context().context().instance().clone(),
+ status_context
+ .observation(RhiServicePhase::Starting, true, true)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?,
+ )
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+ let accepting_mutations = Arc::new(AtomicBool::new(true));
+ let mut cursor_key = [0_u8; 32];
+ time_entropy
+ .entropy()
+ .fill_bytes(&mut cursor_key)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+ let admin_handler = Arc::new(crate::runtime_admin::RuntimeAdminHandler {
+ state: Arc::clone(&state),
+ configuration: Arc::clone(&configuration),
+ identity: foundation.identity_arc(),
+ publication: Arc::clone(&publication),
+ presence: Arc::clone(&presence),
+ status: status_reader.clone(),
+ accepting_mutations: Arc::clone(&accepting_mutations),
+ cursor_key,
+ time_entropy: time_entropy.clone(),
+ });
+ let admin = RhiAdminServer::new(&configuration, admin_handler)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Admin))?
+ .bind(foundation.runtime_context())
+ .await
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Admin))?;
+ let operations = if operations_enabled(&configuration)? {
+ Some(
+ RhiOperationsServer::new(&configuration, &status_reader)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Operations))?
+ .bind()
+ .await
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Operations))?,
+ )
+ } else {
+ None
+ };
+
+ spawn_admin(foundation.supervisor_mut(), admin)?;
+ if let Some(operations) = operations {
+ spawn_operations(foundation.supervisor_mut(), operations)?;
+ }
+ let source_transport = foundation.adapters().transport().clone();
+ let (transport_health_sender, mut transport_health) = tokio::sync::watch::channel(true);
+ spawn_source_subscription(
+ foundation.supervisor_mut(),
+ Arc::clone(&state),
+ Arc::clone(&configuration),
+ source_transport,
+ time_entropy.clone(),
+ initial_subscription,
+ transport_health_sender,
+ )?;
+ let reconciliation_transport = foundation.adapters().transport().clone();
+ let reconciliation_time = foundation.adapters().time_entropy().clone();
+ let reconciliation_identity = foundation.identity_arc();
+ spawn_reconciliation_worker(
+ foundation.supervisor_mut(),
+ Arc::clone(&state),
+ Arc::clone(&configuration),
+ reconciliation_transport,
+ reconciliation_time,
+ reconciliation_identity,
+ Arc::clone(&publication),
+ )?;
+ spawn_publication_worker(
+ foundation.supervisor_mut(),
+ Arc::clone(&state),
+ Arc::clone(&configuration),
+ Arc::clone(&publication),
+ Arc::clone(&exact_sink),
+ time_entropy.clone(),
+ )?;
+ spawn_presence_worker(
+ foundation.supervisor_mut(),
+ Arc::clone(&state),
+ Arc::clone(&configuration),
+ Arc::clone(&exact_sink),
+ time_entropy,
+ )?;
+
+ let mut shutdown_handler = RuntimeShutdownHandler {
+ accepting_mutations,
+ status,
+ status_context,
+ transport_ready: true,
+ operations_ready: true,
+ };
+ shutdown_handler
+ .publish(RhiServicePhase::Ready)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+
+ let mut signals = ProcessSignalAdapter::new(HostSignalSource::new(signals));
+ let refresh_period = worker_idle(&configuration)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+ let mut status_refresh = tokio::time::interval(refresh_period);
+ status_refresh.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
+ status_refresh.tick().await;
+ let signal_initiated = loop {
+ tokio::select! {
+ action = signals.next_action() => {
+ match action {
+ Ok(action) if !action.forces_termination() => break true,
+ Ok(_) | Err(_) => break false,
+ }
+ }
+ changed = transport_health.changed() => {
+ if changed.is_err() {
+ break false;
+ }
+ let ready = *transport_health.borrow_and_update();
+ shutdown_handler.transport_ready = ready;
+ shutdown_handler.publish(shutdown_handler.running_phase())
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+ }
+ joined = foundation.supervisor_mut().join_next() => {
+ match joined {
+ Some(Ok(exit))
+ if exit.status() == SupervisedTaskExitStatus::OptionalFailure
+ || exit.metadata().name().as_str() == TASK_OPERATIONS_SERVER => {
+ shutdown_handler.operations_ready = false;
+ shutdown_handler.publish(shutdown_handler.running_phase())
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+ }
+ Some(Ok(_)) | Some(Err(_)) | None => break false,
+ }
+ }
+ _ = status_refresh.tick() => {
+ shutdown_handler.status_context.refresh_state(&state).await
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?;
+ shutdown_handler.publish(shutdown_handler.running_phase())
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+ }
+ }
+ };
+ let grace = Duration::from_millis(configuration_integer(
+ &configuration,
+ "/service/shutdown_grace_ms",
+ )?);
+ let mut shutdown = GracefulShutdown::new(grace)
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+ let clock = radroots_service_host::SystemMonotonicClock::new();
+ let summary = if signal_initiated {
+ shutdown
+ .run(
+ &clock,
+ foundation.supervisor_mut(),
+ &mut shutdown_handler,
+ async {
+ let _ = signals.next_action().await;
+ },
+ )
+ .await
+ } else {
+ shutdown
+ .run(
+ &clock,
+ foundation.supervisor_mut(),
+ &mut shutdown_handler,
+ pending::<()>(),
+ )
+ .await
+ }
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?;
+ drop(shutdown_handler);
+ drop(state);
+ foundation
+ .shutdown()
+ .await
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::State))?;
+ if signal_initiated && summary.disposition() == ShutdownDisposition::Completed {
+ Ok(RhiProcessResult::Success)
+ } else {
+ Err(RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+ }
+}
+
+async fn initialize_presence(
+ foundation: &RhiRuntimeFoundation,
+ authority: &RhiPresenceDesiredAuthority,
+) -> Result<(), HostError> {
+ let state = foundation.state();
+ let desired = state
+ .repositories()
+ .desired_presence()
+ .commit(authority)
+ .await
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ if desired.changed() {
+ let documents = build_rhi_signed_presence_documents(
+ desired,
+ authority,
+ foundation.identity(),
+ foundation
+ .adapters()
+ .time_entropy()
+ .now_utc()
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?,
+ foundation.adapters().time_entropy().entropy(),
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let now = crate::RhiPresenceUnixMilliseconds::new(
+ foundation
+ .adapters()
+ .time_entropy()
+ .now_utc_milliseconds()
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?,
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ state
+ .repositories()
+ .presence_outbox()
+ .commit_signed_presence(&documents, now)
+ .await
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ }
+ Ok(())
+}
+
+async fn open_initial_subscription(
+ foundation: &RhiRuntimeFoundation,
+ configuration: &RhiConfigDocumentV1,
+) -> Result<InitialSubscription, HostError> {
+ let sources = configured_sources(configuration)?;
+ let (request, subscription) = subscribe_sources(
+ configuration,
+ foundation.adapters().transport(),
+ foundation.adapters().time_entropy(),
+ &sources,
+ )
+ .await?;
+ Ok(InitialSubscription {
+ request,
+ subscription,
+ sources: sources.into_boxed_slice(),
+ })
+}
+
+async fn subscribe_sources(
+ configuration: &RhiConfigDocumentV1,
+ transport: &crate::RhiTransportAdapters,
+ time_entropy: &RhiTimeEntropyAdapters,
+ sources: &[SourceBinding],
+) -> Result<(SubscriptionRequest, radroots_transport::BoxSubscription), HostError> {
+ let targets = TargetSet::new(sources.iter().map(|source| source.target.clone()).collect())
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ let deadline = time_entropy
+ .now_utc_milliseconds()
+ .ok()
+ .and_then(|now| now.checked_add(maximum_source_deadline(configuration).ok()?))
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?;
+ let selector = FetchSelector::all()
+ .with_kinds(TRADE_EVENT_KINDS.to_vec())
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ let request = SubscriptionRequest::new(
+ "rhi-source-subscription",
+ targets,
+ SubscriptionBounds::new(1_000, deadline)
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?,
+ )
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?
+ .with_selector(selector);
+ let subscription = transport
+ .evidence_subscriber()
+ .subscribe(request.clone())
+ .await
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ Ok((request, subscription))
+}
+
+fn configured_sources(
+ configuration: &RhiConfigDocumentV1,
+) -> Result<Vec<SourceBinding>, HostError> {
+ let relays = configuration
+ .normalized()
+ .pointer("/relays")
+ .and_then(Value::as_array)
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?;
+ let relay_urls = relays
+ .iter()
+ .map(|relay| {
+ let id = relay.pointer("/id").and_then(Value::as_str)?;
+ let url = relay.pointer("/url").and_then(Value::as_str)?;
+ Some((id, url))
+ })
+ .collect::<Option<BTreeMap<_, _>>>()
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?;
+ configuration
+ .normalized()
+ .pointer("/evidence/sources")
+ .and_then(Value::as_array)
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?
+ .iter()
+ .map(|source| {
+ let source_id = source
+ .pointer("/source_id")
+ .and_then(Value::as_str)
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?;
+ let relay_id = source
+ .pointer("/relay_id")
+ .and_then(Value::as_str)
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?;
+ let url = relay_urls
+ .get(relay_id)
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?;
+ Ok(SourceBinding {
+ source_id: source_id.into(),
+ target: Target::nostr_relay(url)
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?,
+ })
+ })
+ .collect()
+}
+
+fn spawn_admin(
+ supervisor: &mut radroots_service_host::TaskSupervisor,
+ server: RhiBoundAdminServer,
+) -> Result<(), RhiDaemonError> {
+ supervisor
+ .spawn(
+ task_metadata(TASK_ADMIN_SERVER, TaskClassification::Critical, ShutdownPhase::CloseSockets)?,
+ move |cancellation| async move {
+ let token = RhiAdminCancellationToken::new();
+ let serve_token = token.clone();
+ let serve = server.serve(serve_token);
+ tokio::pin!(serve);
+ tokio::select! {
+ result = serve.as_mut() => result.map_err(|error| HostError::with_source(HostErrorKind::AdminTransport, error)),
+ () = cancellation.cancelled() => {
+ token.cancel();
+ serve.await.map_err(|error| HostError::with_source(HostErrorKind::AdminTransport, error))
+ }
+ }
+ },
+ )
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+}
+
+fn spawn_operations(
+ supervisor: &mut radroots_service_host::TaskSupervisor,
+ server: RhiBoundOperationsServer,
+) -> Result<(), RhiDaemonError> {
+ supervisor
+ .spawn(
+ task_metadata(TASK_OPERATIONS_SERVER, TaskClassification::Optional, ShutdownPhase::CloseSockets)?,
+ move |cancellation| async move {
+ let token = RhiOperationsCancellationToken::new();
+ let serve_token = token.clone();
+ let serve = server.serve(serve_token);
+ tokio::pin!(serve);
+ tokio::select! {
+ result = serve.as_mut() => result.map_err(|error| HostError::with_source(HostErrorKind::OperationsServe, error)),
+ () = cancellation.cancelled() => {
+ token.cancel();
+ serve.await.map_err(|error| HostError::with_source(HostErrorKind::OperationsServe, error))
+ }
+ }
+ },
+ )
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+}
+
+fn spawn_source_subscription(
+ supervisor: &mut radroots_service_host::TaskSupervisor,
+ state: Arc<RhiStateHost>,
+ configuration: Arc<RhiConfigDocumentV1>,
+ transport: crate::RhiTransportAdapters,
+ time_entropy: RhiTimeEntropyAdapters,
+ initial: InitialSubscription,
+ health: tokio::sync::watch::Sender<bool>,
+) -> Result<(), RhiDaemonError> {
+ supervisor
+ .spawn(
+ task_metadata(
+ TASK_SOURCE_SUBSCRIPTION,
+ TaskClassification::Critical,
+ ShutdownPhase::CancelIngress,
+ )?,
+ move |cancellation| async move {
+ run_source_subscription(
+ cancellation,
+ state,
+ configuration,
+ transport,
+ time_entropy,
+ initial,
+ health,
+ )
+ .await
+ },
+ )
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+}
+
+async fn run_source_subscription(
+ cancellation: radroots_service_host::CancellationToken,
+ state: Arc<RhiStateHost>,
+ configuration: Arc<RhiConfigDocumentV1>,
+ transport: crate::RhiTransportAdapters,
+ time_entropy: RhiTimeEntropyAdapters,
+ mut active: InitialSubscription,
+ health: tokio::sync::watch::Sender<bool>,
+) -> Result<(), HostError> {
+ let policy = RhiReconciliationJobPolicy::from_configuration(&configuration)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let retry_delay = worker_idle(&configuration)?;
+ loop {
+ let next = tokio::select! {
+ () = cancellation.cancelled() => {
+ active.subscription.cancel().await
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ return Ok(());
+ }
+ next = active.subscription.next() => next,
+ };
+ let reconnect = match next {
+ Ok(SubscriptionNext::Event(event)) => {
+ event
+ .validate_for_request(&active.request)
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ let Some(source) = active.sources.iter().find(|source| {
+ source.target.fingerprint() == event.observed().provenance().target()
+ }) else {
+ return Err(HostError::new(HostErrorKind::TaskFailure));
+ };
+ let now = time_entropy
+ .now_utc()
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?
+ .get();
+ let observed = RhiTradeMutationObservedAtUnixSeconds::new(now)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let limits = RhiTradeMutationAdmissionLimits::from_config(&configuration)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let admitted = match admit_rhi_trade_mutation_event(
+ limits,
+ event.observed().event().raw_json().as_bytes(),
+ observed,
+ RhiTradeMutationAuthoredTimePolicy::new(0).map_err(|error| {
+ HostError::with_source(HostErrorKind::TaskFailure, error)
+ })?,
+ ) {
+ Ok(admitted) => admitted,
+ Err(_) => continue,
+ };
+ let trade_id = admitted.mutation().trade_id;
+ let attempt = RhiTradeSourceAttempt::new(
+ subscription_attempt_id(event.checkpoint().cursor().as_str()),
+ radroots_service_host::UnixTimeSeconds::new(now),
+ observed,
+ RhiTradeMutationAuthoredTimePolicy::new(0).map_err(|error| {
+ HostError::with_source(HostErrorKind::TaskFailure, error)
+ })?,
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let outcome = source_ingest::ingest_rhi_subscribed_trade_event(
+ &state.repositories(),
+ &configuration,
+ &source.source_id,
+ admitted,
+ attempt,
+ )
+ .await
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ if outcome.dirty_generation_advanced() {
+ state
+ .repositories()
+ .reconciliation_jobs()
+ .schedule_trade(
+ trade_id,
+ policy,
+ RhiReconciliationUnixMilliseconds::new(
+ now.checked_mul(1_000)
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?,
+ )
+ .map_err(|error| {
+ HostError::with_source(HostErrorKind::TaskFailure, error)
+ })?,
+ )
+ .await
+ .map_err(|error| {
+ HostError::with_source(HostErrorKind::TaskFailure, error)
+ })?;
+ }
+ false
+ }
+ Ok(SubscriptionNext::End(end)) => {
+ end.validate_for_request(&active.request)
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ true
+ }
+ Err(_) => true,
+ };
+ if !reconnect {
+ continue;
+ }
+ health.send_replace(false);
+ loop {
+ tokio::select! {
+ () = cancellation.cancelled() => return Ok(()),
+ () = tokio::time::sleep(retry_delay) => {}
+ }
+ match subscribe_sources(&configuration, &transport, &time_entropy, &active.sources)
+ .await
+ {
+ Ok((request, subscription)) => {
+ active.request = request;
+ active.subscription = subscription;
+ health.send_replace(true);
+ break;
+ }
+ Err(_) => continue,
+ }
+ }
+ }
+}
+
+fn spawn_reconciliation_worker(
+ supervisor: &mut radroots_service_host::TaskSupervisor,
+ state: Arc<RhiStateHost>,
+ configuration: Arc<RhiConfigDocumentV1>,
+ transport: crate::RhiTransportAdapters,
+ time_entropy: RhiTimeEntropyAdapters,
+ identity: Arc<crate::RhiDecryptedIdentity>,
+ publication: Arc<RhiPublicationAuthority>,
+) -> Result<(), RhiDaemonError> {
+ supervisor
+ .spawn(
+ task_metadata(
+ TASK_RECONCILIATION_WORKER,
+ TaskClassification::Critical,
+ ShutdownPhase::DrainOperations,
+ )?,
+ move |cancellation| async move {
+ run_reconciliation_worker(
+ cancellation,
+ state,
+ configuration,
+ transport,
+ time_entropy,
+ identity,
+ publication,
+ )
+ .await
+ },
+ )
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+}
+
+async fn run_reconciliation_worker(
+ cancellation: radroots_service_host::CancellationToken,
+ state: Arc<RhiStateHost>,
+ configuration: Arc<RhiConfigDocumentV1>,
+ transport: crate::RhiTransportAdapters,
+ time_entropy: RhiTimeEntropyAdapters,
+ identity: Arc<crate::RhiDecryptedIdentity>,
+ publication: Arc<RhiPublicationAuthority>,
+) -> Result<(), HostError> {
+ let owner = RhiReconciliationLeaseOwner::from_bytes(nonzero_entropy_16(&time_entropy)?)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let idle = Duration::from_millis(
+ configuration_integer(&configuration, "/reconciliation/initial_backoff_ms")
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?,
+ );
+ loop {
+ if cancellation.is_cancelled() {
+ return Ok(());
+ }
+ let now = reconciliation_now_with(&time_entropy)?;
+ let lease = state
+ .repositories()
+ .reconciliation_jobs()
+ .claim_next(owner, now)
+ .await
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let Some(lease) = lease else {
+ tokio::select! {
+ () = cancellation.cancelled() => return Ok(()),
+ () = tokio::time::sleep(idle) => continue,
+ }
+ };
+ let completed = execute_reconciliation_attempt(
+ &cancellation,
+ &state,
+ &configuration,
+ &transport,
+ &time_entropy,
+ &identity,
+ &publication,
+ lease,
+ now,
+ )
+ .await;
+ if completed.is_err() && !cancellation.is_cancelled() {
+ let maximum = RhiJitterBoundMilliseconds::new(lease.retry_delay_upper_bound())
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let delay = time_entropy
+ .sample_full_jitter(maximum)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ state
+ .repositories()
+ .reconciliation_jobs()
+ .record_failure(
+ lease,
+ reconciliation_now_with(&time_entropy)?,
+ RhiReconciliationRetryDelayMilliseconds::new(delay.get()).map_err(|error| {
+ HostError::with_source(HostErrorKind::TaskFailure, error)
+ })?,
+ )
+ .await
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ } else if cancellation.is_cancelled() {
+ return Ok(());
+ }
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn execute_reconciliation_attempt(
+ cancellation: &radroots_service_host::CancellationToken,
+ state: &RhiStateHost,
+ configuration: &RhiConfigDocumentV1,
+ transport: &crate::RhiTransportAdapters,
+ time_entropy: &RhiTimeEntropyAdapters,
+ identity: &crate::RhiDecryptedIdentity,
+ publication: &RhiPublicationAuthority,
+ lease: RhiReconciliationLease,
+ started_at: RhiReconciliationUnixMilliseconds,
+) -> Result<(), HostError> {
+ let plan = RhiReconciliationAttemptPlan::from_claim(lease, configuration, started_at)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let mut replays = Vec::with_capacity(plan.requests().len());
+ for request in plan.requests() {
+ if cancellation.is_cancelled() {
+ return Err(HostError::new(HostErrorKind::TaskFailure));
+ }
+ let prior = reconciliation_replay::read_committed_reconciliation_cursor(
+ &state.repositories(),
+ request,
+ plan.evidence_policy_digest(),
+ )
+ .await
+ .map_err(|()| HostError::new(HostErrorKind::TaskFailure))?;
+ let replay =
+ RhiReconciliationSourceReplayPlan::from_request(&plan, request, configuration, prior)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ replays.push(
+ fetch_reconciliation_source(
+ cancellation,
+ transport,
+ time_entropy,
+ configuration,
+ request,
+ replay,
+ )
+ .await?,
+ );
+ }
+ let committed = state
+ .repositories()
+ .reconciliation_attempts()
+ .commit_source_replays(lease, plan, replays)
+ .await
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let observed_at = time_entropy
+ .now_utc()
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let manifest = committed
+ .into_evidence_manifest(observed_at, RhiReconciliationScopePrerequisites::Satisfied)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let projection = reduce_rhi_reconciliation_manifest(manifest)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let claim = *projection
+ .root_mutation_id()
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?;
+ let evaluation = evaluate_rhi_reconciliation_claim(projection, claim);
+ let now = reconciliation_now_with(time_entropy)?;
+ let fence = state
+ .repositories()
+ .reconciliation_attempts()
+ .prepare_finalization(lease, evaluation, now)
+ .await
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ let supersession =
+ current_verified_supersession(state, fence.evaluation().projection().trade_id()).await?;
+ let attestation = build_rhi_signed_evidence_attestation(
+ fence,
+ identity,
+ observed_at,
+ time_entropy.entropy(),
+ supersession,
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ state
+ .repositories()
+ .reconciliation_attempts()
+ .commit_finalization(&attestation, publication, now)
+ .await
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ Ok(())
+}
+
+async fn current_verified_supersession(
+ state: &RhiStateHost,
+ trade_id: &TradeId,
+) -> Result<Option<RhiEvidenceAttestationSupersession>, HostError> {
+ let trade_id = *trade_id;
+ state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let rows = sqlx::query(
+ r#"SELECT
+ CASE WHEN typeof(report.statement_sha256) = 'blob'
+ AND length(report.statement_sha256) = 32
+ THEN report.statement_sha256 ELSE NULL END AS statement_sha256,
+ CASE WHEN typeof(event.event_id) = 'blob' AND length(event.event_id) = 32
+ THEN event.event_id ELSE NULL END AS event_id,
+ CASE WHEN typeof(report.canonical_report) = 'blob'
+ AND length(report.canonical_report) BETWEEN 1 AND 16384
+ THEN report.canonical_report ELSE NULL END AS canonical_report,
+ CASE WHEN typeof(event.canonical_event_json) = 'blob'
+ AND length(event.canonical_event_json) BETWEEN 1 AND 32768
+ THEN event.canonical_event_json ELSE NULL END AS canonical_event_json
+ FROM attestation_reports AS report
+ JOIN signed_attestation_events AS event
+ ON event.statement_sha256 = report.statement_sha256
+ WHERE report.trade_id = ? AND NOT EXISTS (
+ SELECT 1 FROM attestation_reports AS successor
+ WHERE successor.supersedes_statement_sha256 = report.statement_sha256
+ )
+ ORDER BY report.observed_at_unix_s DESC, report.statement_sha256 DESC
+ LIMIT 2"#,
+ )
+ .bind(trade_id.as_bytes().as_slice())
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| ())?;
+ if rows.is_empty() {
+ return Ok(None);
+ }
+ if rows.len() != 1 {
+ return Err(());
+ }
+ let row = &rows[0];
+ let statement = bounded_blob::<32>(row, "statement_sha256")?;
+ let event_id = bounded_blob::<32>(row, "event_id")?;
+ let canonical_report = row
+ .try_get::<Option<Vec<u8>>, _>("canonical_report")
+ .map_err(|_| ())?
+ .ok_or(())?;
+ let canonical_event = row
+ .try_get::<Option<Vec<u8>>, _>("canonical_event_json")
+ .map_err(|_| ())?
+ .ok_or(())?;
+ RhiEvidenceAttestationSupersession::from_persisted(
+ &trade_id,
+ statement,
+ event_id,
+ &canonical_report,
+ &canonical_event,
+ )
+ .map(Some)
+ .map_err(|_| ())
+ })
+ })
+ .await
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))
+}
+
+fn bounded_blob<const N: usize>(
+ row: &sqlx::sqlite::SqliteRow,
+ column: &str,
+) -> Result<[u8; N], ()> {
+ row.try_get::<Option<Vec<u8>>, _>(column)
+ .map_err(|_| ())?
+ .ok_or(())?
+ .try_into()
+ .map_err(|_| ())
+}
+
+async fn fetch_reconciliation_source(
+ cancellation: &radroots_service_host::CancellationToken,
+ transport: &crate::RhiTransportAdapters,
+ time_entropy: &RhiTimeEntropyAdapters,
+ configuration: &RhiConfigDocumentV1,
+ source_request: &RhiReconciliationSourceRequest,
+ replay: RhiReconciliationSourceReplayPlan,
+) -> Result<RhiReconciliationSourceReplay, HostError> {
+ let source = configured_sources(configuration)?
+ .into_iter()
+ .find(|source| source.source_id.as_ref() == source_request.source_id())
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))?;
+ let target_fingerprint = source.target.fingerprint().clone();
+ let targets = TargetSet::new(vec![source.target])
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ let selector = FetchSelector::all()
+ .with_kinds(TRADE_EVENT_KINDS.to_vec())
+ .and_then(|selector| selector.with_exact_tag_value('d', source_request.trade_id().to_hex()))
+ .and_then(|selector| selector.with_since_unix_seconds(replay.since_unix_seconds()))
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ let started_at = reconciliation_now_with(time_entropy)?;
+ let request_id = format!(
+ "rhi-reconcile-{}",
+ lower_hex(source_request.id().as_bytes())
+ );
+ let maximum_events = usize::try_from(source_request.maximum_events())
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ let mut adapter_cursor = None::<FetchCursor>;
+ let mut seen_cursors = BTreeSet::new();
+ let mut events = Vec::<RhiAdmittedTradeMutationEvent>::new();
+ let mut original_bytes = 0_u64;
+ let mut completion = 'pages: loop {
+ if cancellation.is_cancelled() {
+ break RhiTradeSourceCompletion::IncompleteTimeout;
+ }
+ let remaining = maximum_events.saturating_sub(events.len());
+ let limit = usize::min(
+ usize::from(FETCH_PAGE_MAX_EVENTS),
+ remaining.saturating_add(1),
+ );
+ let bounds = FetchBounds::new(
+ u16::try_from(limit).map_err(|_| HostError::new(HostErrorKind::TaskFailure))?,
+ source_request.deadline().get(),
+ )
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ let mut request = FetchRequest::new(request_id.clone(), targets.clone(), bounds)
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?
+ .with_selector(selector.clone());
+ if let Some(cursor) = adapter_cursor.take() {
+ request = request.with_cursor(cursor);
+ }
+ let fetched = tokio::select! {
+ () = cancellation.cancelled() => {
+ break 'pages RhiTradeSourceCompletion::IncompleteTimeout;
+ }
+ fetched = transport.evidence_source().fetch(request.clone()) => fetched,
+ };
+ let page = match fetched {
+ Ok(page) => page,
+ Err(radroots_transport::Error::UnsupportedOperation) => {
+ events.clear();
+ break RhiTradeSourceCompletion::Unsupported;
+ }
+ Err(_) => break RhiTradeSourceCompletion::IncompleteUnavailable,
+ };
+ if page.validate_for_request(&request).is_err() {
+ break RhiTradeSourceCompletion::IncompleteUnknown;
+ }
+ let Some(outcome) = page
+ .target_outcomes()
+ .iter()
+ .find(|outcome| outcome.target() == &target_fingerprint)
+ .filter(|_| page.target_outcomes().len() == 1)
+ else {
+ break RhiTradeSourceCompletion::IncompleteUnknown;
+ };
+ match outcome.state() {
+ FetchTargetState::Complete | FetchTargetState::Partial => {}
+ FetchTargetState::Unavailable | FetchTargetState::FailedRetryable => {
+ break RhiTradeSourceCompletion::IncompleteUnavailable;
+ }
+ FetchTargetState::Cancelled => {
+ break RhiTradeSourceCompletion::IncompleteTimeout;
+ }
+ FetchTargetState::FailedTerminal => {
+ break RhiTradeSourceCompletion::IncompleteUnknown;
+ }
+ }
+ for observed in page.events() {
+ let bytes = u64::try_from(observed.event().raw_json().len())
+ .map_err(|_| HostError::new(HostErrorKind::TaskFailure))?;
+ original_bytes = original_bytes.saturating_add(bytes);
+ if events.len() >= maximum_events || original_bytes > source_request.maximum_bytes() {
+ break 'pages RhiTradeSourceCompletion::IncompleteResourceLimit;
+ }
+ let observed_at = RhiTradeMutationObservedAtUnixSeconds::new(
+ observed.provenance().observed_at_unix_ms() / 1_000,
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ if let Ok(admitted) = admit_rhi_trade_mutation_event(
+ RhiTradeMutationAdmissionLimits::from_config(configuration)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?,
+ observed.event().raw_json().as_bytes(),
+ observed_at,
+ RhiTradeMutationAuthoredTimePolicy::new(0)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?,
+ ) && admitted.mutation().trade_id == source_request.trade_id()
+ {
+ events.push(admitted);
+ }
+ }
+ if outcome.state() == FetchTargetState::Partial {
+ break RhiTradeSourceCompletion::IncompleteUnknown;
+ }
+ match page.next_page() {
+ NextPage::Complete => break RhiTradeSourceCompletion::Complete,
+ NextPage::Cancelled { .. } => break RhiTradeSourceCompletion::IncompleteTimeout,
+ NextPage::Cursor(cursor) => {
+ if page.events().is_empty() || !seen_cursors.insert(cursor.as_str().to_owned()) {
+ break RhiTradeSourceCompletion::IncompleteUnknown;
+ }
+ adapter_cursor = Some(cursor.clone());
+ }
+ }
+ };
+ let mut finished_at = reconciliation_now_with(time_entropy)?;
+ if finished_at >= source_request.deadline() {
+ completion = RhiTradeSourceCompletion::IncompleteTimeout;
+ finished_at = source_request.deadline();
+ }
+ replay
+ .finish(source_request, completion, started_at, finished_at, events)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))
+}
+
+fn spawn_publication_worker(
+ supervisor: &mut radroots_service_host::TaskSupervisor,
+ state: Arc<RhiStateHost>,
+ configuration: Arc<RhiConfigDocumentV1>,
+ authority: Arc<RhiPublicationAuthority>,
+ sink: Arc<RhiNostrExactSink>,
+ time_entropy: RhiTimeEntropyAdapters,
+) -> Result<(), RhiDaemonError> {
+ supervisor
+ .spawn(
+ task_metadata(
+ TASK_PUBLICATION_WORKER,
+ TaskClassification::Critical,
+ ShutdownPhase::PersistRecoverableWork,
+ )?,
+ move |cancellation| async move {
+ let owner =
+ RhiPublicationLeaseOwner::from_bytes(nonzero_entropy_16(&time_entropy)?)
+ .map_err(|error| {
+ HostError::with_source(HostErrorKind::TaskFailure, error)
+ })?;
+ let idle = worker_idle(&configuration)?;
+ loop {
+ if cancellation.is_cancelled() {
+ return Ok(());
+ }
+ let result = state
+ .repositories()
+ .publication_outbox()
+ .execute_next_publication(owner, &time_entropy, sink.as_ref(), &authority)
+ .await
+ .map_err(|error| {
+ HostError::with_source(HostErrorKind::TaskFailure, error)
+ })?;
+ if result.is_none() {
+ tokio::select! {
+ () = cancellation.cancelled() => return Ok(()),
+ () = tokio::time::sleep(idle) => {}
+ }
+ }
+ }
+ },
+ )
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+}
+
+fn spawn_presence_worker(
+ supervisor: &mut radroots_service_host::TaskSupervisor,
+ state: Arc<RhiStateHost>,
+ configuration: Arc<RhiConfigDocumentV1>,
+ sink: Arc<RhiNostrExactSink>,
+ time_entropy: RhiTimeEntropyAdapters,
+) -> Result<(), RhiDaemonError> {
+ supervisor
+ .spawn(
+ task_metadata(
+ TASK_PRESENCE_WORKER,
+ TaskClassification::Critical,
+ ShutdownPhase::PersistRecoverableWork,
+ )?,
+ move |cancellation| async move {
+ let owner = RhiPresenceLeaseOwner::from_bytes(nonzero_entropy_16(&time_entropy)?)
+ .map_err(|error| {
+ HostError::with_source(HostErrorKind::TaskFailure, error)
+ })?;
+ let idle = worker_idle(&configuration)?;
+ loop {
+ if cancellation.is_cancelled() {
+ return Ok(());
+ }
+ let result = state
+ .repositories()
+ .presence_outbox()
+ .execute_next_presence(owner, &time_entropy, sink.as_ref())
+ .await
+ .map_err(|error| {
+ HostError::with_source(HostErrorKind::TaskFailure, error)
+ })?;
+ if result.is_none() {
+ tokio::select! {
+ () = cancellation.cancelled() => return Ok(()),
+ () = tokio::time::sleep(idle) => {}
+ }
+ }
+ }
+ },
+ )
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+}
+
+fn task_metadata(
+ name: &'static str,
+ classification: TaskClassification,
+ phase: ShutdownPhase,
+) -> Result<TaskMetadata, RhiDaemonError> {
+ TaskMetadata::new(
+ TaskName::new(name).map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))?,
+ classification,
+ Some(phase),
+ )
+ .map_err(|_| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+}
+
+fn runtime_build_info() -> Result<RhiStatusBuildInfoV1, HostError> {
+ let service_revision = option_env!("RADROOTS_SERVICE_REVISION");
+ let lib_revision = option_env!("RADROOTS_LIB_REVISION");
+ let rust_version = option_env!("RADROOTS_RUST_VERSION");
+ let target = option_env!("RADROOTS_BUILD_TARGET");
+ let mode = if service_revision.is_some()
+ && lib_revision.is_some()
+ && rust_version.is_some()
+ && target.is_some()
+ {
+ RhiStatusBuildMode::Release
+ } else {
+ RhiStatusBuildMode::Development
+ };
+ RhiStatusBuildInfoV1::new(
+ mode,
+ Some(env!("CARGO_PKG_VERSION")),
+ service_revision,
+ lib_revision,
+ rust_version,
+ target,
+ Some("service-host"),
+ )
+ .map_err(|error| HostError::with_source(HostErrorKind::Lifecycle, error))
+}
+
+fn operations_enabled(configuration: &RhiConfigDocumentV1) -> Result<bool, RhiDaemonError> {
+ configuration
+ .normalized()
+ .pointer("/operations/enabled")
+ .and_then(Value::as_bool)
+ .ok_or_else(|| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+}
+
+fn maximum_source_deadline(configuration: &RhiConfigDocumentV1) -> Result<u64, HostError> {
+ configuration
+ .normalized()
+ .pointer("/evidence/sources")
+ .and_then(Value::as_array)
+ .and_then(|sources| {
+ sources
+ .iter()
+ .filter_map(|source| source.pointer("/deadline_ms").and_then(Value::as_u64))
+ .max()
+ })
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))
+}
+
+fn configuration_integer(
+ configuration: &RhiConfigDocumentV1,
+ pointer: &str,
+) -> Result<u64, RhiDaemonError> {
+ configuration
+ .normalized()
+ .pointer(pointer)
+ .and_then(Value::as_u64)
+ .ok_or_else(|| RhiDaemonError::new(RhiDaemonErrorKind::Runtime))
+}
+
+fn worker_idle(configuration: &RhiConfigDocumentV1) -> Result<Duration, HostError> {
+ configuration
+ .normalized()
+ .pointer("/reconciliation/initial_backoff_ms")
+ .and_then(Value::as_u64)
+ .map(Duration::from_millis)
+ .ok_or_else(|| HostError::new(HostErrorKind::TaskFailure))
+}
+
+fn reconciliation_now_with(
+ time_entropy: &RhiTimeEntropyAdapters,
+) -> Result<RhiReconciliationUnixMilliseconds, HostError> {
+ let milliseconds = time_entropy
+ .now_utc_milliseconds()
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ RhiReconciliationUnixMilliseconds::new(milliseconds)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))
+}
+
+fn subscription_attempt_id(cursor: &str) -> String {
+ let digest = Sha256::digest(cursor.as_bytes());
+ format!("subscription-{}", lower_hex(&digest))
+}
+
+fn nonzero_entropy_16(time_entropy: &RhiTimeEntropyAdapters) -> Result<[u8; 16], HostError> {
+ for _ in 0..4 {
+ let mut bytes = [0_u8; 16];
+ time_entropy
+ .entropy()
+ .fill_bytes(&mut bytes)
+ .map_err(|error| HostError::with_source(HostErrorKind::TaskFailure, error))?;
+ if bytes.iter().any(|byte| *byte != 0) {
+ return Ok(bytes);
+ }
+ }
+ Err(HostError::new(HostErrorKind::TaskFailure))
+}
+
+fn lower_hex(bytes: &[u8]) -> String {
+ use core::fmt::Write as _;
+
+ let mut encoded = String::with_capacity(bytes.len().saturating_mul(2));
+ for byte in bytes {
+ write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail");
+ }
+ encoded
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn empty_status_context() -> RuntimeStatusContext {
+ let time_entropy = RhiTimeEntropyAdapters::system();
+ RuntimeStatusContext {
+ configuration_digest: "0".repeat(64),
+ configuration_source: RhiStatusConfigurationSource::DerivedRepoLocal,
+ schema_version: crate::RHI_STATE_SCHEMA_VERSION,
+ generation: 1,
+ source_count: 1,
+ started_at: time_entropy.now_monotonic(),
+ time_entropy,
+ reconciliation: RhiReconciliationStatusV1::default(),
+ publication: RhiPublicationStatusV1::default(),
+ presence: RhiPresenceStatusV1::default(),
+ }
+ }
+
+ #[test]
+ fn runtime_status_keeps_optional_operations_degradation_ready_and_reasoned() {
+ let context = empty_status_context();
+ let observation = context
+ .observation(RhiServicePhase::Degraded, true, false)
+ .expect("degraded observation");
+ let (_, reader) = crate::rhi_status_cache(
+ radroots_runtime_paths::InstanceId::new("primary").expect("instance"),
+ observation,
+ )
+ .expect("status cache");
+ let snapshot = reader.snapshot();
+ assert!(snapshot.is_ready());
+ let json = std::str::from_utf8(snapshot.detailed_status_json()).expect("status UTF-8");
+ assert!(json.contains("\"operations_listener_failed\""));
+ assert!(!json.contains("\"source_unavailable\""));
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "macos"))]
+ #[tokio::test]
+ async fn runtime_status_refresh_reads_the_exact_empty_durable_work_summary() {
+ use std::{fs, os::unix::fs::PermissionsExt as _};
+
+ use radroots_service_sqlite::{MigrationAppliedAtUnixSeconds, MigrationBuildIdentity};
+ use radroots_storage::event::SourceGeneration;
+
+ let root = tempfile::tempdir().expect("test root");
+ let root_text = root.path().to_str().expect("UTF-8 root");
+ let invocation = crate::parse_rhi_cli_v1_from([
+ "rhi",
+ "--profile",
+ "repo-local",
+ "--instance",
+ "primary",
+ "--repo-local-root",
+ root_text,
+ "run",
+ ])
+ .expect("invocation");
+ let runtime = crate::resolve_rhi_runtime_context(
+ &crate::RadrootsPathResolver::new(
+ crate::RadrootsPlatform::Linux,
+ crate::RadrootsHostEnvironment::default(),
+ ),
+ &invocation,
+ )
+ .expect("runtime");
+ fs::create_dir_all(runtime.context().paths().state()).expect("state root");
+ fs::set_permissions(
+ runtime.context().paths().state(),
+ fs::Permissions::from_mode(0o700),
+ )
+ .expect("state mode");
+ let configuration = crate::parse_rhi_config_v1(
+ include_bytes!("../contracts/services_hardening/config.v1.example.toml"),
+ crate::RhiConfigProfile::RepoLocal,
+ )
+ .expect("configuration");
+ let metadata = crate::RhiStateMetadata::new(
+ &runtime,
+ &configuration,
+ SourceGeneration::new([0x41; 32]).expect("generation"),
+ 1_725_000_000_000,
+ )
+ .expect("metadata");
+ let applied_at = MigrationAppliedAtUnixSeconds::new(1_725_000_000).expect("time");
+ let build = MigrationBuildIdentity::new(
+ env!("CARGO_PKG_VERSION"),
+ "1111111111111111111111111111111111111111",
+ "21b11e7a5120ea949f7ad0838c746873fc73aac2",
+ "rustc-test",
+ "test-target",
+ "service-host",
+ 1,
+ crate::RHI_STATE_SCHEMA_VERSION,
+ 1,
+ 1,
+ 1,
+ )
+ .expect("build");
+ crate::initialize_rhi_state(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("initialize");
+ let state = crate::open_rhi_state_read_write(&runtime, &metadata, applied_at, &build)
+ .await
+ .expect("open");
+ let mut status = empty_status_context();
+ status.refresh_state(&state).await.expect("refresh");
+ assert_eq!(status.reconciliation, RhiReconciliationStatusV1::default());
+ assert_eq!(status.publication, RhiPublicationStatusV1::default());
+ assert_eq!(status.presence, RhiPresenceStatusV1::default());
+ state.close().await.expect("close");
+ }
+}
diff --git a/src/runtime_signal.rs b/src/runtime_signal.rs
@@ -0,0 +1,50 @@
+//! Binary-owned process-signal injection for the Rhi daemon.
+
+use core::{fmt, future::Future, pin::Pin};
+
+/// One normalized process signal supplied by the Rhi binary.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiProcessSignal {
+ Interrupt,
+ #[cfg(unix)]
+ Terminate,
+}
+
+impl RhiProcessSignal {
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Interrupt => "interrupt",
+ #[cfg(unix)]
+ Self::Terminate => "terminate",
+ }
+ }
+}
+
+impl fmt::Display for RhiProcessSignal {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(self.as_str())
+ }
+}
+
+/// Boxed wait returned by a binary-owned signal source.
+pub type RhiProcessSignalFuture<'a> =
+ Pin<Box<dyn Future<Output = Option<RhiProcessSignal>> + Send + 'a>>;
+
+/// Process-signal source installed only by the executable boundary.
+pub trait RhiProcessSignalSource: Send {
+ fn next_signal(&mut self) -> RhiProcessSignalFuture<'_>;
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn normalized_signal_names_are_stable() {
+ assert_eq!(RhiProcessSignal::Interrupt.as_str(), "interrupt");
+ assert_eq!(RhiProcessSignal::Interrupt.to_string(), "interrupt");
+ #[cfg(unix)]
+ assert_eq!(RhiProcessSignal::Terminate.as_str(), "terminate");
+ }
+}
diff --git a/src/source_ingest.rs b/src/source_ingest.rs
@@ -400,6 +400,43 @@ pub async fn ingest_rhi_trade_source(
commit_source_result(repositories, source, trade_id, attempt, initial, fetched).await
}
+/// Atomically commits one event that was already admitted from a governed
+/// subscription. This keeps subscription delivery on the same checkpoint,
+/// provenance, dirty-generation, and evidence transaction used by paged
+/// source ingestion without issuing a second network request.
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) async fn ingest_rhi_subscribed_trade_event(
+ repositories: &RhiStateRepositories<'_>,
+ configuration: &RhiConfigDocumentV1,
+ source_id: &str,
+ admitted: RhiAdmittedTradeMutationEvent,
+ attempt: RhiTradeSourceAttempt,
+) -> Result<RhiTradeSourceIngestOutcome, RhiTradeSourceIngestError> {
+ let host = repositories.host();
+ if host.mode() != RhiStateHostMode::ReadWriteExisting {
+ return Err(failure(RhiTradeSourceIngestErrorKind::InvalidMode));
+ }
+ let source = ConfiguredSource::new(host, configuration, source_id)?;
+ let trade_id = admitted.mutation().trade_id;
+ if admitted.original_bytes().len() > source.maximum_bytes || source.maximum_events == 0 {
+ return Err(failure(RhiTradeSourceIngestErrorKind::InvalidInput));
+ }
+ let cursor = RhiTradeSourceCursor {
+ created_at_unix_seconds: admitted.authored_at_unix_seconds(),
+ event_id: *admitted.event_id().as_bytes(),
+ };
+ let initial = read_initial_state(repositories, &source, trade_id).await?;
+ let fetched = FetchedSource {
+ completion: RhiTradeSourceCompletion::Complete,
+ received_events: 1,
+ duplicate_events: 0,
+ admitted: vec![admitted],
+ rejected_events: 0,
+ cursor_candidate: Some(cursor),
+ };
+ commit_source_result(repositories, source, trade_id, attempt, initial, fetched).await
+}
+
struct ConfiguredSource {
source_id: Box<str>,
relay_url: Box<str>,
diff --git a/src/state_admin.rs b/src/state_admin.rs
@@ -0,0 +1,807 @@
+//! Bounded durable idempotency for permissioned Rhi admin mutations.
+
+use core::{fmt, future::Future, pin::Pin};
+use std::error::Error;
+
+use radroots_service_sqlite::{
+ ServiceSqliteTransaction, ServiceSqliteTransactionError, ServiceSqliteTransactionErrorKind,
+};
+use sha2::{Digest, Sha256};
+use sqlx::Row;
+
+use crate::{
+ RhiAdminRequestDocument, RhiAdminResponseDocument, RhiAdminRoute, RhiStateHost,
+ RhiStateHostMode,
+};
+
+/// Maximum encoded length of a durable admin operation identifier.
+pub const RHI_ADMIN_OPERATION_ID_MAX_BYTES: usize = 128;
+/// Maximum canonical response-model bytes retained for replay.
+pub const RHI_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES: usize = 8_192;
+/// Maximum retained completed operations after expiry pruning.
+pub const RHI_ADMIN_OPERATION_COMPLETED_LIMIT: u16 = 4_096;
+/// Maximum retained operations whose external outcome is unresolved.
+pub const RHI_ADMIN_OPERATION_PREPARED_LIMIT: u8 = 128;
+/// Frozen seven-day completed-response retention.
+pub const RHI_ADMIN_OPERATION_DEFAULT_RETENTION_MS: u64 = 604_800_000;
+
+const REQUEST_DIGEST_DOMAIN: &[u8] = b"radroots.rhi.admin_operation_request.v1\0";
+const PRUNE_LIMIT: i64 = 4_096;
+
+const PRUNE_EXPIRED_SQL: &str = r#"DELETE FROM rhi_admin_operations
+WHERE operation_id IN (
+ SELECT operation_id FROM rhi_admin_operations
+ WHERE state = 'completed' AND expires_at_unix_ms <= ?
+ ORDER BY expires_at_unix_ms, operation_id
+ LIMIT ?
+)"#;
+
+const READ_OPERATION_SQL: &str = r#"SELECT
+ CASE WHEN typeof(route) = 'text' AND length(CAST(route AS BLOB)) BETWEEN 1 AND 128
+ THEN route ELSE NULL END AS route,
+ CASE WHEN typeof(request_sha256) = 'blob' AND length(request_sha256) = 32
+ THEN request_sha256 ELSE NULL END AS request_sha256,
+ CASE WHEN typeof(state) = 'text' AND length(CAST(state AS BLOB)) <= 16
+ THEN state ELSE NULL END AS state,
+ CASE WHEN typeof(response_model) = 'blob' AND length(response_model) BETWEEN 1 AND 8192
+ THEN response_model ELSE NULL END AS response_model,
+ typeof(response_model) AS response_model_type,
+ CASE WHEN typeof(response_sha256) = 'blob' AND length(response_sha256) = 32
+ THEN response_sha256 ELSE NULL END AS response_sha256,
+ typeof(response_sha256) AS response_sha256_type,
+ prepared_at_unix_ms,
+ completed_at_unix_ms,
+ typeof(completed_at_unix_ms) AS completed_at_type,
+ expires_at_unix_ms,
+ typeof(expires_at_unix_ms) AS expires_at_type
+FROM rhi_admin_operations
+WHERE operation_id = ?
+LIMIT 2"#;
+
+const READ_COUNTS_SQL: &str = r#"SELECT
+ COUNT(CASE WHEN state = 'completed' THEN 1 END) AS completed_count,
+ COUNT(CASE WHEN state = 'prepared' THEN 1 END) AS prepared_count
+FROM rhi_admin_operations"#;
+
+const INSERT_PREPARED_SQL: &str = r#"INSERT INTO rhi_admin_operations (
+ operation_id, route, request_sha256, state, prepared_at_unix_ms
+) VALUES (?, ?, ?, 'prepared', ?)"#;
+
+const COMPLETE_OPERATION_SQL: &str = r#"UPDATE rhi_admin_operations
+SET state = 'completed', response_model = ?, response_sha256 = ?,
+ completed_at_unix_ms = ?, expires_at_unix_ms = ?
+WHERE operation_id = ? AND state = 'prepared'"#;
+
+/// Stable source-free admin-journal failure classes.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiAdminOperationErrorKind {
+ InvalidMode,
+ InvalidInput,
+ OperationConflict,
+ OperationOutcomeUnknown,
+ ResourceExhausted,
+ Binding,
+ Transaction,
+ CommitOutcomeUnknown,
+}
+
+/// Redacted admin-journal error.
+#[derive(Clone, Copy, PartialEq, Eq)]
+pub struct RhiAdminOperationError {
+ kind: RhiAdminOperationErrorKind,
+}
+
+impl RhiAdminOperationError {
+ const fn new(kind: RhiAdminOperationErrorKind) -> Self {
+ Self { kind }
+ }
+
+ /// Returns the stable failure class.
+ #[must_use]
+ pub const fn kind(self) -> RhiAdminOperationErrorKind {
+ self.kind
+ }
+}
+
+impl fmt::Display for RhiAdminOperationError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self.kind {
+ RhiAdminOperationErrorKind::InvalidMode => {
+ "RHI admin operation requires writable state"
+ }
+ RhiAdminOperationErrorKind::InvalidInput => "RHI admin operation input is invalid",
+ RhiAdminOperationErrorKind::OperationConflict => {
+ "RHI admin operation identity conflicts with retained evidence"
+ }
+ RhiAdminOperationErrorKind::OperationOutcomeUnknown => {
+ "RHI admin operation outcome is unknown"
+ }
+ RhiAdminOperationErrorKind::ResourceExhausted => {
+ "RHI admin operation capacity is exhausted"
+ }
+ RhiAdminOperationErrorKind::Binding => "RHI admin operation journal binding is invalid",
+ RhiAdminOperationErrorKind::Transaction => "RHI admin operation transaction failed",
+ RhiAdminOperationErrorKind::CommitOutcomeUnknown => {
+ "RHI admin operation commit outcome is unknown"
+ }
+ })
+ }
+}
+
+impl fmt::Debug for RhiAdminOperationError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiAdminOperationError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl Error for RhiAdminOperationError {}
+
+#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
+struct AdminOperationIdBinding(Box<str>);
+
+impl AdminOperationIdBinding {
+ fn new(value: &str) -> Result<Self, RhiAdminOperationError> {
+ let bytes = value.as_bytes();
+ let valid = !bytes.is_empty()
+ && bytes.len() <= RHI_ADMIN_OPERATION_ID_MAX_BYTES
+ && bytes[0].is_ascii_alphanumeric()
+ && bytes.iter().all(|byte| {
+ byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b':' | b'-')
+ });
+ valid
+ .then(|| Self(value.into()))
+ .ok_or_else(|| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput))
+ }
+
+ fn as_str(&self) -> &str {
+ &self.0
+ }
+}
+
+impl fmt::Debug for AdminOperationIdBinding {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("AdminOperationIdBinding([redacted])")
+ }
+}
+
+/// Injected UTC millisecond evidence representable by SQLite.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct RhiAdminOperationTimeUnixMs(u64);
+
+impl RhiAdminOperationTimeUnixMs {
+ /// Validates one UTC millisecond instant without reading ambient time.
+ pub fn new(value: u64) -> Result<Self, RhiAdminOperationError> {
+ i64::try_from(value)
+ .map(|_| Self(value))
+ .map_err(|_| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput))
+ }
+
+ /// Returns the validated instant.
+ #[must_use]
+ pub const fn get(self) -> u64 {
+ self.0
+ }
+
+ fn sqlite_value(self) -> i64 {
+ i64::try_from(self.0).expect("validated admin operation time fits SQLite")
+ }
+}
+
+/// Explicit bounded completed-response retention policy.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub struct RhiAdminOperationJournalPolicy {
+ completed_retention_ms: u64,
+}
+
+impl RhiAdminOperationJournalPolicy {
+ /// Returns the exact seven-day policy.
+ #[must_use]
+ pub const fn seven_days() -> Self {
+ Self {
+ completed_retention_ms: RHI_ADMIN_OPERATION_DEFAULT_RETENTION_MS,
+ }
+ }
+
+ /// Returns the admitted retention duration.
+ #[must_use]
+ pub const fn completed_retention_ms(self) -> u64 {
+ self.completed_retention_ms
+ }
+}
+
+/// Sealed evidence that one external or cross-resource mutation is unresolved.
+pub struct RhiPreparedAdminOperation {
+ operation_id: AdminOperationIdBinding,
+ route: RhiAdminRoute,
+ request_sha256: [u8; 32],
+ prepared_at: RhiAdminOperationTimeUnixMs,
+}
+
+impl fmt::Debug for RhiPreparedAdminOperation {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiPreparedAdminOperation")
+ .field("route", &self.route)
+ .field("identity", &"[redacted]")
+ .finish()
+ }
+}
+
+/// Result of mutation admission after bounded expiry pruning.
+pub enum RhiAdminOperationAdmission {
+ Prepared(RhiPreparedAdminOperation),
+ ExactReplay(RhiAdminResponseDocument),
+}
+
+impl fmt::Debug for RhiAdminOperationAdmission {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str(match self {
+ Self::Prepared(_) => "RhiAdminOperationAdmission::Prepared([redacted])",
+ Self::ExactReplay(_) => "RhiAdminOperationAdmission::ExactReplay([redacted])",
+ })
+ }
+}
+
+/// Result of completing a previously prepared operation.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum RhiAdminOperationCompletion {
+ Completed,
+ ExactReplay,
+}
+
+pub(crate) struct RhiAdminOperationRepository<'host> {
+ host: &'host RhiStateHost,
+}
+
+impl<'host> RhiAdminOperationRepository<'host> {
+ pub(crate) const fn new(host: &'host RhiStateHost) -> Self {
+ Self { host }
+ }
+
+ /// Atomically commits one SQLite-only admin mutation and its replay receipt.
+ pub(crate) async fn execute_database_admin_operation<F>(
+ &self,
+ request: &RhiAdminRequestDocument,
+ completed_at: RhiAdminOperationTimeUnixMs,
+ policy: RhiAdminOperationJournalPolicy,
+ operation: F,
+ ) -> Result<RhiAdminResponseDocument, RhiAdminOperationError>
+ where
+ F: for<'a, 'b> FnOnce(
+ &'a mut ServiceSqliteTransaction<'b>,
+ ) -> AdminDatabaseOperationFuture<'a>
+ + Send
+ + 'static,
+ {
+ if self.host.mode() != RhiStateHostMode::ReadWriteExisting {
+ return Err(RhiAdminOperationError::new(
+ RhiAdminOperationErrorKind::InvalidMode,
+ ));
+ }
+ let binding = AdminRequestBinding::from_document(request)?;
+ let expires_at = completed_at
+ .get()
+ .checked_add(policy.completed_retention_ms())
+ .filter(|value| i64::try_from(*value).is_ok())
+ .ok_or_else(|| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput))?;
+ self.host
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let prepared = match prepare_operation(transaction, &binding, completed_at)
+ .await?
+ {
+ RhiAdminOperationAdmission::ExactReplay(response) => return Ok(response),
+ RhiAdminOperationAdmission::Prepared(prepared) => prepared,
+ };
+ let response = operation(transaction).await?;
+ if response.route() != binding.route
+ || response.canonical_bytes().is_empty()
+ || response.canonical_bytes().len()
+ > RHI_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES
+ {
+ return Err(AdminJournalOperationError::InvalidInput);
+ }
+ complete_operation(
+ transaction,
+ &PreparedBinding::from_prepared(&prepared),
+ response.canonical_bytes(),
+ completed_at,
+ expires_at,
+ )
+ .await?;
+ Ok(response)
+ })
+ })
+ .await
+ .map_err(map_transaction_error)
+ }
+
+ /// Prunes a bounded expired prefix and admits or replays one mutation.
+ pub async fn prepare_admin_operation(
+ &self,
+ request: &RhiAdminRequestDocument,
+ observed_at: RhiAdminOperationTimeUnixMs,
+ ) -> Result<RhiAdminOperationAdmission, RhiAdminOperationError> {
+ if self.host.mode() != RhiStateHostMode::ReadWriteExisting {
+ return Err(RhiAdminOperationError::new(
+ RhiAdminOperationErrorKind::InvalidMode,
+ ));
+ }
+ let binding = AdminRequestBinding::from_document(request)?;
+ self.host
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move { prepare_operation(transaction, &binding, observed_at).await })
+ })
+ .await
+ .map_err(map_transaction_error)
+ }
+
+ /// Completes one external mutation only after its durable effect exists.
+ pub async fn complete_admin_operation(
+ &self,
+ prepared: &RhiPreparedAdminOperation,
+ response: &RhiAdminResponseDocument,
+ completed_at: RhiAdminOperationTimeUnixMs,
+ policy: RhiAdminOperationJournalPolicy,
+ ) -> Result<RhiAdminOperationCompletion, RhiAdminOperationError> {
+ if self.host.mode() != RhiStateHostMode::ReadWriteExisting {
+ return Err(RhiAdminOperationError::new(
+ RhiAdminOperationErrorKind::InvalidMode,
+ ));
+ }
+ if response.route() != prepared.route
+ || response.canonical_bytes().is_empty()
+ || response.canonical_bytes().len() > RHI_ADMIN_OPERATION_RESPONSE_MODEL_MAX_BYTES
+ || completed_at < prepared.prepared_at
+ {
+ return Err(RhiAdminOperationError::new(
+ RhiAdminOperationErrorKind::InvalidInput,
+ ));
+ }
+ let expires_at = completed_at
+ .get()
+ .checked_add(policy.completed_retention_ms())
+ .filter(|value| i64::try_from(*value).is_ok())
+ .ok_or_else(|| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput))?;
+ let binding = PreparedBinding::from_prepared(prepared);
+ let response = response.canonical_bytes().to_vec().into_boxed_slice();
+ self.host
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ complete_operation(transaction, &binding, &response, completed_at, expires_at)
+ .await
+ })
+ })
+ .await
+ .map_err(map_transaction_error)
+ }
+}
+
+struct AdminRequestBinding {
+ operation_id: AdminOperationIdBinding,
+ route: RhiAdminRoute,
+ request_sha256: [u8; 32],
+}
+
+impl AdminRequestBinding {
+ fn from_document(request: &RhiAdminRequestDocument) -> Result<Self, RhiAdminOperationError> {
+ if !request.route().is_mutation() {
+ return Err(RhiAdminOperationError::new(
+ RhiAdminOperationErrorKind::InvalidInput,
+ ));
+ }
+ let operation_id = request
+ .operation_id()
+ .ok_or_else(|| RhiAdminOperationError::new(RhiAdminOperationErrorKind::InvalidInput))?;
+ Ok(Self {
+ operation_id: AdminOperationIdBinding::new(operation_id)?,
+ route: request.route(),
+ request_sha256: request_digest(request),
+ })
+ }
+}
+
+struct PreparedBinding {
+ operation_id: AdminOperationIdBinding,
+ route: RhiAdminRoute,
+ request_sha256: [u8; 32],
+ prepared_at: RhiAdminOperationTimeUnixMs,
+}
+
+impl PreparedBinding {
+ fn from_prepared(prepared: &RhiPreparedAdminOperation) -> Self {
+ Self {
+ operation_id: prepared.operation_id.clone(),
+ route: prepared.route,
+ request_sha256: prepared.request_sha256,
+ prepared_at: prepared.prepared_at,
+ }
+ }
+}
+
+enum StoredOperation {
+ Prepared {
+ route: RhiAdminRoute,
+ request_sha256: [u8; 32],
+ prepared_at: RhiAdminOperationTimeUnixMs,
+ },
+ Completed {
+ route: RhiAdminRoute,
+ request_sha256: [u8; 32],
+ response: Box<[u8]>,
+ response_sha256: [u8; 32],
+ prepared_at: RhiAdminOperationTimeUnixMs,
+ completed_at: RhiAdminOperationTimeUnixMs,
+ expires_at: RhiAdminOperationTimeUnixMs,
+ },
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum AdminJournalOperationError {
+ InvalidInput,
+ Conflict,
+ OutcomeUnknown,
+ ResourceExhausted,
+ Binding,
+ Storage,
+}
+
+pub(crate) type AdminDatabaseOperationFuture<'a> = Pin<
+ Box<
+ dyn Future<Output = Result<RhiAdminResponseDocument, AdminJournalOperationError>>
+ + Send
+ + 'a,
+ >,
+>;
+
+async fn prepare_operation(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ binding: &AdminRequestBinding,
+ observed_at: RhiAdminOperationTimeUnixMs,
+) -> Result<RhiAdminOperationAdmission, AdminJournalOperationError> {
+ prune_expired(transaction, observed_at).await?;
+ if let Some(existing) = read_operation(transaction, &binding.operation_id).await? {
+ return match existing {
+ StoredOperation::Prepared {
+ route,
+ request_sha256,
+ ..
+ } if route == binding.route && request_sha256 == binding.request_sha256 => {
+ Err(AdminJournalOperationError::OutcomeUnknown)
+ }
+ StoredOperation::Completed {
+ route,
+ request_sha256,
+ response,
+ response_sha256,
+ ..
+ } if route == binding.route && request_sha256 == binding.request_sha256 => {
+ if sha256(&response) != response_sha256 {
+ return Err(AdminJournalOperationError::Binding);
+ }
+ RhiAdminResponseDocument::from_canonical_bytes(route, &response)
+ .map(RhiAdminOperationAdmission::ExactReplay)
+ .map_err(|_| AdminJournalOperationError::Binding)
+ }
+ StoredOperation::Prepared { .. } | StoredOperation::Completed { .. } => {
+ Err(AdminJournalOperationError::Conflict)
+ }
+ };
+ }
+ let (completed, prepared) = read_counts(transaction).await?;
+ let reserved = completed
+ .checked_add(prepared)
+ .ok_or(AdminJournalOperationError::Binding)?;
+ if reserved >= u64::from(RHI_ADMIN_OPERATION_COMPLETED_LIMIT)
+ || prepared >= u64::from(RHI_ADMIN_OPERATION_PREPARED_LIMIT)
+ {
+ return Err(AdminJournalOperationError::ResourceExhausted);
+ }
+ let result = sqlx::query(INSERT_PREPARED_SQL)
+ .bind(binding.operation_id.as_str())
+ .bind(binding.route.operation_id())
+ .bind(binding.request_sha256.as_slice())
+ .bind(observed_at.sqlite_value())
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ require_one(result.rows_affected())?;
+ match read_operation(transaction, &binding.operation_id).await? {
+ Some(StoredOperation::Prepared {
+ route,
+ request_sha256,
+ prepared_at,
+ }) if route == binding.route
+ && request_sha256 == binding.request_sha256
+ && prepared_at == observed_at =>
+ {
+ Ok(RhiAdminOperationAdmission::Prepared(
+ RhiPreparedAdminOperation {
+ operation_id: binding.operation_id.clone(),
+ route,
+ request_sha256,
+ prepared_at,
+ },
+ ))
+ }
+ Some(_) | None => Err(AdminJournalOperationError::Binding),
+ }
+}
+
+async fn complete_operation(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ binding: &PreparedBinding,
+ response: &[u8],
+ completed_at: RhiAdminOperationTimeUnixMs,
+ expires_at: u64,
+) -> Result<RhiAdminOperationCompletion, AdminJournalOperationError> {
+ let response_sha256 = sha256(response);
+ match read_operation(transaction, &binding.operation_id).await? {
+ Some(StoredOperation::Completed {
+ route,
+ request_sha256,
+ response: existing_response,
+ response_sha256: existing_sha256,
+ ..
+ }) if route == binding.route
+ && request_sha256 == binding.request_sha256
+ && existing_response.as_ref() == response
+ && existing_sha256 == response_sha256 =>
+ {
+ return Ok(RhiAdminOperationCompletion::ExactReplay);
+ }
+ Some(StoredOperation::Completed { .. }) => {
+ return Err(AdminJournalOperationError::Conflict);
+ }
+ Some(StoredOperation::Prepared {
+ route,
+ request_sha256,
+ prepared_at,
+ }) if route == binding.route
+ && request_sha256 == binding.request_sha256
+ && prepared_at == binding.prepared_at => {}
+ Some(StoredOperation::Prepared { .. }) => {
+ return Err(AdminJournalOperationError::Conflict);
+ }
+ None => return Err(AdminJournalOperationError::Binding),
+ }
+ let (completed, _) = read_counts(transaction).await?;
+ if completed >= u64::from(RHI_ADMIN_OPERATION_COMPLETED_LIMIT) {
+ return Err(AdminJournalOperationError::ResourceExhausted);
+ }
+ let result = sqlx::query(COMPLETE_OPERATION_SQL)
+ .bind(response)
+ .bind(response_sha256.as_slice())
+ .bind(completed_at.sqlite_value())
+ .bind(i64::try_from(expires_at).map_err(|_| AdminJournalOperationError::InvalidInput)?)
+ .bind(binding.operation_id.as_str())
+ .execute(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ require_one(result.rows_affected())?;
+ match read_operation(transaction, &binding.operation_id).await? {
+ Some(StoredOperation::Completed {
+ route,
+ request_sha256,
+ response: actual_response,
+ response_sha256: actual_sha256,
+ prepared_at,
+ completed_at: actual_completed_at,
+ expires_at: actual_expires_at,
+ }) if route == binding.route
+ && request_sha256 == binding.request_sha256
+ && actual_response.as_ref() == response
+ && actual_sha256 == response_sha256
+ && prepared_at == binding.prepared_at
+ && actual_completed_at == completed_at
+ && actual_expires_at.get() == expires_at =>
+ {
+ Ok(RhiAdminOperationCompletion::Completed)
+ }
+ Some(_) | None => Err(AdminJournalOperationError::Binding),
+ }
+}
+
+async fn prune_expired(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ observed_at: RhiAdminOperationTimeUnixMs,
+) -> Result<(), AdminJournalOperationError> {
+ sqlx::query(PRUNE_EXPIRED_SQL)
+ .bind(observed_at.sqlite_value())
+ .bind(PRUNE_LIMIT)
+ .execute(&mut *transaction)
+ .await
+ .map(|_| ())
+ .map_err(|_| AdminJournalOperationError::Storage)
+}
+
+async fn read_counts(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+) -> Result<(u64, u64), AdminJournalOperationError> {
+ let rows = sqlx::query(READ_COUNTS_SQL)
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ if rows.len() != 1 {
+ return Err(AdminJournalOperationError::Binding);
+ }
+ let completed = rows[0]
+ .try_get::<i64, _>("completed_count")
+ .map_err(|_| AdminJournalOperationError::Binding)?;
+ let prepared = rows[0]
+ .try_get::<i64, _>("prepared_count")
+ .map_err(|_| AdminJournalOperationError::Binding)?;
+ Ok((
+ u64::try_from(completed).map_err(|_| AdminJournalOperationError::Binding)?,
+ u64::try_from(prepared).map_err(|_| AdminJournalOperationError::Binding)?,
+ ))
+}
+
+async fn read_operation(
+ transaction: &mut ServiceSqliteTransaction<'_>,
+ operation_id: &AdminOperationIdBinding,
+) -> Result<Option<StoredOperation>, AdminJournalOperationError> {
+ let rows = sqlx::query(READ_OPERATION_SQL)
+ .bind(operation_id.as_str())
+ .fetch_all(&mut *transaction)
+ .await
+ .map_err(|_| AdminJournalOperationError::Storage)?;
+ if rows.len() > 1 {
+ return Err(AdminJournalOperationError::Binding);
+ }
+ rows.first().map(decode_operation).transpose()
+}
+
+fn decode_operation(
+ row: &sqlx::sqlite::SqliteRow,
+) -> Result<StoredOperation, AdminJournalOperationError> {
+ let route = row
+ .try_get::<Option<&str>, _>("route")
+ .map_err(|_| AdminJournalOperationError::Binding)?
+ .and_then(parse_route)
+ .ok_or(AdminJournalOperationError::Binding)?;
+ let request_sha256 = exact_digest(row, "request_sha256")?;
+ let state = row
+ .try_get::<Option<&str>, _>("state")
+ .map_err(|_| AdminJournalOperationError::Binding)?
+ .ok_or(AdminJournalOperationError::Binding)?;
+ let prepared_at = time(row, "prepared_at_unix_ms")?;
+ match state {
+ "prepared" => {
+ require_null(row, "response_model_type")?;
+ require_null(row, "response_sha256_type")?;
+ require_null(row, "completed_at_type")?;
+ require_null(row, "expires_at_type")?;
+ Ok(StoredOperation::Prepared {
+ route,
+ request_sha256,
+ prepared_at,
+ })
+ }
+ "completed" => {
+ require_type(row, "response_model_type", "blob")?;
+ require_type(row, "response_sha256_type", "blob")?;
+ require_type(row, "completed_at_type", "integer")?;
+ require_type(row, "expires_at_type", "integer")?;
+ let response = row
+ .try_get::<Option<Vec<u8>>, _>("response_model")
+ .map_err(|_| AdminJournalOperationError::Binding)?
+ .ok_or(AdminJournalOperationError::Binding)?
+ .into_boxed_slice();
+ Ok(StoredOperation::Completed {
+ route,
+ request_sha256,
+ response,
+ response_sha256: exact_digest(row, "response_sha256")?,
+ prepared_at,
+ completed_at: time(row, "completed_at_unix_ms")?,
+ expires_at: time(row, "expires_at_unix_ms")?,
+ })
+ }
+ _ => Err(AdminJournalOperationError::Binding),
+ }
+}
+
+fn request_digest(request: &RhiAdminRequestDocument) -> [u8; 32] {
+ let mut hasher = Sha256::new();
+ hasher.update(REQUEST_DIGEST_DOMAIN);
+ hash_field(&mut hasher, request.route().operation_id().as_bytes());
+ hasher.update([0]);
+ hash_field(&mut hasher, request.model_bytes());
+ hasher.finalize().into()
+}
+
+fn hash_field(hasher: &mut Sha256, bytes: &[u8]) {
+ hasher.update(
+ u64::try_from(bytes.len())
+ .expect("bounded field length")
+ .to_be_bytes(),
+ );
+ hasher.update(bytes);
+}
+
+fn sha256(bytes: &[u8]) -> [u8; 32] {
+ Sha256::digest(bytes).into()
+}
+
+fn parse_route(value: &str) -> Option<RhiAdminRoute> {
+ RhiAdminRoute::ALL
+ .into_iter()
+ .find(|route| route.is_mutation() && route.operation_id() == value)
+}
+
+fn exact_digest(
+ row: &sqlx::sqlite::SqliteRow,
+ column: &str,
+) -> Result<[u8; 32], AdminJournalOperationError> {
+ row.try_get::<Option<Vec<u8>>, _>(column)
+ .map_err(|_| AdminJournalOperationError::Binding)?
+ .ok_or(AdminJournalOperationError::Binding)?
+ .try_into()
+ .map_err(|_| AdminJournalOperationError::Binding)
+}
+
+fn time(
+ row: &sqlx::sqlite::SqliteRow,
+ column: &str,
+) -> Result<RhiAdminOperationTimeUnixMs, AdminJournalOperationError> {
+ let value = row
+ .try_get::<i64, _>(column)
+ .map_err(|_| AdminJournalOperationError::Binding)?;
+ RhiAdminOperationTimeUnixMs::new(
+ u64::try_from(value).map_err(|_| AdminJournalOperationError::Binding)?,
+ )
+ .map_err(|_| AdminJournalOperationError::Binding)
+}
+
+fn require_null(
+ row: &sqlx::sqlite::SqliteRow,
+ column: &str,
+) -> Result<(), AdminJournalOperationError> {
+ require_type(row, column, "null")
+}
+
+fn require_type(
+ row: &sqlx::sqlite::SqliteRow,
+ column: &str,
+ expected: &str,
+) -> Result<(), AdminJournalOperationError> {
+ (row.try_get::<&str, _>(column)
+ .map_err(|_| AdminJournalOperationError::Binding)?
+ == expected)
+ .then_some(())
+ .ok_or(AdminJournalOperationError::Binding)
+}
+
+fn require_one(rows: u64) -> Result<(), AdminJournalOperationError> {
+ (rows == 1)
+ .then_some(())
+ .ok_or(AdminJournalOperationError::Storage)
+}
+
+fn map_transaction_error(
+ error: ServiceSqliteTransactionError<AdminJournalOperationError>,
+) -> RhiAdminOperationError {
+ if error.kind() == ServiceSqliteTransactionErrorKind::CommitOutcomeUnknown {
+ return RhiAdminOperationError::new(RhiAdminOperationErrorKind::CommitOutcomeUnknown);
+ }
+ let kind = match error.operation_error() {
+ Some(AdminJournalOperationError::InvalidInput) => RhiAdminOperationErrorKind::InvalidInput,
+ Some(AdminJournalOperationError::Conflict) => RhiAdminOperationErrorKind::OperationConflict,
+ Some(AdminJournalOperationError::OutcomeUnknown) => {
+ RhiAdminOperationErrorKind::OperationOutcomeUnknown
+ }
+ Some(AdminJournalOperationError::ResourceExhausted) => {
+ RhiAdminOperationErrorKind::ResourceExhausted
+ }
+ Some(AdminJournalOperationError::Binding) => RhiAdminOperationErrorKind::Binding,
+ Some(AdminJournalOperationError::Storage) | None => RhiAdminOperationErrorKind::Transaction,
+ };
+ RhiAdminOperationError::new(kind)
+}
diff --git a/src/state_catalog.rs b/src/state_catalog.rs
@@ -12,7 +12,7 @@ use radroots_service_sqlite::{
pub const RHI_STATE_BASE_SCHEMA_VERSION: u32 = 1;
/// The newest governed RHI state schema understood by this binary.
-pub const RHI_STATE_SCHEMA_VERSION: u32 = 10;
+pub const RHI_STATE_SCHEMA_VERSION: u32 = 11;
/// The shared metadata and migration-ledger objects present at schema v1.
pub const RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT: u32 = 6;
@@ -44,10 +44,13 @@ pub const RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT: u32 = 69;
/// The shared objects plus durable exact-byte presence delivery state.
pub const RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT: u32 = 80;
+/// The complete v10 state plus the bounded durable admin-operation journal.
+pub const RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT: u32 = 82;
+
/// SHA-256 identity of the ordered migration catalog rooted at schema v1.
pub const RHI_MIGRATION_CATALOG_SHA256: [u8; 32] = [
- 0x25, 0xe5, 0xba, 0x77, 0x3e, 0xf3, 0xdb, 0x01, 0x33, 0xa8, 0x07, 0x7a, 0x08, 0x3e, 0x88, 0xb4,
- 0x0f, 0xc6, 0xda, 0xdf, 0x9f, 0xb6, 0xf0, 0xcf, 0xde, 0x0e, 0x4b, 0xa4, 0xd3, 0xe0, 0x81, 0xd9,
+ 0xe6, 0xcb, 0xac, 0xbd, 0x1e, 0xb6, 0x36, 0xc1, 0xa5, 0x60, 0xf8, 0x5e, 0xf8, 0xe5, 0x1e, 0x89,
+ 0xc9, 0xff, 0xe3, 0xb3, 0x42, 0xe6, 0x6b, 0xc5, 0xc0, 0xb4, 0x7a, 0xb3, 0x4e, 0x90, 0xc8, 0x18,
];
/// SHA-256 identity of the exact schema-v1 object snapshot.
@@ -164,10 +167,22 @@ pub const RHI_STATE_SCHEMA_VERSION_10_SHA256: [u8; 32] = [
0xcd, 0x50, 0x2a, 0xba, 0xa8, 0xa4, 0xca, 0x30, 0xa4, 0x82, 0x35, 0x35, 0xb8, 0xbd, 0x0e, 0x45,
];
+/// SHA-256 identity of the schema-v11 admin-operation-journal migration.
+pub const RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256: [u8; 32] = [
+ 0xe3, 0xfb, 0xde, 0x51, 0x1e, 0x84, 0x24, 0xc9, 0x70, 0x80, 0xbe, 0x2c, 0x09, 0xed, 0x81, 0x0a,
+ 0xe2, 0x84, 0x63, 0x1d, 0x75, 0xeb, 0x25, 0xc2, 0xe8, 0x8a, 0x60, 0x76, 0xb7, 0x00, 0xaa, 0xef,
+];
+
+/// SHA-256 identity of the exact schema-v11 object snapshot.
+pub const RHI_STATE_SCHEMA_VERSION_11_SHA256: [u8; 32] = [
+ 0xc2, 0x5e, 0xc6, 0x3b, 0x33, 0xb4, 0x11, 0x61, 0x80, 0x68, 0xee, 0x06, 0xa0, 0x4c, 0x99, 0xee,
+ 0x71, 0x66, 0xe0, 0x01, 0x4d, 0x97, 0x90, 0x39, 0xfa, 0xea, 0xea, 0x4d, 0xc1, 0xac, 0x7e, 0x62,
+];
+
/// SHA-256 identity of the schema catalog bound to the migration catalog.
pub const RHI_STATE_SCHEMA_CATALOG_SHA256: [u8; 32] = [
- 0x4f, 0x4d, 0x5f, 0x55, 0x46, 0xa7, 0xc9, 0x4e, 0xf3, 0xcd, 0xab, 0xe2, 0x3e, 0xad, 0xd0, 0xc9,
- 0x7a, 0x64, 0x98, 0x09, 0x84, 0xee, 0x38, 0xca, 0xcb, 0x82, 0x8f, 0x11, 0x25, 0x91, 0x34, 0x88,
+ 0xae, 0xc4, 0x82, 0x81, 0x8b, 0xd9, 0xa6, 0xf3, 0x3f, 0xd9, 0x2b, 0x55, 0xd1, 0x42, 0xc6, 0xb8,
+ 0x5a, 0xa6, 0xf0, 0x86, 0x85, 0x57, 0x01, 0xdf, 0xc4, 0xb7, 0x8f, 0x2a, 0x7e, 0xf5, 0xcf, 0x6d,
];
macro_rules! rhi_config_bindings_table_sql {
@@ -1991,6 +2006,72 @@ const PRESENCE_ATTEMPTS_NO_DELETE_SHA256: [u8; 32] = [
0xbd, 0xf7, 0x74, 0xbb, 0x8d, 0x84, 0xc9, 0xab, 0xd9, 0x76, 0xe2, 0xae, 0x9c, 0xd2, 0x4a, 0x71,
];
+macro_rules! rhi_admin_operations_table_sql {
+ () => {
+ r#"CREATE TABLE rhi_admin_operations (
+ operation_id TEXT NOT NULL PRIMARY KEY
+ CHECK (length(CAST(operation_id AS BLOB)) BETWEEN 1 AND 128)
+ CHECK (substr(operation_id, 1, 1) GLOB '[A-Za-z0-9]')
+ CHECK (operation_id NOT GLOB '*[^A-Za-z0-9._:-]*'),
+ route TEXT NOT NULL CHECK (length(CAST(route AS BLOB)) BETWEEN 1 AND 128),
+ request_sha256 BLOB NOT NULL CHECK (length(request_sha256) = 32),
+ state TEXT NOT NULL CHECK (state IN ('prepared', 'completed')),
+ response_model BLOB CHECK (response_model IS NULL OR
+ length(response_model) BETWEEN 1 AND 8192),
+ response_sha256 BLOB CHECK (response_sha256 IS NULL OR
+ length(response_sha256) = 32),
+ prepared_at_unix_ms INTEGER NOT NULL
+ CHECK (prepared_at_unix_ms BETWEEN 0 AND 9223372036854775807),
+ completed_at_unix_ms INTEGER
+ CHECK (completed_at_unix_ms IS NULL OR
+ completed_at_unix_ms BETWEEN prepared_at_unix_ms AND 9223372036854775807),
+ expires_at_unix_ms INTEGER
+ CHECK (expires_at_unix_ms IS NULL OR
+ expires_at_unix_ms BETWEEN completed_at_unix_ms AND 9223372036854775807),
+ CHECK ((state = 'prepared' AND response_model IS NULL
+ AND response_sha256 IS NULL AND completed_at_unix_ms IS NULL
+ AND expires_at_unix_ms IS NULL)
+ OR (state = 'completed' AND response_model IS NOT NULL
+ AND response_sha256 IS NOT NULL AND completed_at_unix_ms IS NOT NULL
+ AND expires_at_unix_ms IS NOT NULL))
+) STRICT"#
+ };
+}
+
+macro_rules! rhi_admin_operations_guard_update_sql {
+ () => {
+ r#"CREATE TRIGGER rhi_admin_operations_guard_update
+BEFORE UPDATE ON rhi_admin_operations
+WHEN OLD.state != 'prepared' OR NEW.state != 'completed'
+ OR NEW.operation_id != OLD.operation_id OR NEW.route != OLD.route
+ OR NEW.request_sha256 != OLD.request_sha256
+ OR NEW.prepared_at_unix_ms != OLD.prepared_at_unix_ms
+ OR NEW.response_model IS NULL OR NEW.response_sha256 IS NULL
+ OR NEW.completed_at_unix_ms IS NULL OR NEW.expires_at_unix_ms IS NULL
+BEGIN
+ SELECT RAISE(ABORT, 'admin operation transition is invalid');
+END"#
+ };
+}
+
+const CREATE_RHI_ADMIN_OPERATIONS_TABLE_SQL: &str = rhi_admin_operations_table_sql!();
+const CREATE_RHI_ADMIN_OPERATIONS_GUARD_UPDATE_SQL: &str = rhi_admin_operations_guard_update_sql!();
+const CREATE_RHI_ADMIN_OPERATIONS_MIGRATION_SQL: &str = concat!(
+ rhi_admin_operations_table_sql!(),
+ ";\n",
+ rhi_admin_operations_guard_update_sql!(),
+ ";",
+);
+
+const RHI_ADMIN_OPERATIONS_TABLE_SHA256: [u8; 32] = [
+ 0xf7, 0xa6, 0x22, 0x21, 0xc8, 0xec, 0x66, 0x2c, 0x17, 0x14, 0x43, 0x82, 0x2b, 0x11, 0xa9, 0x9b,
+ 0xc4, 0xde, 0x02, 0x13, 0xa1, 0x9e, 0x12, 0xaa, 0x70, 0x54, 0x7b, 0x7f, 0x9d, 0x50, 0x99, 0x21,
+];
+const RHI_ADMIN_OPERATIONS_GUARD_UPDATE_SHA256: [u8; 32] = [
+ 0xac, 0xe5, 0x7c, 0x97, 0xbc, 0xd5, 0xe9, 0xda, 0x0d, 0xfc, 0xe0, 0x23, 0x65, 0x6d, 0xae, 0xda,
+ 0x96, 0xe5, 0xbf, 0xb6, 0x89, 0x70, 0xa6, 0x06, 0x1b, 0x70, 0x7d, 0x21, 0xec, 0x1f, 0x6b, 0x39,
+];
+
/// Stable classes for invalid embedded RHI catalog definitions.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RhiStateCatalogErrorKind {
@@ -2124,6 +2205,13 @@ fn build_rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogErro
MigrationChecksum::from_bytes(RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256),
)
.map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?;
+ let admin_operations = MigrationDescriptor::sql(
+ 11,
+ "create_admin_operation_journal",
+ CREATE_RHI_ADMIN_OPERATIONS_MIGRATION_SQL,
+ MigrationChecksum::from_bytes(RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256),
+ )
+ .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?;
let catalog = MigrationCatalog::new([
configuration,
trade_evidence,
@@ -2134,6 +2222,7 @@ fn build_rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogErro
reconciliation_job_shape_guards,
presence_desired_state,
presence_publication,
+ admin_operations,
])
.map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::MigrationCatalog))?;
Ok(catalog)
@@ -2143,7 +2232,7 @@ fn build_rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogErro
pub fn rhi_migration_catalog() -> Result<MigrationCatalog, RhiStateCatalogError> {
let catalog = build_rhi_migration_catalog()?;
if catalog.current_version() != RHI_STATE_SCHEMA_VERSION
- || catalog.descriptors().len() != 9
+ || catalog.descriptors().len() != 10
|| catalog.digest().as_bytes() != &RHI_MIGRATION_CATALOG_SHA256
{
return Err(RhiStateCatalogError::new(
@@ -2224,6 +2313,12 @@ fn build_rhi_schema_catalog(
SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_10_SHA256),
)
.map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?;
+ let version_eleven = SchemaVersionCatalog::new(
+ 11,
+ rhi_schema_version_eleven_objects()?,
+ SchemaDigest::from_bytes(RHI_STATE_SCHEMA_VERSION_11_SHA256),
+ )
+ .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?;
let catalog = SchemaCatalog::new(
migrations,
[
@@ -2237,6 +2332,7 @@ fn build_rhi_schema_catalog(
version_eight,
version_nine,
version_ten,
+ version_eleven,
],
)
.map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))?;
@@ -2250,10 +2346,10 @@ pub fn validate_rhi_state_catalogs(
) -> Result<(), RhiStateCatalogError> {
let versions = schema.versions();
let valid = migrations.current_version() == RHI_STATE_SCHEMA_VERSION
- && migrations.descriptors().len() == 9
+ && migrations.descriptors().len() == 10
&& migrations.digest().as_bytes() == &RHI_MIGRATION_CATALOG_SHA256
&& schema.migration_catalog_digest() == migrations.digest()
- && versions.len() == 10
+ && versions.len() == 11
&& versions[0].version() == RHI_STATE_BASE_SCHEMA_VERSION
&& versions[0].object_count() == RHI_STATE_SCHEMA_VERSION_1_OBJECT_COUNT
&& versions[0].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_1_SHA256
@@ -2281,9 +2377,12 @@ pub fn validate_rhi_state_catalogs(
&& versions[8].version() == 9
&& versions[8].object_count() == RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT
&& versions[8].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_9_SHA256
- && versions[9].version() == RHI_STATE_SCHEMA_VERSION
+ && versions[9].version() == 10
&& versions[9].object_count() == RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT
&& versions[9].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_10_SHA256
+ && versions[10].version() == RHI_STATE_SCHEMA_VERSION
+ && versions[10].object_count() == RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT
+ && versions[10].digest().as_bytes() == &RHI_STATE_SCHEMA_VERSION_11_SHA256
&& schema.digest().as_bytes() == &RHI_STATE_SCHEMA_CATALOG_SHA256;
if valid {
Ok(())
@@ -2379,6 +2478,39 @@ fn rhi_schema_version_ten_objects() -> Result<Vec<SchemaObject>, RhiStateCatalog
Ok(objects)
}
+fn rhi_schema_version_eleven_objects() -> Result<Vec<SchemaObject>, RhiStateCatalogError> {
+ let mut objects = rhi_schema_version_ten_objects()?;
+ objects.extend(rhi_admin_operation_objects()?);
+ Ok(objects)
+}
+
+fn rhi_admin_operation_objects() -> Result<[SchemaObject; 2], RhiStateCatalogError> {
+ let object = |kind, name, sql, digest| {
+ SchemaObject::new(
+ kind,
+ name,
+ "rhi_admin_operations",
+ sql,
+ SchemaDigest::from_bytes(digest),
+ )
+ .map_err(|_| RhiStateCatalogError::new(RhiStateCatalogErrorKind::SchemaCatalog))
+ };
+ Ok([
+ object(
+ SchemaObjectKind::Table,
+ "rhi_admin_operations",
+ CREATE_RHI_ADMIN_OPERATIONS_TABLE_SQL,
+ RHI_ADMIN_OPERATIONS_TABLE_SHA256,
+ )?,
+ object(
+ SchemaObjectKind::Trigger,
+ "rhi_admin_operations_guard_update",
+ CREATE_RHI_ADMIN_OPERATIONS_GUARD_UPDATE_SQL,
+ RHI_ADMIN_OPERATIONS_GUARD_UPDATE_SHA256,
+ )?,
+ ])
+}
+
fn rhi_presence_publication_objects() -> Result<[SchemaObject; 11], RhiStateCatalogError> {
let object = |kind, name, table_name, sql, digest| {
SchemaObject::new(
diff --git a/src/state_config.rs b/src/state_config.rs
@@ -300,6 +300,23 @@ pub(crate) async fn verify_binding(
.map_err(map_transaction_error)
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+pub(crate) async fn current_generation(host: &RhiStateHost) -> Result<u16, RhiConfigApplyError> {
+ host.sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let history = read_history(transaction).await?;
+ validate_history(&history)?;
+ history
+ .last()
+ .map(|entry| entry.generation)
+ .ok_or(ConfigOperationError::Binding)
+ })
+ })
+ .await
+ .map_err(map_transaction_error)
+}
+
pub(crate) async fn append_configuration(
host: &RhiStateHost,
current: &RhiStateMetadata,
diff --git a/src/state_host.rs b/src/state_host.rs
@@ -449,6 +449,44 @@ pub async fn open_rhi_state_inspection(
Ok(state)
}
+/// Opens existing inspection state from a sealed intent and actual metadata.
+pub async fn open_rhi_state_inspection_from_config(
+ runtime: &RhiRuntimeContext,
+ configuration: &RhiConfigDocumentV1,
+) -> Result<RhiStateHost, RhiStateHostError> {
+ let paths = state_paths(runtime)?;
+ let (migrations, schema) = catalogs()?;
+ let intent = existing_intent(&paths)?;
+ let opened = ServiceSqliteHost::open_read_only_inspection_with_intent(
+ &paths,
+ &intent,
+ &migrations,
+ &schema,
+ ServiceSqliteConnectionOptions::reviewed(),
+ )
+ .await
+ .map_err(|_| RhiStateHostError::new(RhiStateHostErrorKind::InspectionOpen))?;
+ let (host, actual) = opened.into_parts();
+ let metadata = match RhiStateMetadata::from_existing_database(runtime, configuration, &actual) {
+ Ok(metadata) => metadata,
+ Err(_) => {
+ return Err(close_error(&host, RhiStateHostErrorKind::InvalidEvidence).await);
+ }
+ };
+ let state = RhiStateHost {
+ host,
+ mode: RhiStateHostMode::ReadOnlyInspection,
+ metadata,
+ };
+ if state_config::verify_binding(&state, state.metadata())
+ .await
+ .is_err()
+ {
+ return Err(close_error(&state.host, RhiStateHostErrorKind::InvalidEvidence).await);
+ }
+ Ok(state)
+}
+
pub(crate) fn state_paths(
runtime: &RhiRuntimeContext,
) -> Result<ServiceSqlitePaths, RhiStateHostError> {
diff --git a/src/status_v1.rs b/src/status_v1.rs
@@ -189,8 +189,14 @@ impl RhiStatusBuildInfoV1 {
target: Option<&str>,
feature_profile: Option<&str>,
) -> Result<Self, RhiStatusError> {
- let contract_versions = HostContractVersions::new(1, 10, 1, 1, 1)
- .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidBuildInfo))?;
+ let contract_versions = HostContractVersions::new(
+ crate::RHI_CONFIG_SCHEMA_VERSION,
+ crate::RHI_STATE_SCHEMA_VERSION,
+ crate::RHI_ADMIN_CONTRACT_VERSION,
+ crate::RHI_STATUS_CONTRACT_VERSION,
+ crate::RHI_PROVIDER_CONTRACT_VERSION,
+ )
+ .map_err(|_| RhiStatusError::new(RhiStatusErrorKind::InvalidBuildInfo))?;
HostBuildInfo::from_compile_time(
match mode {
RhiStatusBuildMode::Development => HostBuildMode::Development,
diff --git a/src/system_doctor.rs b/src/system_doctor.rs
@@ -0,0 +1,341 @@
+//! Production active-doctor probes composed from existing sealed authorities.
+
+use radroots_service_host::{SystemWallClock, WallClock};
+use radroots_service_sqlite::{
+ IntegrityCheckOutcome, IntegrityCheckedAtUnixMs, MinimumFreeBytes,
+ PlatformStateFilesystemCapacitySource, inspect_state_filesystem_capacity,
+};
+
+use crate::admin_v1::admin_transport_limits;
+use crate::transport_nostr_adapter::{build_rhi_nostr_adapters, probe_required_sources};
+use crate::{
+ RhiConfigDocumentV1, RhiDoctorCheckDefinition, RhiDoctorCheckId, RhiDoctorFuture,
+ RhiDoctorObservation, RhiDoctorProbe, RhiIdentityEnvelopeBinding, RhiRuntimeContext,
+ RhiStateHost, open_rhi_encrypted_identity, open_rhi_state_inspection_from_config,
+ resolve_rhi_wrapping_credential,
+};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+enum StateProbeError {
+ Query,
+}
+
+pub(crate) struct RhiSystemDoctorProbe<'a> {
+ runtime: &'a RhiRuntimeContext,
+ configuration: &'a RhiConfigDocumentV1,
+}
+
+impl<'a> RhiSystemDoctorProbe<'a> {
+ pub(crate) const fn new(
+ runtime: &'a RhiRuntimeContext,
+ configuration: &'a RhiConfigDocumentV1,
+ ) -> Self {
+ Self {
+ runtime,
+ configuration,
+ }
+ }
+
+ async fn run(&self, definition: RhiDoctorCheckDefinition) -> bool {
+ match definition.id() {
+ RhiDoctorCheckId::PathsPermissions => self.probe_paths(),
+ RhiDoctorCheckId::WriterLock
+ | RhiDoctorCheckId::SqliteSchema
+ | RhiDoctorCheckId::SqliteIntegrity
+ | RhiDoctorCheckId::CursorCheckpoint
+ | RhiDoctorCheckId::ReconciliationLeases
+ | RhiDoctorCheckId::ReconciliationBacklog
+ | RhiDoctorCheckId::PublicationInvariants => self.probe_state(definition.id()).await,
+ RhiDoctorCheckId::SqliteFreeSpace => self.probe_free_space(),
+ RhiDoctorCheckId::IdentityBinding => self.probe_identity_binding().await,
+ RhiDoctorCheckId::AdminBindPolicy => self.probe_admin_policy(),
+ RhiDoctorCheckId::OperationsBindPolicy => self.probe_operations_policy(),
+ RhiDoctorCheckId::NetworkPolicy => build_rhi_nostr_adapters(self.configuration).is_ok(),
+ RhiDoctorCheckId::RequiredSources => {
+ let Some(deadline) = absolute_deadline(definition.deadline_ms()) else {
+ return false;
+ };
+ probe_required_sources(self.configuration, deadline)
+ .await
+ .is_ok()
+ }
+ RhiDoctorCheckId::ClockSkew => false,
+ }
+ }
+
+ fn probe_paths(&self) -> bool {
+ let Ok(paths) = crate::state_host::state_paths(self.runtime) else {
+ return false;
+ };
+ let Ok(minimum) = MinimumFreeBytes::new(1) else {
+ return false;
+ };
+ inspect_state_filesystem_capacity(&paths, minimum, &PlatformStateFilesystemCapacitySource)
+ .is_ok()
+ }
+
+ async fn probe_state(&self, check: RhiDoctorCheckId) -> bool {
+ let Ok(state) =
+ open_rhi_state_inspection_from_config(self.runtime, self.configuration).await
+ else {
+ return false;
+ };
+ let outcome = match check {
+ RhiDoctorCheckId::WriterLock | RhiDoctorCheckId::SqliteSchema => true,
+ RhiDoctorCheckId::SqliteIntegrity => match integrity_time() {
+ Some(checked_at) => state
+ .inspect_integrity(checked_at)
+ .await
+ .is_ok_and(|report| {
+ report.sqlite() == IntegrityCheckOutcome::Verified
+ && report.foreign_keys() == IntegrityCheckOutcome::Verified
+ }),
+ None => false,
+ },
+ RhiDoctorCheckId::CursorCheckpoint => {
+ run_scalar_probe(&state, CURSOR_CHECKPOINT_INVARIANTS_SQL, None).await
+ }
+ RhiDoctorCheckId::ReconciliationLeases => {
+ run_scalar_probe(&state, RECONCILIATION_LEASE_INVARIANTS_SQL, None).await
+ }
+ RhiDoctorCheckId::ReconciliationBacklog => {
+ let capacity =
+ configuration_u64(self.configuration, "/reconciliation/queue_capacity")
+ .and_then(|value| i64::try_from(value).ok());
+ match capacity {
+ Some(capacity) => {
+ run_scalar_probe(
+ &state,
+ RECONCILIATION_BACKLOG_INVARIANTS_SQL,
+ Some(capacity),
+ )
+ .await
+ }
+ None => false,
+ }
+ }
+ RhiDoctorCheckId::PublicationInvariants => {
+ run_scalar_probe(&state, PUBLICATION_INVARIANTS_SQL, None).await
+ }
+ _ => false,
+ };
+ let closed = state.close().await.is_ok();
+ outcome && closed
+ }
+
+ fn probe_free_space(&self) -> bool {
+ let Some(minimum) = configuration_u64(self.configuration, "/database/minimum_free_bytes")
+ .and_then(|value| MinimumFreeBytes::new(value).ok())
+ else {
+ return false;
+ };
+ let Ok(paths) = crate::state_host::state_paths(self.runtime) else {
+ return false;
+ };
+ inspect_state_filesystem_capacity(&paths, minimum, &PlatformStateFilesystemCapacitySource)
+ .is_ok_and(|capacity| capacity.allows_authoritative_admission())
+ }
+
+ async fn probe_identity_binding(&self) -> bool {
+ let Ok(state) =
+ open_rhi_state_inspection_from_config(self.runtime, self.configuration).await
+ else {
+ return false;
+ };
+ let result =
+ RhiIdentityEnvelopeBinding::from_configuration(self.configuration, state.metadata())
+ .ok()
+ .and_then(|binding| {
+ let credential =
+ resolve_rhi_wrapping_credential(self.runtime, &binding).ok()?;
+ open_rhi_encrypted_identity(&binding, &credential).ok()
+ });
+ let closed = state.close().await.is_ok();
+ result.is_some() && closed
+ }
+
+ fn probe_admin_policy(&self) -> bool {
+ let path = self.runtime.artifacts().admin_socket();
+ path.is_absolute()
+ && path.to_str().is_some_and(|value| value.len() <= 4_096)
+ && admin_transport_limits(self.configuration).is_ok()
+ }
+
+ fn probe_operations_policy(&self) -> bool {
+ let Some(enabled) = self
+ .configuration
+ .normalized()
+ .pointer("/operations/enabled")
+ .and_then(serde_json::Value::as_bool)
+ else {
+ return false;
+ };
+ !enabled
+ || (self
+ .configuration
+ .normalized()
+ .pointer("/operations/listen")
+ .and_then(serde_json::Value::as_str)
+ .is_some()
+ && self
+ .configuration
+ .normalized()
+ .pointer("/operations/bind_policy")
+ .and_then(serde_json::Value::as_str)
+ .is_some())
+ }
+}
+
+impl RhiDoctorProbe for RhiSystemDoctorProbe<'_> {
+ fn probe(&self, definition: RhiDoctorCheckDefinition) -> RhiDoctorFuture<'_> {
+ Box::pin(async move {
+ if definition.id() == RhiDoctorCheckId::ClockSkew {
+ RhiDoctorObservation::Skipped
+ } else if self.run(definition).await {
+ RhiDoctorObservation::Pass
+ } else {
+ RhiDoctorObservation::Fail
+ }
+ })
+ }
+}
+
+async fn run_scalar_probe(state: &RhiStateHost, sql: &'static str, bound: Option<i64>) -> bool {
+ state
+ .sqlite_host()
+ .transaction(move |transaction| {
+ Box::pin(async move {
+ let mut query = sqlx::query_scalar::<_, i64>(sql);
+ if let Some(bound) = bound {
+ query = query.bind(bound);
+ }
+ query
+ .fetch_one(&mut *transaction)
+ .await
+ .map(|invalid| invalid == 0)
+ .map_err(|_| StateProbeError::Query)
+ })
+ })
+ .await
+ .is_ok_and(|valid| valid)
+}
+
+fn configuration_u64(configuration: &RhiConfigDocumentV1, pointer: &str) -> Option<u64> {
+ configuration
+ .normalized()
+ .pointer(pointer)
+ .and_then(serde_json::Value::as_u64)
+}
+
+fn wall_time_millis() -> Option<u64> {
+ SystemWallClock
+ .now_utc()
+ .ok()
+ .and_then(|time| time.get().checked_mul(1_000))
+ .filter(|value| i64::try_from(*value).is_ok())
+}
+
+fn absolute_deadline(duration_ms: u64) -> Option<u64> {
+ wall_time_millis()?.checked_add(duration_ms)
+}
+
+fn integrity_time() -> Option<IntegrityCheckedAtUnixMs> {
+ IntegrityCheckedAtUnixMs::new(wall_time_millis()?)
+}
+
+const CURSOR_CHECKPOINT_INVARIANTS_SQL: &str = r#"SELECT COUNT(*)
+FROM relay_checkpoints
+WHERE typeof(source_id) != 'text'
+ OR length(CAST(source_id AS BLOB)) NOT BETWEEN 1 AND 64
+ OR source_id GLOB '*[^a-z0-9_-]*'
+ OR substr(source_id, 1, 1) NOT GLOB '[a-z]'
+ OR selector_id != 'trade_mutation_lineage_v1'
+ OR typeof(evidence_policy_sha256) != 'blob'
+ OR length(evidence_policy_sha256) != 32
+ OR typeof(trade_id) != 'blob' OR length(trade_id) != 16
+ OR typeof(cursor_event_id) != 'blob' OR length(cursor_event_id) != 32
+ OR cursor_created_at_unix_s NOT BETWEEN 0 AND 9223372036854775807
+ OR revision NOT BETWEEN 1 AND 9223372036854775807
+ OR completed_at_unix_s NOT BETWEEN 1 AND 9223372036854775807"#;
+
+const RECONCILIATION_LEASE_INVARIANTS_SQL: &str = r#"SELECT COUNT(*)
+FROM reconciliation_jobs
+WHERE (state = 'leased' AND (
+ typeof(lease_owner) != 'blob' OR length(lease_owner) != 16
+ OR lease_expires_unix_ms NOT BETWEEN 1 AND 9223372036854775807
+ OR next_attempt_unix_ms IS NOT NULL))
+ OR (state != 'leased' AND (lease_owner IS NOT NULL OR lease_expires_unix_ms IS NOT NULL))
+ OR attempt_count NOT BETWEEN 0 AND max_attempts
+ OR failure_count NOT BETWEEN 0 AND attempt_count
+ OR revision NOT BETWEEN 1 AND 9223372036854775807"#;
+
+const RECONCILIATION_BACKLOG_INVARIANTS_SQL: &str = r#"SELECT CASE
+ WHEN (SELECT COUNT(*) FROM reconciliation_jobs WHERE state IN ('ready', 'leased')) > ?
+ THEN 1
+ WHEN EXISTS (
+ SELECT 1 FROM reconciliation_jobs
+ WHERE state = 'ready' AND (
+ next_attempt_unix_ms IS NULL OR attempt_count >= max_attempts))
+ THEN 1
+ WHEN EXISTS (
+ SELECT 1 FROM reconciliation_jobs
+ WHERE state IN ('exhausted', 'superseded', 'completed')
+ AND next_attempt_unix_ms IS NOT NULL)
+ THEN 1
+ ELSE 0 END"#;
+
+const PUBLICATION_INVARIANTS_SQL: &str = r#"SELECT COUNT(*)
+FROM publication_outbox AS outbox
+LEFT JOIN signed_attestation_events AS event ON event.event_id = outbox.event_id
+WHERE event.event_id IS NULL
+ OR outbox.event_sha256 != event.event_sha256
+ OR outbox.target_count != (
+ SELECT COUNT(*) FROM publication_targets AS target
+ WHERE target.outbox_id = outbox.outbox_id)
+ OR outbox.required_target_count != (
+ SELECT COUNT(*) FROM publication_targets AS target
+ WHERE target.outbox_id = outbox.outbox_id AND target.required = 1)
+ OR EXISTS (
+ SELECT 1 FROM publication_targets AS target
+ WHERE target.outbox_id = outbox.outbox_id
+ AND (target.target_ordinal < 0 OR target.target_ordinal >= outbox.target_count))
+ OR (outbox.state = 'complete' AND EXISTS (
+ SELECT 1 FROM publication_targets AS target
+ WHERE target.outbox_id = outbox.outbox_id
+ AND target.required = 1 AND target.state != 'accepted'))"#;
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn deadline_math_is_checked_and_clock_skew_remains_unclaimed() {
+ assert!(absolute_deadline(15_000).is_some());
+ assert!(integrity_time().is_some());
+ }
+
+ #[test]
+ fn state_queries_are_bounded_scalar_projections() {
+ for query in [
+ CURSOR_CHECKPOINT_INVARIANTS_SQL,
+ RECONCILIATION_LEASE_INVARIANTS_SQL,
+ RECONCILIATION_BACKLOG_INVARIANTS_SQL,
+ PUBLICATION_INVARIANTS_SQL,
+ ] {
+ assert!(query.starts_with("SELECT"));
+ assert!(!query.contains("SELECT *"));
+ assert!(!query.contains("ORDER BY"));
+ }
+ }
+
+ #[test]
+ fn production_probe_source_retains_no_raw_error_projection() {
+ let source = include_str!("system_doctor.rs")
+ .split("#[cfg(test)]")
+ .next()
+ .expect("production source");
+ for forbidden in ["format!(\"{error", "to_string()", "source()"] {
+ assert!(!source.contains(forbidden), "found `{forbidden}`");
+ }
+ }
+}
diff --git a/src/transport_nostr_adapter.rs b/src/transport_nostr_adapter.rs
@@ -0,0 +1,635 @@
+//! Private source-locked Nostr transport composition for the RHI runtime.
+
+use core::fmt;
+use std::{collections::BTreeMap, error::Error, sync::Arc};
+
+use radroots_event_codec::Codec;
+use radroots_transport::{
+ BoxFuture, DeliveryReceipt, DeliveryRequest, EventSink, EventSource, EventSubscriber,
+ FetchPage, FetchRequest, SinkFailure, SinkStatus, SourceStatus, Target, TargetSet,
+ outcome::{DeliveryOutcomeKind, FetchTargetState},
+ policy::{SatisfactionClass, SatisfactionPolicy, TargetPolicy},
+ sink::{DeliveryPayload, DeliveryTargetReceipt},
+ source::{BoxSubscription, FetchBounds, FetchSelector, SubscriptionRequest},
+ target::TargetFingerprint,
+};
+use radroots_transport_nostr::{
+ Config, NostrTransport, PreparedDelivery, RelayAccess, RelayEndpoint, RelayProfile,
+ RelayProfileKind, RelayUrlPolicy,
+};
+
+use crate::{
+ RhiConfigDocumentV1, RhiConfigProfile, RhiExactPresenceSink, RhiExactPublicationSink,
+ RhiPreparedPresenceAttempt, RhiPreparedPublicationAttempt, RhiPresenceAttemptOutcome,
+ RhiPublicationAttemptOutcome, RhiTransportAdapters,
+};
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) enum RhiNostrAdapterErrorKind {
+ Configuration,
+ Target,
+ Payload,
+ Preparation,
+}
+
+pub(crate) struct RhiNostrAdapterError {
+ kind: RhiNostrAdapterErrorKind,
+}
+
+impl RhiNostrAdapterError {
+ #[cfg(test)]
+ pub(crate) const fn kind(&self) -> RhiNostrAdapterErrorKind {
+ self.kind
+ }
+}
+
+impl fmt::Debug for RhiNostrAdapterError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter
+ .debug_struct("RhiNostrAdapterError")
+ .field("kind", &self.kind)
+ .finish()
+ }
+}
+
+impl fmt::Display for RhiNostrAdapterError {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("RHI Nostr adapter failed")
+ }
+}
+
+impl Error for RhiNostrAdapterError {}
+
+const fn adapter_error(kind: RhiNostrAdapterErrorKind) -> RhiNostrAdapterError {
+ RhiNostrAdapterError { kind }
+}
+
+struct RelayDefinition {
+ id: Box<str>,
+ target: Target,
+ kind: RelayProfileKind,
+}
+
+struct RelayBinding {
+ transport: NostrTransport,
+ target: Target,
+}
+
+/// One private adapter that routes a bounded request to exactly one configured
+/// public or simulator transport group.
+struct RhiNostrTransport {
+ by_target: BTreeMap<TargetFingerprint, NostrTransport>,
+}
+
+impl RhiNostrTransport {
+ fn for_targets(
+ &self,
+ targets: &TargetSet,
+ ) -> Result<NostrTransport, radroots_transport::Error> {
+ let mut selected: Option<NostrTransport> = None;
+ for target in targets.targets() {
+ let transport = self
+ .by_target
+ .get(target.fingerprint())
+ .ok_or(radroots_transport::Error::UnsupportedOperation)?;
+ if let Some(existing) = &selected {
+ if existing.config() != transport.config() {
+ return Err(radroots_transport::Error::UnsupportedOperation);
+ }
+ } else {
+ selected = Some(transport.clone());
+ }
+ }
+ selected.ok_or(radroots_transport::Error::UnsupportedOperation)
+ }
+}
+
+impl EventSource for RhiNostrTransport {
+ fn status(&self) -> BoxFuture<'_, Result<SourceStatus, radroots_transport::Error>> {
+ Box::pin(async { Err(radroots_transport::Error::UnsupportedOperation) })
+ }
+
+ fn fetch(
+ &self,
+ request: FetchRequest,
+ ) -> BoxFuture<'_, Result<FetchPage, radroots_transport::Error>> {
+ Box::pin(async move {
+ let transport = self.for_targets(request.target_set())?;
+ transport.fetch(request).await
+ })
+ }
+}
+
+impl EventSubscriber for RhiNostrTransport {
+ fn subscribe(
+ &self,
+ request: SubscriptionRequest,
+ ) -> BoxFuture<'_, Result<BoxSubscription, radroots_transport::Error>> {
+ Box::pin(async move {
+ let transport = self.for_targets(request.target_set())?;
+ transport.subscribe(request).await
+ })
+ }
+}
+
+impl EventSink for RhiNostrTransport {
+ fn status(&self) -> BoxFuture<'_, Result<SinkStatus, radroots_transport::Error>> {
+ Box::pin(async { Err(radroots_transport::Error::UnsupportedOperation) })
+ }
+
+ fn deliver(
+ &self,
+ request: DeliveryRequest,
+ ) -> BoxFuture<'_, Result<DeliveryReceipt, SinkFailure>> {
+ Box::pin(async move {
+ let transport = self
+ .for_targets(request.target_set())
+ .map_err(|_| SinkFailure::invalid_contract(&request))?;
+ transport.deliver(request).await
+ })
+ }
+}
+
+pub(crate) struct RhiNostrExactSink {
+ relays: BTreeMap<Box<str>, RelayBinding>,
+}
+
+struct RhiPreparedNostrDelivery {
+ transport: NostrTransport,
+ prepared: PreparedDelivery,
+}
+
+impl fmt::Debug for RhiPreparedNostrDelivery {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ formatter.write_str("RhiPreparedNostrDelivery([redacted])")
+ }
+}
+
+impl RhiNostrExactSink {
+ fn prepare(
+ &self,
+ relay_id: &str,
+ request_id: String,
+ exact_event_bytes: &[u8],
+ deadline_unix_ms: u64,
+ ) -> Result<RhiPreparedNostrDelivery, RhiNostrAdapterError> {
+ let binding = self
+ .relays
+ .get(relay_id)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Target))?;
+ let raw = core::str::from_utf8(exact_event_bytes)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Payload))?;
+ let signed = Codec::decode_signed_event(raw)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Payload))?;
+ if signed.raw_json().as_bytes() != exact_event_bytes {
+ return Err(adapter_error(RhiNostrAdapterErrorKind::Payload));
+ }
+ let targets = TargetSet::new(vec![binding.target.clone()])
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?;
+ let request = DeliveryRequest::new(
+ request_id,
+ DeliveryPayload::new(signed),
+ targets,
+ SatisfactionPolicy::new(SatisfactionClass::Accepted, TargetPolicy::all()),
+ deadline_unix_ms,
+ )
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?;
+ let prepared = binding
+ .transport
+ .prepare_delivery(request)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?;
+ Ok(RhiPreparedNostrDelivery {
+ transport: binding.transport.clone(),
+ prepared,
+ })
+ }
+
+ async fn execute(
+ &self,
+ prepared: RhiPreparedNostrDelivery,
+ ) -> Result<DeliveryOutcomeKind, RhiNostrAdapterError> {
+ let RhiPreparedNostrDelivery {
+ transport,
+ prepared,
+ } = prepared;
+ let receipt = transport
+ .execute_prepared_delivery(prepared)
+ .await
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?;
+ classify_receipt(receipt.target_receipts())
+ }
+}
+
+impl RhiExactPublicationSink for RhiNostrExactSink {
+ fn submit_exact<'a>(
+ &'a self,
+ attempt: &'a RhiPreparedPublicationAttempt,
+ ) -> BoxFuture<'a, RhiPublicationAttemptOutcome> {
+ Box::pin(async move {
+ let request_id = request_id("publication", attempt.attempt_id().as_bytes());
+ let prepared = match self.prepare(
+ attempt.relay_id(),
+ request_id,
+ attempt.exact_signed_event_bytes(),
+ attempt.deadline_at().get(),
+ ) {
+ Ok(prepared) => prepared,
+ Err(_) => return RhiPublicationAttemptOutcome::Failed,
+ };
+ match self.execute(prepared).await {
+ Ok(DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered) => {
+ RhiPublicationAttemptOutcome::Accepted
+ }
+ Ok(DeliveryOutcomeKind::Rejected) => RhiPublicationAttemptOutcome::Rejected,
+ Ok(DeliveryOutcomeKind::Unavailable | DeliveryOutcomeKind::Failed) => {
+ RhiPublicationAttemptOutcome::Failed
+ }
+ Err(_) => RhiPublicationAttemptOutcome::Unknown,
+ }
+ })
+ }
+}
+
+impl RhiExactPresenceSink for RhiNostrExactSink {
+ fn submit_exact<'a>(
+ &'a self,
+ attempt: &'a RhiPreparedPresenceAttempt,
+ ) -> BoxFuture<'a, RhiPresenceAttemptOutcome> {
+ Box::pin(async move {
+ let request_id = request_id("presence", attempt.attempt_id().as_bytes());
+ let prepared = match self.prepare(
+ attempt.relay_id(),
+ request_id,
+ attempt.exact_signed_event_bytes(),
+ attempt.deadline_at().get(),
+ ) {
+ Ok(prepared) => prepared,
+ Err(_) => return RhiPresenceAttemptOutcome::Failed,
+ };
+ match self.execute(prepared).await {
+ Ok(DeliveryOutcomeKind::Accepted | DeliveryOutcomeKind::Delivered) => {
+ RhiPresenceAttemptOutcome::Accepted
+ }
+ Ok(DeliveryOutcomeKind::Rejected) => RhiPresenceAttemptOutcome::Rejected,
+ Ok(DeliveryOutcomeKind::Unavailable | DeliveryOutcomeKind::Failed) => {
+ RhiPresenceAttemptOutcome::Failed
+ }
+ Err(_) => RhiPresenceAttemptOutcome::Unknown,
+ }
+ })
+ }
+}
+
+fn classify_receipt(
+ receipts: &[DeliveryTargetReceipt],
+) -> Result<DeliveryOutcomeKind, RhiNostrAdapterError> {
+ let [receipt] = receipts else {
+ return Err(adapter_error(RhiNostrAdapterErrorKind::Preparation));
+ };
+ Ok(receipt.outcome().kind())
+}
+
+fn request_id(prefix: &str, bytes: &[u8; 32]) -> String {
+ const HEX: &[u8; 16] = b"0123456789abcdef";
+ let mut result = String::with_capacity(prefix.len() + 1 + bytes.len() * 2);
+ result.push_str(prefix);
+ result.push('-');
+ for byte in bytes {
+ result.push(char::from(HEX[usize::from(byte >> 4)]));
+ result.push(char::from(HEX[usize::from(byte & 0x0f)]));
+ }
+ result
+}
+
+pub(crate) fn build_rhi_nostr_adapters(
+ configuration: &RhiConfigDocumentV1,
+) -> Result<(RhiTransportAdapters, Arc<RhiNostrExactSink>), RhiNostrAdapterError> {
+ let relays = configuration
+ .normalized()
+ .pointer("/relays")
+ .and_then(serde_json::Value::as_array)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let connect_timeout = configuration_integer(configuration, "/network/connect_deadline_ms")?;
+ let source_timeout = configuration
+ .normalized()
+ .pointer("/evidence/sources")
+ .and_then(serde_json::Value::as_array)
+ .and_then(|sources| {
+ sources
+ .iter()
+ .filter_map(|source| {
+ source
+ .pointer("/deadline_ms")
+ .and_then(serde_json::Value::as_u64)
+ })
+ .max()
+ })
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let publication_timeout = configuration
+ .normalized()
+ .pointer("/publication/retry/attempt_deadline_ms")
+ .and_then(serde_json::Value::as_u64)
+ .unwrap_or(source_timeout);
+ let request_timeout = source_timeout.max(publication_timeout);
+
+ let mut public = Vec::new();
+ let mut simulator = Vec::new();
+ let mut definitions = Vec::with_capacity(relays.len());
+ for relay in relays {
+ let id = relay
+ .pointer("/id")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let url = relay
+ .pointer("/url")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let read = relay
+ .pointer("/read")
+ .and_then(serde_json::Value::as_bool)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let write = relay
+ .pointer("/write")
+ .and_then(serde_json::Value::as_bool)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let access = if write {
+ RelayAccess::ReadWrite
+ } else if read {
+ RelayAccess::ReadOnly
+ } else {
+ return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration));
+ };
+ let (kind, policy) = if url.starts_with("wss://") {
+ (RelayProfileKind::Public, RelayUrlPolicy::Public)
+ } else if configuration.profile() == RhiConfigProfile::RepoLocal && url.starts_with("ws://")
+ {
+ (RelayProfileKind::Simulator, RelayUrlPolicy::Local)
+ } else {
+ return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration));
+ };
+ let endpoint = RelayEndpoint::new(url, policy, access)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ match kind {
+ RelayProfileKind::Public => public.push(endpoint),
+ RelayProfileKind::Simulator => simulator.push(endpoint),
+ _ => return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)),
+ }
+ definitions.push(RelayDefinition {
+ id: Box::from(id),
+ target: Target::nostr_relay(url)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?,
+ kind,
+ });
+ }
+
+ let public_transport = build_transport(
+ RelayProfileKind::Public,
+ public,
+ connect_timeout,
+ request_timeout,
+ )?;
+ let simulator_transport = build_transport(
+ RelayProfileKind::Simulator,
+ simulator,
+ connect_timeout,
+ request_timeout,
+ )?;
+ let mut by_target = BTreeMap::new();
+ let mut bindings = BTreeMap::new();
+ for definition in definitions {
+ let transport = match definition.kind {
+ RelayProfileKind::Public => public_transport.clone(),
+ RelayProfileKind::Simulator => simulator_transport.clone(),
+ _ => None,
+ }
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ if by_target
+ .insert(definition.target.fingerprint().clone(), transport.clone())
+ .is_some()
+ || bindings
+ .insert(
+ definition.id,
+ RelayBinding {
+ transport,
+ target: definition.target,
+ },
+ )
+ .is_some()
+ {
+ return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration));
+ }
+ }
+ if by_target.is_empty() {
+ return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration));
+ }
+ let transport = Arc::new(RhiNostrTransport { by_target });
+ let adapters = RhiTransportAdapters::new(
+ transport.clone(),
+ transport.clone(),
+ transport as Arc<dyn EventSink>,
+ );
+ Ok((adapters, Arc::new(RhiNostrExactSink { relays: bindings })))
+}
+
+pub(crate) async fn probe_required_sources(
+ configuration: &RhiConfigDocumentV1,
+ deadline_unix_ms: u64,
+) -> Result<(), RhiNostrAdapterError> {
+ let relays = configuration
+ .normalized()
+ .pointer("/relays")
+ .and_then(serde_json::Value::as_array)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let sources = configuration
+ .normalized()
+ .pointer("/evidence/sources")
+ .and_then(serde_json::Value::as_array)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let connect_timeout = configuration_integer(configuration, "/network/connect_deadline_ms")?;
+ let request_timeout = sources
+ .iter()
+ .filter_map(|source| {
+ source
+ .pointer("/deadline_ms")
+ .and_then(serde_json::Value::as_u64)
+ })
+ .max()
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+
+ let mut public = Vec::new();
+ let mut public_targets = Vec::new();
+ let mut simulator = Vec::new();
+ let mut simulator_targets = Vec::new();
+ for source in sources.iter().filter(|source| {
+ source
+ .pointer("/required")
+ .and_then(serde_json::Value::as_bool)
+ == Some(true)
+ }) {
+ let relay_id = source
+ .pointer("/relay_id")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let relay = relays
+ .iter()
+ .find(|relay| {
+ relay.pointer("/id").and_then(serde_json::Value::as_str) == Some(relay_id)
+ })
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let url = relay
+ .pointer("/url")
+ .and_then(serde_json::Value::as_str)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let target = Target::nostr_relay(url)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?;
+ let (kind, policy) = if url.starts_with("wss://") {
+ (RelayProfileKind::Public, RelayUrlPolicy::Public)
+ } else if configuration.profile() == RhiConfigProfile::RepoLocal && url.starts_with("ws://")
+ {
+ (RelayProfileKind::Simulator, RelayUrlPolicy::Local)
+ } else {
+ return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration));
+ };
+ let endpoint = RelayEndpoint::new(url, policy, RelayAccess::ReadOnly)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ match kind {
+ RelayProfileKind::Public => {
+ public.push(endpoint);
+ public_targets.push(target);
+ }
+ RelayProfileKind::Simulator => {
+ simulator.push(endpoint);
+ simulator_targets.push(target);
+ }
+ _ => return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration)),
+ }
+ }
+ if public_targets.is_empty() && simulator_targets.is_empty() {
+ return Err(adapter_error(RhiNostrAdapterErrorKind::Configuration));
+ }
+ probe_group(
+ RelayProfileKind::Public,
+ public,
+ public_targets,
+ connect_timeout,
+ request_timeout,
+ deadline_unix_ms,
+ "rhi-doctor-public",
+ )
+ .await?;
+ probe_group(
+ RelayProfileKind::Simulator,
+ simulator,
+ simulator_targets,
+ connect_timeout,
+ request_timeout,
+ deadline_unix_ms,
+ "rhi-doctor-simulator",
+ )
+ .await
+}
+
+async fn probe_group(
+ kind: RelayProfileKind,
+ endpoints: Vec<RelayEndpoint>,
+ targets: Vec<Target>,
+ connect_timeout: u64,
+ request_timeout: u64,
+ deadline_unix_ms: u64,
+ request_id: &'static str,
+) -> Result<(), RhiNostrAdapterError> {
+ if targets.is_empty() {
+ return Ok(());
+ }
+ let transport = build_transport(kind, endpoints, connect_timeout, request_timeout)?
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let target_set =
+ TargetSet::new(targets).map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Target))?;
+ let selector = FetchSelector::all()
+ .with_since_unix_seconds(u64::MAX)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let request = FetchRequest::new(
+ request_id,
+ target_set,
+ FetchBounds::new(1, deadline_unix_ms)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?,
+ )
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?
+ .with_selector(selector);
+ let page = transport
+ .fetch(request)
+ .await
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Preparation))?;
+ if page.target_outcomes().is_empty()
+ || page.target_outcomes().iter().any(|outcome| {
+ !matches!(
+ outcome.state(),
+ FetchTargetState::Complete | FetchTargetState::Partial
+ )
+ })
+ {
+ return Err(adapter_error(RhiNostrAdapterErrorKind::Preparation));
+ }
+ Ok(())
+}
+
+fn build_transport(
+ kind: RelayProfileKind,
+ endpoints: Vec<RelayEndpoint>,
+ connect_timeout: u64,
+ request_timeout: u64,
+) -> Result<Option<NostrTransport>, RhiNostrAdapterError> {
+ if endpoints.is_empty() {
+ return Ok(None);
+ }
+ let maximum_connections = endpoints.len().min(8);
+ let profile = RelayProfile::explicit(kind, endpoints)
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ let config = Config::from_profile(profile)
+ .with_timeouts(connect_timeout, request_timeout, connect_timeout)
+ .and_then(|config| config.with_max_connections(maximum_connections))
+ .map_err(|_| adapter_error(RhiNostrAdapterErrorKind::Configuration))?;
+ Ok(Some(NostrTransport::new(config)))
+}
+
+fn configuration_integer(
+ configuration: &RhiConfigDocumentV1,
+ pointer: &str,
+) -> Result<u64, RhiNostrAdapterError> {
+ configuration
+ .normalized()
+ .pointer(pointer)
+ .and_then(serde_json::Value::as_u64)
+ .ok_or_else(|| adapter_error(RhiNostrAdapterErrorKind::Configuration))
+}
+
+#[cfg(test)]
+mod tests {
+ use std::error::Error as _;
+
+ use super::*;
+ use crate::{RhiConfigProfile, parse_rhi_config_v1};
+
+ const CONFIG: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
+
+ #[test]
+ fn exact_configuration_builds_without_network_io_and_errors_are_redacted() {
+ let configuration = parse_rhi_config_v1(CONFIG.as_bytes(), RhiConfigProfile::Production)
+ .expect("configuration");
+ let (_adapters, sink) =
+ build_rhi_nostr_adapters(&configuration).expect("offline composition");
+ assert_eq!(sink.relays.len(), 2);
+ assert_eq!(request_id("publication", &[0xab; 32]).len(), 76);
+ for kind in [
+ RhiNostrAdapterErrorKind::Configuration,
+ RhiNostrAdapterErrorKind::Target,
+ RhiNostrAdapterErrorKind::Payload,
+ RhiNostrAdapterErrorKind::Preparation,
+ ] {
+ let error = adapter_error(kind);
+ assert_eq!(error.kind(), kind);
+ assert!(error.source().is_none());
+ assert!(!format!("{error} {error:?}").contains("relay.example"));
+ }
+ }
+}
diff --git a/tests/build_policy.rs b/tests/build_policy.rs
@@ -27,7 +27,7 @@ fn source_lock_metadata_is_exact_and_nix_is_absent() {
));
for field in [
"config_contract_version = 1",
- "state_contract_version = 7",
+ "state_contract_version = 11",
"admin_contract_version = 1",
"status_contract_version = 1",
"provider_contract_version = 1",
@@ -73,6 +73,9 @@ fn shared_transport_spi_is_exactly_source_locked_without_serde() {
assert!(MANIFEST.contains(
"radroots_transport = { git = \"https://github.com/radrootslabs/lib\", rev = \"21b11e7a5120ea949f7ad0838c746873fc73aac2\", version = \"=0.1.0-alpha\", default-features = false, features = [\"std\"] }"
));
+ assert!(MANIFEST.contains(
+ "radroots_transport_nostr = { git = \"https://github.com/radrootslabs/lib\", rev = \"21b11e7a5120ea949f7ad0838c746873fc73aac2\", version = \"=0.1.0-alpha\" }"
+ ));
}
#[test]
@@ -93,7 +96,7 @@ fn source_lock_binds_the_current_cargo_lock() {
assert!(!SOURCE_LOCK.contains("flake_lock_sha256"));
assert!(!SOURCE_LOCK.contains("lib_revision ="));
assert!(SOURCE_LOCK.ends_with(
- "[contract_versions]\nconfig = 1\nstate = 7\nadmin = 1\nstatus = 1\nprovider = 1\n"
+ "[contract_versions]\nconfig = 1\nstate = 11\nadmin = 1\nstatus = 1\nprovider = 1\n"
));
}
diff --git a/tests/package_boundary.rs b/tests/package_boundary.rs
@@ -557,7 +557,8 @@ fn doctor_and_process_results_are_closed_bounded_and_process_safe() {
}
assert!(MAIN.contains("parse_rhi_cli_v1_from(std::env::args_os())"));
assert!(MAIN.contains("RhiProcessResult::InputOrConfiguration"));
- assert!(MAIN.contains("eprintln!(\"RHI command failed: {}\", result.code())"));
+ assert!(MAIN.contains("execute_rhi_cli_v1_with_signal_source"));
+ assert!(MAIN.contains("eprintln!(\"{}\", RhiLogRecord::process_result(result))"));
for forbidden in ["{error}", "{error:?}", "process::exit", "tokio::runtime"] {
assert!(
!MAIN.contains(forbidden),
@@ -566,7 +567,7 @@ fn doctor_and_process_results_are_closed_bounded_and_process_safe() {
}
assert!(
MANIFEST.contains(
- "tokio = { version = \"1\", default-features = false, features = [\"time\"] }"
+ "tokio = { version = \"1\", default-features = false, features = [\"io-util\", \"macros\", \"net\", \"rt-multi-thread\", \"signal\", \"sync\", \"time\"] }"
)
);
assert!(MANIFEST.contains(
@@ -1232,7 +1233,7 @@ fn public_errors_are_crate_owned_redacted_and_source_free() {
.lines()
.filter(|line| line.starts_with("pub struct rhi::") && line.ends_with("Error"))
.count();
- assert_eq!(public_error_count, 38);
+ assert_eq!(public_error_count, 39);
}
#[test]
diff --git a/tests/services_hardening_cli.rs b/tests/services_hardening_cli.rs
@@ -6,14 +6,13 @@ use std::path::Path;
use rhi::{
INSTANCE_ID_MAX_BYTES, RhiBootstrapProfileV1, RhiCliAdminOperationV1, RhiCliOfflineOperationV1,
- RhiCliOutputModeV1, RhiCliPrimaryAuthorityV1, RhiCliV1ErrorKind, RhiCommandV1,
- RhiConfigCommandV1, RhiIdentityCommandV1, RhiMetricsCommandV1, RhiPresenceCommandV1,
- RhiPublicationCommandV1, RhiReconciliationCommandV1, RhiSourcesCommandV1, RhiStateCommandV1,
- RhiTradeCommandV1, parse_rhi_cli_v1_from, plan_rhi_cli_v1,
+ RhiCliOutputModeV1, RhiCliPrimaryAuthorityV1, RhiCliV1ErrorKind, parse_rhi_cli_v1_from,
+ plan_rhi_cli_v1,
};
const CLI_SOURCE: &str = include_str!("../src/cli_v1.rs");
const MAIN_SOURCE: &str = include_str!("../src/main.rs");
+const PROCESS_SOURCE: &str = include_str!("../src/process_v1.rs");
const OPERATOR_CONTRACT: &str =
include_str!("../contracts/services_hardening/operator_contract.v1.json");
@@ -25,125 +24,101 @@ fn parse(command: &[&str]) -> rhi::RhiCliInvocationV1 {
#[test]
fn root_api_exposes_the_complete_closed_command_inventory() {
+ let trade = "00000000000000000000000000000000";
let vectors = [
- (&["run"][..], RhiCommandV1::Run),
- (
- &["config", "init"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Init),
- ),
- (
- &["config", "validate"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Validate),
- ),
- (
- &["config", "show"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Show),
- ),
- (
- &["config", "schema"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Schema),
- ),
- (
- &["config", "apply"][..],
- RhiCommandV1::Config(RhiConfigCommandV1::Apply),
- ),
- (
- &["state", "init"][..],
- RhiCommandV1::State(RhiStateCommandV1::Init),
- ),
- (
- &["state", "status"][..],
- RhiCommandV1::State(RhiStateCommandV1::Status),
- ),
- (
- &["state", "backup"][..],
- RhiCommandV1::State(RhiStateCommandV1::Backup),
- ),
- (
- &["state", "restore"][..],
- RhiCommandV1::State(RhiStateCommandV1::Restore),
- ),
- (
- &["state", "verify"][..],
- RhiCommandV1::State(RhiStateCommandV1::Verify),
- ),
- (
- &["state", "migrate"][..],
- RhiCommandV1::State(RhiStateCommandV1::Migrate),
- ),
- (
- &["identity", "init"][..],
- RhiCommandV1::Identity(RhiIdentityCommandV1::Init),
- ),
- (
- &["identity", "status"][..],
- RhiCommandV1::Identity(RhiIdentityCommandV1::Status),
- ),
- (
- &["identity", "export-public"][..],
- RhiCommandV1::Identity(RhiIdentityCommandV1::ExportPublic),
- ),
- (&["status"][..], RhiCommandV1::Status),
- (
- &["metrics", "snapshot"][..],
- RhiCommandV1::Metrics(RhiMetricsCommandV1::Snapshot),
- ),
- (
- &["reconciliation", "status"][..],
- RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Status),
- ),
- (
- &["reconciliation", "jobs"][..],
- RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Jobs),
- ),
- (
- &["reconciliation", "refresh"][..],
- RhiCommandV1::Reconciliation(RhiReconciliationCommandV1::Refresh),
- ),
- (
- &["sources", "list"][..],
- RhiCommandV1::Sources(RhiSourcesCommandV1::List),
- ),
- (
- &["trade", "projection"][..],
- RhiCommandV1::Trade(RhiTradeCommandV1::Projection),
- ),
- (
- &["trade", "report-current"][..],
- RhiCommandV1::Trade(RhiTradeCommandV1::ReportCurrent),
- ),
- (
- &["trade", "reports"][..],
- RhiCommandV1::Trade(RhiTradeCommandV1::Reports),
- ),
- (
- &["publication", "backlog"][..],
- RhiCommandV1::Publication(RhiPublicationCommandV1::Backlog),
- ),
- (
- &["publication", "targets"][..],
- RhiCommandV1::Publication(RhiPublicationCommandV1::Targets),
- ),
- (
- &["publication", "retry"][..],
- RhiCommandV1::Publication(RhiPublicationCommandV1::Retry),
- ),
- (
- &["presence", "desired"][..],
- RhiCommandV1::Presence(RhiPresenceCommandV1::Desired),
- ),
- (
- &["presence", "render"][..],
- RhiCommandV1::Presence(RhiPresenceCommandV1::Render),
- ),
- (
- &["presence", "refresh"][..],
- RhiCommandV1::Presence(RhiPresenceCommandV1::Refresh),
- ),
- (&["doctor"][..], RhiCommandV1::Doctor),
+ vec!["run"],
+ vec!["config", "init"],
+ vec!["config", "validate"],
+ vec!["config", "show"],
+ vec!["config", "schema"],
+ vec![
+ "config",
+ "apply",
+ "--candidate-config",
+ "/tmp/candidate.toml",
+ ],
+ vec!["state", "init"],
+ vec!["state", "status"],
+ vec![
+ "state",
+ "backup",
+ "--operation-id",
+ "backup-1",
+ "--target",
+ "/tmp/backup",
+ "--expected-generation",
+ "1",
+ "--confirm",
+ ],
+ vec![
+ "state",
+ "restore",
+ "--manifest",
+ "/tmp/manifest.json",
+ "--manifest-sha256",
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ "--bundle",
+ "/tmp/backup",
+ "--maximum-state-bytes",
+ "1048576",
+ "--confirm",
+ ],
+ vec!["state", "verify"],
+ vec!["state", "migrate"],
+ vec!["identity", "init"],
+ vec!["identity", "status"],
+ vec!["identity", "export-public"],
+ vec!["status"],
+ vec!["metrics", "snapshot"],
+ vec!["reconciliation", "status"],
+ vec!["reconciliation", "jobs"],
+ vec![
+ "reconciliation",
+ "refresh",
+ "--operation-id",
+ "refresh-1",
+ "--trade-id",
+ trade,
+ "--expected-dirty-generation",
+ "1",
+ ],
+ vec!["sources", "list"],
+ vec!["trade", "projection", "--trade-id", trade],
+ vec!["trade", "report-current", "--trade-id", trade],
+ vec!["trade", "reports", "--trade-id", trade],
+ vec!["publication", "backlog"],
+ vec!["publication", "targets"],
+ vec![
+ "publication",
+ "retry",
+ "--operation-id",
+ "retry-1",
+ "--workflow-id",
+ "workflow-1",
+ "--expected-generation",
+ "1",
+ ],
+ vec!["presence", "desired"],
+ vec![
+ "presence",
+ "render",
+ "--operation-id",
+ "render-1",
+ "--expected-generation",
+ "1",
+ ],
+ vec![
+ "presence",
+ "refresh",
+ "--operation-id",
+ "presence-1",
+ "--expected-generation",
+ "1",
+ ],
+ vec!["doctor"],
];
- for (arguments, expected) in vectors {
- assert_eq!(parse(arguments).command(), expected);
+ for arguments in vectors {
+ parse(&arguments);
}
}
@@ -264,7 +239,12 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"config apply",
- vec!["config", "apply"],
+ vec![
+ "config",
+ "apply",
+ "--candidate-config",
+ "/tmp/candidate.toml",
+ ],
"offline",
Some("config"),
None,
@@ -285,14 +265,36 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"state backup",
- vec!["state", "backup"],
+ vec![
+ "state",
+ "backup",
+ "--operation-id",
+ "backup-1",
+ "--target",
+ "/tmp/backup",
+ "--expected-generation",
+ "1",
+ "--confirm",
+ ],
"live_unix_admin",
None,
Some("/v1/state/backup"),
),
(
"state restore",
- vec!["state", "restore"],
+ vec![
+ "state",
+ "restore",
+ "--manifest",
+ "/tmp/manifest.json",
+ "--manifest-sha256",
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ "--bundle",
+ "/tmp/backup",
+ "--maximum-state-bytes",
+ "1048576",
+ "--confirm",
+ ],
"offline",
Some("state_exclusive"),
None,
@@ -362,7 +364,16 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"reconciliation refresh",
- vec!["reconciliation", "refresh"],
+ vec![
+ "reconciliation",
+ "refresh",
+ "--operation-id",
+ "refresh-1",
+ "--trade-id",
+ "00000000000000000000000000000000",
+ "--expected-dirty-generation",
+ "1",
+ ],
"live_unix_admin",
None,
Some("/v1/reconciliation/refresh"),
@@ -376,21 +387,36 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"trade projection",
- vec!["trade", "projection"],
+ vec![
+ "trade",
+ "projection",
+ "--trade-id",
+ "00000000000000000000000000000000",
+ ],
"live_unix_admin",
None,
Some("/v1/trades/{trade_id}/projection"),
),
(
"trade report-current",
- vec!["trade", "report-current"],
+ vec![
+ "trade",
+ "report-current",
+ "--trade-id",
+ "00000000000000000000000000000000",
+ ],
"live_unix_admin",
None,
Some("/v1/trades/{trade_id}/reports/current"),
),
(
"trade reports",
- vec!["trade", "reports"],
+ vec![
+ "trade",
+ "reports",
+ "--trade-id",
+ "00000000000000000000000000000000",
+ ],
"live_unix_admin",
None,
Some("/v1/trades/{trade_id}/reports"),
@@ -411,7 +437,16 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"publication retry",
- vec!["publication", "retry"],
+ vec![
+ "publication",
+ "retry",
+ "--operation-id",
+ "retry-1",
+ "--workflow-id",
+ "workflow-1",
+ "--expected-generation",
+ "1",
+ ],
"live_unix_admin",
None,
Some("/v1/publication/retry"),
@@ -425,14 +460,28 @@ fn every_command_has_one_exact_nonforgeable_execution_plan() {
),
(
"presence render",
- vec!["presence", "render"],
+ vec![
+ "presence",
+ "render",
+ "--operation-id",
+ "render-1",
+ "--expected-generation",
+ "1",
+ ],
"live_unix_admin",
None,
Some("/v1/presence/render"),
),
(
"presence refresh",
- vec!["presence", "refresh"],
+ vec![
+ "presence",
+ "refresh",
+ "--operation-id",
+ "presence-1",
+ "--expected-generation",
+ "1",
+ ],
"live_unix_admin",
None,
Some("/v1/presence/refresh"),
@@ -586,6 +635,12 @@ fn execution_plan_is_safe_and_the_binary_parses_and_plans_once() {
"/secret/config.toml",
"publication",
"retry",
+ "--operation-id",
+ "operation-1",
+ "--workflow-id",
+ "0000000000000000000000000000000000000000000000000000000000000000",
+ "--expected-generation",
+ "1",
])
.expect("valid invocation");
let rendered = format!("{invocation:?} {:?}", plan_rhi_cli_v1(&invocation));
@@ -604,7 +659,15 @@ fn execution_plan_is_safe_and_the_binary_parses_and_plans_once() {
1
);
assert_eq!(
- MAIN_SOURCE.matches("plan_rhi_cli_v1(&invocation)").count(),
+ MAIN_SOURCE
+ .matches("execute_rhi_cli_v1_with_signal_source")
+ .count(),
+ 1
+ );
+ assert_eq!(
+ PROCESS_SOURCE
+ .matches("plan_rhi_cli_v1(&invocation)")
+ .count(),
1
);
for source in [CLI_SOURCE, MAIN_SOURCE] {
diff --git a/tests/services_hardening_doctor.rs b/tests/services_hardening_doctor.rs
@@ -289,6 +289,11 @@ async fn report_debug_and_public_errors_retain_no_sensitive_values() {
#[test]
fn binary_uses_stable_safe_nonzero_results() {
+ const INPUT_FAILURE: &str = concat!(
+ r#"{"schema":"radroots.rhi.log.v1","contract_version":1,"service":"rhi","#,
+ r#""level":"error","event":"process_result","code":"input_or_configuration","exit_code":2}"#,
+ "\n"
+ );
let canary = "secret-canary-private-key-path-sql-relay-url";
let invalid = Command::new(env!("CARGO_BIN_EXE_rhi"))
.arg(format!("--credential={canary}"))
@@ -297,7 +302,7 @@ fn binary_uses_stable_safe_nonzero_results() {
assert_eq!(invalid.status.code(), Some(2));
assert!(invalid.stdout.is_empty());
let stderr = String::from_utf8(invalid.stderr).expect("invalid stderr");
- assert_eq!(stderr, "RHI command failed: input_or_configuration\n");
+ assert_eq!(stderr, INPUT_FAILURE);
assert!(!stderr.contains(canary));
let repo_local = tempfile::tempdir().expect("repo-local root");
@@ -312,7 +317,7 @@ fn binary_uses_stable_safe_nonzero_results() {
assert!(admitted.stdout.is_empty());
assert_eq!(
String::from_utf8(admitted.stderr).expect("admitted stderr"),
- "RHI command failed: input_or_configuration\n"
+ INPUT_FAILURE
);
}
diff --git a/tests/services_hardening_native_release.rs b/tests/services_hardening_native_release.rs
@@ -201,7 +201,7 @@ fn every_radroots_dependency_is_exactly_source_locked() {
.iter()
.filter(|(name, _)| name.starts_with("radroots_"))
.collect::<Vec<_>>();
- assert_eq!(radroots.len(), 11);
+ assert_eq!(radroots.len(), 12);
for (name, dependency) in radroots {
let dependency = dependency.as_table().expect("detailed dependency");
assert_eq!(
diff --git a/tests/services_hardening_operator_contract.rs b/tests/services_hardening_operator_contract.rs
@@ -228,6 +228,34 @@ fn admin_inventory_is_closed_unique_and_model_complete() {
serde_json::json!({ "kind": "enum", "values": ["Valid", "Invalid", "Indeterminate"] })
);
assert_eq!(
+ value["admin"]["types"]["provider_state"]["fields"],
+ serde_json::json!({
+ "health": "provider_health",
+ "identity": "identity_health",
+ "reason_codes": "reason_codes"
+ })
+ );
+ assert_eq!(
+ value["admin"]["types"]["transport_state"]["fields"],
+ serde_json::json!({
+ "health": "transport_health",
+ "required_sources_ready": "bool",
+ "subscriber_active": "bool",
+ "configured_source_count": "u64",
+ "reachable_source_count": "u64",
+ "reason_codes": "reason_codes"
+ })
+ );
+ assert_eq!(
+ value["admin"]["types"]["rhi_status"]["fields"],
+ serde_json::json!({
+ "identity": "identity_health",
+ "reconciliation": "reconciliation_status",
+ "publication": "publication_status",
+ "presence": "presence_status"
+ })
+ );
+ assert_eq!(
value["admin"]["models"]["service_status_v1"]["fields"]
.as_object()
.unwrap()
@@ -301,7 +329,7 @@ fn admin_inventory_is_closed_unique_and_model_complete() {
assert_eq!(mutation_operations, committed_effects);
assert_eq!(
decision_sections_digest(&value),
- "b227c6f248605a1672d3b7b07f60b0fd9ba8b890478d17e081cd7e3caed8a889"
+ "49376e3bdf0e44c35f877ecd382f26bc9c38fec8fea7a674aa7d4da52ee00c62"
);
}
diff --git a/tests/services_hardening_process.rs b/tests/services_hardening_process.rs
@@ -0,0 +1,441 @@
+#![forbid(unsafe_code)]
+#![cfg(any(target_os = "linux", target_os = "macos"))]
+
+use std::{
+ fs,
+ io::{Read as _, Write as _},
+ net::{TcpListener, TcpStream},
+ os::unix::fs::PermissionsExt as _,
+ path::PathBuf,
+ process::{Child, Command, Output, Stdio},
+ sync::{
+ Arc,
+ atomic::{AtomicBool, Ordering},
+ },
+ thread,
+ time::{Duration, Instant},
+};
+
+use nostr::{Keys, SecretKey};
+use rhi::{
+ RadrootsHostEnvironment, RadrootsPathResolver, RadrootsPlatform, parse_rhi_cli_v1_from,
+ resolve_rhi_runtime_context,
+};
+use sha2::{Digest as _, Sha256};
+use tungstenite::{Error as WebSocketError, Message, accept};
+
+const CONFIG_EXAMPLE: &str = include_str!("../contracts/services_hardening/config.v1.example.toml");
+const PROCESS_DEADLINE: Duration = Duration::from_secs(30);
+const RELAY_IO_TIMEOUT: Duration = Duration::from_millis(100);
+
+struct RelayHarness {
+ address: std::net::SocketAddr,
+ stop: Arc<AtomicBool>,
+ thread: Option<thread::JoinHandle<()>>,
+}
+
+impl RelayHarness {
+ fn start() -> Self {
+ let listener = TcpListener::bind("127.0.0.1:0").expect("test relay listener");
+ listener
+ .set_nonblocking(true)
+ .expect("nonblocking test relay");
+ let address = listener.local_addr().expect("test relay address");
+ let stop = Arc::new(AtomicBool::new(false));
+ let thread_stop = Arc::clone(&stop);
+ let thread = thread::spawn(move || {
+ let mut sessions = Vec::new();
+ while !thread_stop.load(Ordering::SeqCst) {
+ match listener.accept() {
+ Ok((stream, _)) => {
+ let session_stop = Arc::clone(&thread_stop);
+ sessions.push(thread::spawn(move || relay_session(stream, &session_stop)));
+ }
+ Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {
+ thread::sleep(Duration::from_millis(2));
+ }
+ Err(error) => panic!("test relay accept failed: {error}"),
+ }
+ }
+ for session in sessions {
+ session.join().expect("test relay session");
+ }
+ });
+ Self {
+ address,
+ stop,
+ thread: Some(thread),
+ }
+ }
+
+ fn url(&self, path: &str) -> String {
+ format!("ws://{}/{path}", self.address)
+ }
+}
+
+impl Drop for RelayHarness {
+ fn drop(&mut self) {
+ self.stop.store(true, Ordering::SeqCst);
+ let _ = TcpStream::connect(self.address);
+ if let Some(thread) = self.thread.take() {
+ thread.join().expect("test relay");
+ }
+ }
+}
+
+fn relay_session(stream: TcpStream, stop: &AtomicBool) {
+ stream
+ .set_read_timeout(Some(RELAY_IO_TIMEOUT))
+ .expect("relay read timeout");
+ stream
+ .set_write_timeout(Some(RELAY_IO_TIMEOUT))
+ .expect("relay write timeout");
+ let mut websocket = match accept(stream) {
+ Ok(websocket) => websocket,
+ Err(_) => return,
+ };
+ while !stop.load(Ordering::SeqCst) {
+ let message = match websocket.read() {
+ Ok(message) => message,
+ Err(WebSocketError::Io(error))
+ if matches!(
+ error.kind(),
+ std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
+ ) =>
+ {
+ continue;
+ }
+ Err(WebSocketError::ConnectionClosed | WebSocketError::AlreadyClosed) => break,
+ Err(_) => break,
+ };
+ match message {
+ Message::Text(text) => relay_text(&mut websocket, text.as_str()),
+ Message::Ping(bytes) => {
+ if websocket.send(Message::Pong(bytes)).is_err() {
+ break;
+ }
+ }
+ Message::Close(_) => break,
+ _ => {}
+ }
+ }
+}
+
+fn relay_text(websocket: &mut tungstenite::WebSocket<TcpStream>, text: &str) {
+ let Ok(message) = serde_json::from_str::<serde_json::Value>(text) else {
+ return;
+ };
+ let Some(parts) = message.as_array() else {
+ return;
+ };
+ match parts.first().and_then(serde_json::Value::as_str) {
+ Some("REQ") => {
+ let Some(subscription) = parts.get(1).and_then(serde_json::Value::as_str) else {
+ return;
+ };
+ let response = serde_json::json!(["EOSE", subscription]).to_string();
+ let _ = websocket.send(Message::Text(response.into()));
+ }
+ Some("EVENT") => {
+ let Some(event_id) = parts
+ .get(1)
+ .and_then(|event| event.get("id"))
+ .and_then(serde_json::Value::as_str)
+ else {
+ return;
+ };
+ let response = serde_json::json!(["OK", event_id, true, ""]).to_string();
+ let _ = websocket.send(Message::Text(response.into()));
+ }
+ _ => {}
+ }
+}
+
+struct ProcessFixture {
+ root: tempfile::TempDir,
+ config: PathBuf,
+ runtime: rhi::RhiRuntimeContext,
+}
+
+impl ProcessFixture {
+ fn new() -> Self {
+ let root = tempfile::Builder::new()
+ .prefix("rhi-")
+ .tempdir_in("/private/tmp")
+ .expect("short repo-local root");
+ fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700))
+ .expect("secure repo-local root");
+ let config = root.path().join("rhi.toml");
+ let invocation = parse_rhi_cli_v1_from([
+ "rhi",
+ "--profile",
+ "repo-local",
+ "--instance",
+ "primary",
+ "--repo-local-root",
+ root.path().to_str().expect("UTF-8 test root"),
+ "--config",
+ config.to_str().expect("UTF-8 config path"),
+ "run",
+ ])
+ .expect("runtime invocation");
+ let runtime = resolve_rhi_runtime_context(
+ &RadrootsPathResolver::new(
+ RadrootsPlatform::current(),
+ RadrootsHostEnvironment::default(),
+ ),
+ &invocation,
+ )
+ .expect("runtime context");
+ Self {
+ root,
+ config,
+ runtime,
+ }
+ }
+
+ fn command(&self, command: &[&str]) -> Command {
+ let mut process = Command::new(env!("CARGO_BIN_EXE_rhi"));
+ process
+ .args(["--profile", "repo-local", "--instance", "primary"])
+ .arg("--repo-local-root")
+ .arg(self.root.path())
+ .arg("--config")
+ .arg(&self.config)
+ .args(command)
+ .stdin(Stdio::null())
+ .stdout(Stdio::piped())
+ .stderr(Stdio::piped());
+ process
+ }
+
+ fn run(&self, command: &[&str]) -> Output {
+ wait_bounded(self.command(command).spawn().expect("RHI process"))
+ }
+
+ fn run_with_stdin(&self, command: &[&str], bytes: &[u8]) -> Output {
+ let mut process = self.command(command);
+ process.stdin(Stdio::piped());
+ let mut child = process.spawn().expect("RHI process");
+ child
+ .stdin
+ .take()
+ .expect("process stdin")
+ .write_all(bytes)
+ .expect("bounded stdin");
+ wait_bounded(child)
+ }
+}
+
+fn wait_bounded(mut child: Child) -> Output {
+ let deadline = Instant::now() + PROCESS_DEADLINE;
+ loop {
+ if child.try_wait().expect("poll RHI process").is_some() {
+ let output = child.wait_with_output().expect("collect RHI process");
+ assert!(output.stdout.len() <= 1_048_576);
+ assert!(output.stderr.len() <= 8_192);
+ return output;
+ }
+ if Instant::now() >= deadline {
+ let _ = child.kill();
+ let output = child.wait_with_output().expect("reap RHI process");
+ panic!("RHI process exceeded deadline: {:?}", output.stderr);
+ }
+ thread::sleep(Duration::from_millis(2));
+ }
+}
+
+fn identity_secret() -> [u8; 32] {
+ let mut candidate: [u8; 32] =
+ Sha256::digest(b"radroots.rhi.step-213.process-identity.v1").into();
+ while SecretKey::from_slice(&candidate).is_err() {
+ candidate = Sha256::digest(candidate).into();
+ }
+ candidate
+}
+
+fn provisioning_document(secret: [u8; 32]) -> [u8; 117] {
+ let mut document = [0_u8; 117];
+ document[..4].copy_from_slice(b"RHIP");
+ document[4] = 1;
+ document[5..37].copy_from_slice(&secret);
+ document[37..69].copy_from_slice(&Sha256::digest(b"rhi-step-213-data-key"));
+ document[69..93].copy_from_slice(&[3; 24]);
+ document[93..117].copy_from_slice(&[4; 24]);
+ document
+}
+
+fn configuration(
+ fixture: &ProcessFixture,
+ expected_public_key: &str,
+ primary_relay: &str,
+ secondary_relay: &str,
+) -> String {
+ CONFIG_EXAMPLE
+ .replace(
+ "/var/lib/radroots/services/rhi/default/secrets/service.identity.ncrypt",
+ fixture
+ .runtime
+ .identity_path()
+ .to_str()
+ .expect("UTF-8 identity path"),
+ )
+ .replace(&"2".repeat(64), expected_public_key)
+ .replace("wss://relay.example.com/", primary_relay)
+ .replace("wss://relay-secondary.example.com/", secondary_relay)
+ .replace("connect_deadline_ms = 10000", "connect_deadline_ms = 100")
+ .replace("request_deadline_ms = 15000", "request_deadline_ms = 1000")
+}
+
+fn diagnostic_code(output: &Output) -> String {
+ let value: serde_json::Value = serde_json::from_slice(&output.stderr).expect("diagnostic JSON");
+ value["code"].as_str().expect("diagnostic code").to_owned()
+}
+
+fn assert_success(output: &Output) {
+ assert_eq!(output.status.code(), Some(0), "stderr: {:?}", output.stderr);
+ assert_eq!(diagnostic_code(output), "success");
+}
+
+fn bootstrap(fixture: &ProcessFixture, configuration: &str, secret: [u8; 32]) -> String {
+ let expected_public_key = Keys::new(SecretKey::from_slice(&secret).expect("secret"))
+ .public_key()
+ .to_hex();
+ let config = fixture.run_with_stdin(&["config", "init"], configuration.as_bytes());
+ assert_success(&config);
+ assert_eq!(config.stdout, b"config_initialized\n");
+
+ for directory in [
+ fixture.runtime.context().paths().state(),
+ fixture.runtime.context().paths().secrets(),
+ fixture.runtime.context().paths().run(),
+ ] {
+ fs::create_dir_all(directory).expect("secure runtime directory");
+ fs::set_permissions(directory, fs::Permissions::from_mode(0o700))
+ .expect("secure runtime mode");
+ }
+ let credential = fixture
+ .runtime
+ .context()
+ .paths()
+ .secrets()
+ .join("service_wrapping_key");
+ fs::write(&credential, Sha256::digest(b"rhi-step-213-wrapping-key"))
+ .expect("wrapping credential");
+ fs::set_permissions(&credential, fs::Permissions::from_mode(0o600)).expect("credential mode");
+
+ let state = fixture.run(&["state", "init"]);
+ assert_success(&state);
+ assert_eq!(state.stdout, b"state_initialized\n");
+ let identity = fixture.run_with_stdin(
+ &["--output", "json", "identity", "init"],
+ &provisioning_document(secret),
+ );
+ assert_success(&identity);
+ let identity_value: serde_json::Value =
+ serde_json::from_slice(&identity.stdout).expect("identity result");
+ assert_eq!(identity_value["public_key"], expected_public_key);
+
+ for command in [
+ &["config", "validate"][..],
+ &["state", "verify"][..],
+ &["state", "migrate"][..],
+ ] {
+ assert_success(&fixture.run(command));
+ }
+ expected_public_key
+}
+
+fn wait_for_live_status(fixture: &ProcessFixture, daemon: &mut Child) -> Output {
+ let deadline = Instant::now() + PROCESS_DEADLINE;
+ let mut last_diagnostic = Vec::new();
+ loop {
+ if let Some(status) = daemon.try_wait().expect("poll RHI daemon") {
+ let mut stderr = Vec::new();
+ daemon
+ .stderr
+ .take()
+ .expect("RHI daemon stderr")
+ .read_to_end(&mut stderr)
+ .expect("read RHI daemon stderr");
+ panic!("RHI daemon exited before admin became ready: {status}; stderr={stderr:?}");
+ }
+ if fixture.runtime.artifacts().admin_socket().exists() {
+ let status = fixture.run(&["status"]);
+ if status.status.success() {
+ return status;
+ }
+ last_diagnostic = status.stderr;
+ }
+ if Instant::now() >= deadline {
+ panic!(
+ "RHI admin did not become ready before deadline; socket={}; stderr={last_diagnostic:?}",
+ fixture.runtime.artifacts().admin_socket().exists()
+ );
+ }
+ thread::sleep(Duration::from_millis(2));
+ }
+}
+
+#[test]
+fn actual_binary_executes_offline_bootstrap_and_reaches_real_runtime_dependency_boundary() {
+ let fixture = ProcessFixture::new();
+ let secret = identity_secret();
+ let expected_public_key = Keys::new(SecretKey::from_slice(&secret).expect("secret"))
+ .public_key()
+ .to_hex();
+ let configuration = configuration(
+ &fixture,
+ &expected_public_key,
+ "ws://127.0.0.1:9/",
+ "ws://127.0.0.1:10/",
+ );
+ bootstrap(&fixture, &configuration, secret);
+
+ let run = fixture.run(&["run"]);
+ assert_eq!(run.status.code(), Some(3));
+ assert!(run.stdout.is_empty());
+ assert_eq!(diagnostic_code(&run), "service_or_dependency_unavailable");
+ let diagnostic = String::from_utf8(run.stderr).expect("diagnostic UTF-8");
+ assert!(!diagnostic.contains(fixture.root.path().to_str().expect("UTF-8 root")));
+ assert!(!diagnostic.contains("relay.example"));
+ assert!(!diagnostic.contains("service_wrapping_key"));
+}
+
+#[test]
+fn actual_binary_runs_the_task_graph_serves_admin_and_shuts_down_on_interrupt() {
+ let relay = RelayHarness::start();
+ let fixture = ProcessFixture::new();
+ let secret = identity_secret();
+ let expected_public_key = Keys::new(SecretKey::from_slice(&secret).expect("secret"))
+ .public_key()
+ .to_hex();
+ let configuration = configuration(
+ &fixture,
+ &expected_public_key,
+ &relay.url("primary"),
+ &relay.url("secondary"),
+ );
+ bootstrap(&fixture, &configuration, secret);
+
+ let mut daemon = fixture.command(&["run"]).spawn().expect("RHI daemon");
+ let status = wait_for_live_status(&fixture, &mut daemon);
+ assert_success(&status);
+ let status_value: serde_json::Value =
+ serde_json::from_slice(&status.stdout).expect("live status JSON");
+ assert_eq!(status_value["service"], "rhi");
+ assert_eq!(status_value["phase"], "ready");
+ assert_eq!(status_value["ready"], true);
+ assert_eq!(status_value["persistence"]["schema_version"], 11);
+
+ let signal = Command::new("/bin/kill")
+ .arg("-INT")
+ .arg(daemon.id().to_string())
+ .status()
+ .expect("send interrupt");
+ assert!(signal.success());
+ let shutdown = wait_bounded(daemon);
+ assert_success(&shutdown);
+ assert!(shutdown.stdout.is_empty());
+ assert!(!fixture.runtime.artifacts().admin_socket().exists());
+}
diff --git a/tests/services_hardening_state_catalog.rs b/tests/services_hardening_state_catalog.rs
@@ -23,8 +23,9 @@ use rhi::{
RHI_STATE_SCHEMA_VERSION_9_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_9_OBJECT_COUNT,
RHI_STATE_SCHEMA_VERSION_9_SHA256, RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256,
RHI_STATE_SCHEMA_VERSION_10_OBJECT_COUNT, RHI_STATE_SCHEMA_VERSION_10_SHA256,
- RhiStateCatalogErrorKind, rhi_migration_catalog, rhi_schema_catalog,
- validate_rhi_state_catalogs,
+ RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256, RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT,
+ RHI_STATE_SCHEMA_VERSION_11_SHA256, RhiStateCatalogErrorKind, rhi_migration_catalog,
+ rhi_schema_catalog, validate_rhi_state_catalogs,
};
const CATALOG_SOURCE: &str = include_str!("../src/state_catalog.rs");
@@ -32,14 +33,14 @@ const LIB_SOURCE: &str = include_str!("../src/lib.rs");
const MANIFEST: &str = include_str!("../Cargo.toml");
#[test]
-fn schema_v1_through_v10_catalogs_have_exact_literal_identities() {
+fn schema_v1_through_v11_catalogs_have_exact_literal_identities() {
let migrations = rhi_migration_catalog().expect("RHI migration catalog");
let schema = rhi_schema_catalog().expect("RHI schema catalog");
assert_eq!(RHI_STATE_BASE_SCHEMA_VERSION, 1);
- assert_eq!(RHI_STATE_SCHEMA_VERSION, 10);
- assert_eq!(migrations.descriptors().len(), 9);
- assert_eq!(migrations.current_version(), 10);
+ assert_eq!(RHI_STATE_SCHEMA_VERSION, 11);
+ assert_eq!(migrations.descriptors().len(), 10);
+ assert_eq!(migrations.current_version(), 11);
assert_eq!(migrations.descriptors()[0].target_version(), 2);
assert_eq!(
migrations.descriptors()[0].name().as_str(),
@@ -121,12 +122,21 @@ fn schema_v1_through_v10_catalogs_have_exact_literal_identities() {
migrations.descriptors()[8].checksum().as_bytes(),
&RHI_STATE_SCHEMA_VERSION_10_MIGRATION_SHA256
);
+ assert_eq!(migrations.descriptors()[9].target_version(), 11);
+ assert_eq!(
+ migrations.descriptors()[9].name().as_str(),
+ "create_admin_operation_journal"
+ );
+ assert_eq!(
+ migrations.descriptors()[9].checksum().as_bytes(),
+ &RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256
+ );
assert_eq!(
migrations.digest().as_bytes(),
&RHI_MIGRATION_CATALOG_SHA256
);
- assert_eq!(schema.versions().len(), 10);
+ assert_eq!(schema.versions().len(), 11);
let version = schema.versions()[0];
assert_eq!(version.version(), 1);
assert_eq!(
@@ -237,13 +247,24 @@ fn schema_v1_through_v10_catalogs_have_exact_literal_identities() {
version.digest().as_bytes(),
&RHI_STATE_SCHEMA_VERSION_10_SHA256
);
+ let version = schema.versions()[10];
+ assert_eq!(version.version(), 11);
+ assert_eq!(
+ version.object_count(),
+ RHI_STATE_SCHEMA_VERSION_11_OBJECT_COUNT
+ );
+ assert_eq!(version.object_count(), 82);
+ assert_eq!(
+ version.digest().as_bytes(),
+ &RHI_STATE_SCHEMA_VERSION_11_SHA256
+ );
assert_eq!(schema.digest().as_bytes(), &RHI_STATE_SCHEMA_CATALOG_SHA256);
assert_eq!(schema.migration_catalog_digest(), migrations.digest());
validate_rhi_state_catalogs(&migrations, &schema).expect("exact catalogs");
assert_eq!(
lower_hex(&RHI_MIGRATION_CATALOG_SHA256),
- "25e5ba773ef3db0133a8077a083e88b40fc6dadf9fb6f0cfde0e4ba4d3e081d9"
+ "e6cbacbd1eb636c1a560f85ef8e51e89c9ffe3b342e66bc5c0b47ab34e90c818"
);
assert_eq!(
lower_hex(&RHI_STATE_SCHEMA_VERSION_1_SHA256),
@@ -322,8 +343,16 @@ fn schema_v1_through_v10_catalogs_have_exact_literal_identities() {
"d2aed51d0a6a2c01eda1844608472b2dcd502abaa8a4ca30a4823535b8bd0e45"
);
assert_eq!(
+ lower_hex(&RHI_STATE_SCHEMA_VERSION_11_MIGRATION_SHA256),
+ "e3fbde511e8424c97080be2c09ed810ae284631d75eb25c2e88a6076b700aaef"
+ );
+ assert_eq!(
+ lower_hex(&RHI_STATE_SCHEMA_VERSION_11_SHA256),
+ "c25ec63b33b411618068ee06a04c99ee7166e0014d979039faeaea4dc1ac7e62"
+ );
+ assert_eq!(
lower_hex(&RHI_STATE_SCHEMA_CATALOG_SHA256),
- "4f4d5f5546a7c94ef3cdabe23eadd0c97a64980984ee38cacb828f1125913488"
+ "aec482818bd9a6f33fd92b55d142c6b85aa6f086855701dfc4b78f2a7ef5cf6d"
);
}
@@ -395,6 +424,10 @@ fn independent_validator_rejects_migration_or_schema_drift() {
SchemaVersionCatalog::computed_digest(10, [version_two_object()]).expect("v10 digest");
let version_ten = SchemaVersionCatalog::new(10, [version_two_object()], snapshot_digest)
.expect("version ten");
+ let snapshot_digest =
+ SchemaVersionCatalog::computed_digest(11, [version_two_object()]).expect("v11 digest");
+ let version_eleven = SchemaVersionCatalog::new(11, [version_two_object()], snapshot_digest)
+ .expect("version eleven");
let schema = SchemaCatalog::new(
&exact_migrations,
[
@@ -408,6 +441,7 @@ fn independent_validator_rejects_migration_or_schema_drift() {
version_eight,
version_nine,
version_ten,
+ version_eleven,
],
)
.expect("drift schema catalog");
@@ -478,6 +512,10 @@ fn catalog_errors_are_stable_source_free_and_redacted() {
SchemaVersionCatalog::computed_digest(10, [secret_object()]).expect("v10 digest");
let version_ten =
SchemaVersionCatalog::new(10, [secret_object()], version_ten_digest).expect("version ten");
+ let version_eleven_digest =
+ SchemaVersionCatalog::computed_digest(11, [secret_object()]).expect("v11 digest");
+ let version_eleven = SchemaVersionCatalog::new(11, [secret_object()], version_eleven_digest)
+ .expect("version eleven");
let schema = SchemaCatalog::new(
&migrations,
[
@@ -491,6 +529,7 @@ fn catalog_errors_are_stable_source_free_and_redacted() {
version_eight,
version_nine,
version_ten,
+ version_eleven,
],
)
.expect("schema catalog");
diff --git a/tests/services_hardening_state_host.rs b/tests/services_hardening_state_host.rs
@@ -106,6 +106,8 @@ async fn downgrade_fixture_to_schema_v7(runtime: &rhi::RhiRuntimeContext) {
.await
.expect("migration delete guard SQL");
for statement in [
+ "DROP TRIGGER rhi_admin_operations_guard_update",
+ "DROP TABLE rhi_admin_operations",
"DROP TABLE presence_attempts",
"DROP TABLE presence_targets",
"DROP TABLE presence_outbox",
@@ -119,7 +121,7 @@ async fn downgrade_fixture_to_schema_v7(runtime: &rhi::RhiRuntimeContext) {
"DROP TRIGGER schema_migrations_no_update",
"DROP TRIGGER schema_migrations_no_delete",
"UPDATE radroots_service_metadata SET state_schema_version = 7 WHERE singleton = 1",
- "DELETE FROM schema_migrations WHERE version IN (8, 9, 10)",
+ "DELETE FROM schema_migrations WHERE version IN (8, 9, 10, 11)",
] {
sqlx::query(statement)
.execute(&mut connection)
@@ -486,7 +488,7 @@ async fn schema_v8_scans_historical_nullable_job_state_and_installs_permanent_gu
.fetch_one(&mut connection)
.await
.expect("migrated schema state");
- assert_eq!(migrated, (10, 1, 2, 0));
+ assert_eq!(migrated, (11, 1, 2, 0));
let invalid_insert = sqlx::query(
r#"INSERT INTO reconciliation_jobs (
diff --git a/tests/services_hardening_state_resilience.rs b/tests/services_hardening_state_resilience.rs
@@ -347,8 +347,8 @@ async fn exact_open_rejects_unexpected_migration_history_without_repair() {
service_version, service_commit, lib_revision, rust_version, target,
feature_profile, config_contract_version, state_contract_version,
admin_contract_version, status_contract_version, provider_contract_version
- ) VALUES (11, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?,
- 'rustc-test', 'test-target', 'service-host', 1, 10, 1, 1, 1)",
+ ) VALUES (12, 'unexpected_schema', ?, 1725000000, '0.1.0', ?, ?,
+ 'rustc-test', 'test-target', 'service-host', 1, 11, 1, 1, 1)",
)
.bind([0x44_u8; 32].as_slice())
.bind("1111111111111111111111111111111111111111")
diff --git a/tests/services_hardening_status.rs b/tests/services_hardening_status.rs
@@ -170,7 +170,7 @@ fn machine_contract_and_canonical_detailed_status_are_exact() {
let wire = std::str::from_utf8(snapshot.detailed_status_json()).expect("status UTF-8");
assert_eq!(
wire,
- r#"{"contract_version":1,"service":"rhi","instance":"primary","phase":"ready","ready":true,"uptime_millis":120000,"reason_codes":[],"build_info":{"version":"0.1.0","revision":"0123456789abcdef0123456789abcdef01234567","toolchain":"1.97.1","contract_versions":{"config":1,"state":10,"admin":1,"status":1,"provider":1}},"configuration":{"schema":"radroots.rhi.config","schema_version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source":"explicit_config"},"persistence":{"health":"ready","schema_version":10,"generation":42,"integrity":"verified","reason_codes":[]},"provider":{"health":"ready","identity":{"configured":true,"available":true,"reason_codes":[]},"reason_codes":[]},"transport":{"health":"ready","required_sources_ready":true,"subscriber_active":true,"configured_source_count":2,"reachable_source_count":2,"reason_codes":[]},"rhi":{"identity":{"configured":true,"available":true,"reason_codes":[]},"reconciliation":{"pending":5,"leased":3,"exhausted":1,"oldest_pending_at_utc":1723456700},"publication":{"pending":4,"unknown":1,"oldest_pending_at_utc":1723456789},"presence":{"pending":2,"unknown":1}}}"#
+ r#"{"contract_version":1,"service":"rhi","instance":"primary","phase":"ready","ready":true,"uptime_millis":120000,"reason_codes":[],"build_info":{"version":"0.1.0","revision":"0123456789abcdef0123456789abcdef01234567","toolchain":"1.97.1","contract_versions":{"config":1,"state":11,"admin":1,"status":1,"provider":1}},"configuration":{"schema":"radroots.rhi.config","schema_version":1,"digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","source":"explicit_config"},"persistence":{"health":"ready","schema_version":10,"generation":42,"integrity":"verified","reason_codes":[]},"provider":{"health":"ready","identity":{"configured":true,"available":true,"reason_codes":[]},"reason_codes":[]},"transport":{"health":"ready","required_sources_ready":true,"subscriber_active":true,"configured_source_count":2,"reachable_source_count":2,"reason_codes":[]},"rhi":{"identity":{"configured":true,"available":true,"reason_codes":[]},"reconciliation":{"pending":5,"leased":3,"exhausted":1,"oldest_pending_at_utc":1723456700},"publication":{"pending":4,"unknown":1,"oldest_pending_at_utc":1723456789},"presence":{"pending":2,"unknown":1}}}"#
);
assert!(wire.len() < RHI_DETAILED_STATUS_MAX_UTF8_BYTES);
for forbidden in [
diff --git a/tests/services_hardening_wave_100_a.rs b/tests/services_hardening_wave_100_a.rs
@@ -79,6 +79,8 @@ fn executable_has_no_prototype_runtime_fallback() {
);
}
assert!(main.contains("parse_rhi_cli_v1_from"));
- assert!(main.contains("resolve_rhi_runtime_context"));
- assert!(main.contains("Err(())"));
+ assert!(main.contains("execute_rhi_cli_v1_with_signal_source"));
+ assert!(main.contains("RhiProcessResult::InputOrConfiguration"));
+ let process = include_str!("../src/process_v1.rs");
+ assert!(process.contains("resolve_rhi_runtime_context"));
}
diff --git a/tests/source_guards.rs b/tests/source_guards.rs
@@ -174,7 +174,6 @@ fn rhi_wave_one_removes_prototype_runtime_and_selection_authority() {
"std::env::var_os(\"RHI_",
"worker_root",
"nostr_sdk::Client",
- "tokio::signal",
"tracing_appender",
"tracing_subscriber",
] {
@@ -183,6 +182,12 @@ fn rhi_wave_one_removes_prototype_runtime_and_selection_authority() {
"{path} retains removed wave-one authority `{forbidden}`"
);
}
+ if source.contains("tokio::signal") {
+ assert_eq!(
+ path, "src/main.rs",
+ "only the Step213 binary-owned process adapter may observe OS signals"
+ );
+ }
}
}