From 89cd9f0a12f08510adebfadde3f14f2835d418b7 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 09:00:06 -0700 Subject: [PATCH 01/14] feat(keep): prove the experimental Echo content identity bridge --- .github/workflows/echo-keep-experimental.yml | 30 ++ CHANGELOG.md | 4 + .../echo-keep-physical-content-boundary.md | 3 +- experiments/echo-keep/Cargo.lock | 478 ++++++++++++++++++ experiments/echo-keep/Cargo.toml | 19 + experiments/echo-keep/README.md | 21 + experiments/echo-keep/src/lib.rs | 109 ++++ experiments/echo-keep/src/tests.rs | 140 +++++ 8 files changed, 802 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/echo-keep-experimental.yml create mode 100644 experiments/echo-keep/Cargo.lock create mode 100644 experiments/echo-keep/Cargo.toml create mode 100644 experiments/echo-keep/README.md create mode 100644 experiments/echo-keep/src/lib.rs create mode 100644 experiments/echo-keep/src/tests.rs diff --git a/.github/workflows/echo-keep-experimental.yml b/.github/workflows/echo-keep-experimental.yml new file mode 100644 index 000000000..c82b1e19d --- /dev/null +++ b/.github/workflows/echo-keep-experimental.yml @@ -0,0 +1,30 @@ +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +name: Experimental Keep contract +on: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + identity: + name: Echo–Keep experimental conformance + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + CARGO_INCREMENTAL: 0 + CARGO_BUILD_JOBS: 2 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.96.0 + with: + components: rustfmt, clippy + - run: cargo test --manifest-path experiments/echo-keep/Cargo.toml --locked + - run: cargo clippy --manifest-path experiments/echo-keep/Cargo.toml --locked --all-targets -- -D warnings + - run: cargo fmt --manifest-path experiments/echo-keep/Cargo.toml -- --check + - name: Preserve the default CAS toolchain and dependency boundary + run: | + rustup toolchain install 1.90.0 --profile minimal + cargo +1.90.0 check --locked -p echo-cas + if cargo tree --locked -p echo-cas | grep -q "keep v"; then exit 1; fi diff --git a/CHANGELOG.md b/CHANGELOG.md index 571990e85..bb72c2b94 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ ## Unreleased +### Added + +- An isolated experimental Echo–Keep identity bridge checks both independent hash laws and exact byte length from one bounded stream. It leaves the default CAS dependency graph unchanged. + ### Fixed - The generic operation runner names typed obstruction kinds, footprint conflicts, and missing outcomes in bounded summaries. It omits raw outcome records and invocation data on both Action error paths. diff --git a/docs/architecture/echo-keep-physical-content-boundary.md b/docs/architecture/echo-keep-physical-content-boundary.md index 0ab8507fc..c2dcefd93 100644 --- a/docs/architecture/echo-keep-physical-content-boundary.md +++ b/docs/architecture/echo-keep-physical-content-boundary.md @@ -6,8 +6,7 @@ - **Status:** Accepted for experimental conformance; production adoption is not accepted. - **Decision date:** 2026-08-09 -- **Implementation posture:** No Echo physical-content port or Keep adapter is - implemented on this branch. +- **Implementation posture:** `experiments/echo-keep` implements a bounded dual-identity bridge against Keep revision `3165890e9291cfb5fe10e81a9d7cd151f3e59464`. The package is a separate Rust 1.96 workspace. No physical-content port or backend adapter is implemented yet. Echo CAS remains the default. - **Refines:** [Retained reading storage and proof boundary](../adr/0020-retained-reading-storage-and-proof-boundary.md) - **Depends on:** [Durable external-action settlement](../adr/0026-durable-external-action-settlement.md) - **Related:** [Keep authenticated reconstruction contract](https://github.com/flyingrobots/keep/blob/3bf7b9179db41e90620e6d1875c2d40222a2330b/docs/architecture/authenticated-reconstruction-contract.md) diff --git a/experiments/echo-keep/Cargo.lock b/experiments/echo-keep/Cargo.lock new file mode 100644 index 000000000..522df29b9 --- /dev/null +++ b/experiments/echo-keep/Cargo.lock @@ -0,0 +1,478 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + +[[package]] +name = "cap-fs-ext" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78e5a3368ae89b7cb68186411452b4b9fac8b41be9c19bf3f47c2d2c8e36e6b" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes 3.0.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "cap-primitives" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b5f74729fd2f44701d1a8eb47e906cdb3ccd9ec0f02baad85a744b791940b18" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes 3.0.1", + "ipnet", + "maybe-owned", + "rustix", + "rustix-linux-procfs", + "windows-sys 0.61.2", + "winx", +] + +[[package]] +name = "cap-std" +version = "4.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7281235d6e96d3544ca18bba9049be92f4190f8d923e3caef1b5f66cfa752608" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes 3.0.1", + "rustix", +] + +[[package]] +name = "cc" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "echo-cas" +version = "0.1.0" +dependencies = [ + "blake3", + "thiserror", +] + +[[package]] +name = "echo-keep-experimental" +version = "0.0.0" +dependencies = [ + "blake3", + "echo-cas", + "keep", + "thiserror", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes 2.0.4", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-extras" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20fd6de4ccfcc187e38bc21cfa543cb5a302cb86a8b114eb7f0bf0dc9f8ac00f" +dependencies = [ + "io-lifetimes 3.0.1", + "windows-sys 0.60.2", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + +[[package]] +name = "io-lifetimes" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0fb0570afe1fed943c5c3d4102d5358592d8625fda6a0007fdbe65a92fba96" + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "keep" +version = "0.0.0" +source = "git+https://github.com/flyingrobots/keep?rev=3165890e9291cfb5fe10e81a9d7cd151f3e59464#3165890e9291cfb5fe10e81a9d7cd151f3e59464" +dependencies = [ + "blake3", + "cap-fs-ext", + "cap-std", + "rustix", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +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 = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags", + "windows-sys 0.59.0", +] diff --git a/experiments/echo-keep/Cargo.toml b/experiments/echo-keep/Cargo.toml new file mode 100644 index 000000000..93ffbd8aa --- /dev/null +++ b/experiments/echo-keep/Cargo.toml @@ -0,0 +1,19 @@ +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +[package] +name = "echo-keep-experimental" +version = "0.0.0" +edition = "2024" +rust-version = "1.96" +publish = false +license = "Apache-2.0" +description = "Experimental Echo physical-content adapter over Keep" + +# A separate workspace keeps Keep out of Echo default dependency resolution. +[workspace] + +[dependencies] +echo-cas = { path = "../../crates/echo-cas" } +blake3 = "=1.8.5" +keep = { git = "https://github.com/flyingrobots/keep", rev = "3165890e9291cfb5fe10e81a9d7cd151f3e59464" } +thiserror = "2" diff --git a/experiments/echo-keep/README.md b/experiments/echo-keep/README.md new file mode 100644 index 000000000..b0c24282f --- /dev/null +++ b/experiments/echo-keep/README.md @@ -0,0 +1,21 @@ + + + +# Experimental Echo–Keep identity bridge + +This separate Rust 1.96 workspace pins Keep at `3165890e9291cfb5fe10e81a9d7cd151f3e59464`. It does not enter Echo’s default workspace or change `echo-cas`’s Rust 1.90 dependency graph. + +`IdentityBinding::from_source` computes raw BLAKE3 for Echo and the version-1 Keep domain, bytes, and exact-length hash from one bounded stream. The binding keeps Keep coordinates private and has no persisted encoding. `verify_source` rechecks both identities and length. Neither method proves presence, retention, publication, or durability. + +The conformance witness stages the named Keep golden vectors into `ReferenceStore`, reconstructs them, and rechecks exact bytes, both identities, and length. The largest fixture is one MiB. Source failures, limits, and coordinate substitution refuse a complete binding. This does not establish a durable backend or a physical-content port. + +Run these checks inside the admitted, bounded Docker worker: + +```sh +cargo +1.96.0 test --manifest-path experiments/echo-keep/Cargo.toml --locked +cargo +1.96.0 clippy --manifest-path experiments/echo-keep/Cargo.toml --locked --all-targets -- -D warnings +cargo +1.96.0 fmt --manifest-path experiments/echo-keep/Cargo.toml -- --check +cargo +1.90.0 check --locked -p echo-cas +``` + +[The canonical boundary](../../docs/architecture/echo-keep-physical-content-boundary.md) owns the contract. [Issue #759](https://github.com/flyingrobots/echo/issues/759) owns this identity slice. diff --git a/experiments/echo-keep/src/lib.rs b/experiments/echo-keep/src/lib.rs new file mode 100644 index 000000000..4bf7d6f8e --- /dev/null +++ b/experiments/echo-keep/src/lib.rs @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +//! Experimental identity conformance for the Echo–Keep physical-content boundary. +//! +//! Echo hashes raw bytes. Keep has a distinct, versioned identity law. A binding +//! authenticates one bounded source under both laws; it proves no storage presence, +//! retention, visibility, or durability. This crate has no persisted binding ABI. +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +use echo_cas::BlobHash; +use keep::{BlobHasher, BlobId}; +use std::io::{self, Read}; + +/// An opaque in-process binding under the version-1 identity bridge. +/// +/// Keep coordinates stay private. This experimental type has no wire encoding. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct IdentityBinding { + echo: BlobHash, + keep: BlobId, + length: u64, +} + +/// Operational or verification failure; no complete identity claim is returned. +#[derive(Debug, thiserror::Error)] +pub enum IdentityError { + /// Input I/O failed; the original cause is retained. + #[error("identity source I/O failed: {0}")] + Input(#[from] io::Error), + /// Source bytes exceeded the caller-selected limit. + #[error("identity source exceeds its byte limit")] + ResourceLimit, + /// A reader returned an impossible byte count. + #[error("identity source returned an invalid read count")] + InvalidReadCount, + /// Checked Keep length accounting failed. + #[error("Keep identity accounting failed: {0}")] + Accounting(#[from] keep::BlobHashError), + /// Exact Echo identity, Keep identity, or length did not match the binding. + #[error("reconstructed bytes do not match the identity binding")] + Mismatch, +} + +impl IdentityBinding { + /// Computes both independent laws from one bounded, non-materializing stream. + /// + /// Interrupted reads are retried. EOF seals the binding. The limit applies + /// to accepted source bytes, with one fixed 8 KiB read buffer. + /// + /// # Errors + /// Returns an operational error or resource refusal without a binding. + pub fn from_source(source: &mut dyn Read, byte_limit: u64) -> Result { + let mut echo = blake3::Hasher::new(); + let mut keep = BlobHasher::new(); + let mut length = 0_u64; + let mut buffer = [0_u8; 8192]; + loop { + let count = match source.read(&mut buffer) { + Ok(0) => break, + Ok(count) => count, + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + Err(error) => return Err(error.into()), + }; + let bytes = buffer.get(..count).ok_or(IdentityError::InvalidReadCount)?; + let incoming = u64::try_from(count).map_err(|_| IdentityError::ResourceLimit)?; + length = length + .checked_add(incoming) + .filter(|n| *n <= byte_limit) + .ok_or(IdentityError::ResourceLimit)?; + echo.update(bytes); + keep.update(bytes)?; + } + Ok(Self { + echo: BlobHash::from_bytes(*echo.finalize().as_bytes()), + keep: keep.finish(), + length, + }) + } + + /// Returns the raw-content Echo identity. + pub const fn echo_identity(&self) -> BlobHash { + self.echo + } + + /// Returns the exact authenticated length. + pub const fn length(&self) -> u64 { + self.length + } + + /// Rechecks both laws and exact length on a bounded reconstructed source. + /// + /// # Errors + /// Returns `Mismatch` for substituted identity or length, or a source error. + pub fn verify_source( + &self, + source: &mut dyn Read, + byte_limit: u64, + ) -> Result<(), IdentityError> { + if Self::from_source(source, byte_limit)? == *self { + Ok(()) + } else { + Err(IdentityError::Mismatch) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/experiments/echo-keep/src/tests.rs b/experiments/echo-keep/src/tests.rs new file mode 100644 index 000000000..f51d6ef22 --- /dev/null +++ b/experiments/echo-keep/src/tests.rs @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: Apache-2.0 +// © James Ross Ω FLYING•ROBOTS +use super::*; +use keep::{LayoutEntryLimit, ReferenceStore, ReferenceStoreCapacity}; +use std::io::Cursor; + +#[test] +fn pinned_vectors_bind_distinct_laws_and_reconstructed_bytes() +-> Result<(), Box> { + // Keep golden-file-worldline/v1 vectors at the pinned revision. + let cases = [ + ( + Vec::new(), + "keep:blob:v1:blake3-256:0:c0074a279c09f9d019dc10e4c821f79f1450cfb8541ab4627132ab9f3c75e33f", + ), + ( + b"Keep exact bytes.\n".to_vec(), + "keep:blob:v1:blake3-256:18:af75d70e4993121254ac71f16c5edd02410a36f94d795e4d6064ed3122b7967d", + ), + ( + (0..=255).collect::>(), + "keep:blob:v1:blake3-256:256:e782f90f48483f6a8520c9b05eca57ace1647374dd9456b9e41aadccacd10f12", + ), + ( + (0..=255).cycle().take(1_048_576).collect::>(), + "keep:blob:v1:blake3-256:1048576:25399c3df18ecd403c8cacf50a44409e005ca71452e4ad367bd14423c1f86e20", + ), + ]; + for (bytes, vector) in cases { + let limit = bytes.len() as u64; + let binding = IdentityBinding::from_source(&mut Cursor::new(&bytes), limit)?; + assert_eq!(binding.echo, echo_cas::blob_hash(&bytes)); + assert_eq!(binding.keep, vector.parse()?); + assert_eq!(binding.length, limit); + // Keep digest domain and framing never become an Echo CAS coordinate. + let mut independent = blake3::Hasher::new(); + independent.update(b"KEEP:BLOB:DATA\0\0"); + independent.update(&1_u16.to_be_bytes()); + independent.update(&[1]); + independent.update(&bytes); + independent.update(&limit.to_be_bytes()); + let expected = format!( + "keep:blob:v1:blake3-256:{limit}:{}", + independent.finalize().to_hex() + ); + assert_eq!(binding.keep.to_string(), expected); + assert_ne!( + binding.echo.to_string(), + independent.finalize().to_hex().to_string() + ); + let mut store = ReferenceStore::new(ReferenceStoreCapacity::new(bytes.len())); + let published = store + .stage_expected( + &mut Cursor::new(&bytes), + binding.keep, + LayoutEntryLimit::MAXIMUM, + )? + .commit(&mut store)?; + let mut output = Vec::new(); + let receipt = store.reconstruct(published.target(), &mut output)?; + assert_eq!(receipt.target(), binding.keep); + assert_eq!(receipt.bytes_written().get(), binding.length); + assert_eq!(output, bytes); + binding.verify_source(&mut Cursor::new(&output), limit)?; + } + // Official raw-BLAKE3 empty vector anchors the Echo domain independently. + assert_eq!( + echo_cas::blob_hash(b"").to_string(), + "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262" + ); + Ok(()) +} + +#[test] +fn substitution_and_length_mismatch_refuse() -> Result<(), IdentityError> { + let binding = IdentityBinding::from_source(&mut Cursor::new(b"abc"), 3)?; + assert!(matches!( + binding.verify_source(&mut Cursor::new(b"abd"), 3), + Err(IdentityError::Mismatch) + )); + for changed in [ + IdentityBinding { + echo: echo_cas::blob_hash(b"other"), + ..binding + }, + IdentityBinding { + keep: BlobId::hash_bytes(b"other")?, + ..binding + }, + IdentityBinding { + length: 4, + ..binding + }, + ] { + assert!(matches!( + changed.verify_source(&mut Cursor::new(b"abc"), 3), + Err(IdentityError::Mismatch) + )); + } + assert!(matches!( + IdentityBinding::from_source(&mut Cursor::new(b"abcd"), 3), + Err(IdentityError::ResourceLimit) + )); + Ok(()) +} + +struct AdversarialRead { + phase: u8, + failure: bool, +} +impl Read for AdversarialRead { + fn read(&mut self, out: &mut [u8]) -> io::Result { + self.phase += 1; + match self.phase { + 1 => Err(io::ErrorKind::Interrupted.into()), + 2 => { + out[0] = 120; + Ok(1) + } + _ if self.failure => Err(io::ErrorKind::BrokenPipe.into()), + _ => Ok(0), + } + } +} +#[test] +fn interrupted_short_source_retries_but_failed_source_returns_no_binding() +-> Result<(), IdentityError> { + let binding = IdentityBinding::from_source( + &mut AdversarialRead { + phase: 0, + failure: false, + }, + 1, + )?; + assert_eq!(binding.echo, echo_cas::blob_hash(b"x")); + assert!( + matches!(IdentityBinding::from_source(&mut AdversarialRead { phase: 0, failure: true }, 1), Err(IdentityError::Input(e)) if e.kind() == io::ErrorKind::BrokenPipe) + ); + Ok(()) +} From ea355a59f277bc31f669fecbb217851f06e3d6e8 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 09:25:32 -0700 Subject: [PATCH 02/14] fix(keep): classify excess reconstructed bytes as identity mismatch --- experiments/echo-keep/src/lib.rs | 9 ++++++++- experiments/echo-keep/src/tests.rs | 6 +++++- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/experiments/echo-keep/src/lib.rs b/experiments/echo-keep/src/lib.rs index 4bf7d6f8e..61dbbefe4 100644 --- a/experiments/echo-keep/src/lib.rs +++ b/experiments/echo-keep/src/lib.rs @@ -97,7 +97,14 @@ impl IdentityBinding { source: &mut dyn Read, byte_limit: u64, ) -> Result<(), IdentityError> { - if Self::from_source(source, byte_limit)? == *self { + if byte_limit < self.length { + return Err(IdentityError::ResourceLimit); + } + let observed = match Self::from_source(source, byte_limit) { + Err(IdentityError::ResourceLimit) => return Err(IdentityError::Mismatch), + result => result?, + }; + if observed == *self { Ok(()) } else { Err(IdentityError::Mismatch) diff --git a/experiments/echo-keep/src/tests.rs b/experiments/echo-keep/src/tests.rs index f51d6ef22..b93e40ee2 100644 --- a/experiments/echo-keep/src/tests.rs +++ b/experiments/echo-keep/src/tests.rs @@ -78,6 +78,10 @@ fn substitution_and_length_mismatch_refuse() -> Result<(), IdentityError> { binding.verify_source(&mut Cursor::new(b"abd"), 3), Err(IdentityError::Mismatch) )); + assert!(matches!( + binding.verify_source(&mut Cursor::new(b"abcd"), 3), + Err(IdentityError::Mismatch) + )); for changed in [ IdentityBinding { echo: echo_cas::blob_hash(b"other"), @@ -93,7 +97,7 @@ fn substitution_and_length_mismatch_refuse() -> Result<(), IdentityError> { }, ] { assert!(matches!( - changed.verify_source(&mut Cursor::new(b"abc"), 3), + changed.verify_source(&mut Cursor::new(b"abc"), 4), Err(IdentityError::Mismatch) )); } From 0a520bdf4f81652ee4ccd54f82af99003493bb5e Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 09:27:43 -0700 Subject: [PATCH 03/14] fix(keep): keep backend error types behind an opaque source --- experiments/echo-keep/src/lib.rs | 5 +++-- experiments/echo-keep/src/tests.rs | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/experiments/echo-keep/src/lib.rs b/experiments/echo-keep/src/lib.rs index 61dbbefe4..e9e126341 100644 --- a/experiments/echo-keep/src/lib.rs +++ b/experiments/echo-keep/src/lib.rs @@ -36,7 +36,7 @@ pub enum IdentityError { InvalidReadCount, /// Checked Keep length accounting failed. #[error("Keep identity accounting failed: {0}")] - Accounting(#[from] keep::BlobHashError), + Accounting(#[source] Box), /// Exact Echo identity, Keep identity, or length did not match the binding. #[error("reconstructed bytes do not match the identity binding")] Mismatch, @@ -69,7 +69,8 @@ impl IdentityBinding { .filter(|n| *n <= byte_limit) .ok_or(IdentityError::ResourceLimit)?; echo.update(bytes); - keep.update(bytes)?; + keep.update(bytes) + .map_err(|error| IdentityError::Accounting(Box::new(error)))?; } Ok(Self { echo: BlobHash::from_bytes(*echo.finalize().as_bytes()), diff --git a/experiments/echo-keep/src/tests.rs b/experiments/echo-keep/src/tests.rs index b93e40ee2..8547a15da 100644 --- a/experiments/echo-keep/src/tests.rs +++ b/experiments/echo-keep/src/tests.rs @@ -88,7 +88,8 @@ fn substitution_and_length_mismatch_refuse() -> Result<(), IdentityError> { ..binding }, IdentityBinding { - keep: BlobId::hash_bytes(b"other")?, + keep: BlobId::hash_bytes(b"other") + .map_err(|error| IdentityError::Accounting(Box::new(error)))?, ..binding }, IdentityBinding { From 798957fbdadaf63ce42979bc30b5bea7aaee0023 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 09:32:03 -0700 Subject: [PATCH 04/14] fix(tooling): include isolated experiments in exact MSRV admission --- CONTRIBUTING.md | 2 ++ experiments/echo-keep/Cargo.toml | 2 +- scripts/check_rust_versions.sh | 4 +-- scripts/rust-msrv-policy.tsv | 1 + scripts/tests/check_rust_versions_test.sh | 35 +++++++++++++++++++++++ 5 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 86c310b4f..078b403d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -128,6 +128,8 @@ prioritize: ### Runtime and provider toolchains +The isolated `experiments/echo-keep` workspace requires Rust 1.96.0 and remains outside default dependency resolution. Its manifest is part of the same explicit MSRV inventory; experimental packages do not bypass the version guard. + The general toolchain is Rust 1.96.0 because `bunny-num` 0.6.0 requires it. Numerical consumers declare MSRV 1.96.0. Independent leaves retain the workspace default MSRV 1.90.0; [the explicit policy](scripts/rust-msrv-policy.tsv) enumerates diff --git a/experiments/echo-keep/Cargo.toml b/experiments/echo-keep/Cargo.toml index 93ffbd8aa..02db8077d 100644 --- a/experiments/echo-keep/Cargo.toml +++ b/experiments/echo-keep/Cargo.toml @@ -4,7 +4,7 @@ name = "echo-keep-experimental" version = "0.0.0" edition = "2024" -rust-version = "1.96" +rust-version = "1.96.0" publish = false license = "Apache-2.0" description = "Experimental Echo physical-content adapter over Keep" diff --git a/scripts/check_rust_versions.sh b/scripts/check_rust_versions.sh index 4be880e0e..81a461a8e 100755 --- a/scripts/check_rust_versions.sh +++ b/scripts/check_rust_versions.sh @@ -30,7 +30,7 @@ while read -r key version extra || [[ -n "$key$version$extra" ]]; do [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || fail "invalid MSRV policy version: $key $version" case "$key" in toolchain|workspace|xtask/Cargo.toml|tests/edict-provider-host-v1/Cargo.toml) ;; - crates/*/Cargo.toml|specs/*/Cargo.toml) + crates/*/Cargo.toml|specs/*/Cargo.toml|experiments/*/Cargo.toml) [[ "$key" != *"/../"* && "$key" != *"/./"* && "$key" != *"//"* ]] || fail "invalid MSRV policy path: $key" ;; *) fail "unknown MSRV policy key: $key" ;; @@ -94,7 +94,7 @@ manifests=() manifest_count=0 # Include nested manifests, the build driver, and the independent host witness. # Their presence must be accounted for explicitly in the policy inventory. -for root in "$repo_root/crates" "$repo_root/specs"; do +for root in "$repo_root/crates" "$repo_root/specs" "$repo_root/experiments"; do if [[ -d "$root" ]]; then while IFS= read -r manifest; do manifests[$manifest_count]="$manifest" diff --git a/scripts/rust-msrv-policy.tsv b/scripts/rust-msrv-policy.tsv index d8350dc1c..05ddde2e8 100644 --- a/scripts/rust-msrv-policy.tsv +++ b/scripts/rust-msrv-policy.tsv @@ -29,3 +29,4 @@ crates/warp-math/Cargo.toml 1.96.0 crates/warp-wasm/Cargo.toml 1.96.0 tests/edict-provider-host-v1/Cargo.toml 1.96.0 xtask/Cargo.toml 1.96.0 +experiments/echo-keep/Cargo.toml 1.96.0 diff --git a/scripts/tests/check_rust_versions_test.sh b/scripts/tests/check_rust_versions_test.sh index ea8ddf2e4..a11496b7b 100644 --- a/scripts/tests/check_rust_versions_test.sh +++ b/scripts/tests/check_rust_versions_test.sh @@ -358,9 +358,44 @@ test_standalone_host_toolchain_matches_its_policy() { [[ "$failures" -eq 0 ]] } +test_rejects_unregistered_experiment() { + with_tmp_repo bash -c ' + set -euo pipefail + mkdir -p experiments/unknown + cp crates/foo/Cargo.toml experiments/unknown/Cargo.toml + if ./scripts/check_rust_versions.sh > refusal.log 2>&1; then exit 1; fi + grep -q "unregistered.*experiments/unknown/Cargo.toml" refusal.log + ' +} + +test_experiment_policy_requires_exact_version() { + for version in 1.90.0 1.96.0; do + with_tmp_repo bash -c ' + set -euo pipefail + sed "s/1.90.0/1.96.0/" rust-toolchain.toml > replacement + mv replacement rust-toolchain.toml + sed "s/toolchain 1.90.0/toolchain 1.96.0/" scripts/rust-msrv-policy.tsv > replacement + mv replacement scripts/rust-msrv-policy.tsv + mkdir -p experiments/example + cp crates/foo/Cargo.toml experiments/example/Cargo.toml + sed "s/1.90.0/$1/" experiments/example/Cargo.toml > replacement + mv replacement experiments/example/Cargo.toml + printf "%s\n" "experiments/example/Cargo.toml 1.96.0" >> scripts/rust-msrv-policy.tsv + if [[ "$1" == 1.96.0 ]]; then + ./scripts/check_rust_versions.sh + else + if ./scripts/check_rust_versions.sh > refusal.log 2>&1; then exit 1; fi + grep -q "rust-version mismatch.*experiments/example/Cargo.toml" refusal.log + fi + ' bash "$version" + done +} + main() { [[ -f "$checker_src" ]] || fail "checker script missing: $checker_src" + test_rejects_unregistered_experiment + test_experiment_policy_requires_exact_version test_standalone_host_toolchain_matches_its_policy test_rejects_metadata_only_versions test_ignores_metadata_before_package From 55c22a5b0925a49c615dc91ffcae29fcdfbebe02 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 09:33:53 -0700 Subject: [PATCH 05/14] docs(keep): describe the implemented identity conformance scope --- experiments/echo-keep/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiments/echo-keep/Cargo.toml b/experiments/echo-keep/Cargo.toml index 02db8077d..0c3408112 100644 --- a/experiments/echo-keep/Cargo.toml +++ b/experiments/echo-keep/Cargo.toml @@ -7,7 +7,7 @@ edition = "2024" rust-version = "1.96.0" publish = false license = "Apache-2.0" -description = "Experimental Echo physical-content adapter over Keep" +description = "Experimental identity conformance for Echo and Keep" # A separate workspace keeps Keep out of Echo default dependency resolution. [workspace] From 3c2419efc3e882f0c70b9235cabad95938bebff3 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 09:41:10 -0700 Subject: [PATCH 06/14] fix(ci): fail when experimental dependency enumeration fails --- .github/workflows/echo-keep-experimental.yml | 5 +++- .../tests/keep_dependency_boundary_test.sh | 29 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/keep_dependency_boundary_test.sh diff --git a/.github/workflows/echo-keep-experimental.yml b/.github/workflows/echo-keep-experimental.yml index c82b1e19d..fa51186fa 100644 --- a/.github/workflows/echo-keep-experimental.yml +++ b/.github/workflows/echo-keep-experimental.yml @@ -23,8 +23,11 @@ jobs: - run: cargo test --manifest-path experiments/echo-keep/Cargo.toml --locked - run: cargo clippy --manifest-path experiments/echo-keep/Cargo.toml --locked --all-targets -- -D warnings - run: cargo fmt --manifest-path experiments/echo-keep/Cargo.toml -- --check + - name: Verify dependency-tree failure handling + run: bash scripts/tests/keep_dependency_boundary_test.sh - name: Preserve the default CAS toolchain and dependency boundary run: | rustup toolchain install 1.90.0 --profile minimal cargo +1.90.0 check --locked -p echo-cas - if cargo tree --locked -p echo-cas | grep -q "keep v"; then exit 1; fi + dependency_tree=$(cargo tree --locked -p echo-cas) + if grep -q "keep v" <<< "$dependency_tree"; then exit 1; fi diff --git a/scripts/tests/keep_dependency_boundary_test.sh b/scripts/tests/keep_dependency_boundary_test.sh new file mode 100644 index 000000000..49b34ca18 --- /dev/null +++ b/scripts/tests/keep_dependency_boundary_test.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +set -euo pipefail +root=$(git rev-parse --show-toplevel) +work=$(mktemp -d "${TMPDIR:-/tmp}/echo-dependency-step.XXXXXX") +trap 'rm -rf "$work"' EXIT +mkdir "$work/bin" +cat > "$work/bin/cargo" <<'MOCK' +#!/usr/bin/env bash +if [[ "$1" == tree ]]; then exit 23; fi +exit 0 +MOCK +cat > "$work/bin/rustup" <<'MOCK' +#!/usr/bin/env bash +exit 0 +MOCK +chmod +x "$work/bin/cargo" "$work/bin/rustup" +body=$(awk ' + /name: Preserve the default CAS toolchain and dependency boundary/ { step = 1; next } + step && /^[[:space:]]*run: \|/ { command = 1; next } + command { sub(/^ /, ""); print } +' "$root/.github/workflows/echo-keep-experimental.yml") +test -n "$body" +if PATH="$work/bin:$PATH" bash -e -c "$body"; then + echo 'FAIL: cargo tree failure was accepted' >&2 + exit 1 +fi +printf '%s\n' 'PASS: failing dependency-tree command blocks the exact workflow step' From aa6ea2ee6ea990b9c02d0e9042217eedb3898d14 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 09:44:09 -0700 Subject: [PATCH 07/14] fix(security): audit the isolated Keep experiment lockfile --- .github/workflows/security-audit.yml | 2 ++ scripts/run_cargo_audit.sh | 8 ++++++- scripts/tests/keep_audit_lockfiles_test.sh | 26 ++++++++++++++++++++++ 3 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 scripts/tests/keep_audit_lockfiles_test.sh diff --git a/.github/workflows/security-audit.yml b/.github/workflows/security-audit.yml index 6d09b04ca..207a0b4b4 100644 --- a/.github/workflows/security-audit.yml +++ b/.github/workflows/security-audit.yml @@ -31,6 +31,8 @@ jobs: if ! command -v cargo-audit >/dev/null; then cargo install cargo-audit --locked fi + - name: Verify isolated lockfile audit coverage + run: bash scripts/tests/keep_audit_lockfiles_test.sh - name: Run cargo audit run: | set -euo pipefail diff --git a/scripts/run_cargo_audit.sh b/scripts/run_cargo_audit.sh index f1991bfd7..e71154a50 100755 --- a/scripts/run_cargo_audit.sh +++ b/scripts/run_cargo_audit.sh @@ -48,4 +48,10 @@ if [[ "${#ignore_ids[@]}" -ne 0 ]]; then printf ' - %s\n' "${ignore_ids[@]}" >&2 fi -cargo audit --deny warnings "${ignore_flags[@]}" +[[ -f "$repo_root/Cargo.lock" ]] || { echo "Error: root Cargo.lock is missing" >&2; exit 1; } +# The experiment has a separate resolver and must have its own advisory check. +for lockfile in "$repo_root/Cargo.lock" "$repo_root/experiments/echo-keep/Cargo.lock"; do + if [[ -f "$lockfile" ]]; then + cargo audit --file "$lockfile" --deny warnings "${ignore_flags[@]}" + fi +done diff --git a/scripts/tests/keep_audit_lockfiles_test.sh b/scripts/tests/keep_audit_lockfiles_test.sh new file mode 100644 index 000000000..444572ded --- /dev/null +++ b/scripts/tests/keep_audit_lockfiles_test.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +set -euo pipefail +root=$(git rev-parse --show-toplevel) +work=$(mktemp -d "${TMPDIR:-/tmp}/echo-audit-lockfiles.XXXXXX") +trap 'rm -rf "$work"' EXIT +mkdir "$work/bin" +cat > "$work/bin/cargo" <<'MOCK' +#!/usr/bin/env bash +printf '%s\n' "$*" >> "$ECHO_AUDIT_CAPTURE" +if [[ "${ECHO_AUDIT_FAIL_EXPERIMENT:-0}" == 1 && "$*" == *experiments/echo-keep/Cargo.lock* ]]; then exit 23; fi +exit 0 +MOCK +chmod +x "$work/bin/cargo" +PATH="$work/bin:$PATH" ECHO_AUDIT_CAPTURE="$work/calls" bash "$root/scripts/run_cargo_audit.sh" +if ! grep -q -- "--file $root/experiments/echo-keep/Cargo.lock" "$work/calls"; then + echo "FAIL: experimental lockfile was not audited" >&2 + exit 1 +fi +grep -q -- "--file $root/Cargo.lock" "$work/calls" +if PATH="$work/bin:$PATH" ECHO_AUDIT_CAPTURE="$work/failing-calls" ECHO_AUDIT_FAIL_EXPERIMENT=1 bash "$root/scripts/run_cargo_audit.sh"; then + echo 'FAIL: experimental audit failure was accepted' >&2 + exit 1 +fi +printf '%s\n' 'PASS: both lockfiles are audited and an experimental audit failure blocks' From d9f7c2893da4e04781675b34f779a15c193ff8e0 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 10:06:50 -0700 Subject: [PATCH 08/14] test(keep): bind complete verification to its budget preflight --- experiments/echo-keep/src/lib.rs | 6 ++++-- experiments/echo-keep/src/tests.rs | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/experiments/echo-keep/src/lib.rs b/experiments/echo-keep/src/lib.rs index e9e126341..5cbbff3db 100644 --- a/experiments/echo-keep/src/lib.rs +++ b/experiments/echo-keep/src/lib.rs @@ -28,7 +28,7 @@ pub enum IdentityError { /// Input I/O failed; the original cause is retained. #[error("identity source I/O failed: {0}")] Input(#[from] io::Error), - /// Source bytes exceeded the caller-selected limit. + /// Source bytes or the requested complete verification exceed the byte limit. #[error("identity source exceeds its byte limit")] ResourceLimit, /// A reader returned an impossible byte count. @@ -92,7 +92,9 @@ impl IdentityBinding { /// Rechecks both laws and exact length on a bounded reconstructed source. /// /// # Errors - /// Returns `Mismatch` for substituted identity or length, or a source error. + /// Returns `ResourceLimit` before reading when the declared binding cannot + /// fit the byte limit. Otherwise, observed identity or length disagreement + /// returns `Mismatch`. Source failures establish no complete identity. pub fn verify_source( &self, source: &mut dyn Read, diff --git a/experiments/echo-keep/src/tests.rs b/experiments/echo-keep/src/tests.rs index 8547a15da..7a7495f2b 100644 --- a/experiments/echo-keep/src/tests.rs +++ b/experiments/echo-keep/src/tests.rs @@ -143,3 +143,26 @@ fn interrupted_short_source_retries_but_failed_source_returns_no_binding() ); Ok(()) } + +#[test] +fn complete_binding_budget_preflight_does_not_read_source() -> Result<(), IdentityError> { + struct UnreadSource(bool); + impl Read for UnreadSource { + fn read(&mut self, _: &mut [u8]) -> io::Result { + self.0 = true; + Err(io::ErrorKind::BrokenPipe.into()) + } + } + let binding = IdentityBinding::from_source(&mut Cursor::new(b"abc"), 3)?; + let mut source = UnreadSource(false); + assert!(matches!( + binding.verify_source(&mut source, 1), + Err(IdentityError::ResourceLimit) + )); + assert!(!source.0); + assert!(matches!( + binding.verify_source(&mut Cursor::new(b""), 3), + Err(IdentityError::Mismatch) + )); + Ok(()) +} From ca8d9f03223d120d126d0cd66eaba3c618ac2a90 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 10:52:06 -0700 Subject: [PATCH 09/14] fix(keep): stop identity reads at one overlength probe --- experiments/echo-keep/src/lib.rs | 14 ++++++++++---- experiments/echo-keep/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/experiments/echo-keep/src/lib.rs b/experiments/echo-keep/src/lib.rs index 5cbbff3db..902173f13 100644 --- a/experiments/echo-keep/src/lib.rs +++ b/experiments/echo-keep/src/lib.rs @@ -46,7 +46,8 @@ impl IdentityBinding { /// Computes both independent laws from one bounded, non-materializing stream. /// /// Interrupted reads are retried. EOF seals the binding. The limit applies - /// to accepted source bytes, with one fixed 8 KiB read buffer. + /// to accepted source bytes, with one fixed 8 KiB read buffer. Reads request + /// at most the remaining allowance plus one overlength probe. /// /// # Errors /// Returns an operational error or resource refusal without a binding. @@ -56,13 +57,18 @@ impl IdentityBinding { let mut length = 0_u64; let mut buffer = [0_u8; 8192]; loop { - let count = match source.read(&mut buffer) { + let remaining = byte_limit - length; + let request = usize::try_from(remaining.saturating_add(1)) + .unwrap_or(usize::MAX) + .min(buffer.len()); + let window = &mut buffer[..request]; + let count = match source.read(window) { Ok(0) => break, Ok(count) => count, Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, Err(error) => return Err(error.into()), }; - let bytes = buffer.get(..count).ok_or(IdentityError::InvalidReadCount)?; + let bytes = window.get(..count).ok_or(IdentityError::InvalidReadCount)?; let incoming = u64::try_from(count).map_err(|_| IdentityError::ResourceLimit)?; length = length .checked_add(incoming) @@ -103,7 +109,7 @@ impl IdentityBinding { if byte_limit < self.length { return Err(IdentityError::ResourceLimit); } - let observed = match Self::from_source(source, byte_limit) { + let observed = match Self::from_source(source, self.length) { Err(IdentityError::ResourceLimit) => return Err(IdentityError::Mismatch), result => result?, }; diff --git a/experiments/echo-keep/src/tests.rs b/experiments/echo-keep/src/tests.rs index 7a7495f2b..ec5aae635 100644 --- a/experiments/echo-keep/src/tests.rs +++ b/experiments/echo-keep/src/tests.rs @@ -166,3 +166,31 @@ fn complete_binding_budget_preflight_does_not_read_source() -> Result<(), Identi )); Ok(()) } + +#[test] +fn identity_reader_stops_at_one_overlength_probe() -> Result<(), IdentityError> { + struct Endless { + consumed: usize, + } + impl Read for Endless { + fn read(&mut self, bytes: &mut [u8]) -> io::Result { + bytes.fill(97); + self.consumed += bytes.len(); + Ok(bytes.len()) + } + } + let binding = IdentityBinding::from_source(&mut Cursor::new(b"aaa"), 3)?; + let mut source = Endless { consumed: 0 }; + assert!(matches!( + binding.verify_source(&mut source, 32), + Err(IdentityError::Mismatch) + )); + assert_eq!(source.consumed, 4); + let mut source = Endless { consumed: 0 }; + assert!(matches!( + IdentityBinding::from_source(&mut source, 1), + Err(IdentityError::ResourceLimit) + )); + assert_eq!(source.consumed, 2); + Ok(()) +} From 2c79519c9f84fd6a529c36d83bf55967f9d24777 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 10:52:06 -0700 Subject: [PATCH 10/14] fix(ci): check the isolated Keep dependency graph under root policies --- .github/workflows/echo-keep-experimental.yml | 20 +++++++++++ experiments/echo-keep/README.md | 2 ++ scripts/keep_deny_policy.py | 37 ++++++++++++++++++++ scripts/tests/keep_deny_policy_test.py | 29 +++++++++++++++ 4 files changed, 88 insertions(+) create mode 100644 scripts/keep_deny_policy.py create mode 100644 scripts/tests/keep_deny_policy_test.py diff --git a/.github/workflows/echo-keep-experimental.yml b/.github/workflows/echo-keep-experimental.yml index fa51186fa..f4d91a226 100644 --- a/.github/workflows/echo-keep-experimental.yml +++ b/.github/workflows/echo-keep-experimental.yml @@ -31,3 +31,23 @@ jobs: cargo +1.90.0 check --locked -p echo-cas dependency_tree=$(cargo tree --locked -p echo-cas) if grep -q "keep v" <<< "$dependency_tree"; then exit 1; fi + + dependency-policy: + name: Experimental Keep dependency policy + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + - name: Generate and verify scoped source policy + run: | + python3 scripts/keep_deny_policy.py .echo-keep-deny.toml + python3 scripts/tests/keep_deny_policy_test.py + - uses: EmbarkStudios/cargo-deny-action@76cd80eb775d7bbbd2d80292136d74d39e1b4918 # v2.0.14 + with: + manifest-path: experiments/echo-keep/Cargo.toml + rust-version: "1.96.0" + arguments: --locked --all-features + command-arguments: --config /github/workspace/.echo-keep-deny.toml + - name: Remove generated policy + if: always() + run: rm -f .echo-keep-deny.toml diff --git a/experiments/echo-keep/README.md b/experiments/echo-keep/README.md index b0c24282f..47c254850 100644 --- a/experiments/echo-keep/README.md +++ b/experiments/echo-keep/README.md @@ -19,3 +19,5 @@ cargo +1.90.0 check --locked -p echo-cas ``` [The canonical boundary](../../docs/architecture/echo-keep-physical-content-boundary.md) owns the contract. [Issue #759](https://github.com/flyingrobots/echo/issues/759) owns this identity slice. + +The isolated graph has its own dependency-policy CI check. It derives all license, ban, advisory, and source rules from the root policy, with one scoped allowance for the pinned Keep Git source. The production workspace policy remains unchanged. diff --git a/scripts/keep_deny_policy.py b/scripts/keep_deny_policy.py new file mode 100644 index 000000000..26c698641 --- /dev/null +++ b/scripts/keep_deny_policy.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +"""Derive the experimental policy without weakening the root workspace policy.""" +import argparse +import json +from pathlib import Path +import re +import tomllib + +KEEP_SOURCE = "https://github.com/flyingrobots/keep" + +def derive_policy(policy_text, manifest_text): + """Preserve all root rules, admitting only the inspected Keep Git source.""" + policy = tomllib.loads(policy_text) + dependency = tomllib.loads(manifest_text)["dependencies"]["keep"] + if dependency.get("git") != KEEP_SOURCE or not re.fullmatch(r"[0-9a-f]{40}", dependency.get("rev", "")): + raise ValueError("experiment requires the reviewed Keep source and an exact revision") + if "allow-git" in policy["sources"] or policy_text.count("[sources]") != 1: + raise ValueError("root source policy changed; reconcile the scoped exception explicitly") + result = policy_text.replace("[sources]", "[sources]\nallow-git = [" + json.dumps(KEEP_SOURCE) + "]", 1) + parsed = tomllib.loads(result) + assert parsed["sources"].pop("allow-git") == [KEEP_SOURCE] + assert parsed == policy + return result + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output", type=Path) + args = parser.parse_args() + root = Path(__file__).resolve().parent.parent + result = derive_policy((root / "deny.toml").read_text(), (root / "experiments/echo-keep/Cargo.toml").read_text()) + with args.output.open("x") as output: + output.write(result) + +if __name__ == "__main__": + main() diff --git a/scripts/tests/keep_deny_policy_test.py b/scripts/tests/keep_deny_policy_test.py new file mode 100644 index 000000000..a79259af3 --- /dev/null +++ b/scripts/tests/keep_deny_policy_test.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# © James Ross Ω FLYING•ROBOTS +"""Check the real generated policy, unknown-source refusal, and CI aperture.""" +import importlib.util +from pathlib import Path +import tomllib + +root = Path(__file__).resolve().parents[2] +spec = importlib.util.spec_from_file_location("policy", root / "scripts/keep_deny_policy.py") +module = importlib.util.module_from_spec(spec) +spec.loader.exec_module(module) +policy_text = (root / "deny.toml").read_text() +manifest_text = (root / "experiments/echo-keep/Cargo.toml").read_text() +derived = tomllib.loads(module.derive_policy(policy_text, manifest_text)) +original = tomllib.loads(policy_text) +assert derived["sources"].pop("allow-git") == [module.KEEP_SOURCE] +assert derived == original +for invalid in [manifest_text.replace(module.KEEP_SOURCE, "https://example.com/unknown"), manifest_text.replace("3165890e9291cfb5fe10e81a9d7cd151f3e59464", "main")]: + try: + module.derive_policy(policy_text, invalid) + except ValueError: + pass + else: + raise AssertionError("unreviewed source or unpinned revision was admitted") +workflow = (root / ".github/workflows/echo-keep-experimental.yml").read_text() +assert "manifest-path: experiments/echo-keep/Cargo.toml" in workflow +assert "--config /github/workspace/.echo-keep-deny.toml" in workflow +print("PASS: complete root policy retained, only pinned Keep source admitted, isolated CI graph checked") From fb80ec3274f7eaaa664f63b11d17387995debb4f Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 11:13:13 -0700 Subject: [PATCH 11/14] fix(keep): declare the CAS version required by dependency policy --- experiments/echo-keep/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiments/echo-keep/Cargo.toml b/experiments/echo-keep/Cargo.toml index 0c3408112..f81e400ca 100644 --- a/experiments/echo-keep/Cargo.toml +++ b/experiments/echo-keep/Cargo.toml @@ -13,7 +13,7 @@ description = "Experimental identity conformance for Echo and Keep" [workspace] [dependencies] -echo-cas = { path = "../../crates/echo-cas" } +echo-cas = { version = "0.1.0", path = "../../crates/echo-cas" } blake3 = "=1.8.5" keep = { git = "https://github.com/flyingrobots/keep", rev = "3165890e9291cfb5fe10e81a9d7cd151f3e59464" } thiserror = "2" From 13f5416aa79766e3e9ca6c1239a7b786095cc303 Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 11:15:10 -0700 Subject: [PATCH 12/14] fix(keep): declare the pinned backend version for dependency policy --- experiments/echo-keep/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/experiments/echo-keep/Cargo.toml b/experiments/echo-keep/Cargo.toml index f81e400ca..e7f4dfc0b 100644 --- a/experiments/echo-keep/Cargo.toml +++ b/experiments/echo-keep/Cargo.toml @@ -15,5 +15,5 @@ description = "Experimental identity conformance for Echo and Keep" [dependencies] echo-cas = { version = "0.1.0", path = "../../crates/echo-cas" } blake3 = "=1.8.5" -keep = { git = "https://github.com/flyingrobots/keep", rev = "3165890e9291cfb5fe10e81a9d7cd151f3e59464" } +keep = { version = "=0.0.0", git = "https://github.com/flyingrobots/keep", rev = "3165890e9291cfb5fe10e81a9d7cd151f3e59464" } thiserror = "2" From e425b4c8fcc67c61802edb904d8cc231f152783c Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 11:35:59 -0700 Subject: [PATCH 13/14] fix(keep): redact physical coordinates from binding Debug --- experiments/echo-keep/src/lib.rs | 11 ++++++++++- experiments/echo-keep/src/tests.rs | 9 +++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/experiments/echo-keep/src/lib.rs b/experiments/echo-keep/src/lib.rs index 902173f13..49dc70e98 100644 --- a/experiments/echo-keep/src/lib.rs +++ b/experiments/echo-keep/src/lib.rs @@ -15,13 +15,22 @@ use std::io::{self, Read}; /// An opaque in-process binding under the version-1 identity bridge. /// /// Keep coordinates stay private. This experimental type has no wire encoding. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, PartialEq, Eq)] pub struct IdentityBinding { echo: BlobHash, keep: BlobId, length: u64, } +impl std::fmt::Debug for IdentityBinding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("IdentityBinding") + .field("echo", &self.echo) + .field("length", &self.length) + .finish_non_exhaustive() + } +} + /// Operational or verification failure; no complete identity claim is returned. #[derive(Debug, thiserror::Error)] pub enum IdentityError { diff --git a/experiments/echo-keep/src/tests.rs b/experiments/echo-keep/src/tests.rs index ec5aae635..55205bbc4 100644 --- a/experiments/echo-keep/src/tests.rs +++ b/experiments/echo-keep/src/tests.rs @@ -194,3 +194,12 @@ fn identity_reader_stops_at_one_overlength_probe() -> Result<(), IdentityError> assert_eq!(source.consumed, 2); Ok(()) } + +#[test] +fn binding_debug_keeps_backend_coordinates_private() -> Result<(), IdentityError> { + let binding = IdentityBinding::from_source(&mut Cursor::new(b"abc"), 3)?; + let debug = format!("{binding:?}"); + assert!(!debug.contains("keep"), "{debug}"); + assert!(!debug.contains(&format!("{:?}", binding.keep)), "{debug}"); + Ok(()) +} From 64b3a42da8e5fca5f2b7d55015aa3bd2efefe8ee Mon Sep 17 00:00:00 2001 From: James Ross Date: Wed, 7 Oct 2026 11:36:00 -0700 Subject: [PATCH 14/14] fix(ci): avoid persisted credentials in experimental checks --- .github/workflows/echo-keep-experimental.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/echo-keep-experimental.yml b/.github/workflows/echo-keep-experimental.yml index f4d91a226..b9b05946c 100644 --- a/.github/workflows/echo-keep-experimental.yml +++ b/.github/workflows/echo-keep-experimental.yml @@ -16,7 +16,9 @@ jobs: CARGO_INCREMENTAL: 0 CARGO_BUILD_JOBS: 2 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - uses: dtolnay/rust-toolchain@1.96.0 with: components: rustfmt, clippy @@ -37,7 +39,9 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Generate and verify scoped source policy run: | python3 scripts/keep_deny_policy.py .echo-keep-deny.toml