From d9364e8a4904e447d289c4991aba8bfc8cf65d2c Mon Sep 17 00:00:00 2001
From: Tom Ballard
Date: Wed, 24 Jun 2026 07:23:04 +0000
Subject: [PATCH 1/6] feat(overlay): add tested capture core for the overlay
MVP
The platform-agnostic 'brain' of the Lore capture overlay (Host B of
lore-capture-surfaces), as a staging dir in rac-core to extract to its own
lore-* repo later. Implements the two-gate flow (ADR-077) over three trait seams
(rac / gateway / GitHub): propose (fidelity) then publish, which writes,
validates with rac, and opens a DRAFT pull request only. The Publisher trait has
no approve/merge method, so a host cannot self-approve. Model calls go through a
bring-your-own OpenAI-compatible gateway (ADR-035). Verified by a hermetic cargo
test suite plus a real rac-shell test; net clients compile.
---
lore-overlay/.gitignore | 4 +
lore-overlay/core/Cargo.lock | 1369 ++++++++++++++++++++++++++
lore-overlay/core/Cargo.toml | 18 +
lore-overlay/core/src/config.rs | 50 +
lore-overlay/core/src/error.rs | 36 +
lore-overlay/core/src/flow.rs | 150 +++
lore-overlay/core/src/gateway.rs | 102 ++
lore-overlay/core/src/github.rs | 166 ++++
lore-overlay/core/src/lib.rs | 41 +
lore-overlay/core/src/rac.rs | 84 ++
lore-overlay/core/tests/flow_test.rs | 199 ++++
11 files changed, 2219 insertions(+)
create mode 100644 lore-overlay/.gitignore
create mode 100644 lore-overlay/core/Cargo.lock
create mode 100644 lore-overlay/core/Cargo.toml
create mode 100644 lore-overlay/core/src/config.rs
create mode 100644 lore-overlay/core/src/error.rs
create mode 100644 lore-overlay/core/src/flow.rs
create mode 100644 lore-overlay/core/src/gateway.rs
create mode 100644 lore-overlay/core/src/github.rs
create mode 100644 lore-overlay/core/src/lib.rs
create mode 100644 lore-overlay/core/src/rac.rs
create mode 100644 lore-overlay/core/tests/flow_test.rs
diff --git a/lore-overlay/.gitignore b/lore-overlay/.gitignore
new file mode 100644
index 00000000..8571a1c3
--- /dev/null
+++ b/lore-overlay/.gitignore
@@ -0,0 +1,4 @@
+target/
+node_modules/
+dist/
+*.log
diff --git a/lore-overlay/core/Cargo.lock b/lore-overlay/core/Cargo.lock
new file mode 100644
index 00000000..d82fd8d5
--- /dev/null
+++ b/lore-overlay/core/Cargo.lock
@@ -0,0 +1,1369 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
+
+[[package]]
+name = "cc"
+version = "1.2.65"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96"
+dependencies = [
+ "find-msvc-tools",
+ "shlex",
+]
+
+[[package]]
+name = "cfg-if"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+
+[[package]]
+name = "cfg_aliases"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
+
+[[package]]
+name = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "icu_collections"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "idna"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
+dependencies = [
+ "idna_adapter",
+ "smallvec",
+ "utf8_iter",
+]
+
+[[package]]
+name = "idna_adapter"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lore-capture-core"
+version = "0.0.0"
+dependencies = [
+ "base64",
+ "reqwest",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "memchr"
+version = "2.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
+
+[[package]]
+name = "mio"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha",
+ "rand_core",
+]
+
+[[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",
+]
+
+[[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 = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[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 = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustls"
+version = "0.23.41"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "shlex"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "subtle"
+version = "2.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
+
+[[package]]
+name = "syn"
+version = "2.0.118"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "socket2",
+ "windows-sys 0.61.2",
+]
+
+[[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 = "tower"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
+
+[[package]]
+name = "tower-service"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
+
+[[package]]
+name = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[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"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[package]]
+name = "wasi"
+version = "0.11.1+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
+
+[[package]]
+name = "wasip2"
+version = "1.0.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.75"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.125"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+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 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[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_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[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_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[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_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[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_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[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_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/lore-overlay/core/Cargo.toml b/lore-overlay/core/Cargo.toml
new file mode 100644
index 00000000..44c35dae
--- /dev/null
+++ b/lore-overlay/core/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "lore-capture-core"
+version = "0.0.0"
+edition = "2021"
+license = "MIT"
+description = "Platform-agnostic capture core for the Lore overlay: draft a typed artifact through a bring-your-own gateway, validate it with rac, and PROPOSE it as a draft pull request (never lands it)."
+
+[dependencies]
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+# Real network seams (gateway + GitHub) are only compiled for the desktop app,
+# not for the hermetic core tests, so `cargo test` stays fast and offline.
+reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"], optional = true }
+base64 = { version = "0.22", optional = true }
+
+[features]
+default = []
+net = ["dep:reqwest", "dep:base64"]
diff --git a/lore-overlay/core/src/config.rs b/lore-overlay/core/src/config.rs
new file mode 100644
index 00000000..7e51029f
--- /dev/null
+++ b/lore-overlay/core/src/config.rs
@@ -0,0 +1,50 @@
+use serde::{Deserialize, Serialize};
+
+/// Bring-your-own gateway (ADR-035): any OpenAI-compatible endpoint the operator
+/// controls — a self-hosted LiteLLM proxy, a cloud vendor, or a local model.
+///
+/// The `api_key` is `#[serde(skip)]` so it is never written into a config file or
+/// logged; in the shipped app it is read from the OS secret store at runtime.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct GatewayConfig {
+ /// OpenAI-compatible base URL, e.g. `http://localhost:4000/v1`.
+ pub base_url: String,
+ /// Model name as the gateway knows it.
+ pub model: String,
+ #[serde(skip)]
+ pub api_key: String,
+}
+
+/// The repository a capture proposes into.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct RepoConfig {
+ pub owner: String,
+ pub repo: String,
+ #[serde(default = "default_base_branch")]
+ pub base_branch: String,
+}
+
+fn default_base_branch() -> String {
+ "main".to_string()
+}
+
+/// The overlay's whole persisted configuration.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Config {
+ pub gateway: GatewayConfig,
+ pub repo: RepoConfig,
+ /// Global hotkey, in Tauri accelerator syntax.
+ #[serde(default = "default_hotkey")]
+ pub hotkey: String,
+ /// How to invoke the `rac` engine (bundled, on PATH, or a wrapper).
+ #[serde(default = "default_rac_command")]
+ pub rac_command: String,
+}
+
+fn default_hotkey() -> String {
+ "CmdOrCtrl+Shift+L".to_string()
+}
+
+fn default_rac_command() -> String {
+ "rac".to_string()
+}
diff --git a/lore-overlay/core/src/error.rs b/lore-overlay/core/src/error.rs
new file mode 100644
index 00000000..b0180f80
--- /dev/null
+++ b/lore-overlay/core/src/error.rs
@@ -0,0 +1,36 @@
+use std::fmt;
+
+/// One error type for the whole capture flow, naming which seam failed.
+#[derive(Debug)]
+pub enum CaptureError {
+ /// The `rac` engine seam (schema / new / validate) failed.
+ Rac(String),
+ /// The model gateway seam failed.
+ Gateway(String),
+ /// The git/GitHub publish seam failed.
+ Publish(String),
+ /// A local filesystem operation failed.
+ Io(String),
+ /// Output from a subprocess could not be parsed (e.g. the minted id).
+ Parse(String),
+}
+
+impl fmt::Display for CaptureError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ CaptureError::Rac(m) => write!(f, "rac error: {m}"),
+ CaptureError::Gateway(m) => write!(f, "gateway error: {m}"),
+ CaptureError::Publish(m) => write!(f, "publish error: {m}"),
+ CaptureError::Io(m) => write!(f, "io error: {m}"),
+ CaptureError::Parse(m) => write!(f, "parse error: {m}"),
+ }
+ }
+}
+
+impl std::error::Error for CaptureError {}
+
+impl From for CaptureError {
+ fn from(e: std::io::Error) -> Self {
+ CaptureError::Io(e.to_string())
+ }
+}
diff --git a/lore-overlay/core/src/flow.rs b/lore-overlay/core/src/flow.rs
new file mode 100644
index 00000000..63d35a1e
--- /dev/null
+++ b/lore-overlay/core/src/flow.rs
@@ -0,0 +1,150 @@
+use crate::config::RepoConfig;
+use crate::error::CaptureError;
+use crate::gateway::Gateway;
+use crate::github::{PrResult, ProposalRequest, Publisher};
+use crate::rac::Rac;
+
+/// A proposed artifact, produced from raw intent, awaiting the author's fidelity
+/// confirmation (Gate 1). Nothing has been written or pushed yet.
+#[derive(Clone, Debug)]
+pub struct Proposal {
+ pub artifact_type: String,
+ pub title: String,
+ pub body: String,
+}
+
+/// The result of publishing a confirmed proposal: a **draft** pull request. It is
+/// not landed — an independent maintainer's merge is the trust boundary (Gate 2).
+#[derive(Clone, Debug)]
+pub struct CaptureOutcome {
+ pub path: String,
+ pub minted_id: String,
+ pub pr: PrResult,
+}
+
+/// Orchestrates the capture flow over the three seams. Generic over the traits so
+/// the core is exercised with fakes in tests and with the real clients in the app.
+pub struct CaptureFlow {
+ rac: R,
+ gateway: G,
+ publisher: P,
+ repo: RepoConfig,
+}
+
+impl CaptureFlow {
+ pub fn new(rac: R, gateway: G, publisher: P, repo: RepoConfig) -> Self {
+ Self {
+ rac,
+ gateway,
+ publisher,
+ repo,
+ }
+ }
+
+ pub fn repo(&self) -> &RepoConfig {
+ &self.repo
+ }
+
+ /// Gate-1 preparation: turn raw `intent` into a [`Proposal`]. Reads the real
+ /// schema and drafts through the gateway. No file is written, nothing is
+ /// pushed — the author reviews the proposal next.
+ pub fn propose(&self, artifact_type: &str, intent: &str) -> Result {
+ let schema = self.rac.schema(artifact_type)?;
+ let drafted = self.gateway.draft(artifact_type, &schema, intent)?;
+ Ok(Proposal {
+ artifact_type: artifact_type.to_string(),
+ title: drafted.title,
+ body: drafted.body,
+ })
+ }
+
+ /// After the author confirms the proposal is faithful (Gate 1), scaffold the
+ /// file (minting the id), fill the body while keeping the frontmatter,
+ /// validate, and open a **draft** pull request. Refuses to proceed if the
+ /// publisher ever returns a non-draft PR (Gate 2 is the independent merge).
+ pub fn publish(
+ &self,
+ proposal: &Proposal,
+ dest_path: &str,
+ branch: &str,
+ coauthor_trailer: Option<&str>,
+ ) -> Result {
+ let stdout = self.rac.new_artifact(&proposal.artifact_type, dest_path)?;
+ let minted_id = parse_minted_id(&stdout).ok_or_else(|| {
+ CaptureError::Parse("could not find the minted id in `rac new` output".into())
+ })?;
+
+ let scaffold = std::fs::read_to_string(dest_path)?;
+ let filled = fill_body(&scaffold, &proposal.title, &proposal.body)?;
+ std::fs::write(dest_path, &filled)?;
+
+ // Deterministic close before we propose anything.
+ self.rac.validate(dest_path)?;
+
+ let mut pr_body = format!(
+ "Proposed via the Lore capture overlay. Fidelity confirmed by the author; this is a \
+ **draft** awaiting an independent maintainer's review and merge (ADR-077).\n\n\
+ Artifact: `{path}` ({id})\n",
+ path = dest_path,
+ id = minted_id
+ );
+ if let Some(trailer) = coauthor_trailer {
+ pr_body.push('\n');
+ pr_body.push_str(trailer);
+ pr_body.push('\n');
+ }
+
+ let req = ProposalRequest {
+ branch: branch.to_string(),
+ path: dest_path.to_string(),
+ content: filled,
+ commit_message: format!("capture: propose {}", proposal.title),
+ pr_title: format!("capture: {}", proposal.title),
+ pr_body,
+ };
+ let pr = self.publisher.open_draft_pr(&req)?;
+ if !pr.draft {
+ return Err(CaptureError::Publish(
+ "refusing to proceed: capture must open a DRAFT pull request (ADR-077)".into(),
+ ));
+ }
+ Ok(CaptureOutcome {
+ path: dest_path.to_string(),
+ minted_id,
+ pr,
+ })
+ }
+}
+
+/// Parse the opaque id that `rac new` reports (a line like `ID: RAC-XXXX`, or any
+/// bare `RAC-…` token in the output).
+pub fn parse_minted_id(stdout: &str) -> Option {
+ for line in stdout.lines() {
+ let line = line.trim();
+ if let Some(rest) = line.strip_prefix("ID:") {
+ return Some(rest.trim().to_string());
+ }
+ if let Some(tok) = line.split_whitespace().find(|t| t.starts_with("RAC-")) {
+ return Some(tok.trim_end_matches(['.', ',']).to_string());
+ }
+ }
+ None
+}
+
+/// Replace the scaffold's body with the drafted title + body, keeping the `---`
+/// frontmatter block (which carries the minted id and type) byte-for-byte.
+fn fill_body(scaffold: &str, title: &str, body: &str) -> Result {
+ let rest = scaffold
+ .strip_prefix("---\n")
+ .ok_or_else(|| CaptureError::Parse("scaffold has no frontmatter".into()))?;
+ let end = rest
+ .find("\n---\n")
+ .ok_or_else(|| CaptureError::Parse("scaffold frontmatter is unterminated".into()))?;
+ let frontmatter = &rest[..end];
+ Ok(format!(
+ "---\n{frontmatter}\n---\n# {title}\n\n{body}\n",
+ frontmatter = frontmatter,
+ title = title.trim(),
+ body = body.trim_end()
+ ))
+}
diff --git a/lore-overlay/core/src/gateway.rs b/lore-overlay/core/src/gateway.rs
new file mode 100644
index 00000000..551908c8
--- /dev/null
+++ b/lore-overlay/core/src/gateway.rs
@@ -0,0 +1,102 @@
+use crate::error::CaptureError;
+
+/// A drafted artifact: a human title plus the Markdown body — the sections under
+/// the type's headings, **without** frontmatter (`rac new` owns the frontmatter
+/// and mints the id).
+#[derive(Clone, Debug)]
+pub struct DraftedArtifact {
+ pub title: String,
+ pub body: String,
+}
+
+/// The model seam. The interview/draft step runs in the host behind a
+/// bring-your-own gateway (ADR-002/035/067: AI lives in the host, never the
+/// engine). It is a trait so the core is testable without a real model.
+pub trait Gateway {
+ /// Draft an artifact of `artifact_type` from the author's raw `intent`,
+ /// shaped by the real `schema_json` (so it uses only the schema's sections).
+ fn draft(
+ &self,
+ artifact_type: &str,
+ schema_json: &str,
+ intent: &str,
+ ) -> Result;
+}
+
+/// Real OpenAI-compatible gateway client (LiteLLM / OpenRouter / Azure / Ollama /
+/// vLLM …). Compiled only for the desktop app, behind the `net` feature.
+#[cfg(feature = "net")]
+pub struct OpenAiGateway {
+ base_url: String,
+ api_key: String,
+ model: String,
+ client: reqwest::blocking::Client,
+}
+
+#[cfg(feature = "net")]
+impl OpenAiGateway {
+ pub fn new(cfg: &crate::config::GatewayConfig) -> Self {
+ Self {
+ base_url: cfg.base_url.trim_end_matches('/').to_string(),
+ api_key: cfg.api_key.clone(),
+ model: cfg.model.clone(),
+ client: reqwest::blocking::Client::new(),
+ }
+ }
+}
+
+#[cfg(feature = "net")]
+impl Gateway for OpenAiGateway {
+ fn draft(
+ &self,
+ artifact_type: &str,
+ schema_json: &str,
+ intent: &str,
+ ) -> Result {
+ let system = format!(
+ "You draft a Lore (requirements-as-code) {ty} artifact from the author's words. \
+ Use ONLY the section headings the schema lists; never invent sections, and never \
+ emit frontmatter. Capture only what the author says — where a required section has \
+ no material, write a one-line 'TODO: (gap to confirm)'. Respond with STRICT JSON: \
+ {{\"title\": , \"body\": }}. \
+ Schema: {schema}",
+ ty = artifact_type,
+ schema = schema_json
+ );
+ let payload = serde_json::json!({
+ "model": self.model,
+ "temperature": 0,
+ "response_format": {"type": "json_object"},
+ "messages": [
+ {"role": "system", "content": system},
+ {"role": "user", "content": intent},
+ ],
+ });
+ let resp = self
+ .client
+ .post(format!("{}/chat/completions", self.base_url))
+ .bearer_auth(&self.api_key)
+ .json(&payload)
+ .send()
+ .map_err(|e| CaptureError::Gateway(e.to_string()))?;
+ if !resp.status().is_success() {
+ return Err(CaptureError::Gateway(format!("HTTP {}", resp.status())));
+ }
+ let v: serde_json::Value = resp
+ .json()
+ .map_err(|e| CaptureError::Gateway(e.to_string()))?;
+ let content = v["choices"][0]["message"]["content"]
+ .as_str()
+ .ok_or_else(|| CaptureError::Gateway("no content in gateway response".into()))?;
+ let parsed: serde_json::Value = serde_json::from_str(content)
+ .map_err(|e| CaptureError::Gateway(format!("model did not return JSON: {e}")))?;
+ let title = parsed["title"].as_str().unwrap_or("").trim().to_string();
+ let body = parsed["body"].as_str().unwrap_or("").to_string();
+ if title.is_empty() || body.is_empty() {
+ return Err(CaptureError::Gateway(
+ "model returned an empty title or body".into(),
+ ));
+ }
+ Ok(DraftedArtifact { title, body })
+ }
+}
diff --git a/lore-overlay/core/src/github.rs b/lore-overlay/core/src/github.rs
new file mode 100644
index 00000000..1de6713a
--- /dev/null
+++ b/lore-overlay/core/src/github.rs
@@ -0,0 +1,166 @@
+use crate::error::CaptureError;
+
+/// Everything needed to open a draft pull request proposing one artifact.
+#[derive(Clone, Debug)]
+pub struct ProposalRequest {
+ /// New branch to create off the base branch.
+ pub branch: String,
+ /// Repo-relative path of the artifact file.
+ pub path: String,
+ /// Full file content to commit.
+ pub content: String,
+ pub commit_message: String,
+ pub pr_title: String,
+ pub pr_body: String,
+}
+
+/// The opened pull request.
+#[derive(Clone, Debug)]
+pub struct PrResult {
+ pub url: String,
+ pub number: u64,
+ pub draft: bool,
+}
+
+/// The write seam. A capture host only ever **proposes**: it opens a draft pull
+/// request and never approves or merges (ADR-065 / ADR-077). The trait has no
+/// approve or merge method by construction, so the two-gate model cannot be
+/// violated by a host that uses this core.
+pub trait Publisher {
+ fn open_draft_pr(&self, req: &ProposalRequest) -> Result;
+}
+
+/// Real GitHub publisher over the REST API. Compiled only for the desktop app,
+/// behind the `net` feature.
+///
+/// It is given a bearer `token` (a GitHub App installation token in the shipped
+/// app; a PAT is fine for development). Obtaining that token — the desktop
+/// device-flow install — is the shell's job, recorded as an open question in the
+/// `lore-capture-overlay` design.
+#[cfg(feature = "net")]
+pub struct GithubPublisher {
+ owner: String,
+ repo: String,
+ base_branch: String,
+ token: String,
+ api_base: String,
+ client: reqwest::blocking::Client,
+}
+
+#[cfg(feature = "net")]
+impl GithubPublisher {
+ pub fn new(
+ owner: impl Into,
+ repo: impl Into,
+ base_branch: impl Into,
+ token: impl Into,
+ ) -> Self {
+ Self {
+ owner: owner.into(),
+ repo: repo.into(),
+ base_branch: base_branch.into(),
+ token: token.into(),
+ api_base: "https://api.github.com".to_string(),
+ client: reqwest::blocking::Client::new(),
+ }
+ }
+
+ fn get(&self, path: &str) -> reqwest::blocking::RequestBuilder {
+ self.client
+ .get(format!("{}{}", self.api_base, path))
+ .bearer_auth(&self.token)
+ .header("Accept", "application/vnd.github+json")
+ .header("User-Agent", "lore-capture-overlay")
+ }
+
+ fn post(&self, path: &str) -> reqwest::blocking::RequestBuilder {
+ self.client
+ .post(format!("{}{}", self.api_base, path))
+ .bearer_auth(&self.token)
+ .header("Accept", "application/vnd.github+json")
+ .header("User-Agent", "lore-capture-overlay")
+ }
+
+ fn put(&self, path: &str) -> reqwest::blocking::RequestBuilder {
+ self.client
+ .put(format!("{}{}", self.api_base, path))
+ .bearer_auth(&self.token)
+ .header("Accept", "application/vnd.github+json")
+ .header("User-Agent", "lore-capture-overlay")
+ }
+}
+
+#[cfg(feature = "net")]
+impl Publisher for GithubPublisher {
+ fn open_draft_pr(&self, req: &ProposalRequest) -> Result {
+ use base64::Engine as _;
+ let err = |e: reqwest::Error| CaptureError::Publish(e.to_string());
+
+ // 1. Base branch tip sha.
+ let base_ref = self
+ .get(&format!(
+ "/repos/{}/{}/git/ref/heads/{}",
+ self.owner, self.repo, self.base_branch
+ ))
+ .send()
+ .map_err(err)?
+ .error_for_status()
+ .map_err(err)?
+ .json::()
+ .map_err(err)?;
+ let base_sha = base_ref["object"]["sha"]
+ .as_str()
+ .ok_or_else(|| CaptureError::Publish("no base sha".into()))?
+ .to_string();
+
+ // 2. Create the work branch.
+ self.post(&format!("/repos/{}/{}/git/refs", self.owner, self.repo))
+ .json(&serde_json::json!({
+ "ref": format!("refs/heads/{}", req.branch),
+ "sha": base_sha,
+ }))
+ .send()
+ .map_err(err)?
+ .error_for_status()
+ .map_err(err)?;
+
+ // 3. Write the file on the new branch.
+ let content_b64 = base64::engine::general_purpose::STANDARD.encode(req.content.as_bytes());
+ self.put(&format!(
+ "/repos/{}/{}/contents/{}",
+ self.owner, self.repo, req.path
+ ))
+ .json(&serde_json::json!({
+ "message": req.commit_message,
+ "content": content_b64,
+ "branch": req.branch,
+ }))
+ .send()
+ .map_err(err)?
+ .error_for_status()
+ .map_err(err)?;
+
+ // 4. Open a DRAFT pull request. Never approves or merges.
+ let pr = self
+ .post(&format!("/repos/{}/{}/pulls", self.owner, self.repo))
+ .json(&serde_json::json!({
+ "title": req.pr_title,
+ "body": req.pr_body,
+ "head": req.branch,
+ "base": self.base_branch,
+ "draft": true,
+ }))
+ .send()
+ .map_err(err)?
+ .error_for_status()
+ .map_err(err)?
+ .json::()
+ .map_err(err)?;
+
+ Ok(PrResult {
+ url: pr["html_url"].as_str().unwrap_or_default().to_string(),
+ number: pr["number"].as_u64().unwrap_or_default(),
+ draft: pr["draft"].as_bool().unwrap_or(false),
+ })
+ }
+}
diff --git a/lore-overlay/core/src/lib.rs b/lore-overlay/core/src/lib.rs
new file mode 100644
index 00000000..2b8f9933
--- /dev/null
+++ b/lore-overlay/core/src/lib.rs
@@ -0,0 +1,41 @@
+//! `lore-capture-core` — the platform-agnostic "brain" of the Lore capture
+//! overlay (provisional name; see `lore-overlay/README.md`).
+//!
+//! It implements the capture flow that the desktop shell (Tauri) wraps, and that
+//! any other host could reuse: take an author's raw intent, draft a typed
+//! artifact through a bring-your-own gateway, validate it deterministically with
+//! the `rac` engine, and **propose** it as a draft pull request. It never lands
+//! anything.
+//!
+//! ## The two-gate write model (ADR-077)
+//!
+//! - **Gate 1 — fidelity.** [`CaptureFlow::propose`] turns intent into a
+//! [`Proposal`] the author confirms in the host UI. This is a data-quality
+//! check, not a trust boundary; no file is written and nothing is pushed.
+//! - **Gate 2 — trust boundary.** [`CaptureFlow::publish`] writes the artifact,
+//! validates it, and opens a **draft** pull request. The independent
+//! maintainer's merge is the trust boundary. The [`Publisher`] trait has no
+//! approve/merge method by construction — the host can only *propose*.
+//!
+//! The model call (gateway) and git writes (publisher) live behind traits so the
+//! core is testable offline; the concrete network implementations are compiled
+//! only under the `net` feature.
+
+mod config;
+mod error;
+mod flow;
+mod gateway;
+mod github;
+mod rac;
+
+pub use config::{Config, GatewayConfig, RepoConfig};
+pub use error::CaptureError;
+pub use flow::{parse_minted_id, CaptureFlow, CaptureOutcome, Proposal};
+pub use gateway::{DraftedArtifact, Gateway};
+pub use github::{PrResult, ProposalRequest, Publisher};
+pub use rac::{Rac, RacClient};
+
+#[cfg(feature = "net")]
+pub use gateway::OpenAiGateway;
+#[cfg(feature = "net")]
+pub use github::GithubPublisher;
diff --git a/lore-overlay/core/src/rac.rs b/lore-overlay/core/src/rac.rs
new file mode 100644
index 00000000..c07b3f5b
--- /dev/null
+++ b/lore-overlay/core/src/rac.rs
@@ -0,0 +1,84 @@
+use crate::error::CaptureError;
+use std::process::Command;
+
+/// The deterministic engine seam. The core shells to the `rac` CLI rather than
+/// reimplementing classification or validation (ADR-063: thin client over the
+/// published contract; ADR-002/067: no AI in the engine).
+pub trait Rac {
+ /// The JSON schema for an artifact type (`rac schema --json`).
+ fn schema(&self, artifact_type: &str) -> Result;
+ /// Scaffold a new artifact (`rac new `), minting the opaque id.
+ /// Returns the command's stdout, which reports the minted id.
+ fn new_artifact(&self, artifact_type: &str, path: &str) -> Result;
+ /// Deterministic close (`rac validate `). `Ok(())` iff the file is valid.
+ fn validate(&self, path: &str) -> Result<(), CaptureError>;
+}
+
+/// Shells out to a real `rac` binary.
+///
+/// `program` plus `base_args` are prefixed before every subcommand, so a wrapper
+/// invocation (`env PYTHONPATH=… python racrun.py`) works as well as a bare
+/// `rac` on `PATH`.
+#[derive(Clone, Debug)]
+pub struct RacClient {
+ program: String,
+ base_args: Vec,
+}
+
+impl RacClient {
+ pub fn new(program: impl Into) -> Self {
+ Self {
+ program: program.into(),
+ base_args: Vec::new(),
+ }
+ }
+
+ /// Extra args inserted before every subcommand.
+ pub fn with_base_args(mut self, args: Vec) -> Self {
+ self.base_args = args;
+ self
+ }
+
+ fn run(&self, args: &[&str]) -> Result {
+ Command::new(&self.program)
+ .args(&self.base_args)
+ .args(args)
+ .output()
+ .map_err(|e| CaptureError::Rac(format!("failed to run `{}`: {e}", self.program)))
+ }
+}
+
+impl Rac for RacClient {
+ fn schema(&self, artifact_type: &str) -> Result {
+ let out = self.run(&["schema", artifact_type, "--json"])?;
+ if !out.status.success() {
+ return Err(CaptureError::Rac(stderr(&out)));
+ }
+ Ok(String::from_utf8_lossy(&out.stdout).into_owned())
+ }
+
+ fn new_artifact(&self, artifact_type: &str, path: &str) -> Result {
+ let out = self.run(&["new", artifact_type, path])?;
+ if !out.status.success() {
+ return Err(CaptureError::Rac(stderr(&out)));
+ }
+ Ok(String::from_utf8_lossy(&out.stdout).into_owned())
+ }
+
+ fn validate(&self, path: &str) -> Result<(), CaptureError> {
+ let out = self.run(&["validate", path])?;
+ if out.status.success() {
+ Ok(())
+ } else {
+ Err(CaptureError::Rac(format!(
+ "{}{}",
+ String::from_utf8_lossy(&out.stdout),
+ stderr(&out)
+ )))
+ }
+ }
+}
+
+fn stderr(out: &std::process::Output) -> String {
+ String::from_utf8_lossy(&out.stderr).into_owned()
+}
diff --git a/lore-overlay/core/tests/flow_test.rs b/lore-overlay/core/tests/flow_test.rs
new file mode 100644
index 00000000..fd20f1d4
--- /dev/null
+++ b/lore-overlay/core/tests/flow_test.rs
@@ -0,0 +1,199 @@
+//! Hermetic tests for the capture flow.
+//!
+//! The three external seams (rac / gateway / GitHub) are faked, so the suite runs
+//! offline and deterministically while still exercising the real logic: schema →
+//! draft → scaffold → fill-keeping-frontmatter → validate → open a *draft* PR.
+//! One extra test exercises the real `RacClient` shell when `LORE_TEST_RAC` is set.
+
+use lore_capture_core::{
+ parse_minted_id, CaptureError, CaptureFlow, DraftedArtifact, Gateway, PrResult,
+ ProposalRequest, Publisher, Rac, RacClient, RepoConfig,
+};
+use std::cell::RefCell;
+
+/// Emulates `rac new` by writing a real scaffold (frontmatter + minted id), and
+/// `rac validate` by a trivial structural check — enough to drive the flow.
+struct FakeRac;
+impl Rac for FakeRac {
+ fn schema(&self, _t: &str) -> Result {
+ Ok(r#"{"required":["context","decision","consequences"]}"#.to_string())
+ }
+ fn new_artifact(&self, _t: &str, path: &str) -> Result {
+ let scaffold =
+ "---\nschema_version: 1\nid: RAC-FAKE12345\ntype: decision\n---\n# Title\n\n## Context\n\nTODO\n";
+ std::fs::write(path, scaffold)?;
+ Ok("Created decision artifact: x\nID: RAC-FAKE12345\n".to_string())
+ }
+ fn validate(&self, path: &str) -> Result<(), CaptureError> {
+ let s = std::fs::read_to_string(path)?;
+ if s.starts_with("---\n") && s.contains("\n# ") {
+ Ok(())
+ } else {
+ Err(CaptureError::Rac("invalid".into()))
+ }
+ }
+}
+
+struct FakeGateway;
+impl Gateway for FakeGateway {
+ fn draft(
+ &self,
+ _t: &str,
+ _schema: &str,
+ intent: &str,
+ ) -> Result {
+ Ok(DraftedArtifact {
+ title: "ADR-099: Example Decision".to_string(),
+ body: format!(
+ "## Context\n\n{intent}\n\n## Decision\n\nWe will do the thing.\n\n## Consequences\n\nTrade-offs accepted."
+ ),
+ })
+ }
+}
+
+/// Records the request it was asked to publish, and always returns a draft PR.
+struct FakePublisher {
+ seen: RefCell
-
+
+
+
Body
+
+
+
+
+
+
+
+
+
+
diff --git a/lore-overlay/app/src/main.js b/lore-overlay/app/src/main.js
index 09ed0ff6..0a3c9e9c 100644
--- a/lore-overlay/app/src/main.js
+++ b/lore-overlay/app/src/main.js
@@ -13,7 +13,8 @@ $("propose").addEventListener("click", async () => {
// Gate 1 prep: propose. No file is written yet.
current = await invoke("propose", { artifactType: "decision", intent });
$("title").value = current.title;
- $("body").value = current.body;
+ $("body").value = current.body; // the textarea stays the source of truth
+ showPreview(); // default to the rendered view
$("capture").hidden = true;
$("review").hidden = false;
setResult("");
@@ -27,6 +28,86 @@ $("back").addEventListener("click", () => {
$("capture").hidden = false;
});
+// --- Body view toggle: rendered "Preview" (default) vs raw "Edit" -----------
+
+function showPreview() {
+ $("body-preview").innerHTML = renderMarkdown($("body").value);
+ $("body-preview").hidden = false;
+ $("body").hidden = true;
+ $("tab-preview").setAttribute("aria-selected", "true");
+ $("tab-edit").setAttribute("aria-selected", "false");
+}
+
+function showEdit() {
+ $("body").hidden = false;
+ $("body-preview").hidden = true;
+ $("tab-edit").setAttribute("aria-selected", "true");
+ $("tab-preview").setAttribute("aria-selected", "false");
+ $("body").focus();
+}
+
+$("tab-preview").addEventListener("click", showPreview);
+$("tab-edit").addEventListener("click", showEdit);
+
+// Minimal, dependency-free markdown → HTML. Artifact content is untrusted
+// (ADR-065), so escape first, then apply a small, safe subset.
+function renderMarkdown(src) {
+ const esc = src.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
+ const inline = (s) =>
+ s
+ .replace(/`([^`]+)`/g, "
$1")
+ .replace(/\*\*([^*]+)\*\*/g, "
$1")
+ .replace(/\*([^*]+)\*/g, "
$1");
+ let html = "";
+ let list = null;
+ let para = [];
+ const closeList = () => {
+ if (list) {
+ html += `${list}>`;
+ list = null;
+ }
+ };
+ const flushPara = () => {
+ if (para.length) {
+ html += `
${inline(para.join(" "))}
`;
+ para = [];
+ }
+ };
+ for (const line of esc.split("\n")) {
+ let m;
+ if (/^\s*$/.test(line)) {
+ flushPara();
+ closeList();
+ } else if ((m = line.match(/^(#{1,6})\s+(.*)$/))) {
+ flushPara();
+ closeList();
+ html += `
${inline(m[2])}`;
+ } else if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
+ flushPara();
+ if (list !== "ul") {
+ closeList();
+ html += "
";
+ list = "ul";
+ }
+ html += `- ${inline(m[1])}
`;
+ } else if ((m = line.match(/^\s*\d+\.\s+(.*)$/))) {
+ flushPara();
+ if (list !== "ol") {
+ closeList();
+ html += "";
+ list = "ol";
+ }
+ html += `- ${inline(m[1])}
`;
+ } else {
+ closeList();
+ para.push(line.trim());
+ }
+ }
+ flushPara();
+ closeList();
+ return html;
+}
+
$("publish").addEventListener("click", async () => {
if (!current) return;
const title = $("title").value.trim();
diff --git a/lore-overlay/app/src/styles.css b/lore-overlay/app/src/styles.css
index 3f97d95f..c433086e 100644
--- a/lore-overlay/app/src/styles.css
+++ b/lore-overlay/app/src/styles.css
@@ -58,6 +58,76 @@ button.primary {
opacity: 0.8;
margin: 0 0 8px;
}
+.bodyfield {
+ margin: 8px 0;
+}
+.bodyhead {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 4px;
+}
+.bodyhead .lbl {
+ font-weight: 600;
+}
+.seg {
+ display: inline-flex;
+ border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
+ border-radius: 7px;
+ overflow: hidden;
+}
+.seg-btn {
+ border: 0;
+ border-radius: 0;
+ padding: 3px 10px;
+ font-size: 12px;
+ opacity: 0.7;
+}
+.seg-btn[aria-selected="true"] {
+ background: color-mix(in srgb, currentColor 12%, transparent);
+ opacity: 1;
+ font-weight: 600;
+}
+/* Rendered markdown — deliberately quiet, so it reads as normal prose. */
+.rendered {
+ border: 1px solid color-mix(in srgb, currentColor 25%, transparent);
+ border-radius: 6px;
+ padding: 8px 10px;
+ min-height: 196px;
+ max-height: 280px;
+ overflow-y: auto;
+}
+.rendered h1,
+.rendered h2,
+.rendered h3 {
+ font-size: 13px;
+ font-weight: 700;
+ margin: 12px 0 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.3px;
+ opacity: 0.65;
+}
+.rendered > :first-child {
+ margin-top: 0;
+}
+.rendered p {
+ margin: 0 0 8px;
+}
+.rendered ul,
+.rendered ol {
+ margin: 0 0 8px;
+ padding-left: 20px;
+}
+.rendered li {
+ margin: 2px 0;
+}
+.rendered code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.9em;
+ padding: 1px 4px;
+ border-radius: 4px;
+ background: color-mix(in srgb, currentColor 10%, transparent);
+}
#result {
margin-top: 12px;
font-size: 13px;
From df939db36f3518a50e321b8ffe8b2543315d1f66 Mon Sep 17 00:00:00 2001
From: Tom Ballard
Date: Wed, 24 Jun 2026 16:57:41 +0000
Subject: [PATCH 4/6] feat(overlay): render the body as full CommonMark
Replaces the hand-rolled markdown subset with markdown-it for full CommonMark
fidelity in the Gate-1 body preview. Output is sanitized with DOMPurify before
it touches innerHTML and markdown-it runs with html:false, keeping artifact
content treated as untrusted input (ADR-065). Extends the rendered-view styles
to cover blockquotes, fenced code, tables, links, images, and rules.
---
lore-overlay/README.md | 11 ++++++
lore-overlay/app/package.json | 4 +-
lore-overlay/app/src/main.js | 70 ++++++---------------------------
lore-overlay/app/src/styles.css | 48 +++++++++++++++++++++-
4 files changed, 74 insertions(+), 59 deletions(-)
diff --git a/lore-overlay/README.md b/lore-overlay/README.md
index b1ff1af9..573c011f 100644
--- a/lore-overlay/README.md
+++ b/lore-overlay/README.md
@@ -49,6 +49,17 @@ The model call goes through a configurable **OpenAI-compatible** endpoint
local model. No AI runs in the engine (ADR-002/067); the key lives in the OS
secret store, not in any serialized config.
+### Rendered body (Gate 1)
+
+The review step shows the drafted body as **rendered CommonMark** by default, with
+a one-click **Edit** toggle back to the raw source (the textarea stays the source
+of truth for publish). Rendering uses **markdown-it** (`html: false`) and the
+output is sanitized with **DOMPurify** before it touches `innerHTML` — artifact
+content is untrusted input (ADR-065), so the render path must not become an
+injection vector. These are the app's only frontend dependencies; `npm install`
+pulls them, and the frontend needs a bundler (e.g. Vite) to resolve the
+bare-module imports.
+
## What is verified vs not
This MVP was developed in a Linux container, which **cannot build, run, sign, or
diff --git a/lore-overlay/app/package.json b/lore-overlay/app/package.json
index 41c15941..de478d81 100644
--- a/lore-overlay/app/package.json
+++ b/lore-overlay/app/package.json
@@ -8,7 +8,9 @@
"tauri": "tauri"
},
"dependencies": {
- "@tauri-apps/api": "^2"
+ "@tauri-apps/api": "^2",
+ "dompurify": "^3",
+ "markdown-it": "^14"
},
"devDependencies": {
"@tauri-apps/cli": "^2"
diff --git a/lore-overlay/app/src/main.js b/lore-overlay/app/src/main.js
index 0a3c9e9c..69db9d63 100644
--- a/lore-overlay/app/src/main.js
+++ b/lore-overlay/app/src/main.js
@@ -1,6 +1,16 @@
// Minimal two-gate capture UI. Authored scaffold — needs a bundler (e.g. Vite)
-// to resolve the @tauri-apps/api import; see ../../README.md.
+// to resolve the bare-module imports; see ../../README.md.
import { invoke } from "@tauri-apps/api/core";
+import MarkdownIt from "markdown-it";
+import DOMPurify from "dompurify";
+
+// Full CommonMark rendering for the body preview. `html: false` escapes any raw
+// HTML embedded in the artifact rather than passing it through, and the output is
+// run through DOMPurify before it touches innerHTML — artifact content is
+// untrusted input (ADR-065), so the render path must not become an injection
+// vector. `linkify` turns bare URLs into links; markdown-it's own validateLink
+// already blocks javascript:/vbscript:/data: schemes.
+const md = new MarkdownIt({ html: false, linkify: true, typographer: true });
const $ = (id) => document.getElementById(id);
let current = null; // the proposed { artifact_type, title, body }
@@ -49,63 +59,9 @@ function showEdit() {
$("tab-preview").addEventListener("click", showPreview);
$("tab-edit").addEventListener("click", showEdit);
-// Minimal, dependency-free markdown → HTML. Artifact content is untrusted
-// (ADR-065), so escape first, then apply a small, safe subset.
+// CommonMark → sanitized HTML for the rendered body view.
function renderMarkdown(src) {
- const esc = src.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
- const inline = (s) =>
- s
- .replace(/`([^`]+)`/g, "$1")
- .replace(/\*\*([^*]+)\*\*/g, "$1")
- .replace(/\*([^*]+)\*/g, "$1");
- let html = "";
- let list = null;
- let para = [];
- const closeList = () => {
- if (list) {
- html += `${list}>`;
- list = null;
- }
- };
- const flushPara = () => {
- if (para.length) {
- html += `${inline(para.join(" "))}
`;
- para = [];
- }
- };
- for (const line of esc.split("\n")) {
- let m;
- if (/^\s*$/.test(line)) {
- flushPara();
- closeList();
- } else if ((m = line.match(/^(#{1,6})\s+(.*)$/))) {
- flushPara();
- closeList();
- html += `${inline(m[2])}`;
- } else if ((m = line.match(/^\s*[-*]\s+(.*)$/))) {
- flushPara();
- if (list !== "ul") {
- closeList();
- html += "";
- list = "ul";
- }
- html += `- ${inline(m[1])}
`;
- } else if ((m = line.match(/^\s*\d+\.\s+(.*)$/))) {
- flushPara();
- if (list !== "ol") {
- closeList();
- html += "";
- list = "ol";
- }
- html += `- ${inline(m[1])}
`;
- } else {
- closeList();
- para.push(line.trim());
- }
- }
- flushPara();
- closeList();
- return html;
+ return DOMPurify.sanitize(md.render(src));
}
$("publish").addEventListener("click", async () => {
diff --git a/lore-overlay/app/src/styles.css b/lore-overlay/app/src/styles.css
index c433086e..e2d17e45 100644
--- a/lore-overlay/app/src/styles.css
+++ b/lore-overlay/app/src/styles.css
@@ -99,7 +99,10 @@ button.primary {
}
.rendered h1,
.rendered h2,
-.rendered h3 {
+.rendered h3,
+.rendered h4,
+.rendered h5,
+.rendered h6 {
font-size: 13px;
font-weight: 700;
margin: 12px 0 4px;
@@ -121,6 +124,38 @@ button.primary {
.rendered li {
margin: 2px 0;
}
+.rendered li > ul,
+.rendered li > ol {
+ margin: 2px 0;
+}
+.rendered a {
+ color: #2563eb;
+}
+.rendered img {
+ max-width: 100%;
+ border-radius: 6px;
+}
+.rendered hr {
+ border: 0;
+ border-top: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+ margin: 12px 0;
+}
+.rendered blockquote {
+ margin: 0 0 8px;
+ padding: 2px 0 2px 10px;
+ border-left: 3px solid color-mix(in srgb, currentColor 25%, transparent);
+ opacity: 0.85;
+}
+.rendered table {
+ border-collapse: collapse;
+ margin: 0 0 8px;
+}
+.rendered th,
+.rendered td {
+ border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
+ padding: 3px 8px;
+ text-align: left;
+}
.rendered code {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 0.9em;
@@ -128,6 +163,17 @@ button.primary {
border-radius: 4px;
background: color-mix(in srgb, currentColor 10%, transparent);
}
+.rendered pre {
+ margin: 0 0 8px;
+ padding: 8px 10px;
+ overflow-x: auto;
+ border-radius: 6px;
+ background: color-mix(in srgb, currentColor 8%, transparent);
+}
+.rendered pre code {
+ padding: 0;
+ background: none;
+}
#result {
margin-top: 12px;
font-size: 13px;
From 6350c6964878c2f81286dd4c51383794c4619f24 Mon Sep 17 00:00:00 2001
From: Tom Ballard
Date: Wed, 24 Jun 2026 17:16:10 +0000
Subject: [PATCH 5/6] feat(overlay): configurable write modes (per-capture,
rolling, direct)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Decouples PR granularity from capture cadence. The PR is the trust boundary
(ADR-077 Gate 2), not the save action, so its granularity is configurable
without weakening the model:
- per-capture: a branch and draft PR per capture (current behaviour)
- rolling (default): append to a shared capture branch with one batch draft
PR, so a maintainer reviews on their cadence, not per artifact
- direct: commit to a branch, no PR (a solo repo with no independent reviewer)
Splits the Publisher seam into commit_file + ensure_draft_pr — still no
approve/merge method by construction, so the two-gate property holds in every
mode. Adds a read-only footer showing where a capture lands (repo, branch,
mode). Hermetic tests cover all three modes (6 passing).
---
lore-overlay/app/src-tauri/src/lib.rs | 76 +++++++--
lore-overlay/app/src/index.html | 9 ++
lore-overlay/app/src/main.js | 26 ++-
lore-overlay/app/src/styles.css | 19 +++
lore-overlay/core/src/config.rs | 31 ++++
lore-overlay/core/src/flow.rs | 96 ++++++++----
lore-overlay/core/src/github.rs | 218 ++++++++++++++++++--------
lore-overlay/core/src/lib.rs | 13 +-
lore-overlay/core/tests/flow_test.rs | 156 +++++++++++++++---
9 files changed, 506 insertions(+), 138 deletions(-)
diff --git a/lore-overlay/app/src-tauri/src/lib.rs b/lore-overlay/app/src-tauri/src/lib.rs
index 11fc43f5..bc5df898 100644
--- a/lore-overlay/app/src-tauri/src/lib.rs
+++ b/lore-overlay/app/src-tauri/src/lib.rs
@@ -12,7 +12,7 @@
use lore_capture_core::{
CaptureFlow, Config, GatewayConfig, GithubPublisher, OpenAiGateway, Proposal, RacClient,
- RepoConfig,
+ RepoConfig, WriteMode,
};
use tauri::{Manager, WebviewWindow};
@@ -35,6 +35,33 @@ fn load_config() -> Config {
},
hotkey: std::env::var("LORE_HOTKEY").unwrap_or_else(|_| "CmdOrCtrl+Shift+L".into()),
rac_command: std::env::var("LORE_RAC").unwrap_or_else(|_| "rac".into()),
+ write_mode: match std::env::var("LORE_WRITE_MODE").as_deref() {
+ Ok("per-capture") => WriteMode::PerCapture,
+ Ok("direct") => WriteMode::Direct,
+ _ => WriteMode::Rolling, // default: batch draft PR
+ },
+ capture_branch: std::env::var("LORE_CAPTURE_BRANCH")
+ .unwrap_or_else(|_| "capture/inbox".into()),
+ }
+}
+
+/// The branch a capture in the current mode targets (for `PerCapture`, the slug
+/// is appended at publish time, so this is a label).
+fn target_branch(cfg: &Config, slug: Option<&str>) -> String {
+ match cfg.write_mode {
+ WriteMode::PerCapture => match slug {
+ Some(s) => format!("capture/{s}"),
+ None => "capture/".to_string(),
+ },
+ WriteMode::Rolling | WriteMode::Direct => cfg.capture_branch.clone(),
+ }
+}
+
+fn mode_label(mode: WriteMode) -> &'static str {
+ match mode {
+ WriteMode::PerCapture => "draft PR per capture",
+ WriteMode::Rolling => "batch draft PR",
+ WriteMode::Direct => "commit only",
}
}
@@ -52,12 +79,7 @@ fn build_flow(cfg: &Config) -> CaptureFlow Result TargetView {
+ let cfg = load_config();
+ TargetView {
+ repo: format!("{}/{}", cfg.repo.owner, cfg.repo.repo),
+ branch: target_branch(&cfg, None),
+ mode: mode_label(cfg.write_mode).to_string(),
+ opens_pr: cfg.write_mode != WriteMode::Direct,
+ }
+}
+
+/// Gate 2 prep: after the author confirms, write + validate + commit, and (unless
+/// the mode is `Direct`) ensure a DRAFT pull request. The core refuses any
+/// non-draft PR; the independent merge is Gate 2. `slug` derives the per-capture
+/// branch; it is ignored in rolling/direct modes.
#[tauri::command]
#[allow(clippy::too_many_arguments)]
fn publish(
@@ -102,7 +149,7 @@ fn publish(
title: String,
body: String,
dest_path: String,
- branch: String,
+ slug: String,
coauthor: Option,
) -> Result {
let cfg = load_config();
@@ -112,13 +159,16 @@ fn publish(
title,
body,
};
+ let branch = target_branch(&cfg, Some(&slug));
let outcome = flow
- .publish(&proposal, &dest_path, &branch, coauthor.as_deref())
+ .publish(&proposal, &dest_path, &branch, cfg.write_mode, coauthor.as_deref())
.map_err(|e| e.to_string())?;
Ok(OutcomeView {
path: outcome.path,
minted_id: outcome.minted_id,
- pr_url: outcome.pr.url,
+ branch: outcome.branch,
+ mode: mode_label(outcome.mode).to_string(),
+ pr_url: outcome.pr.map(|p| p.url).unwrap_or_default(),
})
}
@@ -158,7 +208,7 @@ pub fn run() {
// steals focus from the app the author is working in.
Ok(())
})
- .invoke_handler(tauri::generate_handler![propose, publish])
+ .invoke_handler(tauri::generate_handler![propose, publish, capture_target])
.run(tauri::generate_context!())
.expect("error while running the Lore capture overlay");
}
diff --git a/lore-overlay/app/src/index.html b/lore-overlay/app/src/index.html
index 029a2aff..65624d16 100644
--- a/lore-overlay/app/src/index.html
+++ b/lore-overlay/app/src/index.html
@@ -46,6 +46,15 @@ Capture a decision
+
+
+