From 2fb74270e9833282e4fd078fd023fb3878ecf79e Mon Sep 17 00:00:00 2001 From: WeiR-h <74817080+WeiR-h@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:57:19 +0800 Subject: [PATCH 1/2] Add optional consent-bound durable actions with recovery and indexed history --- .github/workflows/action-receipts.yml | 36 + Cargo.toml | 1 + README.md | 4 + README.zh-CN.md | 4 + crates/action-receipts/Cargo.lock | 519 ++++++++++++++ crates/action-receipts/Cargo.toml | 18 + crates/action-receipts/LICENSE | 202 ++++++ crates/action-receipts/README.md | 35 + crates/action-receipts/README.zh-CN.md | 35 + .../examples/benchmark_owned_history.rs | 65 ++ .../examples/benchmark_recovery.rs | 60 ++ crates/action-receipts/src/lib.rs | 571 +++++++++++++++ crates/action-receipts/tests/workflow.rs | 657 ++++++++++++++++++ 13 files changed, 2207 insertions(+) create mode 100644 .github/workflows/action-receipts.yml create mode 100644 crates/action-receipts/Cargo.lock create mode 100644 crates/action-receipts/Cargo.toml create mode 100644 crates/action-receipts/LICENSE create mode 100644 crates/action-receipts/README.md create mode 100644 crates/action-receipts/README.zh-CN.md create mode 100644 crates/action-receipts/examples/benchmark_owned_history.rs create mode 100644 crates/action-receipts/examples/benchmark_recovery.rs create mode 100644 crates/action-receipts/src/lib.rs create mode 100644 crates/action-receipts/tests/workflow.rs diff --git a/.github/workflows/action-receipts.yml b/.github/workflows/action-receipts.yml new file mode 100644 index 000000000..f871f6e3f --- /dev/null +++ b/.github/workflows/action-receipts.yml @@ -0,0 +1,36 @@ +name: Recoverable native actions +on: + push: + branches: [durable-action-receipts] + pull_request: + paths: + - 'crates/action-receipts/**' + - '.github/workflows/action-receipts.yml' + workflow_dispatch: +permissions: + contents: read +jobs: + portable-journal: + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - run: rustup toolchain install 1.98.0 --profile minimal + - name: Test production journal and failure recovery + env: + RUSTUP_TOOLCHAIN: 1.98.0 + run: cargo test --manifest-path crates/action-receipts/Cargo.toml --locked + - name: Compare identical history before and after indexing + env: + RUSTUP_TOOLCHAIN: 1.98.0 + run: cargo run --manifest-path crates/action-receipts/Cargo.toml --locked --example benchmark_recovery + - name: Measure unresolved recovery under one account's confirmed history + env: + RUSTUP_TOOLCHAIN: 1.98.0 + run: cargo run --manifest-path crates/action-receipts/Cargo.toml --locked --example benchmark_owned_history diff --git a/Cargo.toml b/Cargo.toml index 57ec9df34..ee3a0c707 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,6 +65,7 @@ members = [ ] # Separate tools with their own lockfiles, and the framework checkouts. exclude = [ + "crates/action-receipts", ".sources", "apps/appcard/personal-data", "apps/appcard/tools", diff --git a/README.md b/README.md index bf5bdfe2d..e39e063ae 100644 --- a/README.md +++ b/README.md @@ -491,3 +491,7 @@ Each product is tagged on its own: `desktop-v*`, `home-v*` (the APK) and `rom-v* ## License Apache License 2.0 ([LICENSE](LICENSE), [NOTICE](NOTICE)). Source copied from Makepad keeps its MIT notice ([LICENSES/](LICENSES)). Dependencies keep their own licenses. + +## Recoverable native actions + +The optional [action-receipts journal](crates/action-receipts/README.md) provides consent-bound durable actions and read-only recovery for reviewed native adapters. It is separately tested and does not alter the default Shell. diff --git a/README.zh-CN.md b/README.zh-CN.md index f84fa73ca..bbe810631 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -479,3 +479,7 @@ python3 ../rom/scripts/build-home.py --help # the Home and B ## 许可证 Apache License 2.0([LICENSE](LICENSE)、[NOTICE](NOTICE))。从 Makepad 复制的源码保留其 MIT 声明([LICENSES/](LICENSES))。依赖项保留各自的许可证。 + +## 可恢复的原生动作 + +可选的 [action-receipts 执行记录库](crates/action-receipts/README.zh-CN.md)为经过审核的原生适配器提供授权绑定、持久化执行与只读恢复。它单独验证,不改变默认 Shell。 diff --git a/crates/action-receipts/Cargo.lock b/crates/action-receipts/Cargo.lock new file mode 100644 index 000000000..f50fe3e4e --- /dev/null +++ b/crates/action-receipts/Cargo.lock @@ -0,0 +1,519 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "action-receipts" +version = "0.1.0" +dependencies = [ + "hex", + "rand", + "rusqlite", + "serde", + "serde_json", + "sha2", +] + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +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 = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[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.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +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 = "rand" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.5", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "zerocopy" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d35102a9f36d089ccae9e4c6802bc118be4487b80aaffc0ab4e0cf5ce92d2873" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c01f5ab44258da43cf276c74a2763db2ff3969c9c652c3f2de07041d0b2bc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/crates/action-receipts/Cargo.toml b/crates/action-receipts/Cargo.toml new file mode 100644 index 000000000..8b9e3fbe1 --- /dev/null +++ b/crates/action-receipts/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "action-receipts" +version = "0.1.0" +edition = "2024" +license = "Apache-2.0" +description = "Host-owned authorization, durable operations and verified receipts" + + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +rusqlite = { version = "0.40.2", features = ["bundled"] } +rand = "0.8" +sha2 = "0.10" +hex = "0.4" + +# Portable optional library; no UI, SDK or runtime is linked by default. +[workspace] diff --git a/crates/action-receipts/LICENSE b/crates/action-receipts/LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/crates/action-receipts/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/crates/action-receipts/README.md b/crates/action-receipts/README.md new file mode 100644 index 000000000..393c741d5 --- /dev/null +++ b/crates/action-receipts/README.md @@ -0,0 +1,35 @@ +# Recoverable host actions + +English | [简体中文](README.zh-CN.md) + +A portable, optional native-host journal for actions that must survive a lost acknowledgement without repeating their external effect. This implementation is used by [BuWei](https://github.com/WeiR-h/buwei) for activity invitations, participation replies, community publication and articles. + +The library is a standalone workspace, excluded from the default shell build. It changes no existing host service, shell permission or UI. Integrating another service requires a reviewed adapter. + +## Contract + +1. The trusted host binds `Authority` to its authenticated SDK account and issues an opaque, non-serializable `Grant` after consent. +2. `prepare` persists the exact action, identity, revision, digest and confirmation lifetime. +3. `confirm` checks the current grant and adapter's business revision. +4. `execute` claims the durable row before dispatch. The adapter uses the original operation ID as its service transaction key. +5. A verified receipt must match the operation ID, account, target and complete action digest. An uncertain result remains pending. +6. `reconcile` only looks up the original effect. A new consent for the same account can recover an older operation, without resending it. + +Account changes and revoked grants invalidate old confirmations. Queue order and capacity remain application rules. SQLite indexes keep recovery scoped to the application and account even with a large history. + +## Reproduce + +Requires Rust 1.98.0 and a C compiler for bundled SQLite: + +```sh +cargo test --manifest-path crates/action-receipts/Cargo.toml --locked +cargo run --manifest-path crates/action-receipts/Cargo.toml --locked --example benchmark_recovery +``` + +The benchmark compares the same production queries over 50,008 synthetic rows before and after the index migration and checks identical results. It reports local database latency, with no network or model calls. + +Adapters in the unit tests are scripted. Real SDK behavior is separately checked in BuWei's two-account runner; passing these unit tests does not establish another host's integration. The journal uses a host-supplied clock; the host adapter must recheck live identity and permission immediately before an external effect. It must verify server evidence, not treat a successful socket write as a receipt. + +Apache-2.0. No credentials, sessions or raw user records are included. + +The additional `benchmark_owned_history` example keeps all 50,008 records under one account, with 50,005 verified historical results and three unresolved actions. This separates recovery-index gains from cross-account filtering. Run it with the same command and `--example benchmark_owned_history`. diff --git a/crates/action-receipts/README.zh-CN.md b/crates/action-receipts/README.zh-CN.md new file mode 100644 index 000000000..78df9b806 --- /dev/null +++ b/crates/action-receipts/README.zh-CN.md @@ -0,0 +1,35 @@ +# 可恢复的宿主动作 + +[English](README.md) | 简体中文 + +这是一个可选的原生宿主执行记录库,解决服务端已经接收动作、本机却丢失回执后的恢复问题。[补位](https://github.com/WeiR-h/buwei)已将同一实现用于活动邀请、本人报名与回复、社群发布和文章发布。 + +该库使用独立工作区,不加入默认 Shell 构建,不修改现有宿主服务、权限或界面。其他服务接入时需单独审核适配器。 + +## 执行约定 + +1. 可信宿主将 `Authority` 绑定到 SDK 核实的当前账号,在本人授权后签发不可序列化的 `Grant`。 +2. `prepare` 保存确切动作、身份、业务版本、内容摘要和确认期限。 +3. `confirm` 核对当前授权与适配器的业务版本。 +4. `execute` 先持久化认领操作,再执行;适配器用原操作编号作为服务端交易编号。 +5. 回执必须同时匹配操作编号、账号、对象和完整内容摘要,结果不明时保留待核实状态。 +6. `reconcile` 只查询原操作。相同账号重新授权后可以核实旧结果,不重新发送。 + +账号变化、撤销授权使旧确认失效。候补顺序和容量由业务层检查。SQLite 索引按应用、账号和状态查询,避免历史记录增多后全表扫描。 + +## 复现 + +需要 Rust 1.98.0 和用于编译 SQLite 的 C 工具: + +```sh +cargo test --manifest-path crates/action-receipts/Cargo.toml --locked +cargo run --manifest-path crates/action-receipts/Cargo.toml --locked --example benchmark_recovery +``` + +基准在相同 50,008 条合成记录上,对比索引迁移前后的生产查询并核对结果一致。数据只反映本地数据库耗时,不发起网络或模型请求。 + +单元测试使用受控适配器。真实 SDK 行为由补位的独立双账号脚本另行验证;单元测试通过不代表另一个宿主已完成接入。宿主提供时钟;适配器必须在外部动作前重新检查当前身份和权限,并核验服务端证据,不能把套接字写入成功当成回执。 + +Apache-2.0。源码不含密钥、登录会话和原始用户记录。 + +另一个 `benchmark_owned_history` 示例将 50,008 条记录全部归于同一账号,其中 50,005 条为已核验历史结果、3 条待核实,用于区分恢复状态索引与跨账号筛选的收益。使用相同命令并把示例名改为 `benchmark_owned_history` 即可运行。 diff --git a/crates/action-receipts/examples/benchmark_owned_history.rs b/crates/action-receipts/examples/benchmark_owned_history.rs new file mode 100644 index 000000000..c7044133f --- /dev/null +++ b/crates/action-receipts/examples/benchmark_owned_history.rs @@ -0,0 +1,65 @@ +//! Real journal queries over synthetic history; never a network benchmark. +use action_receipts::{Action, Authority, Grant, Journal}; +use rusqlite::{Connection, params}; +use serde_json::{Value, json}; +use std::{hint::black_box, time::Instant}; + +fn measure(journal: &Journal, grant: &Grant) -> Value { + let mut times = Vec::new(); + for _ in 0..50 { + let started = Instant::now(); + assert_eq!(black_box(journal.pending(grant, 101).unwrap()).len(), 3); + assert_eq!(black_box(journal.recent(grant, 101).unwrap()).len(), 40); + times.push(started.elapsed().as_secs_f64() * 1000.0); + } + times.sort_by(f64::total_cmp); + json!({"samples":times.len(), "p50_ms":times[24], "p95_ms":times[47], "maximum_ms":times[49]}) +} + +fn main() -> Result<(), Box> { + let folder = std::env::temp_dir().join(format!("buwei-benchmark-{}", action_receipts::new_id())); + std::fs::create_dir(&folder)?; + let path = folder.join("fixture.db"); + let authority = Authority::default(); + authority.set_account(Some("fixture-owner")); + let grant = authority.grant("fixture-app", &["read"], 100, 3600)?; + let mut journal = Journal::open(&path)?; + let template = journal.prepare(&grant, Action { permission:"read".into(), target:"fixture-target".into(), summary:"synthetic benchmark".into(), payload:json!({}) }, 0, 100, 120)?; + let mut db = Connection::open(&path)?; + let raw: String = db.query_row("SELECT body FROM operations WHERE id=?1", [&template.id], |row| row.get(0))?; + // This temporary fixture starts with the previously shipped table schema. + // The baseline uses the same production query functions, grant and rows. + db.execute_batch("DROP INDEX operations_scope; DROP INDEX operations_pending_scope; DELETE FROM operations;")?; + let transaction = db.transaction()?; + { + let mut insert = transaction.prepare("INSERT INTO operations(id,app,account,status,body) VALUES(?1,?2,?3,?4,?5)")?; + for n in 0..50_008 { + let mut body: Value = serde_json::from_str(&raw)?; + let id = format!("{n:032x}"); + let account = "fixture-owner"; + let status = if n < 3 { "unknown" } else { "confirmed" }; + body["id"] = id.clone().into(); body["account"] = account.into(); body["status"] = status.into(); + body["receipt"] = if n < 3 { + json!({"status":"unknown","evidence":null,"message":"synthetic acknowledgement missing"}) + } else { + json!({"status":"confirmed","message":"synthetic historical result","evidence":{"operation_id":id,"external_id":format!("fixture:{id}"),"account":account,"target":body["action"]["target"],"digest":body["digest"]}}) + }; + insert.execute(params![id, "fixture-app", account, status, body.to_string()])?; + } + } + transaction.commit()?; + let ids = |j: &Journal| j.recent(&grant, 101).unwrap().into_iter().map(|op| op.id).collect::>(); + let original = ids(&journal); + let before = measure(&journal, &grant); + drop(journal); + let journal = Journal::open(&path)?; // The actual backwards-compatible upgrade. + assert_eq!(ids(&journal), original); + let after = measure(&journal, &grant); + println!("{}", json!({"schema":1,"passed":true,"kind":"synthetic_single_account_history","rows":50008,"owned_rows":50008,"pending_rows":3,"results_equal":true,"network_calls":0,"before":before,"after":after})); + drop(journal); drop(db); + // Only this program's random, explicitly created temporary fixture. + std::fs::remove_file(&path)?; + for suffix in ["-wal", "-shm"] { let _ = std::fs::remove_file(folder.join(format!("fixture.db{suffix}"))); } + std::fs::remove_dir(&folder)?; + Ok(()) +} diff --git a/crates/action-receipts/examples/benchmark_recovery.rs b/crates/action-receipts/examples/benchmark_recovery.rs new file mode 100644 index 000000000..f1c0e2646 --- /dev/null +++ b/crates/action-receipts/examples/benchmark_recovery.rs @@ -0,0 +1,60 @@ +//! Real journal queries over synthetic history; never a network benchmark. +use action_receipts::{Action, Authority, Grant, Journal}; +use rusqlite::{Connection, params}; +use serde_json::{Value, json}; +use std::{hint::black_box, time::Instant}; + +fn measure(journal: &Journal, grant: &Grant) -> Value { + let mut times = Vec::new(); + for _ in 0..50 { + let started = Instant::now(); + assert_eq!(black_box(journal.pending(grant, 101).unwrap()).len(), 3); + assert_eq!(black_box(journal.recent(grant, 101).unwrap()).len(), 8); + times.push(started.elapsed().as_secs_f64() * 1000.0); + } + times.sort_by(f64::total_cmp); + json!({"samples":times.len(), "p50_ms":times[24], "p95_ms":times[47], "maximum_ms":times[49]}) +} + +fn main() -> Result<(), Box> { + let folder = std::env::temp_dir().join(format!("buwei-benchmark-{}", action_receipts::new_id())); + std::fs::create_dir(&folder)?; + let path = folder.join("fixture.db"); + let authority = Authority::default(); + authority.set_account(Some("fixture-owner")); + let grant = authority.grant("fixture-app", &["read"], 100, 3600)?; + let mut journal = Journal::open(&path)?; + let template = journal.prepare(&grant, Action { permission:"read".into(), target:"fixture-target".into(), summary:"synthetic benchmark".into(), payload:json!({}) }, 0, 100, 120)?; + let mut db = Connection::open(&path)?; + let raw: String = db.query_row("SELECT body FROM operations WHERE id=?1", [&template.id], |row| row.get(0))?; + // This temporary fixture starts with the previously shipped table schema. + // The baseline uses the same production query functions, grant and rows. + db.execute_batch("DROP INDEX operations_scope; DROP INDEX operations_pending_scope; DELETE FROM operations;")?; + let transaction = db.transaction()?; + { + let mut insert = transaction.prepare("INSERT INTO operations(id,app,account,status,body) VALUES(?1,?2,?3,?4,?5)")?; + for n in 0..50_008 { + let mut body: Value = serde_json::from_str(&raw)?; + let id = format!("{n:032x}"); + let account = if n < 8 { "fixture-owner" } else { "other-fixture-account" }; + let status = if n < 3 { "unknown" } else { "prepared" }; + body["id"] = id.clone().into(); body["account"] = account.into(); body["status"] = status.into(); + insert.execute(params![id, "fixture-app", account, status, body.to_string()])?; + } + } + transaction.commit()?; + let ids = |j: &Journal| j.recent(&grant, 101).unwrap().into_iter().map(|op| op.id).collect::>(); + let original = ids(&journal); + let before = measure(&journal, &grant); + drop(journal); + let journal = Journal::open(&path)?; // The actual backwards-compatible upgrade. + assert_eq!(ids(&journal), original); + let after = measure(&journal, &grant); + println!("{}", json!({"schema":1,"passed":true,"kind":"synthetic_local_journal_queries","rows":50008,"owned_rows":8,"pending_rows":3,"results_equal":true,"network_calls":0,"before":before,"after":after})); + drop(journal); drop(db); + // Only this program's random, explicitly created temporary fixture. + std::fs::remove_file(&path)?; + for suffix in ["-wal", "-shm"] { let _ = std::fs::remove_file(folder.join(format!("fixture.db{suffix}"))); } + std::fs::remove_dir(&folder)?; + Ok(()) +} diff --git a/crates/action-receipts/src/lib.rs b/crates/action-receipts/src/lib.rs new file mode 100644 index 000000000..0ae0de23c --- /dev/null +++ b/crates/action-receipts/src/lib.rs @@ -0,0 +1,571 @@ +//! Native-host library. A script or model never receives a Grant or a client. +//! Network adapters must deduplicate on operation.id and verify their evidence. +use rand::RngCore; +use rusqlite::{Connection, OptionalExtension, params}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::{ + collections::BTreeSet, + path::Path, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64, Ordering}, + }, +}; + +pub type Result = std::result::Result; +#[derive(Debug)] +pub enum Error { + Authorization, + Expired, + Conflict, + Integrity, + Invalid(String), + Storage(String), + State(String), +} +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{self:?}") + } +} +impl std::error::Error for Error {} +impl From for Error { + fn from(e: rusqlite::Error) -> Self { + Self::Storage(e.to_string()) + } +} +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Self::Invalid(e.to_string()) + } +} +pub fn new_id() -> String { + let mut b = [0u8; 16]; + rand::thread_rng().fill_bytes(&mut b); + hex::encode(b) +} + +struct AuthorityState { + id: String, + account: Mutex>, + generation: AtomicU64, +} +#[derive(Clone)] +pub struct Authority(Arc); +impl Default for Authority { + fn default() -> Self { + Self(Arc::new(AuthorityState { + id: new_id(), + account: Mutex::new(None), + generation: AtomicU64::new(0), + })) + } +} +/// Intentionally opaque and not serializable; only a trusted host issues it. +#[derive(Clone)] +pub struct Grant { + authority: Arc, + account: String, + app: String, + instance: String, + generation: u64, + expires: u64, + revoked: Arc, + permissions: BTreeSet, +} +impl Authority { + pub fn set_account(&self, account: Option<&str>) { + let mut current = self.0.account.lock().unwrap(); + if current.as_deref() != account { + *current = account.map(str::to_owned); + self.0.generation.fetch_add(1, Ordering::SeqCst); + } + } + /// Call after the host's consent panel; `now` comes from the host clock. + pub fn grant(&self, app: &str, permissions: &[&str], now: u64, lifetime: u64) -> Result { + let current = self.0.account.lock().unwrap(); + let account = current.clone().ok_or(Error::Authorization)?; + let generation = self.0.generation.load(Ordering::SeqCst); + drop(current); + if app.is_empty() || permissions.is_empty() || lifetime == 0 { + return Err(Error::Invalid("empty grant".into())); + } + Ok(Grant { + authority: self.0.clone(), + account, + app: app.into(), + instance: new_id(), + generation, + expires: now.saturating_add(lifetime.min(3600)), + revoked: Arc::new(AtomicBool::new(false)), + permissions: permissions.iter().map(|s| s.to_string()).collect(), + }) + } +} +impl Grant { + pub fn account(&self) -> &str { + &self.account + } + pub fn app(&self) -> &str { + &self.app + } + pub fn revoke(&self) { + self.revoked.store(true, Ordering::SeqCst); + } + /// Native adapters use this before local state changes as well as effects. + /// It does not serialize or manufacture an identity for guest code. + pub fn check(&self, permission: &str, now: u64) -> Result<()> { + let account = self.authority.account.lock().unwrap(); + if self.revoked.load(Ordering::SeqCst) + || self.authority.generation.load(Ordering::SeqCst) != self.generation + || account.as_deref() != Some(self.account.as_str()) + || !self.permissions.contains(permission) + { + return Err(Error::Authorization); + } + if now >= self.expires { + return Err(Error::Expired); + } + Ok(()) + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Action { + pub permission: String, + pub target: String, + pub summary: String, + pub payload: Value, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Status { + Prepared, + Queued, + Dispatching, + Confirmed, + Failed, + Unknown, + Cancelled, + Expired, +} +impl Status { + fn as_str(self) -> &'static str { + match self { + Self::Prepared => "prepared", + Self::Queued => "queued", + Self::Dispatching => "dispatching", + Self::Confirmed => "confirmed", + Self::Failed => "failed", + Self::Unknown => "unknown", + Self::Cancelled => "cancelled", + Self::Expired => "expired", + } + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Evidence { + pub operation_id: String, + pub external_id: String, + pub account: String, + pub target: String, + pub digest: String, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Receipt { + pub status: Status, + pub evidence: Option, + pub message: String, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Operation { + pub id: String, + pub app: String, + pub account: String, + pub action: Action, + pub revision: u64, + pub digest: String, + pub created_at: u64, + pub expires_at: u64, + pub status: Status, + pub receipt: Option, + #[serde(skip_serializing)] + pub(crate) authority_id: String, + #[serde(skip_serializing)] + pub(crate) generation: u64, + #[serde(skip_serializing)] + pub(crate) instance: String, +} +pub enum Dispatch { + Verified(Evidence), + Rejected(String), + Uncertain(String), +} +/// Implementations own the SDK client. Never expose a raw client to an app. +pub trait Adapter { + fn validate(&self, action: &Action, expected_revision: u64) -> Result<()>; + fn dispatch(&self, operation: &Operation) -> Dispatch; + fn lookup(&self, operation: &Operation) -> Result>; +} +fn digest(action: &Action) -> Result { + Ok(hex::encode(Sha256::digest(serde_json::to_vec(action)?))) +} + +pub struct Journal { + db: Connection, +} +impl Journal { + pub fn open(path: impl AsRef) -> Result { + let db = Connection::open(path)?; + db.busy_timeout(std::time::Duration::from_secs(3))?; + db.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=FULL;")?; + let version: u32 = db.query_row("PRAGMA user_version", [], |r| r.get(0))?; + if version > 1 { + return Err(Error::Storage( + "unsupported journal schema; original file preserved".into(), + )); + } + db.execute_batch("CREATE TABLE IF NOT EXISTS operations(id TEXT PRIMARY KEY, app TEXT NOT NULL, account TEXT NOT NULL, status TEXT NOT NULL, body TEXT NOT NULL); + CREATE INDEX IF NOT EXISTS operations_scope ON operations(app, account); + CREATE INDEX IF NOT EXISTS operations_pending_scope ON operations(app, account, status); + PRAGMA user_version=1;")?; + Ok(Self { db }) + } + fn body(op: &Operation) -> Result { + // Context bindings are host metadata, stored separately from public JSON. + let mut body = serde_json::to_value(op)?; + body["authority_id"] = op.authority_id.clone().into(); + body["generation"] = op.generation.into(); + body["instance"] = op.instance.clone().into(); + Ok(body.to_string()) + } + fn get(&self, id: &str) -> Result { + let raw: String = self + .db + .query_row("SELECT body FROM operations WHERE id=?1", [id], |r| { + r.get(0) + }) + .optional()? + .ok_or(Error::Invalid("operation not found".into()))?; + let op: Operation = serde_json::from_str(&raw)?; + if op.id != id || digest(&op.action)? != op.digest { + return Err(Error::Integrity); + } + Ok(op) + } + fn transition(&self, op: &Operation, from: &[Status]) -> Result<()> { + let mut body = serde_json::to_value(op)?; + body["authority_id"] = op.authority_id.clone().into(); + body["generation"] = op.generation.into(); + body["instance"] = op.instance.clone().into(); + let allowed = from + .iter() + .map(|s| format!("'{}'", s.as_str())) + .collect::>() + .join(","); + let sql = format!( + "UPDATE operations SET status=?2,body=?3 WHERE id=?1 AND status IN ({allowed})" + ); + if self + .db + .execute(&sql, params![op.id, op.status.as_str(), body.to_string()])? + != 1 + { + return Err(Error::Conflict); + } + Ok(()) + } + fn owns(grant: &Grant, op: &Operation, now: u64) -> Result<()> { + grant.check(&op.action.permission, now)?; + if grant.app != op.app || grant.account != op.account { + return Err(Error::Authorization); + } + Ok(()) + } + fn same_context(grant: &Grant, op: &Operation) -> Result<()> { + if grant.authority.id != op.authority_id + || grant.generation != op.generation + || grant.instance != op.instance + { + return Err(Error::Authorization); + } + Ok(()) + } + pub fn prepare( + &mut self, + grant: &Grant, + action: Action, + revision: u64, + now: u64, + ttl: u64, + ) -> Result { + grant.check(&action.permission, now)?; + if action.target.trim().is_empty() + || action.summary.trim().is_empty() + || action.summary.len() > 1024 + || serde_json::to_vec(&action)?.len() > 32768 + || !(1..=120).contains(&ttl) + { + return Err(Error::Invalid( + "invalid action or confirmation lifetime".into(), + )); + } + let op = Operation { + id: new_id(), + app: grant.app.clone(), + account: grant.account.clone(), + digest: digest(&action)?, + action, + revision, + created_at: now, + expires_at: now.saturating_add(ttl).min(grant.expires), + status: Status::Prepared, + receipt: None, + authority_id: grant.authority.id.clone(), + generation: grant.generation, + instance: grant.instance.clone(), + }; + // One current preview per app/account. Editing content creates a new ID + // and invalidates earlier unattempted confirmations in one transaction. + let body = Self::body(&op)?; + let tx = self.db.transaction()?; + tx.execute("UPDATE operations SET status='cancelled',body=json_set(body,'$.status','cancelled') WHERE app=?1 AND account=?2 AND status IN ('prepared','queued')",params![op.app,op.account])?; + tx.execute( + "INSERT INTO operations(id,app,account,status,body) VALUES(?1,?2,?3,?4,?5)", + params![op.id, op.app, op.account, op.status.as_str(), body], + )?; + tx.commit()?; + Ok(op) + } + /// Unsent previews may be reused only within the exact active consent. + pub fn preview_current(grant: &Grant, op: &Operation, now: u64) -> bool { + Self::owns(grant, op, now).is_ok() + && Self::same_context(grant, op).is_ok() + && matches!(op.status, Status::Prepared | Status::Queued) + && now < op.expires_at + } + pub fn confirm( + &mut self, + grant: &Grant, + id: &str, + adapter: &impl Adapter, + now: u64, + ) -> Result { + let mut op = self.get(id)?; + Self::owns(grant, &op, now)?; + Self::same_context(grant, &op)?; + if matches!( + op.status, + Status::Queued | Status::Dispatching | Status::Unknown | Status::Confirmed + ) { + return Ok(op); + } + if op.status != Status::Prepared { + return Err(Error::State("cannot confirm this operation".into())); + } + if now >= op.expires_at { + op.status = Status::Expired; + self.transition(&op, &[Status::Prepared])?; + return Err(Error::Expired); + } + adapter.validate(&op.action, op.revision)?; + op.status = Status::Queued; + self.transition(&op, &[Status::Prepared])?; + Ok(op) + } + pub fn cancel(&mut self, grant: &Grant, id: &str, now: u64) -> Result<()> { + let mut op = self.get(id)?; + Self::owns(grant, &op, now)?; + Self::same_context(grant, &op)?; + if !matches!(op.status, Status::Prepared | Status::Queued) { + return Err(Error::State( + "an attempted operation must be reconciled".into(), + )); + } + op.status = Status::Cancelled; + self.transition(&op, &[Status::Prepared, Status::Queued]) + } + /// A current host read grant may retire all unsent previews for its own + /// app/account, including previews bound to an earlier consent. Attempted + /// operations keep their original IDs and must still be reconciled. + pub fn invalidate_previews(&mut self, grant: &Grant, now: u64) -> Result { + grant.check("read", now)?; + Ok(self.db.execute( + "UPDATE operations SET status='cancelled',body=json_set(body,'$.status','cancelled') WHERE app=?1 AND account=?2 AND status IN ('prepared','queued')", + params![grant.app, grant.account], + )?) + } + pub fn execute( + &mut self, + grant: &Grant, + id: &str, + adapter: &impl Adapter, + now: u64, + ) -> Result { + let mut op = self.get(id)?; + Self::owns(grant, &op, now)?; + // A completed effect is observable under a fresh consent for the same account. + if op.status == Status::Confirmed { + return Ok(op); + } + Self::same_context(grant, &op)?; + if op.status != Status::Queued { + return Err(Error::State( + "reconcile attempted operations; never send them blindly".into(), + )); + } + if now >= op.expires_at { + op.status = Status::Expired; + self.transition(&op, &[Status::Queued])?; + return Err(Error::Expired); + } + adapter.validate(&op.action, op.revision)?; + // Conditional acquisition also protects multiple native-host connections. + op.status = Status::Dispatching; + let mut body = serde_json::to_value(&op)?; + body["authority_id"] = op.authority_id.clone().into(); + body["generation"] = op.generation.into(); + body["instance"] = op.instance.clone().into(); + let changed = self.db.execute( + "UPDATE operations SET status='dispatching',body=?2 WHERE id=?1 AND status='queued'", + params![id, body.to_string()], + )?; + if changed != 1 { + return Err(Error::Conflict); + } + grant.check(&op.action.permission, now)?; + let result = adapter.dispatch(&op); + self.finish(op, result) + } + fn finish(&mut self, mut op: Operation, result: Dispatch) -> Result { + let (status, evidence, message) = match result { + Dispatch::Verified(e) + if e.operation_id == op.id + && e.account == op.account + && e.target == op.action.target + && e.digest == op.digest + && !e.external_id.trim().is_empty() => + { + ( + Status::Confirmed, + Some(e), + "host verified the external result".into(), + ) + } + Dispatch::Verified(_) => ( + Status::Unknown, + None, + "external evidence did not match the confirmed operation".into(), + ), + Dispatch::Rejected(m) => (Status::Failed, None, m), + Dispatch::Uncertain(m) => (Status::Unknown, None, m), + }; + op.status = status; + op.receipt = Some(Receipt { + status, + evidence, + message, + }); + match self.transition(&op, &[Status::Dispatching, Status::Unknown]) { + Ok(()) => Ok(op), + Err(Error::Conflict) => { + let current = self.get(&op.id)?; + if current.status == Status::Confirmed { + Ok(current) + } else { + Err(Error::Conflict) + } + } + Err(e) => Err(e), + } + } + /// Read-only external lookup: valid even after the action deadline; never sends. + pub fn reconcile( + &mut self, + grant: &Grant, + id: &str, + adapter: &impl Adapter, + now: u64, + ) -> Result { + let op = self.get(id)?; + Self::owns(grant, &op, now)?; + if op.status == Status::Confirmed { + return Ok(op); + } + if !matches!(op.status, Status::Unknown | Status::Dispatching) { + return Err(Error::State( + "operation has no uncertain external effect".into(), + )); + } + match adapter.lookup(&op)? { + Some(e) => self.finish(op, Dispatch::Verified(e)), + None => self.finish( + op, + Dispatch::Uncertain("no external evidence yet; nothing was resent".into()), + ), + } + } + pub fn receipt(&self, grant: &Grant, id: &str, now: u64) -> Result { + let op = self.get(id)?; + Self::owns(grant, &op, now)?; + Ok(op) + } + /// All unresolved attempts owned by this account, including records older + /// than the visible history. A fresh consent may look up, never re-send. + pub fn pending(&self, grant: &Grant, now: u64) -> Result> { + let permission = grant + .permissions + .iter() + .next() + .ok_or(Error::Authorization)?; + grant.check(permission, now)?; + let mut statement=self.db.prepare("SELECT id FROM operations WHERE app=?1 AND account=?2 AND status IN ('dispatching','unknown') ORDER BY rowid")?; + let ids = statement + .query_map(params![grant.app, grant.account], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + ids.into_iter() + .map(|id| self.receipt(grant, &id, now)) + .collect() + } + pub fn recent(&self, grant: &Grant, now: u64) -> Result> { + // An empty journal must not turn an expired or revoked grant into a valid read. + let permission = grant + .permissions + .iter() + .next() + .ok_or(Error::Authorization)?; + grant.check(permission, now)?; + let mut statement = self.db.prepare( + "SELECT id FROM operations WHERE app=?1 AND account=?2 ORDER BY rowid DESC LIMIT 40", + )?; + let ids = statement + .query_map(params![grant.app, grant.account], |r| r.get::<_, String>(0))? + .collect::, _>>()?; + ids.into_iter() + .map(|id| self.receipt(grant, &id, now)) + .collect() + } + /// Task recovery is independent of the forty-row display history. Every + /// referenced receipt is still checked against the host's current grant. + pub fn recent_with_references( + &self, + grant: &Grant, + references: &[String], + now: u64, + ) -> Result> { + let mut operations = self.recent(grant, now)?; + let mut seen = operations + .iter() + .map(|op| op.id.clone()) + .collect::>(); + for id in references { + if seen.insert(id.clone()) { + operations.push(self.receipt(grant, id, now)?); + } + } + Ok(operations) + } +} diff --git a/crates/action-receipts/tests/workflow.rs b/crates/action-receipts/tests/workflow.rs new file mode 100644 index 000000000..5e816e9a0 --- /dev/null +++ b/crates/action-receipts/tests/workflow.rs @@ -0,0 +1,657 @@ +use action_receipts::*; +use serde_json::json; +use std::sync::{ + Mutex, + atomic::{AtomicU64, AtomicUsize, Ordering}, +}; + +struct Backend { + revision: AtomicU64, + calls: AtomicUsize, + evidence: Mutex>, + mode: u8, +} +impl Backend { + fn new(mode: u8) -> Self { + Self { + revision: AtomicU64::new(0), + calls: AtomicUsize::new(0), + evidence: Mutex::new(None), + mode, + } + } +} +impl Adapter for Backend { + fn validate(&self, _: &Action, revision: u64) -> Result<()> { + if self.revision.load(Ordering::SeqCst) != revision { + Err(Error::Conflict) + } else { + Ok(()) + } + } + fn dispatch(&self, op: &Operation) -> Dispatch { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.mode == 2 { + return Dispatch::Rejected("permission refused".into()); + } + let mut e = Evidence { + operation_id: op.id.clone(), + external_id: format!("fixture:{}", op.id), + account: op.account.clone(), + target: op.action.target.clone(), + digest: op.digest.clone(), + }; + if self.mode == 3 { + e.target = "another recipient".into(); + } + *self.evidence.lock().unwrap() = Some(e.clone()); + self.revision.fetch_add(1, Ordering::SeqCst); + if self.mode == 1 { + Dispatch::Uncertain("lost acknowledgement".into()) + } else { + Dispatch::Verified(e) + } + } + fn lookup(&self, _: &Operation) -> Result> { + Ok(self.evidence.lock().unwrap().clone()) + } +} +fn path() -> std::path::PathBuf { + let p = std::env::temp_dir().join(format!("buwei-journal-{}", new_id())); + std::fs::create_dir_all(&p).unwrap(); + p.join("operations.db") +} +fn setup() -> (Authority, Grant, Journal) { + let a = Authority::default(); + a.set_account(Some("alice")); + let g = a.grant("buwei", &["invite"], 100, 1000).unwrap(); + let j = Journal::open(path()).unwrap(); + (a, g, j) +} +fn action() -> Action { + Action { + permission: "invite".into(), + target: "lin".into(), + summary: "reserve one local fixture seat".into(), + payload: json!({"seats":1}), + } +} +fn queued(j: &mut Journal, g: &Grant, b: &Backend) -> Operation { + let o = j.prepare(g, action(), 0, 100, 120).unwrap(); + j.confirm(g, &o.id, b, 101).unwrap() +} + +#[test] +fn normal_and_repeated_execute_have_one_effect() { + let (_, g, mut j) = setup(); + let b = Backend::new(0); + let o = queued(&mut j, &g, &b); + assert_eq!( + j.execute(&g, &o.id, &b, 102).unwrap().status, + Status::Confirmed + ); + assert_eq!( + j.execute(&g, &o.id, &b, 103).unwrap().status, + Status::Confirmed + ); + assert_eq!(b.calls.load(Ordering::SeqCst), 1); +} +#[test] +fn invalidating_previews_preserves_attempts_and_other_accounts_or_apps() { + let authority = Authority::default(); + authority.set_account(Some("alice")); + let grant = authority.grant("buwei", &["invite", "read"], 100, 1000).unwrap(); + let mut journal = Journal::open(path()).unwrap(); + let confirmed_backend = Backend::new(0); + let confirmed = queued(&mut journal, &grant, &confirmed_backend); + journal.execute(&grant, &confirmed.id, &confirmed_backend, 102).unwrap(); + let uncertain_backend = Backend::new(1); + let uncertain = queued(&mut journal, &grant, &uncertain_backend); + journal.execute(&grant, &uncertain.id, &uncertain_backend, 102).unwrap(); + let preview = journal.prepare(&grant, action(), 0, 103, 120).unwrap(); + + let other_authority = Authority::default(); + other_authority.set_account(Some("bob")); + let other_account = other_authority.grant("buwei", &["invite", "read"], 100, 1000).unwrap(); + let bob_preview = journal.prepare(&other_account, action(), 0, 103, 120).unwrap(); + let other_app = authority.grant("another-app", &["invite", "read"], 100, 1000).unwrap(); + let app_preview = journal.prepare(&other_app, action(), 0, 103, 120).unwrap(); + + // Only cancellation crosses a consent boundary: an old action is never sent. + let renewed = authority.grant("buwei", &["read"], 104, 1000).unwrap(); + assert_eq!(journal.invalidate_previews(&renewed, 105).unwrap(), 1); + assert_eq!(journal.receipt(&grant, &preview.id, 105).unwrap().status, Status::Cancelled); + assert!(journal.confirm(&grant, &preview.id, &Backend::new(0), 105).is_err()); + assert_eq!(journal.receipt(&grant, &confirmed.id, 105).unwrap().status, Status::Confirmed); + assert_eq!(journal.receipt(&grant, &uncertain.id, 105).unwrap().status, Status::Unknown); + assert_eq!(journal.receipt(&other_account, &bob_preview.id, 105).unwrap().status, Status::Prepared); + assert_eq!(journal.receipt(&other_app, &app_preview.id, 105).unwrap().status, Status::Prepared); + assert_eq!(confirmed_backend.calls.load(Ordering::SeqCst), 1); + assert_eq!(uncertain_backend.calls.load(Ordering::SeqCst), 1); + renewed.revoke(); + assert!(journal.invalidate_previews(&renewed, 106).is_err()); + assert!(journal.invalidate_previews(&authority.grant("buwei", &["invite"], 106, 1000).unwrap(), 107).is_err()); +} + +#[test] +fn invalidated_queued_operation_remains_unsent_after_restart() { + let authority = Authority::default(); + authority.set_account(Some("alice")); + let grant = authority.grant("buwei", &["invite", "read"], 100, 1000).unwrap(); + let db_path = path(); + let mut journal = Journal::open(&db_path).unwrap(); + let backend = Backend::new(0); + let preview = queued(&mut journal, &grant, &backend); + let renewed = authority.grant("buwei", &["read"], 104, 1000).unwrap(); + assert_eq!(journal.invalidate_previews(&renewed, 105).unwrap(), 1); + drop(journal); + let mut recovered = Journal::open(&db_path).unwrap(); + assert_eq!(recovered.receipt(&grant, &preview.id, 106).unwrap().status, Status::Cancelled); + assert!(recovered.execute(&grant, &preview.id, &backend, 106).is_err()); + assert_eq!(backend.calls.load(Ordering::SeqCst), 0); + assert_eq!(recovered.invalidate_previews(&renewed, 107).unwrap(), 0); + authority.set_account(Some("bob")); + assert!(recovered.invalidate_previews(&renewed, 108).is_err()); +} +#[test] +fn confirmation_visibility_agrees_with_expiry_and_new_consent() { + let (a, g, mut j) = setup(); + let backend = Backend::new(0); + let preview = j.prepare(&g, action(), 0, 100, 10).unwrap(); + assert!(Journal::preview_current(&g, &preview, 109)); + assert!(!Journal::preview_current(&g, &preview, 110)); + let renewed = a.grant("buwei", &["invite"], 104, 1000).unwrap(); + assert!(!Journal::preview_current(&renewed, &preview, 105)); + assert!(j.confirm(&renewed, &preview.id, &backend, 105).is_err()); + g.revoke(); + assert!(!Journal::preview_current(&g, &preview, 105)); + assert!(j.confirm(&g, &preview.id, &backend, 105).is_err()); + assert_eq!( + j.receipt(&renewed, &preview.id, 105).unwrap().status, + Status::Prepared + ); + assert_eq!(backend.calls.load(Ordering::SeqCst), 0); +} +#[test] +fn account_switch_and_switch_back_invalidate_old_context() { + let (a, g, mut j) = setup(); + let b = Backend::new(0); + let o = j.prepare(&g, action(), 0, 100, 120).unwrap(); + a.set_account(Some("bob")); + a.set_account(Some("alice")); + assert!(matches!( + j.confirm(&g, &o.id, &b, 101), + Err(Error::Authorization) + )); + let fresh = a.grant("buwei", &["invite"], 101, 1000).unwrap(); + assert!(matches!( + j.confirm(&fresh, &o.id, &b, 102), + Err(Error::Authorization) + )); + assert_eq!(b.calls.load(Ordering::SeqCst), 0); +} +#[test] +fn revoke_after_preview_rejects_confirmation() { + let (_, g, mut j) = setup(); + let b = Backend::new(0); + let o = j.prepare(&g, action(), 0, 100, 120).unwrap(); + g.revoke(); + assert!(matches!( + j.confirm(&g, &o.id, &b, 101), + Err(Error::Authorization) + )); +} +#[test] +fn revoke_after_confirmation_rejects_execution() { + let (_, g, mut j) = setup(); + let b = Backend::new(0); + let o = queued(&mut j, &g, &b); + g.revoke(); + assert!(matches!( + j.execute(&g, &o.id, &b, 102), + Err(Error::Authorization) + )); + assert_eq!(b.calls.load(Ordering::SeqCst), 0); +} +#[test] +fn expired_confirmation_is_not_queued() { + let (_, g, mut j) = setup(); + let b = Backend::new(0); + let o = j.prepare(&g, action(), 0, 100, 2).unwrap(); + assert!(matches!(j.confirm(&g, &o.id, &b, 102), Err(Error::Expired))); + assert_eq!(j.receipt(&g, &o.id, 103).unwrap().status, Status::Expired); +} +#[test] +fn expiry_between_confirmation_and_execute_never_dispatches() { + let (_, g, mut j) = setup(); + let b = Backend::new(0); + let o = queued(&mut j, &g, &b); + assert!(matches!(j.execute(&g, &o.id, &b, 220), Err(Error::Expired))); + assert_eq!(b.calls.load(Ordering::SeqCst), 0); +} +#[test] +fn changed_business_revision_invalidates_preview() { + let (_, g, mut j) = setup(); + let b = Backend::new(0); + let o = j.prepare(&g, action(), 0, 100, 120).unwrap(); + b.revision.store(1, Ordering::SeqCst); + assert!(matches!( + j.confirm(&g, &o.id, &b, 101), + Err(Error::Conflict) + )); +} +#[test] +fn changed_business_revision_after_confirmation_never_dispatches() { + let (_, g, mut j) = setup(); + let b = Backend::new(0); + let o = queued(&mut j, &g, &b); + b.revision.store(1, Ordering::SeqCst); + assert!(matches!( + j.execute(&g, &o.id, &b, 102), + Err(Error::Conflict) + )); + assert_eq!(b.calls.load(Ordering::SeqCst), 0); +} +#[test] +fn rejected_result_is_failed_without_success_evidence() { + let (_, g, mut j) = setup(); + let b = Backend::new(2); + let o = queued(&mut j, &g, &b); + let r = j.execute(&g, &o.id, &b, 102).unwrap(); + assert_eq!(r.status, Status::Failed); + assert!(r.receipt.unwrap().evidence.is_none()); + assert!(j.execute(&g, &o.id, &b, 103).is_err()); +} +#[test] +fn lost_reply_can_be_reconciled_without_resending() { + let (_, g, mut j) = setup(); + let b = Backend::new(1); + let o = queued(&mut j, &g, &b); + assert_eq!( + j.execute(&g, &o.id, &b, 102).unwrap().status, + Status::Unknown + ); + assert!(j.execute(&g, &o.id, &b, 103).is_err()); + assert_eq!( + j.reconcile(&g, &o.id, &b, 104).unwrap().status, + Status::Confirmed + ); + assert_eq!(b.calls.load(Ordering::SeqCst), 1); +} +#[test] +fn unavailable_receipt_stays_unknown() { + let (_, g, mut j) = setup(); + let b = Backend::new(1); + let o = queued(&mut j, &g, &b); + j.execute(&g, &o.id, &b, 102).unwrap(); + *b.evidence.lock().unwrap() = None; + assert_eq!( + j.reconcile(&g, &o.id, &b, 103).unwrap().status, + Status::Unknown + ); + assert_eq!(b.calls.load(Ordering::SeqCst), 1); +} +#[test] +fn mismatched_external_evidence_never_confirms() { + let (_, g, mut j) = setup(); + let b = Backend::new(3); + let o = queued(&mut j, &g, &b); + assert_eq!( + j.execute(&g, &o.id, &b, 102).unwrap().status, + Status::Unknown + ); +} +#[test] +fn external_operation_id_and_digest_must_match() { + let (_, g, mut j) = setup(); + let b = Backend::new(1); + let o = queued(&mut j, &g, &b); + j.execute(&g, &o.id, &b, 102).unwrap(); + b.evidence.lock().unwrap().as_mut().unwrap().operation_id = "wrong".into(); + assert_eq!( + j.reconcile(&g, &o.id, &b, 103).unwrap().status, + Status::Unknown + ); + b.evidence.lock().unwrap().as_mut().unwrap().operation_id = o.id.clone(); + b.evidence.lock().unwrap().as_mut().unwrap().digest = "wrong".into(); + assert_eq!( + j.reconcile(&g, &o.id, &b, 104).unwrap().status, + Status::Unknown + ); +} +#[test] +fn restart_recovers_unknown_with_fresh_same_account_consent() { + let p = path(); + let a = Authority::default(); + a.set_account(Some("alice")); + let g = a.grant("buwei", &["invite"], 100, 1000).unwrap(); + let b = Backend::new(1); + let id = { + let mut j = Journal::open(&p).unwrap(); + let o = queued(&mut j, &g, &b); + j.execute(&g, &o.id, &b, 102).unwrap(); + o.id + }; + let a2 = Authority::default(); + a2.set_account(Some("alice")); + let g2 = a2.grant("buwei", &["invite"], 400, 1000).unwrap(); + let mut j = Journal::open(&p).unwrap(); + assert_eq!( + j.reconcile(&g2, &id, &b, 401).unwrap().status, + Status::Confirmed + ); + assert_eq!(b.calls.load(Ordering::SeqCst), 1); +} +#[test] +fn queued_work_is_not_automatically_reauthorized_on_restart() { + let p = path(); + let a = Authority::default(); + a.set_account(Some("alice")); + let g = a.grant("buwei", &["invite"], 100, 1000).unwrap(); + let b = Backend::new(0); + let id = { + let mut j = Journal::open(&p).unwrap(); + queued(&mut j, &g, &b).id + }; + let a2 = Authority::default(); + a2.set_account(Some("alice")); + let g2 = a2.grant("buwei", &["invite"], 101, 1000).unwrap(); + let mut j = Journal::open(&p).unwrap(); + assert!(matches!( + j.execute(&g2, &id, &b, 102), + Err(Error::Authorization) + )); + assert_eq!(b.calls.load(Ordering::SeqCst), 0); +} +#[test] +fn wrong_account_or_app_cannot_read_receipts() { + let (a, g, mut j) = setup(); + let o = j.prepare(&g, action(), 0, 100, 120).unwrap(); + let other = a.grant("article", &["invite"], 100, 1000).unwrap(); + assert!(matches!( + j.receipt(&other, &o.id, 101), + Err(Error::Authorization) + )); + a.set_account(Some("bob")); + let other = a.grant("buwei", &["invite"], 100, 1000).unwrap(); + assert!(matches!( + j.receipt(&other, &o.id, 101), + Err(Error::Authorization) + )); +} +#[test] +fn undeclared_permission_is_rejected() { + let (_, g, mut j) = setup(); + let mut act = action(); + act.permission = "publish".into(); + assert!(matches!( + j.prepare(&g, act, 0, 100, 120), + Err(Error::Authorization) + )); +} +#[test] +fn cancel_prevents_execution() { + let (_, g, mut j) = setup(); + let b = Backend::new(0); + let o = queued(&mut j, &g, &b); + j.cancel(&g, &o.id, 102).unwrap(); + assert!(j.execute(&g, &o.id, &b, 103).is_err()); + assert_eq!(b.calls.load(Ordering::SeqCst), 0); +} +#[test] +fn uncertain_effect_cannot_be_cancelled_locally() { + let (_, g, mut j) = setup(); + let b = Backend::new(1); + let o = queued(&mut j, &g, &b); + j.execute(&g, &o.id, &b, 102).unwrap(); + assert!(j.cancel(&g, &o.id, 103).is_err()); +} +#[test] +fn journal_tampering_is_detected_before_effects() { + let p = path(); + let (_, g, _) = setup(); + let mut j = Journal::open(&p).unwrap(); + let o = j.prepare(&g, action(), 0, 100, 120).unwrap(); + let db = rusqlite::Connection::open(&p).unwrap(); + db.execute( + "UPDATE operations SET body=json_set(body,'$.action.target','forged')", + [], + ) + .unwrap(); + let b = Backend::new(0); + assert!(matches!( + j.confirm(&g, &o.id, &b, 101), + Err(Error::Integrity) + )); + assert_eq!(b.calls.load(Ordering::SeqCst), 0); +} +#[test] +fn two_host_connections_do_not_dispatch_twice() { + let p = path(); + let (a, g, _) = setup(); + let mut j1 = Journal::open(&p).unwrap(); + let mut j2 = Journal::open(&p).unwrap(); + let b = Backend::new(0); + let o = queued(&mut j1, &g, &b); + j1.execute(&g, &o.id, &b, 102).unwrap(); + assert_eq!( + j2.execute(&g, &o.id, &b, 103).unwrap().status, + Status::Confirmed + ); + assert_eq!(b.calls.load(Ordering::SeqCst), 1); + drop(a); +} +#[test] +fn same_library_serves_another_application() { + let (a, _, mut j) = setup(); + let g = a.grant("article", &["publish"], 100, 1000).unwrap(); + let b = Backend::new(0); + let act = Action { + permission: "publish".into(), + target: "local-fixture-room".into(), + summary: "publish a fixture article".into(), + payload: json!({"markdown":"hello"}), + }; + let o = j.prepare(&g, act, 0, 100, 120).unwrap(); + j.confirm(&g, &o.id, &b, 101).unwrap(); + assert_eq!( + j.execute(&g, &o.id, &b, 102).unwrap().status, + Status::Confirmed + ); +} +#[test] +fn invalid_confirmation_ttl_and_oversized_action_are_rejected() { + let (_, g, mut j) = setup(); + assert!(j.prepare(&g, action(), 0, 100, 121).is_err()); + let mut act = action(); + act.payload = json!({"text":"x".repeat(40000)}); + assert!(j.prepare(&g, act, 0, 100, 120).is_err()); +} +#[test] +fn unsupported_journal_version_is_preserved() { + let p = path(); + let db = rusqlite::Connection::open(&p).unwrap(); + db.execute_batch("PRAGMA user_version=99;").unwrap(); + assert!(Journal::open(&p).is_err()); + assert_eq!( + db.query_row("PRAGMA user_version", [], |r| r.get::<_, u32>(0)) + .unwrap(), + 99 + ); +} + +#[test] +fn editing_content_invalidates_old_preview_and_queued_confirmation() { + let (_, g, mut j) = setup(); + let b = Backend::new(0); + let old = queued(&mut j, &g, &b); + let mut edited = action(); + edited.summary = "edited invitation".into(); + edited.payload = json!({"seats":1,"note":"new content"}); + let fresh = j.prepare(&g, edited, 0, 102, 120).unwrap(); + assert_ne!(old.digest, fresh.digest); + assert_eq!( + j.receipt(&g, &old.id, 103).unwrap().status, + Status::Cancelled + ); + assert!(j.execute(&g, &old.id, &b, 103).is_err()); + j.confirm(&g, &fresh.id, &b, 103).unwrap(); + j.execute(&g, &fresh.id, &b, 104).unwrap(); + assert_eq!(b.calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn empty_journal_requires_valid_authorization() { + let (_, g, j) = setup(); + g.revoke(); + assert!(matches!(j.recent(&g, 101), Err(Error::Authorization))); + let (_, g, j) = setup(); + assert!(matches!(j.recent(&g, 1100), Err(Error::Expired))); +} + +#[test] +fn receipt_from_another_sender_is_not_confirmed() { + let (_, g, mut j) = setup(); + let b = Backend::new(1); + let o = queued(&mut j, &g, &b); + j.execute(&g, &o.id, &b, 102).unwrap(); + b.evidence.lock().unwrap().as_mut().unwrap().account = "bob".into(); + assert_eq!( + j.reconcile(&g, &o.id, &b, 103).unwrap().status, + Status::Unknown + ); + assert_eq!(b.calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn simultaneous_host_connections_dispatch_once() { + use std::sync::{Arc, Barrier}; + let p = path(); + let (_, g, _) = setup(); + let b = Arc::new(Backend::new(0)); + let mut j = Journal::open(&p).unwrap(); + let op = queued(&mut j, &g, &b); + drop(j); + let barrier = Arc::new(Barrier::new(2)); + let threads = (0..2) + .map(|_| { + let p = p.clone(); + let g = g.clone(); + let b = b.clone(); + let id = op.id.clone(); + let barrier = barrier.clone(); + std::thread::spawn(move || { + let mut j = Journal::open(p).unwrap(); + barrier.wait(); + j.execute(&g, &id, b.as_ref(), 102) + }) + }) + .collect::>(); + for t in threads { + let _ = t.join().unwrap(); + } + assert_eq!(b.calls.load(Ordering::SeqCst), 1); + assert_eq!( + Journal::open(p) + .unwrap() + .receipt(&g, &op.id, 103) + .unwrap() + .status, + Status::Confirmed + ); +} + +struct CrashAfterCommit(Backend); +impl Adapter for CrashAfterCommit { + fn validate(&self, a: &Action, r: u64) -> Result<()> { + self.0.validate(a, r) + } + fn dispatch(&self, o: &Operation) -> Dispatch { + self.0.dispatch(o); + panic!("simulated host crash after durable external effect") + } + fn lookup(&self, o: &Operation) -> Result> { + self.0.lookup(o) + } +} +#[test] +fn crash_after_effect_recovers_dispatching_without_resend() { + let p = path(); + let (_, g, _) = setup(); + let b = CrashAfterCommit(Backend::new(0)); + let mut j = Journal::open(&p).unwrap(); + let o = j.prepare(&g, action(), 0, 100, 120).unwrap(); + j.confirm(&g, &o.id, &b, 101).unwrap(); + assert!( + std::panic::catch_unwind(std::panic::AssertUnwindSafe( + || j.execute(&g, &o.id, &b, 102) + )) + .is_err() + ); + assert_eq!( + j.receipt(&g, &o.id, 103).unwrap().status, + Status::Dispatching + ); + drop(j); + let a = Authority::default(); + a.set_account(Some("alice")); + let fresh = a.grant("buwei", &["invite"], 104, 1000).unwrap(); + let mut j = Journal::open(&p).unwrap(); + assert_eq!( + j.reconcile(&fresh, &o.id, &b, 105).unwrap().status, + Status::Confirmed + ); + assert_eq!(b.0.calls.load(Ordering::SeqCst), 1); +} + +#[test] +fn task_recovery_keeps_confirmed_receipts_beyond_display_history() { + let (_, g, mut journal) = setup(); + let backend = Backend::new(0); + let oldest = queued(&mut journal, &g, &backend); + journal.execute(&g, &oldest.id, &backend, 102).unwrap(); + for _ in 0..45 { + let later_backend = Backend::new(0); + let later = queued(&mut journal, &g, &later_backend); + journal.execute(&g, &later.id, &later_backend, 102).unwrap(); + } + assert!( + !journal + .recent(&g, 103) + .unwrap() + .iter() + .any(|op| op.id == oldest.id) + ); + let recovered = journal + .recent_with_references(&g, &[oldest.id.clone(), oldest.id.clone()], 103) + .unwrap(); + assert_eq!(recovered.len(), 41); + assert_eq!(recovered.iter().filter(|op| op.id == oldest.id).count(), 1); + assert_eq!( + recovered + .iter() + .find(|op| op.id == oldest.id) + .unwrap() + .status, + Status::Confirmed + ); + assert_eq!(backend.calls.load(Ordering::SeqCst), 1); + g.revoke(); + assert!( + journal + .recent_with_references(&g, &[oldest.id], 104) + .is_err() + ); +} + +#[test] +fn referenced_receipts_cannot_read_another_accounts_operations() { + let (authority, alice, mut journal) = setup(); + let oldest = queued(&mut journal, &alice, &Backend::new(0)); + authority.set_account(Some("bob")); + let bob = authority.grant("buwei", &["invite"], 102, 1000).unwrap(); + assert!(matches!( + journal.recent_with_references(&bob, &[oldest.id], 103), + Err(Error::Authorization) + )); +} From 4d89472246ac93c1f531390fbceec78ef2ff5e7b Mon Sep 17 00:00:00 2001 From: WeiR-h <74817080+WeiR-h@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:32:51 +0800 Subject: [PATCH 2/2] Index unresolved recovery separately from confirmed account history --- crates/action-receipts/examples/benchmark_owned_history.rs | 2 +- crates/action-receipts/examples/benchmark_recovery.rs | 2 +- crates/action-receipts/src/lib.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/action-receipts/examples/benchmark_owned_history.rs b/crates/action-receipts/examples/benchmark_owned_history.rs index c7044133f..f3d630fef 100644 --- a/crates/action-receipts/examples/benchmark_owned_history.rs +++ b/crates/action-receipts/examples/benchmark_owned_history.rs @@ -29,7 +29,7 @@ fn main() -> Result<(), Box> { let raw: String = db.query_row("SELECT body FROM operations WHERE id=?1", [&template.id], |row| row.get(0))?; // This temporary fixture starts with the previously shipped table schema. // The baseline uses the same production query functions, grant and rows. - db.execute_batch("DROP INDEX operations_scope; DROP INDEX operations_pending_scope; DELETE FROM operations;")?; + db.execute_batch("DROP INDEX IF EXISTS operations_scope; DROP INDEX IF EXISTS operations_pending_scope; DROP INDEX IF EXISTS operations_unresolved_scope; DELETE FROM operations;")?; let transaction = db.transaction()?; { let mut insert = transaction.prepare("INSERT INTO operations(id,app,account,status,body) VALUES(?1,?2,?3,?4,?5)")?; diff --git a/crates/action-receipts/examples/benchmark_recovery.rs b/crates/action-receipts/examples/benchmark_recovery.rs index f1c0e2646..d8a83348c 100644 --- a/crates/action-receipts/examples/benchmark_recovery.rs +++ b/crates/action-receipts/examples/benchmark_recovery.rs @@ -29,7 +29,7 @@ fn main() -> Result<(), Box> { let raw: String = db.query_row("SELECT body FROM operations WHERE id=?1", [&template.id], |row| row.get(0))?; // This temporary fixture starts with the previously shipped table schema. // The baseline uses the same production query functions, grant and rows. - db.execute_batch("DROP INDEX operations_scope; DROP INDEX operations_pending_scope; DELETE FROM operations;")?; + db.execute_batch("DROP INDEX IF EXISTS operations_scope; DROP INDEX IF EXISTS operations_pending_scope; DROP INDEX IF EXISTS operations_unresolved_scope; DELETE FROM operations;")?; let transaction = db.transaction()?; { let mut insert = transaction.prepare("INSERT INTO operations(id,app,account,status,body) VALUES(?1,?2,?3,?4,?5)")?; diff --git a/crates/action-receipts/src/lib.rs b/crates/action-receipts/src/lib.rs index 0ae0de23c..c63ec1037 100644 --- a/crates/action-receipts/src/lib.rs +++ b/crates/action-receipts/src/lib.rs @@ -230,7 +230,7 @@ impl Journal { } db.execute_batch("CREATE TABLE IF NOT EXISTS operations(id TEXT PRIMARY KEY, app TEXT NOT NULL, account TEXT NOT NULL, status TEXT NOT NULL, body TEXT NOT NULL); CREATE INDEX IF NOT EXISTS operations_scope ON operations(app, account); - CREATE INDEX IF NOT EXISTS operations_pending_scope ON operations(app, account, status); + CREATE INDEX IF NOT EXISTS operations_unresolved_scope ON operations(app, account) WHERE status IN ('dispatching','unknown'); PRAGMA user_version=1;")?; Ok(Self { db }) }