diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..9448e80 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +* text=auto eol=lf + +*.icns binary +*.ico binary +*.png binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dccca63..5a8f6fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,112 @@ jobs: - run: cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check - run: node scripts/check-dependency-inventory.mjs --rust + tally-portable: + name: Tally portable core + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 # stable + with: + toolchain: 1.96.0 + components: clippy + - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 + with: + workspaces: src-tauri -> target + - name: Test portable Tally truth layer + working-directory: src-tauri + run: >- + cargo test --locked + -p bridge-tally-canonical + -p bridge-tally-compatibility + -p bridge-tally-core + -p bridge-tally-incremental + -p bridge-tally-live-read + -p bridge-tally-observability + -p bridge-tally-protocol + -p bridge-tally-qualification + -p bridge-tally-read-transport + -p bridge-tally-runtime + -p bridge-tally-transport + -p bridge-tally-write + -p tally-protocol-simulator + - name: Test disabled protocol evidence features + working-directory: src-tauri + run: >- + cargo test --locked -p bridge-tally-protocol + --features jsonex-parser,jsonex-request-builder,india-tax-observation-parser,bills-payments-observation-parser,bills-native-outstandings-probe + - name: Test isolated native outstandings qualification path + working-directory: src-tauri + run: | + cargo test --locked -p bridge-tally-compatibility --features bills-native-outstandings-probe-receipt + cargo test --locked -p bridge-tally-read-transport --features bills-native-outstandings-probe-transport + cargo test --locked -p bridge-tally-live-read --features bills-native-outstandings-probe-runner -- --test-threads=1 + - name: Lint portable Tally truth layer + working-directory: src-tauri + run: >- + cargo clippy --locked + -p bridge-tally-canonical + -p bridge-tally-compatibility + -p bridge-tally-core + -p bridge-tally-incremental + -p bridge-tally-live-read + -p bridge-tally-observability + -p bridge-tally-protocol + -p bridge-tally-qualification + -p bridge-tally-read-transport + -p bridge-tally-runtime + -p bridge-tally-transport + -p bridge-tally-write + -p tally-protocol-simulator + --all-targets -- -D warnings + - name: Lint disabled protocol evidence features + working-directory: src-tauri + run: >- + cargo clippy --locked -p bridge-tally-protocol + --features jsonex-parser,jsonex-request-builder,india-tax-observation-parser,bills-payments-observation-parser,bills-native-outstandings-probe + --all-targets -- -D warnings + - name: Lint isolated native outstandings qualification path + working-directory: src-tauri + run: | + cargo clippy --locked -p bridge-tally-compatibility --features bills-native-outstandings-probe-receipt --all-targets -- -D warnings + cargo clippy --locked -p bridge-tally-read-transport --features bills-native-outstandings-probe-transport --all-targets -- -D warnings + cargo clippy --locked -p bridge-tally-live-read --features bills-native-outstandings-probe-runner --all-targets -- -D warnings + - name: Produce parser-only synthetic qualification smoke receipt + working-directory: src-tauri + run: >- + cargo run --locked --release -p bridge-tally-qualification -- + run ci-smoke target/tally-qualification-smoke.json 7 3 + - name: Enforce exact Tally compatibility claims + working-directory: src-tauri + run: >- + cargo run --locked -p bridge-tally-compatibility -- + gate + ../docs/tally/compatibility/compatibility-matrix.json + ../docs/tally/compatibility/compatibility-surface.json + ../docs/tally/compatibility/trusted-evidence-keys.json + ../docs/tally/compatibility/evidence + .. + - name: Reject generated Tally matrix drift + working-directory: src-tauri + run: >- + cargo run --locked -p bridge-tally-compatibility -- + check-matrix-markdown + ../docs/tally/compatibility/compatibility-matrix.json + ../docs/tally/support-matrix.md + - name: Enforce no-write live-read dependency boundary + run: node scripts/check-tally-live-read-boundary.mjs + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: tally-parser-qualification-smoke + path: src-tauri/target/tally-qualification-smoke.json + if-no-files-found: error + retention-days: 7 + native: name: Native checks (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -72,9 +178,30 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: src-tauri -> target - - run: cargo check --locked --manifest-path src-tauri/Cargo.toml - - run: cargo test --locked --manifest-path src-tauri/Cargo.toml - - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings + - name: Provision pinned Perl for vendored SQLCipher/OpenSSL + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install strawberryperl --version=5.42.2.1 --yes --no-progress + Add-Content -Path $env:GITHUB_ENV -Value 'OPENSSL_SRC_PERL=C:\Strawberry\perl\bin\perl.exe' + Add-Content -Path $env:GITHUB_ENV -Value 'LIBCLANG_PATH=C:\Program Files\LLVM\bin' + & 'C:\Strawberry\perl\bin\perl.exe' -MLocale::Maketext::Simple -e "print qq(Perl prerequisite ready\n)" + if (-not (Test-Path -LiteralPath 'C:\Program Files\LLVM\bin\libclang.dll')) { throw 'libclang.dll prerequisite missing' } + - run: cargo check --locked --manifest-path src-tauri/Cargo.toml --workspace + - run: cargo test --locked --manifest-path src-tauri/Cargo.toml --workspace + - name: Test isolated native outstandings qualification feature + working-directory: src-tauri + run: >- + cargo test --locked -p bridge-tally-live-read + --features bills-native-outstandings-probe-runner + --all-targets -- --test-threads=1 + - run: cargo clippy --locked --manifest-path src-tauri/Cargo.toml --workspace --all-targets -- -D warnings + - name: Lint isolated native outstandings qualification feature + working-directory: src-tauri + run: >- + cargo clippy --locked -p bridge-tally-live-read + --features bills-native-outstandings-probe-runner + --all-targets -- -D warnings bundle-smoke: name: Bundle smoke (${{ matrix.os }}) @@ -101,6 +228,15 @@ jobs: - uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 with: workspaces: src-tauri -> target + - name: Provision pinned Perl for vendored SQLCipher/OpenSSL + if: runner.os == 'Windows' + shell: pwsh + run: | + choco install strawberryperl --version=5.42.2.1 --yes --no-progress + Add-Content -Path $env:GITHUB_ENV -Value 'OPENSSL_SRC_PERL=C:\Strawberry\perl\bin\perl.exe' + Add-Content -Path $env:GITHUB_ENV -Value 'LIBCLANG_PATH=C:\Program Files\LLVM\bin' + & 'C:\Strawberry\perl\bin\perl.exe' -MLocale::Maketext::Simple -e "print qq(Perl prerequisite ready\n)" + if (-not (Test-Path -LiteralPath 'C:\Program Files\LLVM\bin\libclang.dll')) { throw 'libclang.dll prerequisite missing' } - run: corepack pnpm install --frozen-lockfile - run: corepack pnpm run license:all - run: corepack pnpm run tauri:build diff --git a/.gitignore b/.gitignore index fab0f13..14e7918 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ node_modules/ Thumbs.db Desktop.ini *.log +.bridge-live/ # Local environment and editor state. Keep examples tracked. .env diff --git a/AGENTS.md b/AGENTS.md index 01db741..c00a430 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,8 +31,10 @@ This document defines agent-level expectations and review responsibilities for t supersedes public issue/PR creation until coordinated disclosure is safe. - PRs that touch existing workflows must include rollback notes and migration compatibility. - Keep issue triage actionable: - - assign one area label (tally / dsc / documents / infra / security) - - set severity (`P1` urgent / `P2` production / `P3` medium / `P4` cleanup) + - assign exactly one area label (`area:tally`, `area:dsc`, + `area:documents`, `area:infra`, or `area:security`) + - set one bug severity label (`severity:p1` urgent / `severity:p2` + production impact / `severity:p3` medium / `severity:p4` cleanup) - avoid open "wip" tasks without acceptance evidence. - If regression was introduced by a specific PR, link it explicitly in the rectify issue and include it in the fix PR summary. - For non-security production regressions, use a dedicated fix branch and label (`type:rectify`). diff --git a/BRIDGE_TALLY_RESEARCH_AND_CODEX_PLAN.md b/BRIDGE_TALLY_RESEARCH_AND_CODEX_PLAN.md new file mode 100644 index 0000000..53df8f4 --- /dev/null +++ b/BRIDGE_TALLY_RESEARCH_AND_CODEX_PLAN.md @@ -0,0 +1,8 @@ +# Bridge Tally research and Codex plan + +The maintained plan now lives at +[docs/tally/TALLY_INTEGRATION_RESEARCH_AND_CODEX_PLAN.md](./docs/tally/TALLY_INTEGRATION_RESEARCH_AND_CODEX_PLAN.md). + +Keep implementation status and public claims in the adjacent +[Tally support matrix](./docs/tally/support-matrix.md); roadmap items are not +support claims until their exact evidence is recorded there. diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e21bf1..817e17f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,9 +14,37 @@ current source. - Relicensed future Bridge distributions from the MIT License to the Apache License, Version 2.0. The previously published `v0.1.0` release remains available under the MIT License that accompanied that release. +- Core snapshot canaries now authorize attempts through stable observed + sealed-profile execution evidence without claiming field support from an + incidental first-day dataset; snapshot rows are always re-fetched after the + durable run starts. +- A probe that no longer returns the selected company now clears and + invalidates every company-scoped evidence, proof, mirror, diagnostic, and + snapshot view before installing the replacement probe, so its fresh review + remains usable without displaying stale company data. +- Snapshot lifecycle probes no longer replace interactive setup-review state; + restart admission uses the exact sealed Core receipt, and ambiguous duplicate + live company identities fail before any snapshot read with their concrete + terminal proof reason preserved. +- Snapshot recovery now durably replays backward-clock abandonment evidence, + enforces a 100,000-record aggregate hydration ceiling, and recovers an exact + already-committed receipt from compact hash-bound proof authority without + rehydrating canonical membership. ### Added +- A local-first Tally Truth Layer with capability passports, explicit truth + states, encrypted mirror evidence, resumable/adaptive snapshots, Proof of + Sync and Gap Map output, and a safer operator console. The migrations are + additive; rollback requires restoring the prior application and retaining + the encrypted database for forward recovery rather than deleting evidence. +- Portable, bounded Tally protocol, canonicalization, transport, runtime, + compatibility, incremental-policy, qualification, observability, and + write-safety crates backed by a synthetic loopback protocol simulator. +- Reviewed single-use setup authority, exact selected-read qualification, and + fail-closed compatibility manifests/runbooks. Live Education behavior and + every write capability remain unknown or disabled until exact reviewed + evidence exists. - Native Windows and macOS CI coverage for formatting, tests, builds, and Clippy. - Repository-local Windows and macOS application icons. @@ -28,6 +56,26 @@ current source. ### Security +- SQLCipher/keyring-backed local Tally state, immutable proof/checkpoint + receipts, loopback-only proxy-free HTTP, bounded incremental decoding, + cancellation and lease enforcement, idempotent crash replay, and sealed + no-write qualification boundaries. +- SQLCipher pool replacement connections now receive raw key bytes from + zeroizing storage without retaining a key-derived pragma string. Proof + contract v3 binds detailed record counts, and historical crash recovery no + longer depends on current checkpoint ownership. +- File-backed snapshot ownership now uses per-run kernel advisory locks, so a + crash can be reclaimed after wall-clock rollback without allowing a live + owner to be stolen. Persisted/live company profiles correlate through an + opaque endpoint-scoped identity key, and macOS qualification reports + `ru_maxrss` in its native byte units. +- Losing checkpoint compare-and-swap decisions terminalize as durable failed + proofs and close staging attempts instead of remaining falsely resumable; + unrelated checkpoint advances do not rewrite Failed or Cancelled outcomes. +- Compatibility claims now require verified synthetic-fixture identity before + an explicit parsed Tally application rejection can establish `Unsupported`; + fixture, context, sentinel, parser, malformed-response, and transport + failures remain fail-closed observations rather than incompatibility claims. - Updated the XML parsing graph and removed unused Linux-only dialog dependencies from the supported Windows and macOS build graph. - Updated the Tauri runtime to 2.11.5 and tauri-runtime-wry to 2.11.4. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25a1307..3d5513d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,12 @@ Bridge supports development on Windows and macOS. Install a supported Node.js 22 or 24 release, Corepack, the Rust toolchain selected by `rust-toolchain.toml`, and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for the host -operating system. Then run from any local clone: +operating system. The bundled SQLCipher/OpenSSL build also requires Perl 5 with +`Locale::Maketext::Simple`; on Windows, use a complete distribution such as +Strawberry Perl and set `OPENSSL_SRC_PERL` if an incomplete Perl appears first +on `PATH`. SQLCipher binding generation also requires LLVM/libclang; set +`LIBCLANG_PATH` to the directory containing `libclang.dll` when it is not +discoverable automatically. Then run from any local clone: ```text corepack pnpm install --frozen-lockfile diff --git a/NOTICE b/NOTICE index 3af2fb8..41f8089 100644 --- a/NOTICE +++ b/NOTICE @@ -20,3 +20,30 @@ Bridge does not ship a local `pkcs11-docs/` directory. The upstream dependency source and referenced material are available at: https://github.com/MarcusGrass/rust-pkcs11 + +------------------------------------------------------------------------------- +SQLCipher +Copyright (c) 2008-2020 Zetetic LLC +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the ZETETIC LLC nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY ZETETIC LLC ''AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL ZETETIC LLC BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 68bb74a..c8a4d35 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,17 @@ Shared prerequisites: - Node.js 22 or 24 and Corepack (`.node-version` pins the CI baseline) - the Rust toolchain pinned by `rust-toolchain.toml` +- Perl 5 with `Locale::Maketext::Simple` for the bundled SQLCipher/OpenSSL build +- LLVM/libclang for SQLCipher binding generation (`LIBCLANG_PATH` may be required) - the operating-system dependencies listed in the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) -On Windows, install the Microsoft C++ build tools and WebView2 components -described by Tauri. On macOS, install Xcode Command Line Tools. DSC workflows +On Windows, install the Microsoft C++ build tools, WebView2 components, and a +complete Perl distribution such as Strawberry Perl. If another incomplete +`perl.exe` appears first on `PATH`, set `OPENSSL_SRC_PERL` to the complete Perl +executable. Install LLVM as well; if `libclang.dll` is not discoverable, set +`LIBCLANG_PATH` to its directory (commonly `C:\Program Files\LLVM\bin`). On +macOS, install Xcode Command Line Tools. DSC workflows also require a vendor PKCS#11 library compatible with the host operating system; never commit a private key, PIN, certificate dump, or locally installed vendor library. diff --git a/THIRD_PARTY_LICENSES_RUST.txt b/THIRD_PARTY_LICENSES_RUST.txt index 6817855..e43eb0e 100644 --- a/THIRD_PARTY_LICENSES_RUST.txt +++ b/THIRD_PARTY_LICENSES_RUST.txt @@ -31,6 +31,10 @@ anyhow 1.0.103 License: MIT OR Apache-2.0 Source: https://github.com/dtolnay/anyhow +apple-native-keyring-store 1.0.1 +License: MIT OR Apache-2.0 +Source: https://github.com/open-source-cooperative/apple-native-keyring-store.git + asn1-rs 0.6.2 License: MIT OR Apache-2.0 Source: https://github.com/rusticata/asn1-rs.git @@ -43,6 +47,10 @@ asn1-rs-impl 0.2.0 License: MIT OR Apache-2.0 Source: https://github.com/rusticata/asn1-rs.git +async-trait 0.1.89 +License: MIT OR Apache-2.0 +Source: https://github.com/dtolnay/async-trait + atoi 2.0.0 License: MIT Source: https://github.com/pacman82/atoi-rs @@ -295,10 +303,6 @@ dom_query 0.27.0 License: MIT Source: https://github.com/niklak/dom_query -dotenvy 0.15.7 -License: MIT -Source: https://github.com/allan2/dotenvy - dpi 0.1.2 License: Apache-2.0 AND MIT Source: https://github.com/rust-windowing/winit @@ -415,6 +419,10 @@ futures-io 0.3.32 License: MIT OR Apache-2.0 Source: https://github.com/rust-lang/futures-rs +futures-macro 0.3.32 +License: MIT OR Apache-2.0 +Source: https://github.com/rust-lang/futures-rs + futures-sink 0.3.32 License: MIT OR Apache-2.0 Source: https://github.com/rust-lang/futures-rs @@ -587,6 +595,14 @@ keyboard-types 0.7.0 License: MIT OR Apache-2.0 Source: https://github.com/pyfisch/keyboard-types +keyring 4.1.4 +License: MIT OR Apache-2.0 +Source: https://github.com/open-source-cooperative/keyring-rs + +keyring-core 1.0.0 +License: MIT OR Apache-2.0 +Source: https://github.com/open-source-cooperative/keyring-core.git + lazy_static 1.5.0 License: MIT OR Apache-2.0 Source: https://github.com/rust-lang-nursery/lazy-static.rs @@ -731,6 +747,14 @@ once_cell 1.21.4 License: MIT OR Apache-2.0 Source: https://github.com/matklad/once_cell +openssl-src 300.6.1+3.6.3 +License: MIT OR Apache-2.0 +Source: https://github.com/alexcrichton/openssl-src-rs + +openssl-sys 0.9.117 +License: MIT +Source: https://github.com/rust-openssl/rust-openssl + option-ext 0.2.0 License: MPL-2.0 Source: https://github.com/soc/option-ext.git @@ -1019,14 +1043,6 @@ sqlx-core 0.9.0 License: MIT OR Apache-2.0 Source: https://github.com/launchbadge/sqlx -sqlx-macros 0.9.0 -License: MIT OR Apache-2.0 -Source: https://github.com/launchbadge/sqlx - -sqlx-macros-core 0.9.0 -License: MIT OR Apache-2.0 -Source: https://github.com/launchbadge/sqlx - sqlx-sqlite 0.9.0 License: MIT OR Apache-2.0 Source: https://github.com/launchbadge/sqlx @@ -1379,6 +1395,10 @@ windows-link 0.2.1 License: MIT OR Apache-2.0 Source: https://github.com/microsoft/windows-rs +windows-native-keyring-store 1.1.0 +License: MIT OR Apache-2.0 +Source: https://github.com/open-source-cooperative/windows-native-keyring-store.git + windows-numerics 0.2.0 License: MIT OR Apache-2.0 Source: https://github.com/microsoft/windows-rs @@ -1403,10 +1423,6 @@ windows-strings 0.5.1 License: MIT OR Apache-2.0 Source: https://github.com/microsoft/windows-rs -windows-sys 0.52.0 -License: MIT OR Apache-2.0 -Source: https://github.com/microsoft/windows-rs - windows-sys 0.59.0 License: MIT OR Apache-2.0 Source: https://github.com/microsoft/windows-rs @@ -2157,7 +2173,6 @@ Apache License 2.0 - windows-result 0.4.1 - windows-strings 0.4.2 - windows-strings 0.5.1 -- windows-sys 0.52.0 - windows-sys 0.59.0 - windows-sys 0.61.2 - windows-targets 0.52.6 @@ -4046,6 +4061,7 @@ Apache License 2.0 - futures-core 0.3.32 - futures-executor 0.3.32 - futures-io 0.3.32 +- futures-macro 0.3.32 - futures-sink 0.3.32 - futures-task 0.3.32 - futures-util 0.3.32 @@ -5703,6 +5719,215 @@ See the License for the specific language governing permissions and limitations under the License. +------------------------------------------------------------------------------- +Apache License 2.0 +- apple-native-keyring-store 1.0.1 +- keyring-core 1.0.0 +- keyring 4.1.4 + + 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 [2017] [keyring developers] + +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. + + ------------------------------------------------------------------------------- Apache License 2.0 - keyboard-types 0.7.0 @@ -5966,6 +6191,7 @@ Apache License 2.0 - num-traits 0.2.19 - oid-registry 0.7.1 - once_cell 1.21.4 +- openssl-src 300.6.1+3.6.3 - parking 2.2.1 - parking_lot 0.12.5 - parking_lot_core 0.9.12 @@ -7256,6 +7482,213 @@ See the License for the specific language governing permissions and limitations under the License. +------------------------------------------------------------------------------- +Apache License 2.0 +- windows-native-keyring-store 1.1.0 + + 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 [2017] [keyring developers] + +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. + + ------------------------------------------------------------------------------- Apache License 2.0 - vcpkg 0.2.15 @@ -8505,6 +8938,7 @@ Apache License 2.0 - allocator-api2 0.2.21 - anyhow 1.0.103 - asn1-rs-impl 0.2.0 +- async-trait 0.1.89 - cargo_toml 0.22.3 - dirs-sys 0.5.0 - dirs 6.0.0 @@ -8543,8 +8977,6 @@ Apache License 2.0 - shlex 2.0.1 - siphasher 1.0.3 - sqlx-core 0.9.0 -- sqlx-macros-core 0.9.0 -- sqlx-macros 0.9.0 - sqlx-sqlite 0.9.0 - syn 2.0.118 - sync_wrapper 1.0.2 @@ -8982,11 +9414,9 @@ THIS SOFTWARE. ------------------------------------------------------------------------------- MIT License -- dotenvy 0.15.7 - -# The MIT License (MIT) +- brotli 8.0.4 -Copyright (c) 2014 Santiago Lapresta and contributors +Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -9000,35 +9430,42 @@ all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ------------------------------------------------------------------------------- MIT License -- brotli 8.0.4 +- openssl-sys 0.9.117 -Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. +Copyright (c) 2014 Alex Crichton -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. ------------------------------------------------------------------------------- diff --git a/docs/adr/0001-tally-transport-negotiation.md b/docs/adr/0001-tally-transport-negotiation.md new file mode 100644 index 0000000..02b06b4 --- /dev/null +++ b/docs/adr/0001-tally-transport-negotiation.md @@ -0,0 +1,25 @@ +# ADR 0001: Negotiate Tally transports from observed evidence + +Status: accepted. + +## Decision + +XML/HTTP is the compatibility baseline. JSONEX, a TDL companion, and ODBC are +separate transports and remain unavailable until an exact product, release, +mode, and query profile proves them. Preference order is selected from current +evidence, not a hard-coded version guess. XML remains the fallback when it can +represent the requested pack truthfully. + +Every transport uses the same canonical pack and proof contracts. Parity must +be demonstrated before a faster transport can replace an already verified +path. Transport success, application success, parsing, reconciliation, and +delivery are distinct phases. + +Production HTTP remains loopback-only with redirects disabled. This protects +the network boundary but does not authenticate the local Tally process. + +## Consequences + +New transports may improve throughput without changing accounting meaning. +Release strings or HTTP 200 cannot enable a capability. Unsupported and unknown +profiles remain visible rather than silently falling back to incomplete data. diff --git a/docs/adr/0002-tally-company-identity.md b/docs/adr/0002-tally-company-identity.md new file mode 100644 index 0000000..f801324 --- /dev/null +++ b/docs/adr/0002-tally-company-identity.md @@ -0,0 +1,34 @@ +# ADR 0002: Bind all company-scoped work to durable source identity + +Status: accepted. + +## Decision + +A display name is not a durable company identity. Bridge binds a run to source +lineage, an observed company GUID where available, and a fingerprint of stable +observations. Company-scoped requests explicitly set the current company and +the response must be checked against the intended identity before commit. +Observed GUIDs are ASCII-case-folded before both storage and fingerprinting, so +letter-casing drift cannot manufacture a second runtime identity. + +Fallback fingerprints are labelled as weaker evidence. They do not authorize +automatic rename or deletion behavior. Identity drift invalidates incremental +checkpoints and requires a full, verified snapshot. + +The operator console correlates a fresh probe with an encrypted persisted +profile through an opaque, versioned SHA-256 key over the canonical endpoint +and case-folded observed company GUID. This lets the live GUID-bearing record +recover the persisted mirror ID after restart without exposing the stored GUID, +guessing from a display name, or merging the same GUID across endpoints. + +Execution revalidates that exactly one currently loaded company resolves to the +reviewed identity. Zero matches fail as missing; two or more matches fail as +ambiguous before Bridge reads or stages any snapshot records. Durable terminal +proofs preserve those concrete missing/ambiguous codes instead of collapsing +identity drift into a generic protocol failure. + +## Consequences + +Renames do not silently create a new company when a durable ID is available. +Ambiguous identity prevents checkpoint advancement. Public diagnostics expose +generated mirror IDs and safe drift codes, not company names or book data. diff --git a/docs/adr/0003-tally-sync-truth-states.md b/docs/adr/0003-tally-sync-truth-states.md new file mode 100644 index 0000000..dec0b16 --- /dev/null +++ b/docs/adr/0003-tally-sync-truth-states.md @@ -0,0 +1,43 @@ +# ADR 0003: Represent sync truth, gaps, and freshness explicitly + +Status: accepted. + +## Decision + +Bridge uses separate outcome (`completed`, `failed`, `cancelled`, or +`outcome_unknown`), verification (`verified`, `partial`, or `unverified`), and +freshness (`fresh`, `stale`, or `never_verified`) states. Capability states are +`supported`, `unsupported`, `unknown`, and `not_configured`. + +A structurally valid, identity-bound execution of the sealed Core Accounting +profile is recorded as observed `unknown` with the stable reason +`sealed_profile_executed`. That exact evidence may authorize a snapshot +attempt, but it never promotes absent or coincidentally populated fields to +supported. The run's reconciliation and proof remain responsible for a +Partial or Verified result. Canary rows are qualification evidence only and +are re-fetched after the durable run start before they can enter snapshot data. +Snapshot start and end probes are also lifecycle evidence, not interactive +setup reviews: they execute through an uncached runtime path and cannot replace +a review token awaiting qualification or save. Restart admission accepts only +the same exact persisted `unknown` + observed + `sealed_profile_executed` +receipt; other unknown reasons and the ordinary supported-pack convention do +not authorize a Core resume. Snapshot company discovery is likewise a fresh, +validated lifecycle read that remains available after the single-use setup +review has been consumed; it neither requires nor recreates that review cache. + +A verified proof requires all planned windows, stable identities, canonical +hashes, count invariants, and pack-specific reconciliation. Warnings may +describe unavailable secondary comparisons, but a known completeness or +accounting mismatch is a gap and prevents verification. Only a verified commit +advances a checkpoint. Incremental absence never means deletion; a tombstone +requires an observed deletion rule for the exact scope. + +Typed tax identifiers prove only the validation explicitly performed by their +type. A GSTIN-shaped value from Tally does not prove that the registration is +active, belongs to the intended entity, or was verified by an external portal. + +## Consequences + +The UI can say exactly what is known without converting partial data into a +green success state. A previous verified snapshot remains active when a later +run fails. Schema placeholders cannot produce verified empty snapshots. diff --git a/docs/adr/0004-tally-write-safety.md b/docs/adr/0004-tally-write-safety.md new file mode 100644 index 0000000..7c3b707 --- /dev/null +++ b/docs/adr/0004-tally-write-safety.md @@ -0,0 +1,43 @@ +# ADR 0004: Keep Tally writes disabled behind a controlled sandbox + +Status: accepted for the safety contract; transport dispatch is not yet +enabled. + +## Decision + +Writes require an observed runtime capability, explicit operator opt-in, a +synthetic company during initial validation, backup guidance acknowledgement, +small batches, exact validation and preview commitments, approval evidence, a +durable idempotency reservation, parser-derived Tally counters, and a strict +company-bound read-after-write verification. + +The lifecycle is draft, validate, preview, approve, arm, send, parse, verify, +and then verified/partial/failed/outcome-unknown. A timeout or connection loss +after bytes may have been sent is outcome-unknown and is never retried +automatically. Bridge does not call an action rollback unless a compensating +Tally operation has been implemented and verified. + +Wire bytes, canonical intended state, the import response, canonical readback +state, and identity coverage use distinct domain-separated commitments. Import +and readback evidence is opaque and parser-derived; callers cannot assert +counts, identity-presence booleans, or a payload hash as proof of observed Tally +state. Raw line-error text is reduced to ordered, domain-separated digests and +is never retained by the portable contract. + +The first qualification profile is ledger-only, limited to create/alter, and +requires a RemoteID-bound preflight. Alter intent declares the exact before and +after state and no-op alters are rejected. A parsed receipt must match exact +create/alter counts (with zero deletes) before an exact applied verdict is +possible. A lost response remains outcome-unknown even when a later readback +matches before or after state; that observation may aid investigation but is +not promoted without import-result evidence. Automatic retry is always false. + +## Consequences + +The network-free contract can be reviewed and tested before dispatch is +introduced. It commits to private deterministic import bytes but exposes no +public byte getter or transport adapter, and every prepared write remains +ineligible for dispatch. Legacy durable rows created by the earlier +caller-attested recovery contract remain readable but cannot be promoted to a +success/recovery terminal state. A later migration must persist the opaque +derived commitments separately before runtime wiring is considered. diff --git a/docs/adr/0005-tally-snapshot-recovery.md b/docs/adr/0005-tally-snapshot-recovery.md new file mode 100644 index 0000000..81b6332 --- /dev/null +++ b/docs/adr/0005-tally-snapshot-recovery.md @@ -0,0 +1,104 @@ +# ADR 0005: Tally snapshot recovery authority + +Status: Accepted (2026-07-15) + +## Decision + +An interrupted Tally snapshot may resume only from an encrypted durable row that binds an immutable +plan, run identity, generation, and state payload. One worker owns a time-bounded lease. Every state +write is generation compare-and-swap protected, and terminal rows are append-only evidence. + +The immutable plan includes the company identity and display-name selector, endpoint-bound capability +snapshot, product, release, mode, transport, pack schema, query profiles, filters, and windows. Resume +re-runs the read-only canary with the stored query context and requires the exact observed capability +profile. A renamed company starts a new run rather than mutating an interrupted plan. + +State v4 introduced a closed adaptive-window policy and a separate exact one-day capability +canary. The caller's root windows remain immutable. If and only if a voucher-window response reaches +the typed response-size limit, Bridge calendar-midpoint-splits that leaf, records the parent/children +and policy in the row-hashed state, and generation-CAS persists the graph before dispatching either +child. Child identities bind the parent, query profile, filter commitment, and exact range. A one-day +overflow or configured leaf-limit exhaustion fails closed without changing the previous checkpoint. +Oversized masters, timeouts, parse failures, application failures, and cancellations cannot trigger a +split. Legacy v3 states remain inspectable but are not restart-resumable under the new policy. + +Current v5 state retains that exact adaptive graph but moves per-record membership into normalized, +encrypted SQLite window attempts. Durable state stores only the owner-bound attempt or completed +receipt, count, and hash commitments; it never embeds the full identity map. Both v3 and v4 +nonterminal rows remain inspectable for operators but are not resumable as v5 runs because the +normalized membership authority cannot be reconstructed truthfully. + +Before final reconciliation, Bridge sums the hash-bound `member_count` values in completed v5 +receipts without hydrating their canonical maps. The run fails closed with +`reconciliation_record_budget_exceeded` above 100,000 aggregate records, including on restart and +for a previously staged reconciled decision. Validated record-key and digest length limits make +that count a deterministic transient-memory ceiling. Reconciliation moves, rather than clones, +the hydrated maps, and normalized SQLite membership remains the only restart authority. +`CommitPending` also persists the compact reconciled proof, never the canonical membership map. +Recovery verifies an already-written immutable receipt against that proof without hydration. If no +receipt exists, an over-budget pending decision is replaced by the same non-advancing Failed proof +as an immediate over-budget run. Legacy v5 pending rows without the compact proof can still retry +within the bound; an already-committed legacy row fails explicitly rather than inventing authority. + +Record staging accepts a replay only when every stored provenance, canonical payload, exact-decimal, +AlterID, validation, and rejection fact matches. Generated observation ID and local observation time +are deliberately excluded. Thus a crash after insert but before state-key persistence resumes as an +exact idempotent acknowledgement; changed content is an explicit conflict and cannot advance a +checkpoint. + +For a file-backed mirror, each run also owns a per-resume-key kernel advisory lock beside the database. +The operating system releases that lock when the worker or process exits. This lock is the live-owner +authority: a restarted process can reclaim a durable row even when the wall clock moved backwards and +left a far-future UTC expiry, while a live process cannot be displaced even when its stored expiry looks +old. File-backed save and heartbeat operations require the lock as well as exact owner/run/generation +equality. In-memory test databases have no cross-process identity and retain the bounded UTC fallback. + +The worker renews its diagnostic UTC lease before and after every connector call and every 30 seconds +while a call is in flight, as well as immediately before local reconciliation and commit authority. +Cancellation is polled during connector calls and rechecked at the same post-staging boundaries. If it +is observed before `CommitPending` is durably established, Bridge commits a Cancelled proof without +advancing the checkpoint instead of allowing a stale Completed or Partial outcome. + +The native app's ordinary Tally HTTP path now incrementally decodes UTF-8/BOM and UTF-16LE/BE chunks, +computes encoded and decoded hashes, and enforces both caps without retaining a second complete wire +body. Exact-wire qualification keeps its separate byte-preserving API. Current XML parsers still +require the complete decoded string; one-pass record-sink parsing and chunk-transaction staging remain +a later PR06 slice and are not claimed by this decision. + +Before mirror commit, the durable `CommitPending` row stores a SHA-256 commitment to the complete +expected ledger facts: run and batch identity, capability/company/pack binding, outcome, verification, +timestamps, provenance-backed accepted, rejected, and provenance-unavailable counts, snapshot hash, +the domain-separated digest of the complete canonical record-count map, checkpoint before/after, +gaps, and warnings. Proof contract v3 and migration 0011 add this count-map commitment without +rewriting historical v1/v2 ledger rows or hashes. +After a crash, only a hash-valid immutable proof-ledger receipt for the exact run and batch matching +that commitment can make the run terminal. Recovery reads that historical receipt even if a later +verified run now owns the current checkpoint; the pending run's receipt facts remain independently +verifiable. Checkpoint advancement at the original commit still requires equality with the checkpoint +observed before extraction inside the same SQLite write transaction. + +If a reconciled `CommitPending` run loses that checkpoint compare-and-swap, +Bridge replaces the stale advancing decision with a durable non-advancing +Failed proof carrying `snapshot_checkpoint_changed`. It closes the staging +batch and all open attempts while preserving the winning checkpoint. Pending +Failed and Cancelled decisions never participate in checkpoint CAS and retain +their original outcome and reason even if another run advances the live head. + +If the wall clock moves backwards during a terminal path, Bridge clamps the completion timestamp to +the durable batch start and records `local_clock_moved_backwards`. The repository independently +rejects any commit whose completion precedes its batch start, so an impossible negative-duration +proof cannot enter the ledger. + +Failed and cancelled proofs carry the complete accumulated safe gap set, while the proof-ledger +receipt also carries the complete warning set. Crash recovery reconstructs those same deterministic +sets from the durable state, so job status, proof output, and immutable ledger evidence cannot diverge. + +## Consequences + +- A crash can be retried without inventing a new plan or proof receipt. +- Concurrent workers and concurrent verified runs fail closed instead of overwriting authority. +- Legacy v3 and v4 rows without v5 normalized membership receipts are inspectable but not resumable. +- Duplicate legacy snapshot run IDs block migration with an explicit diagnostic; Bridge never chooses + one recovery row silently. +- The row hash is corruption evidence, not a keyed authenticity claim against an authorized database + writer. SQLCipher and local credential controls remain part of the trust boundary. diff --git a/docs/adr/0006-tally-incremental-authority.md b/docs/adr/0006-tally-incremental-authority.md new file mode 100644 index 0000000..f3ce925 --- /dev/null +++ b/docs/adr/0006-tally-incremental-authority.md @@ -0,0 +1,55 @@ +# ADR 0006: Incremental Tally authority remains evidence gated + +Status: Accepted (2026-07-15) + +## Decision + +Bridge treats a Tally change identifier as reusable only within an exact scope. The scope binds source +lineage, stable company identity and fingerprint, object type, capability-profile version, product, +release, mode, transport, pack schema, query profile, filter hash, and date/overlap policy. Drift in +any field requires a full snapshot. + +An incremental cursor can be established only by a verified full-snapshot proof and an immutable, +directly observed capability record proving monotonic per-object identifiers and inclusive lower-bound +behavior plus an explicit source high watermark for that same scope. The generic pack proof is +necessary but insufficient: an immutable establishment receipt must additionally bind the complete +exact scope, durable snapshot plan, source response, coverage manifest, proof hash, and canonical +watermark. Cursors are stored as canonical decimal text, not signed integers or +floating-point numbers. Any future cursor change will require generation compare-and-swap and an +immutable audit event. + +The v6 establishment receipt is itself the generation-1 audit event, and the checkpoint head cannot +exist without that receipt. Both are immutable and database triggers reject updates or deletion. +Because no protocol verifier yet authenticates source-response, coverage, and bracketing-watermark +evidence, a separate database gate currently rejects every establishment-receipt insert. Hashing +caller-provided values is not treated as authority. Checkpoint establishment and advancement are not +enabled. A future reviewed migration must replace that gate only when its verifier can atomically +construct the receipt from structured observations. Any future advancement API must atomically +verify the prior generation and exact proof receipt, write the next generation, and append its audit +event before this restriction can be relaxed. + +The current implementation deliberately provides policy, exact-scope persistence, and honest readiness +fallback only. It does not expose live AlterID filtering, checkpoint establishment or advancement, +tombstone activation, scheduling, or a public incremental-start command. Portable transition and +deletion-authority receipts have private fields; production callers cannot self-attest `Verified` or +`Observed` authority. + +## Reasons for remaining disabled + +- Current Core Accounting snapshots are `Partial`, so they cannot establish incremental authority. +- Existing generic Core proof rows do not bind an object-specific query/filter contract, complete + numeric AlterID coverage, or an independently observed source high watermark. +- The Education profile has not proved an explicit source high watermark, inclusive-bound semantics, + empty-feed behavior, reset/regression behavior, cancelled-voucher coverage, or deletion rules. +- A maximum identifier in returned rows is not an acceptable substitute for an explicit source high + watermark. +- Absence from a delta never means deletion. A tombstone requires a separately observed rule and a + verified final-state proof. +- Verification must hash and reconcile the complete materialized state after overlay, not the delta + payload alone. + +## Consequences + +Bridge may do more full reads than a speculative integration, but it cannot silently lose edits, +invent deletions, or advance a cursor from partial evidence. The legacy name-based `altmastid_cache` +helper is retired from runtime code; the old table remains only for database compatibility. diff --git a/docs/adr/0007-tally-accounting-proof-authority.md b/docs/adr/0007-tally-accounting-proof-authority.md new file mode 100644 index 0000000..05789ca --- /dev/null +++ b/docs/adr/0007-tally-accounting-proof-authority.md @@ -0,0 +1,90 @@ +# ADR 0007: Tally accounting evidence and redacted proof authority + +## Status + +Accepted for the first PR 08 implementation slice. + +## Decision + +- Core Accounting pack schema v3 models Tally's documented signed-amount and + `ISDEEMEDPOSITIVE` polarity semantics as a typed debit/credit field. Polarity + is part of canonical serialization and therefore changes the record and + snapshot hash. Deterministic fixtures cover this contract; live Education- + profile validation remains pending. +- Schema v3 also requires Tally's `ISOPTIONAL` state. Cancelled and optional + voucher entries remain preserved evidence but are excluded from ordinary-book + movement and polarity claims. Scenario-inclusive reporting is not inferred. +- Portable reconciliation uses exact decimal strings. It checks reference + integrity, signed voucher balance, and agreement between the signed amount + and Tally polarity without floating-point arithmetic. +- Cancelled or optional vouchers with no entries do not produce a false missing-entry + mismatch. A non-cancelled zero-entry voucher remains an explicit + applicability gap until its voucher-class semantics are modeled. +- An experimental Bridge-defined ledger-balance report uses documented + `TBalOpening` and `TBalClosing` fetch names as a separate cross-view + corroboration path. Bridge validates the returned company GUID, requested + date echo, internal row-count consistency, and equality of returned candidate + ledger identities with the Core mirror before exact-decimal comparison of + `closing - opening` movement. The response is locally associated with the + plan's schema, query profile, filter hash, and report hash; those local values + are not Tally attestations. Signed semantics, ordinary-books applicability, + identity stability, completeness, and Education-profile behavior remain + live-unverified, so production evidence is `period_report_profile_unobserved` + and cannot establish `Verified`. +- After report reads, the engine performs a full semantic reread and then a + cache-bypassing capability probe. Reread equality is recorded as + `bracketed_full_reread_v1`-style stability evidence, not as atomic isolation: + Tally documents no cross-request snapshot transaction, so + `source_cut_atomicity_unavailable` still prevents `Verified`. +- Voucher header totals and tax totals remain unavailable. Bridge does not infer + them from an internally balanced entry set. +- Identical complete-scope masters repeated by date-window exports deduplicate. + A changed hash for the same source identity is source drift and prevents a + checkpoint. +- Production proof commits accept a sealed input obtainable only from the + reconciliation builders. Direct caller-authored `Verified` commit inputs are + not part of the production API. +- Proof summaries and redacted exports revalidate stored proof hashes and chain + linkage. Export additionally requires a hash-valid terminal durable snapshot + receipt. The public-support export is an allow-list DTO and excludes names, + source identities, endpoints, internal run/batch/proof/capability IDs, + checkpoint tokens, source rows, amounts, payloads, and drill-down hashes. +- The `public_support_v1` document also omits stable snapshot and proof-ledger + commitments so independently shared exports cannot be correlated through a + source-derived hash. Those commitments remain visible only in the local + operator console. +- The export says `checksum_only` and `authenticity_claim: none`. A local hash + chain is consistency evidence, not a signature and not proof that the + loopback responder was genuine Tally. +- Encrypted durable window evidence retains bounded internal mismatch tokens. + The local console remaps them to ordinal `local-record-*` aliases only after + proof and durable-state validation. Neither the internal tokens nor the + aliases enter the public support export. + +## Migration and rollback + +Core schema v1/v2 interrupted snapshots are intentionally not resumed by a build +that emits schema v3 canonical entries. They remain historical evidence; the +operator must start a new full snapshot. Rollback to a v1 build cannot consume +v3 plans and should likewise require a new full snapshot rather than silently +dropping polarity or optional-voucher state. + +The redacted export is read-only and introduces no Tally write path. Removing +the UI preview does not mutate the proof ledger or mirror. Existing v1 proof +rows remain readable, but an export requires the newer durable receipt checks +and is refused when those checks cannot be established. + +Proof contract v3 binds a domain-separated digest of the complete canonical +record-count map into both receipt facts and the proof-ledger hash. Additive +migration 0011 leaves v1/v2 rows nullable and preserves their original hash +serialization; only v3 rows require the digest. + +## Remaining evidence gates + +No supported environment may call Core Accounting `Verified` until the exact +profile provides complete source counts and capability-validated candidate +identities, voucher header/applicability coverage, validated cross-view report +semantics, and a product-supported atomic source-cut or an equally strong +documented isolation mechanism. The implemented experimental cross-view, +full reread, and fresh end-profile checks are useful evidence, but report-profile +and cross-request atomicity gates remain deliberately open. diff --git a/docs/adr/0008-tally-india-tax-observation-authority.md b/docs/adr/0008-tally-india-tax-observation-authority.md new file mode 100644 index 0000000..2441717 --- /dev/null +++ b/docs/adr/0008-tally-india-tax-observation-authority.md @@ -0,0 +1,68 @@ +# ADR 0008: Keep India Tax observations unbound until source authority is proven + +Status: accepted for the dormant parser contract; extraction and canonical +promotion are not enabled. + +## Decision + +Bridge may parse one exact, Bridge-defined India Tax observation envelope only +behind the non-default `india-tax-observation-parser` feature. The result is +named `Unbound`, retains source identity candidates and exact raw lexemes, and +records only response-internal counts and claimed company/window metadata. +It is never an `IndiaTaxBatch`, capability observation, source checkpoint, +mirror update, reconciliation result, filing artefact, or proof of GST +correctness. + +The first contract has no request builder, TDL report, HTTP dispatch, runtime +selection, UI, or canonical adapter. This is deliberate: Tally documents the +GST storage and method surface, including multiple company registrations, +effective party-registration histories, voucher overrides, and detailed GST +fields, but that documentation does not prove an exact loopback export +contract for Bridge's target release and Education mode. A Bridge-owned +response shape can make parsing deterministic; it cannot prove that the +proposed source methods populate that shape truthfully or completely. + +The parser therefore requires one exact envelope grammar, application success, +one context before rows, exact field order and cardinality, bounded decoding, +valid dates and exact-decimal lexemes, at least one identity candidate, unique +profile-local observation keys, and matching response-internal counts. It +rejects unknown, duplicated, misplaced, truncated, or oversized input without +partial output. Debug and error surfaces redact company identity, GSTINs, +voucher identities, values, and raw XML. Fragment and response hashes are +domain-separated correlation evidence only. Application success and all other +values are envelope claims; the parser cannot authenticate Tally as their +producer, and the lexemes are exact only within this Bridge-defined envelope. + +## Evidence boundary + +Tally's current product documentation says a company can have multiple GST +registrations and party GST details can have effective histories or be +overridden in transactions. Tally's developer documentation also lists the GST +schema/method surface and print-analysis methods. Those sources justify a +broader future observation model, but not a claim that a raw GSTIN is active, +owner-verified, or portal-validated, nor that a raw rate/value/amount tuple is +tax-correct or return-ready: + +- https://help.tallysolutions.com/set-up-gst-details-in-company/ +- https://help.tallysolutions.com/tally-prime/gst-master-setup/india-gst-creating-party-ledgers-for-gst-tally/ +- https://help.tallysolutions.com/setting-up-gst-in-tallyprime/ +- https://help.tallysolutions.com/what-are-the-changes-in-xml-tags/ +- https://help.tallysolutions.com/developer-reference/schema-and-invoice-changes/gst-comprehensive-invoice/ + +The comprehensive-invoice analysis methods are computed print/report-context +methods, not a documented batch-extraction contract. Education mode also has +no TSS/Connected Services and restricts voucher entry to the 1st, 2nd, and +31st; the documentation does not establish unrestricted read behaviour, so +that remains unknown until observed: + +- https://help.tallysolutions.com/licensing-best-practices/ + +## Consequences + +The portable parser and adversarial corpus can mature without implying source +support. `IndiaTax` remains `Unknown, not supported`; zero rows do not prove an +empty GST population. A later request/extraction slice must independently +qualify exact TDL method evaluation, company and date binding, count scope, +multi-registration and override behaviour, stable Core-compatible identities, +Education-mode behaviour, source atomicity, and a release-specific report tie-out before any +canonical adapter or capability promotion is considered. diff --git a/docs/adr/0009-tally-observability-privacy-and-authority.md b/docs/adr/0009-tally-observability-privacy-and-authority.md new file mode 100644 index 0000000..f1f5e64 --- /dev/null +++ b/docs/adr/0009-tally-observability-privacy-and-authority.md @@ -0,0 +1,99 @@ +# ADR 0009: Bound Tally observability before runtime instrumentation + +Status: accepted for the portable aggregation contract and PR15 read-runtime +collection; persistence, support-bundle integration, and performance budgets +are not yet enabled. + +## Decision + +Bridge starts PR13 with a local-only `bridge-tally-observability` crate. Its +input surface contains only one closed terminal-attempt enum, elapsed +`Duration` values intended to come from a monotonic clock, and either response +bytes actually consumed or an explicit unavailable measurement. The crate +aggregates these caller-supplied values but cannot +authenticate how they were measured. It accepts no strings, dynamic +labels, identifiers, endpoints, ports, dates, hashes, payloads, errors, paths, +company or record metadata, amounts, GSTIN/PAN, or arbitrary attributes. + +The schema-v2 collector aggregates into 1,592 fixed bucket-frequency cells in coherent, +fixed-memory snapshots. It stores no event history. Latency and response-byte +boundaries are versioned, inclusive, and carry overflow buckets. Counts +saturate rather than wrap, and lost cell increments from saturation are +disclosed. Queue wait is defined to exclude Bridge's deliberate post-request +pacing. Response latency is +defined as a custom Bridge pipeline observation that excludes queue wait and +pacing; the portable runtime measures from request-future start through its +terminal decoded result. Response bytes mean bytes actually read, including +a partial body later classified as a size, decode, transport, timeout, or +cancellation failure. A caller that cannot observe that value must use +`unavailable`, never a fabricated zero. Neither measurement is a standard OpenTelemetry HTTP +instrument. + +Runtime classification must use structured state, never error-string matching. +Explicit caller cancellation is `cancelled`; a configured deadline is +`timeout`; crossing the body cap is `size_limit`; a completed body that cannot +decode is `decode`; an HTTP failure status is `http_status`; other connection, +reset, or body-read failures are `transport`; and `success` requires the +bounded body and decode stages to finish. Queue deadline and cancellation are +terminal attempt variants and therefore cannot claim a response in the same +observation. + +The preview always emits the same eight request-class rows in a reviewed +schema. Exact cell counts are reduced to coarse buckets, there is no timestamp, +and the collection scope is `unstamped_collector_instance_lifetime`. +Serialization is bounded to 64 KiB and the payload receives a domain-separated +checksum. The preview states that observations are caller-supplied and +unauthenticated, collection completeness and duplicate-call detection are not +established, taxonomy rows are not capability evidence, the crate has no +network exporter, authenticity is none, integrity is checksum-only, and no +performance support is established. + +The preview is a custom, lossy, coarsened bucket-frequency summary. It has no +arithmetic sums and cannot be merged, used for exact rates or percentile +regression gates, or losslessly converted into an OpenTelemetry Histogram. + +## Authority and privacy boundary + +The collector contract cannot change capability, verification, checkpoint, +retry, circuit-breaker, write, or accounting outcomes. The separate portable +read runtime owns retry and circuit decisions before reporting their bounded +outcomes to the collector. The preview is not Proof of Sync, a +source-completeness signal, a Tally-authentic measurement, or evidence of GST +or accounting correctness. It has no network exporter, database, +logging/tracing backend, system-metrics sampler, or reset API. +Always-emitted rows, including `Import`, are taxonomy only. Imports remain +disabled and must not be wired until write-specific `OutcomeUnknown` semantics +are represented separately. + +The design follows data minimisation and bounded-cardinality principles. In +particular, company metadata is omitted rather than hashed because hashing a +small or predictable identifier space is not reliable anonymisation: + +- https://opentelemetry.io/docs/security/handling-sensitive-data/ +- https://opentelemetry.io/docs/specs/otel/metrics/sdk/ +- https://opentelemetry.io/docs/specs/semconv/general/attribute-requirement-level/ +- https://doc.rust-lang.org/std/time/struct.Instant.html + +## Consequences + +Portable tests can prove fixed-memory cell cardinality, saturation, bucket +boundaries, coherent snapshots, privacy-reduced serialization without direct +identifiers, explicit unavailable-byte and circuit-rejection cells, +deterministic checksums, schema-v2 golden bytes, and a hard preview-size +ceiling. The preview still +reveals coarse operational behaviour and must remain local unless the user +previews and explicitly chooses to share it. These tests do not +prove native execution, timing accuracy, collection completeness, or that any +workload meets a budget. + +PR15 wires queue, retry, circuit, cancellation, and response outcomes through a +closed read-only runtime classifier. Single-response native reads report +encoded bytes after a complete transport body, including a later +application-validation or parse failure. Compound probes, pre-response +failures, and partial transport reads mark them unavailable because the native +transport does not yet expose a partial-byte count. Native Windows validation +passes with the documented SQLCipher Perl/libclang prerequisites, while macOS +evidence remains a configured CI responsibility. Phase, reconciliation, +checkpoint-age, memory, database, resume, and UI measurements remain separate work. A later +synthetic harness must record build/platform/generator/sample methodology and +keep its results distinct from live Tally Education or production performance. diff --git a/docs/adr/0010-tally-synthetic-qualification-authority.md b/docs/adr/0010-tally-synthetic-qualification-authority.md new file mode 100644 index 0000000..677191e --- /dev/null +++ b/docs/adr/0010-tally-synthetic-qualification-authority.md @@ -0,0 +1,56 @@ +# ADR 0010: Synthetic Tally qualification is parser-only evidence + +## Status + +Accepted for the portable PR13B1 correctness slice. + +## Decision + +Bridge uses a repository-owned deterministic generator and a separate fresh +worker process to qualify protocol decoding and voucher parsing. The generator +streams bounded windows to temporary files before measurement. Every window is +limited to 32 MiB of encoded body bytes, has exact record and entry counts, and +has a domain-separated SHA-256 digest. The worker rechecks bytes and digest, +parses each window, and must produce the exact counts and independently +generator-derived semantic digest in every retained sample. The semantic +projection binds identities, dates, voucher fields, cancelled/optional flags, +entry association, names, exact amount text, sign evidence, and fragment hashes. + +A qualification-only body reader defines the encoded-body boundary precisely: +exactly 33,554,432 bytes is accepted; a declared larger length is rejected +before reading; and an unknown-length source is read through at most byte +33,554,433 to detect overflow. This reader is not yet the production HTTP +component, so passing it cannot establish the runtime cap. + +The JSON receipt schema is versioned, rejects unknown fields when deserialized, +and caps both output and validated input at 256 KiB. It retains every accepted +raw timing sample, omits p95 below 20 samples, and uses nearest-rank p95 at 20 +or more. It binds the executable and Cargo.lock digests, Rust compiler version, +target triple, actual Cargo profile, the crate's structurally empty feature set, +generator schema/seed/shape, independently regenerated per-window +payload/semantic hashes, and manifest hash. CI may embed `GITHUB_SHA` at build +time; a missing local build-embedded commit remains null. Its checksum detects +corruption only; it provides no authenticity. + +The receipt authority is `repository_synthetic_bridge_parser_qualification`. +It permanently records that live Tally was not observed and that the evidence +does not establish Tally support, a Tally capability, accounting correctness, +a production response-cap binding, or a performance budget. The 50k and 500k +scenarios are windowed corpora and do not claim one-response capacity or Tally +snapshot completeness. + +## Consequences + +- Pull-request CI may gate generator/parser correctness and receipt integrity. +- Hosted-runner durations are diagnostic and cannot create release budgets. +- Windows process-lifetime peak working set is reported in bytes. Darwin's + `getrusage().ru_maxrss` is already bytes and is preserved exactly; Linux's + KiB value is checked and normalized to bytes. Each uses a distinct method + identifier, and values are not compared across platforms. The difference + between baseline and final lifetime maxima is not an allocation measurement. +- HTTP delivery, cap-boundary transport behavior, native persistence, + reconciliation, resume, UI responsiveness, and live Education/licensed Tally + compatibility remain separate qualification slices. +- `large-voucher` characterizes one schema-valid voucher with 256 entries and + bounded text. The deprecated `deep-voucher` spelling is only a CLI alias and + establishes no XML-depth claim. diff --git a/docs/adr/0011-tally-production-http-boundary.md b/docs/adr/0011-tally-production-http-boundary.md new file mode 100644 index 0000000..8c94cc4 --- /dev/null +++ b/docs/adr/0011-tally-production-http-boundary.md @@ -0,0 +1,76 @@ +# ADR 0011: Tally production HTTP boundary + +- Status: accepted +- Date: 2026-07-15 + +## Context + +Bridge previously had two bounded response readers: one inside the native app +and one inside the repository-synthetic qualification worker. Portable tests +could therefore pass without proving the code used by the app. Runtime session +identity also collapsed every accepted loopback address at a port to one +`localhost` key, even though `127.0.0.1`, other `127/8` addresses, and `::1` +can identify different local services. + +## Decision + +`bridge-tally-transport` is the production XML-over-HTTP boundary. The native +Tally client delegates status and XML requests to it. The crate: + +- accepts only `localhost` or literal loopback addresses; +- normalizes `localhost` to `127.0.0.1` but preserves every other actual IPv4 + loopback address and `::1` as distinct endpoint identities; +- disables inherited proxies and redirects; +- applies a bounded whole-request deadline; +- caps outbound XML and encoded response bytes at closed limits; +- rejects non-identity `Content-Encoding` before interpreting the body; +- decodes UTF-8/BOM, UTF-16LE/BOM, and UTF-16BE/BOM through the portable + protocol crate; and +- exposes closed, privacy-safe transport errors without URLs, headers, bodies, + company identifiers, or accounting values. + +The deterministic simulator separately characterizes Content-Length, +close-delimited, and chunked bodies, declared and streamed cap overflow, +truncation, slow headers, redirects, non-2xx statuses, and content encoding. +These are Bridge resilience requirements, not statements about framing that +Tally guarantees. + +The export application-status parser rejects duplicate or misplaced critical +header fields and active XML constructs. The import parser accepts the current +documented direct `ENVELOPE/BODY/DATA` counter profile without combining it +with Tally's documented wrapped `IMPORTRESULT` profile. + +The unauthenticated `GET /status` response is only a best-effort local +diagnostic heuristic; it is not part of the documented third-party XML +contract and cannot identify the product or gate the XML POST probe. A +successful, strictly parsed XML export can establish reachability of that +observed endpoint profile, but not responder authenticity. + +Runtime sessions use the normalized actual origin as their client, cache, and +snapshot identity. Cancellation retains the endpoint pacing quarantine, and a +half-open circuit reserves one probe. A protocol/application rejection does not +reset prior transport failures. + +## Evidence authority + +Portable tests establish repository-synthetic behavior of the transport, +decoder, parser, generator, and simulator. They do not establish: + +- that the unauthenticated local responder was Tally; +- behavior of a Tally release or Education/licensed profile; +- that Tally emits every tested HTTP framing or encoding; +- source completeness, accounting correctness, or snapshot atomicity; +- stable performance or bounded total process memory; or +- write/import capability. + +The existing parser-qualification receipt continues to keep +`runtime_cap_binding=false`; it was produced by a separate worker and is not +retroactively upgraded by this ADR. + +## Consequences + +Portable CI can now test the exact response reader used by the app. Native +runtime/cancellation tests pass on the validated Windows toolchain, and CI is +configured to repeat the native workspace on Windows and macOS. Process-isolated +HTTP evidence, large-master traversal through the runtime, phase timing, and a +read-only live compatibility receipt remain follow-on work. diff --git a/docs/adr/0012-tally-live-compatibility-evidence.md b/docs/adr/0012-tally-live-compatibility-evidence.md new file mode 100644 index 0000000..6c28ec7 --- /dev/null +++ b/docs/adr/0012-tally-live-compatibility-evidence.md @@ -0,0 +1,86 @@ +# ADR 0012: Tally live compatibility evidence + +- Status: accepted +- Date: 2026-07-15 + +## Context + +The prose support matrix could describe missing live evidence but could not +prevent a release from broadening a claim. The existing qualification receipt +is intentionally repository-synthetic and permanently disclaims live Tally, +runtime-capability, support, accounting, and performance authority. Extending +it would erase an important trust boundary. + +Tally's documented third-party integration contract is XML sent by HTTP POST. +The local responder is unauthenticated. Product and exact release are visible +to an operator in Tally's About UI; they are not established by the XML +message-format `VERSION` field. The `/status` path is a Bridge heuristic rather +than an authoritative Tally integration API. + +## Decision + +Bridge keeps live read observation in the separate +`bridge-tally-compatibility` crate and schema +`bridge.tally.live-read-qualification/1`. A receipt uses closed enums, rejects +unknown fields, is capped at 256 KiB, and binds: + +- Bridge commit, clean/dirty state, executable, Cargo.lock, and a deterministic + compatibility-source surface; +- exact product, release, mode, platform, architecture, loopback family, + transport, ODBC state, company-load state, locale, encoding, and a reviewed + fixture-owned synthetic dataset tier; +- a synthetic fixture-manifest commitment and sealed read-profile/template + identifiers; and +- closed operation outcomes, application status, size/count buckets, and safe + reason codes. + +Receipts exclude raw XML/JSON, endpoint and port, headers, raw errors, company +names/GUIDs, tax identifiers, ledger or voucher identifiers, amounts, +narrations, usernames, and paths. They permanently state that no writes were +attempted and that responder authenticity, accounting correctness, source +completeness/atomicity, performance support, and automatic support eligibility +are not established. + +The receipt checksum provides accidental-change detection only. A positive +matrix cell additionally requires an exact-scope maintainer review attestation +signed with Ed25519 by a configured non-revoked key. The release gate verifies +the signature, key validity at review and release time, attestation expiry, +receipt age, exact claim dimensions and operations, clean source state, commit, +and current compatibility-surface digest. Evidence cannot be generalized to a +different cell. + +The checked-in matrix is the claim authority. Missing evidence remains +`unknown`; absence is never converted to success. `Unsupported` requires a +signed, exact-scope receipt with an observed required-profile application +failure; connection failure alone is insufficient. Parser-only receipts cannot +serve as live evidence. + +## Live-controller boundary + +The compatibility crate validates evidence and release claims but performs no +network requests. The separate `bridge-tally-live-read` controller requires +exact observed About/profile values, an affirmative no-customer-data +attestation, and explicit interactive consent. Its single-use network consent +uses fresh cryptographic randomness, expires after five minutes, binds the full +configuration and endpoint plus the reviewed source/build/fixture evidence, +and revalidates the compatibility surface immediately before dispatch. It uses +a canonical repository-local ignored configuration and reaches +the generic HTTP transport only through a typed adapter that accepts the sealed +`ReadOnlyProfile` enum shared with production. It accepts no arbitrary XML, +reports, TDL, imports, or generic payloads and has no dependency path to Tauri +commands, sync, persistence, or write crates. It stops before company-scoped +reads if the reviewed synthetic fixture marker or unique GUID is absent or +ambiguous, and verifies unique ledger/voucher sentinels, count bounds, company +context, and voucher dates before marking the fixture contract verified. The +operator separately attests that no customer data is loaded. It previews the +exact allow-list receipt and requires receipt-and-output-bound consent before +atomic no-overwrite JSON save through a consumed repository-issued target. + +## Consequences + +CI can reject stale, tampered, revoked, source-drifted, or scope-mismatched +positive claims even when no live Tally host is available. The initial matrix +therefore contains only explicit `unknown` cells. A legitimate Education-host +run remains necessary before any exact Windows cell can become `observed` or +`supported`; no license restriction may be bypassed and no write probe is +authorized by this ADR. diff --git a/docs/adr/0013-tally-party-outstanding-confidence-authority.md b/docs/adr/0013-tally-party-outstanding-confidence-authority.md new file mode 100644 index 0000000..ff3f8e5 --- /dev/null +++ b/docs/adr/0013-tally-party-outstanding-confidence-authority.md @@ -0,0 +1,52 @@ +# ADR 0013: Tally party outstanding confidence authority + +Status: accepted for the portable PR16 foundation; live extraction and support +authority remain unavailable. + +## Context + +Tally exposes bill allocations and receivable/payable reports, but a parsed +row alone does not prove that the selected company, party, date, direction, or +report scope was queried. Empty output also cannot distinguish a true zero from +disabled bill-wise tracking, incomplete coverage, drift, an unsupported +currency shape, or an incompatible export profile. On Account amounts have no +truthful bill-level link. + +## Decision + +Bridge uses a versioned Bills & Payments canonical model and a separate exact +raw-observation parser. Parser output is explicitly unbound and cannot enter the +runtime or promote compatibility. Before a confidence decision, the caller +must match company identity, party ledger, as-of date, direction, query profile, +and scope fingerprint exactly. + +Authority for allocation shape, outstanding shape, signed amounts, On Account +aggregation, settled-row omission, due-date interpretation, direction/sign, +and complete-empty meaning is opaque, bound to the exact expected scope, and +defaults to false. No public constructor can promote it. Source values or +echoed request metadata cannot set it; a future live-qualified adapter +constructor requires separate review. Reconciliation uses +signed decimal arithmetic without floating-point conversion. Ledger-opening +allocations are retained, On Account is aggregate-only, and due dates carry +explicit evidence. Missing, drifted, disabled, partial, foreign-currency, or +unobserved states fail closed with categorical outcomes; no numeric confidence +score is used. + +## Consequences + +- A legitimate live receipt for the exact export profile is required before + source-semantics authority can be enabled. +- Outstanding IDs remain ordinal/profile dependent and are ineligible for + mirror authority until the export's row ordering stability is observed. +- The Bills & Payments pack remains `Unknown, not supported` while its request, + parser adapter, native extraction/runtime, Bills-authoritative mirror/proof, + and UI surfaces are absent. Generic typed canonicalization and shared + reconciliation result plumbing do not supply that authority. +- Education-mode live fixtures must use literal calendar day `01`, `02`, or + `31`. Education mode has no TSS and excludes connected services, so these + fixtures cannot qualify email, WhatsApp, payment-request, remote, or other + connected flows. +- Future UI and support artifacts must replace raw source IDs with bounded, + proof-local aliases. +- Payment automation and all writes require a separate decision and evidence + path. diff --git a/docs/adr/0014-tally-native-outstandings-probe-authority.md b/docs/adr/0014-tally-native-outstandings-probe-authority.md new file mode 100644 index 0000000..c4654ab --- /dev/null +++ b/docs/adr/0014-tally-native-outstandings-probe-authority.md @@ -0,0 +1,94 @@ +# ADR 0014: Native outstandings probe has observation-only authority + +## Status + +Accepted for an isolated, consent-gated qualification runner. Production +dispatch, parsing, and support promotion are rejected pending live +release-specific evidence and a separate promotion decision. + +## Context + +Tally's current integration documentation identifies a native report named +`Ledger Outstandings`, its mandatory `LedgerName` variable, and common export +variables. Current product documentation also describes opening, pending, due, +overdue, bill-wise, and On Account views. It does not define a stable XML +response grammar, exact sign convention, row ordering, empty-scope meaning, or +all method names needed to reproduce that report in a Bridge-owned TDL export. + +Bridge's existing Bills parser accepts a different Bridge-owned envelope only +as unbound evidence. Treating a native report response as that envelope, or +adapting either response directly into a canonical Bills pack, would invent +authority that has not been observed. + +## Decision + +Bridge may expose a non-default, portable qualification probe that renders only +the documented native `Ledger Outstandings` request surface: + +- `TALLYREQUEST=Export` and `TYPE=Data`; +- report ID `Ledger Outstandings`; +- validated `SVCURRENTCOMPANY`, `LedgerName`, and `SVTODATE` inputs; +- XML export format and `EXPLODEFLAG=Yes`. + +The candidate definition is compile-time separated from production read +profiles and the native runtime. It owns the exact request bytes and publishes +domain-separated template, request, and scope commitments with redacted +diagnostics. A second non-default feature may connect that sealed type to a +dedicated qualification-only transport. That transport accepts no raw XML, +uses loopback with no proxy or redirect, applies fixed 64 KiB request and 1 MiB +response caps, has a 20-second timeout, and makes exactly one attempt. + +The standalone runner requires distinct consumed preflight, dispatch, UI-after, +and save confirmations. It verifies separately registered company and party +identity commitments, surrounds three candidate observations with four fresh +identity brackets, compares exact encoded response bytes only in bounded +memory, and retains no raw response. Its separate receipt records only bounded +observation facts and fixes every semantic, completeness, runtime, mirror, and +support authority flag to false. + +A Candidate response with failed or unrecognized Tally application status, an +HTTP rejection, or a transport failure is a modeled attempt fact rather than +an unreceipted controller abort. The runner completes that attempt's trailing +identity bracket and the remaining fixed observation sequence with zero retry, +provided every identity read remains valid and unchanged. Any identity failure +or change still stops before another scoped Candidate request. Repeatability is +`NotEstablished` when any Candidate response body is unavailable. Save consent +is bound to both the sealed receipt and a consumed repository-issued output +target under canonical ignored `.bridge-live`; an existing file is never +replaced. + +Operator UI evidence is a closed typed schema, not arbitrary JSON. It permits +at most 256 ordered rows with contiguous zero-based indices, exactly the ten +reviewed projection fields, a bounded nonempty row kind, and bounded control- +free display text. The settled `INV-001` observation is a closed +`present`/`omitted`/`unobserved` enum and must satisfy the selected scenario's +exact presence rule; invented labels cannot satisfy the gate. + +The protocol candidate's local posture remains `CandidateOnlyNoTransport` and +`ProfileUnobserved`; the separate transport does not mutate or promote that +type. A successful HTTP and application response proves only that the sealed +request was answered in the attested observation bracket. +It would not prove responder authenticity, company or party identity, +completeness, stable ordering, accounting semantics, zero outstandings, source +atomicity, release support, or production suitability. + +CandidateV0 is immutable request-shape evidence. Any byte or scope change must +create a new candidate version. A future live-qualified profile must be a +separate type; CandidateV0 must never be renamed or promoted in place. + +Before any parser or adapter is frozen, a disposable synthetic INR company must +be exercised using the reviewed native-probe runbook. The exact Tally +product/release/mode must be verified in-product, the company and party stable +identities must be independently observed, unchanged reads must be bracketed, +and raw response behavior must be compared with the visible native report. +Unknown, empty, disabled, ambiguous, reordered, or drifted observations fail +closed. + +## Consequences + +This creates a reviewable request and observation boundary without overstating +compatibility. It deliberately postpones the high-risk step: interpreting +release-specific native report output. Compatibility remains `Unknown, not +supported`; the qualification receipt is structurally incompatible with the +support gate, and no live request is permitted when the loaded company may +contain customer data. diff --git a/docs/adr/0015-tally-selected-read-qualification-authority.md b/docs/adr/0015-tally-selected-read-qualification-authority.md new file mode 100644 index 0000000..53d573d --- /dev/null +++ b/docs/adr/0015-tally-selected-read-qualification-authority.md @@ -0,0 +1,89 @@ +# ADR 0015: Selected-read qualification is exact-scope evidence only + +## Status + +Accepted for the local read-only setup flow. Broad ledger or voucher support, +source completeness, accounting correctness, write authority, and public +release support remain rejected without separate evidence. + +## Context + +A successful company-list probe proves neither that a selected company's +ledgers can be exported nor that a bounded voucher request is honored. Treating +any parseable or empty response as broad support would be especially unsafe: +Tally can return application failures inside HTTP success, company context can +drift, empty rows do not prove source emptiness, and an Education installation +does not establish behavior for other releases or license modes. + +## Decision + +Bridge may qualify exactly two versioned XML profiles after an operator reviews +one fresh GUID-bearing company and chooses an inclusive voucher window of at +most 31 days: + +- `bridge.tally.ledgers/1` for the selected company's ledger export; and +- `bridge.tally.vouchers/3` for the same company and an echoed exact + `FROMDATE`/`TODATE` window. + +Qualification is single-attempt and read-only. It requires successful Tally +application status, an exact reviewed wrapper skeleton, no unexpected wrapper +attributes or CDATA, matching company GUID and normalized name, exact schema and +record-count evidence, case-insensitive GUID collision checks, and records that +pass the same bounded identity, text, amount, date, AlterID, and fragment-hash +validation required by canonicalization. A populated result requires verified +record identities. A proven zero-row profile execution records identity +evidence as `not_applicable_empty`; it never becomes a completeness or source- +empty claim. + +The runtime reserves the reviewed probe before either selected request through +an opaque owner-bound lease. Read admission and reservation are mutually +exclusive at the endpoint session, and each qualification dispatch proves the +reservation owner, originating runtime instance, and exact session endpoint. +The lease holds the session alive and releases only its exact review on +cancellation, task abort, panic unwinding, early return, or ordinary drop. +Explicit consume and replacement disarm it idempotently; a stale lease +cannot clear a newer review. Reserved sessions are also excluded from endpoint +capacity eviction. Cancellation therefore restores the original review rather +than manufacturing an observation. Replacement reviews inherit the original +probe freshness origin, so failed or repeated qualification cannot renew stale +setup authority. + +The returned UI object contains no ledger, voucher, amount, or company identity +values beyond the already reviewed company list. Raw rows are discarded. The +local encrypted mirror may retain request and decoded-XML SHA-256 commitments, +the observed decoding label, bounded result buckets, and verification states. +The decoded-response hash is explicitly not a wire-byte hash and is classified +as a local pseudonymous fingerprint; it is excluded from UI serialization and +public support export. + +On explicit save, migration v7 stores the selected scope, the two observations, +and the Capability Passport in one transaction. Migration v8 records the full +review commitment, a domain-separated canonical setup-payload commitment, and +the resulting snapshot/company authority in that same transaction. An exact +replay returns those original references without inserting a second setup; a +different payload under an already consumed review fails closed. This makes a +lost acknowledgement after SQLite commit recoverable without renewing or +duplicating reviewed authority. Consumption rows are immutable and require the +snapshot and company to share one endpoint. + +The repository recomputes the scope commitment from the canonical endpoint, +case-folded persisted company GUID, observed company name, profiles, window, +observation time, outcome, encoding, hashes, and all verification states. +Observations have a composite foreign key to the same scope and capability +snapshot. Scope, observation, consumption, and capability-item rows are +immutable. Legacy case-fold company GUID collisions block migration and are +never auto-merged. + +## Consequences + +The Passport may report `selected_ledger_read` or +`selected_voucher_window_read` only for the committed company/profile/window. +The existing broad `ledger_read` and `voucher_read` features remain `Unknown`. +A ledger failure skips the voucher request and preserves a partial Unknown +outcome; no failure is converted to `Unsupported` without an explicit contract. +No Tally write is attempted or authorized. + +Portable and native simulator evidence can validate this authority boundary, +but it cannot create a public compatibility claim. The exact Windows/Tally/ +release/mode support-matrix cell remains `Unknown` until a consented synthetic +live observation and the separate compatibility attestation gate succeed. diff --git a/docs/provenance.md b/docs/provenance.md index 57fdb88..c29dd35 100644 --- a/docs/provenance.md +++ b/docs/provenance.md @@ -27,6 +27,11 @@ contain customer data in release artifacts. the repository owner under Apache-2.0. - Raster, ICO, and ICNS files under `src-tauri/icons/` are generated derivatives of that vector source and carry the same project license. +- Tally XML and JSON files under `src-tauri/crates/tally-protocol-simulator/` + and `src-tauri/crates/bridge-tally-protocol/tests/fixtures/` are + project-authored synthetic interoperability fixtures contributed under + Apache-2.0. They contain reserved synthetic names and identifiers rather than + customer exports or copied vendor samples. Do not replace or add an icon, font, screenshot, fixture, or other asset unless its source, contributor authority, license, and required attribution are added diff --git a/docs/release-process.md b/docs/release-process.md index ad7e4c3..c0fa684 100644 --- a/docs/release-process.md +++ b/docs/release-process.md @@ -37,6 +37,29 @@ corepack pnpm run license:all inspect signed candidates again before publication. 6. Exercise Tally, DSC, documents, sync, and persistence using synthetic data; attach redacted evidence to the release PR. + Keep repository-synthetic parser qualification receipts separate from the + live Tally compatibility matrix: they cannot establish a product release, + Education/licensed mode, HTTP/runtime behavior, or a performance budget. + Do not broaden a Tally support claim without current exact-profile live + evidence; state missing matrix evidence in the release notes. + Run the executable Tally claim gate: + + ```sh + cd src-tauri + cargo run --locked -p bridge-tally-compatibility -- gate \ + ../docs/tally/compatibility/compatibility-matrix.json \ + ../docs/tally/compatibility/compatibility-surface.json \ + ../docs/tally/compatibility/trusted-evidence-keys.json \ + ../docs/tally/compatibility/evidence .. + ``` + + Any positive cell without an exact fresh signed receipt is a release + blocker. Unknown cells remain visible limitations; they are not failures + unless release notes or product copy claim that scope. + Live Education collection, when legitimately available, must follow the + [read-only runbook](./tally/compatibility/live-education-runbook.md). Never + upload `.bridge-live/` automatically or substitute the parser-only CI + receipt for reviewed live evidence. 7. Confirm the release commit and tag contain no PII, machine paths, secrets, or unsigned third-party assets. 8. Confirm the `Dependency security` workflow passes and GitHub reports no open diff --git a/docs/step-by-step-roadmap.md b/docs/step-by-step-roadmap.md index cfeb3e7..2379e80 100644 --- a/docs/step-by-step-roadmap.md +++ b/docs/step-by-step-roadmap.md @@ -20,6 +20,189 @@ 6. Record platform-specific vendor dependencies without committing proprietary libraries, private keys, PINs, or certificate dumps. +## Tally Truth Layer + +The active Tally roadmap is the +[research and Codex execution plan](./tally/TALLY_INTEGRATION_RESEARCH_AND_CODEX_PLAN.md). +Its public support claims are constrained by the +[evidence matrix](./tally/support-matrix.md). Current implementation work is +ordered as follows: + +Capability Passport profile v2 now exposes and stores provenance-bearing +feature evidence rather than relying on a broad health or display-only status. +It records only the response encoding and company facts observed by the safe +XML company probe. Endpoint evidence is narrowly named responder reachability; +selected reads, practical response limits, all writes, and +uninspected optional-transport configuration remain explicitly `Unknown`. +Endpoint probing is observation-only. Encrypted persistence occurs only after +the operator selects one current GUID-bearing company and explicitly saves a +fresh, opaque, single-use full-probe-bound scope; an owner-bound RAII lease +prevents stale or concurrent saves from consuming another review and releases +its exact review after cancellation, task abort, panic unwinding, early return, +or local atomic-storage failure so the same fresh review can be retried. The +lease keeps its endpoint session alive, reserved sessions cannot be evicted, +and a stale lease cannot alter a newer review. Other +discovered company identities are not pinned as a side effect of discovery. +An already-reserved save blocks a competing probe before it sends Tally +traffic. Post-commit review-cache cleanup cannot convert a durable save into a +reported failure; the UI instead shows an explicit restart warning. +The Passport, capability items, +endpoint observation, and selected company pin commit atomically with the +original probe time. Duplicate normalized GUIDs, unsafe identity fields, stale +UI completions, and replayed review commitments fail closed. GUID matching is +ASCII case-insensitive across selection and persistence, while the spelling +actually observed from Tally is retained. + +Selected-read qualification now adds exact-scope evidence without promoting the +broad read claims. After one fresh reviewed GUID-bearing company is selected, +the operator may run `bridge.tally.ledgers/1` and a maximum-31-day +`bridge.tally.vouchers/3` window. The voucher response must echo the exact +window; both responses must match the reviewed company context, strict XML +structure, record counts, stable populated-row identities, and canonical field +constraints. Empty execution is explicitly identity-not-applicable and never a +completeness claim. Ledger failure skips the voucher request. Cancellation +restores the original review, endpoint read admission is mutually exclusive +with qualification reservation, and replacement reviews retain the original +freshness origin. Explicit save recomputes the scope commitment and atomically +stores immutable migration-v7 evidence tied by composite scope/snapshot +authority. Migration v8 consumes the full review commitment in the same +transaction and makes an exact replay idempotent, closing the cancellation +window after commit but before acknowledgement without duplicating authority. +Raw rows are discarded, decoded-response fingerprints remain local encrypted +evidence, all writes remain disabled, and public compatibility stays `Unknown` +pending consented synthetic live qualification. + +1. Keep the protocol parser and shared endpoint runtime fail closed. +2. Complete native validation of the SQLCipher mirror and OS-backed key. +3. Extend canonical capability-pack models and atomic, resumable snapshots; + Core Accounting schema v3 now retains debit/credit polarity plus optional- + voucher state, an experimental per-ledger balance cross-view, bracketed full + reread evidence, and a fresh end-profile comparison. Cross-view semantics + remain capability-gated pending live Education-profile validation. Core + canonicalization now rejects invalid request windows and any voucher outside + the exact requested date range before snapshot or capability-canary state. + Snapshot state v4 keeps immutable root windows while persisting deterministic + calendar-midpoint children before dispatch; only a typed voucher response + limit may split. The capability canary is one exact first-day window, + one-day overflow and leaf exhaustion fail closed, and exact observation + replays recover lost acknowledgements without an ambiguity gap. Ordinary app + reads now decode and hash HTTP chunks without retaining the complete encoded + body beside the decoded XML. A one-pass XML record sink and chunked staging + transaction are still required to remove the remaining full decoded string + and per-record durable-state rewrite. +4. Establish incremental checkpoints only from verified full snapshots plus an immutable exact-scope coverage and source-high-watermark receipt. +5. Connect the versioned AXAL destination contract without guessing endpoints. +6. The read-side operator console now provides stable-company selection, + offline persisted profiles, separate verified-baseline/latest-attempt + states, phase/window progress, explicit cancellation, company/pack-scoped + recovery history, Gap Maps, proof-ledger views, safe retry guidance, and a + paged metadata-only local mirror explorer. Proof summaries are + hash-revalidated and a reviewed, allow-list-only redacted support export is + available. Write preview, mappings, and conflict resolution remain disabled + until their controlled-write roadmap slices. Separate custom ledger-balance + corroboration and source-stability evidence are implemented, while live + report-profile validation, documented cross-request atomicity, and the + remaining applicability/header-total gates are still required before + `Verified`. +7. JSONEX now has a portable, exact-scope semantic qualification contract and + a separately feature-gated parser for the documented TallyPrime 7.0+ Ledger + and Voucher collection-envelope shapes. A second disabled feature builds + deterministic bytes for only the exact documented Ledger and unbounded + `TSPLVoucherColl` example requests; every result is explicitly ineligible + for dispatch. Parser outputs remain unbound because the documented responses + do not prove company, query, range, completeness, or Core-schema identity. + XML remains the sole runtime transport; no JSONEX HTTP dispatch, canonical + adapter, runtime selection, mirror, checkpoint, proof, or write path exists. + Matching bracketed samples remain + shadow evidence, mismatches return an unenforced scope-local quarantine + recommendation, and XML bracket mismatch or missing completeness/provenance + evidence is inconclusive. Runtime enablement remains gated on exact + TallyPrime 7.0+ release/mode, Education-profile, encoding, nested-shape, + range-filter, repeated parity, and measured operational-benefit evidence. +8. Keep controlled writes disabled. The portable ledger-only qualification + contract now derives exact preflight/import/readback evidence and legacy + caller-attested durable rows cannot be promoted. Runtime dispatch remains + blocked until the durable store persists opaque derived commitments and a + synthetic Education company proves the exact import/readback profile. +9. India Tax now has a non-default parser for one exact Bridge-owned raw + observation envelope. Its outputs remain explicitly unbound, retain only + identity candidates and exact source lexemes, and cannot enter the + canonical pack, capability registry, mirror, checkpoint, proof, runtime, or + UI. Request construction and extraction remain blocked until exact TDL + method evaluation, multi-registration and override behaviour, count scope, + Core-compatible identity binding, Education-mode behaviour, and a + release-specific report tie-out are observed with synthetic data. +10. PR13 now has a portable, fixed-cardinality observability contract with + closed dimensions, coherent fixed-memory histograms, saturation evidence, + bucketed privacy-reduced previews, and no exporter dependency. PR15 wires + its first read-runtime caller and local preview command; phase/reconciliation + metrics, persistence, general support bundles, and measured platform budgets + remain follow-on work. +11. PR13B1 now has bounded deterministic voucher generators and a versioned + parser-only qualification receipt. Inputs are generated before measurement; + each sample uses a fresh worker and must match exact bytes, hashes, record + counts, entry counts, and derived output. The receipt structurally disclaims + live Tally, support, capability, accounting, runtime-cap, and performance + budget authority. Windowed 50k/500k, HTTP fault/cap cases, native DB/resume, + UI responsiveness, and reviewed baselines remain follow-on work. +12. PR13B2 binds the app to the portable bounded HTTP transport, preserves + distinct loopback socket identities, rejects proxy/redirect/non-identity + content encoding paths, and characterizes Content-Length, chunked, + close-delimited, truncation, cap, timeout, and UTF-16 behavior against the + loopback simulator. Export/import envelope ambiguity, cancellation pacing, + and half-open probe stampedes are rectified. A bounded 50,000-ledger master + generator and 10,000-ledger cross-encoding parser test exist. This is + synthetic evidence only; process-isolated runtime, native database/UI, and + live Education qualification remain pending. +13. PR14 now has a closed-schema compatibility receipt and executable release + gate. Eleven exact Windows/Tally matrix cells cover current release/mode/transport/ + platform/company/locale/data dimensions and all remain `unknown`. A + positive claim requires a clean exact source surface, fresh live receipt, + reviewed Ed25519 attestation, and non-revoked key. The standalone controller + now requires a reviewed fixture, single-use five-minute network consent + bound to the full run/source/build evidence, sealed production read + templates, context/range checks, an exact receipt preview, and receipt-and- + output-bound repository-confined atomic no-overwrite save. Unknown About/ + profile values and a false no-customer-data attestation fail before consent + and before network access. A legitimate Windows Education observation + remains pending; macOS live/simulator evidence is also pending. +14. PR15 adds a portable read-only endpoint runtime with per-endpoint + serialization, queue deadlines, cancellation, spacing, typed bounded retry, + circuit admission, and schema-v2 observations. The simulator can serve a + bounded deterministic response sequence, including a tested 500-to-200 + retry. Native source wiring and the Windows workspace checks now pass with + the documented SQLCipher Perl/libclang prerequisites; macOS verification + remains delegated to the configured CI host. Support-bundle UX, + fuzz/property tests, live XML evidence, and measured performance budgets + remain pending. +15. PR16 adds the portable Party Outstanding Confidence Receipt foundation: + Bills & Payments schema v2, an exact unbound observation parser, and a + fail-closed exact-arithmetic reconciliation engine. Company/party/date/ + direction/profile/scope reuse is rejected, On Account remains aggregate, + and unknown, incomplete, unobserved, or unauthoritative empty evidence + cannot become zero. Request construction, + runtime/mirror/UI wiring, and live Education semantics remain pending, so + the pack stays unknown and unsupported. +16. PR17 adds a dormant, feature-gated native Ledger Outstandings request probe + with exact request/scope commitments and a synthetic Education fixture + protocol. It has no production dispatch, parser authority, mirror, UI, or + support claim. +17. PR18 adds an isolated qualification-only runner with distinct expiring + preflight/dispatch consents, exact 13-POST bracketed execution, bounded + byte-for-byte response comparison, separate UI-after/save gates, and a + dedicated no-authority receipt. Candidate application/HTTP/transport + failures are modeled with trailing identity brackets and no retry; missing + bodies make repeatability not established. Receipt save is exact-output- + bound and consumes a repository-confined target. UI evidence uses typed, + bounded, deny-unknown rows plus scenario-exact settlement presence. Checked-in local profiles + remain invalid examples and no live POST or Bills support claim has been + made. +18. Keep selected-read support scope-bound: never copy the selected feature + states without their scope and full review commitments, and do not promote + broad read or completeness claims from this evidence. +19. Record Windows and macOS installer evidence before a supported release + claim. + ## Managed repository controls 1. Require pull requests and the required checks on `master`. diff --git a/docs/tally/README.md b/docs/tally/README.md new file mode 100644 index 0000000..938c6e7 --- /dev/null +++ b/docs/tally/README.md @@ -0,0 +1,65 @@ +# Tally Truth Layer + +Bridge's Tally integration is designed to be inspectable and fail closed. It +must distinguish network reachability from Tally application success, observed +capability from assumption, and a completed request from a verified snapshot. + +## Public contracts + +- [Support matrix](./support-matrix.md) records what is implemented, what has + been observed on a real Tally host, and what remains planned. +- [Executable compatibility matrix](./compatibility/compatibility-matrix.json) + enumerates exact claim cells and fails closed when positive claims lack + current reviewed evidence. +- [Privacy model](./privacy-model.md) defines what may be retained or included + in diagnostics. +- [Research and execution plan](./TALLY_INTEGRATION_RESEARCH_AND_CODEX_PLAN.md) + contains the source research, product model, threat analysis, and staged + implementation plan. + +The architectural decisions are recorded in: + +- [Transport negotiation](../adr/0001-tally-transport-negotiation.md) +- [Company identity](../adr/0002-tally-company-identity.md) +- [Sync truth states](../adr/0003-tally-sync-truth-states.md) +- [Write safety](../adr/0004-tally-write-safety.md) +- [Synthetic qualification authority](../adr/0010-tally-synthetic-qualification-authority.md) +- [Live compatibility evidence authority](../adr/0012-tally-live-compatibility-evidence.md) +- [Party outstanding confidence authority](../adr/0013-tally-party-outstanding-confidence-authority.md) + +## Non-negotiable invariants + +1. Production Tally traffic is loopback-only and redirects are rejected. +2. Company-scoped operations name and verify the intended company. +3. HTTP success is never treated as Tally application success. +4. Unknown, unsupported, and not-configured are distinct states. +5. A checkpoint advances only after durable staging and reconciliation. +6. Absence in an incremental response is not deletion. +7. Writes are disabled unless their exact runtime capability was observed, and + ambiguous post-send outcomes are never retried automatically. +8. Logs, fixtures, screenshots, and support bundles use synthetic data and safe + reason codes, not book data. + +## Contributor verification + +The deterministic simulator and canonical core are the default development +surface. Live tests are supplemental and must record the exact product, +release, operating mode, host platform, and company fixture used. Education +mode is tested as Education mode; Bridge does not bypass its restrictions. + +The simulator and fixture rules live in +[`src-tauri/crates/tally-protocol-simulator`](../../src-tauri/crates/tally-protocol-simulator/README.md). +The parser-only evidence contract lives in +[`src-tauri/crates/bridge-tally-qualification`](../../src-tauri/crates/bridge-tally-qualification/README.md). +The separate live-observation DTO and release gate live in +[`src-tauri/crates/bridge-tally-compatibility`](../../src-tauri/crates/bridge-tally-compatibility/README.md). +That crate performs no network requests. The separate +[`bridge-tally-live-read`](../../src-tauri/crates/bridge-tally-live-read/README.md) +controller requires a reviewed synthetic fixture and two interactive +confirmations, and exposes only byte-identical sealed production read profiles. +See the [Education runbook](./compatibility/live-education-runbook.md). No +receipt should be manufactured by hand. + +Do not advertise a capability from a descriptor or roadmap item. A capability +becomes supported only when the exact release, mode, transport, query profile, +required fields, and invariants have current evidence. diff --git a/docs/tally/TALLY_INTEGRATION_RESEARCH_AND_CODEX_PLAN.md b/docs/tally/TALLY_INTEGRATION_RESEARCH_AND_CODEX_PLAN.md new file mode 100644 index 0000000..d15aba4 --- /dev/null +++ b/docs/tally/TALLY_INTEGRATION_RESEARCH_AND_CODEX_PLAN.md @@ -0,0 +1,2355 @@ +# Bridge × Tally: Trustworthy Integration Research and Codex Execution Plan + +**Repository:** `lamemustafa/bridge` + +**Research date:** 2026-07-14 + +**Target:** Bridge 0.2.x, React 19 + TypeScript, Rust/Tauri 2, SQLite + +**Primary Tally target:** TallyPrime + +**Compatibility target:** Tally.ERP 9 only where the existing XML path continues to work safely +**Testing constraint:** The available real Tally installation is an Education edition; no license restriction may be bypassed or worked around. + +--- + +## 0. Executive directive + +Bridge should not compete by claiming the longest checklist of Tally features. + +It should become the **most inspectable, honest, recoverable, and accounting-safe Tally connector**: + +1. It never reports “synced” merely because an HTTP request returned 200 or because rows appeared. +2. It always identifies the exact company context used for a request. +3. It records what was requested, what Tally returned, what was accepted, what was rejected, and what remains unknown. +4. It treats reads, retries, imports, deletions, and reconciliation as separate correctness problems. +5. It defaults to data minimisation and read-only behaviour. +6. It makes failure understandable to an accountant, not only to a developer. +7. It can be tested thoroughly without a live or licensed Tally instance through a faithful local simulator. +8. It enables alternate transports only after they prove semantic parity with the compatibility baseline and demonstrate a measured operational benefit. +9. It never promises rollback, real-time behaviour, completeness, or accuracy without evidence. + +The memorable product layer should be called the **Tally Truth Layer**. Its user-facing pieces are: + +- **Capability Passport** — what this Tally instance can actually do. +- **Proof of Sync** — evidence that a run is complete, partial, stale, unsupported, or failed. +- **Gap Map** — fields and records requested versus obtained versus intentionally omitted. +- **Safe Import Preview** — the exact write set and expected effect before anything is posted. +- **Truth States** — `Verified`, `Partial`, `Stale`, `Unsupported`, `Failed`, never a vague green check. + +--- + +## 1. How the research was divided + +This plan was produced through six independent review tracks. They should also be represented during implementation and review. + +| Review track | Persona | Primary question | +|---|---|---| +| Protocol | Tally/TDL integration engineer | What does Tally actually guarantee at HTTP, XML, JSONEX, ODBC, and import-response levels? | +| Reliability | Distributed-systems/SRE engineer | What happens under concurrency, timeout, partial response, retry, interruption, and restart? | +| Accounting | Chartered-accountant/data-integrity reviewer | What evidence is required before totals, GST data, balances, or writebacks can be trusted? | +| Security | Local-agent and privacy engineer | Which data leaves Tally, which data is retained, and how can logs/support bundles avoid leaking books? | +| Product | Finance-operations UX designer | How does an accountant understand setup, freshness, gaps, conflicts, and recovery? | +| Open source | Rust/Tauri maintainer and test architect | How can contributors reproduce behaviour without proprietary data or a live Tally installation? | + +A change is not “done” merely because the implementation track approves it. Protocol, reliability, accounting, security, product, and open-source acceptance criteria all apply. + +--- + +## 2. Evidence baseline + +### 2.1 What Tally officially supports + +Official Tally documentation establishes the following useful baseline: + +- TallyPrime supports external integration through TDL, external APIs/HTTP/DLL methods, XML, JSON, and ODBC. +- XML over HTTP is built in. Requests and responses use an `ENVELOPE` with `HEADER` and `BODY`. +- Tally export-envelope application success is expressed as `STATUS=1` and failure as `STATUS=0`; other or missing values are protocol errors. HTTP success alone is insufficient. +- At least one company must be loaded for integration, and the HTTP server is commonly exposed on port 9000. +- Tally can respond using UTF-8, UTF-16, or ASCII. +- TallyPrime 7.0 and later support native JSON. Tally recommends JSONEX for new integrations because it produces valid and predictable arrays and keys; Bridge must not assume that it is faster or semantically equivalent to XML without measurements and parity evidence. +- Tally’s JSON integration documentation explicitly treats `svCurrentCompany` as mandatory; omitting it can cause import/export against whichever company happens to be active. +- Third-party applications can read ledgers, vouchers, stock items, cost centres, reports, and other collections through HTTP/XML or ODBC. +- Tally can accept masters and vouchers. Import responses expose counters such as created, altered, and errors; invalid or duplicate entries are reflected in error results. +- TDL can add in-product UI, reports, validation, alerts, role-based execution, and internal workflow logic. +- TallyPrime 7.1 was released on 2026-05-20. It should be in the live validation matrix, but the integration must not assume every user is on 7.1. +- Tally.ERP 9 is no longer in active development. New capabilities should target TallyPrime, with ERP 9 treated as a compatibility profile rather than a parity target. + +### 2.2 Education edition policy + +Official public documentation reviewed for this plan does not provide a complete, integration-specific capability table for Education mode. Current Tally guidance does document the material voucher-date restriction: Education mode permits voucher entry on the 1st, 2nd, and 31st of a month. The 31st is literal and must not be generalised to the last calendar day of shorter months. + +Therefore Bridge must use an **observed capability policy**: + +- Never infer a write or date-range capability from folklore. +- Record whether a capability is `Documented`, `Observed`, `Inferred`, or `Unknown`. +- Do not bypass or simulate a licensed state. +- Default to read-only probes. +- Run write tests only against a clearly synthetic company, only on a documented permitted date (1st, 2nd, or 31st), and only after a non-destructive capability check succeeds. +- Include a disallowed ordinary date as a negative test; do not alter system time or otherwise work around Education-mode limits. +- Mark test results as applying to the exact Tally product/release/mode observed. +- Preserve the distinction between “not supported”, “not permitted in this mode”, “not configured”, and “not yet tested”. + +### 2.3 What adjacent products teach us + +These product pages are useful as workflow benchmarks, not as proof of accounting correctness: + +| Product | Published integration pattern | What Bridge should borrow | How Bridge should improve it | +|---|---|---|---| +| RazorpayX | Very low-friction Tally connection, two-way payment sync, scheduled and manual sync, automatic reconciliation, vendor-payment and bank-statement flow | Fast setup, explicit manual refresh, scheduled sync, closed-loop payment state | Publish the exact sync checkpoint, source records, reconciliation evidence, exceptions, and last verified state | +| Volopay | Automated exchange, data mapping, monitoring/alerts, custom workflows, categorisation rules, transaction attachments | Mapping UX, proactive alerts, configurable rules, operator dashboard | Every suggestion must carry confidence and reasons; every alert must include remediation and whether retry is safe | +| Vyapar TaxOne | Multi-format source ingestion, bank-statement-to-voucher flow, ledger suggestions, review-before-bulk-push, GST reconciliation and checks | Review/verify gate, bulk import preview, ledger mapping assistance, reconciliation workspace | Separate deterministic rules from probabilistic suggestions; never silently create a ledger or voucher; provide an exact mutation diff | +| Sage Expense Management | Chart-of-accounts/vendor/project import, receipt-to-transaction matching, approved-item posting, two-way accounting sync | Master import, canonical coding model, evidence attachment, approval-to-post lifecycle | Make source identity and idempotency explicit; retain an immutable audit record of the approved payload and Tally response | +| Happay / EnKash | Multi-source capture, policy checks, approval flows, audit-ready records, reconciliation, ERP connectivity | Exception queues, review workflows, policy controls, audit trail | Keep the Tally connector focused: validation and accounting evidence belong in the sync contract, not in broad “AI automation” claims | +| `labs-infinitum/tally-sdk-rs` | Typed reads for masters/vouchers/reports, master creation, detailed import counters, retries, live integration tests | Broader typed surface, import-result modelling, report access, live fixtures | Retain Bridge’s async and security model; add app-level status parsing, global serialization, simulator tests, and proof of completeness | + +### 2.4 Product conclusion + +The recurring market promises are: + +- easy setup; +- two-way or scheduled sync; +- field mapping and auto-categorisation; +- alerts and operator visibility; +- reconciliation; +- approval or review before posting; +- audit-ready records. + +Bridge should implement those only where they serve the Tally workflow. Its unique advantage should be **truthful evidence**: + +> “Here is the company, capability profile, range, count, identity set, checksum, reconciliation result, skipped data, and precise reason this run is Verified or Partial.” + +--- + +## 3. Current Bridge audit + +> **Implementation note (2026-07-14):** This section records the baseline that +> motivated the plan. Several P0/P1 items have since been implemented in the +> working tree. The adjacent [support matrix](./support-matrix.md) is the +> authoritative, evidence-scoped implementation status; roadmap text is never +> a support claim. + +### 3.1 What is already good + +The current codebase has a respectable security and maintainability baseline: + +- Tally connections are loopback-only. +- Redirects are disabled. +- Request timeouts and response-size caps exist. +- XML values are escaped before insertion into TDL. +- Parsing uses a streaming-style XML event reader rather than regexes. +- Current fetches intentionally minimise fields. +- There are synthetic parser tests. +- The repository has cross-platform CI, formatting, Clippy with warnings denied, Rust tests, bundle smoke tests, dependency auditing, license inventory, and governance guidance. +- The latest merged security PR reports 36 Rust tests passing plus native Windows/macOS checks and bundle smoke. + +These controls should be preserved. + +### 3.2 Immediate correctness defect: serialization is not global + +`TallyClient` owns a `SerialTallyQueue`, but every Tauri command creates a fresh `TallyClient`. + +That means the mutex only serialises requests made through one temporary client. Two concurrent Tauri commands can each own a separate mutex and post to the same Tally endpoint simultaneously. + +**Required fix:** manage a shared `TallyRuntime` in Tauri state. It must reuse one endpoint session and one queue per canonical endpoint. + +### 3.3 HTTP success is currently mistaken for application success + +The current `post_xml` path calls `error_for_status()` and returns the body. It does not first classify: + +- Tally export-envelope `STATUS = 0` or `1`, treating any other or missing value as a protocol error; +- failure descriptions; +- `LINEERROR`; +- import counters; +- malformed success envelopes; +- unexpected content such as an HTML error page returned with status 200. + +**Required fix:** introduce a typed application-response parser and make every command return a typed failure category. + +### 3.4 Company context is not universally proven + +Ledger and voucher requests include `SVCURRENTCOMPANY`, which is good. Company enumeration naturally cannot be scoped the same way. However, Bridge does not yet verify that the returned objects belong to the intended company, nor does it pin a stable company identity. + +Official Tally documentation warns that omitting the current-company variable can exchange data with the wrong active company. + +**Required fix:** every company-scoped operation must carry explicit company context and, where possible, verify a stable company identity in the response or through a paired probe. + +### 3.5 Models are too shallow for honest accounting work + +Current vouchers include only: + +- date; +- type; +- number; +- party name; +- an optional identifier. + +Current ledger data is similarly minimal. The local schema does not yet store vouchers, voucher lines, inventory lines, tax lines, bill allocations, or sync-run manifests. + +This data is enough for a browser-like preview, but not enough to claim: + +- complete voucher sync; +- balanced accounting entries; +- GST return preparation; +- inventory reconciliation; +- reliable writeback; +- deletion detection; +- conflict resolution. + +**Required fix:** create canonical, exact models before advertising higher-level outcomes. + +### 3.6 Sync and conflict modules are placeholders + +The current sync engine is a small plan structure; conflict resolution is only an enum. The existing SQLite schema contains useful beginnings—outbox, sync log, alter-ID cache, conflict queue, companies, and ledgers—but the operational protocol is not implemented. + +### 3.7 The UI reports activity, not truth + +The UI exposes direct fetch buttons and shows row counts, with lists capped to 100 visible records. It does not show: + +- whether the result is complete; +- what date range was actually honoured; +- the last durable checkpoint; +- whether Tally returned an application error; +- whether records were skipped or malformed; +- reconciliation status; +- age/freshness; +- unsupported fields; +- retry state; +- company mismatch risk. + +The replacement should present a run lifecycle and Truth State rather than “N loaded”. + +### 3.8 Further protocol hardening gaps + +The implementation plan must address: + +- backend date validation, not only frontend validation; +- UTF-16/BOM and encoding handling; +- cancellation and deadlines; +- safe retry classification; +- jittered backoff; +- per-request IDs; +- bounded diagnostic capture; +- content-type and payload-shape checking; +- adaptive request windows for large companies; +- streaming or staged parsing instead of retaining a full 32 MiB body; +- deterministic canonicalisation and hashing; +- write idempotency; +- periodic deletion sweeps. + +--- + +## 4. Product principles and non-negotiable invariants + +### 4.1 Truth before convenience + +A run may be: + +- `Verified` — all requested scopes completed, validation passed, checkpoint committed, and reconciliation passed. +- `Partial` — useful data exists, but one or more declared scopes, windows, validations, or records did not complete. +- `Stale` — the last verified state is older than the configured freshness target. +- `Unsupported` — the capability is unavailable for the detected product/release/mode. +- `Failed` — no new durable state was committed. + +There is no generic `Success` state. + +### 4.2 Company pinning + +For every company-scoped request: + +- the company parameter is required and non-empty; +- the request explicitly sets the current company; +- the selected company is represented internally by a stable identity where available; +- a response that indicates a different company blocks commit; +- a rename updates a label, not the record identity. + +### 4.3 Atomic checkpoints + +A checkpoint advances only after: + +1. transport completes; +2. application response is accepted; +3. parsing completes; +4. validation completes; +5. staging writes complete; +6. reconciliation completes or is explicitly recorded as unavailable; +7. the snapshot is committed atomically. + +Cancellation, crash, parse error, or mismatch must leave the previous verified checkpoint intact. + +### 4.4 Exact money + +Never use floating-point values for accounting amounts. + +Use a canonical decimal representation containing: + +- original raw value; +- normalised sign; +- integer coefficient; +- scale; +- optional currency; +- parsing/rounding status. + +Any lossy conversion must be explicit and must prevent `Verified` status. + +### 4.5 Read retry and write retry are different + +- Safe, idempotent reads may retry selected transient failures. +- Validation errors, company mismatches, malformed payloads, and application failures do not auto-retry. +- Writes do not auto-retry until an idempotency strategy is proven for that operation. +- A timed-out write is `OutcomeUnknown`, not `Failed`, until re-read or import-log evidence resolves it. + +### 4.6 Data minimisation + +Fetch profiles are feature-specific: + +- connection/capability; +- company identity; +- master summary; +- voucher accounting; +- GST; +- inventory; +- reconciliation; +- write verification. + +Do not fetch narrations, addresses, tax identifiers, item details, or transaction lines unless a declared feature requires them. Diagnostic logs never contain raw payloads by default. + +### 4.7 No fictional rollback + +Do not call an operation “rollback” unless Bridge has implemented and verified a compensating Tally operation. The safe default is: + +- stop; +- preserve evidence; +- mark outcome; +- guide the operator; +- allow a reviewed compensating action. + +--- + +## 5. Target architecture + +### 5.1 TallyRuntime + +Add a Tauri-managed singleton: + +```text +TallyRuntime +└── endpoint_sessions: HashMap> + +TallySession +├── canonical endpoint +├── shared reqwest client +├── one request gate / queue +├── capability profile cache +├── health and circuit state +├── request sequence +└── cancellation registry +``` + +`EndpointKey` must be canonicalised after current loopback validation, so `localhost`, `127.0.0.1`, and equivalent loopback inputs do not accidentally create competing queues for the same endpoint. + +The command layer accepts `tauri::State` rather than constructing a new client. + +### 5.2 Transport abstraction + +```rust +#[async_trait] +trait TallyTransport { + async fn probe(&self, ctx: RequestContext) -> Result; + async fn execute(&self, request: TallyRequest, ctx: RequestContext) + -> Result; +} +``` + +Target-state implementations; only the XML path exists in the runtime today: + +1. `XmlHttpTransport` — compatibility baseline. +2. `JsonExHttpTransport` — TallyPrime 7.0+ version-gated path after capability negotiation and XML parity validation. +3. `OdbcDiagnosticTransport` — optional, read-only count/report verification; never a silent fallback for writes. +4. `SimulatedTallyTransport` or local fake HTTP server — deterministic tests. + +Avoid forcing the whole codebase to know XML or JSON shapes. Both transports normalise into the same canonical domain types. + +### 5.3 Request context + +Every request carries: + +- request ID; +- endpoint session ID; +- operation kind; +- company identity/name; +- deadline; +- cancellation token; +- maximum response bytes; +- retry policy; +- data profile; +- expected response shape; +- safe-to-retry flag; +- redaction policy. + +### 5.4 Typed error model + +Minimum categories: + +```text +Configuration +EndpointRejected +ConnectionRefused +TimeoutBeforeSend +TimeoutAfterSend +ResponseTooLarge +UnsupportedEncoding +UnexpectedContent +MalformedPayload +TallyApplicationError +TallyLineError +CompanyMismatch +CapabilityUnsupported +ValidationError +ReconciliationMismatch +Cancelled +OutcomeUnknown +Database +InvariantViolation +``` + +Each error contains: + +- stable machine code; +- safe operator message; +- optional technical detail; +- retry classification; +- whether local state changed; +- whether Tally state may have changed; +- remediation steps; +- request/run ID. + +### 5.5 Capability Passport + +Illustrative future-state shape, not current Bridge capability evidence: + +```json +{ + "observedAt": "...", + "endpoint": "127.0.0.1:9000", + "product": "TallyPrime", + "release": "7.1", + "mode": { + "value": "Education", + "confidence": "Observed" + }, + "loadedCompanies": [ + { + "name": "Synthetic Bridge Test", + "stableId": "...", + "identityConfidence": "Observed" + } + ], + "transports": { + "xml": { "read": "Verified", "write": "Unknown" }, + "jsonex": { "read": "Verified", "write": "Unknown" }, + "odbc": { "read": "NotConfigured" } + }, + "features": { + "companyRead": "Verified", + "ledgerRead": "Verified", + "voucherRead": "Partial", + "voucherWrite": "Unsupported", + "gstFields": "Unknown" + }, + "warnings": [] +} +``` + +Every field records provenance: `Documented`, `Observed`, `Inferred`, or `Unknown`. + +### 5.6 Canonical domain model + +Do not use names as primary identities. + +Suggested identifiers: + +```text +TallyObjectIdentity +- company_id +- object_type +- guid? +- remote_id? +- master_id? +- alter_id? +- fallback_fingerprint? +- identity_confidence +``` + +A fallback fingerprint is not equivalent to a stable Tally ID. It must be labelled and may not support automatic rename/deletion handling. + +Core entities: + +- company; +- group; +- ledger; +- voucher type; +- voucher; +- voucher ledger entry; +- inventory entry; +- batch allocation; +- bill allocation; +- cost-centre allocation; +- tax line; +- stock item; +- unit; +- godown; +- currency; +- sync run; +- sync scope; +- sync window; +- checkpoint; +- tombstone; +- mapping rule; +- import job; +- import item; +- import result; +- conflict; +- audit event. + +### 5.7 Proposed SQLite evolution + +Use versioned SQLx migrations, not a monolithic initial schema edit. + +Add at least: + +```text +tally_endpoints +tally_capability_profiles +tally_companies +tally_groups +tally_ledgers +tally_stock_items +tally_voucher_types +tally_vouchers +tally_voucher_entries +tally_inventory_entries +tally_bill_allocations +tally_cost_centre_allocations +tally_tax_lines +tally_sync_runs +tally_sync_scopes +tally_sync_windows +tally_sync_errors +tally_checkpoints +tally_tombstones +tally_reconciliation_results +tally_mapping_rules +tally_import_jobs +tally_import_items +tally_import_results +``` + +Important columns: + +- stable internal UUID; +- source IDs and their confidence; +- company ID; +- raw source hash; +- canonical hash; +- observed alter ID; +- first/last seen run; +- deleted/tombstoned status; +- validation status; +- source transport; +- source release; +- schema version. + +### 5.8 Proof of Sync manifest + +Each run produces an immutable manifest: + +```json +{ + "runId": "...", + "state": "Verified", + "company": { + "requestedName": "...", + "verifiedIdentity": "..." + }, + "capabilityProfileId": "...", + "startedAt": "...", + "finishedAt": "...", + "transport": "XML", + "scopes": [ + { + "name": "vouchers", + "requestedRange": ["2026-04-01", "2026-06-30"], + "windows": 13, + "receivedRecords": 4216, + "acceptedRecords": 4216, + "rejectedRecords": 0, + "canonicalHash": "...", + "checkpointBefore": "...", + "checkpointAfter": "..." + } + ], + "reconciliation": { + "recordCounts": "Passed", + "entryBalance": "Passed", + "reportTieOut": "Unavailable" + }, + "gaps": [], + "warnings": [] +} +``` + +The manifest must not contain raw voucher data or sensitive values unless the user explicitly creates an encrypted support bundle. + +### 5.9 Gap Map + +For every scope, show: + +- requested fields; +- fetched fields; +- intentionally omitted fields and why; +- unavailable fields; +- parse failures; +- unsupported object types; +- skipped records; +- lossy normalisations; +- reconciliation limitations. + +This prevents a minimal export from being misrepresented as a full accounting mirror. + +--- + +## 6. Ordered pull-request roadmap + +Each PR must follow `AGENTS.md`, the repository review checklist, rollback/migration notes, and the Tally/security impact sections. + +### PR 00 — Tally trust contract and ADRs + +**Branch:** `chore/tally-truth-contract` + +**Risk:** low +**Purpose:** Freeze the correctness contract before implementation. + +#### Deliverables + +- `docs/tally/README.md` +- `docs/tally/support-matrix.md` +- `docs/tally/privacy-model.md` +- `docs/adr/00x-tally-transport-negotiation.md` +- `docs/adr/00x-tally-company-identity.md` +- `docs/adr/00x-tally-sync-truth-states.md` +- `docs/adr/00x-tally-write-safety.md` +- Update `docs/step-by-step-roadmap.md`. + +#### Decisions to record + +- XML is the compatibility baseline. +- JSONEX is capability-negotiated for TallyPrime 7.0+. +- ODBC is diagnostic/read-only until separately justified. +- External integration remains the default. +- A companion TDL package is optional and may only be introduced after a documented gap cannot be solved safely from Bridge. +- Tally.ERP 9 is compatibility-only. +- Education mode is observed, not assumed. +- No remote plaintext Tally connection is added in this roadmap. + +#### Acceptance + +- No runtime behaviour changes. +- All terminology and Truth States are defined. +- `pnpm run build`, Cargo formatting, tests, Clippy, license checks remain green. + +--- + +### PR 01 — Shared Tally runtime and real global serialization + +**Branch:** `fix/tally-shared-runtime` + +**Risk:** high correctness / low product surface +**Purpose:** Ensure there is one queue and one HTTP client per endpoint. + +#### Code changes + +- Add `src-tauri/src/tally/runtime.rs`. +- Add `TallyRuntime`, `EndpointKey`, and `TallySession`. +- Register runtime through `tauri::Builder::manage`. +- Change Tally commands to use `tauri::State`. +- Preserve current frontend command signatures where practical. +- Canonicalise equivalent loopback endpoint inputs. +- Remove the artificial 500 ms sleep unless measurements prove it is needed; make spacing configurable and observed in capability/session state. +- Make cancellation release the request gate. + +#### Tests + +- Two concurrent commands to the same endpoint never exceed one in-flight request. +- Commands to distinct endpoint keys can proceed independently. +- Equivalent loopback aliases share a session. +- A cancelled request does not deadlock the next request. +- Client/session reuse is observable in tests without exposing internals to the UI. +- A panic/error path releases the gate. + +#### Acceptance + +- Current connection/company/ledger/voucher commands still work. +- No concurrent POSTs per canonical endpoint. +- Existing security restrictions remain unchanged. + +#### Implemented reservation-liveness rectification (2026-07-16) + +Reviewed setup and selected-read qualification now hold an opaque owner-bound +reservation lease across every network and database await. The lease keeps the +endpoint session alive, is excluded from capacity eviction, and releases only +its exact review on cancellation, task abort, panic unwinding, early return, or +drop. Consume and replacement are idempotent; replacement preserves the +original freshness origin, and a stale lease cannot clear or consume a newer +review. Ordinary read admission remains atomically exclusive with the lease. +Native regressions cover drop, task abort, an aborted pending HTTP +qualification, active-request cleanup, stale-owner isolation, single-use +consume, freshness-preserving replacement, read exclusion, and capacity +eviction. Qualification dispatch accepts the opaque lease itself and verifies +its originating runtime instance, so knowing a public review ID or constructing +a second runtime cannot borrow another task's authority. Migration v8 also +consumes the full review commitment beside the saved Passport/company in the +same SQLite transaction. Exact replay after a lost commit acknowledgement +returns the original references; changed payload reuse fails closed, and the +consumption authority is immutable. + +--- + +### PR 02 — Protocol envelope, encoding, validation, and typed errors + +**Branch:** `fix/tally-protocol-contract` + +**Risk:** high +**Purpose:** Stop treating HTTP 200 and parseable rows as proof of Tally success. + +#### Code changes + +- Add `TallyError`. +- Add request IDs and `RequestContext`. +- Validate company and date values in Rust. +- Parse XML response header `STATUS`. +- Parse failure descriptions and `LINEERROR`. +- Parse import counters into a typed result even before write UI exists. +- Detect unexpected HTML/plain text and malformed envelopes. +- Detect BOM/declared encoding and support documented UTF-8/UTF-16 responses. +- Preserve strict response byte limits. +- Classify safe read retries versus permanent errors. +- Do not auto-retry writes. +- Ensure every company-scoped request explicitly sets the current company. +- Add a post-response company verification hook. + +#### Tests + +- HTTP 200 + `STATUS=0` is a failure. +- HTTP 200 + line error is a typed failure. +- HTTP 500 is a transport failure. +- HTML with HTTP 200 is `UnexpectedContent`. +- UTF-8 and UTF-16 fixtures produce equal canonical values. +- malformed/truncated XML does not advance state. +- invalid dates/company names fail before network access. +- sensitive payload fragments are absent from `Display`/serialised errors. + +#### Acceptance + +- Frontend receives stable error codes and remediation. +- No command reports success solely from HTTP status. +- No write response can be accepted without import-result parsing. + +--- + +### PR 03 — Deterministic Tally simulator and fixture corpus + +**Branch:** `chore/tally-simulator` + +**Risk:** low runtime / high leverage +**Purpose:** Make integration reliability reproducible for every contributor. + +#### Deliverables + +- A test-only local HTTP server or feature-gated simulator binary. +- Synthetic fixture corpus with no customer data. +- Fixture generation documentation. +- A scenario DSL or clear scenario enum. + +#### Required scenarios + +- `/status` for TallyPrime, ERP 9, unknown product. +- normal XML export. +- application failure in HTTP 200. +- line errors. +- empty collection. +- duplicate records. +- wrong-company response. +- UTF-16 response. +- malformed XML. +- truncated response. +- response over the byte cap. +- slow headers. +- slow body. +- connection reset before body. +- connection reset after request may have been processed. +- inconsistent date-filter behaviour. +- import created/altered/ignored/error counters. +- duplicate import. +- partial import. +- delayed import response with unknown outcome. +- JSONEX valid arrays. +- Canonical semantic-projection fixtures that explicitly exclude transport + provenance; the existing synthetic JSON reference is not an official Tally + JSONEX envelope and does not prove XML/JSONEX parity. +- unsupported capability. + +#### Acceptance + +- Tests run without Tally installed. +- Simulator is bound to loopback and disabled from production builds unless explicitly feature-gated. +- Fixtures are synthetic and reviewed for data leakage. + +#### Implemented date-filter rectification (2026-07-16) + +- The simulator now has an explicit synthetic inconsistent-date-filter + scenario: a declared July 2026 request window with a returned June voucher. +- Core canonicalization validates the requested calendar window and every + voucher date, rejecting malformed, reversed, before-window, and after-window + values before any canonical window or capability canary can exist. +- Reconciliation keeps its independent out-of-range evidence check as defense + in depth. This portable behavior does not prove that a live Tally release + honors date filters; that support claim still requires exact live evidence. + +--- + +### PR 04 — Capability Passport and safe setup wizard + +**Branch:** `feat/tally-capability-passport` + +**Risk:** medium +**Purpose:** Replace “reachable” with an evidence-based compatibility profile. + +#### Probe categories + +- product and release; +- endpoint responder reachability; +- loaded/active companies; +- stable company identity availability; +- XML read; +- JSONEX read; +- ODBC configuration as an optional diagnostic; +- encoding behaviour; +- practical response limit; +- selected master/voucher reads; +- write state: `Unknown` until explicitly tested; +- mode/license confidence, without claiming undocumented details. + +#### UI + +A guided setup flow: + +1. Detect Tally on the configured local port. +2. Explain how to enable the HTTP server if unavailable. +3. Enumerate loaded companies. +4. Require explicit company selection. +5. Run safe read probes. +6. Present Capability Passport. +7. Show warnings and unsupported capabilities. +8. Save configuration only after the user sees the exact scope. + +#### Acceptance + +- “Reachable” is not equivalent to “compatible”. +- Every capability has provenance/confidence. +- Education mode never triggers write probes automatically. +- Raw server text is not used as the primary UI. + +#### Implemented truth-state rectification + +Capability Passport profile v2 now records and persists explicit feature +evidence for endpoint responder reachability, loaded-company presence, stable company identity, +the exact decoded response encoding, practical response-limit qualification, +company/ledger/voucher reads, and write capability. The connection probe remains +read-only: ledger and voucher reads, practical limits, and writes stay +`Unknown` until a separately scoped probe establishes them. ODBC and the TDL +companion also remain `Unknown` with `configuration_not_observed`; Bridge no +longer reports either as observed `NotConfigured` without inspecting its +configuration. Legacy profile-v1 JSON remains readable with an empty feature +map and cannot acquire invented evidence during deserialization. + +The setup flow now separates observation from persistence. `Probe and discover` +updates only in-memory review state. Saving requires a second explicit action +within five minutes, an opaque review-ID-bound single-use commitment over the +canonical endpoint, original observation time, connection result, complete ordered +company result, and Passport, plus +one GUID-bearing company from that same cached probe. Only that selected +company pin and the reviewed Passport are stored in one local database +transaction using the original observation timestamp; changing endpoint or +company scope requires another review and save. The cache uses conditional +reservation: a stale or concurrent review cannot consume the current probe, +and an atomic local-store failure releases the same fresh review for retry. +An existing reservation rejects a concurrent probe before another live request +is sent. If in-memory token cleanup fails after the database commit, the save +still returns its truthful durable success plus a restart warning; cleanup +cannot disguise a committed setup as a failed save. +Duplicate normalized company GUIDs and invalid identity fields cannot claim +stable identity or be saved. Company GUID matching is ASCII case-insensitive +across review selection and database identity resolution, while persistence +retains the exact spelling observed from Tally instead of accepting +caller-controlled casing. +Late save results cannot restore live state after UI invalidation. No Tally +setup action performs a write to Tally. + +Selected-read qualification now uses two exact scope-bound profiles: +`bridge.tally.ledgers/1` and `bridge.tally.vouchers/3`. The latter requires the +response to echo the exact requested maximum-31-day window, including on an +empty result. Both require strict wrapper structure, company GUID and normalized +name agreement, exact record counts, case-folded GUID collision detection, and +canonical identity/text/amount/date/AlterID/hash validation. Empty execution is +recorded as identity-not-applicable, never as proof that the source is empty or +complete. Ledger failure skips voucher qualification; cancellation restores the +parent review. The endpoint reservation is atomically exclusive with ordinary +read admission, and a replacement review retains the original probe freshness +origin rather than extending it. + +Migration v7 stores the exact company/profile/window scope and two immutable +observations in the same transaction as the Passport and company pin. The +repository recomputes the full scope commitment, including hashes, decoded +encoding, outcome and verification states, and a composite foreign key prevents +an observation from borrowing another snapshot's capability item. Raw rows are +discarded. Decoded-response fingerprints are local encrypted pseudonymous +evidence, not wire hashes, and are excluded from UI serialization and public +support exports. Broad ledger/voucher features, completeness, accounting +correctness, writes, and public release support remain `Unknown`. See +[ADR 0015](../adr/0015-tally-selected-read-qualification-authority.md). + +--- + +### PR 05 — Canonical identities, exact amounts, and versioned migrations + +**Branch:** `feat/tally-canonical-model` + +**Risk:** high data model +**Purpose:** Build a trustworthy local mirror foundation. + +#### Code changes + +- Add exact decimal parser. +- Add stable/candidate identity model. +- Add voucher, entry, inventory, tax, bill, and allocation types. +- Add versioned SQLx migrations. +- Replace name-as-primary-key semantics. +- Add raw and canonical hashes. +- Store source transport/release and identity confidence. +- Add repository APIs and transaction boundaries. +- Preserve migration rollback notes. + +#### Property tests + +- decimal parse/format round trips; +- sign conventions; +- arbitrary whitespace and valid Tally amount forms; +- stable canonical ordering; +- hash reproducibility; +- Unicode names; +- rename does not create a second entity when stable ID is available; +- fallback identity is never upgraded silently without an audit event. + +#### Acceptance + +- No floating-point accounting values. +- A company/ledger rename does not depend on mutable display name where a stable identity exists. +- Existing schema users are migrated safely or the migration is explicitly gated for pre-production data. + +--- + +### PR 06 — Atomic, resumable full snapshot pipeline + +**Branch:** `feat/tally-snapshot-sync` + +**Risk:** high +**Purpose:** Move from button fetches to a durable sync protocol. + +#### Pipeline + +```text +Prepare +→ capability/profile check +→ company identity check +→ plan scopes/windows +→ extract +→ normalise +→ validate +→ stage +→ reconcile +→ commit +→ emit Proof of Sync +``` + +#### Design + +- Adaptive date windows for voucher reads. +- Adaptive collection windows or filters where supported. +- Stage data in run-scoped tables/records. +- Avoid one 32 MiB in-memory string for large exports. +- Persist window completion for resume. +- Keep old verified snapshot active until commit. +- Deterministic ordering and hashing. +- Explicit Gap Map. +- Preserve source counts and parse counts. +- Use a cancellation token. +- Track progress by phase and window, not fake percentages. + +#### Acceptance + +- A crash/cancel leaves the previous snapshot intact. +- Resume does not duplicate records. +- Re-running unchanged data gives identical canonical hashes. +- A missing window produces `Partial`, not `Verified`. +- A response date outside the requested range is detected and handled according to the declared policy. + +#### Implemented bounded-streaming and adaptive-window slice (2026-07-16) + +Bridge now has a parallel decoded-only HTTP response path for ordinary native +Tally reads. It incrementally detects UTF-8, UTF-8 BOM, UTF-16LE, or UTF-16BE, +enforces encoded and decoded limits while chunks arrive, and computes both +commitments without retaining a complete encoded body. The exact-wire API is +unchanged for live qualification. Protocol regressions cover every byte split, +one-byte chunks, surrogate pairs, malformed tails, exact/plus-one caps, and +digest equivalence. The current XML parsers still consume one complete decoded +string, so this is not yet a full event-streamed record pipeline. + +Snapshot state v4 binds a closed adaptive policy and immutable one-day canary. +Only the typed voucher-window response-limit outcome can create deterministic +calendar-midpoint children under an immutable root. The row-hashed split graph +is generation-CAS persisted before child dispatch; resume never replans or +refetches a split parent. Split parents cannot carry evidence, graph drift or +overlap fails closed, and reconciliation uses leaf IDs only. A one-day overflow +or leaf-limit exhaustion becomes an explicit terminal Gap without advancing the +previous checkpoint. Exact lost-ack observation replay is idempotent; a changed +replay is a conflict. Tests cover recursive/leap-date splitting, graph tamper, +post-split crash/resume, one-day overflow, leaf limits, and checkpoint +preservation. Per-record keys still live in the bounded durable JSON row and are +saved after each record; a relational/chunk cursor plus one-pass parser sink is +the next PR06 resilience/performance slice. + +--- + +### PR 07 — Incremental sync and deletion awareness + +**Branch:** `feat/tally-incremental-sync` + +**Risk:** high +**Purpose:** Reduce work without sacrificing completeness. + +#### Rules + +- Use Tally change/alter identifiers only after the capability is verified for the exact object type and release. +- Checkpoints are scoped by company, object type, transport, schema version, and query profile. +- Maintain an overlap window where needed. +- Deduplicate by stable identity and canonical content. +- Periodically run a full identity sweep. +- Records absent from an incremental feed are not automatically deleted. +- A tombstone is created only after a deletion rule is proven. +- If an identifier regresses/resets, invalidate the incremental checkpoint and require a full snapshot. + +#### Acceptance + +- Incremental output equals a subsequent full snapshot for the same final Tally state. +- Edits, renames, and deletions are covered by tests. +- Restart/resume is idempotent. +- Unsupported incremental semantics fall back to full snapshot with an honest warning. + +--- + +### PR 08 — Accounting reconciliation and Proof of Sync + +**Branch:** `feat/tally-proof-of-sync` + +**Risk:** high accounting +**Purpose:** Make completeness and accounting validity visible. + +#### Reconciliation layers + +1. **Protocol** + - all windows responded; + - all payloads passed Tally status checks; + - no unclassified parse errors. + +2. **Identity/count** + - response counts versus accepted counts; + - duplicate IDs; + - missing required identities; + - per-date/per-type counts. + +3. **Accounting** + - voucher ledger entries balance according to documented sign conventions; + - voucher header/entry totals agree where applicable; + - tax components sum to declared totals where the profile supports it. + +4. **Report tie-out** + - documented native report totals, or explicitly capability-probed custom cross-view totals, compared to the canonical mirror; + - clearly label report configuration and unavailable or non-comparable scopes. + +5. **Freshness** + - last verified time; + - age target; + - current Tally capability/profile drift. + +#### Acceptance + +- A reconciliation mismatch prevents `Verified`. +- Results include safe drill-down identifiers but not leaked book contents. +- User can export a redacted Proof of Sync. +- “No mismatch detected” is not described as “accurate” when the comparison scope is incomplete. + +--- + +### PR 09 — Operator-grade Tally UX + +**Branch:** `feat/tally-operator-console` + +**Risk:** medium +**Purpose:** Replace raw fetch controls with a reliable operational workflow. + +#### Screens + +- Setup / Capability Passport. +- Company profile. +- Sync runs. +- Proof of Sync. +- Gap Map. +- Errors and remediation. +- Local mirror explorer with proper pagination/virtualisation. +- Mapping rules. +- Import preview. +- Conflict queue. +- Diagnostics/support bundle. + +#### UX rules + +- Always display the selected company and stable identity confidence. +- Show last verified state separately from the latest failed/partial attempt. +- Use phase labels rather than one global `busy` flag. +- Make cancellation explicit. +- Show retry only when safe. +- Never show raw XML by default. +- Copyable request/run IDs. +- Every “Fix” action explains what it changes. +- Maintain keyboard and screen-reader accessibility. + +#### Acceptance + +- Row caps are explicit and do not imply dataset completeness. +- Error messages distinguish configuration, Tally application, parsing, reconciliation, and permission/mode problems. +- An accountant can determine: “What is current? What is missing? What should I do?” + +#### Current implementation boundary (2026-07-15) + +The read-side operator workflow is implemented: setup and capability evidence, +stable company selection, backend-canonical endpoint attribution, bounded and +truncation-labelled offline persisted profiles, verified baseline versus latest +attempt, scoped run recovery, phase/window progress, cancellation, Proof of +Sync, Gap Map, remediation guidance, copyable local correlation IDs with a +selectable fallback, and a paged metadata-only mirror explorer. Raw source +diagnostics are collapsed, require an explicit sensitive-data reveal, clear on +scope/view/close changes, reject obsolete in-flight results, and are explicitly +labelled as display-capped diagnostics rather than Proof of Sync. Direct +endpoint/runtime/source-diagnostic command errors use stable safe envelopes +with category, retry, remediation, and state-change semantics instead of +exposing raw transport errors; snapshot commands use reviewed safe messages and +codes. + +Mapping rules, import preview, conflict resolution, and write-facing actions +remain intentionally unavailable. They depend on PR 11 and PR 12 safety, +approval, outbox, exact-import-result, and recovery contracts. Native Tauri/SQLx +execution and Windows/macOS assistive-technology evidence also remain pending; +a successful frontend build or static-browser inspection is not represented as +that evidence. + +--- + +### PR 10 — JSONEX negotiated path with semantic shadowing + +**Branch:** `feat/tally-jsonex-transport` + +**Risk:** medium/high +**Purpose:** Add a version-gated, read-only JSONEX candidate path and the +evidence needed to qualify an exact scope against XML. No comparator result +enables JSONEX by itself. + +#### Current implementation boundary + +The repository contains the v1 qualification comparator plus an optional, +portable parser for the exact Ledger and Voucher collection-envelope shapes in +Tally's current native-JSON examples. The parser preserves documented typed +wrappers and absent-versus-empty values, validates application status before +success containers, binds the expected UTF-8 or UTF-16LE response contract, +rejects duplicate keys recursively, and enforces byte/record/structure limits. +Its outputs are explicitly `Unbound`: the documented responses do not echo a +stable company identity, requested range, Bridge query profile, completeness +counts, or Core schema. The Bridge application does not enable the parser +feature. A second disabled feature builds deterministic bytes for only the +exact documented Ledger and unbounded `TSPLVoucherColl` example payloads. It +pins fixed export headers, DOCX key/value spellings, the selected company name, +Bridge's existing 255-byte company safety cap, request byte limits, and +UTF-8/UTF-16 BOM profiles, but every output is marked +ineligible for dispatch, company verification, or date-range claims. The +application has no JSONEX HTTP dispatch or canonical adapter. + +The BOM modes deliberately combine the plain DOCX logical profiles with the +separately documented charset/BOM rules. They are not claimed byte-identical to +Tally's multilingual downloads, which currently use different +`svExportFormat`/fetch spellings. Each alternative requires its own versioned +profile and evidence before any live use. + +The comparator still operates only on caller-supplied, already-canonicalized +XML-before, JSONEX-candidate, and XML-after Core Accounting windows. It +validates chronological bracketing, exact current-schema scope/count +fingerprints, five-object count coverage/cardinality, record-evidence coverage, +and canonical reference integrity. Neither layer proves that a window came +from the stated transport or company, establishes source atomicity, persists +or enforces a quarantine recommendation, selects JSONEX, changes +mirror/checkpoint/proof state, or supports writes. `Matched` remains +`Shadowing`. + +#### Rules + +- XML/HTTP remains the reference and fallback transport. +- Release 7.0+ is necessary but not sufficient. A JSONEX candidate is eligible + for live shadowing only after a separate Capability Passport probe records + the exact endpoint, release, mode, company identity, encoding, and versioned + request/query profile, and proves application status, expected response + container, company pinning, Unicode decoding, and actual range behavior. +- Run chronologically bracketed XML-before, JSONEX-candidate, and XML-after + read-only observations on synthetic or otherwise stable data. Tally documents + no cross-request snapshot isolation, so a reference-bracket mismatch is + `Inconclusive`; a matched bracket is evidence only of equal observed + semantics, not proof of source atomicity. +- Normalise both into the same canonical model, then compare a deterministic + semantic projection. Raw wire hashes and transport-derived nested-entry IDs + remain provenance and are not semantic equality keys. +- Comparator v1 covers sorted group, ledger, voucher-type, voucher, and + flattened ledger-entry semantics with exact-decimal scale normalization. + A `Matched` result requires the exact five Core count scopes/cardinalities and + one-to-one record evidence in all three windows. Gap Map state and nested + source ordering are not part of v1. +- A candidate mismatch inside a matched XML reference bracket returns a quarantine recommendation + for that exact scope. A separate durable policy must persist and enforce it. +- Preference requires a separate promotion policy backed by repeated live + matches and measured operational benefit. Recorded timing and response bytes + are observations, not proof of benefit by themselves. +- Writes remain disabled; no read or comparator result authorizes write + fallback or dispatch. + +#### Implemented qualification boundary + +- `bridge-tally-core` now emits a raw-value-free, hash-scoped local Core Accounting parity + observation with reference/candidate timing and payload-size observations. +- Semantic hashes are local correlation evidence and must not be copied into a + public support export without a separate privacy review. +- The slice emits either a continue-shadowing or recommend-quarantine decision. + Neither is persisted or enforced. Even a matching observation cannot + qualify, select, prefer, or persist JSONEX data. +- The runtime still has no enabled JSONEX response parser or request builder, + HTTP dispatch, live probe, canonical adapter, mirror staging, checkpoint, + proof, fallback, or write path. The optional protocol parser returns only + unbound documented-envelope evidence. The optional deterministic builder + exposes only two fixed official-example profiles, including an explicitly + unbounded voucher profile, and returns no dispatch authority. Neither feature + is enabled by the production application. +- The old Bridge-shaped JSON simulator fixture is explicitly labelled a + synthetic semantic reference. It is not an official Tally JSONEX envelope + and supplies no capability or parity evidence. + +#### Acceptance + +- Deterministic comparator tests cover scale normalization, exclusion of + transport-derived provenance, bracketed candidate mismatch, inconclusive reference-bracket mismatch/missing evidence, receipt + redaction, and invalid scope/metric rejection. +- Sanitized official-derived parser fixtures now cover typed wrappers, nested + and repeated values, omitted-versus-empty fields, application failure status, + wrong containers, multilingual/UTF-16 behavior, duplicate-key rejection, and + resource limits. Bounded date filtering remains unproven because the current + official examples contain no date-range request/response contract; it still + requires an approved read-only live observation before runtime work. +- Deterministic request tests pin the exact documented `fetch_List`, `jsonex`, + `TSPLVoucherColl`, `Type`, and `Native Method` spellings; mandatory company + variable; fixed export headers; BOM/charset coupling; byte limits; injection + resistance; and non-dispatchable/unbounded evidence flags. Official spelling + alternatives require a new versioned profile rather than silent aliases. +- Live validation must record the exact endpoint, product/release/mode/company + and request profile and demonstrate that the requested range is honored. +- A live match cannot leave `Shadowing` until repeated evidence and a separate + promotion policy exist; UI selection, fallback, and enforced quarantine + claims remain pending until that policy is wired. + +--- + +### India Tax raw-observation authority slice + +Research against Tally's current GST setup, Release 3.0 schema, party-ledger, +override, and report documentation also showed that the existing +`IndiaTaxBatch` cannot faithfully represent multiple company registrations, +effective party-registration histories, voucher overrides, +uncertain/excluded populations, or tax-row provenance and valuation basis. +Direct extraction into that schema would manufacture authority. + +The implemented first India Tax slice is therefore a non-default, portable +parser for one exact Bridge-owned observation envelope. It emits only +explicitly `Unbound` registrations and voucher-tax observations, preserves +identity candidates and exact Bridge-envelope decimal/text lexemes, derives response-internal +counts and claimed company/window metadata from the same bounded response, and +uses domain-separated fragment and response hashes. Its errors and Debug +surfaces do not expose company identity, GSTINs, voucher identities, monetary +values, or raw XML. + +There is no request builder, TDL profile, HTTP dispatch, runtime feature, +canonical adapter, capability promotion, mirror/checkpoint/proof integration, +or GST filing/reconciliation claim. Eight initial synthetic adversarial tests +cover the authority firewall, zero-row semantics, counts/order/nesting, +case-variant and unknown attributes, owner/voucher identity ambiguity, +duplicate observations, invalid numeric lexemes, bounds/truncation, and +privacy sentinels. Live read-only source qualification and a richer canonical +schema remain prerequisites for extraction. + +--- + +### PR 11 — Safe write sandbox and exact import results + +**Branch:** `feat/tally-safe-import-sandbox` + +**Risk:** very high +**Purpose:** Introduce write capability without endangering real books. + +#### Preconditions + +- Explicit user opt-in. +- Synthetic company selected. +- Capability Passport does not mark write as unsupported. +- Exact payload preview. +- Validation passes. +- Idempotency strategy exists. +- Backup guidance is shown where applicable. +- Small batch limit. +- No automatic retry. + +#### Import lifecycle + +```text +Draft +→ Validate +→ Preview exact diff +→ User approves +→ Post +→ Parse Tally counters and line errors +→ Re-read affected identities +→ Compare intended versus observed +→ Verified / Partial / OutcomeUnknown / Failed +``` + +#### Acceptance + +- `CREATED`, `ALTERED`, `IGNORED`, `ERRORS`, exceptions, and line errors are preserved. Evidence separately records whether `EXCEPTIONS` was source-reported; for Tally's documented direct profile, an absent field is retained as profile-defaulted zero and is never labelled an observed zero. +- A timeout after send is `OutcomeUnknown`. +- Re-read verification is required for `Verified`. +- Duplicate submission tests prove idempotency or block auto-retry. +- No production-company write test is part of ordinary CI. + +#### Current rectification status (2026-07-15) + +- A portable, ledger-only qualification crate replaces the earlier + caller-attested in-memory verification sandbox. +- Import wire, canonical intended state, raw import response, canonical + readback, requested identities, and observed identity coverage have distinct + domain-separated commitments. +- Import counters and ordered redacted `LINEERROR` digests are derived from the + same bounded parser result. Duplicate status/counters/import containers, + counters outside the exact response profile, and malformed line-error text + fail with one stable redacted error. Caller-provided verification hashes, + item counts, identity-presence booleans, and version strings are no longer + accepted. +- Exact preflight and post-write projections bind company GUID, query profile, + RemoteID, operation, name, parent, GSTIN, and opening balance. Create/alter + only; no-op alters are rejected. +- Every prepared payload remains non-dispatchable. Thirteen portable write + qualification tests plus six protocol-evidence tests + cover exact applied/not-applied, ambiguous response resolution, dirty + counters, stale/changed state, company/profile/identity mismatch, duplicate + identity, limits, XML escaping, digest separation, and line-error redaction. +- Import wire bytes are not exposed by the public API. Exact preview + commitments must be approved after explicit opt-in, observed capability, + synthetic-company confirmation, backup acknowledgement, and before an + idempotency reservation or exact preflight. The readback context must echo a + domain-separated digest of every requested RemoteID, including identities + expected to be absent. A qualification-only one-attempt transition then + binds receipt and readback evaluation; a missing receipt remains + `OutcomeUnknown` even if a later readback matches before or after state. +- Legacy durable caller-attested success/recovery promotion is blocked. Native + store migration to persist opaque derived verdicts, live synthetic-company + validation, HTTP dispatch, UI enablement, vouchers, deletes, and production + writes remain explicitly out of scope. + +--- + +### PR 12 — Mapping, conflict, outbox, and recovery + +**Branch:** `feat/tally-write-orchestration` + +**Risk:** very high +**Purpose:** Build controlled two-way workflows only after reads are trustworthy. + +#### Features + +- deterministic field mapping; +- mapping suggestions with confidence/reasons; +- operator approval; +- mapping versions; +- outbox with idempotency keys; +- per-item import state; +- conflict detection using source identity and observed versions; +- conflict policies per object/field; +- manual resolution; +- audit events; +- re-read verification; +- recovery from `OutcomeUnknown`. + +#### Non-goals + +- Generic autonomous AI bookkeeping. +- Silent ledger creation. +- Silent conflict resolution. +- Blanket “Tally wins” or “cloud wins” defaults. +- Fake rollback. + +#### Acceptance + +- Every mutation is traceable to source, mapping version, approver, payload hash, request ID, Tally result, and verification read. +- Retrying a completed job cannot duplicate a voucher. +- Uncertain outcomes remain visible until resolved. + +--- + +### PR 13 — Observability, support bundle, and performance budgets + +**Branch:** `feat/tally-observability` + +**Risk:** medium +**Purpose:** Make reliability measurable without exposing books. + +#### Telemetry + +- phase durations; +- bytes received; +- records parsed/accepted/rejected; +- retry counts; +- error categories; +- queue wait; +- Tally response latency; +- peak staging memory where measurable; +- reconciliation duration; +- checkpoint age. + +#### Redaction + +- hash or omit company names in generic diagnostics; +- mask GSTIN/PAN-like values; +- omit narration and raw entries; +- never log raw request/response by default; +- support bundle is user-initiated, previewable, bounded, and redacted; +- no credentials or machine paths. + +#### Performance testing + +Use measured baselines rather than marketing targets. Define budgets after collecting representative synthetic sizes: + +- small: 1k vouchers; +- medium: 50k vouchers; +- large: 500k vouchers; +- deeply nested voucher; +- many masters; +- slow Tally endpoint; +- maximum permitted response. + +Track: + +- wall time; +- peak memory; +- database size; +- resume time; +- UI responsiveness; +- hash/reconciliation cost. + +#### Acceptance + +- No unbounded cardinality in logs/metrics. +- No raw accounting values in default logs. +- Performance regression threshold is documented from measured baselines. +- A slow run remains cancellable and the UI remains responsive. + +#### Implemented PR13A boundary + +The first observability slice is a portable, local-only aggregation contract. +It accepts only closed request/queue/response enums, monotonic durations, and +response-byte counts. There are no strings, arbitrary attributes, IDs, +endpoints, timestamps, payloads, paths, book fields, event history, runtime +wiring, persistence, exporter, or automatic upload. Coherent fixed-memory +schema-v2 snapshots aggregate 1,592 versioned bucket-frequency cells; counters saturate +visibly. One terminal attempt observation prevents a single call from claiming +both queue failure and response success, while duplicate calls and collection +completeness remain explicitly unauthenticated/unestablished. The user-facing +preview has eight fixed taxonomy rows, coarse count buckets, an unstamped +collector-instance scope, a 64 KiB cap, and a domain-separated checksum. It is +a lossy custom summary, not an OTel Histogram, and explicitly establishes no +authenticity, capability, exact-rate/percentile, or performance-support claim. + +Nine portable tests cover exact inclusive/overflow latency/byte cells, explicit +unavailable-byte and circuit-rejection cells, 100,000 +observations without cardinality/size growth, sensitive-field exclusion, +snapshots taken during concurrent writes with matched response histograms, +deterministic idle checksums, saturation, the longest textual bucket preview +cap, schema-v2 golden taxonomy/bounds/bytes, and terminal +queue-versus-response attempt separation. +This proves aggregation/privacy/cardinality semantics. PR15 now supplies the +first read-runtime caller, but phase, reconciliation, checkpoint-age, memory, +database, resume, UI, benchmark, and budget evidence remain unavailable. + +#### Implemented PR13B1 boundary + +The first performance-evidence slice is a portable repository-synthetic parser +qualification harness. A bounded generator streams deterministic UTF-8 voucher +windows to temporary files before measurement. Closed scenarios cover a CI +smoke, 1k vouchers, windowed 50k and 500k corpora, and a large-voucher +characterization using 256 ledger entries and bounded text width. The legacy +`deep-voucher` CLI spelling remains an alias but establishes no parser-depth +claim. The generator rejects a window above the existing 32 MiB +encoded-body ceiling before touching the destination and records exact emitted +records, ledger entries, bytes, per-window hashes, and a manifest hash. + +Every timing sample runs a fresh child process and covers file open/read, +bounded-body retention, payload hashing, protocol decode, voucher parsing, and +semantic-output hashing. The worker rechecks each input length and +domain-separated digest, requires exact per-window parsed counts, and matches an +independently generator-derived semantic projection before the controller +accepts the sample. `Instant` supplies monotonic, non-steady elapsed time. +Windows use `GetProcessMemoryInfo.PeakWorkingSetSize`; Unix/macOS use explicitly +labelled `getrusage` maximum resident size normalized from KiB to bytes. These +are process-lifetime maxima, their delta is not an allocation measure, and +platform methods are non-comparable. Unavailable memory is a reason code, never +zero. Receipts retain all accepted samples, omit p95 below 20, use nearest-rank +p95 at 20 or more, bind the compiler/target/actual Cargo +profile/executable/Cargo.lock digest and generator inputs, cap JSON and +validated JSON input at 256 KiB, and carry a domain-separated checksum. +CI-supplied commit evidence is embedded at build time; local absence remains +explicit. +The qualification-only body reader also enforces an inclusive 32 MiB encoded +body ceiling, rejects a declared over-limit body before reading, and reads at +most one detection byte beyond the ceiling for unknown lengths. This component +is not shared with the production HTTP path, so the receipt keeps runtime cap +binding false. + +This evidence authority is parser-only. Its validator permanently rejects any +claim that live Tally was observed or that support, a Tally capability, +accounting correctness, the production runtime cap, or a performance budget was +established. The 50k/500k cases are windowed corpora and do not establish a +single-response capacity or source snapshot completeness. CI therefore gates +only deterministic correctness and privacy-reduced receipt integrity; budgets +remain blocked until repeated comparable release-profile baselines exist. + +HTTP/server isolation and framing, UTF-16 scale, +many-master generation, cancellation, native database/reconcile/resume cost, UI +responsiveness, stable-runner baselines, and a live compatibility receipt remain +follow-on work. + +#### Implemented PR13B2 transport-hardening boundary + +The native Tally client now delegates its status and XML requests to the +portable `bridge-tally-transport` crate. This removes the earlier production +versus qualification response-reader split. The app-bound transport preserves +actual normalized loopback origin identity (`localhost` aliases only to +`127.0.0.1`; other `127/8` addresses and `::1` remain distinct), disables +proxies and redirects, applies a bounded whole-request deadline, caps outbound +XML and encoded response bodies, rejects non-identity content encoding, and +returns closed privacy-safe errors. Endpoint client/cache evidence can no +longer be silently reused across distinct local socket addresses. + +The loopback simulator now has closed Content-Length, close-delimited, chunked, +and mismatched-length framing plus closed content-encoding modes. Portable +end-to-end tests cover exact cap/cap+1, streamed overflow, truncation, slow +headers, redirects/non-2xx, unsupported content encoding, and UTF-8/BOM and +BOM-qualified UTF-16LE/BE. A deterministic master generator preflights up to +50,000 ledger masters against the production 32 MiB encoded-body ceiling; a +10,000-ledger corpus traverses the production decoder and strict ledger parser; +an independently derived digest proves every parsed identity and accounting +field matches the generator expectation and remains identical across encodings. + +Protocol hardening rejects duplicate/misplaced export header status fields and +DTD/processing constructs. Import result parsing accepts Tally's documented +direct `ENVELOPE/BODY/DATA` counters as a distinct profile and rejects mixing +with Tally's documented wrapped `IMPORTRESULT` profile. The later portable +runtime suite preserves post-cancellation endpoint spacing, reserves a single +half-open circuit probe, rejects stale queued admission after a circuit opens, +and prevents application/validation failures from resetting prior transport +failures. + +This remains repository-synthetic evidence. The HTTP simulator is still an +in-process loopback server, large masters have not yet traversed the full +Tauri/runtime path, and native runtime tests cannot execute locally because +`libclang.dll` is unavailable. Process isolation, phase evidence, native +database/reconcile/resume/UI cost, comparable performance baselines, and a +read-only live Education compatibility receipt remain follow-on work. The +existing PR13B1 receipt is not retroactively granted runtime-cap authority. + +--- + +### PR 14 — Compatibility matrix, live validation harness, and release gates + +**Branch:** `chore/tally-compatibility-matrix` + +**Risk:** medium +**Purpose:** Turn “supported” into reproducible evidence. + +#### Matrix dimensions + +- TallyPrime 7.1; +- TallyPrime 7.0; +- one supported older TallyPrime XML-only profile; +- Tally.ERP 9 compatibility profile where available; +- Education mode; +- licensed mode when legitimately available; +- XML; +- JSONEX; +- ODBC disabled/enabled; +- no company loaded; +- one company loaded; +- multiple companies loaded; +- different locale/character sets; +- large dataset; +- Bridge Windows build; +- Bridge macOS simulator/UI build; +- live Tally validation only on legitimate hosts/configurations. + +#### Evidence + +- exact Tally release; +- mode confidence; +- endpoint settings; +- synthetic data seed; +- operations attempted; +- observed results; +- unsupported outcomes; +- hashes/manifests; +- Bridge commit; +- redacted logs. + +#### Release gate + +A release may not broaden its support claim unless the matrix has current evidence. Missing evidence must be stated in release notes. + +#### Implemented PR14 claim-control boundary (2026-07-15) + +- `bridge-tally-compatibility` now owns a separate, closed, privacy-reduced + live-read receipt schema. It does not broaden the parser-only qualification + receipt and cannot itself emit a support claim. +- Eleven exact machine-readable Windows/Tally cells cover the initial release, mode, + transport, ODBC, company-state, locale/encoding, data-tier, and platform + dimensions. Every cell remains `unknown`; no live support evidence has been + manufactured from synthetic tests. +- CI and the release process execute an exact-scope gate. Positive cells require + a fresh clean-source receipt, matching compatibility-surface digest, and an + unexpired Ed25519 maintainer-review attestation from a non-revoked key. +- The app probe no longer treats the undocumented `/status` heuristic as an + authoritative product identifier or prerequisite for the documented XML POST + path. +- The standalone live controller now reuses sealed production read templates, + requires a reviewed synthetic fixture and two run/receipt-bound interactive + confirmations, stops on marker/GUID/context/range ambiguity, does not persist + or output raw source data (bounded responses still exist transiently in + memory), and saves atomically without overwrite. The controller has no + callable generic XML API; its sealed adapter internally delegates the fixed + profiles to the generic production HTTP POST transport. It has no dependency + path to TDL, imports, Tauri, persistence, sync, or writes. A legitimate live + Education receipt is still pending. + +#### PR14 safety rectification (2026-07-16) + +- The default live controller now rejects unknown product, exact release, mode, + ODBC, or locale evidence and a false no-customer-data attestation before + issuing consent or allowing a network request. +- Network consent is a consumed opaque type with a fresh cryptographic nonce, + a five-minute expiry, and a commitment to the full configuration/endpoint, + fixture, commit/dirty state, executable, Cargo.lock, and compatibility + surface. The surface is revalidated immediately before transport dispatch. +- Config and receipt paths are confined to the canonical repository-local + ignored `.bridge-live` root. Receipt saving consumes a repository-issued + target, revalidates it, accepts JSON only, binds the typed save phrase to both + receipt and exact output, and never overwrites. +- Regression tests prove false-attestation zero-POST behavior, unknown-profile + rejection, wrong/cross-run/expired consent rejection, output-path binding, + repository-confined public save, and atomic no-overwrite behavior. No live + POST or support claim is introduced by this rectification. + +--- + +### PR 15 — Portable read runtime, typed retry, and runtime telemetry + +**Branch:** `feat/tally-portable-read-runtime` + +**Risk:** medium +**Purpose:** Make endpoint execution deterministic and observable before adding +another product pack. + +#### Implemented PR15 boundary (2026-07-15) + +- `bridge-tally-runtime` is a Tauri/SQLCipher-independent read control plane. + It owns normalized per-endpoint serialization, bounded queue deadlines, + post-attempt spacing, cancellation, a threshold/cooldown/one-probe circuit, + deterministic bounded jitter, and a maximum endpoint-session count. +- Its public operation enum is read-only. Retry is allowed only for connection + failure, deadline, request failure, HTTP 5xx, and rate limiting, with at most + five attempts. HTTP 4xx, size/decode/application/parse/validation failures, + and company mismatch never retry. +- The native Tally runtime is wired in source to this controller for status, + capability, company, master, voucher, and validated report reads. The older + client-local queue is no longer on the request path. Request IDs, explicit + cancellation, capability cache, and operator snapshots remain native-facing. +- Schema-v2 observations record one terminal outcome per attempted read, + including queue deadline/cancellation, response class, circuit rejection, + and an explicit `unavailable` body-byte measurement. A Tauri command returns + the bounded privacy-reduced preview plus its checksum; nothing is uploaded or + persisted automatically. +- The loopback simulator now supports a fail-closed sequence of 1–64 response + plans. A real portable HTTP transport/runtime test proves an exact synthetic + HTTP 500 then HTTP 200 sequence causes one retry and two processed requests. + +Nine observability tests, eight runtime tests, and fifteen simulator integration +tests pass, and focused Clippy passes with warnings denied. These are +repository-synthetic results. The native crate still stops in +`libsqlite3-sys` before Bridge code can be checked because `libclang.dll` is +unavailable; the attempted LLVM installation was cancelled and was not +retried. No live XML POST, legitimate Education receipt, Windows/macOS native +runtime result, support-bundle save/review UI, performance budget, or support +claim is established. + +Production transport still retains each bounded response as bytes and decoded +text before parsing. Single-response status/export reads now report their +encoded byte count after a complete body, including when later application +validation or parsing fails. Compound capability probes, pre-response failures, +and partial transport reads remain explicitly unavailable. Streaming/staged production parsing, +partial-failure byte plumbing, property/fuzz testing, and live profile evidence +remain P1/P2 follow-on work. + +#### Implemented PR16 boundary — Party Outstanding Confidence Receipt v1 + +PR16 establishes a portable, read-only foundation for bill allocations and +party outstandings. The version-2 canonical model preserves all four known +reference kinds plus unclassified source text, voucher and ledger-opening +origins, exact signed decimals, optional/evidence-qualified due dates, currency +basis, bill-wise state, source counts, coverage, query profile, and a fetch +bracket. Supplied derivation helpers exclude mutable amounts and dates by using +parent or exact report scope plus ordinal, and reconciliation rejects the +schema's legacy mutable-reference identity basis. Outstanding identity remains +ordinal/profile dependent and is not qualified for mirror authority until row +ordering stability is observed. + +A non-default parser accepts only one exact Bridge-owned raw-observation +envelope. It is deliberately `Unbound`: its company, party, date, direction, +profile, counts, and values are response-internal claims and cannot authorize a +capability or canonical import. Parsing is bounded, UTF-8/UTF-16 aware, +fail-closed on grammar/count/duplicate/active-XML violations, and uses redacted +errors plus domain-separated response and fragment commitments. + +The confidence engine requires caller-owned equality for company identity, +party, as-of date, receivable/payable direction, query profile, and exact scope +fingerprint before comparison. Opaque engine authority must separately prove +the allocation profile, outstanding profile, signed-amount semantics, +direction/sign and due-date/ageing semantics, On Account aggregation, +settled-row omission semantics, and the meaning of an empty complete scope. +There is no public constructor that can promote this +authority; a future live-qualified adapter path requires separate review. Exact arithmetic +then categorizes matches, partial settlement, On Account aggregate matches, +mismatches, disabled bill-wise tracking, incomplete coverage, incomparable +currency, profile unobserved, source change, or unavailable evidence. Unknown, +incomplete, unobserved, or unauthoritative empty evidence never proves zero. +Only an independently authorized complete and stable empty scope may match; +no such live authority exists today. The engine never invents a bill link for +On Account amounts. + +Generic typed Bills canonicalization and the shared reconciliation result shape +exist, but this slice has no parser-to-canonical adapter, request builder, TDL +export, native extraction/runtime dispatch, Bills-qualified mirror/checkpoint/ +proof authority, UI, reminder, payment, or write surface. It therefore remains +`Unknown, not supported`. A live Education-mode +fixture must use literal calendar day `01`, `02`, or `31`, and a qualifying +receipt must be captured before any source-semantics authority or support claim +can be promoted. Education mode has no TSS and excludes connected services, so +this fixture cannot qualify email, WhatsApp, payment-request, remote, or other +connected flows; local XML/TDL behavior remains release-specific. + +Tally's Sample XML page documents report names and import-side `BILLTYPE` +values. It does not establish Bridge's raw observation envelope or a universal +read/export response schema. + +#### Implemented PR17 boundary — native Ledger Outstandings observation probe + +PR17 adds only a dormant, feature-gated request probe for Tally's documented +native `Ledger Outstandings` report. It fixes `Export`/`Data`, the report ID, +validated company, party-ledger and report To-date inputs, XML export, and exploded +detail, and binds the exact template, rendered request, and scope using +domain-separated SHA-256 commitments. Diagnostics redact all dynamic values. +The probe is outside the production `ReadOnlyProfile` enum and native runtime; +it has no transport method, response parser, retry, canonical adapter, mirror, +checkpoint, proof, capability, UI, or authority constructor. + +This is request-shape evidence only. Official documentation does not establish +the release-stable response grammar, sign convention, due-date derivation, row +ordering, completeness, or empty-scope meaning. The companion runbook defines +a disposable INR Education fixture covering ledger opening, New Ref, partial +Agst Ref, On Account, Advance, and explicit due date, plus three unchanged +reads and negative cases. No live POST was performed: the running executable's +file metadata reports TallyPrime Edit Log 1.1.7.0, while the About screen, +loaded synthetic-company state, and independent party identity could not be +safely observed. Current TallyPrime documentation must not be generalized to +that unverified local release. + +#### Implemented PR18 boundary — qualification-only typed runner + +PR18 adds that runner as a required-feature standalone binary, without adding +it to the native Bridge graph. Its transport accepts only the sealed +CandidateV0 type, revalidates the frozen template and rendered request hash at +dispatch, enforces loopback/no-proxy/no-redirect, fixes a 20-second timeout, +caps the request at 64 KiB and both encoded and decoded response data at 1 MiB, +and performs no retry. + +The runner rejects unknown About values, non-Education mode, configured +TDLs/add-ons, customer-data attestation gaps, placeholder identity commitments, +unreviewed registration, paths outside tracked fixture or ignored local roots, +stale/incomplete UI evidence, and any surface drift before network access. Its +preflight and dispatch consents are distinct consumed types, use fresh +cryptographic nonces, expire after five minutes, and bind the build, lockfile, +surface, fixture, scenario, endpoint, identity registration, UI-before, and +Candidate commitments. A successful run is exactly 2 preflight POSTs followed +by B0/Candidate1/B1/Candidate2/B2/Candidate3/B3 (11 POSTs), for 13 total. + +Every identity bracket re-establishes the unique company GUID and party stable +source identity. A byte-repeatable positive observation requires all three +Candidate responses to have HTTP success and strict Tally `STATUS=1`; their +exact encoded bodies are compared byte-for-byte in bounded memory and then +discarded. The runner retains no raw response and +requires a separate UI-after and receipt-save confirmation. Its dedicated +receipt is structurally incompatible with `LiveCompatibilityReceipt` and fixes +authenticity, scope, grammar, accounting semantics, completeness, atomicity, +performance, runtime, mirror, and support authority to false. + +The qualification runner now also preserves negative observation evidence. +Candidate application failure/unrecognized status, HTTP rejection, and +transport failure become typed attempt facts; each completes its trailing +identity bracket and the remaining fixed sequence only while company and party +identity remain valid and unchanged. This is not retry: the request budget and +order remain fixed. Missing bodies make repeatability `NotEstablished`, raw +bytes are still discarded, and the receipt retains only bounded hashes/status/ +reason facts. Identity failure or drift still stops before a subsequent scoped +Candidate request. Native receipt saving now consumes a repository-issued, +exact-path-bound JSON target and recomputes the save challenge internally. +UI evidence rows are now closed typed objects with exactly the reviewed ten +fields, contiguous indices, bounded control-free text, and a 256-row cap. The +settled-reference observation is a closed enum enforced against the selected +scenario, so arbitrary JSON or invented status prose cannot authorize a run. + +No live POST was performed in PR18. The checked-in profile and UI files are +deliberately invalid examples, and this workstation still lacks a separately +reviewed identity registration plus complete About/UI evidence. The next gate +is therefore a visible, synthetic-only local registration and UI evidence pass, +followed by one consented observation. Only after exact release evidence exists +may a response grammar be frozen. Parser binding, semantic authority, canonical +adaptation, production dispatch, and support promotion each remain separate +future reviews. Bills stays `Unknown, not supported`. + +Official research inputs: + +- +- +- +- +- +- +- +- + +--- + +### Decision gate — Optional Bridge Companion TDL + +Do **not** make a TDL add-on a prerequisite now. + +Consider an optional open-source companion only when one or more of these remain unsolved externally: + +- stable change feed or company identity; +- efficient server-side filtering; +- in-Tally validation required at the point of entry; +- role-aware Bridge actions inside Tally; +- an operator-visible Bridge status panel; +- reliable webhook-like notification; +- import preconditions that must execute inside Tally. + +Before adoption, write an ADR covering: + +- installation/update/removal; +- Tally release compatibility; +- signatures/provenance; +- permissions; +- performance; +- failure isolation; +- support burden; +- fallback when absent; +- Education-mode behaviour; +- whether the feature remains useful without it. + +--- + +## 7. Test strategy + +### 7.1 Test pyramid + +1. **Pure unit tests** + - builders; + - escaping; + - encodings; + - parsers; + - error classification; + - decimals; + - identity; + - canonical hashing; + - state machine. + +2. **Property/fuzz tests** + - XML/JSON escaping; + - nested/repeated fields; + - malformed input; + - arbitrary Unicode; + - decimal forms; + - duplicate ordering; + - parser never panics; + - redaction never exposes seeded secrets. + +3. **Simulator integration tests** + - all protocol/failure scenarios; + - concurrency; + - retry; + - cancellation; + - resume; + - ambiguous write. + +4. **Database integration tests** + - migrations; + - atomic snapshot; + - rollback; + - checkpoint; + - tombstones; + - idempotent upsert; + - outbox recovery. + +5. **Frontend tests** + - Truth State rendering; + - setup wizard; + - partial/failure remediation; + - cancellation; + - accessibility; + - no misleading counts. + +6. **Live Tally contract tests** + - explicit, opt-in, synthetic company; + - exact release/mode recorded; + - no customer data; + - destructive tests disabled by default; + - artifacts redacted. + +### 7.2 Critical failure invariants + +- Concurrent same-endpoint operations: maximum in-flight requests = 1. +- Wrong company: no commit. +- HTTP 200 application error: no commit. +- Parse error: no checkpoint advance. +- Cancellation: no checkpoint advance. +- Retry after read timeout: no duplicates. +- Write timeout after send: `OutcomeUnknown`. +- Re-run unchanged snapshot: same canonical hash. +- Incremental + final full snapshot: equal canonical state. +- Reconciliation mismatch: not `Verified`. +- Logs/support output: seeded sensitive values absent. + +### 7.3 Education edition live test sequence + +Run only after the simulator and P0 protocol work are complete. + +1. Record Tally product/release and visible mode. +2. Use a synthetic company; do not use real books. +3. Confirm HTTP server configuration. +4. Optionally record `/status` as a non-authoritative diagnostic, then run the + documented XML POST company enumeration as the integration check. +5. Select the synthetic company explicitly. +6. Run one minimal ledger read. +7. Run one minimal voucher-range read. +8. Test an empty range and a small populated range. +9. Verify actual range filtering client-side. +10. Probe JSONEX only if release indicates 7.0+. +11. Do not write unless the Capability Passport and operator explicitly allow the synthetic write test. +12. For a write: + - use a documented permitted Education-mode voucher date: the 1st, 2nd, or 31st, never a generic "last day" substitution; + - include an ordinary disallowed date as a negative test without changing system time or bypassing licensing; + - one uniquely tagged synthetic ledger first; + - parse all counters; + - re-read it; + - delete/compensate only if a separately reviewed operation exists. +13. Record every capability as observed for that exact release/mode. +14. Remove or archive the synthetic company according to the operator’s normal Tally process. + +No workaround may be added to evade Education-mode limits. + +--- + +## 8. Security and privacy threat model + +### Assets + +- company identity; +- ledgers and parties; +- tax identifiers; +- vouchers and amounts; +- narrations; +- inventory; +- credentials for downstream AXAL; +- import decisions; +- audit evidence. + +### Threats + +- posting to the wrong company; +- leaking books through logs or GitHub fixtures; +- remote plaintext exposure; +- malicious local service impersonating Tally on port 9000; +- oversized/malformed payload denial of service; +- XML/JSON parser abuse; +- duplicate write after timeout/retry; +- stale or poisoned mapping; +- unsafe support bundle; +- SQL migration loss; +- UI misrepresentation of partial data. + +### Required controls + +- retain loopback-only Tally policy; +- capability/application-level handshake; +- explicit company pinning; +- response caps and deadlines; +- strict parsing; +- shared serial queue; +- data minimisation; +- redacted structured logs; +- immutable audit events; +- no raw payload by default; +- bounded previewable support export; +- typed ambiguous outcomes; +- idempotency and re-read verification; +- migration backups/rollback notes; +- synthetic fixtures only. + +A local process can impersonate port 9000. Bridge should therefore describe the endpoint as **local and capability-verified**, not cryptographically authenticated, unless a future Tally-supported authentication mechanism is implemented for this interface. + +--- + +## 9. Performance and resilience design + +### Do + +- reuse HTTP clients and endpoint sessions; +- serialise per endpoint; +- stream/stage large responses; +- use adaptive date windows; +- hash incrementally; +- commit in database transactions; +- persist resumable run/window state; +- keep UI work off the main thread; +- use bounded queues; +- make cancellation cooperative; +- retry only classified transient reads; +- apply jitter; +- trip a small circuit after repeated connection failures; +- expose next safe action. + +### Do not + +- fetch all native methods; +- store complete raw payloads; +- build one enormous in-memory DOM; +- retry malformed or validation errors; +- retry writes blindly; +- advance a checkpoint before reconciliation; +- equate row count with completeness; +- use names as immutable IDs; +- use floating-point amounts; +- claim “real time” without measured end-to-end latency and a defined trigger model. + +--- + +## 10. Codex operating rules + +Codex must follow these rules for every PR: + +1. Read `AGENTS.md`, `CONTRIBUTING.md`, `SECURITY.md`, `review-checklist.md`, and Tally docs before editing. +2. Keep each PR focused on one roadmap stage. +3. Preserve loopback-only Tally networking. +4. Use synthetic values only. +5. Never add sample company exports, raw responses, GSTINs, PANs, narrations, certificates, credentials, local usernames, or absolute machine paths. +6. Add tests before or with behavioural changes. +7. Do not weaken response caps, CSP, endpoint validation, or redaction. +8. Do not add a dependency until license inventory and security impact are addressed. +9. Do not modify unrelated DSC/document/AXAL behaviour except minimal compile wiring. +10. Any schema change uses a versioned migration and contains rollback/compatibility notes. +11. Every Tally request is explicit about company context when company-scoped. +12. Every response is checked at transport and Tally application levels. +13. A failed/partial/cancelled run cannot advance the verified checkpoint. +14. Writes remain disabled until the dedicated safe-write stage. +15. No unsupported Tally claim is added to README or UI. +16. Run: + - `corepack pnpm install --frozen-lockfile` + - `corepack pnpm run license:all` + - `corepack pnpm run build` + - `corepack pnpm run cargo:fmt` + - `corepack pnpm run cargo:check` + - `corepack pnpm run cargo:test` + - `corepack pnpm run cargo:clippy` + - relevant security audits +17. PR body includes: + - functional summary; + - tests; + - migration/sync impact; + - rollback; + - Tally/security impact; + - supported/missing platform evidence; + - linked review-checklist line. + +--- + +## 11. Copy-paste master prompt for Codex + +```text +Repository: lamemustafa/bridge +Base branch: master + +Mission: +Implement the next item in docs/tally/TALLY_INTEGRATION_RESEARCH_AND_CODEX_PLAN.md. +Bridge must become a truthful, resilient, accounting-safe Tally connector, not a +feature-count demo. + +Before editing: +1. Read AGENTS.md, CONTRIBUTING.md, SECURITY.md, review-checklist.md, + docs/step-by-step-roadmap.md, README.md, and all files under + src-tauri/src/tally. +2. Inspect the current tests and CI commands. +3. State the exact invariant this PR will establish. +4. Keep the PR limited to the named roadmap item. + +Non-negotiable rules: +- Preserve loopback-only Tally connectivity and redirect blocking. +- Use only synthetic test data. +- Do not log or commit raw accounting payloads, customer data, GSTIN/PAN values, + narrations, credentials, local usernames, or machine-specific paths. +- Do not treat HTTP 200 as Tally success. +- Require explicit current-company context for every company-scoped request. +- Never use floating point for amounts. +- Never advance a verified checkpoint on partial, failed, cancelled, or + unreconciled runs. +- Do not automatically retry writes. +- Do not add write capability before the safe-write roadmap stage. +- Do not change DSC, document, or AXAL behaviour except minimal compile wiring. +- Every behavioural change needs a regression test. +- Every DB change needs a versioned migration and rollback/compatibility notes. +- Keep frontend messages honest: Verified, Partial, Stale, Unsupported, Failed. + +Required validation: +corepack pnpm install --frozen-lockfile +corepack pnpm run license:all +corepack pnpm run build +corepack pnpm run cargo:fmt +corepack pnpm run cargo:check +corepack pnpm run cargo:test +corepack pnpm run cargo:clippy +corepack pnpm run security:audit:frontend +cargo audit --file src-tauri/Cargo.lock + +PR output: +- concise implementation summary; +- files changed; +- tests added; +- exact commands/results; +- migration and rollback impact; +- Tally/security/privacy impact; +- remaining uncertainty; +- one linked review-checklist item. +``` + +--- + +## 12. First implementation prompt for Codex: PR 01 + +```text +Repository: lamemustafa/bridge +Base: master +Create branch: fix/tally-shared-runtime + +Goal: +Fix the current Tally serialization scope. Today every Tauri command constructs a +new TallyClient, and every TallyClient constructs its own SerialTallyQueue. This +does not prevent concurrent commands from posting to the same Tally endpoint. + +Implement: +1. Add a Tauri-managed TallyRuntime. +2. The runtime owns/reuses one TallySession per canonical loopback endpoint. +3. A TallySession owns: + - the reqwest Client; + - one request mutex/queue; + - endpoint configuration; + - any request spacing setting. +4. Register TallyRuntime with tauri::Builder::manage. +5. Change check_tally_connection, fetch_tally_companies, + fetch_tally_ledgers, and fetch_tally_vouchers to use the managed runtime. +6. Preserve current renderer command payloads and response shapes. +7. Preserve loopback validation, no redirects, timeouts, and response byte caps. +8. Ensure localhost/127.0.0.1 aliases cannot create competing sessions for the + same endpoint. +9. Ensure cancellation/error releases the gate. +10. Do not implement retries, JSONEX, schema changes, or new UI in this PR. + +Required tests: +- concurrent same-endpoint operations observe max_in_flight == 1; +- operations on distinct endpoint keys are not forced through one global gate; +- localhost and 127.0.0.1 resolve to the same session key; +- a failed request releases the gate; +- a cancelled request releases the gate; +- existing endpoint-rejection tests remain green. + +Design constraints: +- Keep production code independent of a real Tally installation. +- Use a synthetic loopback test server. +- Avoid sleeping 500 ms in unit tests. Make spacing injectable or zero in tests. +- Do not expose internal Arc/Mutex types through command responses. +- Do not add raw request/response logging. + +Validation: +Run every command listed in the master prompt. Update documentation only where +needed to explain the runtime invariant. Include rollback and Tally/security +impact in the PR body. +``` + +--- + +## 13. Issue-ready backlog labels + +Apply the repository’s existing area/severity conventions. + +| Roadmap item | Suggested labels | +|---|---| +| PR 00 | `area:tally`, `type:chore`, `severity:p3` | +| PR 01 | `area:tally`, `type:rectify`, `severity:p1` | +| PR 02 | `area:tally`, `type:bug`, `severity:p1` | +| PR 03 | `area:tally`, `type:chore`, `severity:p2` | +| PR 04 | `area:tally`, `type:feature`, `severity:p2` | +| PR 05 | `area:tally`, `type:feature`, `severity:p2` | +| PR 06 | `area:tally`, `type:feature`, `severity:p1` | +| PR 07 | `area:tally`, `type:feature`, `severity:p1` | +| PR 08 | `area:tally`, `type:feature`, `severity:p1` | +| PR 09 | `area:tally`, `type:feature`, `severity:p2` | +| PR 10 | `area:tally`, `type:feature`, `severity:p2` | +| PR 11 | `area:tally`, `type:feature`, `severity:p1` | +| PR 12 | `area:tally`, `type:feature`, `severity:p1` | +| PR 13 | `area:tally`, `type:feature`, `severity:p2` | +| PR 14 | `area:tally`, `type:chore`, `severity:p2` | + +Use the exact label names present in the repository; adjust this table rather than creating duplicates. Apply exactly one `area:*` label. Suspected vulnerabilities and sensitive-data exposures do not use this public backlog: follow `SECURITY.md` privately until coordinated disclosure is safe. + +--- + +## 14. Definition of “top-notch” + +The Tally integration is top-notch when all of the following are true: + +- A fresh contributor can reproduce protocol failures without Tally. +- A user can connect with a guided, low-friction setup. +- Bridge identifies the selected company explicitly. +- Same-endpoint requests cannot race. +- HTTP and Tally application errors are distinct. +- UTF-8/UTF-16 and malformed-response cases are tested. +- Large snapshots are bounded, resumable, and atomic. +- Incremental sync is deletion-aware and periodically reconciled. +- Exact money and stable identities are used. +- Every run has a Proof of Sync and Gap Map. +- The UI distinguishes verified state from the latest attempt. +- JSONEX is enabled only where parity and a measured operational benefit have been proven; no intrinsic speed claim is made. +- Writes require preview, approval, import counters, idempotency, and re-read. +- An ambiguous write remains visible rather than being retried blindly. +- Logs and support bundles do not leak books. +- Education-mode capability is observed and honestly labelled. +- Support claims map to current compatibility evidence. +- Failures include safe, specific recovery instructions. +- No marketing adjective substitutes for a measured invariant. + +--- + +## 15. Explicit non-goals for the first half of the roadmap + +Until PR 08 is complete, do not prioritise: + +- autonomous bookkeeping; +- document OCR; +- generic GST return filing claims; +- broad remote/LAN plaintext connectivity; +- production writeback; +- a mandatory TDL package; +- multi-ERP abstraction; +- cloud scheduling; +- “real-time” marketing; +- complex AI ledger mapping; +- large UI redesign unrelated to Truth States. + +The first milestone is **trustworthy read sync**, not two-way feature breadth. + +--- + +## 16. Research sources + +Official Tally sources: + +- Integrate with TallyPrime: https://help.tallysolutions.com/integrate-with-tallyprime/ +- XML Integration: https://help.tallysolutions.com/xml-integration/ +- Pre-requisites for Integrations: https://help.tallysolutions.com/pre-requisites-for-integrations/ +- JSON Integration / JSONEX: https://help.tallysolutions.com/tally-prime-integration-using-json-1/ +- Integration Initiated From TPAs: https://help.tallysolutions.com/integration-initiated-from-tpas/ +- ODBC Integrations: https://help.tallysolutions.com/odbc-integrations/ +- Integration Methods and Technologies: https://help.tallysolutions.com/integration-methods-and-technologies/ +- TallyPrime 7.1 release notes: https://help.tallysolutions.com/release-notes-tallyprime-7-1/ +- Licensing FAQ: https://help.tallysolutions.com/tally-prime/installation-and-licensing/licensing-faq-errors-tally/ +- Licensing best practices, including Education-mode dates: https://help.tallysolutions.com/licensing-best-practices/ + +Product workflow benchmarks: + +- RazorpayX Accounting Payouts: https://razorpay.com/x/accounting-payouts/ +- Volopay ERP Integration: https://www.volopay.com/integration/ +- Volopay Accounting Automation: https://www.volopay.com/accounting-automation/ +- Vyapar TaxOne Data Automation: https://taxone.vyapar.com/data-entry-automation-feature +- Vyapar TaxOne GST Reconciliation: https://taxone.vyapar.com/gst-reconciliation-feature +- Sage Expense Management: https://www.fylehq.com/ +- Happay: https://happay.com/ +- EnKash: https://www.enkash.com/ +- Open-source Rust Tally SDK: https://github.com/labs-infinitum/tally-sdk-rs + +Repository evidence: + +- https://github.com/lamemustafa/bridge +- `README.md` +- `AGENTS.md` +- `package.json` +- `.github/workflows/ci.yml` +- `src-tauri/Cargo.toml` +- `src-tauri/src/tally/*` +- `src-tauri/src/commands.rs` +- `src-tauri/src/lib.rs` +- `src-tauri/src/db/schema.rs` +- `src-tauri/src/sync/*` +- `src/main.tsx` diff --git a/docs/tally/compatibility/README.md b/docs/tally/compatibility/README.md new file mode 100644 index 0000000..35bf215 --- /dev/null +++ b/docs/tally/compatibility/README.md @@ -0,0 +1,78 @@ + +| Exact cell | Product / release / mode | Host | Transport / loopback / ODBC | Data profile | Claim | Promotion eligible | Evidence | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `erp9-6-6-3-windows-education-xml-one-company` | `tally_erp9` / `6.6.3` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-6-2-windows-education-xml-one-company` | `tally_prime` / `6.2` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-0-windows-education-xml-one-company` | `tally_prime` / `7.0` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-1-windows-education-jsonex-one-company` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `json_ex_shadow` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `false` | `missing` | +| `prime-7-1-windows-education-xml-large` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_large` | `unknown` | `false` | `missing` | +| `prime-7-1-windows-education-xml-multiple-companies` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `multiple` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-1-windows-education-xml-no-company` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `none` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `false` | `missing` | +| `prime-7-1-windows-education-xml-odbc-enabled` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `enabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-1-windows-education-xml-one-company` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-1-windows-education-xml-other-locale-utf16le` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `other` / `utf16_le` / `synthetic_small` | `unknown` | `false` | `missing` | +| `prime-7-1-windows-licensed-xml-one-company` | `tally_prime` / `7.1` / `licensed` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | + + +# Tally compatibility evidence + +`compatibility-matrix.json` is the machine-readable authority for exact Tally +support cells. Every cell starts as `unknown`; absence of evidence is never +success. `compatibility-surface.json` binds evidence freshness to the exact +Bridge Tally request, parser, transport, runtime, lockfile, and gate sources. + +An evidenced `observed`, `supported`, or `unsupported` cell requires all of the +following: + +- a privacy-reduced live-read receipt for the exact product, release, mode, + platform, architecture, transport, ODBC state, company-load state, locale, + encoding, fixture-owned dataset tier, source surface, commit, and operation + profiles, plus an explicit operator attestation that no customer data was + loaded; +- an unexpired maintainer review attestation signed by a non-revoked key in + `trusted-evidence-keys.json`; +- a clean source tree at observation time and evidence no older than the cell's + declared maximum age; +- a successful executable gate run against the current source surface. + +The receipt never establishes responder authenticity, source completeness or +atomicity, accounting correctness, performance support, or any write behavior. +Checksums detect accidental change; only the reviewed signature supplies claim +authority. + +Run the checked-in gate from `src-tauri`: + +```sh +cargo run --locked -p bridge-tally-compatibility -- gate \ + ../docs/tally/compatibility/compatibility-matrix.json \ + ../docs/tally/compatibility/compatibility-surface.json \ + ../docs/tally/compatibility/trusted-evidence-keys.json \ + ../docs/tally/compatibility/evidence .. +``` + +`Observed` and `supported` require every required operation to pass the full +fixture sentinel contract. `Unsupported` requires the synthetic fixture +identity to be verified first and an explicit, profile-specific unsupported +signature to be reviewed and implemented. No such signature currently exists, +so live receipts cannot promote any `Unsupported` claim. In particular, a +generic Tally `STATUS=0` rejection can also mean malformed TDL, permissions, or +a transient condition and remains `Failed` evidence. Fixture, context, or +sentinel mismatches; parser or malformed-response failures; transport failures; +a missing or mismatched fixture; failure of the marker check itself; an +unreachable port; and absent evidence are likewise not unsupported. Those +observations remain fail-closed rather than being promoted into product +incompatibility. JSONEX, large-dataset, no-company, and UTF-16 cells are +currently non-promotable and remain `unknown` until their qualification paths +exist. + +The standalone live controller is documented in +[`live-education-runbook.md`](./live-education-runbook.md). It dispatches only +the sealed production read profiles through a typed adapter, requires two +interactive confirmations, and cannot overwrite a receipt. Never create a +receipt manually or promote parser-only qualification evidence. + +The separate [`bills-native-probe-runbook.md`](./bills-native-probe-runbook.md) +defines the synthetic fixture and stop conditions for the dormant native +`Ledger Outstandings` probe. Its non-default typed runner can create only a +structurally separate observation receipt. It is not a production read profile, +cannot create a compatibility receipt, and cannot satisfy a support claim. diff --git a/docs/tally/compatibility/bills-native-probe-runbook.md b/docs/tally/compatibility/bills-native-probe-runbook.md new file mode 100644 index 0000000..66efd96 --- /dev/null +++ b/docs/tally/compatibility/bills-native-probe-runbook.md @@ -0,0 +1,264 @@ +# Bills native outstandings probe runbook + +This runbook defines a future consent-gated observation of Tally's native +`Ledger Outstandings` report using only the disposable synthetic fixture in +[`education-native-outstandings-v0.json`](./fixtures/education-native-outstandings-v0.json). +It does not qualify a production connector or create a support claim. + +CandidateV0 now has an isolated, non-default qualification runner, but the +checked-in example remains **NO-GO for live dispatch**. Never send or +copy/paste the candidate XML. A live observation is allowed only when all of +the following exist together: + +- the reviewed typed runner whose dispatch method accepts only + `SealedNativeLedgerOutstandingsProbe`; +- a separately registered and reviewed local company and party identity + commitment; +- visible About-screen evidence for the exact product, release, Education mode, + locale, HTTP server port, and configured TDL/add-on counts; +- complete structured UI-before and UI-after evidence for the selected scope; +- separate, run-bound preflight and dispatch consent challenges. + +Build or invoke only the required-feature binary from the repository root: + +```sh +cargo run --locked --manifest-path src-tauri/Cargo.toml \ + -p bridge-tally-live-read \ + --features bills-native-outstandings-probe-runner \ + --bin bridge-tally-native-outstandings-probe -- \ + run .bridge-live/native-outstandings-profile.json \ + .bridge-live/native-outstandings-observation.json \ + --consent read-only-synthetic-native-outstandings +``` + +The binary is absent from default builds and from the native Bridge feature +graph. Its receipt is an observation-only +`BillsNativeOutstandingsProbeReceiptV0`, not a `LiveCompatibilityReceipt`. + +The shipped +[`native-outstandings-profile.example.json`](./native-outstandings-profile.example.json) +is deliberately unusable. Its identities are all-zero placeholders, its About +fields are unknown, and its attestations are false. A future runner must reject +it until a separate local registration and review replaces every placeholder. +Do not invent a GUID or another Tally identity merely to pass the gate. + +## Safety gate + +- Use one invocation for exactly one reviewed scenario and one ToDate: + `20260701`, `20260702`, or `20260731`. Do not batch dates. +- Tally must be bound to literal IPv4 or IPv6 loopback on the explicitly + reviewed port. Redirects, proxies, DNS hosts, and non-loopback endpoints are + forbidden. +- The About screen must visibly confirm exact Tally product, release, Education + mode, locale, HTTP server port, and zero configured TDLs/add-ons for the + baseline observation. Executable file metadata is not About evidence. +- Exactly one loaded company must be the high-entropy synthetic fixture company. + No customer, production, personal, or other company data may be loaded. +- Company and party identities must match nonzero commitments from a separate + reviewed local registration. Name equality alone is insufficient, and an + observed identifier is not evidence that its stability has been qualified. +- Request bytes must come from the sealed, non-default CandidateV0 profile. + Caller-provided XML, TDL, report names, formulas, headers, or request counts + are forbidden. +- No import, write, reminder, settlement, payment, email, WhatsApp, connected + service, or fixture-creation action is permitted. +- No automatic retry or compensating request is permitted. A preflight or + identity-bracket failure stops with fewer than 13 POSTs. A Candidate + application, HTTP, or transport failure is recorded, followed by its required + identity bracket and the remaining fixed Candidate/bracket observations, but + only while identity remains valid and unchanged. +- PR18 retains no raw Tally response. Response bodies may exist only in bounded + memory long enough to validate, hash, and compare them, and must then be + discarded. + +If About evidence, UI evidence, the company marker, either identity commitment, +or any request commitment cannot be verified, stop before the applicable +consent challenge. Do not interpret an empty or unrecognized response as zero. + +## Reviewed Education fixture + +The fixture uses INR, enables bill-by-bill tracking, and uses these immutable +synthetic markers: + +- company: + `BRIDGE-PR18-NATIVE-OUTSTANDINGS-COMPANY-019f605f-e6cf-77b2-ac95-31722887a911` +- party: + `BRIDGE-PR18-NATIVE-OUTSTANDINGS-PARTY-019f605f-e6cf-77b2-ac95-31722887a911` + +Education-mode voucher events use only literal calendar days 1, 2, or 31. + +| Source event | Date | Synthetic detail | Expected accounting state | +|---|---:|---|---:| +| Ledger opening | fixture setup | `OPEN-001`, INR 250 Dr | opening bill INR 250 Dr | +| Sales | 2026-07-01 | `INV-001`, New Ref INR 1,000 Dr, explicit due 2026-07-31 | invoice pending INR 1,000 Dr | +| Sales | 2026-07-01 | separate On Account INR 125 Dr | On Account INR 125 Dr | +| Receipt | 2026-07-02 | INR 400 Agst Ref `INV-001` and INR 50 Advance `ADV-001` Cr | invoice pending INR 600 Dr; advance INR 50 Cr | +| Receipt | 2026-07-31 | final INR 600 Agst Ref `INV-001` | invoice settled; report presence or omission remains unobserved | + +These are fixture and accounting expectations, not expected XML tags, signs, +ordering, completeness, or zero semantics. The exact Candidate profile, +template, rendered-request, and scope hashes for each allowed scenario are +sealed in the reviewed fixture manifest. A mismatch requires a new reviewed +candidate or fixture version; it must not be accepted as drift. + +## Structured UI evidence + +Before invoking the runner, copy the incomplete +[`native-outstandings-ui-before.example.json`](./native-outstandings-ui-before.example.json) +and +[`native-outstandings-ui-after.example.json`](./native-outstandings-ui-after.example.json) +under ignored `.bridge-live/` and complete the UI-before capture. The examples +are rejection fixtures: zero hashes, +zero timestamps, false visibility flags, empty projections, `unobserved` +settlement state, or `evidence_complete: false` must never authorize dispatch or +complete a receipt. + +For the selected scenario, both observations must record: + +- the exact fixture, scenario, company marker, party marker, and ToDate; +- the native `Ledger Outstandings` report in Detailed mode; +- Opening, Pending, Due, and Overdue columns visibly present; +- a screenshot SHA-256 and nonzero capture time; +- every visible row in display order using the exact ordered projection fields + declared by the fixture manifest; +- the displayed date, reference, opening and pending amount text, due and + overdue text, voucher details, and Dr/Cr text without normalizing them; +- an explicit operator attestation that no Tally interaction occurred inside + the dispatch bracket. + +Each projection row is a deny-unknown-fields object containing exactly the ten +declared fields. `row_index` must be contiguous from zero, `row_kind` is a +nonempty bounded label, each copied display field is bounded and control-free, +and at most 256 rows are accepted. The settled-reference value is the closed +enum `present`, `omitted`, or `unobserved`; arbitrary prose is rejected. The +July 1 and July 2 scenarios require `present`, while July 31 requires an +explicit `present` or `omitted` observation. + +The runner must compare canonical structured before/after projections. A +screenshot hash only binds an artifact; before and after screenshot hashes are +not expected to be equal and do not prove semantic equality. UI evidence is +operator-observed, not machine-attested. + +For the July 31 scenario, +`inv_001_settled_reference_observation` must be exactly `present` or `omitted` +in both observations. The runner must not assume omission. For July 1 and July +2 it must record `present`. + +## Two-stage consent and exact POST sequence + +A complete invocation that reaches every Candidate uses exactly **2 preflight +POSTs + 11 dispatch POSTs = 13 POSTs**. The dispatch consists of eight sealed identity +reads and three byte-identical CandidateV0 reads. Preflight authority cannot be +reused as dispatch authority. + +### Stage 1: identity preflight consent + +Before any network request, validate the reviewed fixture/profile hashes, exact +About observations, nonzero reviewed identity commitments, loopback endpoint, +no-customer-data attestation, and selected single scenario. Then require an +exact run-bound challenge equivalent to: + +`PREFLIGHT NATIVE-OUTSTANDINGS 2POSTS ` + +The digest must bind the runner/build and reviewed surface, executable and lock +hashes, fixture and local-registration hashes, About observations, canonical +loopback endpoint, selected scenario, and the exact two-request budget. + +Only after exact consent may the runner send, in order: + +1. sealed `CompanyListV1` once; +2. sealed `LedgersV1` once, scoped to the uniquely verified company. + +The company read must return exactly one loaded company whose marker and +identity match the reviewed local commitment. The ledger read must return +exactly one matching synthetic party whose independently observed source +identity matches its commitment. Any mismatch, ambiguity, invalid application +status, truncation, encoding failure, or response-limit failure ends the run. +No CandidateV0 request follows. + +### Stage 2: UI bracket and dispatch consent + +The runner validates the already captured UI-before observation before the +preflight challenge, then reuses its commitment when binding dispatch. After +both preflight reads pass, it seals CandidateV0 for the single scenario and +verifies its profile, template, rendered-request, and scope hashes against the fixture. +Require a new exact challenge equivalent to: + +`DISPATCH NATIVE-OUTSTANDINGS 11POSTS ` + +This digest must additionally bind the two observed identity commitments, both +preflight response commitments, the UI-before commitment, the exact sealed +CandidateV0 commitments, and the immutable 11-request interleave below. It is +single-use and cannot be replayed for another date, endpoint, fixture, release, +UI capture, or build. + +The runner may then execute exactly this order with zero retries and no Tally +interaction between requests: + +1. identity bracket B0: sealed `CompanyListV1`, then sealed `LedgersV1`; +2. CandidateV0 attempt 1; +3. identity bracket B1: sealed `CompanyListV1`, then sealed `LedgersV1`; +4. CandidateV0 attempt 2; +5. identity bracket B2: sealed `CompanyListV1`, then sealed `LedgersV1`; +6. CandidateV0 attempt 3; +7. identity bracket B3: sealed `CompanyListV1`, then sealed `LedgersV1`; +8. capture UI-after evidence immediately after B3. + +Every B0-B3 identity read must reproduce the uniquely verified company and +party context and match the same reviewed identity commitments. Each Candidate +request must be byte-identical. A complete fixed-budget run therefore has 2 +preflight POSTs followed by 8 bracket identity POSTs and 3 Candidate POSTs, for +13 POSTs in one fixed order. A preflight or identity failure stops the run and +does not consume the remaining budget. A Candidate failure does not authorize +a retry: it is recorded as the already-budgeted attempt, its trailing bracket +is completed, and the fixed sequence continues only while identity is valid and +unchanged. + +For each of the three Candidate responses, record only bounded metadata: +attempt ordinal, template/request/scope hashes, HTTP and strict application +status, encoding, encoded byte count, response hash when available, and a safe +reason code for non-success. Record +the corresponding bounded status and commitments for every bracket identity +read. Compare all three available Candidate response bodies byte-for-byte +while they remain in memory. Hash equality is a commitment, not a substitute +for the in-memory byte comparison. If any body is unavailable, byte +repeatability is `NotEstablished`. Drift, a bracket mismatch, a failed attempt, +or any ambiguous grammar keeps the result `ProfileUnobserved`. + +After B3, capture and validate UI-after evidence. A canonical before/after +projection mismatch invalidates the whole sequence. + +The final save challenge commits to both the sealed receipt and its exact JSON +output under the canonical repository-local ignored `.bridge-live` directory. +The output target is issued before dispatch, consumed on save, revalidated, and +never overwrites an existing file. + +## No raw retention in PR18 + +PR18 has no raw-response save mode, save challenge, XML output file, diagnostic +preview, or automatic upload. Raw response bytes must never enter logs, errors, +receipts, screenshots, clipboard helpers, or `.bridge-live/`. Only hashes, +counts, statuses, encodings, timings, and the bounded operator-created UI +evidence may survive. A later request to retain raw fixture responses requires a +separate security/privacy review and new explicit authority. + +## Negative observations remain blocked + +Wrong-company, wrong-party, no-company, bill-wise-disabled, empty-party, and +other negative scenarios are outside this positive fixture runner. They require +separate reviewed fixture/scenario manifests and separate consent budgets. Do +not weaken the identity gate to exercise them. + +## Promotion boundary + +A probe result cannot enter `BillsAndPaymentsBatch`, mirror, checkpoint, proof, +capability, support matrix, or production runtime paths. Before adding a parser, +freeze the exact observed grammar for the attested release and add adversarial +replay, scope-mismatch, truncation, encoding, duplicate, drift, row-reordering, +and false-zero tests. Before adding a canonical adapter, separately qualify +sign, direction, due-date, On Account, settled-row omission, coverage, and +empty-scope semantics. + +CandidateV0 request bytes and commitments are immutable. Any request change +must introduce CandidateV1; a future qualified profile must use a separate type +instead of renaming or broadening CandidateV0. diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json new file mode 100644 index 0000000..1468d30 --- /dev/null +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -0,0 +1,238 @@ +{ + "schema_version": 1, + "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", + "compatibility_surface_sha256": "3af3def7be6ebf2c34c081fd7c8b886db7c65d6bee6b4de219fb6192a989f212", + "claims": [ + { + "claim_id": "erp9-6-6-3-windows-education-xml-one-company", + "level": "unknown", + "promotion_eligible": true, + "product": "tally_erp9", + "release": "6.6.3", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "one", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-6-2-windows-education-xml-one-company", + "level": "unknown", + "promotion_eligible": true, + "product": "tally_prime", + "release": "6.2", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "one", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-7-0-windows-education-xml-one-company", + "level": "unknown", + "promotion_eligible": true, + "product": "tally_prime", + "release": "7.0", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "one", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-7-1-windows-education-jsonex-one-company", + "level": "unknown", + "promotion_eligible": false, + "product": "tally_prime", + "release": "7.1", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "json_ex_shadow", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "one", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-7-1-windows-education-xml-large", + "level": "unknown", + "promotion_eligible": false, + "product": "tally_prime", + "release": "7.1", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "one", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_large", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-7-1-windows-education-xml-multiple-companies", + "level": "unknown", + "promotion_eligible": true, + "product": "tally_prime", + "release": "7.1", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "multiple", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-7-1-windows-education-xml-no-company", + "level": "unknown", + "promotion_eligible": false, + "product": "tally_prime", + "release": "7.1", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "none", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-7-1-windows-education-xml-odbc-enabled", + "level": "unknown", + "promotion_eligible": true, + "product": "tally_prime", + "release": "7.1", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "enabled", + "company_state": "one", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-7-1-windows-education-xml-one-company", + "level": "unknown", + "promotion_eligible": true, + "product": "tally_prime", + "release": "7.1", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "one", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-7-1-windows-education-xml-other-locale-utf16le", + "level": "unknown", + "promotion_eligible": false, + "product": "tally_prime", + "release": "7.1", + "mode": "education", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "one", + "locale": "other", + "encoding": "utf16_le", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + }, + { + "claim_id": "prime-7-1-windows-licensed-xml-one-company", + "level": "unknown", + "promotion_eligible": true, + "product": "tally_prime", + "release": "7.1", + "mode": "licensed", + "platform": "windows", + "architecture": "x86_64", + "transport": "xml_http", + "endpoint_family": "ipv4", + "odbc_state": "disabled", + "company_state": "one", + "locale": "english_india", + "encoding": "utf8", + "dataset_tier": "synthetic_small", + "fixture_manifest_sha256": null, + "required_profiles": [], + "max_evidence_age_days": 180, + "evidence_id": null + } + ] +} diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json new file mode 100644 index 0000000..6b6df64 --- /dev/null +++ b/docs/tally/compatibility/compatibility-surface.json @@ -0,0 +1,346 @@ +{ + "schema_version": 1, + "files": [ + { + "path": ".github/workflows/ci.yml", + "sha256": "5cbddb9c052f54e9b69c40ce779dc6885390d86510267fe0be7ed6c47d31f0db" + }, + { + "path": "docs/adr/0005-tally-snapshot-recovery.md", + "sha256": "ba5fd3abd12a6cc64acba939e75fc6a2cdc101bcd2c105affe07c7a95557abc6" + }, + { + "path": "docs/adr/0014-tally-native-outstandings-probe-authority.md", + "sha256": "85f1ca5b2d073670f84b1450a3c027c8f78b8be37380e4439df6f750ba27d7c3" + }, + { + "path": "docs/adr/0015-tally-selected-read-qualification-authority.md", + "sha256": "06d9e9178de87ddeb2f7228a6f37a841896337ad0d8fd8af591eb1899ecbb8f5" + }, + { + "path": "docs/step-by-step-roadmap.md", + "sha256": "32f57f362942613a39ffc837234e5339eac3cd27ceb8156a81c5b1d992ac15b7" + }, + { + "path": "docs/tally/TALLY_INTEGRATION_RESEARCH_AND_CODEX_PLAN.md", + "sha256": "e035243a7f5a9f3b78e1fd2f800b9da979777f5ad402e15ec892c514bf9e7610" + }, + { + "path": "docs/tally/compatibility/README.md", + "sha256": "4bc1e8b7f2e2cdb843ab9c794c509c61b9d505ee874ca7dfbd9cda22681baeaf" + }, + { + "path": "docs/tally/compatibility/bills-native-probe-runbook.md", + "sha256": "21f04b2698f00087afb992707ad24f9f102193a2ee0f79f7d5c8a2f93093d846" + }, + { + "path": "docs/tally/compatibility/fixtures/education-native-outstandings-v0.json", + "sha256": "95036eb7a735dd68932327b093a7c44cb87127719f5675d742be7a6c5fc213a0" + }, + { + "path": "docs/tally/compatibility/fixtures/education-small-v1.json", + "sha256": "4783d8ae7c0ce81ed63f4dbead855b81b792890f7ea2d5ab49c44b2e78366553" + }, + { + "path": "docs/tally/compatibility/native-outstandings-profile.example.json", + "sha256": "7ab63db939c19b34944851e81fb74dc580a74279bc5d9a6b1f1d3c448f452a1f" + }, + { + "path": "docs/tally/compatibility/native-outstandings-ui-after.example.json", + "sha256": "1c11a91ecf1c63bf62ae2976e5aed29e85f21c2b4709de803350e4b181f4dd23" + }, + { + "path": "docs/tally/compatibility/native-outstandings-ui-before.example.json", + "sha256": "9f842721c5ac86c44c900d0c94b2e5342ff4928ea6dbbf2dee6dcc26a255a9e0" + }, + { + "path": "docs/tally/support-matrix.md", + "sha256": "d2acf62109c337299168555b447e9e8e70c12f22836e949fe6251ece62eb6a21" + }, + { + "path": "package.json", + "sha256": "43b99ed2beb5e10cd766bcb1d6bc52765ca6a82acbf4739f10a69787a55b6d80" + }, + { + "path": "scripts/check-tally-live-read-boundary.mjs", + "sha256": "9fb008716aa69d6d37260c552b16b56d228d6b42c6713759d1662ec383191ac0" + }, + { + "path": "scripts/tally-company-selection.test.mjs", + "sha256": "64130a6fa57179622cb300041d75fb752bd1a081dbcd9118595a4a8f120d9161" + }, + { + "path": "src-tauri/Cargo.lock", + "sha256": "c1b300fced10c2abd5e4f8cb5574192c6e0b7b143eab4e35d3a0b60ba9666690" + }, + { + "path": "src-tauri/Cargo.toml", + "sha256": "4dbc169005a5beec87b38077536ba3f4032afa85ceba0745197919c5b3ba375e" + }, + { + "path": "src-tauri/crates/bridge-tally-canonical/Cargo.toml", + "sha256": "8750257055c37d81d9b93aea43c6902bd5a609a88eb5acd143752a88e8229631" + }, + { + "path": "src-tauri/crates/bridge-tally-canonical/src/lib.rs", + "sha256": "0bd5c939487e7f873286d65014dbbac804d046730ee0ac6722cdaac06ed675b6" + }, + { + "path": "src-tauri/crates/bridge-tally-canonical/tests/canonicalization.rs", + "sha256": "c09ae5ccb8489070a4024618f0cc6b7f13345e1147f187302e0af8a3df430c9a" + }, + { + "path": "src-tauri/crates/bridge-tally-compatibility/Cargo.toml", + "sha256": "fa7861275e42c039e1fa1f6e9ebf9de8102e7ecc89c55e6c3360da748fd64a0e" + }, + { + "path": "src-tauri/crates/bridge-tally-compatibility/src/bills_native_outstandings_probe_receipt.rs", + "sha256": "93a939dcf04d4b0b64feca20f2399ff6494fede9e6aa35fdb5ecffd896bf5978" + }, + { + "path": "src-tauri/crates/bridge-tally-compatibility/src/lib.rs", + "sha256": "13c383c759112a2eecc7e7189a8f498957d9ac1539ec872c32da0d3b4e6037c1" + }, + { + "path": "src-tauri/crates/bridge-tally-compatibility/src/main.rs", + "sha256": "c6c97013b8e91d2b54c36e78233f531802bcf316fcc4c36c6c75aabe37993aa9" + }, + { + "path": "src-tauri/crates/bridge-tally-core/Cargo.toml", + "sha256": "31b2dda54fa78162c47aa202c17d86303ea2b648c5e562e368fecb0c8d1ee260" + }, + { + "path": "src-tauri/crates/bridge-tally-core/src/bills_reconciliation.rs", + "sha256": "c3b0bfa66147b3e90800f0ccf3ede8425b6324c5025af4c0f033b123eb9fe63a" + }, + { + "path": "src-tauri/crates/bridge-tally-core/src/exact_arithmetic.rs", + "sha256": "ea01744f3d1e68496414745bbedaec194585e97f0c18625a961a47b114e042c1" + }, + { + "path": "src-tauri/crates/bridge-tally-core/src/lib.rs", + "sha256": "cd9ee4a7aeb7781375af1db570f677552e6e66c0964d4a3527f172f467482b8e" + }, + { + "path": "src-tauri/crates/bridge-tally-core/src/pack_models.rs", + "sha256": "3d3fb7b82c588a9c6f4b93bbed1acea6139c53cf5ca582b90337ceb0664598f1" + }, + { + "path": "src-tauri/crates/bridge-tally-live-read/Cargo.toml", + "sha256": "627b2ef41ef8bc01a3e5e28fc7985d98feece91f38135373b2aef9459e301226" + }, + { + "path": "src-tauri/crates/bridge-tally-live-read/src/bin/native_outstandings_probe.rs", + "sha256": "14c7e7e5c073ac1f36c331eb680b163c307d862e0bdefbaa88a65750ee2d3a7d" + }, + { + "path": "src-tauri/crates/bridge-tally-live-read/src/lib.rs", + "sha256": "e5a37cc45e5718353525f7bd0a00f4cad739716c8588c2409aa828b125351d70" + }, + { + "path": "src-tauri/crates/bridge-tally-live-read/src/main.rs", + "sha256": "c426c99d24ac50739566a3af04dfb21f94be842c2a70c755d0a741fdf0c6030f" + }, + { + "path": "src-tauri/crates/bridge-tally-live-read/src/native_outstandings_qualification.rs", + "sha256": "2f638c85044a2f4d5617e10b9ef396bf0183810fa868f758e2d08a6722d9b7db" + }, + { + "path": "src-tauri/crates/bridge-tally-observability/Cargo.toml", + "sha256": "21567544f89c83b6f291f9a483199c02c3a55f9fcb31e00beb27ae945032633f" + }, + { + "path": "src-tauri/crates/bridge-tally-observability/src/lib.rs", + "sha256": "6e861415d3872f9c1a62db386623077a798e689b7fd9ebf3d150d0ee514572e2" + }, + { + "path": "src-tauri/crates/bridge-tally-protocol/Cargo.toml", + "sha256": "9cf69356f36044a530018f1dc01bcc010847a9dfa42c8a260d253f078a282983" + }, + { + "path": "src-tauri/crates/bridge-tally-protocol/src/bills_native_outstandings_probe.rs", + "sha256": "ac237e8a042cb7105620b3503ff6ad46df543ab14b236ef58c5a246052bf0fb9" + }, + { + "path": "src-tauri/crates/bridge-tally-protocol/src/bills_payments_observation.rs", + "sha256": "070bae560982c3f9553c2cacfffe83a7debe0415688c43bb1b77befac0cec648" + }, + { + "path": "src-tauri/crates/bridge-tally-protocol/src/lib.rs", + "sha256": "79d1cd5e79b68eb07b3a409c45b5fc5d09d840781e242f960e4071454e16a811" + }, + { + "path": "src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs", + "sha256": "95b491c98c95c315bf073a4aa5686558b34428e88c7b6accb3913d48631cb859" + }, + { + "path": "src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs", + "sha256": "a6197ca60ccfa63af98aeb0a61d14472971d957482b5fce5c07e3f050a032b59" + }, + { + "path": "src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs", + "sha256": "62cb2bf28a63eff3dc70870d084262e8c762206cd917c786a17463a1b6b1e627" + }, + { + "path": "src-tauri/crates/bridge-tally-read-transport/Cargo.toml", + "sha256": "39ef352d8a1b8c71fd258ea70a9bc0f3656a9dbe381d2f00f98f64996a99e8dc" + }, + { + "path": "src-tauri/crates/bridge-tally-read-transport/src/lib.rs", + "sha256": "ef1d33e90da527faa9735469ea5040c9258fbab9e4c761f2473d79e7b3dbd0c4" + }, + { + "path": "src-tauri/crates/bridge-tally-runtime/Cargo.toml", + "sha256": "bf89ba14640d8bd05dffbdf79d719676e9aa881657c144efe0f105f34cabad87" + }, + { + "path": "src-tauri/crates/bridge-tally-runtime/src/lib.rs", + "sha256": "61624920f0386c827305d4a11d43269c6a9a2366265b402c61a7a72fd68c11f7" + }, + { + "path": "src-tauri/crates/bridge-tally-transport/Cargo.toml", + "sha256": "b6348f100abc27b6f074d9cebbf28ee9ace227b8591a4bd5dd29cda613c05ddd" + }, + { + "path": "src-tauri/crates/bridge-tally-transport/src/lib.rs", + "sha256": "0d6a1a66c158cbe3565f24a9d99e10d9020720fc6a80363a4949fd8fad9aefe3" + }, + { + "path": "src-tauri/crates/bridge-tally-transport/tests/http_transport.rs", + "sha256": "8fcca1f0a3629ef8893e3e067c7cbbc8654d4bfd5b573814ea956891b9358c03" + }, + { + "path": "src-tauri/crates/tally-protocol-simulator/Cargo.toml", + "sha256": "45e0bb723e924938811bd058d91ee8a34cb12ca8edc2ddca84ca3a500618f6f0" + }, + { + "path": "src-tauri/crates/tally-protocol-simulator/README.md", + "sha256": "e05ccf4ed654b2add4af4405e046972a5037623c17c4ad522238c5cd406532e9" + }, + { + "path": "src-tauri/crates/tally-protocol-simulator/fixtures/inconsistent_date_filter.xml", + "sha256": "2bb6598b08167f3c38f99e7e0fc2902bbc2012b210b2f4c1ba030eacac27068c" + }, + { + "path": "src-tauri/crates/tally-protocol-simulator/src/fixtures.rs", + "sha256": "28ccbb51a939e54d212647a2ec67af7d570e991c072eedd92d93988fff12d0a6" + }, + { + "path": "src-tauri/crates/tally-protocol-simulator/src/server.rs", + "sha256": "1a38498b3e8e3eeb7ed48333c28cca986442079f0fb2a85e8aa0d3f463072358" + }, + { + "path": "src-tauri/crates/tally-protocol-simulator/tests/protocol_simulator.rs", + "sha256": "194216b149d4e7e0351a2afb5124c41e40eea70f23cc97c525df01ae17baae95" + }, + { + "path": "src-tauri/src/commands.rs", + "sha256": "74449f1f5d1499013c76deef67b143609dd655280088c4625bd2e20a47b35266" + }, + { + "path": "src-tauri/src/db/encrypted.rs", + "sha256": "32a482e7126cfd8974fdde825526b9351f44bc2bcbd49e4b3193e311973c9cfe" + }, + { + "path": "src-tauri/src/db/migrations/0007_tally_selected_read_evidence.sql", + "sha256": "619fcdc8931f21fdfb6df8eaa1b1c30a5251f9f2f3032d948a894a23e8365e8b" + }, + { + "path": "src-tauri/src/db/migrations/0008_tally_reviewed_setup_consumption.sql", + "sha256": "4f2156f242031ad3d891b9e2cd5166fb2f22dcd88f57f4691efcfeeb5611e9a4" + }, + { + "path": "src-tauri/src/db/migrations/0009_tally_snapshot_window_staging.sql", + "sha256": "a1c6dc89699daf6bd18c23902a242faa7af2306fdc2e9df8e8b43aa4dd0fb02d" + }, + { + "path": "src-tauri/src/db/migrations/0010_tally_provenance_unavailable_counts.sql", + "sha256": "3ca9d39a4f9605726c8041d1a4450403785433921ce5785cf21ee84e6b77ab3f" + }, + { + "path": "src-tauri/src/db/migrations/0011_tally_proof_record_counts_digest.sql", + "sha256": "d79ad3fea0789424d3ecad5327fff26b7926e9671cb2aa566f1f21714f1374a1" + }, + { + "path": "src-tauri/src/db/migrations/0012_tally_window_terminal_evidence.sql", + "sha256": "3649b141a5d5af81a00a8561bac3bae2ccaedb3b3c4a91cf187c799c49794753" + }, + { + "path": "src-tauri/src/db/tally_incremental.rs", + "sha256": "bac2c859de102cf1e558f669dee0445a9406e95ee9923b6f52bd498ee768e73b" + }, + { + "path": "src-tauri/src/db/tally_mirror.rs", + "sha256": "6e5a75066b3521397209ec525af759b76c92bc5d819a738d22588b42006fd956" + }, + { + "path": "src-tauri/src/db/tally_write_store.rs", + "sha256": "628a33d2cb481fcb73393b26be51a6b4239f1c9c1eddab8a858dd5111851b91d" + }, + { + "path": "src-tauri/src/lib.rs", + "sha256": "7781a41ce27adf7427665dbafcded69ed15451ee38ff457374d7004271a3f2cb" + }, + { + "path": "src-tauri/src/sync/coordinator.rs", + "sha256": "eab6c3761f18a36fb333d7c52752da1ea577919806ba68b353359642b8c0d433" + }, + { + "path": "src-tauri/src/sync/reconciliation.rs", + "sha256": "0b6cadc911d86b82b5060c3e2096e000698ab31729e366eb4911ca8adccf22e9" + }, + { + "path": "src-tauri/src/sync/snapshot.rs", + "sha256": "f06f85038c04949607d1b40f937efe28387e948ab23fbe51d5f85183ff9baa0e" + }, + { + "path": "src-tauri/src/tally/capability_packs.rs", + "sha256": "92ba5d666a677ff164f95f140937705c238b967a9c8214f7e7101ed8ac2ebc7f" + }, + { + "path": "src-tauri/src/tally/connection.rs", + "sha256": "c5d297323b3cded360429bf6ac265eb87631c0d019d7f30e786bb7f5076459ce" + }, + { + "path": "src-tauri/src/tally/connector.rs", + "sha256": "3c5ff3eb9591240e6af4981bf9a1bf92ceeb60aeb426612ec3762be2a4f06815" + }, + { + "path": "src-tauri/src/tally/mod.rs", + "sha256": "9659ca8e4535444bc65e08d40ba570dd13b5ea280daf2e13cb4faccda62031ef" + }, + { + "path": "src-tauri/src/tally/runtime.rs", + "sha256": "8547d9886478337b15e034e732a222243588bee06ff862ad1fdc698335c8a629" + }, + { + "path": "src-tauri/src/tally/serial_queue.rs", + "sha256": "cf0e9c3a1da9510de6cb756455a2f0150e0d4d9ae6750489839a079d95fbdd98" + }, + { + "path": "src-tauri/src/tally/tdl_engine.rs", + "sha256": "59e5c6c853b61ec22f5fd4d97cb9ef87e6c67a91807bb488fe3c21d281918fe1" + }, + { + "path": "src-tauri/src/tally/validators.rs", + "sha256": "40976622598bdbfe2567f3fd70adee9ff78f5cd9c7fbc43e1fbac0640ef1523f" + }, + { + "path": "src-tauri/src/tally/xml_parser.rs", + "sha256": "654938f3207822cd9889b8d81cd53684ca43c856b727fbabc09aadde6f9e82eb" + }, + { + "path": "src/main.tsx", + "sha256": "1d8a6d3235aa3fd3997650f6d234189feafa7338a4c6ccc41af9eb25e2dab5e4" + }, + { + "path": "src/styles.css", + "sha256": "2a1123727cd527e54190236719787dbdc1420c5099712011290eab9e40590ccd" + }, + { + "path": "src/tally-company-selection.ts", + "sha256": "5a5c6eaaba234c3cbda52dfa040ed3314f79e535f744b87bc14d8d76a2299811" + } + ], + "manifest_sha256": "3af3def7be6ebf2c34c081fd7c8b886db7c65d6bee6b4de219fb6192a989f212" +} diff --git a/docs/tally/compatibility/evidence/README.md b/docs/tally/compatibility/evidence/README.md new file mode 100644 index 0000000..29af669 --- /dev/null +++ b/docs/tally/compatibility/evidence/README.md @@ -0,0 +1,9 @@ +# Reviewed live evidence + +This directory accepts only paired `*.receipt.json` and +`*.attestation.json` files reviewed through a pull request. Never add raw Tally +requests or responses, company names or GUIDs, GSTIN/PAN values, amounts, +narrations, endpoint details, ports, paths, usernames, headers, or raw errors. + +Repository-synthetic parser qualification receipts belong elsewhere and are +not valid live compatibility evidence. diff --git a/docs/tally/compatibility/fixtures/education-native-outstandings-v0.json b/docs/tally/compatibility/fixtures/education-native-outstandings-v0.json new file mode 100644 index 0000000..f24b7f6 --- /dev/null +++ b/docs/tally/compatibility/fixtures/education-native-outstandings-v0.json @@ -0,0 +1,192 @@ +{ + "schema_version": 1, + "fixture_id": "education-native-outstandings-v0", + "dataset_tier": "synthetic_small", + "candidate": { + "profile_id": "native_ledger_outstandings_candidate_v0", + "template_sha256": "bc3b87484adb9a10cc15f6c9042853bb1047278896bcf0f495b93e7e6b428526", + "observation_posture": "profile_unobserved", + "request_shape_immutable": true + }, + "synthetic_scope": { + "company_marker": "BRIDGE-PR18-NATIVE-OUTSTANDINGS-COMPANY-019f605f-e6cf-77b2-ac95-31722887a911", + "party_marker": "BRIDGE-PR18-NATIVE-OUTSTANDINGS-PARTY-019f605f-e6cf-77b2-ac95-31722887a911", + "currency": "INR", + "bill_by_bill_tracking_required": true, + "customer_or_personal_data_forbidden": true, + "expected_company_identity_commitment_source": "separate_reviewed_local_registration", + "expected_party_identity_commitment_source": "separate_reviewed_local_registration" + }, + "education_constraints": { + "voucher_dates_are_literal_calendar_days": [ + 1, + 2, + 31 + ], + "connected_services_forbidden": true, + "runner_is_read_only": true, + "fixture_creation_is_outside_runner_authority": true + }, + "fixture_facts": [ + { + "event_id": "opening-open-001", + "kind": "ledger_opening_bill", + "date": null, + "reference": "OPEN-001", + "allocation_type": "opening_bill", + "amount_text": "250.00", + "accounting_polarity": "debit" + }, + { + "event_id": "sale-inv-001", + "kind": "sales_voucher", + "date": "20260701", + "reference": "INV-001", + "allocation_type": "new_ref", + "amount_text": "1000.00", + "accounting_polarity": "debit", + "due_date": "20260731" + }, + { + "event_id": "sale-on-account-001", + "kind": "sales_voucher", + "date": "20260701", + "reference": null, + "allocation_type": "on_account", + "amount_text": "125.00", + "accounting_polarity": "debit" + }, + { + "event_id": "receipt-partial-inv-001", + "kind": "receipt_voucher", + "date": "20260702", + "reference": "INV-001", + "allocation_type": "agst_ref", + "amount_text": "400.00", + "accounting_polarity": "credit" + }, + { + "event_id": "receipt-advance-001", + "kind": "receipt_voucher", + "date": "20260702", + "reference": "ADV-001", + "allocation_type": "advance", + "amount_text": "50.00", + "accounting_polarity": "credit" + }, + { + "event_id": "receipt-final-inv-001", + "kind": "receipt_voucher", + "date": "20260731", + "reference": "INV-001", + "allocation_type": "agst_ref", + "amount_text": "600.00", + "accounting_polarity": "credit" + } + ], + "scenarios": [ + { + "scenario_id": "education-to-date-20260701", + "to_date": "20260701", + "request_sha256": "b169cf8022d6ed6e0f2425d966c289e36953f7c1a60bdd861d02da4bd0da8467", + "scope_sha256": "cd5b3291d7a34300e57a4b3f7f941e01f7b727d74d1356d76d6215d54a83088f", + "expected_accounting_facts": [ + "OPEN-001 remains INR 250.00 debit", + "INV-001 remains INR 1000.00 debit and is due 20260731", + "INR 125.00 debit remains On Account" + ], + "inv_001_ui_presence_requirement": "must_be_observed_present" + }, + { + "scenario_id": "education-to-date-20260702", + "to_date": "20260702", + "request_sha256": "52a9e96dc800140ec6ec264fe3ea635ecf6c4c85a6af767c4cfc37d66a1bc026", + "scope_sha256": "37f060d8834aae8c753f3901898108b4969a3a4dae646eb017df1a5f3cbd8823", + "expected_accounting_facts": [ + "OPEN-001 remains INR 250.00 debit", + "INV-001 remains INR 600.00 debit and is due 20260731", + "INR 125.00 debit remains On Account", + "ADV-001 remains INR 50.00 credit" + ], + "inv_001_ui_presence_requirement": "must_be_observed_present" + }, + { + "scenario_id": "education-to-date-20260731", + "to_date": "20260731", + "request_sha256": "45447855b2dfb98fba648d8813ae6a127e279926127587b664faf04f8e8cf321", + "scope_sha256": "744371330ab70936d47264f126630ae6508393fd20a7d25083904f8ed4883c30", + "expected_accounting_facts": [ + "OPEN-001 remains INR 250.00 debit", + "INV-001 has zero accounting pending amount after its final settlement", + "INR 125.00 debit remains On Account", + "ADV-001 remains INR 50.00 credit" + ], + "inv_001_ui_presence_requirement": "must_record_observed_present_or_omitted" + } + ], + "one_scenario_per_invocation": true, + "request_budget": { + "preflight_posts": 2, + "dispatch_identity_posts": 8, + "candidate_dispatch_posts": 3, + "dispatch_posts": 11, + "maximum_total_posts": 13, + "automatic_retries": 0, + "preflight_order": [ + "company_list_v1", + "ledgers_v1" + ], + "dispatch_order": [ + "b0_company_list_v1", + "b0_ledgers_v1", + "candidate_1", + "b1_company_list_v1", + "b1_ledgers_v1", + "candidate_2", + "b2_company_list_v1", + "b2_ledgers_v1", + "candidate_3", + "b3_company_list_v1", + "b3_ledgers_v1" + ], + "dispatch_request": "four_identity_brackets_and_three_byte_identical_native_ledger_outstandings_candidate_v0_requests" + }, + "ui_observation_contract": { + "required_phases": [ + "before", + "after" + ], + "report_name": "Ledger Outstandings", + "report_mode": "detailed", + "visible_columns_required": [ + "opening", + "pending", + "due", + "overdue" + ], + "ordered_projection_fields": [ + "row_index", + "row_kind", + "display_date_text", + "reference_text", + "opening_amount_text", + "pending_amount_text", + "due_date_text", + "overdue_text", + "voucher_details_text", + "dr_cr_text" + ], + "screenshot_sha256_required": true, + "structured_projection_required": true, + "before_after_projection_must_match": true, + "july31_inv_001_presence_or_omission_required": true, + "operator_observation_is_not_machine_attestation": true + }, + "authority": { + "fixture_facts_are_accounting_expectations_not_xml_semantics": true, + "support_claim_authority": "none", + "canonical_authority": "none", + "zero_or_empty_semantics_authority": "none", + "source_identity_stability_authority": "none" + } +} diff --git a/docs/tally/compatibility/fixtures/education-small-v1.json b/docs/tally/compatibility/fixtures/education-small-v1.json new file mode 100644 index 0000000..9dbf629 --- /dev/null +++ b/docs/tally/compatibility/fixtures/education-small-v1.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "fixture_id": "education-small-v1", + "dataset_tier": "synthetic_small", + "company_marker": "BRIDGE-PR14-SYNTHETIC-019f605f-e6cf-77b2-ac95-31722887a911", + "ledger_sentinel": "BRIDGE-LEDGER-019f605f-e6cf-77b2-ac95-31722887a911", + "voucher_number_sentinel": "BRIDGE-VOUCHER-019f605f-e6cf-77b2-ac95-31722887a911", + "empty_voucher_range": { + "from_yyyymmdd": "20260403", + "to_yyyymmdd": "20260403" + }, + "populated_voucher_range": { + "from_yyyymmdd": "20260401", + "to_yyyymmdd": "20260402" + }, + "minimum_ledger_count": 1, + "maximum_ledger_count": 100, + "minimum_populated_voucher_count": 1, + "maximum_populated_voucher_count": 20 +} diff --git a/docs/tally/compatibility/live-education-runbook.md b/docs/tally/compatibility/live-education-runbook.md new file mode 100644 index 0000000..c77e977 --- /dev/null +++ b/docs/tally/compatibility/live-education-runbook.md @@ -0,0 +1,86 @@ +# Read-only Tally Education qualification + +This runbook observes one legitimate local Tally Education profile. It does not +bypass licensing, change system time, send an import, or prove that the local +responder is authentic Tally. Tally must be running with its HTTP server +configured on the selected loopback port. Official prerequisites describe a +loaded company, HTTP POST, port configuration, and supported encodings: +. + +## Prepare a disposable fixture + +Use no customer or personal data. In Tally Education, create or load a +disposable company whose name exactly matches the reviewed `company_marker` in +[`fixtures/education-small-v1.json`](./fixtures/education-small-v1.json). Add at +least one synthetic ledger and at least one synthetic voucher dated 1 or 2 +April 2026. Exactly one ledger must use the reviewed `ledger_sentinel`, and +exactly one voucher must use the reviewed `voucher_number_sentinel`. Keep all +counts within the reviewed minimum and maximum bounds and keep 3 April 2026 +empty. The 1st and 2nd are documented Education +voucher dates; the controller performs reads only and adds no workaround. + +From Tally's About page, read the exact Application and Release. Confirm the +visible operating mode, ODBC state, and locale. Do not infer the release from +XML `` or `/status` text. + +## Create the ignored local profile + +From the repository root: + +```powershell +New-Item -ItemType Directory -Force .bridge-live +Copy-Item docs/tally/compatibility/live-profile.example.json .bridge-live/profile.json +``` + +Edit `.bridge-live/profile.json` and replace every `unknown` value only after +the exact setting was directly observed. Product, release, mode, ODBC state, +and locale must all be exact: an unknown, wildcard, or placeholder value stops +the controller before consent and before any network request. Keep the config +and JSON output as direct children of the repository's canonical +`.bridge-live/` directory; the whole directory is ignored. + +Set `no_customer_data_attested` to `true` only after personally confirming the +loaded books contain no customer, personal, or production data. The receipt +records this as your attestation; Bridge does not infer it from the fixture +filename or company marker. A false attestation stops before consent and before +any network request. + +## Run + +From `src-tauri`: + +```powershell +cargo run --locked -p bridge-tally-live-read -- run ` + ../.bridge-live/profile.json ../.bridge-live/receipt.json ` + --consent read-only-synthetic +``` + +The controller first prints a single-use, run-bound `QUALIFY ...` challenge. +The challenge uses fresh operating-system randomness, expires after five +minutes, and is bound to the full config and endpoint, reviewed fixture, +current source surface, commit/dirty state, executable, and Cargo.lock. Network +reads start only after the exact text is entered, and the source surface is +revalidated immediately before dispatch. It reads loaded-company metadata, +requires one unique GUID-bearing match for the reviewed fixture marker, and +then reads ledgers, an expected-empty voucher range, and an expected-populated +voucher range. The fixture contract is verified only when the unique ledger and +voucher sentinels, count bounds, dates, GUID, and company context all pass. A +marker, sentinel, GUID, company-context, parser, application-status, range, +timeout, or transport failure stops every later read. + +The controller next prints the exact privacy-reduced receipt and a +receipt-and-output-bound `SAVE ...` challenge. The controller consumes a +repository-issued output target, revalidates its canonical parent at save time, +and saves atomically only after that exact text is entered. An existing file is +never replaced. Raw +responses, names, GUIDs, GSTIN/PAN values, amounts, narrations, endpoint/port, +paths, headers, and raw errors are never written into the receipt. + +## Review authority + +A local receipt is observation evidence only. It records that no writes were +attempted and does not establish responder authenticity, accounting +correctness, source completeness/atomicity, Tauri runtime behavior, performance +support, or a support claim. Promotion requires a pull-request review, a +trusted non-revoked Ed25519 attestation, an exact matching matrix cell, and the +release gate. diff --git a/docs/tally/compatibility/live-profile.example.json b/docs/tally/compatibility/live-profile.example.json new file mode 100644 index 0000000..9d83282 --- /dev/null +++ b/docs/tally/compatibility/live-profile.example.json @@ -0,0 +1,13 @@ +{ + "schema_version": 1, + "repository_root": "..", + "fixture_manifest": "../docs/tally/compatibility/fixtures/education-small-v1.json", + "endpoint_family": "ipv4", + "port": 9000, + "product": "unknown", + "release": "unknown", + "mode": "unknown", + "odbc_state": "unknown", + "locale": "unknown", + "no_customer_data_attested": false +} diff --git a/docs/tally/compatibility/native-outstandings-profile.example.json b/docs/tally/compatibility/native-outstandings-profile.example.json new file mode 100644 index 0000000..f12139b --- /dev/null +++ b/docs/tally/compatibility/native-outstandings-profile.example.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "repository_root": "..", + "fixture_manifest": "../docs/tally/compatibility/fixtures/education-native-outstandings-v0.json", + "scenario_id": "education-to-date-20260731", + "endpoint_family": "ipv4", + "port": 9000, + "product": "unknown", + "release": "unknown", + "mode": "unknown", + "locale": "unknown", + "configured_tdl_count": null, + "configured_add_on_count": null, + "expected_company_identity_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "expected_party_identity_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "identity_registration_id": "unregistered", + "identity_registration_reviewed": false, + "no_customer_data_attested": false, + "ui_before_observation": "../.bridge-live/native-outstandings-ui-before.json", + "ui_after_observation": "../.bridge-live/native-outstandings-ui-after.json" +} diff --git a/docs/tally/compatibility/native-outstandings-ui-after.example.json b/docs/tally/compatibility/native-outstandings-ui-after.example.json new file mode 100644 index 0000000..eea0fca --- /dev/null +++ b/docs/tally/compatibility/native-outstandings-ui-after.example.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "fixture_id": "education-native-outstandings-v0", + "scenario_id": "education-to-date-20260731", + "phase": "after", + "evidence_complete": false, + "captured_unix_ms": 0, + "screenshot_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "context": { + "company_marker": "BRIDGE-PR18-NATIVE-OUTSTANDINGS-COMPANY-019f605f-e6cf-77b2-ac95-31722887a911", + "party_marker": "BRIDGE-PR18-NATIVE-OUTSTANDINGS-PARTY-019f605f-e6cf-77b2-ac95-31722887a911", + "to_date": "20260731", + "report_name": "Ledger Outstandings", + "report_mode": "detailed" + }, + "visible_columns": { + "opening": false, + "pending": false, + "due": false, + "overdue": false + }, + "ordered_projection_fields": [ + "row_index", + "row_kind", + "display_date_text", + "reference_text", + "opening_amount_text", + "pending_amount_text", + "due_date_text", + "overdue_text", + "voucher_details_text", + "dr_cr_text" + ], + "ordered_projection": [], + "inv_001_settled_reference_observation": "unobserved", + "operator_attests_no_tally_interaction_since_before_capture": false +} diff --git a/docs/tally/compatibility/native-outstandings-ui-before.example.json b/docs/tally/compatibility/native-outstandings-ui-before.example.json new file mode 100644 index 0000000..c1f360e --- /dev/null +++ b/docs/tally/compatibility/native-outstandings-ui-before.example.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "fixture_id": "education-native-outstandings-v0", + "scenario_id": "education-to-date-20260731", + "phase": "before", + "evidence_complete": false, + "captured_unix_ms": 0, + "screenshot_sha256": "0000000000000000000000000000000000000000000000000000000000000000", + "context": { + "company_marker": "BRIDGE-PR18-NATIVE-OUTSTANDINGS-COMPANY-019f605f-e6cf-77b2-ac95-31722887a911", + "party_marker": "BRIDGE-PR18-NATIVE-OUTSTANDINGS-PARTY-019f605f-e6cf-77b2-ac95-31722887a911", + "to_date": "20260731", + "report_name": "Ledger Outstandings", + "report_mode": "detailed" + }, + "visible_columns": { + "opening": false, + "pending": false, + "due": false, + "overdue": false + }, + "ordered_projection_fields": [ + "row_index", + "row_kind", + "display_date_text", + "reference_text", + "opening_amount_text", + "pending_amount_text", + "due_date_text", + "overdue_text", + "voucher_details_text", + "dr_cr_text" + ], + "ordered_projection": [], + "inv_001_settled_reference_observation": "unobserved", + "operator_attests_no_tally_interaction_until_after_capture": false +} diff --git a/docs/tally/compatibility/trusted-evidence-keys.json b/docs/tally/compatibility/trusted-evidence-keys.json new file mode 100644 index 0000000..c6e1b77 --- /dev/null +++ b/docs/tally/compatibility/trusted-evidence-keys.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "keys": [] +} diff --git a/docs/tally/privacy-model.md b/docs/tally/privacy-model.md new file mode 100644 index 0000000..65a129f --- /dev/null +++ b/docs/tally/privacy-model.md @@ -0,0 +1,47 @@ +# Tally privacy model + +Tally book data is sensitive by default. Bridge minimizes collection and keeps +the data plane local unless the operator explicitly configures a reviewed +destination contract. + +## Data classes + +| Class | Examples | Public logs/support bundles | Encrypted local mirror | +| --- | --- | --- | --- | +| Secrets | credentials, tokens, PINs, private keys | Never | Never as ordinary mirror records | +| Book data | company names, GSTINs, ledgers, vouchers, amounts, inventory | Never | Only when required for an enabled pack | +| Sensitive metadata | certificate metadata, local paths, usernames, raw Tally errors | Never | Only if an explicit feature requires it; otherwise discard | +| Safe operational evidence | generated IDs, counts, timings, hashes, allow-listed reason codes | Allowed after review | Allowed | +| Synthetic fixtures | obviously fictional companies and records | Allowed | Allowed | + +## Storage boundaries + +- The Tally mirror is an application-data-relative SQLCipher database. +- Its key is generated independently and stored through the operating-system + credential facility; it is not derived from a username or checkout path. +- Initialization is locked, the key is applied before schema access, and an + integrity check is required before use. +- Raw response bodies are not normal diagnostic output. Canonical records are + retained only for enabled packs and are associated with source identity, + content hashes, and proof state. +- A failed, partial, cancelled, or ambiguous run does not replace the last + verified checkpoint. + +## Network boundaries + +- Tally HTTP is restricted to canonical loopback addresses. Redirects are + disabled so a local response cannot redirect Bridge to another host. +- The local endpoint is capability-verified, not cryptographically + authenticated. Another local process may impersonate the configured port. +- Delivery to AXAL or another destination requires an explicit, versioned + adapter contract. The repository does not invent or guess remote endpoints. + +## Diagnostics and deletion + +Diagnostics should contain safe reason codes, phase, elapsed time, counts, +generated request/run IDs, and hashes. They must exclude raw XML/JSON, company +or record names, tax identifiers, amounts, local paths, and credentials. + +Operators must be able to delete the mirror and associated OS credential as a +single documented reset operation. Until that workflow is implemented and +tested, no UI should claim that local Tally data has been fully erased. diff --git a/docs/tally/support-matrix.md b/docs/tally/support-matrix.md new file mode 100644 index 0000000..925e4e1 --- /dev/null +++ b/docs/tally/support-matrix.md @@ -0,0 +1,15 @@ + +| Exact cell | Product / release / mode | Host | Transport / loopback / ODBC | Data profile | Claim | Promotion eligible | Evidence | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `erp9-6-6-3-windows-education-xml-one-company` | `tally_erp9` / `6.6.3` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-6-2-windows-education-xml-one-company` | `tally_prime` / `6.2` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-0-windows-education-xml-one-company` | `tally_prime` / `7.0` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-1-windows-education-jsonex-one-company` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `json_ex_shadow` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `false` | `missing` | +| `prime-7-1-windows-education-xml-large` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_large` | `unknown` | `false` | `missing` | +| `prime-7-1-windows-education-xml-multiple-companies` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `multiple` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-1-windows-education-xml-no-company` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `none` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `false` | `missing` | +| `prime-7-1-windows-education-xml-odbc-enabled` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `enabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-1-windows-education-xml-one-company` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | +| `prime-7-1-windows-education-xml-other-locale-utf16le` | `tally_prime` / `7.1` / `education` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `other` / `utf16_le` / `synthetic_small` | `unknown` | `false` | `missing` | +| `prime-7-1-windows-licensed-xml-one-company` | `tally_prime` / `7.1` / `licensed` | `windows` / `x86_64` | `xml_http` / `ipv4` / `disabled` | `one` / `english_india` / `utf8` / `synthetic_small` | `unknown` | `true` | `missing` | + diff --git a/package.json b/package.json index f1ddfab..c370fb1 100644 --- a/package.json +++ b/package.json @@ -13,21 +13,23 @@ "url": "https://github.com/lamemustafa/bridge/issues" }, "homepage": "https://github.com/lamemustafa/bridge#readme", - "packageManager": "pnpm@9.15.4", + "packageManager": "pnpm@11.7.0", "engines": { "node": ">=22.12 <25" }, "scripts": { "dev": "vite --host 127.0.0.1", - "build": "tsc && vite build", + "build": "node --experimental-strip-types --test scripts/tally-company-selection.test.mjs && tsc && vite build", "check": "pnpm run build && pnpm run cargo:check", "tauri": "tauri", "tauri:dev": "tauri dev", "tauri:build": "tauri build", - "cargo:check": "cargo check --locked --manifest-path src-tauri/Cargo.toml", - "cargo:clippy": "cargo clippy --locked --manifest-path src-tauri/Cargo.toml --all-targets -- -D warnings", + "cargo:check": "cargo check --locked --manifest-path src-tauri/Cargo.toml --workspace", + "cargo:clippy": "cargo clippy --locked --manifest-path src-tauri/Cargo.toml --workspace --all-targets -- -D warnings", "cargo:fmt": "cargo fmt --manifest-path src-tauri/Cargo.toml --all -- --check", - "cargo:test": "cargo test --locked --manifest-path src-tauri/Cargo.toml", + "cargo:test": "cargo test --locked --manifest-path src-tauri/Cargo.toml --workspace", + "test:tally-company-selection": "node --experimental-strip-types --test scripts/tally-company-selection.test.mjs", + "tally:live-read-boundary": "node scripts/check-tally-live-read-boundary.mjs", "security:audit:frontend": "corepack pnpm audit --prod --audit-level high", "license:check": "node scripts/check-license-metadata.mjs && node scripts/check-dependency-inventory.mjs --frontend", "license:rust": "node scripts/check-dependency-inventory.mjs --rust", diff --git a/scripts/check-dependency-inventory.mjs b/scripts/check-dependency-inventory.mjs index 16e0371..ccd9bf7 100644 --- a/scripts/check-dependency-inventory.mjs +++ b/scripts/check-dependency-inventory.mjs @@ -8,6 +8,22 @@ const root = fileURLToPath(new URL("../", import.meta.url)); const modes = new Set(process.argv.slice(2)); const checkFrontend = modes.size === 0 || modes.has("--frontend"); const checkRust = modes.size === 0 || modes.has("--rust"); +const firstPartyRustPackages = new Set([ + "bridge", + "bridge-tally-canonical", + "bridge-tally-compatibility", + "bridge-tally-core", + "bridge-tally-incremental", + "bridge-tally-live-read", + "bridge-tally-observability", + "bridge-tally-protocol", + "bridge-tally-qualification", + "bridge-tally-read-transport", + "bridge-tally-runtime", + "bridge-tally-transport", + "bridge-tally-write", + "tally-protocol-simulator", +]); if ([...modes].some((mode) => !["--frontend", "--rust"].includes(mode))) { throw new Error("Usage: check-dependency-inventory.mjs [--frontend] [--rust]"); @@ -114,7 +130,7 @@ if (checkRust) { ); for (const line of tree.split(/\r?\n/)) { const match = line.match(/^([A-Za-z0-9_.+-]+) v(\d+\.\d+\.\d+(?:[+-][^\s]+)?)/); - if (match && match[1] !== "bridge") { + if (match && !firstPartyRustPackages.has(match[1])) { expected.add(`${match[1]} ${match[2]}`); } } diff --git a/scripts/check-license-metadata.mjs b/scripts/check-license-metadata.mjs index 6f50327..b320980 100644 --- a/scripts/check-license-metadata.mjs +++ b/scripts/check-license-metadata.mjs @@ -47,6 +47,13 @@ if (!license.includes("Apache License") || !license.includes("Grant of Patent Li if (!notice.includes("Rust PKCS#11 Library") || !notice.includes("OASIS IPR Policy")) { failures.push("required PKCS#11 NOTICE attribution"); } +if ( + !notice.includes("SQLCipher") || + !notice.includes("Copyright (c) 2008-2020 Zetetic LLC") || + !notice.includes("LOSS OF USE, DATA, OR PROFITS") +) { + failures.push("complete bundled SQLCipher NOTICE attribution"); +} if (!readme.includes("Apache License, Version 2.0")) failures.push("README license declaration"); if (!frontendNotices.includes("lucide-react") || !frontendNotices.includes("react 19.2.7")) { failures.push("frontend third-party notices"); diff --git a/scripts/check-tally-live-read-boundary.mjs b/scripts/check-tally-live-read-boundary.mjs new file mode 100644 index 0000000..6bf7f67 --- /dev/null +++ b/scripts/check-tally-live-read-boundary.mjs @@ -0,0 +1,220 @@ +// SPDX-License-Identifier: Apache-2.0 + +import { spawnSync } from "node:child_process"; +import { readFileSync, readdirSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../", import.meta.url)); +const result = spawnSync( + "cargo", + [ + "tree", + "--locked", + "--manifest-path", + "src-tauri/Cargo.toml", + "-p", + "bridge-tally-live-read", + "--edges", + "normal", + "--prefix", + "none", + "--format", + "{p}", + ], + { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true }, +); +if (result.error || result.status !== 0) { + throw new Error("live-read dependency tree failed"); +} + +const packages = new Set(); +for (const line of result.stdout.split(/\r?\n/)) { + const match = line.match(/^([A-Za-z0-9_.+-]+) v\d/); + if (match) packages.add(match[1]); +} +const forbiddenNames = [ + "bridge", + "bridge-tally-canonical", + "bridge-tally-core", + "bridge-tally-incremental", + "bridge-tally-observability", + "bridge-tally-qualification", + "bridge-tally-write", + "libsqlite3-sys", + "rusqlite", + "sqlx", + "tauri", + "tauri-runtime", +]; +const forbidden = forbiddenNames.filter((name) => packages.has(name)); +if (forbidden.length) { + throw new Error(`live-read boundary reached forbidden packages: ${forbidden.join(", ")}`); +} + +const firstParty = [...packages].filter((name) => name.startsWith("bridge-tally-")).sort(); +const expected = [ + "bridge-tally-compatibility", + "bridge-tally-live-read", + "bridge-tally-protocol", + "bridge-tally-read-transport", + "bridge-tally-transport", +]; +if (JSON.stringify(firstParty) !== JSON.stringify(expected)) { + throw new Error(`live-read first-party boundary changed: ${firstParty.join(", ")}`); +} + +const manifest = readFileSync(new URL("../src-tauri/crates/bridge-tally-live-read/Cargo.toml", import.meta.url), "utf8"); +const liveReadRoot = fileURLToPath(new URL("../src-tauri/crates/bridge-tally-live-read", import.meta.url)).replaceAll("\\", "/"); +for (const forbiddenManifestText of [ + "bridge-tally-transport", + "bridge-tally-write", + "path = \"../..\"", +]) { + if (manifest.includes(forbiddenManifestText)) { + throw new Error(`live-read manifest exposes forbidden dependency: ${forbiddenManifestText}`); + } +} +for (const path of walkFiles(liveReadRoot)) { + if (!path.endsWith(".rs")) continue; + const source = readFileSync(path, "utf8"); + for (const forbiddenSourceText of ["post_xml", " { + if (state.selectedReadScope) { + state.passport = null; + state.profileSha256 = null; + state.reviewId = null; + state.reviewCommitmentSha256 = null; + state.selectedReadScope = null; + } + }, + clearPassportSnapshot: () => { state.passportSnapshotId = null; }, + clearSensitiveDiagnostics: () => { + state.diagnostics = []; + state.diagnosticsRequestVersion += 1; + }, + clearSyncEvidence: () => { + state.syncEvidence = null; + state.syncEvidenceError = null; + }, + clearProofPreview: () => { + state.proofPreview = null; + state.proofPreviewSelection = null; + state.proofPreviewRequestVersion += 1; + }, + clearMirrorExplorer: () => { + state.mirrorExplorer = null; + state.mirrorExplorerError = null; + }, + clearSnapshotState: () => { + state.snapshotJob = null; + state.snapshotError = null; + state.snapshotStartOutcomeUnknown = false; + state.snapshotSelectionVersion += 1; + }, + invalidateTallyResults: () => { state.tallyResultsVersion += 1; }, + }; +} + +test("an automatic probe drop clears old company state before installing a usable fresh review", () => { + const state = companyScopedState(); + const freshProbe = { + passport: { profile_version: 2, company: "new-company" }, + profileSha256: "profile-new", + reviewId: "review-new", + reviewCommitmentSha256: "commitment-new", + selectedReadScope: null, + passportSnapshotId: null, + }; + + const transition = applyProbeCompanySelectionTransition( + "old-company", + ["new-company"], + { + clearDroppedCompanyScope: () => clearCompanyScopedState(cleanupFor(state)), + installProbeState: () => Object.assign(state, freshProbe), + }, + ); + assert.deepEqual(transition, { selectedCompany: "", dropped: true }); + assert.deepEqual(state, { + ...freshProbe, + selectedReadScope: null, + passportSnapshotId: null, + diagnostics: [], + syncEvidence: null, + syncEvidenceError: null, + proofPreview: null, + proofPreviewSelection: null, + mirrorExplorer: null, + mirrorExplorerError: null, + snapshotJob: null, + snapshotError: null, + snapshotStartOutcomeUnknown: false, + diagnosticsRequestVersion: 5, + proofPreviewRequestVersion: 8, + snapshotSelectionVersion: 12, + tallyResultsVersion: 14, + }); + assert.equal( + Boolean(state.passport && state.reviewId && state.reviewCommitmentSha256), + true, + "the fresh probe review remains available after selecting a returned company", + ); +}); + +test("a manual company selection clears the existing review and all company-scoped state", () => { + const state = companyScopedState(); + + clearCompanyScopedState(cleanupFor(state)); + + assert.equal(state.passport, null); + assert.equal(state.profileSha256, null); + assert.equal(state.reviewId, null); + assert.equal(state.reviewCommitmentSha256, null); + assert.equal(state.selectedReadScope, null); + assert.equal(state.passportSnapshotId, null); + assert.deepEqual(state.diagnostics, []); + assert.equal(state.syncEvidence, null); + assert.equal(state.proofPreview, null); + assert.equal(state.mirrorExplorer, null); + assert.equal(state.snapshotJob, null); + assert.equal(state.snapshotStartOutcomeUnknown, false); + assert.equal(state.diagnosticsRequestVersion, 5); + assert.equal(state.proofPreviewRequestVersion, 8); + assert.equal(state.snapshotSelectionVersion, 12); + assert.equal(state.tallyResultsVersion, 14); +}); + +test("a selected company retained by the probe is not reported as dropped", () => { + assert.deepEqual( + reconcileProbeCompanySelection("retained-company", ["other-company", "retained-company"]), + { selectedCompany: "retained-company", dropped: false }, + ); + assert.deepEqual( + reconcileProbeCompanySelection("", ["new-company"]), + { selectedCompany: "", dropped: false }, + ); +}); diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 15b88a9..f2243fc 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,17 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -53,6 +64,17 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[package]] +name = "apple-native-keyring-store" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "797f94b6a53d7d10b56dc18290e0d40a2158352f108bb4ff32350825081a9f29" +dependencies = [ + "keyring-core", + "log", + "security-framework", +] + [[package]] name = "asn1-rs" version = "0.6.2" @@ -92,6 +114,137 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "atk" version = "0.18.2" @@ -148,6 +301,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + [[package]] name = "bit-set" version = "0.8.0" @@ -196,6 +355,15 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -205,14 +373,38 @@ dependencies = [ "objc2", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bridge" version = "0.2.0" dependencies = [ "anyhow", + "async-trait", + "bridge-tally-canonical", + "bridge-tally-core", + "bridge-tally-incremental", + "bridge-tally-protocol", + "bridge-tally-runtime", + "bridge-tally-transport", + "bridge-tally-write", "chrono", "cryptoki", + "getrandom 0.3.4", + "keyring", "libc", + "libsqlite3-sys", "pkcs11", "quick-xml", "reqwest 0.12.28", @@ -221,6 +413,7 @@ dependencies = [ "serde_json", "sha2 0.11.0", "sqlx", + "tally-protocol-simulator", "tauri", "tauri-build", "tempfile", @@ -235,6 +428,147 @@ dependencies = [ "zeroize", ] +[[package]] +name = "bridge-tally-canonical" +version = "0.1.0" +dependencies = [ + "bridge-tally-core", + "bridge-tally-protocol", + "sha2 0.11.0", +] + +[[package]] +name = "bridge-tally-compatibility" +version = "0.1.0" +dependencies = [ + "ed25519-dalek", + "hex", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "bridge-tally-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "bridge-tally-incremental" +version = "0.1.0" +dependencies = [ + "bridge-tally-core", + "serde", +] + +[[package]] +name = "bridge-tally-live-read" +version = "0.1.0" +dependencies = [ + "bridge-tally-compatibility", + "bridge-tally-protocol", + "bridge-tally-read-transport", + "getrandom 0.3.4", + "serde", + "serde_json", + "sha2 0.11.0", + "tally-protocol-simulator", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "bridge-tally-observability" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "sha2 0.11.0", +] + +[[package]] +name = "bridge-tally-protocol" +version = "0.1.0" +dependencies = [ + "anyhow", + "quick-xml", + "serde", + "serde_json", + "sha2 0.11.0", + "tally-protocol-simulator", +] + +[[package]] +name = "bridge-tally-qualification" +version = "0.1.0" +dependencies = [ + "bridge-tally-protocol", + "hex", + "libc", + "serde", + "serde_json", + "sha2 0.11.0", + "tally-protocol-simulator", + "tempfile", + "thiserror 2.0.18", + "windows-sys 0.61.2", +] + +[[package]] +name = "bridge-tally-read-transport" +version = "0.1.0" +dependencies = [ + "bridge-tally-protocol", + "bridge-tally-transport", + "sha2 0.11.0", + "tally-protocol-simulator", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "bridge-tally-runtime" +version = "0.1.0" +dependencies = [ + "bridge-tally-observability", + "tokio", + "tokio-util", +] + +[[package]] +name = "bridge-tally-transport" +version = "0.1.0" +dependencies = [ + "bridge-tally-protocol", + "reqwest 0.12.28", + "serde", + "sha2 0.11.0", + "tally-protocol-simulator", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "bridge-tally-write" +version = "0.1.0" +dependencies = [ + "bridge-tally-core", + "bridge-tally-protocol", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", +] + [[package]] name = "brotli" version = "8.0.4" @@ -359,6 +693,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.65" @@ -410,7 +753,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -427,6 +770,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "cmov" version = "0.5.4" @@ -452,6 +805,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -673,6 +1032,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "darling" version = "0.23.0" @@ -724,6 +1110,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "zeroize", +] + [[package]] name = "der-parser" version = "9.0.0" @@ -776,6 +1172,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", "crypto-common 0.1.7", + "subtle", ] [[package]] @@ -785,7 +1182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", ] @@ -808,7 +1205,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -929,6 +1326,30 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.16.0" @@ -967,6 +1388,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -991,7 +1439,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1015,6 +1463,16 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -1030,6 +1488,12 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "field-offset" version = "0.3.6" @@ -1174,6 +1638,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -1354,7 +1831,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1568,19 +2045,43 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac 0.12.1", +] + [[package]] name = "hkdf" version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018" dependencies = [ - "hmac", + "hmac 0.13.0", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest 0.10.7", ] [[package]] @@ -1902,6 +2403,16 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2025,6 +2536,27 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "4.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ee8d4dae108d4177d0a0ce241f98acc1ef28e20837bdef43cff4d160cf70fe" +dependencies = [ + "apple-native-keyring-store", + "keyring-core", + "windows-native-keyring-store", + "zbus-secret-service-keyring-store", +] + +[[package]] +name = "keyring-core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb1e621458ca9c51aa110bd0339d4751a056b9576bf1253aee1aa560dda0fc9d" +dependencies = [ + "log", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2116,6 +2648,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", + "openssl-sys", "pkg-config", "vcpkg", ] @@ -2309,7 +2842,21 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.6", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", ] [[package]] @@ -2333,6 +2880,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" @@ -2348,6 +2904,27 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.6", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2620,6 +3197,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + [[package]] name = "openssl-sys" version = "0.9.117" @@ -2628,6 +3214,7 @@ checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" dependencies = [ "cc", "libc", + "openssl-src", "pkg-config", "vcpkg", ] @@ -2638,6 +3225,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + [[package]] name = "pango" version = "0.18.3" @@ -2757,6 +3354,17 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs11" version = "0.5.0" @@ -2767,6 +3375,16 @@ dependencies = [ "num-bigint 0.2.6", ] +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "pkg-config" version = "0.3.33" @@ -2812,6 +3430,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2934,7 +3566,16 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", ] [[package]] @@ -3166,7 +3807,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3298,6 +3939,25 @@ dependencies = [ "zeroize", ] +[[package]] +name = "secret-service" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14" +dependencies = [ + "aes", + "cbc", + "futures-util", + "generic-array", + "getrandom 0.2.17", + "hkdf 0.12.4", + "num", + "once_cell", + "serde", + "sha2 0.10.9", + "zbus", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -3578,6 +4238,15 @@ dependencies = [ "libc", ] +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "simd-adler32" version = "0.3.9" @@ -3672,6 +4341,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + [[package]] name = "sqlx" version = "0.9.0" @@ -3755,7 +4434,6 @@ dependencies = [ "sqlx-postgres", "sqlx-sqlite", "syn 2.0.118", - "thiserror 2.0.18", "tokio", "url", ] @@ -3806,8 +4484,8 @@ dependencies = [ "futures-core", "futures-util", "hex", - "hkdf", - "hmac", + "hkdf 0.13.0", + "hmac 0.13.0", "itoa", "log", "md-5", @@ -3990,6 +4668,18 @@ dependencies = [ "version-compare", ] +[[package]] +name = "tally-protocol-simulator" +version = "0.1.0" +dependencies = [ + "bridge-tally-core", + "hex", + "quick-xml", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", +] + [[package]] name = "tao" version = "0.35.3" @@ -4270,7 +4960,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4455,6 +5145,7 @@ dependencies = [ "bytes", "futures-core", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] @@ -4726,6 +5417,17 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -5144,7 +5846,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5261,6 +5963,19 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-native-keyring-store" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063426e76fdec7438d56bb777f67e318a84a25c707b07e575cb8b78e10c028f8" +dependencies = [ + "byteorder", + "keyring-core", + "regex", + "windows-sys 0.61.2", + "zeroize", +] + [[package]] name = "windows-numerics" version = "0.2.0" @@ -5644,6 +6359,78 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28b97f866896a4be7aefd2b5a8e01bb6773d19a775d54ab28b4d094b9a4480e" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 1.0.3", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-secret-service-keyring-store" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ccede190ba363386a24e8021c7f3848393976609ec9f5d1f8c6c09ef37075b4" +dependencies = [ + "keyring-core", + "secret-service", + "zbus", +] + +[[package]] +name = "zbus_macros" +version = "5.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1039ca249fee9559680f3a9f05b55e0761fee51af4f6c1e7d8c1f31e549721d2" +dependencies = [ + "serde", + "winnow 1.0.3", + "zvariant", +] + [[package]] name = "zerofrom" version = "0.1.8" @@ -5709,3 +6496,43 @@ name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cf057bb00bf5c9ad77abb6147b0ca4818236a1858416e9d988e40d6322fefa7" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 1.0.3", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.118", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.118", + "winnow 1.0.3", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 78620f0..d071579 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,6 +10,25 @@ edition = "2021" rust-version = "1.96" default-run = "bridge" +[workspace] +members = [ + ".", + "crates/bridge-tally-canonical", + "crates/bridge-tally-compatibility", + "crates/bridge-tally-core", + "crates/bridge-tally-incremental", + "crates/bridge-tally-live-read", + "crates/bridge-tally-observability", + "crates/bridge-tally-protocol", + "crates/bridge-tally-qualification", + "crates/bridge-tally-read-transport", + "crates/bridge-tally-runtime", + "crates/bridge-tally-transport", + "crates/bridge-tally-write", + "crates/tally-protocol-simulator", +] +resolver = "2" + [lib] name = "bridge_lib" crate-type = ["staticlib", "cdylib", "rlib"] @@ -31,8 +50,19 @@ tauri-build = { version = "2", features = [] } [dependencies] anyhow = "1" +async-trait = "0.1" +bridge-tally-core = { path = "crates/bridge-tally-core" } +bridge-tally-canonical = { path = "crates/bridge-tally-canonical" } +bridge-tally-incremental = { path = "crates/bridge-tally-incremental" } +bridge-tally-protocol = { path = "crates/bridge-tally-protocol" } +bridge-tally-runtime = { path = "crates/bridge-tally-runtime" } +bridge-tally-transport = { path = "crates/bridge-tally-transport" } +bridge-tally-write = { path = "crates/bridge-tally-write" } chrono = { version = "0.4", features = ["serde"] } cryptoki = "0.12" +getrandom = "0.3" +keyring = { version = "4.1.4", default-features = false, features = ["v1"] } +libsqlite3-sys = { version = "0.30.1", features = ["bundled-sqlcipher-vendored-openssl"] } pkcs11 = "0.5" quick-xml = { version = "0.41", features = ["serialize"] } reqwest = { version = "0.12", features = ["json", "stream"] } @@ -41,7 +71,7 @@ rfd = { version = "0.17", default-features = false } serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.11" -sqlx = { version = "0.9", features = ["runtime-tokio", "sqlite", "migrate", "chrono", "uuid"] } +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "sqlite-bundled", "migrate", "chrono", "uuid"] } tauri = { version = "2", features = [] } thiserror = "2" tempfile = "3" @@ -53,6 +83,9 @@ uuid = { version = "1", features = ["v4", "serde"] } x509-parser = "0.16" zeroize = "1" +[dev-dependencies] +tally-protocol-simulator = { path = "crates/tally-protocol-simulator" } + [target.'cfg(unix)'.dependencies] libc = "0.2" diff --git a/src-tauri/crates/bridge-tally-canonical/Cargo.toml b/src-tauri/crates/bridge-tally-canonical/Cargo.toml new file mode 100644 index 0000000..f8d1138 --- /dev/null +++ b/src-tauri/crates/bridge-tally-canonical/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bridge-tally-canonical" +version = "0.1.0" +description = "Portable fail-closed canonicalization for Bridge Tally exports" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[dependencies] +bridge-tally-core = { path = "../bridge-tally-core" } +bridge-tally-protocol = { path = "../bridge-tally-protocol" } +sha2 = "0.11" diff --git a/src-tauri/crates/bridge-tally-canonical/src/lib.rs b/src-tauri/crates/bridge-tally-canonical/src/lib.rs new file mode 100644 index 0000000..bb5d450 --- /dev/null +++ b/src-tauri/crates/bridge-tally-canonical/src/lib.rs @@ -0,0 +1,528 @@ +//! Portable, deterministic conversion from strict Tally export records to Bridge canonical packs. +//! +//! This crate deliberately has no HTTP, database, OpenSSL, or Tauri dependency so the complete +//! identity and reference-binding boundary remains executable on every supported development host. + +use bridge_tally_core::{ + source_count_scope_fingerprint, CanonicalPackWindow, CanonicalText, CoreAccountingBatch, + ExactDecimal, GroupRecord, LedgerEntryPolarity, LedgerEntryRecord, LedgerRecord, + ObservedSourceIdentities, PackBatch, RawSourceSha256, RequestContext, SourceAlterId, + SourceCountScope, SourceCountScopeDescriptor, SourceIdentityKind, SourceRecordEvidence, + SourceRecordId, SourceReportedCountEvidence, TallyDate, TallyError, VoucherRecord, + VoucherTypeRecord, +}; +use bridge_tally_protocol::{ + ParsedExport, ParsedSourceIdentityKind, ParsedSourceRecord, TallyLedger, TallyNamedMaster, + TallyVoucher, +}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; + +/// Converts the four exact core-accounting exports into one reference-complete canonical window. +/// Any missing/ambiguous identity, mutable-name collision, or unresolved relationship fails closed. +pub fn build_core_window( + context: &RequestContext, + groups: ParsedExport>, + ledgers: ParsedExport>, + voucher_types: ParsedExport>, + vouchers: ParsedExport>, +) -> Result { + let requested_from = TallyDate::parse(context.window.from_yyyymmdd.clone()) + .map_err(|_| invalid_data("requested_window_invalid"))?; + let requested_to = TallyDate::parse(context.window.to_yyyymmdd.clone()) + .map_err(|_| invalid_data("requested_window_invalid"))?; + if requested_from.as_str() > requested_to.as_str() { + return Err(invalid_data("requested_window_invalid")); + } + let group_count = required_source_count(&groups, "group_source_count_missing")?; + let ledger_count = required_source_count(&ledgers, "ledger_source_count_missing")?; + let voucher_type_count = + required_source_count(&voucher_types, "voucher_type_source_count_missing")?; + let voucher_count = required_source_count(&vouchers, "voucher_source_count_missing")?; + validate_selected_voucher_window( + context.window.from_yyyymmdd.as_str(), + context.window.to_yyyymmdd.as_str(), + &vouchers, + )?; + let mut batch = CoreAccountingBatch::default(); + let mut record_evidence = Vec::new(); + + let group_ids_by_name = unique_source_ids_by_name( + &groups.records, + |record| &record.name, + "group_identity_missing", + "group_name_missing", + "group_name_duplicate", + )?; + for source in groups.records { + let source_id = required_source_id(&source, "group_identity_missing")?; + let evidence = source_evidence("group", source_id.clone(), &source)?; + let name = required_text(&source.record.name, "group_name_missing")?; + let parent_source_id = resolve_group_parent( + source.record.parent.as_deref(), + &group_ids_by_name, + "group_parent_missing", + )?; + batch.groups.push(GroupRecord { + source_id, + name, + parent_source_id, + }); + record_evidence.push(evidence); + } + + let ledger_ids_by_name = unique_source_ids_by_name( + &ledgers.records, + |record| &record.name, + "ledger_identity_missing", + "ledger_name_missing", + "ledger_name_duplicate", + )?; + for source in ledgers.records { + let source_id = required_source_id(&source, "ledger_identity_missing")?; + let evidence = source_evidence("ledger", source_id.clone(), &source)?; + let name = required_text(&source.record.name, "ledger_name_missing")?; + let parent_source_id = resolve_optional_reference( + source.record.parent.as_deref(), + &group_ids_by_name, + "ledger_parent_group_missing", + )?; + let opening_balance = source + .record + .opening_balance + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| ExactDecimal::parse(value.to_string())) + .transpose()?; + batch.ledgers.push(LedgerRecord { + source_id, + name, + parent_source_id, + opening_balance, + }); + record_evidence.push(evidence); + } + + let voucher_type_ids_by_name = unique_source_ids_by_name( + &voucher_types.records, + |record| &record.name, + "voucher_type_identity_missing", + "voucher_type_name_missing", + "voucher_type_name_duplicate", + )?; + for source in voucher_types.records { + let source_id = required_source_id(&source, "voucher_type_identity_missing")?; + let evidence = source_evidence("voucher_type", source_id.clone(), &source)?; + let name = required_text(&source.record.name, "voucher_type_name_missing")?; + batch + .voucher_types + .push(VoucherTypeRecord { source_id, name }); + record_evidence.push(evidence); + } + + for source in vouchers.records { + let voucher_source_id = required_source_id(&source, "voucher_identity_missing")?; + let voucher_evidence = source_evidence("voucher", voucher_source_id.clone(), &source)?; + let voucher_type_name = source + .record + .voucher_type + .as_deref() + .ok_or_else(|| invalid_data("voucher_type_missing"))?; + let voucher_type_source_id = resolve_required_reference( + voucher_type_name, + &voucher_type_ids_by_name, + "voucher_type_reference_missing", + )?; + let date_yyyymmdd = required_text( + source + .record + .date + .as_deref() + .ok_or_else(|| invalid_data("voucher_date_missing"))?, + "voucher_date_missing", + )?; + let voucher_date = TallyDate::parse(date_yyyymmdd.clone()) + .map_err(|_| invalid_data("voucher_date_invalid"))?; + if voucher_date.as_str() < requested_from.as_str() + || voucher_date.as_str() > requested_to.as_str() + { + return Err(invalid_data("voucher_date_outside_requested_window")); + } + let voucher_number = source + .record + .voucher_number + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| required_text(value, "voucher_number_invalid")) + .transpose()?; + let cancelled = source + .record + .cancelled + .ok_or_else(|| invalid_data("voucher_cancelled_missing"))?; + let optional = source + .record + .optional + .ok_or_else(|| invalid_data("voucher_optional_missing"))?; + + for entry in &source.record.ledger_entries { + let ledger_source_id = resolve_required_reference( + &entry.ledger_name, + &ledger_ids_by_name, + "voucher_ledger_reference_missing", + )?; + let entry_source_id = derived_ledger_entry_id( + &context.company.identity.company_guid, + &source, + entry.entry_index, + &entry.raw_source_sha256, + )?; + batch.ledger_entries.push(LedgerEntryRecord { + source_id: entry_source_id.clone(), + voucher_source_id: voucher_source_id.clone(), + ledger_source_id, + amount: ExactDecimal::parse(entry.amount.clone())?, + polarity: if entry.is_deemed_positive { + LedgerEntryPolarity::Debit + } else { + LedgerEntryPolarity::Credit + }, + }); + record_evidence.push(SourceRecordEvidence { + object_type: CanonicalText::parse("ledger_entry")?, + source_id: SourceRecordId::parse(entry_source_id)?, + identity_kind: SourceIdentityKind::Fallback, + observed_identities: ObservedSourceIdentities::default(), + // Hash of the exact decoded XML row fragment, not the HTTP transport bytes. + raw_source_sha256: RawSourceSha256::parse(entry.raw_source_sha256.clone())?, + alter_id: None, + }); + } + + batch.vouchers.push(VoucherRecord { + source_id: voucher_source_id, + date_yyyymmdd, + voucher_type_source_id, + voucher_number, + cancelled, + optional, + }); + record_evidence.push(voucher_evidence); + } + + let source_counts = vec![ + count_evidence(context, "group", group_count, SourceCountScope::Complete)?, + count_evidence(context, "ledger", ledger_count, SourceCountScope::Complete)?, + count_evidence( + context, + "voucher_type", + voucher_type_count, + SourceCountScope::Complete, + )?, + count_evidence(context, "voucher", voucher_count, SourceCountScope::Window)?, + ]; + let window = CanonicalPackWindow { + batch: PackBatch::CoreAccounting(batch), + source_counts: Some(source_counts), + record_evidence: Some(record_evidence), + }; + window.validate_source_count_evidence()?; + window.validate_record_evidence_binding()?; + Ok(window) +} + +/// Validates the exact selected voucher profile without canonicalising or retaining book data. +/// A successful zero-row response proves only execution of the selected profile, not emptiness or +/// source completeness. +pub fn validate_selected_voucher_window( + from_yyyymmdd: &str, + to_yyyymmdd: &str, + vouchers: &ParsedExport>, +) -> Result<(), TallyError> { + let requested_from = TallyDate::parse(from_yyyymmdd.to_string()) + .map_err(|_| invalid_data("requested_window_invalid"))?; + let requested_to = TallyDate::parse(to_yyyymmdd.to_string()) + .map_err(|_| invalid_data("requested_window_invalid"))?; + if requested_from.as_str() > requested_to.as_str() { + return Err(invalid_data("requested_window_invalid")); + } + for source in &vouchers.records { + let source_id = required_source_id(source, "voucher_identity_missing")?; + source_evidence("voucher", source_id, source)?; + required_text( + source + .record + .voucher_type + .as_deref() + .ok_or_else(|| invalid_data("voucher_type_missing"))?, + "voucher_type_missing", + )?; + let date = required_text( + source + .record + .date + .as_deref() + .ok_or_else(|| invalid_data("voucher_date_missing"))?, + "voucher_date_missing", + )?; + let voucher_date = + TallyDate::parse(date).map_err(|_| invalid_data("voucher_date_invalid"))?; + if voucher_date.as_str() < requested_from.as_str() + || voucher_date.as_str() > requested_to.as_str() + { + return Err(invalid_data("voucher_date_outside_requested_window")); + } + source + .record + .cancelled + .ok_or_else(|| invalid_data("voucher_cancelled_missing"))?; + source + .record + .optional + .ok_or_else(|| invalid_data("voucher_optional_missing"))?; + source + .record + .voucher_number + .as_deref() + .map(|value| required_text(value, "voucher_number_invalid")) + .transpose()?; + source + .record + .party_ledger_name + .as_deref() + .map(|value| required_text(value, "voucher_party_ledger_name_invalid")) + .transpose()?; + let declared_entries = source + .record + .ledger_entry_count + .ok_or_else(|| invalid_data("voucher_ledger_entry_count_missing"))?; + if declared_entries != source.record.ledger_entries.len() as u64 { + return Err(invalid_data("voucher_ledger_entry_count_mismatch")); + } + let mut entry_indices = std::collections::BTreeSet::new(); + for entry in &source.record.ledger_entries { + if entry.entry_index == 0 || !entry_indices.insert(entry.entry_index) { + return Err(invalid_data("voucher_ledger_entry_index_invalid")); + } + required_text(&entry.ledger_name, "voucher_ledger_name_invalid")?; + ExactDecimal::parse(entry.amount.clone())?; + RawSourceSha256::parse(entry.raw_source_sha256.clone())?; + } + } + Ok(()) +} + +fn required_source_count( + export: &ParsedExport, + code: &'static str, +) -> Result { + export + .evidence + .source_record_count + .ok_or_else(|| protocol_error(code)) +} + +fn unique_source_ids_by_name( + records: &[ParsedSourceRecord], + name: F, + missing_identity_code: &'static str, + invalid_name_code: &'static str, + duplicate_name_code: &'static str, +) -> Result, TallyError> +where + F: Fn(&T) -> &str, +{ + let mut ids = BTreeMap::new(); + for source in records { + let source_id = required_source_id(source, missing_identity_code)?; + let canonical_name = required_text(name(&source.record), invalid_name_code)?; + if ids.insert(canonical_name, source_id).is_some() { + return Err(invalid_data(duplicate_name_code)); + } + } + Ok(ids) +} + +fn resolve_optional_reference( + value: Option<&str>, + ids_by_name: &BTreeMap, + missing_code: &'static str, +) -> Result, TallyError> { + value + .filter(|value| !value.trim().is_empty()) + .map(|value| resolve_required_reference(value, ids_by_name, missing_code)) + .transpose() +} + +fn resolve_group_parent( + value: Option<&str>, + ids_by_name: &BTreeMap, + missing_code: &'static str, +) -> Result, TallyError> { + let Some(value) = value.filter(|value| !value.trim().is_empty()) else { + return Ok(None); + }; + // `Primary` is Tally's reserved top-level classification, not one of the exported Group + // masters. Preserve the canonical tree root as `None`; every other named parent must resolve. + if value.trim().eq_ignore_ascii_case("primary") { + return Ok(None); + } + resolve_required_reference(value, ids_by_name, missing_code).map(Some) +} + +fn resolve_required_reference( + value: &str, + ids_by_name: &BTreeMap, + missing_code: &'static str, +) -> Result { + let name = required_text(value, missing_code)?; + ids_by_name + .get(&name) + .cloned() + .ok_or_else(|| invalid_data(missing_code)) +} + +fn count_evidence( + context: &RequestContext, + object_type: &str, + count: u64, + scope: SourceCountScope, +) -> Result { + let object_type = CanonicalText::parse(object_type)?; + let descriptor = SourceCountScopeDescriptor { + source_identity: context.company.identity.clone(), + pack: context.pack, + pack_schema_version: context.schema_version, + object_type: object_type.clone(), + query_profile: context.query_profile.clone(), + filters_sha256: context.filters_sha256.clone(), + window: (scope == SourceCountScope::Window).then(|| context.window.clone()), + }; + Ok(SourceReportedCountEvidence { + object_type, + query_profile: context.query_profile.clone(), + source_scope_fingerprint: source_count_scope_fingerprint(&descriptor, scope)?, + source_count_scope: scope, + source_reported_count: count, + }) +} + +fn source_evidence( + object_type: &str, + source_id: String, + source: &ParsedSourceRecord, +) -> Result { + let identity_kind = match source.identity_kind { + Some(ParsedSourceIdentityKind::Guid) => SourceIdentityKind::Guid, + Some(ParsedSourceIdentityKind::RemoteId) => SourceIdentityKind::RemoteId, + Some(ParsedSourceIdentityKind::MasterId) => SourceIdentityKind::MasterId, + None => return Err(invalid_data("source_identity_kind_missing")), + }; + Ok(SourceRecordEvidence { + object_type: CanonicalText::parse(object_type)?, + source_id: SourceRecordId::parse(source_id)?, + identity_kind, + observed_identities: ObservedSourceIdentities { + guid: source + .identities + .guid + .clone() + .map(SourceRecordId::parse) + .transpose()?, + remote_id: source + .identities + .remote_id + .clone() + .map(SourceRecordId::parse) + .transpose()?, + master_id: source + .identities + .master_id + .clone() + .map(SourceRecordId::parse) + .transpose()?, + }, + raw_source_sha256: RawSourceSha256::parse(source.raw_source_sha256.clone())?, + alter_id: source + .alter_id + .clone() + .map(SourceAlterId::parse) + .transpose()?, + }) +} + +fn required_source_id( + source: &ParsedSourceRecord, + code: &'static str, +) -> Result { + source + .source_id + .clone() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| invalid_data(code)) +} + +fn required_text(value: &str, code: &'static str) -> Result { + CanonicalText::parse(value.to_string()) + .map(|value| value.as_str().to_string()) + .map_err(|_| invalid_data(code)) +} + +fn derived_ledger_entry_id( + company_guid: &str, + voucher: &ParsedSourceRecord, + entry_index: u64, + entry_fragment_sha256: &str, +) -> Result { + let identity_kind = voucher + .identity_kind + .ok_or_else(|| invalid_data("voucher_identity_kind_missing"))?; + let source_id = required_source_id(voucher, "voucher_identity_missing")?; + RawSourceSha256::parse(entry_fragment_sha256.to_string())?; + + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-ledger-entry-derived-id-v1\0"); + hash_field(&mut digest, company_guid.as_bytes()); + hash_field(&mut digest, parsed_identity_kind_code(identity_kind)); + hash_field(&mut digest, source_id.as_bytes()); + hash_field(&mut digest, &entry_index.to_be_bytes()); + hash_field(&mut digest, entry_fragment_sha256.as_bytes()); + Ok(format!( + "bridge-derived:ledger-entry:v1:{}", + hex_lower(&digest.finalize()) + )) +} + +fn parsed_identity_kind_code(kind: ParsedSourceIdentityKind) -> &'static [u8] { + match kind { + ParsedSourceIdentityKind::Guid => b"guid", + ParsedSourceIdentityKind::RemoteId => b"remote_id", + ParsedSourceIdentityKind::MasterId => b"master_id", + } +} + +fn hash_field(digest: &mut Sha256, value: &[u8]) { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn invalid_data(code: &'static str) -> TallyError { + TallyError::InvalidData { + code: code.to_string(), + } +} + +fn protocol_error(code: &'static str) -> TallyError { + TallyError::Protocol { + code: code.to_string(), + } +} diff --git a/src-tauri/crates/bridge-tally-canonical/tests/canonicalization.rs b/src-tauri/crates/bridge-tally-canonical/tests/canonicalization.rs new file mode 100644 index 0000000..4e8d0f2 --- /dev/null +++ b/src-tauri/crates/bridge-tally-canonical/tests/canonicalization.rs @@ -0,0 +1,278 @@ +use bridge_tally_canonical::{build_core_window, validate_selected_voucher_window}; +use bridge_tally_core::{ + CanonicalPackWindow, CanonicalText, CapabilityPackId, CompanyRef, LedgerEntryPolarity, + ObservedSourceIdentities, PackBatch, PackSchemaVersion, ReadWindow, RequestContext, + SourceIdentity, SourceIdentityKind, TallyError, +}; +use bridge_tally_protocol::{ + parse_group_source_records_with_evidence, parse_ledger_source_records_with_evidence, + parse_voucher_source_records_with_evidence, parse_voucher_type_source_records_with_evidence, + ParsedExport, ParsedSourceRecord, TallyLedger, TallyNamedMaster, TallyVoucher, + BRIDGE_GROUP_EXPORT_SCHEMA, BRIDGE_LEDGER_EXPORT_SCHEMA, BRIDGE_VOUCHER_EXPORT_SCHEMA, + BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA, +}; + +fn context() -> RequestContext { + RequestContext { + run_id: "synthetic-run".to_string(), + company: CompanyRef { + identity: SourceIdentity { + bridge_source_lineage: "synthetic-lineage".to_string(), + company_guid: "synthetic-company-guid".to_string(), + observed_fingerprint: "synthetic-observation".to_string(), + }, + display_name: "BRIDGE SYNTHETIC BOOK".to_string(), + }, + pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + window: ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260731".to_string(), + }, + query_profile: CanonicalText::parse("core_accounting_v1").unwrap(), + filters_sha256: CanonicalText::parse("0".repeat(64)).unwrap(), + } +} + +fn groups() -> ParsedExport> { + parse_group_source_records_with_evidence(&format!( + r#"
1
Primary
"#, + BRIDGE_GROUP_EXPORT_SCHEMA + )) + .unwrap() +} + +fn ledgers_and_vouchers( + cash_name: &str, + entry_ledger_name: &str, +) -> ( + ParsedExport>, + ParsedExport>, +) { + let ledgers = parse_ledger_source_records_with_evidence(&format!( + r#"
1
Assets0Assets0
"#, + BRIDGE_LEDGER_EXPORT_SCHEMA, cash_name + )) + .unwrap(); + let vouchers = parse_voucher_source_records_with_evidence(&format!( + r#"
1
20260714ReceiptSYN-1NoNo21{}-100.00Yes2Sales100.00No
"#, + BRIDGE_VOUCHER_EXPORT_SCHEMA, entry_ledger_name + )) + .unwrap(); + (ledgers, vouchers) +} + +fn voucher_types() -> ParsedExport> { + parse_voucher_type_source_records_with_evidence(&format!( + r#"
1
Receipt
"#, + BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA + )) + .unwrap() +} + +fn valid_window() -> CanonicalPackWindow { + let (ledgers, vouchers) = ledgers_and_vouchers("Cash", "Cash"); + build_core_window(&context(), groups(), ledgers, voucher_types(), vouchers).unwrap() +} + +#[test] +fn canonicalizes_all_core_records_with_exact_reference_and_provenance_binding() { + let window = valid_window(); + window.validate_record_evidence_binding().unwrap(); + let PackBatch::CoreAccounting(batch) = &window.batch else { + panic!("wrong pack") + }; + assert_eq!( + ( + batch.groups.len(), + batch.ledgers.len(), + batch.voucher_types.len() + ), + (1, 2, 1) + ); + assert_eq!((batch.vouchers.len(), batch.ledger_entries.len()), (1, 2)); + assert_eq!( + batch.ledgers[0].parent_source_id.as_deref(), + Some("group-guid") + ); + assert_eq!(batch.groups[0].parent_source_id, None); + assert_eq!( + batch.vouchers[0].voucher_type_source_id, + "voucher-type-guid" + ); + assert_eq!(batch.ledger_entries[0].ledger_source_id, "ledger-cash"); + assert_eq!(batch.ledger_entries[0].voucher_source_id, "voucher-guid"); + assert_eq!(batch.ledger_entries[0].polarity, LedgerEntryPolarity::Debit); + assert_eq!( + batch.ledger_entries[1].polarity, + LedgerEntryPolarity::Credit + ); + assert!(batch.ledger_entries[0] + .source_id + .starts_with("bridge-derived:ledger-entry:v1:")); + assert_eq!(window.source_counts.as_ref().unwrap().len(), 4); + assert!(window + .source_counts + .as_ref() + .unwrap() + .iter() + .all(|evidence| evidence.object_type.as_str() != "ledger_entry")); + assert_eq!(window.record_evidence.as_ref().unwrap().len(), 7); + + let voucher_evidence = window + .record_evidence + .as_ref() + .unwrap() + .iter() + .find(|evidence| evidence.object_type.as_str() == "voucher") + .unwrap(); + assert_eq!(voucher_evidence.identity_kind, SourceIdentityKind::Guid); + assert_eq!( + voucher_evidence + .observed_identities + .remote_id + .as_ref() + .unwrap() + .as_str(), + "voucher-remote" + ); + assert_eq!( + voucher_evidence + .observed_identities + .master_id + .as_ref() + .unwrap() + .as_str(), + "9" + ); +} + +#[test] +fn nested_entry_totals_remain_local_and_are_never_claimed_as_source_reported() { + let window = valid_window(); + let PackBatch::CoreAccounting(batch) = &window.batch else { + panic!("wrong pack") + }; + + assert_eq!(batch.ledger_entries.len(), 2); + assert_eq!( + window + .record_evidence + .as_ref() + .unwrap() + .iter() + .filter(|evidence| evidence.object_type.as_str() == "ledger_entry") + .count(), + 2 + ); + assert!(window + .source_counts + .as_ref() + .unwrap() + .iter() + .all(|evidence| evidence.object_type.as_str() != "ledger_entry")); +} + +#[test] +fn derived_entry_ids_are_deterministic_but_never_claim_native_identity() { + fn entry_ids(window: &CanonicalPackWindow) -> Vec { + let PackBatch::CoreAccounting(batch) = &window.batch else { + panic!("wrong pack") + }; + batch + .ledger_entries + .iter() + .map(|entry| entry.source_id.clone()) + .collect() + } + let first = valid_window(); + let second = valid_window(); + assert_eq!(entry_ids(&first), entry_ids(&second)); + let entry_evidence = first + .record_evidence + .as_ref() + .unwrap() + .iter() + .filter(|evidence| evidence.object_type.as_str() == "ledger_entry") + .collect::>(); + assert_eq!(entry_evidence.len(), 2); + assert!(entry_evidence.iter().all(|evidence| { + evidence.identity_kind == SourceIdentityKind::Fallback + && evidence.observed_identities == ObservedSourceIdentities::default() + })); +} + +#[test] +fn unresolved_mutable_name_reference_fails_closed() { + let (ledgers, vouchers) = ledgers_and_vouchers("Cash", "Missing Ledger"); + let error = + build_core_window(&context(), groups(), ledgers, voucher_types(), vouchers).unwrap_err(); + assert!(matches!( + error, + TallyError::InvalidData { code } + if code == "voucher_ledger_reference_missing" + )); +} + +#[test] +fn duplicate_mutable_names_fail_closed_even_when_native_ids_differ() { + let (ledgers, vouchers) = ledgers_and_vouchers("Sales", "Sales"); + let error = + build_core_window(&context(), groups(), ledgers, voucher_types(), vouchers).unwrap_err(); + assert!(matches!( + error, + TallyError::InvalidData { code } if code == "ledger_name_duplicate" + )); +} + +#[test] +fn invalid_or_out_of_window_voucher_dates_fail_before_canonical_state_exists() { + for (date, expected_code) in [ + ("20260230", "voucher_date_invalid"), + ("20260630", "voucher_date_outside_requested_window"), + ("20260801", "voucher_date_outside_requested_window"), + ] { + let (ledgers, mut vouchers) = ledgers_and_vouchers("Cash", "Cash"); + vouchers.records[0].record.date = Some(date.to_string()); + let error = build_core_window(&context(), groups(), ledgers, voucher_types(), vouchers) + .unwrap_err(); + assert!(matches!( + error, + TallyError::InvalidData { code } if code == expected_code + )); + } +} + +#[test] +fn invalid_requested_window_fails_before_source_rows_are_canonicalized() { + for (from, to) in [("20260230", "20260731"), ("20260801", "20260731")] { + let mut request = context(); + request.window.from_yyyymmdd = from.to_string(); + request.window.to_yyyymmdd = to.to_string(); + let (ledgers, vouchers) = ledgers_and_vouchers("Cash", "Cash"); + let error = + build_core_window(&request, groups(), ledgers, voucher_types(), vouchers).unwrap_err(); + assert!(matches!( + error, + TallyError::InvalidData { code } if code == "requested_window_invalid" + )); + } +} + +#[test] +fn selected_voucher_qualification_rejects_noncanonical_records_and_entries() { + let (_, vouchers) = ledgers_and_vouchers("Cash", "Cash"); + validate_selected_voucher_window("20260701", "20260731", &vouchers).unwrap(); + + let mut invalid_amount = vouchers.clone(); + invalid_amount.records[0].record.ledger_entries[0].amount = "not-an-amount".to_string(); + assert!(validate_selected_voucher_window("20260701", "20260731", &invalid_amount).is_err()); + + let mut invalid_name = vouchers.clone(); + invalid_name.records[0].record.ledger_entries[0].ledger_name = " x ".to_string(); + assert!(validate_selected_voucher_window("20260701", "20260731", &invalid_name).is_err()); + + let mut invalid_alter_id = vouchers; + invalid_alter_id.records[0].alter_id = Some("contains whitespace".to_string()); + assert!(validate_selected_voucher_window("20260701", "20260731", &invalid_alter_id).is_err()); +} diff --git a/src-tauri/crates/bridge-tally-compatibility/Cargo.toml b/src-tauri/crates/bridge-tally-compatibility/Cargo.toml new file mode 100644 index 0000000..3a76fb7 --- /dev/null +++ b/src-tauri/crates/bridge-tally-compatibility/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bridge-tally-compatibility" +version = "0.1.0" +description = "Privacy-safe live Tally compatibility evidence and release-claim gates for Bridge" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[features] +default = [] +bills-native-outstandings-probe-receipt = [] + +[dependencies] +ed25519-dalek = "2" +hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +thiserror = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/src-tauri/crates/bridge-tally-compatibility/README.md b/src-tauri/crates/bridge-tally-compatibility/README.md new file mode 100644 index 0000000..1b8ea0a --- /dev/null +++ b/src-tauri/crates/bridge-tally-compatibility/README.md @@ -0,0 +1,19 @@ +# Bridge Tally compatibility evidence + +This crate defines a separate authority boundary for live, read-only Tally +qualification. It does not perform network requests and can never emit a +support claim. Raw receipts are privacy-reduced observations with checksum-only +integrity; positive release claims additionally require an exact-scope, +maintainer-reviewed Ed25519 attestation from a configured non-revoked key. + +The contract deliberately excludes raw requests/responses, endpoint ports, +company or record identifiers, accounting values, local paths, and arbitrary +labels. It permanently states that responder authenticity, accounting +correctness, source completeness/atomicity, performance support, and writes are +not established by this evidence. Portable receipts also permanently record +that the Tauri runtime was not observed. + +The companion CLI validates receipts, seals deterministic source-surface +manifests, and enforces structured release claims. The later live controller +must depend only on sealed read templates and produce this DTO; it must not +expose generic XML dispatch or any write surface. diff --git a/src-tauri/crates/bridge-tally-compatibility/src/bills_native_outstandings_probe_receipt.rs b/src-tauri/crates/bridge-tally-compatibility/src/bills_native_outstandings_probe_receipt.rs new file mode 100644 index 0000000..264e74e --- /dev/null +++ b/src-tauri/crates/bridge-tally-compatibility/src/bills_native_outstandings_probe_receipt.rs @@ -0,0 +1,1018 @@ +//! Qualification-only receipt for the dormant native Ledger Outstandings probe. +//! +//! This artifact records bounded observation facts. It is intentionally not a +//! [`crate::LiveCompatibilityReceipt`], cannot satisfy the support gate, and +//! carries no parser, accounting, runtime, mirror, or support authority. + +use std::fmt; + +use serde::{Deserialize, Serialize}; + +use crate::{ + checksum, invalid, validate_commit, validate_exact_release, validate_label, validate_safe_code, + validate_sha256, validate_slug, ApplicationStatus, Architecture, CompatibilityError, + LocaleProfile, LoopbackFamily, Platform, ProductFamily, TallyMode, TextEncoding, +}; + +pub const BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_SCHEMA_VERSION: u16 = 0; +pub const BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_MAX_BYTES: usize = 256 * 1024; +pub const BILLS_NATIVE_OUTSTANDINGS_RESPONSE_MAX_BYTES: u64 = 1024 * 1024; +pub const BILLS_NATIVE_OUTSTANDINGS_PROFILE_ID: &str = "native_ledger_outstandings_candidate_v0"; +pub const BILLS_NATIVE_OUTSTANDINGS_REPORT_ID: &str = "ledger_outstandings"; + +const RECEIPT_CHECKSUM_DOMAIN: &[u8] = b"bridge.tally.native-ledger-outstandings.probe-receipt/0\0"; + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeEndpointV0 { + pub family: LoopbackFamily, + pub port: u16, + pub canonical_origin: String, +} + +impl fmt::Debug for ProbeEndpointV0 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProbeEndpointV0") + .field("family", &self.family) + .field("port", &self.port) + .field("canonical_origin", &"[redacted]") + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProbeAttestationAuthority { + User, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeAttestedEnvironmentV0 { + pub authority: ProbeAttestationAuthority, + pub product: ProductFamily, + pub release: String, + pub mode: TallyMode, + pub locale: LocaleProfile, + pub configured_tdl_count: u32, + pub no_customer_data: bool, +} + +impl fmt::Debug for ProbeAttestedEnvironmentV0 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProbeAttestedEnvironmentV0") + .field("authority", &self.authority) + .field("product", &self.product) + .field("release", &"[redacted]") + .field("mode", &self.mode) + .field("locale", &self.locale) + .field("configured_tdl_count", &self.configured_tdl_count) + .field("no_customer_data", &self.no_customer_data) + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeCommitmentsV0 { + pub fixture_id: String, + pub fixture_manifest_sha256: String, + pub profile_id: String, + pub template_sha256: String, + pub request_sha256: String, + pub scope_sha256: String, +} + +impl fmt::Debug for ProbeCommitmentsV0 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProbeCommitmentsV0") + .field("fixture_id", &self.fixture_id) + .field("profile_id", &self.profile_id) + .field("hashes", &"[redacted]") + .finish() + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeInitialPreflightV0 { + pub observed_at_unix_ms: i64, + pub company_identity_commitment_sha256: String, + pub party_identity_commitment_sha256: String, + pub company_response_sha256: String, + pub party_response_sha256: String, +} + +impl fmt::Debug for ProbeInitialPreflightV0 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProbeInitialPreflightV0") + .field("observed_at_unix_ms", &self.observed_at_unix_ms) + .field("commitments", &"[redacted]") + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentitySnapshotId { + B0, + B1, + B2, + B3, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeIdentitySnapshotV0 { + pub snapshot_id: IdentitySnapshotId, + pub observed_at_unix_ms: i64, + pub company_identity_commitment_sha256: String, + pub party_identity_commitment_sha256: String, + pub company_response_sha256: String, + pub party_response_sha256: String, +} + +impl fmt::Debug for ProbeIdentitySnapshotV0 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProbeIdentitySnapshotV0") + .field("snapshot_id", &self.snapshot_id) + .field("observed_at_unix_ms", &self.observed_at_unix_ms) + .field("commitments", &"[redacted]") + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CandidateAttemptId { + A1, + A2, + A3, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CandidateAttemptOutcome { + ResponseObserved, + HttpRejected, + TransportFailed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IdentityBracketState { + Unchanged, + Changed, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeCandidateAttemptV0 { + pub attempt_id: CandidateAttemptId, + pub observed_at_unix_ms: i64, + pub outcome: CandidateAttemptOutcome, + pub http_status: Option, + pub application_status: ApplicationStatus, + pub encoding: TextEncoding, + pub exact_encoded_bytes: u64, + pub encoded_body_sha256: Option, + pub decoded_text_sha256: Option, + pub safe_reason_code: Option, + pub bracket_state: IdentityBracketState, +} + +impl fmt::Debug for ProbeCandidateAttemptV0 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProbeCandidateAttemptV0") + .field("attempt_id", &self.attempt_id) + .field("observed_at_unix_ms", &self.observed_at_unix_ms) + .field("outcome", &self.outcome) + .field("http_status", &self.http_status) + .field("application_status", &self.application_status) + .field("encoding", &self.encoding) + .field("exact_encoded_bytes", &self.exact_encoded_bytes) + .field("response_hashes", &"[redacted]") + .field("safe_reason_code", &self.safe_reason_code) + .field("bracket_state", &self.bracket_state) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ByteRepeatability { + Identical, + Different, + NotEstablished, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UiObservationPosition { + Before, + After, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeUiObservationV0 { + pub position: UiObservationPosition, + pub observed_at_unix_ms: i64, + pub product: ProductFamily, + pub release: String, + pub mode: TallyMode, + pub report_id: String, + pub opening_column_visible: bool, + pub pending_column_visible: bool, + pub due_column_visible: bool, + pub overdue_column_visible: bool, + pub company_identity_commitment_sha256: String, + pub party_identity_commitment_sha256: String, + pub structured_observation_sha256: String, + pub screenshot_sha256: String, +} + +impl fmt::Debug for ProbeUiObservationV0 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ProbeUiObservationV0") + .field("position", &self.position) + .field("observed_at_unix_ms", &self.observed_at_unix_ms) + .field("product", &self.product) + .field("release", &"[redacted]") + .field("mode", &self.mode) + .field("report_id", &self.report_id) + .field("commitments", &"[redacted]") + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UserAttestedUiChange { + Unchanged, + Changed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeReceiptAuthorityV0 { + pub read_only: bool, + pub writes_attempted: bool, + pub raw_response_retained: bool, + pub consent_replay_allowed: bool, + pub responder_authenticity_established: bool, + pub response_scope_bound: bool, + pub response_grammar_established: bool, + pub accounting_semantics_established: bool, + pub source_completeness_established: bool, + pub source_atomicity_established: bool, + pub performance_budget_established: bool, + pub native_runtime_observed: bool, + pub mirror_written: bool, + pub support_claim_eligible: bool, +} + +impl ProbeReceiptAuthorityV0 { + pub fn observation_only() -> Self { + Self { + read_only: true, + writes_attempted: false, + raw_response_retained: false, + consent_replay_allowed: false, + responder_authenticity_established: false, + response_scope_bound: false, + response_grammar_established: false, + accounting_semantics_established: false, + source_completeness_established: false, + source_atomicity_established: false, + performance_budget_established: false, + native_runtime_observed: false, + mirror_written: false, + support_claim_eligible: false, + } + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BillsNativeOutstandingsProbeReceiptV0 { + pub schema_version: u16, + pub observed_at_unix_ms: i64, + pub bridge_commit_sha: String, + pub working_tree_dirty: bool, + pub compatibility_surface_sha256: String, + pub executable_sha256: String, + pub cargo_lock_sha256: String, + pub platform: Platform, + pub architecture: Architecture, + pub endpoint: ProbeEndpointV0, + pub attested_environment: ProbeAttestedEnvironmentV0, + pub commitments: ProbeCommitmentsV0, + pub initial_preflight: ProbeInitialPreflightV0, + pub identity_snapshots: [ProbeIdentitySnapshotV0; 4], + pub attempts: [ProbeCandidateAttemptV0; 3], + pub byte_repeatability: ByteRepeatability, + pub ui_before: ProbeUiObservationV0, + pub ui_after: ProbeUiObservationV0, + pub user_attested_ui_change: UserAttestedUiChange, + pub authority: ProbeReceiptAuthorityV0, + pub receipt_sha256: String, +} + +impl fmt::Debug for BillsNativeOutstandingsProbeReceiptV0 { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("BillsNativeOutstandingsProbeReceiptV0") + .field("schema_version", &self.schema_version) + .field("observed_at_unix_ms", &self.observed_at_unix_ms) + .field("working_tree_dirty", &self.working_tree_dirty) + .field("platform", &self.platform) + .field("architecture", &self.architecture) + .field("endpoint", &self.endpoint) + .field("attested_environment", &self.attested_environment) + .field("commitments", &self.commitments) + .field("initial_preflight", &self.initial_preflight) + .field("identity_snapshot_count", &self.identity_snapshots.len()) + .field("attempt_count", &self.attempts.len()) + .field("byte_repeatability", &self.byte_repeatability) + .field("ui_before", &self.ui_before) + .field("ui_after", &self.ui_after) + .field("user_attested_ui_change", &self.user_attested_ui_change) + .field("authority", &self.authority) + .field("environment_and_receipt_hashes", &"[redacted]") + .finish() + } +} + +impl BillsNativeOutstandingsProbeReceiptV0 { + pub fn seal(mut self) -> Result { + self.receipt_sha256.clear(); + self.validate_shape(false)?; + self.receipt_sha256 = checksum(RECEIPT_CHECKSUM_DOMAIN, &self)?; + self.validate()?; + Ok(self) + } + + pub fn validate(&self) -> Result<(), CompatibilityError> { + self.validate_shape(true)?; + let mut unsigned = self.clone(); + let supplied = std::mem::take(&mut unsigned.receipt_sha256); + let expected = checksum(RECEIPT_CHECKSUM_DOMAIN, &unsigned)?; + if supplied != expected { + return Err(invalid("native_probe_receipt_checksum_mismatch")); + } + Ok(()) + } + + pub fn to_pretty_json(&self) -> Result, CompatibilityError> { + self.validate()?; + let bytes = serde_json::to_vec_pretty(self).map_err(|_| invalid("serialization_failed"))?; + if bytes.len() > BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_MAX_BYTES { + return Err(invalid("native_probe_receipt_too_large")); + } + Ok(bytes) + } + + pub fn from_json(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_MAX_BYTES { + return Err(invalid("native_probe_receipt_size_invalid")); + } + let receipt: Self = serde_json::from_slice(bytes) + .map_err(|_| invalid("native_probe_receipt_json_invalid"))?; + receipt.validate()?; + Ok(receipt) + } + + fn validate_shape(&self, require_checksum: bool) -> Result<(), CompatibilityError> { + if self.schema_version != BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_SCHEMA_VERSION { + return Err(invalid("native_probe_receipt_schema_unsupported")); + } + if self.observed_at_unix_ms <= 0 { + return Err(invalid("native_probe_receipt_time_invalid")); + } + validate_commit(&self.bridge_commit_sha)?; + for digest in [ + &self.compatibility_surface_sha256, + &self.executable_sha256, + &self.cargo_lock_sha256, + ] { + validate_sha256(digest)?; + } + if require_checksum { + validate_sha256(&self.receipt_sha256)?; + } else if !self.receipt_sha256.is_empty() { + return Err(invalid("native_probe_receipt_checksum_must_start_empty")); + } + + validate_endpoint(&self.endpoint)?; + validate_attested_environment(&self.attested_environment)?; + validate_commitments(&self.commitments)?; + validate_initial_preflight(&self.initial_preflight)?; + validate_identity_snapshots(&self.identity_snapshots)?; + validate_attempts(&self.attempts, &self.identity_snapshots)?; + validate_ui_observations( + &self.attested_environment, + &self.initial_preflight, + &self.identity_snapshots, + &self.ui_before, + &self.ui_after, + self.user_attested_ui_change, + )?; + + let timeline = [ + self.ui_before.observed_at_unix_ms, + self.initial_preflight.observed_at_unix_ms, + self.identity_snapshots[0].observed_at_unix_ms, + self.attempts[0].observed_at_unix_ms, + self.identity_snapshots[1].observed_at_unix_ms, + self.attempts[1].observed_at_unix_ms, + self.identity_snapshots[2].observed_at_unix_ms, + self.attempts[2].observed_at_unix_ms, + self.identity_snapshots[3].observed_at_unix_ms, + self.ui_after.observed_at_unix_ms, + self.observed_at_unix_ms, + ]; + if timeline.iter().any(|value| *value <= 0) + || timeline.windows(2).any(|window| window[0] > window[1]) + { + return Err(invalid("native_probe_receipt_timeline_invalid")); + } + + if self.byte_repeatability != expected_repeatability(&self.attempts) { + return Err(invalid("native_probe_byte_repeatability_inconsistent")); + } + if self.authority != ProbeReceiptAuthorityV0::observation_only() { + return Err(invalid("native_probe_receipt_authority_invalid")); + } + Ok(()) + } +} + +fn validate_endpoint(endpoint: &ProbeEndpointV0) -> Result<(), CompatibilityError> { + if endpoint.port == 0 { + return Err(invalid("native_probe_endpoint_invalid")); + } + let expected = match endpoint.family { + LoopbackFamily::LocalhostAlias | LoopbackFamily::Ipv4 => { + format!("http://127.0.0.1:{}", endpoint.port) + } + LoopbackFamily::Ipv6 => format!("http://[::1]:{}", endpoint.port), + }; + if endpoint.canonical_origin != expected { + return Err(invalid("native_probe_endpoint_not_canonical")); + } + Ok(()) +} + +fn validate_attested_environment( + environment: &ProbeAttestedEnvironmentV0, +) -> Result<(), CompatibilityError> { + validate_label(&environment.release)?; + validate_exact_release(&environment.release)?; + if environment.authority != ProbeAttestationAuthority::User + || environment.product == ProductFamily::Unknown + || environment.mode != TallyMode::Education + || environment.locale == LocaleProfile::Unknown + || environment.configured_tdl_count != 0 + || !environment.no_customer_data + { + return Err(invalid("native_probe_environment_attestation_invalid")); + } + Ok(()) +} + +fn validate_commitments(commitments: &ProbeCommitmentsV0) -> Result<(), CompatibilityError> { + validate_slug(&commitments.fixture_id)?; + if commitments.profile_id != BILLS_NATIVE_OUTSTANDINGS_PROFILE_ID { + return Err(invalid("native_probe_profile_invalid")); + } + for digest in [ + &commitments.fixture_manifest_sha256, + &commitments.template_sha256, + &commitments.request_sha256, + &commitments.scope_sha256, + ] { + validate_sha256(digest)?; + } + Ok(()) +} + +fn validate_initial_preflight( + preflight: &ProbeInitialPreflightV0, +) -> Result<(), CompatibilityError> { + if preflight.observed_at_unix_ms <= 0 { + return Err(invalid("native_probe_preflight_time_invalid")); + } + for digest in [ + &preflight.company_identity_commitment_sha256, + &preflight.party_identity_commitment_sha256, + &preflight.company_response_sha256, + &preflight.party_response_sha256, + ] { + validate_sha256(digest)?; + } + Ok(()) +} + +fn validate_identity_snapshots( + snapshots: &[ProbeIdentitySnapshotV0; 4], +) -> Result<(), CompatibilityError> { + let expected = [ + IdentitySnapshotId::B0, + IdentitySnapshotId::B1, + IdentitySnapshotId::B2, + IdentitySnapshotId::B3, + ]; + for (snapshot, expected_id) in snapshots.iter().zip(expected) { + if snapshot.snapshot_id != expected_id || snapshot.observed_at_unix_ms <= 0 { + return Err(invalid("native_probe_identity_snapshot_order_invalid")); + } + for digest in [ + &snapshot.company_identity_commitment_sha256, + &snapshot.party_identity_commitment_sha256, + &snapshot.company_response_sha256, + &snapshot.party_response_sha256, + ] { + validate_sha256(digest)?; + } + } + Ok(()) +} + +fn validate_attempts( + attempts: &[ProbeCandidateAttemptV0; 3], + snapshots: &[ProbeIdentitySnapshotV0; 4], +) -> Result<(), CompatibilityError> { + let expected = [ + CandidateAttemptId::A1, + CandidateAttemptId::A2, + CandidateAttemptId::A3, + ]; + for (index, (attempt, expected_id)) in attempts.iter().zip(expected).enumerate() { + if attempt.attempt_id != expected_id || attempt.observed_at_unix_ms <= 0 { + return Err(invalid("native_probe_attempt_order_invalid")); + } + if let Some(code) = &attempt.safe_reason_code { + validate_safe_code(code)?; + } + let has_hashes = + attempt.encoded_body_sha256.is_some() && attempt.decoded_text_sha256.is_some(); + match attempt.outcome { + CandidateAttemptOutcome::ResponseObserved => { + if !attempt + .http_status + .is_some_and(|status| (200..=299).contains(&status)) + || attempt.application_status == ApplicationStatus::NotApplicable + || attempt.encoding == TextEncoding::Unknown + || attempt.exact_encoded_bytes > BILLS_NATIVE_OUTSTANDINGS_RESPONSE_MAX_BYTES + || !has_hashes + { + return Err(invalid("native_probe_observed_attempt_invalid")); + } + validate_sha256( + attempt + .encoded_body_sha256 + .as_deref() + .ok_or_else(|| invalid("native_probe_body_hash_missing"))?, + )?; + validate_sha256( + attempt + .decoded_text_sha256 + .as_deref() + .ok_or_else(|| invalid("native_probe_decoded_hash_missing"))?, + )?; + let needs_reason = attempt.application_status != ApplicationStatus::Success; + if needs_reason != attempt.safe_reason_code.is_some() { + return Err(invalid("native_probe_application_reason_inconsistent")); + } + } + CandidateAttemptOutcome::HttpRejected => { + if !attempt.http_status.is_some_and(|status| { + (100..=599).contains(&status) && !(200..=299).contains(&status) + }) || attempt.application_status != ApplicationStatus::NotApplicable + || attempt.encoding != TextEncoding::Unknown + || attempt.exact_encoded_bytes != 0 + || attempt.encoded_body_sha256.is_some() + || attempt.decoded_text_sha256.is_some() + || attempt.safe_reason_code.is_none() + { + return Err(invalid("native_probe_http_rejected_attempt_invalid")); + } + } + CandidateAttemptOutcome::TransportFailed => { + if attempt.http_status.is_some() + || attempt.application_status != ApplicationStatus::NotApplicable + || attempt.encoding != TextEncoding::Unknown + || attempt.exact_encoded_bytes != 0 + || attempt.encoded_body_sha256.is_some() + || attempt.decoded_text_sha256.is_some() + || attempt.safe_reason_code.is_none() + { + return Err(invalid("native_probe_transport_failed_attempt_invalid")); + } + } + } + + let expected_bracket = if same_identity(&snapshots[index], &snapshots[index + 1]) { + IdentityBracketState::Unchanged + } else { + IdentityBracketState::Changed + }; + if attempt.bracket_state != expected_bracket { + return Err(invalid("native_probe_attempt_bracket_inconsistent")); + } + } + + for left in attempts { + if left.outcome != CandidateAttemptOutcome::ResponseObserved { + continue; + } + for right in attempts { + if right.outcome == CandidateAttemptOutcome::ResponseObserved + && left.encoded_body_sha256 == right.encoded_body_sha256 + && (left.exact_encoded_bytes != right.exact_encoded_bytes + || left.encoding != right.encoding + || left.decoded_text_sha256 != right.decoded_text_sha256) + { + return Err(invalid("native_probe_identical_body_facts_inconsistent")); + } + } + } + Ok(()) +} + +fn validate_ui_observations( + environment: &ProbeAttestedEnvironmentV0, + preflight: &ProbeInitialPreflightV0, + snapshots: &[ProbeIdentitySnapshotV0; 4], + before: &ProbeUiObservationV0, + after: &ProbeUiObservationV0, + attested_change: UserAttestedUiChange, +) -> Result<(), CompatibilityError> { + if before.position != UiObservationPosition::Before + || after.position != UiObservationPosition::After + || before.observed_at_unix_ms <= 0 + || after.observed_at_unix_ms <= 0 + { + return Err(invalid("native_probe_ui_position_invalid")); + } + for observation in [before, after] { + validate_label(&observation.release)?; + validate_exact_release(&observation.release)?; + if observation.product == ProductFamily::Unknown + || observation.mode == TallyMode::Unknown + || observation.report_id != BILLS_NATIVE_OUTSTANDINGS_REPORT_ID + || !observation.opening_column_visible + || !observation.pending_column_visible + || !observation.due_column_visible + || !observation.overdue_column_visible + { + return Err(invalid("native_probe_ui_observation_invalid")); + } + for digest in [ + &observation.company_identity_commitment_sha256, + &observation.party_identity_commitment_sha256, + &observation.structured_observation_sha256, + &observation.screenshot_sha256, + ] { + validate_sha256(digest)?; + } + } + if before.product != environment.product + || before.release != environment.release + || before.mode != environment.mode + || before.company_identity_commitment_sha256 != preflight.company_identity_commitment_sha256 + || before.party_identity_commitment_sha256 != preflight.party_identity_commitment_sha256 + || before.company_identity_commitment_sha256 + != snapshots[0].company_identity_commitment_sha256 + || before.party_identity_commitment_sha256 != snapshots[0].party_identity_commitment_sha256 + || after.company_identity_commitment_sha256 + != snapshots[3].company_identity_commitment_sha256 + || after.party_identity_commitment_sha256 != snapshots[3].party_identity_commitment_sha256 + { + return Err(invalid("native_probe_ui_identity_or_environment_mismatch")); + } + if before.screenshot_sha256 == after.screenshot_sha256 { + return Err(invalid("native_probe_ui_screenshot_reused")); + } + + let expected_change = if same_ui_observation(before, after) { + UserAttestedUiChange::Unchanged + } else { + UserAttestedUiChange::Changed + }; + if attested_change != expected_change { + return Err(invalid("native_probe_ui_change_attestation_inconsistent")); + } + Ok(()) +} + +fn same_identity(left: &ProbeIdentitySnapshotV0, right: &ProbeIdentitySnapshotV0) -> bool { + left.company_identity_commitment_sha256 == right.company_identity_commitment_sha256 + && left.party_identity_commitment_sha256 == right.party_identity_commitment_sha256 +} + +fn same_ui_observation(left: &ProbeUiObservationV0, right: &ProbeUiObservationV0) -> bool { + left.product == right.product + && left.release == right.release + && left.mode == right.mode + && left.report_id == right.report_id + && left.opening_column_visible == right.opening_column_visible + && left.pending_column_visible == right.pending_column_visible + && left.due_column_visible == right.due_column_visible + && left.overdue_column_visible == right.overdue_column_visible + && left.company_identity_commitment_sha256 == right.company_identity_commitment_sha256 + && left.party_identity_commitment_sha256 == right.party_identity_commitment_sha256 + && left.structured_observation_sha256 == right.structured_observation_sha256 +} + +fn expected_repeatability(attempts: &[ProbeCandidateAttemptV0; 3]) -> ByteRepeatability { + if attempts + .iter() + .any(|attempt| attempt.outcome != CandidateAttemptOutcome::ResponseObserved) + { + return ByteRepeatability::NotEstablished; + } + let first = &attempts[0]; + if attempts[1..].iter().all(|attempt| { + attempt.exact_encoded_bytes == first.exact_encoded_bytes + && attempt.encoding == first.encoding + && attempt.encoded_body_sha256 == first.encoded_body_sha256 + && attempt.decoded_text_sha256 == first.decoded_text_sha256 + }) { + ByteRepeatability::Identical + } else { + ByteRepeatability::Different + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::LiveCompatibilityReceipt; + + const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const COMMIT: &str = "cccccccccccccccccccccccccccccccccccccccc"; + const T0: i64 = 1_800_000_000_000; + + fn snapshot(id: IdentitySnapshotId, time: i64) -> ProbeIdentitySnapshotV0 { + ProbeIdentitySnapshotV0 { + snapshot_id: id, + observed_at_unix_ms: time, + company_identity_commitment_sha256: SHA_A.to_string(), + party_identity_commitment_sha256: SHA_A.to_string(), + company_response_sha256: SHA_A.to_string(), + party_response_sha256: SHA_A.to_string(), + } + } + + fn attempt(id: CandidateAttemptId, time: i64) -> ProbeCandidateAttemptV0 { + ProbeCandidateAttemptV0 { + attempt_id: id, + observed_at_unix_ms: time, + outcome: CandidateAttemptOutcome::ResponseObserved, + http_status: Some(200), + application_status: ApplicationStatus::Success, + encoding: TextEncoding::Utf8, + exact_encoded_bytes: 1024, + encoded_body_sha256: Some(SHA_A.to_string()), + decoded_text_sha256: Some(SHA_A.to_string()), + safe_reason_code: None, + bracket_state: IdentityBracketState::Unchanged, + } + } + + fn ui(position: UiObservationPosition, time: i64, screenshot: &str) -> ProbeUiObservationV0 { + ProbeUiObservationV0 { + position, + observed_at_unix_ms: time, + product: ProductFamily::TallyPrime, + release: "7.1".to_string(), + mode: TallyMode::Education, + report_id: BILLS_NATIVE_OUTSTANDINGS_REPORT_ID.to_string(), + opening_column_visible: true, + pending_column_visible: true, + due_column_visible: true, + overdue_column_visible: true, + company_identity_commitment_sha256: SHA_A.to_string(), + party_identity_commitment_sha256: SHA_A.to_string(), + structured_observation_sha256: SHA_A.to_string(), + screenshot_sha256: screenshot.to_string(), + } + } + + fn receipt() -> BillsNativeOutstandingsProbeReceiptV0 { + BillsNativeOutstandingsProbeReceiptV0 { + schema_version: BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_SCHEMA_VERSION, + observed_at_unix_ms: T0 + 10, + bridge_commit_sha: COMMIT.to_string(), + working_tree_dirty: true, + compatibility_surface_sha256: SHA_A.to_string(), + executable_sha256: SHA_A.to_string(), + cargo_lock_sha256: SHA_A.to_string(), + platform: Platform::Windows, + architecture: Architecture::X86_64, + endpoint: ProbeEndpointV0 { + family: LoopbackFamily::Ipv4, + port: 9000, + canonical_origin: "http://127.0.0.1:9000".to_string(), + }, + attested_environment: ProbeAttestedEnvironmentV0 { + authority: ProbeAttestationAuthority::User, + product: ProductFamily::TallyPrime, + release: "7.1".to_string(), + mode: TallyMode::Education, + locale: LocaleProfile::EnglishIndia, + configured_tdl_count: 0, + no_customer_data: true, + }, + commitments: ProbeCommitmentsV0 { + fixture_id: "education-small-v1".to_string(), + fixture_manifest_sha256: SHA_A.to_string(), + profile_id: BILLS_NATIVE_OUTSTANDINGS_PROFILE_ID.to_string(), + template_sha256: SHA_A.to_string(), + request_sha256: SHA_A.to_string(), + scope_sha256: SHA_A.to_string(), + }, + initial_preflight: ProbeInitialPreflightV0 { + observed_at_unix_ms: T0 + 1, + company_identity_commitment_sha256: SHA_A.to_string(), + party_identity_commitment_sha256: SHA_A.to_string(), + company_response_sha256: SHA_A.to_string(), + party_response_sha256: SHA_A.to_string(), + }, + identity_snapshots: [ + snapshot(IdentitySnapshotId::B0, T0 + 2), + snapshot(IdentitySnapshotId::B1, T0 + 4), + snapshot(IdentitySnapshotId::B2, T0 + 6), + snapshot(IdentitySnapshotId::B3, T0 + 8), + ], + attempts: [ + attempt(CandidateAttemptId::A1, T0 + 3), + attempt(CandidateAttemptId::A2, T0 + 5), + attempt(CandidateAttemptId::A3, T0 + 7), + ], + byte_repeatability: ByteRepeatability::Identical, + ui_before: ui(UiObservationPosition::Before, T0, SHA_A), + ui_after: ui(UiObservationPosition::After, T0 + 9, SHA_B), + user_attested_ui_change: UserAttestedUiChange::Unchanged, + authority: ProbeReceiptAuthorityV0::observation_only(), + receipt_sha256: String::new(), + } + } + + fn reseal( + mut value: BillsNativeOutstandingsProbeReceiptV0, + ) -> Result { + value.receipt_sha256.clear(); + value.seal() + } + + #[test] + fn round_trip_is_bounded_redacted_and_structurally_separate() { + let receipt = receipt().seal().unwrap(); + let bytes = receipt.to_pretty_json().unwrap(); + assert!(bytes.len() < BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_MAX_BYTES); + assert_eq!( + BillsNativeOutstandingsProbeReceiptV0::from_json(&bytes).unwrap(), + receipt + ); + assert!(LiveCompatibilityReceipt::from_json(&bytes).is_err()); + let debug = format!("{receipt:?}"); + for forbidden in [SHA_A, SHA_B, COMMIT, "http://127.0.0.1:9000", "7.1"] { + assert!(!debug.contains(forbidden)); + } + } + + #[test] + fn checksum_tampering_and_unknown_json_fields_fail_closed() { + let receipt = receipt().seal().unwrap(); + let mut tampered = receipt.clone(); + tampered.working_tree_dirty = !tampered.working_tree_dirty; + assert_eq!( + tampered.validate(), + Err(invalid("native_probe_receipt_checksum_mismatch")) + ); + + let mut value = serde_json::to_value(receipt).unwrap(); + value.as_object_mut().unwrap().insert( + "support_claim_eligible".to_string(), + serde_json::Value::Bool(true), + ); + assert!(BillsNativeOutstandingsProbeReceiptV0::from_json( + &serde_json::to_vec(&value).unwrap() + ) + .is_err()); + } + + #[test] + fn ordering_timeline_and_bracket_consistency_are_enforced() { + let mut value = receipt(); + value.identity_snapshots.swap(0, 1); + assert!(reseal(value).is_err()); + + let mut value = receipt(); + value.attempts.swap(0, 1); + assert!(reseal(value).is_err()); + + let mut value = receipt(); + value.identity_snapshots[1].party_identity_commitment_sha256 = SHA_B.to_string(); + assert!(reseal(value.clone()).is_err()); + value.attempts[0].bracket_state = IdentityBracketState::Changed; + value.attempts[1].bracket_state = IdentityBracketState::Changed; + assert!(reseal(value).is_ok()); + + let mut value = receipt(); + value.attempts[1].observed_at_unix_ms = T0; + assert!(reseal(value).is_err()); + } + + #[test] + fn attempt_fact_shapes_and_repeatability_cannot_be_laundered() { + let mut value = receipt(); + value.attempts[1].outcome = CandidateAttemptOutcome::TransportFailed; + value.attempts[1].http_status = None; + value.attempts[1].application_status = ApplicationStatus::NotApplicable; + value.attempts[1].encoding = TextEncoding::Unknown; + value.attempts[1].exact_encoded_bytes = 0; + value.attempts[1].encoded_body_sha256 = None; + value.attempts[1].decoded_text_sha256 = None; + value.attempts[1].safe_reason_code = Some("request_failed".to_string()); + assert!(reseal(value.clone()).is_err()); + value.byte_repeatability = ByteRepeatability::NotEstablished; + assert!(reseal(value).is_ok()); + + let mut value = receipt(); + value.attempts[2].encoded_body_sha256 = Some(SHA_B.to_string()); + value.attempts[2].decoded_text_sha256 = Some(SHA_B.to_string()); + assert!(reseal(value.clone()).is_err()); + value.byte_repeatability = ByteRepeatability::Different; + assert!(reseal(value).is_ok()); + + let mut value = receipt(); + value.attempts[0].exact_encoded_bytes = BILLS_NATIVE_OUTSTANDINGS_RESPONSE_MAX_BYTES + 1; + assert!(reseal(value).is_err()); + } + + #[test] + fn environment_ui_and_authority_cannot_promote_the_observation() { + let mut value = receipt(); + value.attested_environment.no_customer_data = false; + assert!(reseal(value).is_err()); + + let mut value = receipt(); + value.attested_environment.configured_tdl_count = 1; + assert!(reseal(value).is_err()); + + let mut value = receipt(); + value.ui_after.screenshot_sha256 = value.ui_before.screenshot_sha256.clone(); + assert!(reseal(value).is_err()); + + let mut value = receipt(); + value.ui_after.structured_observation_sha256 = SHA_B.to_string(); + assert!(reseal(value.clone()).is_err()); + value.user_attested_ui_change = UserAttestedUiChange::Changed; + assert!(reseal(value).is_ok()); + + for mutate in [ + |authority: &mut ProbeReceiptAuthorityV0| authority.support_claim_eligible = true, + |authority: &mut ProbeReceiptAuthorityV0| authority.response_scope_bound = true, + |authority: &mut ProbeReceiptAuthorityV0| { + authority.accounting_semantics_established = true + }, + |authority: &mut ProbeReceiptAuthorityV0| authority.raw_response_retained = true, + ] { + let mut value = receipt(); + mutate(&mut value.authority); + assert!(reseal(value).is_err()); + } + } + + #[test] + fn endpoint_profile_and_artifact_size_are_strict() { + let mut value = receipt(); + value.endpoint.canonical_origin = "http://localhost:9000".to_string(); + assert!(reseal(value).is_err()); + + let mut value = receipt(); + value.commitments.profile_id = "native_ledger_outstandings_candidate_v1".to_string(); + assert!(reseal(value).is_err()); + + assert_eq!( + BillsNativeOutstandingsProbeReceiptV0::from_json(&vec![ + b' '; + BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_MAX_BYTES + + 1 + ]), + Err(invalid("native_probe_receipt_size_invalid")) + ); + } +} diff --git a/src-tauri/crates/bridge-tally-compatibility/src/lib.rs b/src-tauri/crates/bridge-tally-compatibility/src/lib.rs new file mode 100644 index 0000000..6ddd2a4 --- /dev/null +++ b/src-tauri/crates/bridge-tally-compatibility/src/lib.rs @@ -0,0 +1,2122 @@ +use std::{ + collections::{BTreeMap, BTreeSet}, + fmt::Write as _, + fs, + path::{Component, Path}, +}; + +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +#[cfg(feature = "bills-native-outstandings-probe-receipt")] +pub mod bills_native_outstandings_probe_receipt; + +pub const LIVE_RECEIPT_SCHEMA_VERSION: u16 = 1; +pub const SURFACE_SCHEMA_VERSION: u16 = 1; +pub const SUPPORT_MANIFEST_SCHEMA_VERSION: u16 = 1; +pub const TRUST_MANIFEST_SCHEMA_VERSION: u16 = 1; +pub const ATTESTATION_SCHEMA_VERSION: u16 = 1; +pub const MAX_ARTIFACT_BYTES: usize = 256 * 1024; +pub const MAX_SURFACE_FILES: usize = 96; +pub const MAX_OPERATIONS: usize = 16; +pub const MAX_CLAIMS: usize = 128; +pub const MAX_KEYS: usize = 32; +pub const MAX_MATRIX_MARKDOWN_BYTES: usize = 1024 * 1024; +const MAX_FUTURE_SKEW_MS: i64 = 5 * 60 * 1000; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum CompatibilityError { + #[error("compatibility artifact was invalid ({code})")] + Invalid { code: &'static str }, + #[error("compatibility support gate failed ({code})")] + Gate { code: &'static str }, +} + +fn invalid(code: &'static str) -> CompatibilityError { + CompatibilityError::Invalid { code } +} + +fn gate(code: &'static str) -> CompatibilityError { + CompatibilityError::Gate { code } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProductFamily { + TallyPrime, + TallyErp9, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TallyMode { + Education, + Licensed, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceAuthority { + OfficialDocumentation, + BridgeConfiguration, + BridgeObservation, + EndpointClaim, + UserAttestation, + Inference, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceConfidence { + Observed, + Attested, + Inferred, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ProfileValue { + pub value: T, + pub authority: EvidenceAuthority, + pub confidence: EvidenceConfidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Platform { + Windows, + Macos, + Linux, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Architecture { + X86_64, + Aarch64, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LoopbackFamily { + LocalhostAlias, + Ipv4, + Ipv6, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportProfile { + XmlHttp, + JsonExShadow, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReadProfileId { + XmlCompanyEnumerationV1, + XmlSyntheticFixtureMarkerV1, + XmlLedgerReadV1, + XmlVoucherEmptyRangeV1, + XmlVoucherPopulatedRangeV1, + XmlEducationModeProbeV1, + JsonExSemanticShadowV1, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationOutcome { + Passed, + Failed, + Unsupported, + Inconclusive, + NotAttempted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ApplicationStatus { + Success, + Failure, + Unrecognized, + NotApplicable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TextEncoding { + Utf8, + Utf8Bom, + Utf16Le, + Utf16Be, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OdbcState { + Disabled, + Enabled, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CompanyLoadState { + None, + One, + Multiple, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum LocaleProfile { + EnglishIndia, + Other, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DatasetTier { + SyntheticSmall, + SyntheticMedium, + SyntheticLarge, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SizeBucket { + Zero, + Bytes1To4096, + Bytes4097To65536, + Bytes65537To1048576, + Over1048576, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CountBucket { + Zero, + One, + TwoToFive, + SixToTwenty, + OverTwenty, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OperationEvidence { + pub profile: ReadProfileId, + pub template_sha256: String, + pub outcome: OperationOutcome, + pub application_status: ApplicationStatus, + pub encoding: TextEncoding, + pub response_size: SizeBucket, + pub record_count: CountBucket, + pub safe_reason_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LiveReadAuthority { + pub live_endpoint_response_observed: bool, + pub read_only: bool, + pub writes_attempted: bool, + pub raw_customer_data_retained: bool, + pub responder_authenticity_established: bool, + pub accounting_correctness_established: bool, + pub source_completeness_established: bool, + pub source_atomicity_established: bool, + pub performance_budget_established: bool, + pub tauri_runtime_observed: bool, + pub support_claim_eligible: bool, +} + +impl LiveReadAuthority { + pub fn observation_only() -> Self { + Self { + live_endpoint_response_observed: true, + read_only: true, + writes_attempted: false, + raw_customer_data_retained: false, + responder_authenticity_established: false, + accounting_correctness_established: false, + source_completeness_established: false, + source_atomicity_established: false, + performance_budget_established: false, + tauri_runtime_observed: false, + support_claim_eligible: false, + } + } + + pub fn attempt_only() -> Self { + Self { + live_endpoint_response_observed: false, + ..Self::observation_only() + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LiveCompatibilityReceipt { + pub schema_version: u16, + pub observed_at_unix_ms: i64, + pub bridge_commit_sha: String, + pub working_tree_dirty: bool, + pub compatibility_surface_sha256: String, + pub executable_sha256: String, + pub cargo_lock_sha256: String, + pub platform: Platform, + pub architecture: Architecture, + pub endpoint_family: LoopbackFamily, + pub transport: TransportProfile, + pub product: ProfileValue, + pub release: ProfileValue, + pub mode: ProfileValue, + pub odbc_state: ProfileValue, + pub locale: ProfileValue, + pub dataset_tier: ProfileValue, + pub fixture_manifest_sha256: String, + pub fixture_marker_verified: bool, + pub no_customer_data: ProfileValue, + pub loaded_company_count: CountBucket, + pub operations: Vec, + pub authority: LiveReadAuthority, + pub receipt_sha256: String, +} + +impl LiveCompatibilityReceipt { + pub fn seal(mut self) -> Result { + self.receipt_sha256.clear(); + self.validate_shape(false)?; + self.receipt_sha256 = checksum(b"bridge.tally.live-read-qualification/1\0", &self)?; + self.validate()?; + Ok(self) + } + + pub fn validate(&self) -> Result<(), CompatibilityError> { + self.validate_shape(true)?; + let mut unsigned = self.clone(); + let supplied = std::mem::take(&mut unsigned.receipt_sha256); + let expected = checksum(b"bridge.tally.live-read-qualification/1\0", &unsigned)?; + if supplied != expected { + return Err(invalid("receipt_checksum_mismatch")); + } + Ok(()) + } + + fn validate_shape(&self, require_checksum: bool) -> Result<(), CompatibilityError> { + if self.schema_version != LIVE_RECEIPT_SCHEMA_VERSION { + return Err(invalid("receipt_schema_unsupported")); + } + if self.observed_at_unix_ms <= 0 { + return Err(invalid("receipt_time_invalid")); + } + validate_commit(&self.bridge_commit_sha)?; + for digest in [ + &self.compatibility_surface_sha256, + &self.executable_sha256, + &self.cargo_lock_sha256, + &self.fixture_manifest_sha256, + ] { + validate_sha256(digest)?; + } + if require_checksum { + validate_sha256(&self.receipt_sha256)?; + } else if !self.receipt_sha256.is_empty() { + return Err(invalid("receipt_checksum_must_start_empty")); + } + validate_profile_values( + &self.product, + &self.release, + &self.mode, + &self.odbc_state, + &self.locale, + &self.dataset_tier, + &self.no_customer_data, + )?; + if self.operations.is_empty() || self.operations.len() > MAX_OPERATIONS { + return Err(invalid("operation_count_invalid")); + } + let mut previous = None; + for operation in &self.operations { + if previous.is_some_and(|value| value >= operation.profile) { + return Err(invalid("operations_not_unique_sorted")); + } + previous = Some(operation.profile); + validate_operation(operation)?; + } + self.operations + .iter() + .find(|operation| operation.profile == ReadProfileId::XmlSyntheticFixtureMarkerV1) + .ok_or_else(|| invalid("fixture_marker_operation_missing"))?; + if !self + .operations + .iter() + .any(|operation| operation.profile == ReadProfileId::XmlCompanyEnumerationV1) + { + return Err(invalid("company_enumeration_operation_missing")); + } + if self.fixture_marker_verified { + let required = [ + ReadProfileId::XmlCompanyEnumerationV1, + ReadProfileId::XmlSyntheticFixtureMarkerV1, + ]; + if required.iter().any(|profile| { + !self.operations.iter().any(|operation| { + operation.profile == *profile + && operation.outcome == OperationOutcome::Passed + && operation.application_status == ApplicationStatus::Success + }) + }) { + return Err(invalid("fixture_marker_contract_not_passed")); + } + } + if self.authority != LiveReadAuthority::observation_only() + && self.authority != LiveReadAuthority::attempt_only() + { + return Err(invalid("receipt_authority_invalid")); + } + if !self.fixture_marker_verified { + for operation in &self.operations { + if matches!( + operation.profile, + ReadProfileId::XmlLedgerReadV1 + | ReadProfileId::XmlVoucherEmptyRangeV1 + | ReadProfileId::XmlVoucherPopulatedRangeV1 + ) && operation.outcome != OperationOutcome::NotAttempted + { + return Err(invalid("company_read_without_fixture_marker")); + } + } + } + Ok(()) + } + + pub fn to_pretty_json(&self) -> Result, CompatibilityError> { + self.validate()?; + let bytes = serde_json::to_vec_pretty(self).map_err(|_| invalid("serialization_failed"))?; + if bytes.len() > MAX_ARTIFACT_BYTES { + return Err(invalid("artifact_too_large")); + } + Ok(bytes) + } + + pub fn from_json(bytes: &[u8]) -> Result { + let receipt: Self = parse_bounded_json(bytes)?; + receipt.validate()?; + Ok(receipt) + } +} + +fn validate_profile_values( + product: &ProfileValue, + release: &ProfileValue, + mode: &ProfileValue, + odbc_state: &ProfileValue, + locale: &ProfileValue, + dataset_tier: &ProfileValue, + no_customer_data: &ProfileValue, +) -> Result<(), CompatibilityError> { + let product_ok = match product.value { + ProductFamily::Unknown => { + product.authority == EvidenceAuthority::Unknown + && product.confidence == EvidenceConfidence::Unknown + } + _ => { + product.authority == EvidenceAuthority::UserAttestation + && product.confidence == EvidenceConfidence::Attested + } + }; + if !product_ok { + return Err(invalid("product_authority_invalid")); + } + if release.value == "unknown" { + if release.authority != EvidenceAuthority::Unknown + || release.confidence != EvidenceConfidence::Unknown + { + return Err(invalid("release_authority_invalid")); + } + } else { + validate_label(&release.value)?; + if release.authority != EvidenceAuthority::UserAttestation + || release.confidence != EvidenceConfidence::Attested + { + return Err(invalid("release_authority_invalid")); + } + } + let mode_ok = match mode.value { + TallyMode::Unknown => { + mode.authority == EvidenceAuthority::Unknown + && mode.confidence == EvidenceConfidence::Unknown + } + _ => matches!( + (mode.authority, mode.confidence), + ( + EvidenceAuthority::UserAttestation, + EvidenceConfidence::Attested + ) | ( + EvidenceAuthority::EndpointClaim, + EvidenceConfidence::Observed + ) + ), + }; + if !mode_ok { + return Err(invalid("mode_authority_invalid")); + } + for (is_unknown, authority, confidence, code) in [ + ( + odbc_state.value == OdbcState::Unknown, + odbc_state.authority, + odbc_state.confidence, + "odbc_authority_invalid", + ), + ( + locale.value == LocaleProfile::Unknown, + locale.authority, + locale.confidence, + "locale_authority_invalid", + ), + ] { + let valid = if is_unknown { + authority == EvidenceAuthority::Unknown && confidence == EvidenceConfidence::Unknown + } else { + authority == EvidenceAuthority::UserAttestation + && confidence == EvidenceConfidence::Attested + }; + if !valid { + return Err(invalid(code)); + } + } + let dataset_valid = if dataset_tier.value == DatasetTier::Unknown { + dataset_tier.authority == EvidenceAuthority::Unknown + && dataset_tier.confidence == EvidenceConfidence::Unknown + } else { + dataset_tier.authority == EvidenceAuthority::BridgeConfiguration + && dataset_tier.confidence == EvidenceConfidence::Attested + }; + if !dataset_valid { + return Err(invalid("dataset_authority_invalid")); + } + if no_customer_data.authority != EvidenceAuthority::UserAttestation + || no_customer_data.confidence != EvidenceConfidence::Attested + { + return Err(invalid("customer_data_authority_invalid")); + } + Ok(()) +} + +fn validate_operation(operation: &OperationEvidence) -> Result<(), CompatibilityError> { + validate_sha256(&operation.template_sha256)?; + match operation.outcome { + OperationOutcome::Passed => { + if operation.safe_reason_code.is_some() + || operation.application_status != ApplicationStatus::Success + { + return Err(invalid("passed_operation_invalid")); + } + } + OperationOutcome::NotAttempted => { + if operation.application_status != ApplicationStatus::NotApplicable + || operation.encoding != TextEncoding::Unknown + || operation.response_size != SizeBucket::Zero + || operation.record_count != CountBucket::Unknown + { + return Err(invalid("not_attempted_operation_invalid")); + } + let reason = operation + .safe_reason_code + .as_deref() + .ok_or_else(|| invalid("operation_reason_missing"))?; + validate_safe_code(reason)?; + } + OperationOutcome::Unsupported => { + return Err(invalid("unsupported_operation_signature_unavailable")); + } + OperationOutcome::Failed | OperationOutcome::Inconclusive => { + let reason = operation + .safe_reason_code + .as_deref() + .ok_or_else(|| invalid("operation_reason_missing"))?; + validate_safe_code(reason)?; + } + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SurfaceFile { + pub path: String, + pub sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CompatibilitySurfaceManifest { + pub schema_version: u16, + pub files: Vec, + pub manifest_sha256: String, +} + +impl CompatibilitySurfaceManifest { + pub fn seal(mut self) -> Result { + self.manifest_sha256.clear(); + self.validate_shape(false)?; + self.manifest_sha256 = checksum(b"bridge.tally.compatibility-surface/1\0", &self)?; + self.validate()?; + Ok(self) + } + + pub fn validate(&self) -> Result<(), CompatibilityError> { + self.validate_shape(true)?; + let mut unsigned = self.clone(); + let supplied = std::mem::take(&mut unsigned.manifest_sha256); + let expected = checksum(b"bridge.tally.compatibility-surface/1\0", &unsigned)?; + if supplied != expected { + return Err(invalid("surface_checksum_mismatch")); + } + Ok(()) + } + + fn validate_shape(&self, require_checksum: bool) -> Result<(), CompatibilityError> { + if self.schema_version != SURFACE_SCHEMA_VERSION { + return Err(invalid("surface_schema_unsupported")); + } + if self.files.is_empty() || self.files.len() > MAX_SURFACE_FILES { + return Err(invalid("surface_file_count_invalid")); + } + let mut previous: Option<&str> = None; + for file in &self.files { + validate_relative_path(&file.path)?; + validate_sha256(&file.sha256)?; + if previous.is_some_and(|value| value >= file.path.as_str()) { + return Err(invalid("surface_files_not_unique_sorted")); + } + previous = Some(&file.path); + } + if require_checksum { + validate_sha256(&self.manifest_sha256)?; + } else if !self.manifest_sha256.is_empty() { + return Err(invalid("surface_checksum_must_start_empty")); + } + Ok(()) + } + + pub fn validate_files(&self, repository_root: &Path) -> Result<(), CompatibilityError> { + self.validate()?; + for file in &self.files { + let bytes = fs::read(repository_root.join(&file.path)) + .map_err(|_| invalid("surface_file_unavailable"))?; + if sha256_bytes(&bytes) != file.sha256 { + return Err(invalid("surface_file_changed")); + } + } + Ok(()) + } + + pub fn from_json(bytes: &[u8]) -> Result { + let value: Self = parse_bounded_json(bytes)?; + value.validate()?; + Ok(value) + } + + pub fn to_pretty_json(&self) -> Result, CompatibilityError> { + self.validate()?; + let bytes = serde_json::to_vec_pretty(self).map_err(|_| invalid("serialization_failed"))?; + if bytes.len() > MAX_ARTIFACT_BYTES { + return Err(invalid("artifact_too_large")); + } + Ok(bytes) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrustedEvidenceKey { + pub key_id: String, + pub public_key_hex: String, + pub valid_from_unix_ms: i64, + pub valid_until_unix_ms: i64, + pub revoked_at_unix_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct TrustedEvidenceKeys { + pub schema_version: u16, + pub keys: Vec, +} + +impl TrustedEvidenceKeys { + pub fn validate(&self) -> Result<(), CompatibilityError> { + if self.schema_version != TRUST_MANIFEST_SCHEMA_VERSION || self.keys.len() > MAX_KEYS { + return Err(invalid("trust_manifest_invalid")); + } + let mut ids = BTreeSet::new(); + for key in &self.keys { + validate_slug(&key.key_id)?; + let bytes = + hex::decode(&key.public_key_hex).map_err(|_| invalid("public_key_invalid"))?; + let _: [u8; 32] = bytes + .try_into() + .map_err(|_| invalid("public_key_invalid"))?; + if key.valid_from_unix_ms <= 0 || key.valid_until_unix_ms <= key.valid_from_unix_ms { + return Err(invalid("key_validity_invalid")); + } + if key + .revoked_at_unix_ms + .is_some_and(|value| value < key.valid_from_unix_ms) + { + return Err(invalid("key_revocation_invalid")); + } + if !ids.insert(&key.key_id) { + return Err(invalid("duplicate_key_id")); + } + } + Ok(()) + } + + pub fn from_json(bytes: &[u8]) -> Result { + let value: Self = parse_bounded_json(bytes)?; + value.validate()?; + Ok(value) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ReviewedEvidenceAttestation { + pub schema_version: u16, + pub evidence_id: String, + pub receipt_sha256: String, + pub compatibility_surface_sha256: String, + pub reviewed_at_unix_ms: i64, + pub expires_at_unix_ms: i64, + pub review_commit_sha: String, + pub review_url: String, + pub key_id: String, + pub signature_hex: String, +} + +impl ReviewedEvidenceAttestation { + fn signing_bytes(&self) -> Result, CompatibilityError> { + #[derive(Serialize)] + struct Signed<'a> { + domain: &'static str, + schema_version: u16, + evidence_id: &'a str, + receipt_sha256: &'a str, + compatibility_surface_sha256: &'a str, + reviewed_at_unix_ms: i64, + expires_at_unix_ms: i64, + review_commit_sha: &'a str, + review_url: &'a str, + key_id: &'a str, + } + serde_json::to_vec(&Signed { + domain: "bridge.tally.reviewed-live-evidence/1", + schema_version: self.schema_version, + evidence_id: &self.evidence_id, + receipt_sha256: &self.receipt_sha256, + compatibility_surface_sha256: &self.compatibility_surface_sha256, + reviewed_at_unix_ms: self.reviewed_at_unix_ms, + expires_at_unix_ms: self.expires_at_unix_ms, + review_commit_sha: &self.review_commit_sha, + review_url: &self.review_url, + key_id: &self.key_id, + }) + .map_err(|_| invalid("attestation_serialization_failed")) + } + + pub fn validate_shape(&self) -> Result<(), CompatibilityError> { + if self.schema_version != ATTESTATION_SCHEMA_VERSION { + return Err(invalid("attestation_schema_unsupported")); + } + validate_slug(&self.evidence_id)?; + validate_slug(&self.key_id)?; + validate_sha256(&self.receipt_sha256)?; + validate_sha256(&self.compatibility_surface_sha256)?; + validate_commit(&self.review_commit_sha)?; + if self.reviewed_at_unix_ms <= 0 || self.expires_at_unix_ms <= self.reviewed_at_unix_ms { + return Err(invalid("attestation_time_invalid")); + } + if !self + .review_url + .starts_with("https://github.com/lamemustafa/bridge/") + || self.review_url.len() > 256 + || self.review_url.chars().any(char::is_control) + { + return Err(invalid("review_url_invalid")); + } + let signature = hex::decode(&self.signature_hex) + .map_err(|_| invalid("attestation_signature_invalid"))?; + let _: [u8; 64] = signature + .try_into() + .map_err(|_| invalid("attestation_signature_invalid"))?; + Ok(()) + } + + pub fn verify( + &self, + trust: &TrustedEvidenceKeys, + now_unix_ms: i64, + ) -> Result<(), CompatibilityError> { + self.validate_shape()?; + trust.validate()?; + let key = trust + .keys + .iter() + .find(|key| key.key_id == self.key_id) + .ok_or_else(|| gate("attestation_key_untrusted"))?; + if now_unix_ms < key.valid_from_unix_ms + || now_unix_ms > key.valid_until_unix_ms + || key + .revoked_at_unix_ms + .is_some_and(|revoked| now_unix_ms >= revoked) + { + return Err(gate("attestation_key_inactive")); + } + if self.reviewed_at_unix_ms < key.valid_from_unix_ms + || self.reviewed_at_unix_ms > key.valid_until_unix_ms + || key + .revoked_at_unix_ms + .is_some_and(|revoked| self.reviewed_at_unix_ms >= revoked) + { + return Err(gate("attestation_review_key_inactive")); + } + if self.reviewed_at_unix_ms > now_unix_ms.saturating_add(MAX_FUTURE_SKEW_MS) + || now_unix_ms > self.expires_at_unix_ms + { + return Err(gate("attestation_not_current")); + } + let public: [u8; 32] = hex::decode(&key.public_key_hex) + .map_err(|_| invalid("public_key_invalid"))? + .try_into() + .map_err(|_| invalid("public_key_invalid"))?; + let verifier = + VerifyingKey::from_bytes(&public).map_err(|_| invalid("public_key_invalid"))?; + let signature: [u8; 64] = hex::decode(&self.signature_hex) + .map_err(|_| invalid("attestation_signature_invalid"))? + .try_into() + .map_err(|_| invalid("attestation_signature_invalid"))?; + verifier + .verify(&self.signing_bytes()?, &Signature::from_bytes(&signature)) + .map_err(|_| gate("attestation_signature_unverified")) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ClaimLevel { + Unknown, + Observed, + Supported, + Unsupported, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SupportClaim { + pub claim_id: String, + pub level: ClaimLevel, + pub promotion_eligible: bool, + pub product: ProductFamily, + pub release: String, + pub mode: TallyMode, + pub platform: Platform, + pub architecture: Architecture, + pub transport: TransportProfile, + pub endpoint_family: LoopbackFamily, + pub odbc_state: OdbcState, + pub company_state: CompanyLoadState, + pub locale: LocaleProfile, + pub encoding: TextEncoding, + pub dataset_tier: DatasetTier, + pub fixture_manifest_sha256: Option, + pub required_profiles: Vec, + pub max_evidence_age_days: u16, + pub evidence_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SupportClaimsManifest { + pub schema_version: u16, + pub bridge_commit_sha: String, + pub compatibility_surface_sha256: String, + pub claims: Vec, +} + +impl SupportClaimsManifest { + pub fn validate(&self) -> Result<(), CompatibilityError> { + if self.schema_version != SUPPORT_MANIFEST_SCHEMA_VERSION + || self.claims.is_empty() + || self.claims.len() > MAX_CLAIMS + { + return Err(invalid("support_manifest_invalid")); + } + validate_commit(&self.bridge_commit_sha)?; + validate_sha256(&self.compatibility_surface_sha256)?; + let mut ids = BTreeSet::new(); + let mut previous_id: Option<&str> = None; + for claim in &self.claims { + validate_claim(claim)?; + if previous_id.is_some_and(|value| value >= claim.claim_id.as_str()) { + return Err(invalid("claims_not_unique_sorted")); + } + previous_id = Some(&claim.claim_id); + if !ids.insert(&claim.claim_id) { + return Err(invalid("duplicate_claim_id")); + } + } + Ok(()) + } + + pub fn from_json(bytes: &[u8]) -> Result { + let value: Self = parse_bounded_json(bytes)?; + value.validate()?; + Ok(value) + } +} + +fn validate_claim(claim: &SupportClaim) -> Result<(), CompatibilityError> { + validate_slug(&claim.claim_id)?; + validate_label(&claim.release)?; + if let Some(fixture) = &claim.fixture_manifest_sha256 { + validate_sha256(fixture)?; + } + if !(1..=365).contains(&claim.max_evidence_age_days) { + return Err(invalid("claim_age_invalid")); + } + let mut previous = None; + for profile in &claim.required_profiles { + if previous.is_some_and(|value| value >= *profile) { + return Err(invalid("claim_profiles_not_unique_sorted")); + } + previous = Some(*profile); + } + match claim.level { + ClaimLevel::Unknown => { + if claim.evidence_id.is_some() { + return Err(invalid("unknown_claim_has_evidence")); + } + } + ClaimLevel::Observed | ClaimLevel::Supported | ClaimLevel::Unsupported => { + if claim.product == ProductFamily::Unknown + || claim.mode == TallyMode::Unknown + || claim.release == "unknown" + || claim.odbc_state == OdbcState::Unknown + || claim.company_state == CompanyLoadState::Unknown + || claim.locale == LocaleProfile::Unknown + || claim.encoding == TextEncoding::Unknown + || claim.dataset_tier == DatasetTier::Unknown + || claim.fixture_manifest_sha256.is_none() + || claim.required_profiles.is_empty() + { + return Err(invalid("positive_claim_scope_incomplete")); + } + validate_exact_release(&claim.release)?; + validate_sha256( + claim + .fixture_manifest_sha256 + .as_deref() + .ok_or_else(|| invalid("positive_claim_fixture_missing"))?, + )?; + validate_slug( + claim + .evidence_id + .as_deref() + .ok_or_else(|| invalid("positive_claim_missing_evidence"))?, + )?; + } + } + match claim.level { + ClaimLevel::Observed | ClaimLevel::Supported if !claim.promotion_eligible => { + return Err(invalid("positive_claim_not_promotion_eligible")); + } + ClaimLevel::Unsupported if claim.promotion_eligible => { + return Err(invalid("unsupported_claim_promotion_eligible")); + } + _ => {} + } + match claim.transport { + TransportProfile::XmlHttp => { + if claim + .required_profiles + .contains(&ReadProfileId::JsonExSemanticShadowV1) + { + return Err(invalid("xml_claim_contains_jsonex_profile")); + } + } + TransportProfile::JsonExShadow => { + if claim + .required_profiles + .iter() + .any(|profile| *profile != ReadProfileId::JsonExSemanticShadowV1) + { + return Err(invalid("jsonex_claim_contains_xml_profile")); + } + if claim.promotion_eligible || claim.level != ClaimLevel::Unknown { + return Err(invalid("jsonex_claim_not_qualifiable")); + } + } + } + if claim.level == ClaimLevel::Supported { + if claim.transport != TransportProfile::XmlHttp { + return Err(invalid("supported_claim_transport_invalid")); + } + let required: BTreeSet<_> = [ + ReadProfileId::XmlCompanyEnumerationV1, + ReadProfileId::XmlSyntheticFixtureMarkerV1, + ReadProfileId::XmlLedgerReadV1, + ReadProfileId::XmlVoucherEmptyRangeV1, + ReadProfileId::XmlVoucherPopulatedRangeV1, + ] + .into_iter() + .collect(); + if !required.is_subset(&claim.required_profiles.iter().copied().collect()) { + return Err(invalid("supported_claim_missing_core_profiles")); + } + } + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GateReport { + pub unknown_claims: usize, + pub evidenced_claims: usize, +} + +pub fn enforce_support_gate( + manifest: &SupportClaimsManifest, + surface: &CompatibilitySurfaceManifest, + trust: &TrustedEvidenceKeys, + receipts: &[LiveCompatibilityReceipt], + attestations: &[ReviewedEvidenceAttestation], + repository_root: &Path, + now_unix_ms: i64, +) -> Result { + manifest.validate()?; + surface.validate_files(repository_root)?; + trust.validate()?; + if manifest.compatibility_surface_sha256 != surface.manifest_sha256 { + return Err(gate("support_surface_mismatch")); + } + let receipt_by_checksum = receipts + .iter() + .map(|receipt| { + receipt.validate()?; + Ok((receipt.receipt_sha256.as_str(), receipt)) + }) + .collect::, CompatibilityError>>()?; + let attestation_by_id = attestations + .iter() + .map(|attestation| { + attestation.validate_shape()?; + Ok((attestation.evidence_id.as_str(), attestation)) + }) + .collect::, CompatibilityError>>()?; + if receipt_by_checksum.len() != receipts.len() || attestation_by_id.len() != attestations.len() + { + return Err(gate("duplicate_evidence")); + } + + let mut report = GateReport { + unknown_claims: 0, + evidenced_claims: 0, + }; + for claim in &manifest.claims { + if claim.level == ClaimLevel::Unknown { + report.unknown_claims += 1; + continue; + } + report.evidenced_claims += 1; + let evidence_id = claim + .evidence_id + .as_deref() + .ok_or_else(|| gate("evidence_missing"))?; + let attestation = attestation_by_id + .get(evidence_id) + .copied() + .ok_or_else(|| gate("attestation_missing"))?; + attestation.verify(trust, now_unix_ms)?; + if attestation.compatibility_surface_sha256 != surface.manifest_sha256 + || attestation.review_commit_sha != manifest.bridge_commit_sha + { + return Err(gate("attestation_scope_mismatch")); + } + let max_age_ms = i64::from(claim.max_evidence_age_days) * 24 * 60 * 60 * 1000; + if now_unix_ms.saturating_sub(attestation.reviewed_at_unix_ms) > max_age_ms { + return Err(gate("reviewed_evidence_stale")); + } + let receipt = receipt_by_checksum + .get(attestation.receipt_sha256.as_str()) + .copied() + .ok_or_else(|| gate("receipt_missing"))?; + validate_receipt_for_claim(receipt, claim, manifest, surface, attestation, now_unix_ms)?; + } + Ok(report) +} + +fn validate_receipt_for_claim( + receipt: &LiveCompatibilityReceipt, + claim: &SupportClaim, + manifest: &SupportClaimsManifest, + surface: &CompatibilitySurfaceManifest, + attestation: &ReviewedEvidenceAttestation, + now_unix_ms: i64, +) -> Result<(), CompatibilityError> { + let max_age_ms = i64::from(claim.max_evidence_age_days) * 24 * 60 * 60 * 1000; + if receipt.working_tree_dirty + || receipt.bridge_commit_sha != manifest.bridge_commit_sha + || receipt.compatibility_surface_sha256 != surface.manifest_sha256 + || receipt.receipt_sha256 != attestation.receipt_sha256 + || receipt.observed_at_unix_ms > attestation.reviewed_at_unix_ms + || receipt.product.value != claim.product + || receipt.release.value != claim.release + || receipt.mode.value != claim.mode + || receipt.platform != claim.platform + || receipt.architecture != claim.architecture + || receipt.transport != claim.transport + || receipt.endpoint_family != claim.endpoint_family + || receipt.odbc_state.value != claim.odbc_state + || company_state(receipt.loaded_company_count) != claim.company_state + || receipt.locale.value != claim.locale + || receipt.dataset_tier.value != claim.dataset_tier + || Some(receipt.fixture_manifest_sha256.as_str()) + != claim.fixture_manifest_sha256.as_deref() + || !receipt.operations.iter().all(|operation| { + operation.outcome == OperationOutcome::NotAttempted + || operation.encoding == claim.encoding + }) + || receipt.observed_at_unix_ms > now_unix_ms.saturating_add(MAX_FUTURE_SKEW_MS) + || now_unix_ms.saturating_sub(receipt.observed_at_unix_ms) > max_age_ms + || attestation + .reviewed_at_unix_ms + .saturating_sub(receipt.observed_at_unix_ms) + > max_age_ms + || !receipt.no_customer_data.value + || !receipt.authority.live_endpoint_response_observed + { + return Err(gate("receipt_claim_scope_mismatch")); + } + if receipt.product.authority != EvidenceAuthority::UserAttestation + || receipt.product.confidence != EvidenceConfidence::Attested + || receipt.release.authority != EvidenceAuthority::UserAttestation + || receipt.release.confidence != EvidenceConfidence::Attested + { + return Err(gate("receipt_profile_authority_insufficient")); + } + if receipt.dataset_tier.authority != EvidenceAuthority::BridgeConfiguration + || receipt.dataset_tier.confidence != EvidenceConfidence::Attested + || receipt.no_customer_data.authority != EvidenceAuthority::UserAttestation + || receipt.no_customer_data.confidence != EvidenceConfidence::Attested + { + return Err(gate("receipt_dataset_authority_insufficient")); + } + let operations: BTreeMap<_, _> = receipt + .operations + .iter() + .map(|operation| (operation.profile, operation)) + .collect(); + match claim.level { + ClaimLevel::Observed | ClaimLevel::Supported => { + if !receipt.fixture_marker_verified { + return Err(gate("fixture_marker_contract_not_verified")); + } + for profile in &claim.required_profiles { + let operation = operations + .get(profile) + .ok_or_else(|| gate("required_operation_missing"))?; + if operation.outcome != OperationOutcome::Passed + || operation.application_status != ApplicationStatus::Success + { + return Err(gate("required_operation_not_passed")); + } + } + } + ClaimLevel::Unsupported => { + if !receipt.fixture_marker_verified { + return Err(gate("fixture_marker_contract_not_verified")); + } + return Err(gate("unsupported_claim_signature_unavailable")); + } + ClaimLevel::Unknown => return Err(gate("unknown_claim_reached_evidence_validation")), + } + Ok(()) +} + +fn company_state(count: CountBucket) -> CompanyLoadState { + match count { + CountBucket::Zero => CompanyLoadState::None, + CountBucket::One => CompanyLoadState::One, + CountBucket::TwoToFive | CountBucket::SixToTwenty | CountBucket::OverTwenty => { + CompanyLoadState::Multiple + } + CountBucket::Unknown => CompanyLoadState::Unknown, + } +} + +pub fn parse_artifact(bytes: &[u8]) -> Result { + parse_bounded_json(bytes) +} + +fn parse_bounded_json(bytes: &[u8]) -> Result { + if bytes.is_empty() || bytes.len() > MAX_ARTIFACT_BYTES { + return Err(invalid("artifact_size_invalid")); + } + serde_json::from_slice(bytes).map_err(|_| invalid("artifact_json_invalid")) +} + +fn checksum(domain: &[u8], value: &T) -> Result { + let bytes = serde_json::to_vec(value).map_err(|_| invalid("serialization_failed"))?; + let mut digest = Sha256::new(); + digest.update(domain); + digest.update(bytes); + Ok(hex::encode(digest.finalize())) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn validate_sha256(value: &str) -> Result<(), CompatibilityError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid("sha256_invalid")); + } + Ok(()) +} + +fn validate_commit(value: &str) -> Result<(), CompatibilityError> { + if !matches!(value.len(), 40 | 64) + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid("commit_invalid")); + } + Ok(()) +} + +fn validate_slug(value: &str) -> Result<(), CompatibilityError> { + if value.is_empty() + || value.len() > 96 + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) + { + return Err(invalid("slug_invalid")); + } + Ok(()) +} + +fn validate_safe_code(value: &str) -> Result<(), CompatibilityError> { + validate_slug(value) +} + +fn validate_label(value: &str) -> Result<(), CompatibilityError> { + if value.is_empty() + || value.len() > 64 + || value.trim() != value + || value.chars().any(char::is_control) + || !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_' | b' ')) + { + return Err(invalid("label_invalid")); + } + Ok(()) +} + +fn validate_exact_release(value: &str) -> Result<(), CompatibilityError> { + let normalized = value.to_ascii_lowercase(); + if matches!(normalized.as_str(), "unknown" | "latest") + || normalized.contains('*') + || normalized.ends_with(".x") + { + return Err(invalid("exact_release_required")); + } + Ok(()) +} + +fn validate_relative_path(value: &str) -> Result<(), CompatibilityError> { + if value.is_empty() || value.len() > 240 || value.contains('\\') { + return Err(invalid("surface_path_invalid")); + } + let path = Path::new(value); + if path.is_absolute() + || path + .components() + .any(|part| !matches!(part, Component::Normal(_))) + { + return Err(invalid("surface_path_invalid")); + } + Ok(()) +} + +pub fn sha256_file(path: &Path) -> Result { + let bytes = fs::read(path).map_err(|_| invalid("file_unavailable"))?; + Ok(sha256_bytes(&bytes)) +} + +pub fn now_unix_ms() -> Result { + let elapsed = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_err(|_| invalid("system_clock_invalid"))?; + i64::try_from(elapsed.as_millis()).map_err(|_| invalid("system_clock_invalid")) +} + +pub fn safe_error_code(error: &CompatibilityError) -> &'static str { + match error { + CompatibilityError::Invalid { code } | CompatibilityError::Gate { code } => code, + } +} + +pub fn format_gate_success(report: &GateReport) -> String { + let mut output = String::new(); + write!( + &mut output, + "compatibility_gate_passed:unknown_claims={}:evidenced_claims={}", + report.unknown_claims, report.evidenced_claims + ) + .expect("writing to a String cannot fail"); + output +} + +pub fn render_claim_matrix(manifest: &SupportClaimsManifest) -> Result { + manifest.validate()?; + let mut output = String::from( + "\n\ +| Exact cell | Product / release / mode | Host | Transport / loopback / ODBC | Data profile | Claim | Promotion eligible | Evidence |\n\ +| --- | --- | --- | --- | --- | --- | --- | --- |\n", + ); + for claim in &manifest.claims { + let evidence = claim.evidence_id.as_deref().unwrap_or("missing"); + writeln!( + &mut output, + "| `{}` | `{}` / `{}` / `{}` | `{}` / `{}` | `{}` / `{}` / `{}` | `{}` / `{}` / `{}` / `{}` | `{}` | `{}` | `{}` |", + claim.claim_id, + enum_label(&claim.product)?, + claim.release, + enum_label(&claim.mode)?, + enum_label(&claim.platform)?, + enum_label(&claim.architecture)?, + enum_label(&claim.transport)?, + enum_label(&claim.endpoint_family)?, + enum_label(&claim.odbc_state)?, + enum_label(&claim.company_state)?, + enum_label(&claim.locale)?, + enum_label(&claim.encoding)?, + enum_label(&claim.dataset_tier)?, + enum_label(&claim.level)?, + claim.promotion_eligible, + evidence, + ) + .map_err(|_| invalid("matrix_render_failed"))?; + } + output.push_str(""); + Ok(output) +} + +pub fn verify_claim_matrix_markdown( + manifest: &SupportClaimsManifest, + markdown: &[u8], +) -> Result<(), CompatibilityError> { + if markdown.is_empty() || markdown.len() > MAX_MATRIX_MARKDOWN_BYTES { + return Err(invalid("matrix_markdown_size_invalid")); + } + let text = std::str::from_utf8(markdown).map_err(|_| invalid("matrix_markdown_invalid"))?; + let expected = render_claim_matrix(manifest)?; + if text + .matches("") + .count() + != 1 + || text + .matches("") + .count() + != 1 + || !text.contains(&expected) + { + return Err(invalid("matrix_markdown_drift")); + } + Ok(()) +} + +fn enum_label(value: &T) -> Result { + let serialized = serde_json::to_string(value).map_err(|_| invalid("serialization_failed"))?; + serialized + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .map(str::to_owned) + .ok_or_else(|| invalid("matrix_render_failed")) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + + const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const COMMIT: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const NOW: i64 = 1_800_000_000_000; + + fn profile(value: T) -> ProfileValue { + ProfileValue { + value, + authority: EvidenceAuthority::UserAttestation, + confidence: EvidenceConfidence::Attested, + } + } + + fn configured(value: T) -> ProfileValue { + ProfileValue { + value, + authority: EvidenceAuthority::BridgeConfiguration, + confidence: EvidenceConfidence::Attested, + } + } + + fn operation(profile: ReadProfileId) -> OperationEvidence { + OperationEvidence { + profile, + template_sha256: SHA.to_string(), + outcome: OperationOutcome::Passed, + application_status: ApplicationStatus::Success, + encoding: TextEncoding::Utf8, + response_size: SizeBucket::Bytes1To4096, + record_count: CountBucket::One, + safe_reason_code: None, + } + } + + fn receipt(surface: &str) -> LiveCompatibilityReceipt { + LiveCompatibilityReceipt { + schema_version: LIVE_RECEIPT_SCHEMA_VERSION, + observed_at_unix_ms: NOW - 10_000, + bridge_commit_sha: COMMIT.to_string(), + working_tree_dirty: false, + compatibility_surface_sha256: surface.to_string(), + executable_sha256: SHA.to_string(), + cargo_lock_sha256: SHA.to_string(), + platform: Platform::Windows, + architecture: Architecture::X86_64, + endpoint_family: LoopbackFamily::Ipv4, + transport: TransportProfile::XmlHttp, + product: profile(ProductFamily::TallyPrime), + release: profile("7.1".to_string()), + mode: profile(TallyMode::Education), + odbc_state: profile(OdbcState::Disabled), + locale: profile(LocaleProfile::EnglishIndia), + dataset_tier: configured(DatasetTier::SyntheticSmall), + fixture_manifest_sha256: SHA.to_string(), + fixture_marker_verified: true, + no_customer_data: profile(true), + loaded_company_count: CountBucket::One, + operations: [ + ReadProfileId::XmlCompanyEnumerationV1, + ReadProfileId::XmlSyntheticFixtureMarkerV1, + ReadProfileId::XmlLedgerReadV1, + ReadProfileId::XmlVoucherEmptyRangeV1, + ReadProfileId::XmlVoucherPopulatedRangeV1, + ] + .into_iter() + .map(operation) + .collect(), + authority: LiveReadAuthority::observation_only(), + receipt_sha256: String::new(), + } + .seal() + .unwrap() + } + + fn trust(signing: &SigningKey) -> TrustedEvidenceKeys { + TrustedEvidenceKeys { + schema_version: TRUST_MANIFEST_SCHEMA_VERSION, + keys: vec![TrustedEvidenceKey { + key_id: "release-evidence-1".to_string(), + public_key_hex: hex::encode(signing.verifying_key().to_bytes()), + valid_from_unix_ms: NOW - 100_000, + valid_until_unix_ms: NOW + 100_000, + revoked_at_unix_ms: None, + }], + } + } + + fn attestation( + receipt: &LiveCompatibilityReceipt, + surface: &CompatibilitySurfaceManifest, + signing: &SigningKey, + ) -> ReviewedEvidenceAttestation { + let mut value = ReviewedEvidenceAttestation { + schema_version: ATTESTATION_SCHEMA_VERSION, + evidence_id: "evidence-1".to_string(), + receipt_sha256: receipt.receipt_sha256.clone(), + compatibility_surface_sha256: surface.manifest_sha256.clone(), + reviewed_at_unix_ms: NOW - 1_000, + expires_at_unix_ms: NOW + 50_000, + review_commit_sha: COMMIT.to_string(), + review_url: "https://github.com/lamemustafa/bridge/pull/1".to_string(), + key_id: "release-evidence-1".to_string(), + signature_hex: "00".repeat(64), + }; + value.signature_hex = hex::encode(signing.sign(&value.signing_bytes().unwrap()).to_bytes()); + value + } + + fn unsupported_manifest( + surface: &CompatibilitySurfaceManifest, + required_profile: ReadProfileId, + ) -> SupportClaimsManifest { + SupportClaimsManifest { + schema_version: SUPPORT_MANIFEST_SCHEMA_VERSION, + bridge_commit_sha: COMMIT.to_string(), + compatibility_surface_sha256: surface.manifest_sha256.clone(), + claims: vec![SupportClaim { + claim_id: "unsupported-exact-scope".to_string(), + level: ClaimLevel::Unsupported, + promotion_eligible: false, + product: ProductFamily::TallyPrime, + release: "7.1".to_string(), + mode: TallyMode::Education, + platform: Platform::Windows, + architecture: Architecture::X86_64, + transport: TransportProfile::XmlHttp, + endpoint_family: LoopbackFamily::Ipv4, + odbc_state: OdbcState::Disabled, + company_state: CompanyLoadState::One, + locale: LocaleProfile::EnglishIndia, + encoding: TextEncoding::Utf8, + dataset_tier: DatasetTier::SyntheticSmall, + fixture_manifest_sha256: Some(SHA.to_string()), + required_profiles: vec![required_profile], + max_evidence_age_days: 30, + evidence_id: Some("evidence-1".to_string()), + }], + } + } + + fn not_attempted_operation(profile: ReadProfileId) -> OperationEvidence { + OperationEvidence { + profile, + template_sha256: SHA.to_string(), + outcome: OperationOutcome::NotAttempted, + application_status: ApplicationStatus::NotApplicable, + encoding: TextEncoding::Unknown, + response_size: SizeBucket::Zero, + record_count: CountBucket::Unknown, + safe_reason_code: Some("fixture_not_verified".to_string()), + } + } + + #[test] + fn receipt_round_trip_is_bounded_private_and_checksum_bound() { + let receipt = receipt(SHA); + let bytes = receipt.to_pretty_json().unwrap(); + assert!(bytes.len() < MAX_ARTIFACT_BYTES); + assert_eq!( + LiveCompatibilityReceipt::from_json(&bytes).unwrap(), + receipt + ); + let mut tampered = receipt.clone(); + tampered.loaded_company_count = CountBucket::TwoToFive; + assert_eq!( + tampered.validate().unwrap_err(), + invalid("receipt_checksum_mismatch") + ); + let text = String::from_utf8(bytes).unwrap(); + for forbidden in [ + "company_name", + "company_guid", + "endpoint_port", + "raw_xml", + "amount", + ] { + assert!(!text.contains(forbidden)); + } + } + + #[test] + fn receipt_cannot_claim_support_authenticity_or_writes() { + for mutate in [ + |value: &mut LiveCompatibilityReceipt| value.authority.support_claim_eligible = true, + |value: &mut LiveCompatibilityReceipt| value.authority.writes_attempted = true, + |value: &mut LiveCompatibilityReceipt| { + value.authority.responder_authenticity_established = true + }, + ] { + let mut value = receipt(SHA); + value.receipt_sha256.clear(); + mutate(&mut value); + assert_eq!( + value.seal().unwrap_err(), + invalid("receipt_authority_invalid") + ); + } + let mut value = receipt(SHA); + value.receipt_sha256.clear(); + value.authority.tauri_runtime_observed = true; + assert_eq!( + value.seal().unwrap_err(), + invalid("receipt_authority_invalid") + ); + } + + #[test] + fn unsuccessful_attempt_is_receipted_without_live_or_fixture_claims() { + let not_attempted = |profile| OperationEvidence { + profile, + template_sha256: SHA.to_string(), + outcome: OperationOutcome::NotAttempted, + application_status: ApplicationStatus::NotApplicable, + encoding: TextEncoding::Unknown, + response_size: SizeBucket::Zero, + record_count: CountBucket::Unknown, + safe_reason_code: Some("fixture_not_verified".to_string()), + }; + let mut value = receipt(SHA); + value.receipt_sha256.clear(); + value.fixture_marker_verified = false; + value.loaded_company_count = CountBucket::Zero; + value.authority = LiveReadAuthority::attempt_only(); + value.operations = vec![ + OperationEvidence { + profile: ReadProfileId::XmlCompanyEnumerationV1, + template_sha256: SHA.to_string(), + outcome: OperationOutcome::Failed, + application_status: ApplicationStatus::NotApplicable, + encoding: TextEncoding::Unknown, + response_size: SizeBucket::Zero, + record_count: CountBucket::Zero, + safe_reason_code: Some("endpoint_unreachable".to_string()), + }, + not_attempted(ReadProfileId::XmlSyntheticFixtureMarkerV1), + not_attempted(ReadProfileId::XmlLedgerReadV1), + not_attempted(ReadProfileId::XmlVoucherEmptyRangeV1), + not_attempted(ReadProfileId::XmlVoucherPopulatedRangeV1), + ]; + let sealed = value.seal().unwrap(); + assert!(!sealed.authority.live_endpoint_response_observed); + assert!(!sealed.fixture_marker_verified); + + let mut illegal = sealed; + illegal.receipt_sha256.clear(); + illegal.operations[2].outcome = OperationOutcome::Failed; + illegal.operations[2].safe_reason_code = Some("read_failed".to_string()); + assert_eq!( + illegal.seal().unwrap_err(), + invalid("company_read_without_fixture_marker") + ); + } + + #[test] + fn unknown_claims_pass_without_live_or_trusted_evidence() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("surface.txt"), b"surface").unwrap(); + let file_sha = sha256_file(&temp.path().join("surface.txt")).unwrap(); + let surface = CompatibilitySurfaceManifest { + schema_version: SURFACE_SCHEMA_VERSION, + files: vec![SurfaceFile { + path: "surface.txt".to_string(), + sha256: file_sha, + }], + manifest_sha256: String::new(), + } + .seal() + .unwrap(); + let manifest = SupportClaimsManifest { + schema_version: SUPPORT_MANIFEST_SCHEMA_VERSION, + bridge_commit_sha: COMMIT.to_string(), + compatibility_surface_sha256: surface.manifest_sha256.clone(), + claims: vec![SupportClaim { + claim_id: "tally-prime-7-1-windows-education".to_string(), + level: ClaimLevel::Unknown, + promotion_eligible: true, + product: ProductFamily::TallyPrime, + release: "7.1".to_string(), + mode: TallyMode::Education, + platform: Platform::Windows, + architecture: Architecture::X86_64, + transport: TransportProfile::XmlHttp, + endpoint_family: LoopbackFamily::Ipv4, + odbc_state: OdbcState::Disabled, + company_state: CompanyLoadState::One, + locale: LocaleProfile::EnglishIndia, + encoding: TextEncoding::Utf8, + dataset_tier: DatasetTier::SyntheticSmall, + fixture_manifest_sha256: None, + required_profiles: Vec::new(), + max_evidence_age_days: 30, + evidence_id: None, + }], + }; + let report = enforce_support_gate( + &manifest, + &surface, + &TrustedEvidenceKeys { + schema_version: TRUST_MANIFEST_SCHEMA_VERSION, + keys: Vec::new(), + }, + &[], + &[], + temp.path(), + NOW, + ) + .unwrap(); + assert_eq!(report.unknown_claims, 1); + assert_eq!(report.evidenced_claims, 0); + } + + #[test] + fn positive_claim_requires_fresh_signed_exact_scope_evidence() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("surface.txt"), b"surface").unwrap(); + let surface = CompatibilitySurfaceManifest { + schema_version: SURFACE_SCHEMA_VERSION, + files: vec![SurfaceFile { + path: "surface.txt".to_string(), + sha256: sha256_file(&temp.path().join("surface.txt")).unwrap(), + }], + manifest_sha256: String::new(), + } + .seal() + .unwrap(); + let receipt = receipt(&surface.manifest_sha256); + let signing = SigningKey::from_bytes(&[7_u8; 32]); + let trust = TrustedEvidenceKeys { + schema_version: TRUST_MANIFEST_SCHEMA_VERSION, + keys: vec![TrustedEvidenceKey { + key_id: "release-evidence-1".to_string(), + public_key_hex: hex::encode(signing.verifying_key().to_bytes()), + valid_from_unix_ms: NOW - 100_000, + valid_until_unix_ms: NOW + 100_000, + revoked_at_unix_ms: None, + }], + }; + let mut attestation = ReviewedEvidenceAttestation { + schema_version: ATTESTATION_SCHEMA_VERSION, + evidence_id: "evidence-1".to_string(), + receipt_sha256: receipt.receipt_sha256.clone(), + compatibility_surface_sha256: surface.manifest_sha256.clone(), + reviewed_at_unix_ms: NOW - 1_000, + expires_at_unix_ms: NOW + 50_000, + review_commit_sha: COMMIT.to_string(), + review_url: "https://github.com/lamemustafa/bridge/pull/1".to_string(), + key_id: "release-evidence-1".to_string(), + signature_hex: "00".repeat(64), + }; + attestation.signature_hex = hex::encode( + signing + .sign(&attestation.signing_bytes().unwrap()) + .to_bytes(), + ); + let profiles = receipt + .operations + .iter() + .map(|value| value.profile) + .collect(); + let manifest = SupportClaimsManifest { + schema_version: SUPPORT_MANIFEST_SCHEMA_VERSION, + bridge_commit_sha: COMMIT.to_string(), + compatibility_surface_sha256: surface.manifest_sha256.clone(), + claims: vec![SupportClaim { + claim_id: "supported-exact-scope".to_string(), + level: ClaimLevel::Supported, + promotion_eligible: true, + product: ProductFamily::TallyPrime, + release: "7.1".to_string(), + mode: TallyMode::Education, + platform: Platform::Windows, + architecture: Architecture::X86_64, + transport: TransportProfile::XmlHttp, + endpoint_family: LoopbackFamily::Ipv4, + odbc_state: OdbcState::Disabled, + company_state: CompanyLoadState::One, + locale: LocaleProfile::EnglishIndia, + encoding: TextEncoding::Utf8, + dataset_tier: DatasetTier::SyntheticSmall, + fixture_manifest_sha256: Some(SHA.to_string()), + required_profiles: profiles, + max_evidence_age_days: 30, + evidence_id: Some("evidence-1".to_string()), + }], + }; + assert!(enforce_support_gate( + &manifest, + &surface, + &trust, + std::slice::from_ref(&receipt), + std::slice::from_ref(&attestation), + temp.path(), + NOW, + ) + .is_ok()); + + let mut review_time_invalid = trust.clone(); + review_time_invalid.keys[0].valid_from_unix_ms = NOW - 500; + assert_eq!( + enforce_support_gate( + &manifest, + &surface, + &review_time_invalid, + std::slice::from_ref(&receipt), + std::slice::from_ref(&attestation), + temp.path(), + NOW, + ) + .unwrap_err(), + gate("attestation_review_key_inactive") + ); + + let mut wildcard = manifest.clone(); + wildcard.claims[0].release = "latest".to_string(); + assert_eq!( + wildcard.validate().unwrap_err(), + invalid("exact_release_required") + ); + + let mut revoked = trust.clone(); + revoked.keys[0].revoked_at_unix_ms = Some(NOW - 1); + assert_eq!( + enforce_support_gate( + &manifest, + &surface, + &revoked, + &[receipt], + &[attestation], + temp.path(), + NOW, + ) + .unwrap_err(), + gate("attestation_key_inactive") + ); + } + + #[test] + fn unsupported_claims_remain_disabled_without_a_profile_specific_signature() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("surface.txt"), b"surface").unwrap(); + let surface = CompatibilitySurfaceManifest { + schema_version: SURFACE_SCHEMA_VERSION, + files: vec![SurfaceFile { + path: "surface.txt".to_string(), + sha256: sha256_file(&temp.path().join("surface.txt")).unwrap(), + }], + manifest_sha256: String::new(), + } + .seal() + .unwrap(); + let signing = SigningKey::from_bytes(&[9_u8; 32]); + let trust = trust(&signing); + + let mut invented_unsupported = receipt(&surface.manifest_sha256); + invented_unsupported.receipt_sha256.clear(); + let ledger = invented_unsupported + .operations + .iter_mut() + .find(|operation| operation.profile == ReadProfileId::XmlLedgerReadV1) + .unwrap(); + ledger.outcome = OperationOutcome::Unsupported; + ledger.application_status = ApplicationStatus::Failure; + ledger.safe_reason_code = Some("tally_export_rejected".to_string()); + assert_eq!( + invented_unsupported.seal().unwrap_err(), + invalid("unsupported_operation_signature_unavailable") + ); + + let mut later_failure = receipt(&surface.manifest_sha256); + later_failure.receipt_sha256.clear(); + let ledger = later_failure + .operations + .iter_mut() + .find(|operation| operation.profile == ReadProfileId::XmlLedgerReadV1) + .unwrap(); + ledger.outcome = OperationOutcome::Failed; + ledger.application_status = ApplicationStatus::Failure; + ledger.safe_reason_code = Some("tally_export_rejected".to_string()); + for profile in [ + ReadProfileId::XmlVoucherEmptyRangeV1, + ReadProfileId::XmlVoucherPopulatedRangeV1, + ] { + let operation = later_failure + .operations + .iter_mut() + .find(|operation| operation.profile == profile) + .unwrap(); + *operation = not_attempted_operation(profile); + } + let later_failure = later_failure.seal().unwrap(); + let later_attestation = attestation(&later_failure, &surface, &signing); + let ledger_manifest = unsupported_manifest(&surface, ReadProfileId::XmlLedgerReadV1); + assert_eq!( + enforce_support_gate( + &ledger_manifest, + &surface, + &trust, + std::slice::from_ref(&later_failure), + std::slice::from_ref(&later_attestation), + temp.path(), + NOW, + ) + .unwrap_err(), + gate("unsupported_claim_signature_unavailable") + ); + + for (reason, application_status, transport_failure) in [ + ( + "ledger_fixture_or_context_invalid", + ApplicationStatus::Success, + false, + ), + ( + "ledger_response_malformed", + ApplicationStatus::Unrecognized, + false, + ), + ( + "transport_connection_reset", + ApplicationStatus::NotApplicable, + true, + ), + ] { + let mut non_authoritative = receipt(&surface.manifest_sha256); + non_authoritative.receipt_sha256.clear(); + let ledger = non_authoritative + .operations + .iter_mut() + .find(|operation| operation.profile == ReadProfileId::XmlLedgerReadV1) + .unwrap(); + ledger.outcome = OperationOutcome::Failed; + ledger.application_status = application_status; + ledger.safe_reason_code = Some(reason.to_string()); + if transport_failure { + ledger.encoding = TextEncoding::Unknown; + ledger.response_size = SizeBucket::Zero; + } + for profile in [ + ReadProfileId::XmlVoucherEmptyRangeV1, + ReadProfileId::XmlVoucherPopulatedRangeV1, + ] { + let operation = non_authoritative + .operations + .iter_mut() + .find(|operation| operation.profile == profile) + .unwrap(); + *operation = not_attempted_operation(profile); + } + let non_authoritative = non_authoritative.seal().unwrap(); + let non_authoritative_attestation = attestation(&non_authoritative, &surface, &signing); + assert_eq!( + enforce_support_gate( + &ledger_manifest, + &surface, + &trust, + std::slice::from_ref(&non_authoritative), + std::slice::from_ref(&non_authoritative_attestation), + temp.path(), + NOW, + ) + .unwrap_err(), + if transport_failure { + gate("receipt_claim_scope_mismatch") + } else { + gate("unsupported_claim_signature_unavailable") + }, + "non-authoritative failure was accepted: {reason}" + ); + } + + let mut wrong_fixture = later_failure.clone(); + wrong_fixture.receipt_sha256.clear(); + wrong_fixture.fixture_manifest_sha256 = "c".repeat(64); + let wrong_fixture = wrong_fixture.seal().unwrap(); + let wrong_attestation = attestation(&wrong_fixture, &surface, &signing); + assert_eq!( + enforce_support_gate( + &ledger_manifest, + &surface, + &trust, + std::slice::from_ref(&wrong_fixture), + std::slice::from_ref(&wrong_attestation), + temp.path(), + NOW, + ) + .unwrap_err(), + gate("receipt_claim_scope_mismatch") + ); + + let mut missing_fixture = receipt(&surface.manifest_sha256); + missing_fixture.receipt_sha256.clear(); + missing_fixture.fixture_marker_verified = false; + for profile in [ + ReadProfileId::XmlSyntheticFixtureMarkerV1, + ReadProfileId::XmlLedgerReadV1, + ReadProfileId::XmlVoucherEmptyRangeV1, + ReadProfileId::XmlVoucherPopulatedRangeV1, + ] { + let operation = missing_fixture + .operations + .iter_mut() + .find(|operation| operation.profile == profile) + .unwrap(); + *operation = not_attempted_operation(profile); + } + let missing_fixture = missing_fixture.seal().unwrap(); + let missing_attestation = attestation(&missing_fixture, &surface, &signing); + assert_eq!( + enforce_support_gate( + &ledger_manifest, + &surface, + &trust, + std::slice::from_ref(&missing_fixture), + std::slice::from_ref(&missing_attestation), + temp.path(), + NOW, + ) + .unwrap_err(), + gate("fixture_marker_contract_not_verified") + ); + + let mut marker_failure = receipt(&surface.manifest_sha256); + marker_failure.receipt_sha256.clear(); + marker_failure.fixture_marker_verified = false; + let marker = marker_failure + .operations + .iter_mut() + .find(|operation| operation.profile == ReadProfileId::XmlSyntheticFixtureMarkerV1) + .unwrap(); + marker.outcome = OperationOutcome::Failed; + marker.application_status = ApplicationStatus::Failure; + marker.safe_reason_code = Some("synthetic_fixture_unverified".to_string()); + for profile in [ + ReadProfileId::XmlLedgerReadV1, + ReadProfileId::XmlVoucherEmptyRangeV1, + ReadProfileId::XmlVoucherPopulatedRangeV1, + ] { + let operation = marker_failure + .operations + .iter_mut() + .find(|operation| operation.profile == profile) + .unwrap(); + *operation = not_attempted_operation(profile); + } + let marker_failure = marker_failure.seal().unwrap(); + let marker_attestation = attestation(&marker_failure, &surface, &signing); + let marker_manifest = + unsupported_manifest(&surface, ReadProfileId::XmlSyntheticFixtureMarkerV1); + assert_eq!( + enforce_support_gate( + &marker_manifest, + &surface, + &trust, + std::slice::from_ref(&marker_failure), + std::slice::from_ref(&marker_attestation), + temp.path(), + NOW, + ) + .unwrap_err(), + gate("fixture_marker_contract_not_verified") + ); + } + + #[test] + fn surface_manifest_detects_compatibility_drift() { + let temp = tempfile::tempdir().unwrap(); + fs::write(temp.path().join("surface.txt"), b"before").unwrap(); + let surface = CompatibilitySurfaceManifest { + schema_version: SURFACE_SCHEMA_VERSION, + files: vec![SurfaceFile { + path: "surface.txt".to_string(), + sha256: sha256_file(&temp.path().join("surface.txt")).unwrap(), + }], + manifest_sha256: String::new(), + } + .seal() + .unwrap(); + surface.validate_files(temp.path()).unwrap(); + fs::write(temp.path().join("surface.txt"), b"after").unwrap(); + assert_eq!( + surface.validate_files(temp.path()).unwrap_err(), + invalid("surface_file_changed") + ); + } + + #[test] + fn rendered_claim_matrix_is_deterministic_and_drift_checked() { + let manifest = SupportClaimsManifest { + schema_version: SUPPORT_MANIFEST_SCHEMA_VERSION, + bridge_commit_sha: COMMIT.to_string(), + compatibility_surface_sha256: SHA.to_string(), + claims: vec![SupportClaim { + claim_id: "prime-7-1-windows-education-xml-one-company".to_string(), + level: ClaimLevel::Unknown, + promotion_eligible: true, + product: ProductFamily::TallyPrime, + release: "7.1".to_string(), + mode: TallyMode::Education, + platform: Platform::Windows, + architecture: Architecture::X86_64, + transport: TransportProfile::XmlHttp, + endpoint_family: LoopbackFamily::Ipv4, + odbc_state: OdbcState::Disabled, + company_state: CompanyLoadState::One, + locale: LocaleProfile::EnglishIndia, + encoding: TextEncoding::Utf8, + dataset_tier: DatasetTier::SyntheticSmall, + fixture_manifest_sha256: None, + required_profiles: Vec::new(), + max_evidence_age_days: 180, + evidence_id: None, + }], + }; + let rendered = render_claim_matrix(&manifest).unwrap(); + assert!(rendered.contains("`unknown` | `true` | `missing`")); + let document = format!("# Matrix\n\n{rendered}\n"); + verify_claim_matrix_markdown(&manifest, document.as_bytes()).unwrap(); + assert_eq!( + verify_claim_matrix_markdown(&manifest, b"# Matrix\n").unwrap_err(), + invalid("matrix_markdown_drift") + ); + + let mut unsupported = manifest.clone(); + unsupported.claims[0].level = ClaimLevel::Unsupported; + unsupported.claims[0].promotion_eligible = false; + unsupported.claims[0].fixture_manifest_sha256 = Some(SHA.to_string()); + unsupported.claims[0].required_profiles = vec![ReadProfileId::XmlCompanyEnumerationV1]; + unsupported.claims[0].evidence_id = Some("observed-failure-1".to_string()); + assert!(unsupported.validate().is_ok()); + + let mut non_promotable_positive = unsupported.clone(); + non_promotable_positive.claims[0].level = ClaimLevel::Observed; + assert_eq!( + non_promotable_positive.validate().unwrap_err(), + invalid("positive_claim_not_promotion_eligible") + ); + + let mut mixed_transport = manifest; + mixed_transport.claims[0].transport = TransportProfile::JsonExShadow; + mixed_transport.claims[0].promotion_eligible = false; + mixed_transport.claims[0].required_profiles = vec![ReadProfileId::XmlCompanyEnumerationV1]; + assert_eq!( + mixed_transport.validate().unwrap_err(), + invalid("jsonex_claim_contains_xml_profile") + ); + } +} diff --git a/src-tauri/crates/bridge-tally-compatibility/src/main.rs b/src-tauri/crates/bridge-tally-compatibility/src/main.rs new file mode 100644 index 0000000..2853638 --- /dev/null +++ b/src-tauri/crates/bridge-tally-compatibility/src/main.rs @@ -0,0 +1,158 @@ +use std::{ + fs, + path::{Path, PathBuf}, + process::ExitCode, +}; + +use bridge_tally_compatibility::{ + enforce_support_gate, format_gate_success, now_unix_ms, parse_artifact, render_claim_matrix, + safe_error_code, verify_claim_matrix_markdown, CompatibilitySurfaceManifest, + LiveCompatibilityReceipt, ReviewedEvidenceAttestation, SupportClaimsManifest, + TrustedEvidenceKeys, MAX_ARTIFACT_BYTES, +}; + +fn main() -> ExitCode { + match run() { + Ok(message) => { + println!("{message}"); + ExitCode::SUCCESS + } + Err(code) => { + eprintln!("bridge_tally_compatibility_failed:{code}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result { + let mut args = std::env::args().skip(1); + match args.next().as_deref() { + Some("validate-receipt") => { + let path = one_path(&mut args)?; + let bytes = read_bounded(&path)?; + LiveCompatibilityReceipt::from_json(&bytes).map_err(|error| safe_error_code(&error))?; + Ok("compatibility_receipt_valid".to_string()) + } + Some("gate") => { + let support = next_path(&mut args, "missing_support_manifest")?; + let surface = next_path(&mut args, "missing_surface_manifest")?; + let trust = next_path(&mut args, "missing_trust_manifest")?; + let evidence = next_path(&mut args, "missing_evidence_directory")?; + let root = next_path(&mut args, "missing_repository_root")?; + if args.next().is_some() { + return Err("unexpected_argument"); + } + gate_command(&support, &surface, &trust, &evidence, &root) + } + Some("seal-surface") => { + let path = one_path(&mut args)?; + let draft = parse_artifact::(&read_bounded(&path)?) + .map_err(|error| safe_error_code(&error))?; + let sealed = draft.seal().map_err(|error| safe_error_code(&error))?; + let bytes = sealed + .to_pretty_json() + .map_err(|error| safe_error_code(&error))?; + String::from_utf8(bytes).map_err(|_| "serialization_failed") + } + Some("check-matrix-markdown") => { + let manifest_path = next_path(&mut args, "missing_support_manifest")?; + let markdown_path = next_path(&mut args, "missing_matrix_markdown")?; + if args.next().is_some() { + return Err("unexpected_argument"); + } + let manifest = SupportClaimsManifest::from_json(&read_bounded(&manifest_path)?) + .map_err(|error| safe_error_code(&error))?; + let markdown = fs::read(&markdown_path).map_err(|_| "matrix_markdown_unavailable")?; + verify_claim_matrix_markdown(&manifest, &markdown) + .map_err(|error| safe_error_code(&error))?; + Ok("compatibility_matrix_markdown_current".to_string()) + } + Some("render-matrix") => { + let path = one_path(&mut args)?; + let manifest = SupportClaimsManifest::from_json(&read_bounded(&path)?) + .map_err(|error| safe_error_code(&error))?; + render_claim_matrix(&manifest).map_err(|error| safe_error_code(&error)) + } + _ => Err("usage_validate_receipt_seal_surface_render_or_check_matrix_markdown_or_gate"), + } +} + +fn gate_command( + support_path: &Path, + surface_path: &Path, + trust_path: &Path, + evidence_dir: &Path, + repository_root: &Path, +) -> Result { + let support = SupportClaimsManifest::from_json(&read_bounded(support_path)?) + .map_err(|error| safe_error_code(&error))?; + let surface = CompatibilitySurfaceManifest::from_json(&read_bounded(surface_path)?) + .map_err(|error| safe_error_code(&error))?; + let trust = TrustedEvidenceKeys::from_json(&read_bounded(trust_path)?) + .map_err(|error| safe_error_code(&error))?; + let mut receipts = Vec::new(); + let mut attestations = Vec::new(); + if evidence_dir.exists() { + let entries = fs::read_dir(evidence_dir).map_err(|_| "evidence_directory_unavailable")?; + for (index, entry) in entries.enumerate() { + if index >= 128 { + return Err("evidence_file_limit"); + } + let path = entry.map_err(|_| "evidence_directory_unavailable")?.path(); + if !path.is_file() { + continue; + } + let name = path + .file_name() + .and_then(|value| value.to_str()) + .ok_or("evidence_filename_invalid")?; + if name.ends_with(".receipt.json") { + receipts.push( + LiveCompatibilityReceipt::from_json(&read_bounded(&path)?) + .map_err(|error| safe_error_code(&error))?, + ); + } else if name.ends_with(".attestation.json") { + attestations.push( + parse_artifact::(&read_bounded(&path)?) + .map_err(|error| safe_error_code(&error))?, + ); + } else if name != "README.md" && name != ".gitkeep" { + return Err("evidence_filename_invalid"); + } + } + } + let report = enforce_support_gate( + &support, + &surface, + &trust, + &receipts, + &attestations, + repository_root, + now_unix_ms().map_err(|error| safe_error_code(&error))?, + ) + .map_err(|error| safe_error_code(&error))?; + Ok(format_gate_success(&report)) +} + +fn one_path(args: &mut impl Iterator) -> Result { + let path = next_path(args, "missing_path")?; + if args.next().is_some() { + return Err("unexpected_argument"); + } + Ok(path) +} + +fn next_path( + args: &mut impl Iterator, + missing: &'static str, +) -> Result { + args.next().map(PathBuf::from).ok_or(missing) +} + +fn read_bounded(path: &Path) -> Result, &'static str> { + let metadata = fs::metadata(path).map_err(|_| "artifact_unavailable")?; + if metadata.len() == 0 || metadata.len() > MAX_ARTIFACT_BYTES as u64 { + return Err("artifact_size_invalid"); + } + fs::read(path).map_err(|_| "artifact_unavailable") +} diff --git a/src-tauri/crates/bridge-tally-core/Cargo.toml b/src-tauri/crates/bridge-tally-core/Cargo.toml new file mode 100644 index 0000000..6a53e32 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "bridge-tally-core" +version = "0.1.0" +description = "Reusable, local-first Tally integration contracts for Bridge" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[dependencies] +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +thiserror = "2" + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/src-tauri/crates/bridge-tally-core/src/bills_reconciliation.rs b/src-tauri/crates/bridge-tally-core/src/bills_reconciliation.rs new file mode 100644 index 0000000..a70393f --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/bills_reconciliation.rs @@ -0,0 +1,1225 @@ +use std::cmp::Ordering; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use crate::exact_arithmetic::{ + is_negative_nonzero, magnitude_cmp, numeric_equal, same_nonzero_sign, ExactDecimalAccumulator, +}; +use crate::{ + BillAllocationRecord, BillReferenceKind, BillWiseState, BillsAndPaymentsBatch, + BillsCoverageState, CurrencyBasis, FetchBracketState, LedgerEntryPolarity, + OutstandingDirection, OutstandingObservation, PartyOutstandingFacts, SourceIdentity, + SourceRecordId, TallyDate, TallyError, +}; + +/// Adapter-owned authority. No source response or request echo may promote +/// these flags; a qualifying live receipt for the exact profile is required. +#[derive(Clone, PartialEq, Eq, Default)] +pub struct PartyOutstandingAuthority { + qualified_scope: Option, + allocation_profile_observed: bool, + outstanding_profile_observed: bool, + signed_amount_semantics_observed: bool, + due_date_semantics_observed: bool, + on_account_aggregate_semantics_observed: bool, + settled_omission_semantics_observed: bool, + empty_scope_semantics_observed: bool, +} + +impl fmt::Debug for PartyOutstandingAuthority { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PartyOutstandingAuthority") + .field("qualified_scope", &self.qualified_scope.is_some()) + .field( + "allocation_profile_observed", + &self.allocation_profile_observed, + ) + .field( + "outstanding_profile_observed", + &self.outstanding_profile_observed, + ) + .field( + "signed_amount_semantics_observed", + &self.signed_amount_semantics_observed, + ) + .field( + "due_date_semantics_observed", + &self.due_date_semantics_observed, + ) + .field( + "on_account_aggregate_semantics_observed", + &self.on_account_aggregate_semantics_observed, + ) + .field( + "settled_omission_semantics_observed", + &self.settled_omission_semantics_observed, + ) + .field( + "empty_scope_semantics_observed", + &self.empty_scope_semantics_observed, + ) + .finish() + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PartyOutstandingConfidenceState { + MatchedWithinObservedBracket, + PartiallySettledMatched, + OnAccountAggregateMatched, + Mismatch, + BillWiseDisabled, + CoverageIncomplete, + IncomparableCurrency, + ProfileUnobserved, + SourceChangedDuringFetch, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BillComparisonState { + Matched, + PartiallySettledMatched, + OnAccountAggregateMatched, + Mismatch, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DueState { + Overdue { days: u32 }, + DueToday, + NotDue { days: u32 }, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingInterpretation { + ObservedOpen, + PartialSettlementObserved, + AdvanceObserved, + OnAccountObserved, + SettledOmissionObserved, + Unresolved, +} + +/// Raw source IDs remain in this non-serializable intermediate. A future UI +/// adapter must replace them with bounded, proof-local aliases before support +/// export, logging, or persistence outside encrypted run state. +#[derive(Clone, PartialEq, Eq)] +pub struct PartyOutstandingConfidenceRow { + pub state: BillComparisonState, + pub due_state: DueState, + pub pending_interpretation: PendingInterpretation, + pub allocation_source_ids: Vec, + pub outstanding_source_id: Option, +} + +impl fmt::Debug for PartyOutstandingConfidenceRow { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PartyOutstandingConfidenceRow") + .field("state", &self.state) + .field("due_state", &self.due_state) + .field("pending_interpretation", &self.pending_interpretation) + .field( + "allocation_source_id_count", + &self.allocation_source_ids.len(), + ) + .field( + "outstanding_source_id_present", + &self.outstanding_source_id.is_some(), + ) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PartyOutstandingConfidenceAssessment { + pub state: PartyOutstandingConfidenceState, + pub compared_reference_count: u64, + pub matched_reference_count: u64, + pub mismatch_count: u64, + pub unavailable_count: u64, + pub safe_reason_codes: BTreeSet<&'static str>, + pub rows: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct BillKey { + name: String, + currency: String, +} + +#[derive(Default)] +struct BillGroup<'a> { + allocations: Vec<&'a BillAllocationRecord>, + outstanding: Vec<&'a OutstandingObservation>, +} + +/// Caller-owned scope that the observation must match exactly before any +/// confidence decision is permitted. This prevents a valid receipt for one +/// company, party, date, direction, or export profile from being reused for +/// another scope. +#[derive(Clone, PartialEq, Eq)] +pub struct PartyOutstandingExpectedScope { + pub source_identity: SourceIdentity, + pub party_ledger_source_id: SourceRecordId, + pub report_as_of_yyyymmdd: TallyDate, + pub direction: OutstandingDirection, + pub query_profile: crate::CanonicalText, + pub source_scope_fingerprint: crate::CanonicalText, +} + +impl fmt::Debug for PartyOutstandingExpectedScope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PartyOutstandingExpectedScope") + .field("source_identity", &"redacted") + .field("party_ledger_source_id", &"redacted") + .field("report_as_of_yyyymmdd", &"redacted") + .field("direction", &self.direction) + .field("query_profile", &"redacted") + .field("source_scope_fingerprint", &"redacted") + .finish() + } +} + +pub fn assess_party_outstanding( + facts: &PartyOutstandingFacts, + expected_scope: &PartyOutstandingExpectedScope, + authority: PartyOutstandingAuthority, +) -> Result { + BillsAndPaymentsBatch { + parties: vec![facts.clone()], + } + .validate()?; + + if facts.source_identity != expected_scope.source_identity + || facts.party_ledger_source_id != expected_scope.party_ledger_source_id + || facts.report_as_of_yyyymmdd != expected_scope.report_as_of_yyyymmdd + || facts.direction != expected_scope.direction + || facts.query_profile != expected_scope.query_profile + || facts.source_scope_fingerprint != expected_scope.source_scope_fingerprint + { + return Err(TallyError::InvalidData { + code: "party_outstanding_scope_mismatch".to_string(), + }); + } + + if authority.qualified_scope.as_ref() != Some(expected_scope) + || !authority.allocation_profile_observed + || !authority.outstanding_profile_observed + || !authority.signed_amount_semantics_observed + || !authority.due_date_semantics_observed + { + return Ok(terminal( + PartyOutstandingConfidenceState::ProfileUnobserved, + "bills_profile_unobserved", + )); + } + match facts.fetch_bracket { + FetchBracketState::ChangedObserved => { + return Ok(terminal( + PartyOutstandingConfidenceState::SourceChangedDuringFetch, + "bills_source_changed_during_fetch", + )); + } + FetchBracketState::Unavailable => { + return Ok(terminal( + PartyOutstandingConfidenceState::Unavailable, + "bills_fetch_bracket_unavailable", + )); + } + FetchBracketState::StableObserved => {} + } + match facts.bill_wise_state { + BillWiseState::CompanyDisabledObserved | BillWiseState::PartyDisabledObserved => { + return Ok(terminal( + PartyOutstandingConfidenceState::BillWiseDisabled, + "bill_wise_tracking_disabled", + )); + } + BillWiseState::UnsupportedForeignCurrencyLedgerObserved => { + return Ok(terminal( + PartyOutstandingConfidenceState::IncomparableCurrency, + "foreign_currency_party_ledger_billwise_unsupported", + )); + } + BillWiseState::Unknown => { + return Ok(terminal( + PartyOutstandingConfidenceState::Unavailable, + "bill_wise_tracking_state_unknown", + )); + } + BillWiseState::EnabledObserved => {} + } + if facts.allocation_coverage != BillsCoverageState::ObservedCompleteScope + || facts.outstanding_coverage != BillsCoverageState::ObservedCompleteScope + { + return Ok(terminal( + PartyOutstandingConfidenceState::CoverageIncomplete, + "bills_source_coverage_incomplete", + )); + } + if facts.allocations.is_empty() + && facts.outstanding.is_empty() + && !authority.empty_scope_semantics_observed + { + return Ok(terminal( + PartyOutstandingConfidenceState::Unavailable, + "empty_scope_semantics_unobserved", + )); + } + + let mut currencies = BTreeSet::new(); + for currency in facts + .allocations + .iter() + .map(|record| &record.currency_basis) + .chain( + facts + .outstanding + .iter() + .map(|record| &record.currency_basis), + ) + { + match currency { + CurrencyBasis::CompanyBase { currency } => { + currencies.insert(currency.as_str()); + } + CurrencyBasis::ObservedSource { .. } | CurrencyBasis::Unspecified => { + return Ok(terminal( + PartyOutstandingConfidenceState::IncomparableCurrency, + "bills_currency_basis_incomparable", + )); + } + } + } + if currencies.len() > 1 { + return Ok(terminal( + PartyOutstandingConfidenceState::IncomparableCurrency, + "bills_currency_scope_mixed", + )); + } + + let mut groups = BTreeMap::>::new(); + let mut on_account_allocations = Vec::new(); + let mut on_account_outstanding = Vec::new(); + for allocation in &facts.allocations { + if !polarity_matches(allocation.amount.as_str(), allocation.observed_polarity) { + return Ok(terminal( + PartyOutstandingConfidenceState::Mismatch, + "bill_allocation_polarity_mismatch", + )); + } + match allocation.reference.kind { + BillReferenceKind::OnAccount => on_account_allocations.push(allocation), + BillReferenceKind::Unclassified => { + return Ok(terminal( + PartyOutstandingConfidenceState::Unavailable, + "bill_reference_type_unclassified", + )); + } + BillReferenceKind::Advance + | BillReferenceKind::AgainstReference + | BillReferenceKind::NewReference => { + groups + .entry(reference_key( + allocation + .reference + .name + .as_ref() + .expect("validated named bill reference") + .as_str(), + &allocation.currency_basis, + )) + .or_default() + .allocations + .push(allocation); + } + } + } + for outstanding in &facts.outstanding { + if !polarity_matches( + outstanding.pending_amount.as_str(), + outstanding.observed_polarity, + ) || !reference_direction_matches( + outstanding.reference.kind, + outstanding.pending_amount.as_str(), + facts.direction, + ) || outstanding.opening_amount.as_ref().is_some_and(|opening| { + !reference_direction_matches( + outstanding.reference.kind, + opening.as_str(), + facts.direction, + ) + }) { + return Ok(terminal( + PartyOutstandingConfidenceState::Mismatch, + "bill_outstanding_direction_or_polarity_mismatch", + )); + } + match outstanding.reference.kind { + BillReferenceKind::OnAccount => on_account_outstanding.push(outstanding), + BillReferenceKind::Unclassified => { + return Ok(terminal( + PartyOutstandingConfidenceState::Unavailable, + "bill_reference_type_unclassified", + )); + } + BillReferenceKind::Advance + | BillReferenceKind::AgainstReference + | BillReferenceKind::NewReference => { + groups + .entry(reference_key( + outstanding + .reference + .name + .as_ref() + .expect("validated named bill reference") + .as_str(), + &outstanding.currency_basis, + )) + .or_default() + .outstanding + .push(outstanding); + } + } + } + + let mut assessment = PartyOutstandingConfidenceAssessment { + state: PartyOutstandingConfidenceState::MatchedWithinObservedBracket, + compared_reference_count: 0, + matched_reference_count: 0, + mismatch_count: 0, + unavailable_count: 0, + safe_reason_codes: BTreeSet::new(), + rows: Vec::new(), + }; + let mut partial = false; + let mut due_unavailable = false; + for group in groups.into_values() { + assessment.compared_reference_count = assessment.compared_reference_count.saturating_add(1); + let mut allocation_total = ExactDecimalAccumulator::default(); + for allocation in &group.allocations { + allocation_total.add(allocation.amount.as_str()); + } + let allocation_source_ids = group + .allocations + .iter() + .map(|record| record.source_id.clone()) + .collect::>(); + let mut row = PartyOutstandingConfidenceRow { + state: BillComparisonState::Mismatch, + due_state: DueState::Unavailable, + pending_interpretation: PendingInterpretation::Unresolved, + allocation_source_ids, + outstanding_source_id: group + .outstanding + .first() + .map(|record| record.source_id.clone()), + }; + let anchors = group + .allocations + .iter() + .filter(|allocation| { + matches!( + allocation.reference.kind, + BillReferenceKind::NewReference | BillReferenceKind::Advance + ) + }) + .collect::>(); + if anchors.len() != 1 + || !reference_direction_matches( + anchors[0].reference.kind, + anchors[0].amount.as_str(), + facts.direction, + ) + || group + .outstanding + .first() + .is_some_and(|outstanding| outstanding.reference.kind != anchors[0].reference.kind) + { + assessment.mismatch_count = assessment.mismatch_count.saturating_add(1); + assessment + .safe_reason_codes + .insert("bill_reference_kind_composition_invalid"); + assessment.rows.push(row); + continue; + } + if group.outstanding.len() > 1 { + assessment.mismatch_count = assessment.mismatch_count.saturating_add(1); + assessment + .safe_reason_codes + .insert("duplicate_outstanding_reference"); + assessment.rows.push(row); + continue; + } + match group.outstanding.first().copied() { + Some(outstanding) if allocation_total.equals(outstanding.pending_amount.as_str()) => { + row.due_state = due_state(outstanding, &facts.report_as_of_yyyymmdd); + if matches!(row.due_state, DueState::Unavailable) { + due_unavailable = true; + assessment.unavailable_count = assessment.unavailable_count.saturating_add(1); + assessment + .safe_reason_codes + .insert("bill_due_date_unavailable"); + } + if source_overdue_days_mismatch(outstanding, row.due_state) { + row.state = BillComparisonState::Mismatch; + assessment.mismatch_count = assessment.mismatch_count.saturating_add(1); + assessment + .safe_reason_codes + .insert("source_overdue_days_mismatch"); + } else { + let is_partial = outstanding.opening_amount.as_ref().is_some_and(|opening| { + same_nonzero_sign(opening.as_str(), outstanding.pending_amount.as_str()) + && magnitude_cmp(outstanding.pending_amount.as_str(), opening.as_str()) + == Ordering::Less + }); + row.state = if is_partial { + partial = true; + BillComparisonState::PartiallySettledMatched + } else { + BillComparisonState::Matched + }; + row.pending_interpretation = + if is_partial { + PendingInterpretation::PartialSettlementObserved + } else if group.allocations.iter().any(|allocation| { + allocation.reference.kind == BillReferenceKind::Advance + }) { + PendingInterpretation::AdvanceObserved + } else { + PendingInterpretation::ObservedOpen + }; + assessment.matched_reference_count = + assessment.matched_reference_count.saturating_add(1); + } + } + None if allocation_total.is_zero() && authority.settled_omission_semantics_observed => { + row.state = BillComparisonState::Matched; + row.pending_interpretation = PendingInterpretation::SettledOmissionObserved; + assessment.matched_reference_count = + assessment.matched_reference_count.saturating_add(1); + } + None if allocation_total.is_zero() => { + row.state = BillComparisonState::Unavailable; + assessment.unavailable_count = assessment.unavailable_count.saturating_add(1); + assessment + .safe_reason_codes + .insert("settled_omission_semantics_unobserved"); + } + Some(_) | None => { + assessment.mismatch_count = assessment.mismatch_count.saturating_add(1); + assessment + .safe_reason_codes + .insert("bill_pending_amount_mismatch"); + } + } + assessment.rows.push(row); + } + + let on_account_present = + !on_account_allocations.is_empty() || !on_account_outstanding.is_empty(); + let mut on_account_matched = false; + if on_account_present { + assessment.compared_reference_count = assessment.compared_reference_count.saturating_add(1); + let mut allocation_total = ExactDecimalAccumulator::default(); + let mut outstanding_total = ExactDecimalAccumulator::default(); + for record in &on_account_allocations { + allocation_total.add(record.amount.as_str()); + } + for record in &on_account_outstanding { + outstanding_total.add(record.pending_amount.as_str()); + } + let mut row = PartyOutstandingConfidenceRow { + state: BillComparisonState::Unavailable, + due_state: DueState::Unavailable, + pending_interpretation: PendingInterpretation::OnAccountObserved, + allocation_source_ids: on_account_allocations + .iter() + .map(|record| record.source_id.clone()) + .collect(), + outstanding_source_id: if on_account_outstanding.len() == 1 { + Some(on_account_outstanding[0].source_id.clone()) + } else { + None + }, + }; + if !authority.on_account_aggregate_semantics_observed { + assessment.unavailable_count = assessment.unavailable_count.saturating_add(1); + assessment + .safe_reason_codes + .insert("on_account_aggregate_semantics_unobserved"); + } else if on_account_allocations.is_empty() || on_account_outstanding.is_empty() { + if on_account_outstanding.is_empty() + && allocation_total.is_zero() + && authority.settled_omission_semantics_observed + { + row.state = BillComparisonState::OnAccountAggregateMatched; + on_account_matched = true; + assessment.matched_reference_count = + assessment.matched_reference_count.saturating_add(1); + } else { + assessment.unavailable_count = assessment.unavailable_count.saturating_add(1); + assessment + .safe_reason_codes + .insert("on_account_comparison_side_missing"); + } + } else if allocation_total == outstanding_total { + row.state = BillComparisonState::OnAccountAggregateMatched; + on_account_matched = true; + assessment.matched_reference_count = + assessment.matched_reference_count.saturating_add(1); + } else { + row.state = BillComparisonState::Mismatch; + assessment.mismatch_count = assessment.mismatch_count.saturating_add(1); + assessment + .safe_reason_codes + .insert("on_account_pending_amount_mismatch"); + } + assessment.rows.push(row); + } + + assessment.state = if assessment.mismatch_count > 0 { + PartyOutstandingConfidenceState::Mismatch + } else if assessment.unavailable_count > 0 || due_unavailable { + PartyOutstandingConfidenceState::Unavailable + } else if partial { + PartyOutstandingConfidenceState::PartiallySettledMatched + } else if on_account_matched { + PartyOutstandingConfidenceState::OnAccountAggregateMatched + } else { + if assessment.rows.is_empty() { + assessment + .safe_reason_codes + .insert("proven_empty_observation_scope"); + } + PartyOutstandingConfidenceState::MatchedWithinObservedBracket + }; + Ok(assessment) +} + +fn terminal( + state: PartyOutstandingConfidenceState, + reason: &'static str, +) -> PartyOutstandingConfidenceAssessment { + PartyOutstandingConfidenceAssessment { + state, + compared_reference_count: 0, + matched_reference_count: 0, + mismatch_count: u64::from(state == PartyOutstandingConfidenceState::Mismatch), + unavailable_count: u64::from(matches!( + state, + PartyOutstandingConfidenceState::CoverageIncomplete + | PartyOutstandingConfidenceState::IncomparableCurrency + | PartyOutstandingConfidenceState::ProfileUnobserved + | PartyOutstandingConfidenceState::SourceChangedDuringFetch + | PartyOutstandingConfidenceState::Unavailable + )), + safe_reason_codes: BTreeSet::from([reason]), + rows: Vec::new(), + } +} + +fn reference_key(name: &str, currency_basis: &CurrencyBasis) -> BillKey { + let currency = match currency_basis { + CurrencyBasis::CompanyBase { currency } => currency.as_str(), + CurrencyBasis::ObservedSource { .. } | CurrencyBasis::Unspecified => { + unreachable!("currency comparability was established") + } + }; + BillKey { + name: name.to_string(), + currency: currency.to_string(), + } +} + +fn polarity_matches(amount: &str, polarity: Option) -> bool { + if numeric_equal(amount, "0") { + return polarity.is_some(); + } + matches!( + (polarity, is_negative_nonzero(amount)), + (Some(LedgerEntryPolarity::Debit), true) | (Some(LedgerEntryPolarity::Credit), false) + ) +} + +fn direction_matches(amount: &str, direction: OutstandingDirection) -> bool { + if numeric_equal(amount, "0") { + return false; + } + match direction { + OutstandingDirection::Receivable => is_negative_nonzero(amount), + OutstandingDirection::Payable => !is_negative_nonzero(amount), + } +} + +fn reference_direction_matches( + kind: BillReferenceKind, + amount: &str, + direction: OutstandingDirection, +) -> bool { + match kind { + BillReferenceKind::NewReference | BillReferenceKind::OnAccount => { + direction_matches(amount, direction) + } + BillReferenceKind::Advance => match direction { + OutstandingDirection::Receivable => { + !numeric_equal(amount, "0") && !is_negative_nonzero(amount) + } + OutstandingDirection::Payable => is_negative_nonzero(amount), + }, + BillReferenceKind::AgainstReference | BillReferenceKind::Unclassified => false, + } +} + +fn due_state(outstanding: &OutstandingObservation, as_of: &TallyDate) -> DueState { + let Some(due_date) = outstanding.due_date_yyyymmdd.as_ref() else { + return DueState::Unavailable; + }; + let due = date_ordinal(due_date.as_str()); + let observed = date_ordinal(as_of.as_str()); + match observed.cmp(&due) { + Ordering::Greater => DueState::Overdue { + days: u32::try_from(observed - due).unwrap_or(u32::MAX), + }, + Ordering::Equal => DueState::DueToday, + Ordering::Less => DueState::NotDue { + days: u32::try_from(due - observed).unwrap_or(u32::MAX), + }, + } +} + +fn source_overdue_days_mismatch(outstanding: &OutstandingObservation, due_state: DueState) -> bool { + let Some(source_days) = outstanding.source_reported_overdue_days else { + return false; + }; + match due_state { + DueState::Overdue { days } => source_days != days, + DueState::DueToday | DueState::NotDue { .. } => source_days != 0, + DueState::Unavailable => false, + } +} + +fn date_ordinal(value: &str) -> i64 { + let year = value[0..4] + .parse::() + .expect("validated TallyDate year"); + let month = value[4..6] + .parse::() + .expect("validated TallyDate month"); + let day = value[6..8].parse::().expect("validated TallyDate day"); + let year = year - i64::from(month <= 2); + let era = if year >= 0 { year } else { year - 399 } / 400; + let year_of_era = year - era * 400; + let shifted_month = month + if month > 2 { -3 } else { 9 }; + let day_of_year = (153 * shifted_month + 2) / 5 + day - 1; + let day_of_era = year_of_era * 365 + year_of_era / 4 - year_of_era / 100 + day_of_year; + era * 146_097 + day_of_era +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + BillAllocationOrigin, BillDueDateEvidence, BillReference, CurrencyBasis, + DerivedIdentityBasis, ExactDecimal, OutstandingDirection, OutstandingOrigin, TallyDate, + }; + + fn id(value: &str) -> SourceRecordId { + SourceRecordId::parse(value).unwrap() + } + + fn text(value: &str) -> crate::CanonicalText { + crate::CanonicalText::parse(value).unwrap() + } + + fn reference(kind: BillReferenceKind, name: Option<&str>) -> BillReference { + BillReference { + kind, + name: name.map(text), + raw_kind: None, + } + } + + fn currency() -> CurrencyBasis { + CurrencyBasis::CompanyBase { + currency: text("company-base"), + } + } + + fn allocation( + id_value: &str, + kind: BillReferenceKind, + name: Option<&str>, + amount: &str, + origin: BillAllocationOrigin, + ) -> BillAllocationRecord { + BillAllocationRecord { + source_id: id(id_value), + identity_basis: DerivedIdentityBasis::ParentOrdinal, + origin, + reference: reference(kind, name), + bill_date_yyyymmdd: Some(TallyDate::parse("20260701").unwrap()), + effective_date_yyyymmdd: None, + due_date_yyyymmdd: Some(TallyDate::parse("20260731").unwrap()), + due_date_evidence: BillDueDateEvidence::Explicit, + amount: ExactDecimal::parse(amount).unwrap(), + observed_polarity: Some(if amount.starts_with('-') { + LedgerEntryPolarity::Debit + } else { + LedgerEntryPolarity::Credit + }), + currency_basis: currency(), + } + } + + fn outstanding( + id_value: &str, + kind: BillReferenceKind, + name: Option<&str>, + opening: Option<&str>, + pending: &str, + ) -> OutstandingObservation { + OutstandingObservation { + source_id: id(id_value), + identity_basis: DerivedIdentityBasis::ParentOrdinal, + origin: OutstandingOrigin::Voucher { + voucher_source_id: Some(id("voucher:1")), + }, + reference: reference(kind, name), + bill_date_yyyymmdd: Some(TallyDate::parse("20260701").unwrap()), + effective_date_yyyymmdd: None, + due_date_yyyymmdd: Some(TallyDate::parse("20260731").unwrap()), + due_date_evidence: BillDueDateEvidence::Explicit, + opening_amount: opening.map(|value| ExactDecimal::parse(value).unwrap()), + pending_amount: ExactDecimal::parse(pending).unwrap(), + observed_polarity: Some(if pending.starts_with('-') { + LedgerEntryPolarity::Debit + } else { + LedgerEntryPolarity::Credit + }), + source_reported_overdue_days: Some(1), + currency_basis: currency(), + } + } + + fn facts( + allocations: Vec, + outstanding: Vec, + ) -> PartyOutstandingFacts { + PartyOutstandingFacts { + source_identity: SourceIdentity { + bridge_source_lineage: "bridge-source:test".to_string(), + company_guid: "company-guid:test".to_string(), + observed_fingerprint: "b".repeat(64), + }, + party_ledger_source_id: id("ledger:party"), + report_as_of_yyyymmdd: TallyDate::parse("20260801").unwrap(), + direction: OutstandingDirection::Receivable, + bill_wise_state: BillWiseState::EnabledObserved, + allocation_coverage: BillsCoverageState::ObservedCompleteScope, + outstanding_coverage: BillsCoverageState::ObservedCompleteScope, + fetch_bracket: FetchBracketState::StableObserved, + query_profile: text("bills-confidence-v1"), + source_scope_fingerprint: text(&"a".repeat(64)), + source_reported_allocation_count: allocations.len() as u64, + source_reported_outstanding_count: outstanding.len() as u64, + allocations, + outstanding, + } + } + + fn authority(facts: &PartyOutstandingFacts) -> PartyOutstandingAuthority { + PartyOutstandingAuthority { + qualified_scope: Some(expected_scope(facts)), + allocation_profile_observed: true, + outstanding_profile_observed: true, + signed_amount_semantics_observed: true, + due_date_semantics_observed: true, + on_account_aggregate_semantics_observed: true, + settled_omission_semantics_observed: false, + empty_scope_semantics_observed: false, + } + } + + fn expected_scope(facts: &PartyOutstandingFacts) -> PartyOutstandingExpectedScope { + PartyOutstandingExpectedScope { + source_identity: facts.source_identity.clone(), + party_ledger_source_id: facts.party_ledger_source_id.clone(), + report_as_of_yyyymmdd: facts.report_as_of_yyyymmdd.clone(), + direction: facts.direction, + query_profile: facts.query_profile.clone(), + source_scope_fingerprint: facts.source_scope_fingerprint.clone(), + } + } + + fn assess( + facts: &PartyOutstandingFacts, + authority: PartyOutstandingAuthority, + ) -> Result { + assess_party_outstanding(facts, &expected_scope(facts), authority) + } + + #[test] + fn profile_unobserved_and_disabled_never_become_empty_or_matched() { + let unobserved = facts(Vec::new(), Vec::new()); + assert_eq!( + assess(&unobserved, PartyOutstandingAuthority::default()) + .unwrap() + .state, + PartyOutstandingConfidenceState::ProfileUnobserved + ); + let mut disabled = unobserved; + disabled.bill_wise_state = BillWiseState::PartyDisabledObserved; + assert_eq!( + assess(&disabled, authority(&disabled)).unwrap().state, + PartyOutstandingConfidenceState::BillWiseDisabled + ); + + let facts = facts(Vec::new(), Vec::new()); + let unavailable = assess(&facts, authority(&facts)).unwrap(); + assert_eq!( + unavailable.state, + PartyOutstandingConfidenceState::Unavailable + ); + assert!(unavailable + .safe_reason_codes + .contains("empty_scope_semantics_unobserved")); + + let mut observed_empty_authority = authority(&facts); + observed_empty_authority.empty_scope_semantics_observed = true; + let proven_empty = assess(&facts, observed_empty_authority).unwrap(); + assert_eq!( + proven_empty.state, + PartyOutstandingConfidenceState::MatchedWithinObservedBracket + ); + assert!(proven_empty + .safe_reason_codes + .contains("proven_empty_observation_scope")); + } + + #[test] + fn opening_allocation_and_partial_settlement_match_exactly() { + let facts = facts( + vec![ + allocation( + "allocation:opening", + BillReferenceKind::NewReference, + Some("INV-1"), + "-1000.00", + BillAllocationOrigin::LedgerOpening, + ), + allocation( + "allocation:receipt-1", + BillReferenceKind::AgainstReference, + Some("INV-1"), + "300.0", + BillAllocationOrigin::Voucher { + voucher_source_id: id("voucher:receipt-1"), + party_entry_source_id: id("entry:receipt-1"), + }, + ), + allocation( + "allocation:receipt-2", + BillReferenceKind::AgainstReference, + Some("INV-1"), + "200", + BillAllocationOrigin::Voucher { + voucher_source_id: id("voucher:receipt-2"), + party_entry_source_id: id("entry:receipt-2"), + }, + ), + ], + vec![outstanding( + "outstanding:1", + BillReferenceKind::NewReference, + Some("INV-1"), + Some("-1000"), + "-500.000", + )], + ); + let result = assess(&facts, authority(&facts)).unwrap(); + assert_eq!( + result.state, + PartyOutstandingConfidenceState::PartiallySettledMatched + ); + assert_eq!(result.matched_reference_count, 1); + assert_eq!(result.rows[0].due_state, DueState::Overdue { days: 1 }); + } + + #[test] + fn party_scoped_reference_and_on_account_aggregate_do_not_invent_bill_links() { + let matched_facts = facts( + vec![allocation( + "allocation:on-account", + BillReferenceKind::OnAccount, + None, + "-50", + BillAllocationOrigin::Voucher { + voucher_source_id: id("voucher:on-account"), + party_entry_source_id: id("entry:on-account"), + }, + )], + vec![outstanding( + "outstanding:on-account", + BillReferenceKind::OnAccount, + None, + None, + "-50.00", + )], + ); + let result = assess(&matched_facts, authority(&matched_facts)).unwrap(); + assert_eq!( + result.state, + PartyOutstandingConfidenceState::OnAccountAggregateMatched + ); + assert!(result.rows[0].outstanding_source_id.is_some()); + let debug = format!("{:?}", result.rows[0]); + assert!(!debug.contains("allocation:on-account")); + assert!(!debug.contains("outstanding:on-account")); + + let omitted = facts( + vec![ + allocation( + "allocation:on-account-debit", + BillReferenceKind::OnAccount, + None, + "-50", + BillAllocationOrigin::LedgerOpening, + ), + allocation( + "allocation:on-account-credit", + BillReferenceKind::OnAccount, + None, + "50", + BillAllocationOrigin::Voucher { + voucher_source_id: id("voucher:on-account-credit"), + party_entry_source_id: id("entry:on-account-credit"), + }, + ), + ], + Vec::new(), + ); + let result = assess(&omitted, authority(&omitted)).unwrap(); + assert_eq!(result.state, PartyOutstandingConfidenceState::Unavailable); + assert!(result + .safe_reason_codes + .contains("on_account_comparison_side_missing")); + } + + #[test] + fn exact_mismatch_currency_drift_and_missing_due_date_fail_closed() { + let base = facts( + vec![allocation( + "allocation:1", + BillReferenceKind::NewReference, + Some("INV-1"), + "-100", + BillAllocationOrigin::Voucher { + voucher_source_id: id("voucher:1"), + party_entry_source_id: id("entry:1"), + }, + )], + vec![outstanding( + "outstanding:1", + BillReferenceKind::NewReference, + Some("INV-1"), + Some("-100"), + "-99.999", + )], + ); + assert_eq!( + assess(&base, authority(&base)).unwrap().state, + PartyOutstandingConfidenceState::Mismatch + ); + + let mut wrong_direction = base.clone(); + wrong_direction.allocations[0].amount = ExactDecimal::parse("100").unwrap(); + wrong_direction.allocations[0].observed_polarity = Some(LedgerEntryPolarity::Credit); + wrong_direction.outstanding[0].opening_amount = Some(ExactDecimal::parse("100").unwrap()); + wrong_direction.outstanding[0].pending_amount = ExactDecimal::parse("100").unwrap(); + wrong_direction.outstanding[0].observed_polarity = Some(LedgerEntryPolarity::Credit); + assert_eq!( + assess(&wrong_direction, authority(&wrong_direction)) + .unwrap() + .state, + PartyOutstandingConfidenceState::Mismatch + ); + + let mut ambiguous_reference = base.clone(); + ambiguous_reference.allocations.push(allocation( + "allocation:advance-conflict", + BillReferenceKind::Advance, + Some("INV-1"), + "-1", + BillAllocationOrigin::LedgerOpening, + )); + ambiguous_reference.source_reported_allocation_count = 2; + let result = assess(&ambiguous_reference, authority(&ambiguous_reference)).unwrap(); + assert_eq!(result.state, PartyOutstandingConfidenceState::Mismatch); + assert!(result + .safe_reason_codes + .contains("bill_reference_kind_composition_invalid")); + + let mut due_unobserved = authority(&base); + due_unobserved.due_date_semantics_observed = false; + assert_eq!( + assess(&base, due_unobserved).unwrap().state, + PartyOutstandingConfidenceState::ProfileUnobserved + ); + + let mut foreign = base.clone(); + foreign.allocations[0].currency_basis = CurrencyBasis::ObservedSource { + currency: text("USD"), + }; + assert_eq!( + assess(&foreign, authority(&foreign)).unwrap().state, + PartyOutstandingConfidenceState::IncomparableCurrency + ); + + let mut drifted = base.clone(); + drifted.fetch_bracket = FetchBracketState::ChangedObserved; + assert_eq!( + assess(&drifted, authority(&drifted)).unwrap().state, + PartyOutstandingConfidenceState::SourceChangedDuringFetch + ); + + let mut missing_due = base; + missing_due.outstanding[0].pending_amount = ExactDecimal::parse("-100").unwrap(); + missing_due.outstanding[0].due_date_yyyymmdd = None; + missing_due.outstanding[0].due_date_evidence = BillDueDateEvidence::Unavailable; + missing_due.outstanding[0].source_reported_overdue_days = None; + assert_eq!( + assess(&missing_due, authority(&missing_due)).unwrap().state, + PartyOutstandingConfidenceState::Unavailable + ); + } + + #[test] + fn zero_net_omission_is_not_settled_without_observed_semantics() { + let correct_settlement = facts( + vec![ + allocation( + "allocation:new", + BillReferenceKind::NewReference, + Some("INV-1"), + "-100", + BillAllocationOrigin::Voucher { + voucher_source_id: id("voucher:new"), + party_entry_source_id: id("entry:new"), + }, + ), + allocation( + "allocation:settle", + BillReferenceKind::AgainstReference, + Some("INV-1"), + "100", + BillAllocationOrigin::Voucher { + voucher_source_id: id("voucher:settle"), + party_entry_source_id: id("entry:settle"), + }, + ), + ], + Vec::new(), + ); + let result = assess(&correct_settlement, authority(&correct_settlement)).unwrap(); + assert_eq!(result.state, PartyOutstandingConfidenceState::Unavailable); + assert!(result + .safe_reason_codes + .contains("settled_omission_semantics_unobserved")); + + let mut observed_omission = authority(&correct_settlement); + observed_omission.settled_omission_semantics_observed = true; + assert_eq!( + assess(&correct_settlement, observed_omission) + .unwrap() + .state, + PartyOutstandingConfidenceState::MatchedWithinObservedBracket + ); + + let wrong_sign = facts( + vec![ + allocation( + "allocation:wrong-new", + BillReferenceKind::NewReference, + Some("INV-WRONG"), + "100", + BillAllocationOrigin::LedgerOpening, + ), + allocation( + "allocation:wrong-settle", + BillReferenceKind::AgainstReference, + Some("INV-WRONG"), + "-100", + BillAllocationOrigin::Voucher { + voucher_source_id: id("voucher:wrong-settle"), + party_entry_source_id: id("entry:wrong-settle"), + }, + ), + ], + Vec::new(), + ); + let mut observed_omission = authority(&wrong_sign); + observed_omission.settled_omission_semantics_observed = true; + assert_eq!( + assess(&wrong_sign, observed_omission).unwrap().state, + PartyOutstandingConfidenceState::Mismatch + ); + + let zero_anchor = facts( + vec![allocation( + "allocation:zero-new", + BillReferenceKind::NewReference, + Some("INV-ZERO"), + "0", + BillAllocationOrigin::LedgerOpening, + )], + Vec::new(), + ); + let mut observed_omission = authority(&zero_anchor); + observed_omission.settled_omission_semantics_observed = true; + assert_eq!( + assess(&zero_anchor, observed_omission).unwrap().state, + PartyOutstandingConfidenceState::Mismatch + ); + } + + #[test] + fn receipt_scope_must_match_caller_expectations_exactly() { + let facts = facts(Vec::new(), Vec::new()); + let debug = format!("{:?} {:?}", expected_scope(&facts), authority(&facts)); + assert!(!debug.contains("company-guid:test")); + assert!(!debug.contains("ledger:party")); + assert!(!debug.contains(&"a".repeat(64))); + let mut expected = expected_scope(&facts); + expected.source_identity.company_guid = "company-guid:other".to_string(); + let error = assess_party_outstanding(&facts, &expected, authority(&facts)).unwrap_err(); + assert!(matches!( + error, + TallyError::InvalidData { code } if code == "party_outstanding_scope_mismatch" + )); + + let mut expected = expected_scope(&facts); + expected.report_as_of_yyyymmdd = TallyDate::parse("20260802").unwrap(); + assert!(assess_party_outstanding(&facts, &expected, authority(&facts)).is_err()); + + let mut expected = expected_scope(&facts); + expected.query_profile = text("different-profile"); + assert!(assess_party_outstanding(&facts, &expected, authority(&facts)).is_err()); + + let authority_for_first_scope = authority(&facts); + let mut other_scope = facts.clone(); + other_scope.source_identity.company_guid = "company-guid:other".to_string(); + assert_eq!( + assess(&other_scope, authority_for_first_scope) + .unwrap() + .state, + PartyOutstandingConfidenceState::ProfileUnobserved + ); + } +} diff --git a/src-tauri/crates/bridge-tally-core/src/destination.rs b/src-tauri/crates/bridge-tally-core/src/destination.rs new file mode 100644 index 0000000..3c78469 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/destination.rs @@ -0,0 +1,535 @@ +use crate::{ + CapabilityPackId, DeliveryReceipt, DeliverySession, DestinationAdapter, PackBatch, + PackSchemaVersion, ProofManifest, RunOutcome, TallyError, VerificationState, +}; +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::sync::Arc; + +const AXAL_TALLY_CONTRACT_VERSION: u16 = 1; +const MAX_IDEMPOTENCY_KEY_BYTES: usize = 200; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AxalTallyCapabilities { + pub contract_version: u16, + pub accepted_pack_versions: BTreeMap, + pub max_batch_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BeginDeliveryRequest { + pub contract_version: u16, + pub proof: ProofManifest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct DeliverBatchRequest { + pub contract_version: u16, + pub delivery_id: String, + pub pack: CapabilityPackId, + pub batch: PackBatch, + pub content_sha256: String, + pub idempotency_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FinalizeDeliveryRequest { + pub contract_version: u16, + pub delivery_id: String, + pub proof: ProofManifest, +} + +#[async_trait] +pub trait AxalTallyGateway: Send + Sync { + async fn capabilities(&self) -> Result; + async fn begin_delivery( + &self, + request: BeginDeliveryRequest, + ) -> Result; + async fn deliver_batch( + &self, + request: DeliverBatchRequest, + ) -> Result; + async fn finalize_delivery( + &self, + request: FinalizeDeliveryRequest, + ) -> Result; +} + +#[derive(Debug, Default)] +pub struct UnconfiguredAxalTallyGateway; + +#[async_trait] +impl AxalTallyGateway for UnconfiguredAxalTallyGateway { + async fn capabilities(&self) -> Result { + Err(TallyError::Unsupported { + code: "axal_tally_contract_not_configured".to_string(), + }) + } + + async fn begin_delivery( + &self, + _request: BeginDeliveryRequest, + ) -> Result { + Err(TallyError::Unsupported { + code: "axal_tally_contract_not_configured".to_string(), + }) + } + + async fn deliver_batch( + &self, + _request: DeliverBatchRequest, + ) -> Result { + Err(TallyError::Unsupported { + code: "axal_tally_contract_not_configured".to_string(), + }) + } + + async fn finalize_delivery( + &self, + _request: FinalizeDeliveryRequest, + ) -> Result { + Err(TallyError::Unsupported { + code: "axal_tally_contract_not_configured".to_string(), + }) + } +} + +#[derive(Clone)] +pub struct AxalDestinationAdapter { + gateway: Arc, +} + +impl AxalDestinationAdapter +where + G: AxalTallyGateway, +{ + pub fn new(gateway: Arc) -> Self { + Self { gateway } + } +} + +#[async_trait] +impl DestinationAdapter for AxalDestinationAdapter +where + G: AxalTallyGateway + 'static, +{ + async fn supported_packs( + &self, + ) -> Result, TallyError> { + let capabilities = self.gateway.capabilities().await?; + validate_capabilities(&capabilities)?; + Ok(capabilities.accepted_pack_versions) + } + + async fn begin_delivery(&self, proof: &ProofManifest) -> Result { + validate_deliverable_proof(proof)?; + let capabilities = self.gateway.capabilities().await?; + validate_capabilities(&capabilities)?; + let accepted_version = capabilities + .accepted_pack_versions + .get(&proof.pack) + .ok_or_else(|| TallyError::Unsupported { + code: "axal_pack_not_accepted".to_string(), + })?; + if accepted_version != &proof.pack_schema_version { + return Err(TallyError::Unsupported { + code: "axal_pack_schema_not_accepted".to_string(), + }); + } + + let session = self + .gateway + .begin_delivery(BeginDeliveryRequest { + contract_version: AXAL_TALLY_CONTRACT_VERSION, + proof: proof.clone(), + }) + .await?; + validate_session(&session, &capabilities, proof)?; + Ok(session) + } + + async fn deliver_batch( + &self, + session: &DeliverySession, + batch: &PackBatch, + content_sha256: &str, + idempotency_key: &str, + ) -> Result { + validate_session_shape(session)?; + validate_idempotency_key(idempotency_key)?; + validate_sha256(content_sha256, "invalid_content_sha256")?; + let pack = pack_id(batch); + if !session.accepted_pack_versions.contains_key(&pack) { + return Err(TallyError::Unsupported { + code: "delivery_session_does_not_accept_pack".to_string(), + }); + } + let canonical = serde_json::to_vec(batch).map_err(|_| TallyError::InvalidData { + code: "batch_serialization_failed".to_string(), + })?; + if canonical.len() as u64 > session.max_batch_bytes { + return Err(TallyError::InvalidData { + code: "batch_exceeds_negotiated_limit".to_string(), + }); + } + let calculated_hash = sha256_hex(&canonical); + if calculated_hash != content_sha256 { + return Err(TallyError::InvalidData { + code: "batch_content_hash_mismatch".to_string(), + }); + } + + let receipt = self + .gateway + .deliver_batch(DeliverBatchRequest { + contract_version: AXAL_TALLY_CONTRACT_VERSION, + delivery_id: session.delivery_id.clone(), + pack, + batch: batch.clone(), + content_sha256: content_sha256.to_string(), + idempotency_key: idempotency_key.to_string(), + }) + .await?; + validate_receipt(&receipt, session, content_sha256, false)?; + Ok(receipt) + } + + async fn finalize_delivery( + &self, + session: &DeliverySession, + proof: &ProofManifest, + ) -> Result { + validate_session_shape(session)?; + validate_deliverable_proof(proof)?; + let proof_hash = + sha256_hex( + &serde_json::to_vec(proof).map_err(|_| TallyError::InvalidData { + code: "proof_serialization_failed".to_string(), + })?, + ); + let receipt = self + .gateway + .finalize_delivery(FinalizeDeliveryRequest { + contract_version: AXAL_TALLY_CONTRACT_VERSION, + delivery_id: session.delivery_id.clone(), + proof: proof.clone(), + }) + .await?; + validate_receipt(&receipt, session, &proof_hash, true)?; + Ok(receipt) + } +} + +fn validate_capabilities(capabilities: &AxalTallyCapabilities) -> Result<(), TallyError> { + if capabilities.contract_version != AXAL_TALLY_CONTRACT_VERSION { + return Err(TallyError::Unsupported { + code: "axal_tally_contract_version_unsupported".to_string(), + }); + } + if capabilities.max_batch_bytes == 0 || capabilities.accepted_pack_versions.is_empty() { + return Err(TallyError::Protocol { + code: "axal_tally_capabilities_invalid".to_string(), + }); + } + if capabilities + .accepted_pack_versions + .values() + .any(|version| version.major == 0) + { + return Err(TallyError::Protocol { + code: "axal_pack_schema_invalid".to_string(), + }); + } + Ok(()) +} + +fn validate_session( + session: &DeliverySession, + capabilities: &AxalTallyCapabilities, + proof: &ProofManifest, +) -> Result<(), TallyError> { + validate_session_shape(session)?; + if session.max_batch_bytes > capabilities.max_batch_bytes + || session.accepted_pack_versions != capabilities.accepted_pack_versions + || session.accepted_pack_versions.get(&proof.pack) != Some(&proof.pack_schema_version) + { + return Err(TallyError::Protocol { + code: "axal_delivery_session_changed_negotiated_scope".to_string(), + }); + } + Ok(()) +} + +fn validate_session_shape(session: &DeliverySession) -> Result<(), TallyError> { + if session.delivery_id.is_empty() + || session.delivery_id.len() > 200 + || session.delivery_id.chars().any(char::is_control) + || session.max_batch_bytes == 0 + || session.accepted_pack_versions.is_empty() + { + return Err(TallyError::Protocol { + code: "axal_delivery_session_invalid".to_string(), + }); + } + Ok(()) +} + +fn validate_deliverable_proof(proof: &ProofManifest) -> Result<(), TallyError> { + if proof.proof_contract_version == 0 + || proof.run_id.is_empty() + || proof.outcome != RunOutcome::Completed + || proof.verification != VerificationState::Verified + || proof.snapshot_sha256.is_none() + || !proof.gaps.is_empty() + || proof.completed_at_unix_ms.is_none() + { + return Err(TallyError::InvalidData { + code: "proof_not_verified_for_delivery".to_string(), + }); + } + validate_sha256( + proof.snapshot_sha256.as_deref().unwrap_or_default(), + "proof_snapshot_hash_invalid", + ) +} + +fn validate_idempotency_key(value: &str) -> Result<(), TallyError> { + if value.is_empty() + || value.len() > MAX_IDEMPOTENCY_KEY_BYTES + || value.chars().any(|character| { + character.is_control() + || !(character.is_ascii_alphanumeric() + || matches!(character, '-' | '_' | ':' | '.')) + }) + { + return Err(TallyError::InvalidData { + code: "idempotency_key_invalid".to_string(), + }); + } + Ok(()) +} + +fn validate_sha256(value: &str, code: &'static str) -> Result<(), TallyError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(TallyError::InvalidData { + code: code.to_string(), + }); + } + Ok(()) +} + +fn validate_receipt( + receipt: &DeliveryReceipt, + session: &DeliverySession, + expected_hash: &str, + must_be_committed: bool, +) -> Result<(), TallyError> { + if receipt.delivery_id != session.delivery_id + || receipt.receipt_id.is_empty() + || receipt.receipt_id.len() > 200 + || receipt.receipt_id.chars().any(char::is_control) + || receipt.content_sha256 != expected_hash + || (must_be_committed && !receipt.committed) + || (!must_be_committed && receipt.committed) + { + return Err(TallyError::Protocol { + code: "axal_delivery_receipt_invalid".to_string(), + }); + } + Ok(()) +} + +fn pack_id(batch: &PackBatch) -> CapabilityPackId { + match batch { + PackBatch::CoreAccounting(_) => CapabilityPackId::CoreAccounting, + PackBatch::IndiaTax(_) => CapabilityPackId::IndiaTax, + PackBatch::BillsAndPayments(_) => CapabilityPackId::BillsAndPayments, + PackBatch::Inventory(_) => CapabilityPackId::Inventory, + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let digest = Sha256::digest(bytes); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{CoreAccountingBatch, Freshness, SourceIdentity, PROOF_CONTRACT_VERSION}; + use std::sync::Mutex; + + struct FakeGateway { + capabilities: AxalTallyCapabilities, + corrupt_receipt_hash: bool, + delivered_keys: Mutex>, + } + + #[async_trait] + impl AxalTallyGateway for FakeGateway { + async fn capabilities(&self) -> Result { + Ok(self.capabilities.clone()) + } + + async fn begin_delivery( + &self, + _request: BeginDeliveryRequest, + ) -> Result { + Ok(DeliverySession { + delivery_id: "delivery-1".to_string(), + accepted_pack_versions: self.capabilities.accepted_pack_versions.clone(), + max_batch_bytes: self.capabilities.max_batch_bytes, + }) + } + + async fn deliver_batch( + &self, + request: DeliverBatchRequest, + ) -> Result { + self.delivered_keys + .lock() + .expect("delivery keys lock") + .push(request.idempotency_key); + Ok(DeliveryReceipt { + delivery_id: request.delivery_id, + receipt_id: "receipt-1".to_string(), + content_sha256: if self.corrupt_receipt_hash { + "0".repeat(64) + } else { + request.content_sha256 + }, + committed: false, + }) + } + + async fn finalize_delivery( + &self, + request: FinalizeDeliveryRequest, + ) -> Result { + Ok(DeliveryReceipt { + delivery_id: request.delivery_id, + receipt_id: "receipt-final".to_string(), + content_sha256: sha256_hex( + &serde_json::to_vec(&request.proof).expect("serialize proof"), + ), + committed: true, + }) + } + } + + fn capabilities() -> AxalTallyCapabilities { + AxalTallyCapabilities { + contract_version: AXAL_TALLY_CONTRACT_VERSION, + accepted_pack_versions: BTreeMap::from([( + CapabilityPackId::CoreAccounting, + PackSchemaVersion { major: 1, minor: 0 }, + )]), + max_batch_bytes: 1024 * 1024, + } + } + + fn proof() -> ProofManifest { + ProofManifest { + proof_contract_version: PROOF_CONTRACT_VERSION, + run_id: "run-1".to_string(), + source_identity: SourceIdentity { + bridge_source_lineage: "lineage-1".to_string(), + company_guid: "company-1".to_string(), + observed_fingerprint: "fingerprint-1".to_string(), + }, + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + freshness: Freshness::Fresh, + started_at_unix_ms: 1, + completed_at_unix_ms: Some(2), + record_counts: BTreeMap::new(), + snapshot_sha256: Some("a".repeat(64)), + gaps: Vec::new(), + } + } + + #[tokio::test] + async fn delivers_only_verified_hash_matching_batches_and_receipts() { + let gateway = Arc::new(FakeGateway { + capabilities: capabilities(), + corrupt_receipt_hash: false, + delivered_keys: Mutex::new(Vec::new()), + }); + let adapter = AxalDestinationAdapter::new(gateway.clone()); + let session = adapter + .begin_delivery(&proof()) + .await + .expect("begin delivery"); + let batch = PackBatch::CoreAccounting(CoreAccountingBatch::default()); + let content_hash = sha256_hex(&serde_json::to_vec(&batch).expect("serialize batch")); + adapter + .deliver_batch(&session, &batch, &content_hash, "run-1:core:0") + .await + .expect("deliver batch"); + adapter + .finalize_delivery(&session, &proof()) + .await + .expect("finalize delivery"); + assert_eq!( + gateway + .delivered_keys + .lock() + .expect("delivery keys lock") + .as_slice(), + ["run-1:core:0"] + ); + } + + #[tokio::test] + async fn rejects_partial_proofs_changed_hashes_and_corrupt_receipts() { + let gateway = Arc::new(FakeGateway { + capabilities: capabilities(), + corrupt_receipt_hash: true, + delivered_keys: Mutex::new(Vec::new()), + }); + let adapter = AxalDestinationAdapter::new(gateway); + let mut partial = proof(); + partial.verification = VerificationState::Partial; + assert!(adapter.begin_delivery(&partial).await.is_err()); + + let session = adapter + .begin_delivery(&proof()) + .await + .expect("begin delivery"); + let batch = PackBatch::CoreAccounting(CoreAccountingBatch::default()); + assert!(adapter + .deliver_batch(&session, &batch, &"b".repeat(64), "run-1:core:0") + .await + .is_err()); + let content_hash = sha256_hex(&serde_json::to_vec(&batch).expect("serialize batch")); + assert!(adapter + .deliver_batch(&session, &batch, &content_hash, "run-1:core:0") + .await + .is_err()); + } + + #[tokio::test] + async fn unconfigured_gateway_fails_explicitly() { + let adapter = AxalDestinationAdapter::new(Arc::new(UnconfiguredAxalTallyGateway)); + let error = adapter + .supported_packs() + .await + .expect_err("unconfigured gateway must not imply support"); + assert!(matches!(error, TallyError::Unsupported { .. })); + } +} diff --git a/src-tauri/crates/bridge-tally-core/src/exact_arithmetic.rs b/src-tauri/crates/bridge-tally-core/src/exact_arithmetic.rs new file mode 100644 index 0000000..384bab2 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/exact_arithmetic.rs @@ -0,0 +1,191 @@ +use std::cmp::Ordering; + +/// Exact signed decimal accumulator for already-validated `ExactDecimal` +/// lexemes. It never converts through floating point and treats scale-only +/// differences and negative zero as numerically equal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ExactDecimalAccumulator { + negative: bool, + digits: String, + scale: usize, +} + +impl Default for ExactDecimalAccumulator { + fn default() -> Self { + Self { + negative: false, + digits: "0".to_string(), + scale: 0, + } + } +} + +impl ExactDecimalAccumulator { + pub(crate) fn add(&mut self, value: &str) { + let (negative, digits, scale) = decimal_parts(value); + self.combine(negative, digits, scale); + } + + pub(crate) fn subtract(&mut self, value: &str) { + let (negative, digits, scale) = decimal_parts(value); + let zero = digits.bytes().all(|digit| digit == b'0'); + self.combine(if zero { false } else { !negative }, digits, scale); + } + + pub(crate) fn is_zero(&self) -> bool { + self.digits.bytes().all(|digit| digit == b'0') + } + + pub(crate) fn is_negative_nonzero(&self) -> bool { + self.negative && !self.is_zero() + } + + pub(crate) fn equals(&self, value: &str) -> bool { + let mut difference = self.clone(); + difference.subtract(value); + difference.is_zero() + } + + fn combine(&mut self, negative: bool, digits: String, scale: usize) { + let target_scale = self.scale.max(scale); + let left = aligned_digits(&self.digits, target_scale - self.scale); + let right = aligned_digits(&digits, target_scale - scale); + let (negative, digits) = if self.negative == negative { + (self.negative, add_unsigned(&left, &right)) + } else { + match compare_unsigned(&left, &right) { + Ordering::Greater => (self.negative, subtract_unsigned(&left, &right)), + Ordering::Less => (negative, subtract_unsigned(&right, &left)), + Ordering::Equal => (false, "0".to_string()), + } + }; + self.negative = negative; + self.digits = digits; + self.scale = target_scale; + self.normalise(); + } + + fn normalise(&mut self) { + self.digits = self.digits.trim_start_matches('0').to_string(); + while self.scale > 0 && self.digits.ends_with('0') { + self.digits.pop(); + self.scale -= 1; + } + if self.digits.is_empty() { + self.digits.push('0'); + self.negative = false; + self.scale = 0; + } + } +} + +pub(crate) fn numeric_equal(left: &str, right: &str) -> bool { + let mut difference = ExactDecimalAccumulator::default(); + difference.add(left); + difference.subtract(right); + difference.is_zero() +} + +pub(crate) fn is_negative_nonzero(value: &str) -> bool { + let mut parsed = ExactDecimalAccumulator::default(); + parsed.add(value); + parsed.is_negative_nonzero() +} + +pub(crate) fn magnitude_cmp(left: &str, right: &str) -> Ordering { + let (_, left_digits, left_scale) = decimal_parts(left); + let (_, right_digits, right_scale) = decimal_parts(right); + let target_scale = left_scale.max(right_scale); + compare_unsigned( + &aligned_digits(&left_digits, target_scale - left_scale), + &aligned_digits(&right_digits, target_scale - right_scale), + ) +} + +pub(crate) fn same_nonzero_sign(left: &str, right: &str) -> bool { + if numeric_equal(left, "0") || numeric_equal(right, "0") { + return false; + } + is_negative_nonzero(left) == is_negative_nonzero(right) +} + +fn decimal_parts(value: &str) -> (bool, String, usize) { + let (negative, value) = value + .strip_prefix('-') + .map_or((false, value), |unsigned| (true, unsigned)); + let (whole, fractional) = value.split_once('.').unwrap_or((value, "")); + (negative, format!("{whole}{fractional}"), fractional.len()) +} + +fn aligned_digits(digits: &str, zeros: usize) -> String { + let mut aligned = String::with_capacity(digits.len() + zeros); + aligned.push_str(digits); + aligned.extend(std::iter::repeat_n('0', zeros)); + aligned +} + +fn compare_unsigned(left: &str, right: &str) -> Ordering { + let left = left.trim_start_matches('0'); + let right = right.trim_start_matches('0'); + left.len().cmp(&right.len()).then_with(|| left.cmp(right)) +} + +fn add_unsigned(left: &str, right: &str) -> String { + let mut carry = 0_u8; + let mut result = Vec::new(); + let mut left = left.bytes().rev(); + let mut right = right.bytes().rev(); + loop { + let left_digit = left.next().map(|byte| byte - b'0'); + let right_digit = right.next().map(|byte| byte - b'0'); + if left_digit.is_none() && right_digit.is_none() && carry == 0 { + break; + } + let sum = left_digit.unwrap_or(0) + right_digit.unwrap_or(0) + carry; + result.push(b'0' + sum % 10); + carry = sum / 10; + } + result.reverse(); + String::from_utf8(result).expect("decimal digits are ASCII") +} + +fn subtract_unsigned(larger: &str, smaller: &str) -> String { + let mut borrow = 0_i8; + let mut result = Vec::new(); + let mut smaller = smaller.bytes().rev(); + for byte in larger.bytes().rev() { + let mut digit = (byte - b'0') as i8 - borrow; + let subtract = smaller.next().map_or(0, |value| (value - b'0') as i8); + if digit < subtract { + digit += 10; + borrow = 1; + } else { + borrow = 0; + } + result.push(b'0' + (digit - subtract) as u8); + } + while result.len() > 1 && result.last() == Some(&b'0') { + result.pop(); + } + result.reverse(); + String::from_utf8(result).expect("decimal digits are ASCII") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_accumulator_handles_scale_sign_large_values_and_negative_zero() { + let mut value = ExactDecimalAccumulator::default(); + value.add("999999999999999999999.0010"); + value.add("-999999999999999999999.001"); + assert!(value.is_zero()); + assert!(numeric_equal("-0.000", "0")); + assert!(numeric_equal("1.2300", "1.23")); + assert!(is_negative_nonzero("-0.001")); + assert!(!is_negative_nonzero("-0.000")); + assert_eq!(magnitude_cmp("-10.00", "9.999"), Ordering::Greater); + assert!(same_nonzero_sign("-10", "-1.0")); + } +} diff --git a/src-tauri/crates/bridge-tally-core/src/lib.rs b/src-tauri/crates/bridge-tally-core/src/lib.rs new file mode 100644 index 0000000..5390283 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/lib.rs @@ -0,0 +1,574 @@ +use async_trait::async_trait; +use serde::{Deserialize, Deserializer, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +pub mod bills_reconciliation; +mod exact_arithmetic; +mod pack_models; +pub mod reconciliation; +pub mod report_tie_out; +pub mod transport_qualification; + +pub use pack_models::*; + +pub mod destination; + +pub const PROOF_CONTRACT_VERSION: u16 = 3; +const MAX_EXACT_DECIMAL_BYTES: usize = 256; +pub const CORE_ACCOUNTING_SCHEMA_VERSION: PackSchemaVersion = + PackSchemaVersion { major: 3, minor: 0 }; +pub const BILLS_AND_PAYMENTS_SCHEMA_VERSION: PackSchemaVersion = + PackSchemaVersion { major: 2, minor: 0 }; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityPackId { + CoreAccounting, + IndiaTax, + BillsAndPayments, + Inventory, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportId { + XmlHttp, + JsonEx, + TdlCompanion, + Odbc, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityFeatureId { + EndpointReachability, + LoadedCompanies, + StableCompanyIdentity, + EncodingBehaviour, + PracticalResponseLimit, + CompanyRead, + LedgerRead, + VoucherRead, + SelectedLedgerRead, + SelectedVoucherWindowRead, + Write, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct PackSchemaVersion { + pub major: u16, + pub minor: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SourceIdentity { + pub bridge_source_lineage: String, + pub company_guid: String, + pub observed_fingerprint: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct CompanyRef { + pub identity: SourceIdentity, + pub display_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ReadWindow { + pub from_yyyymmdd: String, + pub to_yyyymmdd: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct RequestContext { + pub run_id: String, + pub company: CompanyRef, + pub pack: CapabilityPackId, + pub schema_version: PackSchemaVersion, + pub window: ReadWindow, + pub query_profile: CanonicalText, + pub filters_sha256: CanonicalText, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CapabilityState { + Supported, + Unsupported, + Unknown, + NotConfigured, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceConfidence { + Documented, + Observed, + Inferred, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct CapabilityEvidence { + pub state: CapabilityState, + pub confidence: EvidenceConfidence, + pub safe_reason_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct CapabilityProfile { + pub profile_version: u16, + pub product: String, + pub release: Option, + pub mode: Option, + pub transports: BTreeMap, + #[serde(default)] + pub features: BTreeMap, + pub packs: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct Gap { + pub pack: CapabilityPackId, + pub field_or_invariant: String, + pub state: CapabilityState, + pub safe_reason_code: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct ExactDecimal(String); + +impl ExactDecimal { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + let bytes = value.as_bytes(); + let body = bytes.strip_prefix(b"-").unwrap_or(bytes); + let mut sections = body.split(|byte| *byte == b'.'); + let whole = sections.next().unwrap_or_default(); + let fractional = sections.next(); + let valid = bytes.len() <= MAX_EXACT_DECIMAL_BYTES + && !whole.is_empty() + && whole.iter().all(u8::is_ascii_digit) + && fractional + .is_none_or(|part| !part.is_empty() && part.iter().all(u8::is_ascii_digit)) + && sections.next().is_none(); + if !valid { + return Err(TallyError::InvalidData { + code: "invalid_exact_decimal".to_string(), + }); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for ExactDecimal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct GroupRecord { + pub source_id: String, + pub name: String, + pub parent_source_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct LedgerRecord { + pub source_id: String, + pub name: String, + pub parent_source_id: Option, + pub opening_balance: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct VoucherTypeRecord { + pub source_id: String, + pub name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct VoucherRecord { + pub source_id: String, + pub date_yyyymmdd: String, + pub voucher_type_source_id: String, + pub voucher_number: Option, + pub cancelled: bool, + /// Source-observed Tally optional-voucher state. Optional vouchers do not + /// affect ordinary books unless an explicitly selected scenario includes + /// them; Bridge's Core profile is bound to ordinary books. + pub optional: bool, +} + +/// Tally's `ISDEEMEDPOSITIVE` accounting polarity. Tally represents debits +/// with `Yes` and credits with `No`; the signed amount must independently +/// agree with this observed flag. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LedgerEntryPolarity { + Debit, + Credit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct LedgerEntryRecord { + pub source_id: String, + pub voucher_source_id: String, + pub ledger_source_id: String, + pub amount: ExactDecimal, + pub polarity: LedgerEntryPolarity, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +pub struct CoreAccountingBatch { + pub groups: Vec, + pub ledgers: Vec, + pub voucher_types: Vec, + pub vouchers: Vec, + pub ledger_entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "pack", content = "batch", rename_all = "snake_case")] +pub enum PackBatch { + CoreAccounting(CoreAccountingBatch), + IndiaTax(IndiaTaxBatch), + BillsAndPayments(BillsAndPaymentsBatch), + Inventory(InventoryBatch), +} + +pub type CanonicalPackWindow = PackWindow; + +impl PackWindow { + /// Verifies that supplied provenance is a one-to-one cover of every + /// canonical record in the returned batch. Absence is allowed so tests and + /// synthetic producers remain representable; callers must treat it as an + /// explicit provenance gap rather than manufacturing evidence. + pub fn validate_record_evidence_binding(&self) -> Result<(), TallyError> { + self.validate_record_evidence()?; + let Some(record_evidence) = &self.record_evidence else { + return Ok(()); + }; + + let expected = canonical_record_keys(&self.batch); + let supplied = record_evidence + .iter() + .map(|evidence| { + ( + evidence.object_type.as_str().to_string(), + evidence.source_id.as_str().to_string(), + ) + }) + .collect::>(); + if supplied != expected { + return Err(TallyError::InvalidData { + code: "source_record_evidence_binding_mismatch".to_string(), + }); + } + Ok(()) + } +} + +fn canonical_record_keys(batch: &PackBatch) -> BTreeSet<(String, String)> { + let mut keys = BTreeSet::new(); + macro_rules! insert_records { + ($records:expr, $object_type:literal) => { + keys.extend($records.iter().map(|record| { + ( + $object_type.to_string(), + record.source_id.as_str().to_string(), + ) + })); + }; + } + macro_rules! insert_core_records { + ($records:expr, $object_type:literal) => { + keys.extend( + $records + .iter() + .map(|record| ($object_type.to_string(), record.source_id.clone())), + ); + }; + } + + match batch { + PackBatch::CoreAccounting(core) => { + insert_core_records!(core.groups, "group"); + insert_core_records!(core.ledgers, "ledger"); + insert_core_records!(core.voucher_types, "voucher_type"); + insert_core_records!(core.vouchers, "voucher"); + insert_core_records!(core.ledger_entries, "ledger_entry"); + } + PackBatch::IndiaTax(tax) => { + insert_records!(tax.tax_registrations, "tax_registration"); + insert_records!(tax.voucher_taxes, "voucher_tax"); + } + PackBatch::BillsAndPayments(bills) => { + for party in &bills.parties { + keys.insert(( + "party_outstanding".to_string(), + party.party_ledger_source_id.as_str().to_string(), + )); + insert_records!(party.allocations, "bill_allocation"); + insert_records!(party.outstanding, "bill_outstanding"); + } + } + PackBatch::Inventory(inventory) => { + insert_records!(inventory.stock_items, "stock_item"); + insert_records!(inventory.godowns, "godown"); + insert_records!(inventory.inventory_entries, "inventory_entry"); + } + } + keys +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RunOutcome { + Completed, + Failed, + Cancelled, + OutcomeUnknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum VerificationState { + Verified, + Partial, + Unverified, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Freshness { + Fresh, + Stale, + NeverVerified, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProofManifest { + pub proof_contract_version: u16, + pub run_id: String, + pub source_identity: SourceIdentity, + pub pack: CapabilityPackId, + pub pack_schema_version: PackSchemaVersion, + pub outcome: RunOutcome, + pub verification: VerificationState, + pub freshness: Freshness, + pub started_at_unix_ms: i64, + pub completed_at_unix_ms: Option, + pub record_counts: BTreeMap, + pub snapshot_sha256: Option, + pub gaps: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ProbeResult { + pub reachable: bool, + pub profile: CapabilityProfile, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DeliverySession { + pub delivery_id: String, + pub accepted_pack_versions: BTreeMap, + pub max_batch_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DeliveryReceipt { + pub delivery_id: String, + pub receipt_id: String, + pub content_sha256: String, + pub committed: bool, +} + +#[derive(Debug, thiserror::Error)] +pub enum TallyError { + #[error("Tally is not reachable")] + Unreachable, + #[error("Tally returned an invalid protocol response ({code})")] + Protocol { code: String }, + #[error("Tally data failed validation ({code})")] + InvalidData { code: String }, + #[error("Capability is unavailable ({code})")] + Unsupported { code: String }, + #[error("Tally read response exceeded the bounded limit ({scope:?})")] + ReadResponseTooLarge { scope: ReadResponseScope }, + #[error("Operation was cancelled")] + Cancelled, + #[error("The outcome of the write could not be proven")] + OutcomeUnknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadResponseScope { + VoucherWindow, +} + +#[async_trait] +pub trait TallyConnector: Send + Sync { + async fn probe(&self) -> Result; + /// Performs a new source observation rather than returning capability + /// evidence cached by an earlier probe. Connectors that cannot make that + /// guarantee must leave the check unavailable. + async fn probe_fresh(&self) -> Result { + Err(TallyError::Unsupported { + code: "fresh_capability_probe_not_supported".to_string(), + }) + } + async fn discover_companies(&self) -> Result, TallyError>; + async fn read_pack_window( + &self, + context: &RequestContext, + ) -> Result; + async fn read_core_period_balance_report( + &self, + _context: &RequestContext, + ) -> Result { + Err(TallyError::Unsupported { + code: "core_period_balance_report_not_supported".to_string(), + }) + } +} + +#[async_trait] +pub trait DestinationAdapter: Send + Sync { + async fn supported_packs( + &self, + ) -> Result, TallyError>; + async fn begin_delivery(&self, proof: &ProofManifest) -> Result; + async fn deliver_batch( + &self, + session: &DeliverySession, + batch: &PackBatch, + content_sha256: &str, + idempotency_key: &str, + ) -> Result; + async fn finalize_delivery( + &self, + session: &DeliverySession, + proof: &ProofManifest, + ) -> Result; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_decimals_round_trip_without_float_conversion() { + for value in ["0", "0.00", "12345678901234567890.0001", "-1180.00"] { + let parsed = ExactDecimal::parse(value).expect("valid exact decimal"); + assert_eq!(parsed.as_str(), value); + } + } + + #[test] + fn exact_decimals_reject_ambiguous_or_non_numeric_values() { + for value in ["", "-", ".1", "1.", "+1", "1,000", "NaN", "1.2.3"] { + assert!( + ExactDecimal::parse(value).is_err(), + "unexpectedly accepted {value}" + ); + } + assert!(ExactDecimal::parse("9".repeat(MAX_EXACT_DECIMAL_BYTES + 1)).is_err()); + } + + #[test] + fn legacy_capability_profiles_deserialize_without_inventing_feature_evidence() { + let profile: CapabilityProfile = serde_json::from_str( + r#"{ + "profile_version": 1, + "product": "Unknown", + "release": null, + "mode": null, + "transports": {}, + "packs": {} + }"#, + ) + .expect("deserialize legacy profile"); + + assert_eq!(profile.profile_version, 1); + assert!(profile.features.is_empty()); + } + + fn record_evidence(object_type: &str, source_id: &str) -> SourceRecordEvidence { + let source_id = SourceRecordId::parse(source_id).unwrap(); + SourceRecordEvidence { + object_type: CanonicalText::parse(object_type).unwrap(), + source_id: source_id.clone(), + identity_kind: SourceIdentityKind::Guid, + observed_identities: ObservedSourceIdentities { + guid: Some(source_id), + ..Default::default() + }, + raw_source_sha256: RawSourceSha256::parse("a".repeat(64)).unwrap(), + alter_id: Some(SourceAlterId::parse("alter:42").unwrap()), + } + } + + #[test] + fn record_provenance_must_bind_one_to_one_to_canonical_records() { + let batch = PackBatch::Inventory(InventoryBatch { + stock_items: vec![StockItemRecord { + source_id: SourceRecordId::parse("stock:1").unwrap(), + name: CanonicalText::parse("Synthetic Item").unwrap(), + base_unit: CanonicalText::parse("nos").unwrap(), + }], + godowns: Vec::new(), + inventory_entries: Vec::new(), + }); + let valid = CanonicalPackWindow { + batch: batch.clone(), + source_counts: None, + record_evidence: Some(vec![record_evidence("stock_item", "stock:1")]), + }; + valid.validate_record_evidence_binding().unwrap(); + + let missing = CanonicalPackWindow { + batch: batch.clone(), + source_counts: None, + record_evidence: Some(vec![record_evidence("godown", "stock:1")]), + }; + assert!(matches!( + missing.validate_record_evidence_binding(), + Err(TallyError::InvalidData { code }) + if code == "source_record_evidence_binding_mismatch" + )); + + let duplicate = CanonicalPackWindow { + batch, + source_counts: None, + record_evidence: Some(vec![ + record_evidence("stock_item", "stock:1"), + record_evidence("stock_item", "stock:1"), + ]), + }; + assert!(matches!( + duplicate.validate_record_evidence_binding(), + Err(TallyError::InvalidData { code }) + if code == "source_record_evidence_duplicate_record" + )); + } + + #[test] + fn record_provenance_rejects_noncanonical_hashes_and_alter_ids() { + assert!(RawSourceSha256::parse("A".repeat(64)).is_err()); + assert!(RawSourceSha256::parse("a".repeat(63)).is_err()); + assert!(SourceAlterId::parse("contains whitespace").is_err()); + assert!(SourceAlterId::parse("alter:42").is_ok()); + } +} diff --git a/src-tauri/crates/bridge-tally-core/src/pack_models.rs b/src-tauri/crates/bridge-tally-core/src/pack_models.rs new file mode 100644 index 0000000..623ea79 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/pack_models.rs @@ -0,0 +1,1355 @@ +use crate::{ + CapabilityPackId, ExactDecimal, PackSchemaVersion, ReadWindow, SourceIdentity, TallyError, +}; +use serde::{Deserialize, Deserializer, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +const MAX_CANONICAL_TEXT_BYTES: usize = 512; +const MAX_SOURCE_ID_BYTES: usize = 512; +const MAX_ALTER_ID_BYTES: usize = 128; + +/// A structurally valid GSTIN-shaped identifier observed from Tally. +/// +/// This type does not claim that the registration exists, is active, belongs to the company, or +/// passed an external GST portal lookup; those are separate evidence states. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct CanonicalText(String); + +impl CanonicalText { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + validate_text(&value, "canonical_text_invalid")?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for CanonicalText { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct Gstin(String); + +impl Gstin { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + let bytes = value.as_bytes(); + let valid = bytes.len() == 15 + && bytes[0..2].iter().all(u8::is_ascii_digit) + && bytes[2..7].iter().all(u8::is_ascii_uppercase) + && bytes[7..11].iter().all(u8::is_ascii_digit) + && bytes[11].is_ascii_uppercase() + && (bytes[12].is_ascii_uppercase() || bytes[12].is_ascii_digit()) + && bytes[13] == b'Z' + && (bytes[14].is_ascii_uppercase() || bytes[14].is_ascii_digit()); + if !valid { + return Err(invalid_data("gstin_invalid")); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct NonNegativeExactDecimal(ExactDecimal); + +impl NonNegativeExactDecimal { + pub fn parse(value: impl Into) -> Result { + let value = ExactDecimal::parse(value)?; + if value.as_str().starts_with('-') { + return Err(invalid_data("negative_exact_decimal")); + } + Ok(Self(value)) + } + + pub fn as_exact_decimal(&self) -> &ExactDecimal { + &self.0 + } +} + +impl<'de> Deserialize<'de> for NonNegativeExactDecimal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +impl<'de> Deserialize<'de> for Gstin { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceCountScope { + /// The source explicitly reported the count for the complete object/query + /// scope, not merely for the records parsed in this response. + Complete, + Window, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SourceReportedCountEvidence { + pub object_type: CanonicalText, + pub query_profile: CanonicalText, + /// Stable fingerprint of the exact company/filter/window count scope. + pub source_scope_fingerprint: CanonicalText, + pub source_count_scope: SourceCountScope, + pub source_reported_count: u64, +} + +/// Identifies which Tally-origin identity field produced a canonical record's +/// source ID. Consumers must preserve the selected field rather than guessing +/// that every identifier is a remote ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceIdentityKind { + Guid, + RemoteId, + MasterId, + Fallback, +} + +/// SHA-256 of the exact source payload from which the record was parsed. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct RawSourceSha256(String); + +impl RawSourceSha256 { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if !is_lower_sha256(&value) { + return Err(invalid_data("raw_source_sha256_invalid")); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for RawSourceSha256 { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +/// Source alter identifier observed with a record, retained as an opaque +/// printable token rather than interpreted as a number. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct SourceAlterId(String); + +impl SourceAlterId { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_ALTER_ID_BYTES + || value.trim() != value + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':') + }) + { + return Err(invalid_data("source_alter_id_invalid")); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for SourceAlterId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +/// Per-record provenance supplied by the connector. The object type and +/// source ID bind this evidence to exactly one canonical record. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SourceRecordEvidence { + pub object_type: CanonicalText, + pub source_id: SourceRecordId, + pub identity_kind: SourceIdentityKind, + #[serde(default)] + pub observed_identities: ObservedSourceIdentities, + pub raw_source_sha256: RawSourceSha256, + pub alter_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ObservedSourceIdentities { + pub guid: Option, + pub remote_id: Option, + pub master_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct SourceCountScopeDescriptor { + pub source_identity: SourceIdentity, + pub pack: CapabilityPackId, + pub pack_schema_version: PackSchemaVersion, + pub object_type: CanonicalText, + pub query_profile: CanonicalText, + pub filters_sha256: CanonicalText, + /// Must be `None` for `Complete` and the exact requested window for + /// `Window`; the helper rejects the opposite combinations. + pub window: Option, +} + +#[derive(Serialize)] +struct SourceCountScopeFingerprintPreimage<'a> { + contract: &'static str, + source_identity: &'a SourceIdentity, + pack: CapabilityPackId, + pack_schema_version: PackSchemaVersion, + object_type: &'a CanonicalText, + query_profile: &'a CanonicalText, + filters_sha256: &'a CanonicalText, + window: &'a Option, +} + +pub fn source_count_scope_fingerprint( + descriptor: &SourceCountScopeDescriptor, + scope: SourceCountScope, +) -> Result { + validate_scope_descriptor(descriptor, scope)?; + let preimage = SourceCountScopeFingerprintPreimage { + contract: "bridge_tally_source_count_scope_v1", + source_identity: &descriptor.source_identity, + pack: descriptor.pack, + pack_schema_version: descriptor.pack_schema_version, + object_type: &descriptor.object_type, + query_profile: &descriptor.query_profile, + filters_sha256: &descriptor.filters_sha256, + window: &descriptor.window, + }; + let canonical = serde_json::to_vec(&preimage) + .map_err(|_| invalid_data("source_count_scope_serialization_failed"))?; + let digest = Sha256::digest(canonical); + CanonicalText::parse(hex_lower(&digest)) +} + +impl SourceReportedCountEvidence { + pub fn matches_scope_descriptor( + &self, + descriptor: &SourceCountScopeDescriptor, + ) -> Result { + if self.object_type != descriptor.object_type + || self.query_profile != descriptor.query_profile + { + return Ok(false); + } + Ok(self.source_scope_fingerprint + == source_count_scope_fingerprint(descriptor, self.source_count_scope)?) + } +} + +/// Canonical response envelope for a single requested pack window. Counts and +/// per-record provenance are optional because Bridge must never invent source +/// evidence. Missing record evidence is valid for synthetic/test producers, +/// but production reconciliation must classify that window as partial. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PackWindow { + pub batch: T, + pub source_counts: Option>, + pub record_evidence: Option>, +} + +impl PackWindow { + pub fn without_source_count_evidence(batch: T) -> Self { + Self { + batch, + source_counts: None, + record_evidence: None, + } + } + + pub fn validate_source_count_evidence(&self) -> Result<(), TallyError> { + let Some(source_counts) = &self.source_counts else { + return Ok(()); + }; + if source_counts.is_empty() { + return Err(invalid_data("source_count_evidence_empty")); + } + let mut scopes = BTreeSet::new(); + for evidence in source_counts { + let key = ( + evidence.object_type.as_str(), + evidence.query_profile.as_str(), + evidence.source_scope_fingerprint.as_str(), + evidence.source_count_scope, + ); + if !scopes.insert(key) { + return Err(invalid_data("source_count_evidence_duplicate_scope")); + } + } + Ok(()) + } + + pub fn validate_record_evidence(&self) -> Result<(), TallyError> { + let Some(record_evidence) = &self.record_evidence else { + return Ok(()); + }; + // Empty evidence is meaningful for a proven-empty pack window; the pack-specific + // binding check still requires it to match an empty canonical record set exactly. + let mut records = BTreeSet::new(); + for evidence in record_evidence { + let selected = match evidence.identity_kind { + SourceIdentityKind::Guid => evidence.observed_identities.guid.as_ref(), + SourceIdentityKind::RemoteId => evidence.observed_identities.remote_id.as_ref(), + SourceIdentityKind::MasterId => evidence.observed_identities.master_id.as_ref(), + SourceIdentityKind::Fallback => { + if evidence.observed_identities != ObservedSourceIdentities::default() { + return Err(invalid_data("fallback_identity_claimed_native_ids")); + } + Some(&evidence.source_id) + } + }; + if selected != Some(&evidence.source_id) { + return Err(invalid_data("source_record_primary_identity_mismatch")); + } + if !records.insert((evidence.object_type.as_str(), evidence.source_id.as_str())) { + return Err(invalid_data("source_record_evidence_duplicate_record")); + } + } + Ok(()) + } +} + +/// Stable Tally-origin identity or reference. It is intentionally serialized +/// as a string so the wire contract stays compact while construction and +/// deserialization remain fail-closed. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct SourceRecordId(String); + +impl SourceRecordId { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_SOURCE_ID_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(invalid_data("invalid_source_record_id")); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for SourceRecordId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +/// A validated Gregorian calendar date in Tally's canonical YYYYMMDD form. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(transparent)] +pub struct TallyDate(String); + +impl TallyDate { + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if !is_valid_yyyymmdd(&value) { + return Err(invalid_data("invalid_tally_date")); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for TallyDate { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(serde::de::Error::custom) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TaxRegistrationOwnerKind { + Company, + Ledger, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TaxRegistrationRecord { + pub source_id: SourceRecordId, + pub owner_kind: TaxRegistrationOwnerKind, + pub owner_source_id: SourceRecordId, + pub registration_type: CanonicalText, + pub gstin: Gstin, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct VoucherTaxRecord { + pub source_id: SourceRecordId, + pub voucher_source_id: SourceRecordId, + pub place_of_supply: CanonicalText, + pub assessable_value: ExactDecimal, + pub tax_component: CanonicalText, + pub tax_rate: NonNegativeExactDecimal, + pub tax_amount: ExactDecimal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct IndiaTaxBatch { + pub tax_registrations: Vec, + pub voucher_taxes: Vec, +} + +impl IndiaTaxBatch { + pub fn validate(&self) -> Result<(), TallyError> { + ensure_unique_source_ids( + self.tax_registrations + .iter() + .map(|record| &record.source_id), + "duplicate_tax_registration_source_id", + )?; + ensure_unique_source_ids( + self.voucher_taxes.iter().map(|record| &record.source_id), + "duplicate_voucher_tax_source_id", + )?; + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BillReferenceKind { + Advance, + AgainstReference, + NewReference, + OnAccount, + Unclassified, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BillReference { + pub kind: BillReferenceKind, + pub name: Option, + /// Preserved only when `kind` is `Unclassified`; known values never retain + /// a second, potentially contradictory representation. + pub raw_kind: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "origin", rename_all = "snake_case")] +pub enum BillAllocationOrigin { + Voucher { + voucher_source_id: SourceRecordId, + party_entry_source_id: SourceRecordId, + }, + LedgerOpening, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "origin", rename_all = "snake_case")] +pub enum OutstandingOrigin { + Voucher { + voucher_source_id: Option, + }, + LedgerOpening, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum DerivedIdentityBasis { + ParentOrdinal, + MutableReferenceOrdinal, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(tag = "basis", rename_all = "snake_case")] +pub enum CurrencyBasis { + CompanyBase { currency: CanonicalText }, + ObservedSource { currency: CanonicalText }, + Unspecified, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum OutstandingDirection { + Receivable, + Payable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BillDueDateEvidence { + Explicit, + DerivedFromCreditPeriod, + DefaultedToBillDate, + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BillWiseState { + EnabledObserved, + CompanyDisabledObserved, + PartyDisabledObserved, + UnsupportedForeignCurrencyLedgerObserved, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum BillsCoverageState { + ObservedCompleteScope, + ObservedPartial, + Drifted, + Truncated, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FetchBracketState { + StableObserved, + ChangedObserved, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BillAllocationRecord { + pub source_id: SourceRecordId, + pub identity_basis: DerivedIdentityBasis, + pub origin: BillAllocationOrigin, + pub reference: BillReference, + pub bill_date_yyyymmdd: Option, + pub effective_date_yyyymmdd: Option, + pub due_date_yyyymmdd: Option, + pub due_date_evidence: BillDueDateEvidence, + pub amount: ExactDecimal, + pub observed_polarity: Option, + pub currency_basis: CurrencyBasis, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct OutstandingObservation { + pub source_id: SourceRecordId, + pub identity_basis: DerivedIdentityBasis, + pub origin: OutstandingOrigin, + pub reference: BillReference, + pub bill_date_yyyymmdd: Option, + pub effective_date_yyyymmdd: Option, + pub due_date_yyyymmdd: Option, + pub due_date_evidence: BillDueDateEvidence, + pub opening_amount: Option, + pub pending_amount: ExactDecimal, + pub observed_polarity: Option, + pub source_reported_overdue_days: Option, + pub currency_basis: CurrencyBasis, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct PartyOutstandingFacts { + pub source_identity: SourceIdentity, + pub party_ledger_source_id: SourceRecordId, + pub report_as_of_yyyymmdd: TallyDate, + pub direction: OutstandingDirection, + pub bill_wise_state: BillWiseState, + pub allocation_coverage: BillsCoverageState, + pub outstanding_coverage: BillsCoverageState, + pub fetch_bracket: FetchBracketState, + pub query_profile: CanonicalText, + pub source_scope_fingerprint: CanonicalText, + pub source_reported_allocation_count: u64, + pub source_reported_outstanding_count: u64, + pub allocations: Vec, + pub outstanding: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BillsAndPaymentsBatch { + pub parties: Vec, +} + +pub fn derive_bill_allocation_source_id( + company_fingerprint: &CanonicalText, + party_ledger_source_id: &SourceRecordId, + origin_parent_source_id: &SourceRecordId, + allocation_ordinal: u64, +) -> Result { + if allocation_ordinal == 0 { + return Err(invalid_data("bill_allocation_ordinal_invalid")); + } + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-bill-allocation-id-v1\0"); + digest.update(company_fingerprint.as_str().as_bytes()); + digest.update(b"\0"); + digest.update(party_ledger_source_id.as_str().as_bytes()); + digest.update(b"\0"); + digest.update(origin_parent_source_id.as_str().as_bytes()); + digest.update(b"\0"); + digest.update(allocation_ordinal.to_be_bytes()); + SourceRecordId::parse(format!("bill-allocation:{}", hex_lower(&digest.finalize()))) +} + +pub fn derive_bill_outstanding_source_id( + exact_scope_fingerprint: &CanonicalText, + row_ordinal: u64, +) -> Result { + if row_ordinal == 0 { + return Err(invalid_data("bill_outstanding_ordinal_invalid")); + } + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-outstanding-observation-id-v1\0"); + digest.update(exact_scope_fingerprint.as_str().as_bytes()); + digest.update(b"\0"); + digest.update(row_ordinal.to_be_bytes()); + SourceRecordId::parse(format!( + "bill-outstanding:{}", + hex_lower(&digest.finalize()) + )) +} + +impl BillsAndPaymentsBatch { + pub fn validate(&self) -> Result<(), TallyError> { + ensure_unique_source_ids( + self.parties + .iter() + .map(|record| &record.party_ledger_source_id), + "duplicate_party_outstanding_scope", + )?; + let mut allocation_ids = BTreeSet::new(); + let mut outstanding_ids = BTreeSet::new(); + let expected_source_identity = self.parties.first().map(|party| &party.source_identity); + for party in &self.parties { + validate_source_identity( + &party.source_identity, + "bills_source_identity_invalid", + "bills_source_fingerprint_invalid", + )?; + if Some(&party.source_identity) != expected_source_identity { + return Err(invalid_data("bills_mixed_source_identity")); + } + validate_sha256( + party.source_scope_fingerprint.as_str(), + "bills_source_scope_fingerprint_invalid", + )?; + validate_source_count( + party.allocation_coverage, + party.source_reported_allocation_count, + party.allocations.len(), + "bill_allocation_source_count_invalid", + )?; + validate_source_count( + party.outstanding_coverage, + party.source_reported_outstanding_count, + party.outstanding.len(), + "bill_outstanding_source_count_invalid", + )?; + for record in &party.allocations { + if !allocation_ids.insert(&record.source_id) { + return Err(invalid_data("duplicate_bill_allocation_source_id")); + } + if record.identity_basis == DerivedIdentityBasis::MutableReferenceOrdinal { + return Err(invalid_data("bill_allocation_identity_basis_unsafe")); + } + validate_bill_reference(&record.reference)?; + validate_due_date(record.due_date_yyyymmdd.as_ref(), record.due_date_evidence)?; + } + for record in &party.outstanding { + if !outstanding_ids.insert(&record.source_id) { + return Err(invalid_data("duplicate_bill_outstanding_source_id")); + } + if record.identity_basis == DerivedIdentityBasis::MutableReferenceOrdinal { + return Err(invalid_data("bill_outstanding_identity_basis_unsafe")); + } + validate_bill_reference(&record.reference)?; + validate_due_date(record.due_date_yyyymmdd.as_ref(), record.due_date_evidence)?; + } + } + Ok(()) + } +} + +fn validate_source_count( + coverage: BillsCoverageState, + source_reported_count: u64, + parsed_count: usize, + code: &'static str, +) -> Result<(), TallyError> { + let parsed_count = u64::try_from(parsed_count).map_err(|_| invalid_data(code))?; + let valid = match coverage { + BillsCoverageState::ObservedCompleteScope => source_reported_count == parsed_count, + BillsCoverageState::ObservedPartial + | BillsCoverageState::Drifted + | BillsCoverageState::Truncated + | BillsCoverageState::Unknown => source_reported_count >= parsed_count, + }; + if valid { + Ok(()) + } else { + Err(invalid_data(code)) + } +} + +fn validate_bill_reference(reference: &BillReference) -> Result<(), TallyError> { + let valid = match reference.kind { + BillReferenceKind::OnAccount => reference.name.is_none() && reference.raw_kind.is_none(), + BillReferenceKind::Unclassified => reference.raw_kind.is_some(), + BillReferenceKind::Advance + | BillReferenceKind::AgainstReference + | BillReferenceKind::NewReference => { + reference.name.is_some() && reference.raw_kind.is_none() + } + }; + if valid { + Ok(()) + } else { + Err(invalid_data("bill_reference_invalid")) + } +} + +fn validate_due_date( + due_date: Option<&TallyDate>, + due_date_evidence: BillDueDateEvidence, +) -> Result<(), TallyError> { + if matches!(due_date_evidence, BillDueDateEvidence::Unavailable) == due_date.is_none() { + Ok(()) + } else { + Err(invalid_data("bill_due_date_evidence_invalid")) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct StockItemRecord { + pub source_id: SourceRecordId, + pub name: CanonicalText, + pub base_unit: CanonicalText, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct GodownRecord { + pub source_id: SourceRecordId, + pub name: CanonicalText, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InventoryEntryRecord { + pub source_id: SourceRecordId, + pub voucher_source_id: SourceRecordId, + pub stock_item_source_id: SourceRecordId, + pub godown_source_id: SourceRecordId, + pub quantity: ExactDecimal, + pub rate: ExactDecimal, + pub amount: ExactDecimal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct InventoryBatch { + pub stock_items: Vec, + pub godowns: Vec, + pub inventory_entries: Vec, +} + +impl InventoryBatch { + pub fn validate(&self) -> Result<(), TallyError> { + ensure_unique_source_ids( + self.stock_items.iter().map(|record| &record.source_id), + "duplicate_stock_item_source_id", + )?; + ensure_unique_source_ids( + self.godowns.iter().map(|record| &record.source_id), + "duplicate_godown_source_id", + )?; + ensure_unique_source_ids( + self.inventory_entries + .iter() + .map(|record| &record.source_id), + "duplicate_inventory_entry_source_id", + )?; + Ok(()) + } +} + +fn ensure_unique_source_ids<'a>( + source_ids: impl IntoIterator, + code: &'static str, +) -> Result<(), TallyError> { + let mut observed = BTreeSet::new(); + if source_ids + .into_iter() + .any(|source_id| !observed.insert(source_id.as_str())) + { + return Err(invalid_data(code)); + } + Ok(()) +} + +fn validate_text(value: &str, code: &'static str) -> Result<(), TallyError> { + if value.is_empty() + || value.len() > MAX_CANONICAL_TEXT_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(invalid_data(code)); + } + Ok(()) +} + +fn invalid_data(code: &'static str) -> TallyError { + TallyError::InvalidData { + code: code.to_string(), + } +} + +fn is_lower_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn validate_scope_descriptor( + descriptor: &SourceCountScopeDescriptor, + scope: SourceCountScope, +) -> Result<(), TallyError> { + let identity = &descriptor.source_identity; + for value in [ + identity.bridge_source_lineage.as_str(), + identity.company_guid.as_str(), + identity.observed_fingerprint.as_str(), + ] { + validate_text(value, "source_count_source_identity_invalid")?; + } + if descriptor.pack_schema_version.major == 0 { + return Err(invalid_data("source_count_pack_schema_invalid")); + } + validate_sha256( + descriptor.filters_sha256.as_str(), + "source_count_filters_sha256_invalid", + )?; + match (scope, descriptor.window.as_ref()) { + (SourceCountScope::Complete, None) | (SourceCountScope::Window, Some(_)) => {} + _ => return Err(invalid_data("source_count_window_scope_mismatch")), + } + if let Some(window) = &descriptor.window { + TallyDate::parse(window.from_yyyymmdd.clone())?; + TallyDate::parse(window.to_yyyymmdd.clone())?; + if window.from_yyyymmdd > window.to_yyyymmdd { + return Err(invalid_data("source_count_window_reversed")); + } + } + Ok(()) +} + +fn validate_sha256(value: &str, code: &'static str) -> Result<(), TallyError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(invalid_data(code)); + } + Ok(()) +} + +fn validate_source_identity( + identity: &SourceIdentity, + text_code: &'static str, + fingerprint_code: &'static str, +) -> Result<(), TallyError> { + validate_text(&identity.bridge_source_lineage, text_code)?; + validate_text(&identity.company_guid, text_code)?; + validate_sha256(&identity.observed_fingerprint, fingerprint_code) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +fn is_valid_yyyymmdd(value: &str) -> bool { + if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + let year = value[0..4].parse::().unwrap_or_default(); + let month = value[4..6].parse::().unwrap_or_default(); + let day = value[6..8].parse::().unwrap_or_default(); + let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)); + let days_in_month = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return false, + }; + year != 0 && (1..=days_in_month).contains(&day) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn id(value: &str) -> SourceRecordId { + SourceRecordId::parse(value).expect("valid synthetic source id") + } + + fn decimal(value: &str) -> ExactDecimal { + ExactDecimal::parse(value).expect("valid exact decimal") + } + + fn text(value: &str) -> CanonicalText { + CanonicalText::parse(value).expect("valid canonical text") + } + + fn non_negative(value: &str) -> NonNegativeExactDecimal { + NonNegativeExactDecimal::parse(value).expect("valid non-negative exact decimal") + } + + #[test] + fn canonical_pack_models_round_trip_with_exact_values_and_references() { + let tax = IndiaTaxBatch { + tax_registrations: vec![TaxRegistrationRecord { + source_id: id("tax-registration:1"), + owner_kind: TaxRegistrationOwnerKind::Ledger, + owner_source_id: id("ledger:customer"), + registration_type: text("regular"), + gstin: Gstin::parse("27ABCDE1234F1Z5").unwrap(), + }], + voucher_taxes: vec![VoucherTaxRecord { + source_id: id("voucher-tax:1"), + voucher_source_id: id("voucher:1"), + place_of_supply: text("27"), + assessable_value: decimal("1000.00"), + tax_component: text("igst"), + tax_rate: non_negative("18.00"), + tax_amount: decimal("180.00"), + }], + }; + tax.validate().expect("valid tax batch"); + let encoded = serde_json::to_string(&tax).expect("serialize tax batch"); + let decoded: IndiaTaxBatch = serde_json::from_str(&encoded).expect("deserialize tax batch"); + assert_eq!(decoded, tax); + assert_eq!(decoded.voucher_taxes[0].tax_amount.as_str(), "180.00"); + + let bills = BillsAndPaymentsBatch { + parties: vec![PartyOutstandingFacts { + source_identity: SourceIdentity { + bridge_source_lineage: "bridge-source:test".to_string(), + company_guid: "company-guid:test".to_string(), + observed_fingerprint: "b".repeat(64), + }, + party_ledger_source_id: id("ledger:customer"), + report_as_of_yyyymmdd: TallyDate::parse("20260228").unwrap(), + direction: OutstandingDirection::Receivable, + bill_wise_state: BillWiseState::EnabledObserved, + allocation_coverage: BillsCoverageState::ObservedCompleteScope, + outstanding_coverage: BillsCoverageState::ObservedCompleteScope, + fetch_bracket: FetchBracketState::StableObserved, + query_profile: text("bills-confidence-v1"), + source_scope_fingerprint: text(&"a".repeat(64)), + source_reported_allocation_count: 1, + source_reported_outstanding_count: 0, + allocations: vec![BillAllocationRecord { + source_id: id("bill-allocation:1"), + identity_basis: DerivedIdentityBasis::ParentOrdinal, + origin: BillAllocationOrigin::Voucher { + voucher_source_id: id("voucher:1"), + party_entry_source_id: id("entry:1"), + }, + reference: BillReference { + kind: BillReferenceKind::NewReference, + name: Some(text("INV-0001")), + raw_kind: None, + }, + bill_date_yyyymmdd: Some(TallyDate::parse("20260201").unwrap()), + effective_date_yyyymmdd: None, + due_date_yyyymmdd: Some(TallyDate::parse("20260228").unwrap()), + due_date_evidence: BillDueDateEvidence::Explicit, + amount: decimal("-1180.00"), + observed_polarity: Some(crate::LedgerEntryPolarity::Debit), + currency_basis: CurrencyBasis::CompanyBase { + currency: text("company-base"), + }, + }], + outstanding: Vec::new(), + }], + }; + bills.validate().expect("valid bills batch"); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&bills).expect("serialize bills batch") + ) + .expect("deserialize bills batch"), + bills + ); + + let mut invalid = bills.clone(); + invalid.parties[0].allocations[0].reference = BillReference { + kind: BillReferenceKind::OnAccount, + name: Some(text("invented-link")), + raw_kind: None, + }; + assert!(invalid.validate().is_err()); + + let mut invalid = bills.clone(); + invalid.parties[0].allocations[0].due_date_evidence = BillDueDateEvidence::Unavailable; + assert!(invalid.validate().is_err()); + + let mut invalid = bills.clone(); + invalid.parties[0].allocation_coverage = BillsCoverageState::ObservedPartial; + invalid.parties[0].source_reported_allocation_count = 0; + assert!(invalid.validate().is_err()); + + let mut invalid = bills.clone(); + invalid.parties[0].source_scope_fingerprint = text("not-a-sha256"); + assert!(invalid.validate().is_err()); + + let mut invalid = bills.clone(); + invalid.parties[0].allocations[0].identity_basis = + DerivedIdentityBasis::MutableReferenceOrdinal; + assert!(invalid.validate().is_err()); + + let inventory = InventoryBatch { + stock_items: vec![StockItemRecord { + source_id: id("stock-item:1"), + name: text("Synthetic Item"), + base_unit: text("nos"), + }], + godowns: vec![GodownRecord { + source_id: id("godown:1"), + name: text("Synthetic Location"), + }], + inventory_entries: vec![InventoryEntryRecord { + source_id: id("inventory-entry:1"), + voucher_source_id: id("voucher:1"), + stock_item_source_id: id("stock-item:1"), + godown_source_id: id("godown:1"), + quantity: decimal("2.000"), + rate: decimal("500.00"), + amount: decimal("1000.00"), + }], + }; + inventory.validate().expect("valid inventory batch"); + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&inventory).expect("serialize inventory batch") + ) + .expect("deserialize inventory batch"), + inventory + ); + } + + #[test] + fn derived_bill_ids_bind_parent_scope_and_ordinal_not_mutable_values() { + let company = text(&"c".repeat(64)); + let party = id("ledger:party"); + let parent = id("voucher:1"); + let first = derive_bill_allocation_source_id(&company, &party, &parent, 1).unwrap(); + let same = derive_bill_allocation_source_id(&company, &party, &parent, 1).unwrap(); + let next = derive_bill_allocation_source_id(&company, &party, &parent, 2).unwrap(); + assert_eq!(first, same, "amount and due date are not identity inputs"); + assert_ne!(first, next); + assert!(derive_bill_allocation_source_id(&company, &party, &parent, 0).is_err()); + + let scope = text(&"a".repeat(64)); + assert_ne!( + derive_bill_outstanding_source_id(&scope, 1).unwrap(), + derive_bill_outstanding_source_id(&scope, 2).unwrap() + ); + } + + #[test] + fn missing_fields_unknown_fields_and_invalid_exact_decimals_fail_deserialization() { + assert!(serde_json::from_str::( + r#"{ + "source_id":"entry:1", + "voucher_source_id":"voucher:1", + "stock_item_source_id":"item:1", + "godown_source_id":"godown:1", + "quantity":"1", + "rate":"10.00" + }"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{"stock_items":[],"godowns":[],"inventory_entries":[],"schema_version":1}"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{ + "source_id":"tax:1", + "voucher_source_id":"voucher:1", + "place_of_supply":"27", + "assessable_value":"100.00", + "tax_component":"igst", + "tax_rate":"NaN", + "tax_amount":"18.00" + }"# + ) + .is_err()); + assert!(serde_json::from_str::( + r#"{ + "source_id":"tax:1", + "voucher_source_id":"voucher:1", + "place_of_supply":"27", + "assessable_value":"100.00", + "tax_component":"igst", + "tax_rate":"-18.00", + "tax_amount":"18.00" + }"# + ) + .is_err()); + } + + #[test] + fn typed_ids_and_dates_reject_ambiguous_values_at_construction_and_deserialization() { + for value in ["", " leading", "trailing ", "line\nbreak"] { + assert!(SourceRecordId::parse(value).is_err(), "accepted {value:?}"); + } + for value in ["20260229", "20261301", "20260001", "00000101", "2026-01-01"] { + assert!(TallyDate::parse(value).is_err(), "accepted {value}"); + } + assert!(TallyDate::parse("20240229").is_ok()); + assert!(serde_json::from_str::(r#""20260229""#).is_err()); + for value in ["", " leading", "trailing ", "line\nbreak"] { + assert!(CanonicalText::parse(value).is_err(), "accepted {value:?}"); + } + for value in ["", "27abcde1234f1z5", "27ABCDE1234F1Z"] { + assert!(Gstin::parse(value).is_err(), "accepted {value:?}"); + } + } + + #[test] + fn semantic_validation_rejects_duplicate_ids_and_unpopulated_required_text() { + let duplicate = StockItemRecord { + source_id: id("item:1"), + name: text("Item"), + base_unit: text("nos"), + }; + let inventory = InventoryBatch { + stock_items: vec![duplicate.clone(), duplicate], + godowns: Vec::new(), + inventory_entries: Vec::new(), + }; + assert!(matches!( + inventory.validate(), + Err(TallyError::InvalidData { code }) if code == "duplicate_stock_item_source_id" + )); + + assert!(serde_json::from_str::( + r#"{ + "source_id":"tax:1", + "voucher_source_id":"voucher:1", + "place_of_supply":"", + "assessable_value":"100.00", + "tax_component":"igst", + "tax_rate":"18.00", + "tax_amount":"18.00" + }"# + ) + .is_err()); + } + + #[test] + fn source_counts_are_optional_explicit_evidence_never_derived_from_records() { + let batch = InventoryBatch { + stock_items: vec![StockItemRecord { + source_id: id("item:1"), + name: text("Synthetic Item"), + base_unit: text("nos"), + }], + godowns: Vec::new(), + inventory_entries: Vec::new(), + }; + let absent = PackWindow::without_source_count_evidence(batch.clone()); + assert_eq!(absent.source_counts, None); + absent + .validate_source_count_evidence() + .expect("absent source evidence is honest"); + + let observed = PackWindow { + batch, + source_counts: Some(vec![SourceReportedCountEvidence { + object_type: text("stock_item"), + query_profile: text("inventory-v1"), + source_scope_fingerprint: text("scope-sha256:synthetic"), + source_count_scope: SourceCountScope::Complete, + // Deliberately differs from the parsed Vec length. The model + // stores the source claim without inventing reconciliation. + source_reported_count: 37, + }]), + record_evidence: None, + }; + observed + .validate_source_count_evidence() + .expect("valid explicit evidence"); + assert_eq!( + observed.source_counts.as_ref().unwrap()[0].source_reported_count, + 37 + ); + } + + #[test] + fn source_count_fingerprint_binds_exact_versioned_scope() { + let descriptor = SourceCountScopeDescriptor { + source_identity: SourceIdentity { + bridge_source_lineage: "lineage:synthetic".to_string(), + company_guid: "company:synthetic".to_string(), + observed_fingerprint: "fingerprint:synthetic".to_string(), + }, + pack: CapabilityPackId::Inventory, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + object_type: text("stock_item"), + query_profile: text("inventory-v1"), + filters_sha256: text(&"a".repeat(64)), + window: None, + }; + let fingerprint = source_count_scope_fingerprint(&descriptor, SourceCountScope::Complete) + .expect("valid complete scope fingerprint"); + assert_eq!(fingerprint.as_str().len(), 64); + assert_eq!( + fingerprint, + source_count_scope_fingerprint(&descriptor, SourceCountScope::Complete).unwrap() + ); + + let evidence = SourceReportedCountEvidence { + object_type: descriptor.object_type.clone(), + query_profile: descriptor.query_profile.clone(), + source_scope_fingerprint: fingerprint, + source_count_scope: SourceCountScope::Complete, + source_reported_count: 0, + }; + assert!(evidence.matches_scope_descriptor(&descriptor).unwrap()); + + let mut drifted = descriptor.clone(); + drifted.query_profile = text("inventory-v2"); + assert!(!evidence.matches_scope_descriptor(&drifted).unwrap()); + assert!(source_count_scope_fingerprint(&descriptor, SourceCountScope::Window).is_err()); + } + + #[test] + fn window_count_fingerprint_requires_exact_valid_window() { + let mut descriptor = SourceCountScopeDescriptor { + source_identity: SourceIdentity { + bridge_source_lineage: "lineage:synthetic".to_string(), + company_guid: "company:synthetic".to_string(), + observed_fingerprint: "fingerprint:synthetic".to_string(), + }, + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + object_type: text("voucher"), + query_profile: text("voucher-v1"), + filters_sha256: text(&"b".repeat(64)), + window: Some(ReadWindow { + from_yyyymmdd: "20260101".to_string(), + to_yyyymmdd: "20260131".to_string(), + }), + }; + assert!(source_count_scope_fingerprint(&descriptor, SourceCountScope::Window).is_ok()); + assert!(source_count_scope_fingerprint(&descriptor, SourceCountScope::Complete).is_err()); + + descriptor.window.as_mut().unwrap().from_yyyymmdd = "20260201".to_string(); + assert!(source_count_scope_fingerprint(&descriptor, SourceCountScope::Window).is_err()); + } + + #[test] + fn empty_or_duplicate_source_count_evidence_fails_closed() { + let empty = PackWindow { + batch: IndiaTaxBatch::default(), + source_counts: Some(Vec::new()), + record_evidence: None, + }; + assert!(matches!( + empty.validate_source_count_evidence(), + Err(TallyError::InvalidData { code }) if code == "source_count_evidence_empty" + )); + + let count = SourceReportedCountEvidence { + object_type: text("voucher_tax"), + query_profile: text("tax-v1"), + source_scope_fingerprint: text("scope-sha256:synthetic"), + source_count_scope: SourceCountScope::Complete, + source_reported_count: 2, + }; + let duplicate = PackWindow { + batch: IndiaTaxBatch::default(), + source_counts: Some(vec![count.clone(), count]), + record_evidence: None, + }; + assert!(matches!( + duplicate.validate_source_count_evidence(), + Err(TallyError::InvalidData { code }) if code == "source_count_evidence_duplicate_scope" + )); + } +} diff --git a/src-tauri/crates/bridge-tally-core/src/reconciliation.rs b/src-tauri/crates/bridge-tally-core/src/reconciliation.rs new file mode 100644 index 0000000..8312e1b --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/reconciliation.rs @@ -0,0 +1,378 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; + +use crate::exact_arithmetic::{is_negative_nonzero, numeric_equal, ExactDecimalAccumulator}; +use crate::{CoreAccountingBatch, LedgerEntryPolarity, LedgerEntryRecord}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CheckState { + Passed, + Mismatch, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct CoreAccountingChecks { + pub reference_integrity: CheckState, + pub voucher_entry_balance: CheckState, + pub voucher_entry_polarity: CheckState, + pub voucher_entry_applicability: CheckState, + pub voucher_header_entry_total: CheckState, +} + +/// Raw source IDs are intentionally kept in a non-serializable intermediate. +/// The application must replace them with local-only aliases before any +/// operator-visible persistence or export. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AccountingIssue { + pub safe_reason_code: &'static str, + pub source_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreAccountingAssessment { + pub checks: CoreAccountingChecks, + pub issues: Vec, +} + +pub fn assess_core_accounting(core: &CoreAccountingBatch) -> CoreAccountingAssessment { + let voucher_types = core + .voucher_types + .iter() + .map(|record| record.source_id.as_str()) + .collect::>(); + let vouchers = core + .vouchers + .iter() + .map(|record| record.source_id.as_str()) + .collect::>(); + let ledgers = core + .ledgers + .iter() + .map(|record| record.source_id.as_str()) + .collect::>(); + let mut issues = Vec::new(); + + push_issue( + &mut issues, + "voucher_type_reference_missing", + core.vouchers + .iter() + .filter(|record| !voucher_types.contains(record.voucher_type_source_id.as_str())) + .map(|record| record.source_id.clone()) + .collect(), + ); + push_issue( + &mut issues, + "voucher_reference_missing", + core.ledger_entries + .iter() + .filter(|record| !vouchers.contains(record.voucher_source_id.as_str())) + .map(|record| record.source_id.clone()) + .collect(), + ); + push_issue( + &mut issues, + "ledger_reference_missing", + core.ledger_entries + .iter() + .filter(|record| !ledgers.contains(record.ledger_source_id.as_str())) + .map(|record| record.source_id.clone()) + .collect(), + ); + let reference_integrity = state_for_codes( + &issues, + &[ + "voucher_type_reference_missing", + "voucher_reference_missing", + "ledger_reference_missing", + ], + ); + + let excluded_from_books = core + .vouchers + .iter() + .filter(|voucher| voucher.cancelled || voucher.optional) + .map(|voucher| voucher.source_id.as_str()) + .collect::>(); + + push_issue( + &mut issues, + "voucher_entry_polarity_mismatch", + core.ledger_entries + .iter() + .filter(|entry| !excluded_from_books.contains(entry.voucher_source_id.as_str())) + .filter(|entry| !entry_polarity_matches_amount(entry)) + .map(|entry| entry.source_id.clone()) + .collect(), + ); + let voucher_entry_polarity = + if state_for_codes(&issues, &["voucher_entry_polarity_mismatch"]) == CheckState::Mismatch { + CheckState::Mismatch + } else if core + .ledger_entries + .iter() + .filter(|entry| !excluded_from_books.contains(entry.voucher_source_id.as_str())) + .any(entry_amount_is_zero) + { + CheckState::Unavailable + } else { + CheckState::Passed + }; + + let mut totals: BTreeMap<&str, ExactDecimalAccumulator> = BTreeMap::new(); + for entry in &core.ledger_entries { + if !excluded_from_books.contains(entry.voucher_source_id.as_str()) { + totals + .entry(entry.voucher_source_id.as_str()) + .or_default() + .add(entry.amount.as_str()); + } + } + push_issue( + &mut issues, + "voucher_entries_unbalanced", + core.vouchers + .iter() + .filter(|voucher| !voucher.cancelled && !voucher.optional) + .filter(|voucher| { + totals + .get(voucher.source_id.as_str()) + .is_some_and(|total| !total.is_zero()) + }) + .map(|voucher| voucher.source_id.clone()) + .collect(), + ); + let voucher_entry_balance = state_for_codes(&issues, &["voucher_entries_unbalanced"]); + let voucher_entry_applicability = if core + .vouchers + .iter() + .filter(|voucher| !voucher.cancelled && !voucher.optional) + .any(|voucher| !totals.contains_key(voucher.source_id.as_str())) + { + CheckState::Unavailable + } else { + CheckState::Passed + }; + + CoreAccountingAssessment { + checks: CoreAccountingChecks { + reference_integrity, + voucher_entry_balance, + voucher_entry_polarity, + voucher_entry_applicability, + // The Core v2 model has no independently observed voucher header + // total. Absence is a gap, never an inferred pass. + voucher_header_entry_total: CheckState::Unavailable, + }, + issues, + } +} + +fn push_issue( + issues: &mut Vec, + safe_reason_code: &'static str, + mut source_ids: Vec, +) { + if source_ids.is_empty() { + return; + } + source_ids.sort(); + source_ids.dedup(); + issues.push(AccountingIssue { + safe_reason_code, + source_ids, + }); +} + +fn state_for_codes(issues: &[AccountingIssue], codes: &[&str]) -> CheckState { + if issues + .iter() + .any(|issue| codes.contains(&issue.safe_reason_code)) + { + CheckState::Mismatch + } else { + CheckState::Passed + } +} + +fn entry_polarity_matches_amount(entry: &LedgerEntryRecord) -> bool { + let zero = numeric_equal(entry.amount.as_str(), "0"); + let negative = is_negative_nonzero(entry.amount.as_str()); + zero || matches!( + (entry.polarity, negative), + (LedgerEntryPolarity::Debit, true) | (LedgerEntryPolarity::Credit, false) + ) +} + +fn entry_amount_is_zero(entry: &LedgerEntryRecord) -> bool { + numeric_equal(entry.amount.as_str(), "0") +} + +#[cfg(test)] +mod tests { + use crate::{ + CoreAccountingBatch, ExactDecimal, LedgerEntryPolarity, LedgerEntryRecord, LedgerRecord, + VoucherRecord, VoucherTypeRecord, + }; + + use super::*; + + fn batch(amounts: &[(&str, LedgerEntryPolarity)], cancelled: bool) -> CoreAccountingBatch { + CoreAccountingBatch { + ledgers: vec![ + LedgerRecord { + source_id: "ledger-a".to_string(), + name: "A".to_string(), + parent_source_id: None, + opening_balance: None, + }, + LedgerRecord { + source_id: "ledger-b".to_string(), + name: "B".to_string(), + parent_source_id: None, + opening_balance: None, + }, + ], + voucher_types: vec![VoucherTypeRecord { + source_id: "sales".to_string(), + name: "Sales".to_string(), + }], + vouchers: vec![VoucherRecord { + source_id: "voucher".to_string(), + date_yyyymmdd: "20260701".to_string(), + voucher_type_source_id: "sales".to_string(), + voucher_number: None, + cancelled, + optional: false, + }], + ledger_entries: amounts + .iter() + .enumerate() + .map(|(index, (amount, polarity))| LedgerEntryRecord { + source_id: format!("entry-{index}"), + voucher_source_id: "voucher".to_string(), + ledger_source_id: if index % 2 == 0 { + "ledger-a".to_string() + } else { + "ledger-b".to_string() + }, + amount: ExactDecimal::parse(*amount).unwrap(), + polarity: *polarity, + }) + .collect(), + ..CoreAccountingBatch::default() + } + } + + #[test] + fn exact_mixed_scale_and_large_amounts_balance_without_float_math() { + let assessment = assess_core_accounting(&batch( + &[ + ("-999999999999999999999.001", LedgerEntryPolarity::Debit), + ("999999999999999999999.0010", LedgerEntryPolarity::Credit), + ], + false, + )); + assert_eq!(assessment.checks.voucher_entry_balance, CheckState::Passed); + assert_eq!(assessment.checks.voucher_entry_polarity, CheckState::Passed); + } + + #[test] + fn numeric_zero_sum_cannot_hide_contradictory_tally_polarity() { + let assessment = assess_core_accounting(&batch( + &[ + ("-100.00", LedgerEntryPolarity::Credit), + ("100", LedgerEntryPolarity::Debit), + ], + false, + )); + assert_eq!(assessment.checks.voucher_entry_balance, CheckState::Passed); + assert_eq!( + assessment.checks.voucher_entry_polarity, + CheckState::Mismatch + ); + } + + #[test] + fn sub_cent_imbalance_is_detected_exactly() { + let assessment = assess_core_accounting(&batch( + &[ + ("-100.000", LedgerEntryPolarity::Debit), + ("99.999", LedgerEntryPolarity::Credit), + ], + false, + )); + assert_eq!( + assessment.checks.voucher_entry_balance, + CheckState::Mismatch + ); + } + + #[test] + fn cancelled_empty_voucher_is_not_a_missing_entry_claim() { + let assessment = assess_core_accounting(&batch(&[], true)); + assert_eq!( + assessment.checks.voucher_entry_applicability, + CheckState::Passed + ); + assert!(assessment.issues.is_empty()); + } + + #[test] + fn cancelled_entries_do_not_claim_book_effect_polarity_mismatches() { + let assessment = assess_core_accounting(&batch( + &[ + ("-100.00", LedgerEntryPolarity::Credit), + ("100.00", LedgerEntryPolarity::Debit), + ], + true, + )); + assert_eq!(assessment.checks.voucher_entry_balance, CheckState::Passed); + assert_eq!(assessment.checks.voucher_entry_polarity, CheckState::Passed); + assert!(assessment.issues.is_empty()); + } + + #[test] + fn optional_entries_are_excluded_from_ordinary_book_effect_checks() { + let mut core = batch( + &[ + ("-100.00", LedgerEntryPolarity::Credit), + ("100.00", LedgerEntryPolarity::Debit), + ], + false, + ); + core.vouchers[0].optional = true; + let assessment = assess_core_accounting(&core); + assert_eq!(assessment.checks.voucher_entry_balance, CheckState::Passed); + assert_eq!(assessment.checks.voucher_entry_polarity, CheckState::Passed); + assert_eq!( + assessment.checks.voucher_entry_applicability, + CheckState::Passed + ); + assert!(assessment.issues.is_empty()); + } + + #[test] + fn non_cancelled_empty_voucher_applicability_is_unavailable() { + let assessment = assess_core_accounting(&batch(&[], false)); + assert_eq!( + assessment.checks.voucher_entry_applicability, + CheckState::Unavailable + ); + } + + #[test] + fn zero_amount_cannot_claim_observed_polarity() { + for polarity in [LedgerEntryPolarity::Debit, LedgerEntryPolarity::Credit] { + let assessment = + assess_core_accounting(&batch(&[("0.00", polarity), ("-0.000", polarity)], false)); + assert_eq!( + assessment.checks.voucher_entry_polarity, + CheckState::Unavailable + ); + } + } +} diff --git a/src-tauri/crates/bridge-tally-core/src/report_tie_out.rs b/src-tauri/crates/bridge-tally-core/src/report_tie_out.rs new file mode 100644 index 0000000..1c1725d --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/report_tie_out.rs @@ -0,0 +1,349 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use crate::exact_arithmetic::ExactDecimalAccumulator; +use crate::{CoreAccountingBatch, ExactDecimal, ReadWindow, SourceIdentity, TallyError}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TieOutState { + Passed, + Mismatch, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LedgerPeriodBalance { + pub ledger_source_id: String, + pub opening_balance: ExactDecimal, + pub closing_balance: ExactDecimal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct LedgerPeriodBalanceReport { + pub source_identity: SourceIdentity, + pub window: ReadWindow, + /// True only after the exact Tally release/profile has independently + /// demonstrated that this view matches Bridge's ordinary-books model. + /// A request echo must never set this authority bit. + pub ordinary_books_scope_observed: bool, + pub source_reported_count: u64, + pub balances: Vec, +} + +/// Raw source IDs remain in this non-serializable intermediate. The native +/// application must replace them with bounded, proof-local aliases before +/// operator display or persistence outside encrypted run state. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CoreReportTieOutAssessment { + pub state: TieOutState, + pub compared_ledger_count: u64, + pub safe_reason_codes: BTreeSet<&'static str>, + pub mismatched_ledger_source_ids: Vec, +} + +/// Converts a raw source identifier into a bounded, run-local token before it +/// may enter durable reconciliation evidence. +pub fn scoped_mismatch_record_alias( + company_fingerprint: &str, + run_id: &str, + window_id: &str, + raw_source_id: &str, +) -> String { + let mut digest = Sha256::new(); + digest.update(b"bridge-report-mismatch-record-alias-v1\0"); + digest.update(company_fingerprint.as_bytes()); + digest.update(b"\0"); + digest.update(run_id.as_bytes()); + digest.update(b"\0"); + digest.update(window_id.as_bytes()); + digest.update(b"\0"); + digest.update(raw_source_id.as_bytes()); + let hex = digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("rid:{hex}") +} + +pub fn assess_core_period_report( + core: &CoreAccountingBatch, + expected_source: &SourceIdentity, + expected_window: &ReadWindow, + report: &LedgerPeriodBalanceReport, +) -> Result { + if &report.source_identity != expected_source || &report.window != expected_window { + return Err(invalid_data("period_report_scope_mismatch")); + } + if !report.ordinary_books_scope_observed { + return Ok(unavailable("period_report_profile_unobserved")); + } + if report.source_reported_count != report.balances.len() as u64 { + return Err(invalid_data("period_report_count_mismatch")); + } + + let ledger_ids = core + .ledgers + .iter() + .map(|ledger| ledger.source_id.as_str()) + .collect::>(); + if ledger_ids.len() != core.ledgers.len() { + return Err(invalid_data("period_report_core_ledger_identity_duplicate")); + } + + let mut report_by_ledger = BTreeMap::new(); + for balance in &report.balances { + if balance.ledger_source_id.is_empty() + || report_by_ledger + .insert(balance.ledger_source_id.as_str(), balance) + .is_some() + { + return Err(invalid_data("period_report_ledger_identity_invalid")); + } + } + let report_ids = report_by_ledger.keys().copied().collect::>(); + if report_ids != ledger_ids { + let mut assessment = mismatch("period_report_ledger_coverage_mismatch"); + assessment.mismatched_ledger_source_ids = report_ids + .symmetric_difference(&ledger_ids) + .map(|value| (*value).to_string()) + .collect(); + return Ok(assessment); + } + + let posted_vouchers = core + .vouchers + .iter() + .filter(|voucher| !voucher.cancelled && !voucher.optional) + .map(|voucher| voucher.source_id.as_str()) + .collect::>(); + let mut movements: BTreeMap<&str, ExactDecimalAccumulator> = BTreeMap::new(); + for entry in &core.ledger_entries { + if posted_vouchers.contains(entry.voucher_source_id.as_str()) { + movements + .entry(entry.ledger_source_id.as_str()) + .or_default() + .add(entry.amount.as_str()); + } + } + + let mut mismatched = Vec::new(); + for ledger_id in &ledger_ids { + let balance = report_by_ledger + .get(ledger_id) + .expect("ledger-set equality was established"); + let mut report_movement = ExactDecimalAccumulator::default(); + report_movement.add(balance.closing_balance.as_str()); + report_movement.subtract(balance.opening_balance.as_str()); + if movements.get(ledger_id).cloned().unwrap_or_default() != report_movement { + mismatched.push((*ledger_id).to_string()); + } + } + if mismatched.is_empty() { + Ok(CoreReportTieOutAssessment { + state: TieOutState::Passed, + compared_ledger_count: ledger_ids.len() as u64, + safe_reason_codes: BTreeSet::new(), + mismatched_ledger_source_ids: Vec::new(), + }) + } else { + Ok(CoreReportTieOutAssessment { + state: TieOutState::Mismatch, + compared_ledger_count: ledger_ids.len() as u64, + safe_reason_codes: BTreeSet::from(["period_report_movement_mismatch"]), + mismatched_ledger_source_ids: mismatched, + }) + } +} + +fn unavailable(code: &'static str) -> CoreReportTieOutAssessment { + CoreReportTieOutAssessment { + state: TieOutState::Unavailable, + compared_ledger_count: 0, + safe_reason_codes: BTreeSet::from([code]), + mismatched_ledger_source_ids: Vec::new(), + } +} + +fn mismatch(code: &'static str) -> CoreReportTieOutAssessment { + CoreReportTieOutAssessment { + state: TieOutState::Mismatch, + compared_ledger_count: 0, + safe_reason_codes: BTreeSet::from([code]), + mismatched_ledger_source_ids: Vec::new(), + } +} + +fn invalid_data(code: &'static str) -> TallyError { + TallyError::InvalidData { + code: code.to_string(), + } +} + +#[cfg(test)] +mod tests { + use crate::{ + CoreAccountingBatch, LedgerEntryPolarity, LedgerEntryRecord, LedgerRecord, VoucherRecord, + }; + + use super::*; + + fn source() -> SourceIdentity { + SourceIdentity { + bridge_source_lineage: "synthetic".to_string(), + company_guid: "company-guid".to_string(), + observed_fingerprint: "fingerprint".to_string(), + } + } + + fn window() -> ReadWindow { + ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260731".to_string(), + } + } + + fn core(optional: bool, cancelled: bool) -> CoreAccountingBatch { + CoreAccountingBatch { + ledgers: vec![LedgerRecord { + source_id: "ledger-a".to_string(), + name: "A".to_string(), + parent_source_id: None, + opening_balance: None, + }], + vouchers: vec![VoucherRecord { + source_id: "voucher-a".to_string(), + date_yyyymmdd: "20260702".to_string(), + voucher_type_source_id: "sales".to_string(), + voucher_number: None, + cancelled, + optional, + }], + ledger_entries: vec![LedgerEntryRecord { + source_id: "entry-a".to_string(), + voucher_source_id: "voucher-a".to_string(), + ledger_source_id: "ledger-a".to_string(), + amount: ExactDecimal::parse("-0.001").unwrap(), + polarity: LedgerEntryPolarity::Debit, + }], + ..CoreAccountingBatch::default() + } + } + + fn report(opening: &str, closing: &str) -> LedgerPeriodBalanceReport { + LedgerPeriodBalanceReport { + source_identity: source(), + window: window(), + ordinary_books_scope_observed: true, + source_reported_count: 1, + balances: vec![LedgerPeriodBalance { + ledger_source_id: "ledger-a".to_string(), + opening_balance: ExactDecimal::parse(opening).unwrap(), + closing_balance: ExactDecimal::parse(closing).unwrap(), + }], + } + } + + #[test] + fn exact_period_movement_ties_out_across_scales() { + let assessment = assess_core_period_report( + &core(false, false), + &source(), + &window(), + &report("100.0000", "99.999"), + ) + .unwrap(); + assert_eq!(assessment.state, TieOutState::Passed); + assert_eq!(assessment.compared_ledger_count, 1); + } + + #[test] + fn sub_cent_report_mismatch_is_not_rounded_away() { + let assessment = assess_core_period_report( + &core(false, false), + &source(), + &window(), + &report("100", "100"), + ) + .unwrap(); + assert_eq!(assessment.state, TieOutState::Mismatch); + assert_eq!(assessment.mismatched_ledger_source_ids, ["ledger-a"]); + } + + #[test] + fn optional_and_cancelled_movements_are_excluded_from_ordinary_books() { + for core in [core(true, false), core(false, true)] { + let assessment = + assess_core_period_report(&core, &source(), &window(), &report("100", "100.0")) + .unwrap(); + assert_eq!(assessment.state, TieOutState::Passed); + } + } + + #[test] + fn missing_or_unexpected_ledger_rows_are_mismatches() { + let mut report = report("100", "99.999"); + report.balances[0].ledger_source_id = "ledger-other".to_string(); + let assessment = + assess_core_period_report(&core(false, false), &source(), &window(), &report).unwrap(); + assert_eq!(assessment.state, TieOutState::Mismatch); + assert_eq!(assessment.mismatched_ledger_source_ids.len(), 2); + } + + #[test] + fn report_scope_and_count_are_strictly_bound() { + let mut unobserved_profile = report("100", "99.999"); + unobserved_profile.ordinary_books_scope_observed = false; + let unavailable = assess_core_period_report( + &core(false, false), + &source(), + &window(), + &unobserved_profile, + ) + .unwrap(); + assert_eq!(unavailable.state, TieOutState::Unavailable); + assert!(unavailable + .safe_reason_codes + .contains("period_report_profile_unobserved")); + + let mut wrong_scope = report("100", "99.999"); + wrong_scope.window.to_yyyymmdd = "20260730".to_string(); + assert!( + assess_core_period_report(&core(false, false), &source(), &window(), &wrong_scope,) + .is_err() + ); + + let mut wrong_count = report("100", "99.999"); + wrong_count.source_reported_count = 2; + assert!( + assess_core_period_report(&core(false, false), &source(), &window(), &wrong_count,) + .is_err() + ); + } + + #[test] + fn mismatch_aliases_are_scoped_and_never_contain_raw_ids() { + let raw = "00000000-0000-4000-8000-000000000777"; + let alias = scoped_mismatch_record_alias("company-fingerprint", "run-1", "window-1", raw); + assert!(alias.starts_with("rid:")); + assert_eq!(alias.len(), 68); + assert!(!alias.contains(raw)); + assert_eq!( + alias, + scoped_mismatch_record_alias("company-fingerprint", "run-1", "window-1", raw) + ); + assert_ne!( + alias, + scoped_mismatch_record_alias("company-fingerprint", "run-2", "window-1", raw) + ); + assert_ne!( + alias, + scoped_mismatch_record_alias("other-company", "run-1", "window-1", raw) + ); + } +} diff --git a/src-tauri/crates/bridge-tally-core/src/transport_qualification.rs b/src-tauri/crates/bridge-tally-core/src/transport_qualification.rs new file mode 100644 index 0000000..acc91d7 --- /dev/null +++ b/src-tauri/crates/bridge-tally-core/src/transport_qualification.rs @@ -0,0 +1,1056 @@ +//! Transport qualification contracts for read-only semantic shadowing. +//! +//! This module deliberately does not select JSONEX, write mirror data, advance +//! checkpoints, or alter accounting proof. A matching observation remains in +//! `Shadowing`; promotion requires a separate policy backed by repeated live +//! evidence and measured operational benefit. + +use crate::{ + CanonicalPackWindow, CanonicalText, CapabilityPackId, CoreAccountingBatch, ExactDecimal, + LedgerEntryPolarity, PackBatch, PackSchemaVersion, ReadWindow, SourceCountScope, + SourceCountScopeDescriptor, SourceIdentity, SourceIdentityKind, SourceRecordId, TallyDate, + TallyError, TransportId, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +pub const TRANSPORT_QUALIFICATION_CONTRACT_VERSION: u16 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TransportQualificationScope { + pub source_identity: SourceIdentity, + pub product: CanonicalText, + pub release: CanonicalText, + pub mode: CanonicalText, + pub pack: CapabilityPackId, + pub pack_schema_version: PackSchemaVersion, + pub window: ReadWindow, + pub query_profile: CanonicalText, + pub filters_sha256: CanonicalText, + pub reference_transport: TransportId, + pub candidate_transport: TransportId, + /// Versioned request/response schema profile used for the candidate read. + pub candidate_request_profile: CanonicalText, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceStabilityEvidence { + ReferenceBracketMatched, + ReferenceBracketMismatch, + EvidenceUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportParityVerdict { + Matched, + Mismatched, + Inconclusive, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CandidateTransportRecommendation { + ContinueShadowing, + RecommendQuarantine, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TransportParityReasonCode { + SemanticParityObserved, + CanonicalSemanticsMismatch, + SourceCountEvidenceMismatch, + RecordEvidenceCoverageMismatch, + ReferenceBracketMismatch, + SourceCountEvidenceUnavailable, + RecordEvidenceUnavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TransportReadMetrics { + pub started_at_unix_ms: i64, + pub completed_at_unix_ms: i64, + pub response_bytes: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TransportParityObservation { + pub contract_version: u16, + /// Hash of the exact company/product/release/mode/pack/window/query scope. + /// The raw company identity is intentionally not repeated in this receipt. + pub scope_sha256: String, + pub reference_transport: TransportId, + pub candidate_transport: TransportId, + pub reference_semantic_sha256: String, + pub candidate_semantic_sha256: String, + pub source_stability: SourceStabilityEvidence, + pub verdict: TransportParityVerdict, + /// A recommendation for a separate durable policy reducer. This value is + /// neither persisted nor enforced by the comparator. + pub candidate_recommendation: CandidateTransportRecommendation, + pub reason_codes: Vec, + pub reference_before_metrics: TransportReadMetrics, + pub candidate_metrics: TransportReadMetrics, + pub reference_after_metrics: TransportReadMetrics, +} + +/// Compares an XML/JSONEX/XML bracket without making raw payloads or +/// transport-derived entry IDs part of semantic equality. The scope remains a +/// caller-supplied assertion until a runtime reader binds it to a live request. +pub fn qualify_core_transport_shadow( + scope: &TransportQualificationScope, + reference_before: &CanonicalPackWindow, + candidate: &CanonicalPackWindow, + reference_after: &CanonicalPackWindow, + reference_before_metrics: TransportReadMetrics, + candidate_metrics: TransportReadMetrics, + reference_after_metrics: TransportReadMetrics, +) -> Result { + validate_scope(scope)?; + validate_metrics(reference_before_metrics)?; + validate_metrics(candidate_metrics)?; + validate_metrics(reference_after_metrics)?; + if reference_before_metrics.completed_at_unix_ms > candidate_metrics.started_at_unix_ms + || candidate_metrics.completed_at_unix_ms > reference_after_metrics.started_at_unix_ms + { + return Err(invalid_data( + "transport_qualification_bracket_order_invalid", + )); + } + reference_before.validate_record_evidence_binding()?; + candidate.validate_record_evidence_binding()?; + reference_after.validate_record_evidence_binding()?; + reference_before.validate_source_count_evidence()?; + candidate.validate_source_count_evidence()?; + reference_after.validate_source_count_evidence()?; + validate_count_scopes(scope, reference_before)?; + validate_count_scopes(scope, candidate)?; + validate_count_scopes(scope, reference_after)?; + + let reference_batch = core_batch(reference_before)?; + let candidate_batch = core_batch(candidate)?; + let reference_after_batch = core_batch(reference_after)?; + validate_core_reference_integrity(reference_batch)?; + validate_core_reference_integrity(candidate_batch)?; + validate_core_reference_integrity(reference_after_batch)?; + let reference_projection = project_core(reference_batch); + let candidate_projection = project_core(candidate_batch); + let reference_after_projection = project_core(reference_after_batch); + let reference_semantic_sha256 = sha256_json_contract( + "bridge_tally_core_semantic_projection_v1", + &reference_projection, + )?; + let candidate_semantic_sha256 = sha256_json_contract( + "bridge_tally_core_semantic_projection_v1", + &candidate_projection, + )?; + + let reference_counts = project_source_counts(reference_before); + let candidate_counts = project_source_counts(candidate); + let reference_after_counts = project_source_counts(reference_after); + let reference_evidence = project_record_evidence(reference_before); + let candidate_evidence = project_record_evidence(candidate); + let reference_after_evidence = project_record_evidence(reference_after); + + let mut mismatches = Vec::new(); + if reference_projection != candidate_projection { + mismatches.push(TransportParityReasonCode::CanonicalSemanticsMismatch); + } + if reference_counts != candidate_counts { + mismatches.push(TransportParityReasonCode::SourceCountEvidenceMismatch); + } + if reference_evidence != candidate_evidence { + mismatches.push(TransportParityReasonCode::RecordEvidenceCoverageMismatch); + } + + let reference_bracket_mismatch = reference_projection != reference_after_projection + || reference_counts != reference_after_counts + || reference_evidence != reference_after_evidence; + let mut unavailable = Vec::new(); + if !has_complete_core_count_evidence(reference_before, reference_batch) + || !has_complete_core_count_evidence(candidate, candidate_batch) + || !has_complete_core_count_evidence(reference_after, reference_after_batch) + { + unavailable.push(TransportParityReasonCode::SourceCountEvidenceUnavailable); + } + if reference_evidence.is_none() + || candidate_evidence.is_none() + || reference_after_evidence.is_none() + { + unavailable.push(TransportParityReasonCode::RecordEvidenceUnavailable); + } + + let (source_stability, verdict, candidate_recommendation, reason_codes) = + if !unavailable.is_empty() { + unavailable.extend(mismatches); + ( + SourceStabilityEvidence::EvidenceUnavailable, + TransportParityVerdict::Inconclusive, + CandidateTransportRecommendation::ContinueShadowing, + unavailable, + ) + } else if reference_bracket_mismatch { + let mut reasons = vec![TransportParityReasonCode::ReferenceBracketMismatch]; + reasons.extend(mismatches); + ( + SourceStabilityEvidence::ReferenceBracketMismatch, + TransportParityVerdict::Inconclusive, + CandidateTransportRecommendation::ContinueShadowing, + reasons, + ) + } else if mismatches.is_empty() { + ( + SourceStabilityEvidence::ReferenceBracketMatched, + TransportParityVerdict::Matched, + CandidateTransportRecommendation::ContinueShadowing, + vec![TransportParityReasonCode::SemanticParityObserved], + ) + } else { + ( + SourceStabilityEvidence::ReferenceBracketMatched, + TransportParityVerdict::Mismatched, + CandidateTransportRecommendation::RecommendQuarantine, + mismatches, + ) + }; + + Ok(TransportParityObservation { + contract_version: TRANSPORT_QUALIFICATION_CONTRACT_VERSION, + scope_sha256: sha256_json_contract("bridge_tally_transport_scope_v1", scope)?, + reference_transport: scope.reference_transport, + candidate_transport: scope.candidate_transport, + reference_semantic_sha256, + candidate_semantic_sha256, + source_stability, + verdict, + candidate_recommendation, + reason_codes, + reference_before_metrics, + candidate_metrics, + reference_after_metrics, + }) +} + +fn validate_scope(scope: &TransportQualificationScope) -> Result<(), TallyError> { + if scope.pack != CapabilityPackId::CoreAccounting { + return Err(invalid_data("transport_qualification_pack_not_core")); + } + if scope.reference_transport != TransportId::XmlHttp + || scope.candidate_transport != TransportId::JsonEx + { + return Err(invalid_data("transport_qualification_pair_invalid")); + } + CanonicalText::parse(scope.source_identity.bridge_source_lineage.clone())?; + SourceRecordId::parse(scope.source_identity.company_guid.clone())?; + if !is_lower_sha256(&scope.source_identity.observed_fingerprint) { + return Err(invalid_data( + "transport_qualification_source_fingerprint_invalid", + )); + } + if scope.pack_schema_version != crate::CORE_ACCOUNTING_SCHEMA_VERSION { + return Err(invalid_data( + "transport_qualification_schema_version_invalid", + )); + } + TallyDate::parse(scope.window.from_yyyymmdd.clone())?; + TallyDate::parse(scope.window.to_yyyymmdd.clone())?; + if scope.window.from_yyyymmdd > scope.window.to_yyyymmdd { + return Err(invalid_data("transport_qualification_window_invalid")); + } + if !is_lower_sha256(scope.filters_sha256.as_str()) { + return Err(invalid_data("transport_qualification_filter_hash_invalid")); + } + Ok(()) +} + +fn validate_count_scopes( + scope: &TransportQualificationScope, + window: &CanonicalPackWindow, +) -> Result<(), TallyError> { + let Some(counts) = &window.source_counts else { + return Ok(()); + }; + for count in counts { + let descriptor = SourceCountScopeDescriptor { + source_identity: scope.source_identity.clone(), + pack: scope.pack, + pack_schema_version: scope.pack_schema_version, + object_type: count.object_type.clone(), + query_profile: scope.query_profile.clone(), + filters_sha256: scope.filters_sha256.clone(), + window: match count.source_count_scope { + SourceCountScope::Complete => None, + SourceCountScope::Window => Some(scope.window.clone()), + }, + }; + if !count.matches_scope_descriptor(&descriptor)? { + return Err(invalid_data( + "transport_qualification_source_count_scope_mismatch", + )); + } + } + Ok(()) +} + +fn validate_core_reference_integrity(batch: &CoreAccountingBatch) -> Result<(), TallyError> { + fn unique<'a>(values: impl Iterator) -> bool { + let mut observed = BTreeSet::new(); + values.into_iter().all(|value| observed.insert(value)) + } + + if !unique(batch.groups.iter().map(|record| record.source_id.as_str())) + || !unique(batch.ledgers.iter().map(|record| record.source_id.as_str())) + || !unique( + batch + .voucher_types + .iter() + .map(|record| record.source_id.as_str()), + ) + || !unique( + batch + .vouchers + .iter() + .map(|record| record.source_id.as_str()), + ) + || !unique( + batch + .ledger_entries + .iter() + .map(|record| record.source_id.as_str()), + ) + { + return Err(invalid_data( + "transport_qualification_duplicate_canonical_id", + )); + } + + let group_ids = batch + .groups + .iter() + .map(|record| record.source_id.as_str()) + .collect::>(); + for group in &batch.groups { + SourceRecordId::parse(group.source_id.clone())?; + CanonicalText::parse(group.name.clone())?; + if group + .parent_source_id + .as_deref() + .is_some_and(|parent| !group_ids.contains(parent)) + { + return Err(invalid_data("transport_qualification_group_parent_missing")); + } + } + for ledger in &batch.ledgers { + SourceRecordId::parse(ledger.source_id.clone())?; + CanonicalText::parse(ledger.name.clone())?; + if ledger + .parent_source_id + .as_deref() + .is_some_and(|parent| !group_ids.contains(parent)) + { + return Err(invalid_data( + "transport_qualification_ledger_parent_missing", + )); + } + } + for voucher_type in &batch.voucher_types { + SourceRecordId::parse(voucher_type.source_id.clone())?; + CanonicalText::parse(voucher_type.name.clone())?; + } + for voucher in &batch.vouchers { + SourceRecordId::parse(voucher.source_id.clone())?; + TallyDate::parse(voucher.date_yyyymmdd.clone())?; + } + for entry in &batch.ledger_entries { + SourceRecordId::parse(entry.source_id.clone())?; + } + if crate::reconciliation::assess_core_accounting(batch) + .checks + .reference_integrity + != crate::reconciliation::CheckState::Passed + { + return Err(invalid_data( + "transport_qualification_reference_integrity_failed", + )); + } + Ok(()) +} + +fn validate_metrics(metrics: TransportReadMetrics) -> Result<(), TallyError> { + if metrics.started_at_unix_ms < 0 + || metrics.completed_at_unix_ms < metrics.started_at_unix_ms + || metrics.response_bytes == 0 + { + return Err(invalid_data("transport_qualification_metrics_invalid")); + } + Ok(()) +} + +fn has_complete_core_count_evidence( + window: &CanonicalPackWindow, + batch: &CoreAccountingBatch, +) -> bool { + let Some(counts) = &window.source_counts else { + return false; + }; + let expected = [ + ("group", SourceCountScope::Complete, batch.groups.len()), + ("ledger", SourceCountScope::Complete, batch.ledgers.len()), + ( + "voucher_type", + SourceCountScope::Complete, + batch.voucher_types.len(), + ), + ("voucher", SourceCountScope::Window, batch.vouchers.len()), + ( + "ledger_entry", + SourceCountScope::Window, + batch.ledger_entries.len(), + ), + ]; + counts.len() == expected.len() + && expected.iter().all(|(object_type, scope, count)| { + counts.iter().any(|evidence| { + evidence.object_type.as_str() == *object_type + && evidence.source_count_scope == *scope + && evidence.source_reported_count == *count as u64 + }) + }) +} + +fn core_batch(window: &CanonicalPackWindow) -> Result<&CoreAccountingBatch, TallyError> { + match &window.batch { + PackBatch::CoreAccounting(batch) => Ok(batch), + _ => Err(invalid_data("transport_qualification_window_pack_mismatch")), + } +} + +#[derive(Debug, PartialEq, Eq, Serialize)] +struct CoreProjection { + groups: Vec, + ledgers: Vec, + voucher_types: Vec, + vouchers: Vec, + ledger_entries: Vec, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +struct GroupProjection { + source_id: String, + name: String, + parent_source_id: Option, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +struct LedgerProjection { + source_id: String, + name: String, + parent_source_id: Option, + opening_balance: Option, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +struct VoucherTypeProjection { + source_id: String, + name: String, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +struct VoucherProjection { + source_id: String, + date_yyyymmdd: String, + voucher_type_source_id: String, + voucher_number: Option, + cancelled: bool, + optional: bool, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +struct LedgerEntryProjection { + voucher_source_id: String, + ledger_source_id: String, + amount: String, + polarity: &'static str, +} + +fn project_core(batch: &CoreAccountingBatch) -> CoreProjection { + let mut groups = batch + .groups + .iter() + .map(|record| GroupProjection { + source_id: record.source_id.clone(), + name: record.name.clone(), + parent_source_id: record.parent_source_id.clone(), + }) + .collect::>(); + groups.sort(); + + let mut ledgers = batch + .ledgers + .iter() + .map(|record| LedgerProjection { + source_id: record.source_id.clone(), + name: record.name.clone(), + parent_source_id: record.parent_source_id.clone(), + opening_balance: record.opening_balance.as_ref().map(normalize_decimal), + }) + .collect::>(); + ledgers.sort(); + + let mut voucher_types = batch + .voucher_types + .iter() + .map(|record| VoucherTypeProjection { + source_id: record.source_id.clone(), + name: record.name.clone(), + }) + .collect::>(); + voucher_types.sort(); + + let mut vouchers = batch + .vouchers + .iter() + .map(|record| VoucherProjection { + source_id: record.source_id.clone(), + date_yyyymmdd: record.date_yyyymmdd.clone(), + voucher_type_source_id: record.voucher_type_source_id.clone(), + voucher_number: record.voucher_number.clone(), + cancelled: record.cancelled, + optional: record.optional, + }) + .collect::>(); + vouchers.sort(); + + let mut ledger_entries = batch + .ledger_entries + .iter() + .map(|record| LedgerEntryProjection { + // The production entry source ID includes raw-fragment provenance + // and is therefore intentionally transport-specific. + voucher_source_id: record.voucher_source_id.clone(), + ledger_source_id: record.ledger_source_id.clone(), + amount: normalize_decimal(&record.amount), + polarity: match record.polarity { + LedgerEntryPolarity::Debit => "debit", + LedgerEntryPolarity::Credit => "credit", + }, + }) + .collect::>(); + ledger_entries.sort(); + + CoreProjection { + groups, + ledgers, + voucher_types, + vouchers, + ledger_entries, + } +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +struct SourceCountProjection { + object_type: String, + query_profile: String, + source_scope_fingerprint: String, + source_count_scope: &'static str, + source_reported_count: u64, +} + +fn project_source_counts(window: &CanonicalPackWindow) -> Option> { + window.source_counts.as_ref().map(|counts| { + let mut projection = counts + .iter() + .map(|count| SourceCountProjection { + object_type: count.object_type.as_str().to_string(), + query_profile: count.query_profile.as_str().to_string(), + source_scope_fingerprint: count.source_scope_fingerprint.as_str().to_string(), + source_count_scope: match count.source_count_scope { + SourceCountScope::Complete => "complete", + SourceCountScope::Window => "window", + }, + source_reported_count: count.source_reported_count, + }) + .collect::>(); + projection.sort(); + projection + }) +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +struct RecordEvidenceProjection { + object_type: String, + identity_kind: &'static str, + observed_guid: Option, + observed_remote_id: Option, + observed_master_id: Option, + alter_id: Option, +} + +fn project_record_evidence(window: &CanonicalPackWindow) -> Option> { + window.record_evidence.as_ref().map(|records| { + let mut projection = records + .iter() + .map(|record| RecordEvidenceProjection { + object_type: record.object_type.as_str().to_string(), + identity_kind: match record.identity_kind { + SourceIdentityKind::Guid => "guid", + SourceIdentityKind::RemoteId => "remote_id", + SourceIdentityKind::MasterId => "master_id", + SourceIdentityKind::Fallback => "fallback", + }, + observed_guid: record + .observed_identities + .guid + .as_ref() + .map(|value| value.as_str().to_string()), + observed_remote_id: record + .observed_identities + .remote_id + .as_ref() + .map(|value| value.as_str().to_string()), + observed_master_id: record + .observed_identities + .master_id + .as_ref() + .map(|value| value.as_str().to_string()), + alter_id: record + .alter_id + .as_ref() + .map(|value| value.as_str().to_string()), + }) + .collect::>(); + projection.sort(); + projection + }) +} + +fn normalize_decimal(value: &ExactDecimal) -> String { + let raw = value.as_str(); + let (negative, unsigned) = raw + .strip_prefix('-') + .map_or((false, raw), |body| (true, body)); + let (whole, fractional) = unsigned + .split_once('.') + .map_or((unsigned, None), |(whole, fraction)| { + (whole, Some(fraction)) + }); + let normalized_whole = whole.trim_start_matches('0'); + let normalized_whole = if normalized_whole.is_empty() { + "0" + } else { + normalized_whole + }; + let normalized_fractional = fractional.map(|part| part.trim_end_matches('0')); + let zero = normalized_whole == "0" && normalized_fractional.is_none_or(str::is_empty); + let sign = if negative && !zero { "-" } else { "" }; + match normalized_fractional.filter(|part| !part.is_empty()) { + Some(part) => format!("{sign}{normalized_whole}.{part}"), + None => format!("{sign}{normalized_whole}"), + } +} + +#[derive(Serialize)] +struct QualificationHashPreimage<'a, T: Serialize + ?Sized> { + contract: &'static str, + value: &'a T, +} + +fn sha256_json_contract( + contract: &'static str, + value: &(impl Serialize + ?Sized), +) -> Result { + let bytes = serde_json::to_vec(&QualificationHashPreimage { contract, value }) + .map_err(|_| invalid_data("transport_qualification_serialization_failed"))?; + Ok(hex_lower(&Sha256::digest(bytes))) +} + +fn is_lower_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for byte in bytes { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + encoded +} + +fn invalid_data(code: &str) -> TallyError { + TallyError::InvalidData { + code: code.to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + source_count_scope_fingerprint, LedgerEntryRecord, LedgerRecord, ObservedSourceIdentities, + RawSourceSha256, SourceRecordEvidence, SourceReportedCountEvidence, VoucherRecord, + VoucherTypeRecord, + }; + + fn scope() -> TransportQualificationScope { + TransportQualificationScope { + source_identity: SourceIdentity { + bridge_source_lineage: "tally_local:test".to_string(), + company_guid: "synthetic-private-company-guid".to_string(), + observed_fingerprint: "c".repeat(64), + }, + product: CanonicalText::parse("TallyPrime").unwrap(), + release: CanonicalText::parse("7.0").unwrap(), + mode: CanonicalText::parse("Educational").unwrap(), + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: crate::CORE_ACCOUNTING_SCHEMA_VERSION, + window: ReadWindow { + from_yyyymmdd: "20260401".to_string(), + to_yyyymmdd: "20260430".to_string(), + }, + query_profile: CanonicalText::parse("bridge_core_v3").unwrap(), + filters_sha256: CanonicalText::parse("a".repeat(64)).unwrap(), + reference_transport: TransportId::XmlHttp, + candidate_transport: TransportId::JsonEx, + candidate_request_profile: CanonicalText::parse("jsonex_core_v1").unwrap(), + } + } + + fn metrics(started_at_unix_ms: i64, completed_at_unix_ms: i64) -> TransportReadMetrics { + TransportReadMetrics { + started_at_unix_ms, + completed_at_unix_ms, + response_bytes: 128, + } + } + + fn evidence(object_type: &str, source_id: &str, raw_hash_byte: char) -> SourceRecordEvidence { + let source_id = SourceRecordId::parse(source_id).unwrap(); + let fallback = object_type == "ledger_entry"; + SourceRecordEvidence { + object_type: CanonicalText::parse(object_type).unwrap(), + source_id: source_id.clone(), + identity_kind: if fallback { + SourceIdentityKind::Fallback + } else { + SourceIdentityKind::Guid + }, + observed_identities: if fallback { + ObservedSourceIdentities::default() + } else { + ObservedSourceIdentities { + guid: Some(source_id.clone()), + ..Default::default() + } + }, + raw_source_sha256: RawSourceSha256::parse(raw_hash_byte.to_string().repeat(64)) + .unwrap(), + alter_id: None, + } + } + + fn entry_window( + scope: &TransportQualificationScope, + entry_source_id: &str, + amount: &str, + raw_hash_byte: char, + ) -> CanonicalPackWindow { + let count = |object_type: &str, source_count_scope: SourceCountScope, value: u64| { + let descriptor = SourceCountScopeDescriptor { + source_identity: scope.source_identity.clone(), + pack: scope.pack, + pack_schema_version: scope.pack_schema_version, + object_type: CanonicalText::parse(object_type).unwrap(), + query_profile: scope.query_profile.clone(), + filters_sha256: scope.filters_sha256.clone(), + window: (source_count_scope == SourceCountScope::Window) + .then(|| scope.window.clone()), + }; + SourceReportedCountEvidence { + object_type: descriptor.object_type.clone(), + query_profile: descriptor.query_profile.clone(), + source_scope_fingerprint: source_count_scope_fingerprint( + &descriptor, + source_count_scope, + ) + .unwrap(), + source_count_scope, + source_reported_count: value, + } + }; + CanonicalPackWindow { + batch: PackBatch::CoreAccounting(CoreAccountingBatch { + ledgers: vec![LedgerRecord { + source_id: "ledger:1".to_string(), + name: "Synthetic Ledger".to_string(), + parent_source_id: None, + opening_balance: Some(ExactDecimal::parse("0.00").unwrap()), + }], + voucher_types: vec![VoucherTypeRecord { + source_id: "voucher-type:1".to_string(), + name: "Synthetic Voucher Type".to_string(), + }], + vouchers: vec![VoucherRecord { + source_id: "voucher:1".to_string(), + date_yyyymmdd: "20260415".to_string(), + voucher_type_source_id: "voucher-type:1".to_string(), + voucher_number: Some("SYN-1".to_string()), + cancelled: false, + optional: false, + }], + ledger_entries: vec![LedgerEntryRecord { + source_id: entry_source_id.to_string(), + voucher_source_id: "voucher:1".to_string(), + ledger_source_id: "ledger:1".to_string(), + amount: ExactDecimal::parse(amount).unwrap(), + polarity: LedgerEntryPolarity::Debit, + }], + ..Default::default() + }), + source_counts: Some(vec![ + count("group", SourceCountScope::Complete, 0), + count("ledger", SourceCountScope::Complete, 1), + count("voucher_type", SourceCountScope::Complete, 1), + count("voucher", SourceCountScope::Window, 1), + count("ledger_entry", SourceCountScope::Window, 1), + ]), + record_evidence: Some(vec![ + evidence("ledger", "ledger:1", raw_hash_byte), + evidence("voucher_type", "voucher-type:1", raw_hash_byte), + evidence("voucher", "voucher:1", raw_hash_byte), + evidence("ledger_entry", entry_source_id, raw_hash_byte), + ]), + } + } + + #[test] + fn semantic_match_excludes_transport_raw_hash_and_entry_id_and_normalizes_scale() { + let scope = scope(); + let reference = entry_window(&scope, "xml-entry-hash", "-001.00", 'a'); + let candidate = entry_window(&scope, "json-entry-hash", "-1.0", 'b'); + let observation = qualify_core_transport_shadow( + &scope, + &reference, + &candidate, + &reference, + metrics(10, 20), + metrics(21, 25), + metrics(26, 35), + ) + .unwrap(); + + assert_eq!(observation.verdict, TransportParityVerdict::Matched); + assert_eq!( + observation.candidate_recommendation, + CandidateTransportRecommendation::ContinueShadowing + ); + assert_eq!( + observation.reason_codes, + vec![TransportParityReasonCode::SemanticParityObserved] + ); + assert_eq!( + observation.reference_semantic_sha256, + observation.candidate_semantic_sha256 + ); + } + + #[test] + fn bracketed_semantic_mismatch_recommends_scope_quarantine() { + let scope = scope(); + let reference = entry_window(&scope, "xml-entry", "-1.00", 'a'); + let candidate = entry_window(&scope, "json-entry", "-2.00", 'b'); + let observation = qualify_core_transport_shadow( + &scope, + &reference, + &candidate, + &reference, + metrics(10, 20), + metrics(21, 25), + metrics(26, 35), + ) + .unwrap(); + + assert_eq!(observation.verdict, TransportParityVerdict::Mismatched); + assert_eq!( + observation.candidate_recommendation, + CandidateTransportRecommendation::RecommendQuarantine + ); + assert!(observation + .reason_codes + .contains(&TransportParityReasonCode::CanonicalSemanticsMismatch)); + } + + #[test] + fn drift_or_missing_evidence_never_becomes_a_transport_mismatch() { + let scope = scope(); + let before = entry_window(&scope, "xml-entry", "-1.00", 'a'); + let candidate = entry_window(&scope, "json-entry", "-2.00", 'b'); + let after = entry_window(&scope, "xml-entry-after", "-3.00", 'c'); + let drift = qualify_core_transport_shadow( + &scope, + &before, + &candidate, + &after, + metrics(10, 20), + metrics(21, 25), + metrics(26, 35), + ) + .unwrap(); + assert_eq!(drift.verdict, TransportParityVerdict::Inconclusive); + assert_eq!( + drift.source_stability, + SourceStabilityEvidence::ReferenceBracketMismatch + ); + assert!(drift + .reason_codes + .contains(&TransportParityReasonCode::CanonicalSemanticsMismatch)); + + let mut no_counts = before.clone(); + no_counts.source_counts = None; + let missing = qualify_core_transport_shadow( + &scope, + &no_counts, + &no_counts, + &no_counts, + metrics(10, 20), + metrics(21, 25), + metrics(26, 35), + ) + .unwrap(); + assert_eq!(missing.verdict, TransportParityVerdict::Inconclusive); + assert!(missing + .reason_codes + .contains(&TransportParityReasonCode::SourceCountEvidenceUnavailable)); + + let mut partial_counts = before.clone(); + partial_counts.source_counts.as_mut().unwrap().pop(); + let partial = qualify_core_transport_shadow( + &scope, + &partial_counts, + &partial_counts, + &partial_counts, + metrics(10, 20), + metrics(21, 25), + metrics(26, 35), + ) + .unwrap(); + assert_eq!(partial.verdict, TransportParityVerdict::Inconclusive); + assert_eq!( + partial.source_stability, + SourceStabilityEvidence::EvidenceUnavailable + ); + } + + #[test] + fn observation_receipt_does_not_serialize_company_or_record_values() { + let scope = scope(); + let reference = entry_window(&scope, "xml-private-entry", "-1.00", 'a'); + let candidate = entry_window(&scope, "json-private-entry", "-1.0", 'b'); + let observation = qualify_core_transport_shadow( + &scope, + &reference, + &candidate, + &reference, + metrics(10, 20), + metrics(21, 25), + metrics(26, 35), + ) + .unwrap(); + let json = serde_json::to_string(&observation).unwrap(); + for private in [ + "synthetic-private-company-guid", + "xml-private-entry", + "json-private-entry", + ] { + assert!(!json.contains(private)); + } + } + + #[test] + fn invalid_pair_and_metrics_fail_closed() { + let valid_scope = scope(); + let reference = entry_window(&valid_scope, "xml-entry", "-1.00", 'a'); + let candidate = entry_window(&valid_scope, "json-entry", "-1.00", 'b'); + let mut invalid_scope = valid_scope.clone(); + invalid_scope.candidate_transport = TransportId::Odbc; + assert!(matches!( + qualify_core_transport_shadow( + &invalid_scope, + &reference, + &candidate, + &reference, + metrics(10, 20), + metrics(21, 25), + metrics(26, 35), + ), + Err(TallyError::InvalidData { code }) if code == "transport_qualification_pair_invalid" + )); + assert!(matches!( + qualify_core_transport_shadow( + &valid_scope, + &reference, + &candidate, + &reference, + TransportReadMetrics { + started_at_unix_ms: 20, + completed_at_unix_ms: 10, + response_bytes: 128, + }, + metrics(21, 25), + metrics(26, 35), + ), + Err(TallyError::InvalidData { code }) if code == "transport_qualification_metrics_invalid" + )); + + assert!(matches!( + qualify_core_transport_shadow( + &valid_scope, + &reference, + &candidate, + &reference, + metrics(10, 30), + metrics(20, 25), + metrics(31, 40), + ), + Err(TallyError::InvalidData { code }) if code == "transport_qualification_bracket_order_invalid" + )); + + let mut wrong_schema = valid_scope.clone(); + wrong_schema.pack_schema_version.minor += 1; + assert!(matches!( + qualify_core_transport_shadow( + &wrong_schema, + &reference, + &candidate, + &reference, + metrics(10, 20), + metrics(21, 25), + metrics(26, 35), + ), + Err(TallyError::InvalidData { code }) if code == "transport_qualification_schema_version_invalid" + )); + + let mut invalid_scope = valid_scope; + invalid_scope.window.to_yyyymmdd = "20260230".to_string(); + assert!(qualify_core_transport_shadow( + &invalid_scope, + &reference, + &candidate, + &reference, + metrics(10, 20), + metrics(21, 25), + metrics(26, 35), + ) + .is_err()); + } +} diff --git a/src-tauri/crates/bridge-tally-incremental/Cargo.toml b/src-tauri/crates/bridge-tally-incremental/Cargo.toml new file mode 100644 index 0000000..c711cb1 --- /dev/null +++ b/src-tauri/crates/bridge-tally-incremental/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "bridge-tally-incremental" +version = "0.1.0" +edition = "2021" +rust-version = "1.96" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +description = "Portable fail-closed incremental policy for Bridge Tally sync" + +[dependencies] +bridge-tally-core = { path = "../bridge-tally-core" } +serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/crates/bridge-tally-incremental/src/lib.rs b/src-tauri/crates/bridge-tally-incremental/src/lib.rs new file mode 100644 index 0000000..0907cc8 --- /dev/null +++ b/src-tauri/crates/bridge-tally-incremental/src/lib.rs @@ -0,0 +1,1121 @@ +//! Portable, fail-closed incremental-sync policy for Bridge Tally integrations. +//! +//! This crate performs deterministic policy calculations; it does not authenticate +//! database or protocol evidence. The production runtime must obtain capability and +//! checkpoint facts through its sealed repository verifier before calling `plan_sync`. + +use bridge_tally_core::{ + CapabilityPackId, CapabilityState, EvidenceConfidence, PackSchemaVersion, TransportId, + VerificationState, +}; +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Every dimension that can change the meaning or ordering of a Tally change +/// identifier. Checkpoints are reusable only under exact equality. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct IncrementalScope { + /// Stable Bridge lineage for the Tally origin; never a raw endpoint. + pub source_lineage: String, + pub company_guid: String, + pub company_fingerprint: String, + pub object_type: String, + pub capability_profile_version: u16, + pub product: String, + pub release: String, + pub mode: String, + pub transport: TransportId, + pub pack: CapabilityPackId, + pub pack_schema_version: PackSchemaVersion, + /// Stable name of the exact query and canonical mapping. + pub query_profile: String, + /// Canonical lowercase SHA-256 of every filter that changes feed membership. + pub filters_sha256: String, + /// Versioned date/overlap-window policy; never inferred from a cursor alone. + pub date_window_policy: String, +} + +impl IncrementalScope { + pub fn is_exact(&self) -> bool { + self.capability_profile_version > 0 + && [ + self.source_lineage.as_str(), + self.company_guid.as_str(), + self.company_fingerprint.as_str(), + self.object_type.as_str(), + self.product.as_str(), + self.release.as_str(), + self.mode.as_str(), + self.query_profile.as_str(), + self.date_window_policy.as_str(), + ] + .into_iter() + .all(|value| { + !value.trim().is_empty() + && value.len() <= 512 + && !value.chars().any(char::is_control) + }) + && self.filters_sha256.len() == 64 + && self + .filters_sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangeIdentifierSemantics { + /// Verified monotonic identifier scoped to one exact Tally object type. + MonotonicPerObject, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct IncrementalCapabilityObservation { + pub scope: IncrementalScope, + pub state: CapabilityState, + pub confidence: EvidenceConfidence, + pub identifier_semantics: ChangeIdentifierSemantics, + /// True only after the exact query profile was observed to support an + /// inclusive lower bound, which makes overlap reads safe. + pub inclusive_lower_bound_observed: bool, + /// True only when the exact response contract exposes a source high + /// watermark independently of the maximum identifier in returned rows. + pub explicit_source_high_watermark_observed: bool, +} + +impl IncrementalCapabilityObservation { + fn proves_incremental_for(&self, scope: &IncrementalScope) -> bool { + self.scope == *scope + && scope.is_exact() + && self.state == CapabilityState::Supported + && self.confidence == EvidenceConfidence::Observed + && self.identifier_semantics == ChangeIdentifierSemantics::MonotonicPerObject + && self.inclusive_lower_bound_observed + && self.explicit_source_high_watermark_observed + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct IncrementalCheckpoint { + pub scope: IncrementalScope, + pub high_watermark: u64, + pub established_by_verified_full_snapshot: bool, + pub established_by_proof_sha256: String, + pub last_transition_proof_sha256: String, + pub last_identity_sweep_unix_ms: i64, + pub invalidated_reason: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub struct IncrementalPolicy { + /// Number of identifiers re-read before the checkpoint. Inclusive queries + /// therefore start at `checkpoint - overlap`, saturating at zero. + pub overlap_identifiers: u64, + pub identity_sweep_interval_ms: i64, +} + +impl Default for IncrementalPolicy { + fn default() -> Self { + Self { + overlap_identifiers: 128, + identity_sweep_interval_ms: 24 * 60 * 60 * 1_000, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FullSnapshotReason { + ScopeIncomplete, + CapabilityNotObserved, + CapabilityUnsupported, + CapabilityNotObservedAtRuntime, + CapabilityScopeDrift, + NoVerifiedCheckpoint, + CheckpointScopeDrift, + CheckpointInvalidated, + InvalidPolicy, + ReceiptScopeMismatch, + SourceHighWatermarkMissing, + InvalidProofReceipt, +} + +impl FullSnapshotReason { + pub const fn safe_warning_code(self) -> &'static str { + match self { + Self::ScopeIncomplete => "incremental_scope_incomplete_full_snapshot_required", + Self::CapabilityNotObserved => "incremental_capability_unknown_full_snapshot_required", + Self::CapabilityUnsupported => { + "incremental_capability_unsupported_full_snapshot_required" + } + Self::CapabilityNotObservedAtRuntime => { + "incremental_capability_not_observed_full_snapshot_required" + } + Self::CapabilityScopeDrift => { + "incremental_capability_scope_drift_full_snapshot_required" + } + Self::NoVerifiedCheckpoint => "verified_full_snapshot_checkpoint_required", + Self::CheckpointScopeDrift => { + "incremental_checkpoint_scope_drift_full_snapshot_required" + } + Self::CheckpointInvalidated => "incremental_checkpoint_invalid_full_snapshot_required", + Self::InvalidPolicy => "incremental_policy_invalid_full_snapshot_required", + Self::ReceiptScopeMismatch => { + "incremental_receipt_scope_mismatch_full_snapshot_required" + } + Self::SourceHighWatermarkMissing => { + "incremental_source_high_watermark_missing_full_snapshot_required" + } + Self::InvalidProofReceipt => "incremental_proof_receipt_invalid_full_snapshot_required", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedFullSnapshotReceipt { + scope: IncrementalScope, + verification: VerificationState, + proof_sha256: String, + observed_source_high_watermark: Option, + completed_at_unix_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct VerifiedIncrementalReceipt { + scope: IncrementalScope, + checkpoint_before: u64, + verification: VerificationState, + proof_sha256: String, + observed_source_high_watermark: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SyncPlan { + FullSnapshot { + reason: FullSnapshotReason, + warning_code: &'static str, + }, + Incremental { + from_change_id_inclusive: u64, + checkpoint_before: u64, + identity_sweep_required: bool, + }, +} + +pub fn plan_sync( + policy: IncrementalPolicy, + scope: &IncrementalScope, + capability: Option<&IncrementalCapabilityObservation>, + checkpoint: Option<&IncrementalCheckpoint>, + now_unix_ms: i64, +) -> SyncPlan { + let full = |reason: FullSnapshotReason| SyncPlan::FullSnapshot { + reason, + warning_code: reason.safe_warning_code(), + }; + + if policy.identity_sweep_interval_ms <= 0 { + return full(FullSnapshotReason::InvalidPolicy); + } + if !scope.is_exact() { + return full(FullSnapshotReason::ScopeIncomplete); + } + + let Some(capability) = capability else { + return full(FullSnapshotReason::CapabilityNotObserved); + }; + if capability.scope != *scope { + return full(FullSnapshotReason::CapabilityScopeDrift); + } + if capability.state == CapabilityState::Unsupported { + return full(FullSnapshotReason::CapabilityUnsupported); + } + if !capability.proves_incremental_for(scope) { + return full(FullSnapshotReason::CapabilityNotObservedAtRuntime); + } + + let Some(checkpoint) = checkpoint else { + return full(FullSnapshotReason::NoVerifiedCheckpoint); + }; + if checkpoint.scope != *scope { + return full(FullSnapshotReason::CheckpointScopeDrift); + } + if checkpoint.invalidated_reason.is_some() { + return full(FullSnapshotReason::CheckpointInvalidated); + } + if !checkpoint.established_by_verified_full_snapshot { + return full(FullSnapshotReason::NoVerifiedCheckpoint); + } + if !is_lower_sha256(&checkpoint.established_by_proof_sha256) + || !is_lower_sha256(&checkpoint.last_transition_proof_sha256) + { + return full(FullSnapshotReason::InvalidProofReceipt); + } + + SyncPlan::Incremental { + from_change_id_inclusive: checkpoint + .high_watermark + .saturating_sub(policy.overlap_identifiers), + checkpoint_before: checkpoint.high_watermark, + identity_sweep_required: identity_sweep_due( + checkpoint.last_identity_sweep_unix_ms, + now_unix_ms, + policy.identity_sweep_interval_ms, + ), + } +} + +pub fn establish_checkpoint_from_full_snapshot( + scope: IncrementalScope, + capability: &IncrementalCapabilityObservation, + receipt: VerifiedFullSnapshotReceipt, +) -> Result { + if receipt.scope != scope { + return Err(FullSnapshotReason::ReceiptScopeMismatch); + } + if receipt.verification != VerificationState::Verified { + return Err(FullSnapshotReason::NoVerifiedCheckpoint); + } + if !is_lower_sha256(&receipt.proof_sha256) || receipt.completed_at_unix_ms <= 0 { + return Err(FullSnapshotReason::InvalidProofReceipt); + } + let observed_high_watermark = receipt + .observed_source_high_watermark + .ok_or(FullSnapshotReason::SourceHighWatermarkMissing)?; + if !capability.proves_incremental_for(&scope) { + return Err(FullSnapshotReason::CapabilityNotObservedAtRuntime); + } + Ok(IncrementalCheckpoint { + scope, + high_watermark: observed_high_watermark, + established_by_verified_full_snapshot: true, + established_by_proof_sha256: receipt.proof_sha256.clone(), + last_transition_proof_sha256: receipt.proof_sha256, + last_identity_sweep_unix_ms: receipt.completed_at_unix_ms, + invalidated_reason: None, + }) +} + +fn identity_sweep_due(last_sweep_unix_ms: i64, now_unix_ms: i64, interval_ms: i64) -> bool { + now_unix_ms < last_sweep_unix_ms + || now_unix_ms.saturating_sub(last_sweep_unix_ms) >= interval_ms +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CheckpointInvalidationReason { + IdentifierRegressedOrReset, + SourceHighWatermarkMissing, + IncrementalResponseUnverified, + ReceiptScopeMismatch, + InvalidProofReceipt, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CheckpointTransition { + Advanced { + from: u64, + to: u64, + }, + Unchanged { + at: u64, + }, + Invalidated { + at: u64, + reason: CheckpointInvalidationReason, + }, +} + +/// Advance only from a fully verified incremental response and an explicit +/// source high-watermark. Record IDs inside the overlap are intentionally not +/// used to detect regression. +pub fn apply_incremental_high_watermark( + checkpoint: &mut IncrementalCheckpoint, + receipt: &VerifiedIncrementalReceipt, +) -> CheckpointTransition { + let invalidate = |checkpoint: &mut IncrementalCheckpoint, + reason: CheckpointInvalidationReason| { + checkpoint.invalidated_reason = Some(reason); + CheckpointTransition::Invalidated { + at: checkpoint.high_watermark, + reason, + } + }; + + if let Some(reason) = checkpoint.invalidated_reason { + return CheckpointTransition::Invalidated { + at: checkpoint.high_watermark, + reason, + }; + } + + if receipt.scope != checkpoint.scope || receipt.checkpoint_before != checkpoint.high_watermark { + return invalidate( + checkpoint, + CheckpointInvalidationReason::ReceiptScopeMismatch, + ); + } + if !is_lower_sha256(&receipt.proof_sha256) { + return invalidate( + checkpoint, + CheckpointInvalidationReason::InvalidProofReceipt, + ); + } + if receipt.verification != VerificationState::Verified { + return invalidate( + checkpoint, + CheckpointInvalidationReason::IncrementalResponseUnverified, + ); + } + let Some(observed) = receipt.observed_source_high_watermark else { + return invalidate( + checkpoint, + CheckpointInvalidationReason::SourceHighWatermarkMissing, + ); + }; + if observed < checkpoint.high_watermark { + return invalidate( + checkpoint, + CheckpointInvalidationReason::IdentifierRegressedOrReset, + ); + } + if observed == checkpoint.high_watermark { + checkpoint.last_transition_proof_sha256 = receipt.proof_sha256.clone(); + return CheckpointTransition::Unchanged { at: observed }; + } + + let from = checkpoint.high_watermark; + checkpoint.high_watermark = observed; + checkpoint.last_transition_proof_sha256 = receipt.proof_sha256.clone(); + CheckpointTransition::Advanced { from, to: observed } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CanonicalChange { + pub stable_identity: String, + pub change_id: u64, + pub canonical_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct AmbiguousChange { + pub change_id: u64, + pub reason: ChangeRejectionReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ChangeRejectionReason { + InvalidIdentity, + InvalidCanonicalSha256, + ConflictingPayload, +} + +/// Deduplicate overlap records deterministically. The newest change identifier +/// wins; identical replayed records collapse; conflicting payloads at the same +/// identity and change identifier fail closed. +pub fn deduplicate_changes( + changes: impl IntoIterator, +) -> Result, AmbiguousChange> { + let mut deduplicated = BTreeMap::::new(); + for change in changes { + if !valid_identity(&change.stable_identity) { + return Err(AmbiguousChange { + change_id: change.change_id, + reason: ChangeRejectionReason::InvalidIdentity, + }); + } + if !is_lower_sha256(&change.canonical_sha256) { + return Err(AmbiguousChange { + change_id: change.change_id, + reason: ChangeRejectionReason::InvalidCanonicalSha256, + }); + } + match deduplicated.get(&change.stable_identity) { + None => { + deduplicated.insert(change.stable_identity.clone(), change); + } + Some(existing) if change.change_id > existing.change_id => { + deduplicated.insert(change.stable_identity.clone(), change); + } + Some(existing) if change.change_id < existing.change_id => {} + Some(existing) if change.canonical_sha256 == existing.canonical_sha256 => {} + Some(_) => { + return Err(AmbiguousChange { + change_id: change.change_id, + reason: ChangeRejectionReason::ConflictingPayload, + }); + } + } + } + Ok(deduplicated.into_values().collect()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeletionCapabilityObservation { + scope: IncrementalScope, + rule_id: String, + state: CapabilityState, + confidence: EvidenceConfidence, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExplicitTombstone { + pub stable_identity: String, + pub rule_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IncrementalReconciliation { + pub upserted_identities: BTreeSet, + pub deleted_identities: BTreeSet, + /// Existing records not mentioned by the feed remain present. This set is + /// explicit so callers cannot accidentally interpret absence as deletion. + pub retained_absent_identities: BTreeSet, + pub rejected_tombstones: BTreeSet, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum IncrementalReconciliationError { + InvalidIdentity, + InvalidDeletionRule, + AmbiguousChange { + change_id: u64, + reason: ChangeRejectionReason, + }, + ConflictingAction, +} + +pub fn reconcile_incremental( + scope: &IncrementalScope, + existing_identities: &BTreeSet, + changes: &[CanonicalChange], + tombstones: &[ExplicitTombstone], + deletion_capability: Option<&DeletionCapabilityObservation>, +) -> Result { + for identity in existing_identities { + if !valid_identity(identity) { + return Err(IncrementalReconciliationError::InvalidIdentity); + } + } + let deduplicated = deduplicate_changes(changes.iter().cloned()).map_err(|error| { + IncrementalReconciliationError::AmbiguousChange { + change_id: error.change_id, + reason: error.reason, + } + })?; + let upserted_identities = deduplicated + .iter() + .map(|change| change.stable_identity.clone()) + .collect::>(); + + let mut deleted_identities = BTreeSet::new(); + let mut rejected_tombstones = BTreeSet::new(); + for tombstone in tombstones { + if !valid_identity(&tombstone.stable_identity) { + return Err(IncrementalReconciliationError::InvalidIdentity); + } + if !valid_rule_id(&tombstone.rule_id) { + return Err(IncrementalReconciliationError::InvalidDeletionRule); + } + let proven = deletion_capability.is_some_and(|capability| { + capability.scope == *scope + && capability.state == CapabilityState::Supported + && capability.confidence == EvidenceConfidence::Observed + && !capability.rule_id.trim().is_empty() + && capability.rule_id == tombstone.rule_id + }); + if proven { + deleted_identities.insert(tombstone.stable_identity.clone()); + } else { + rejected_tombstones.insert(tombstone.stable_identity.clone()); + } + } + if upserted_identities + .intersection(&deleted_identities) + .next() + .is_some() + { + return Err(IncrementalReconciliationError::ConflictingAction); + } + + let retained_absent_identities = existing_identities + .difference(&upserted_identities) + .filter(|identity| !deleted_identities.contains(*identity)) + .cloned() + .collect(); + + Ok(IncrementalReconciliation { + upserted_identities, + deleted_identities, + retained_absent_identities, + rejected_tombstones, + }) +} + +fn valid_identity(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 512 && !value.chars().any(char::is_control) +} + +fn valid_rule_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.')) +} + +fn is_lower_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scope(release: &str, query_profile: &str) -> IncrementalScope { + IncrementalScope { + source_lineage: "source-lineage".to_string(), + company_guid: "company-guid".to_string(), + company_fingerprint: "company-fingerprint".to_string(), + object_type: "voucher".to_string(), + capability_profile_version: 1, + product: "tally_prime".to_string(), + release: release.to_string(), + mode: "education".to_string(), + transport: TransportId::XmlHttp, + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + query_profile: query_profile.to_string(), + filters_sha256: "a".repeat(64), + date_window_policy: "change_id_overlap_v1".to_string(), + } + } + + fn observed_capability(scope: IncrementalScope) -> IncrementalCapabilityObservation { + IncrementalCapabilityObservation { + scope, + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + identifier_semantics: ChangeIdentifierSemantics::MonotonicPerObject, + inclusive_lower_bound_observed: true, + explicit_source_high_watermark_observed: true, + } + } + + fn checkpoint(scope: IncrementalScope, high_watermark: u64) -> IncrementalCheckpoint { + IncrementalCheckpoint { + scope, + high_watermark, + established_by_verified_full_snapshot: true, + established_by_proof_sha256: "c".repeat(64), + last_transition_proof_sha256: "c".repeat(64), + last_identity_sweep_unix_ms: 1_000, + invalidated_reason: None, + } + } + + fn full_receipt( + scope: IncrementalScope, + verification: VerificationState, + high_watermark: Option, + ) -> VerifiedFullSnapshotReceipt { + VerifiedFullSnapshotReceipt { + scope, + verification, + proof_sha256: "d".repeat(64), + observed_source_high_watermark: high_watermark, + completed_at_unix_ms: 1_000, + } + } + + #[test] + fn overlap_start_saturates_and_never_exceeds_checkpoint() { + let scope = scope("7.0", "voucher-v1"); + let capability = observed_capability(scope.clone()); + for checkpoint_value in 0..=256 { + for overlap in [0, 1, 7, 128, u64::MAX] { + let plan = plan_sync( + IncrementalPolicy { + overlap_identifiers: overlap, + identity_sweep_interval_ms: 10_000, + }, + &scope, + Some(&capability), + Some(&checkpoint(scope.clone(), checkpoint_value)), + 1_001, + ); + let SyncPlan::Incremental { + from_change_id_inclusive, + checkpoint_before, + .. + } = plan + else { + panic!("observed exact capability should permit incremental sync"); + }; + assert_eq!(checkpoint_before, checkpoint_value); + assert_eq!( + from_change_id_inclusive, + checkpoint_value.saturating_sub(overlap) + ); + assert!(from_change_id_inclusive <= checkpoint_before); + } + } + } + + #[test] + fn any_scope_drift_forces_honest_full_snapshot_fallback() { + let original = scope("7.0", "voucher-v1"); + let capability = observed_capability(original.clone()); + let checkpoint = checkpoint(original.clone(), 42); + let mut object_changed = original.clone(); + object_changed.object_type = "ledger".to_string(); + let mut profile_changed = original.clone(); + profile_changed.capability_profile_version = 2; + let mut transport_changed = original.clone(); + transport_changed.transport = TransportId::JsonEx; + let mut schema_changed = original.clone(); + schema_changed.pack_schema_version.minor = 1; + let mut company_changed = original.clone(); + company_changed.company_guid = "different-company".to_string(); + let mut pack_changed = original.clone(); + pack_changed.pack = CapabilityPackId::BillsAndPayments; + let mut lineage_changed = original.clone(); + lineage_changed.source_lineage = "different-source-lineage".to_string(); + let mut filters_changed = original.clone(); + filters_changed.filters_sha256 = "b".repeat(64); + let mut window_policy_changed = original.clone(); + window_policy_changed.date_window_policy = "different-policy".to_string(); + for changed in [ + scope("7.1", "voucher-v1"), + scope("7.0", "voucher-v2"), + object_changed, + profile_changed, + transport_changed, + schema_changed, + company_changed, + pack_changed, + lineage_changed, + filters_changed, + window_policy_changed, + ] { + let plan = plan_sync( + IncrementalPolicy::default(), + &changed, + Some(&capability), + Some(&checkpoint), + 2_000, + ); + assert!(matches!( + plan, + SyncPlan::FullSnapshot { + reason: FullSnapshotReason::CapabilityScopeDrift, + .. + } + )); + } + } + + #[test] + fn documented_or_inferred_capability_is_not_treated_as_observed() { + let scope = scope("7.0", "voucher-v1"); + for confidence in [ + EvidenceConfidence::Documented, + EvidenceConfidence::Inferred, + EvidenceConfidence::Unknown, + ] { + let mut capability = observed_capability(scope.clone()); + capability.confidence = confidence; + assert!(matches!( + plan_sync( + IncrementalPolicy::default(), + &scope, + Some(&capability), + Some(&checkpoint(scope.clone(), 42)), + 2_000, + ), + SyncPlan::FullSnapshot { + reason: FullSnapshotReason::CapabilityNotObservedAtRuntime, + .. + } + )); + } + for missing_protocol_fact in [ + { + let mut capability = observed_capability(scope.clone()); + capability.inclusive_lower_bound_observed = false; + capability + }, + { + let mut capability = observed_capability(scope.clone()); + capability.explicit_source_high_watermark_observed = false; + capability + }, + ] { + assert!(matches!( + plan_sync( + IncrementalPolicy::default(), + &scope, + Some(&missing_protocol_fact), + Some(&checkpoint(scope.clone(), 42)), + 2_000, + ), + SyncPlan::FullSnapshot { + reason: FullSnapshotReason::CapabilityNotObservedAtRuntime, + .. + } + )); + } + } + + #[test] + fn malformed_filter_hash_or_missing_profile_dimension_forces_full_snapshot() { + let valid = scope("7.0", "voucher-v1"); + let mut invalid_hash = valid.clone(); + invalid_hash.filters_sha256 = "not-a-sha256".to_string(); + let mut missing_profile = valid.clone(); + missing_profile.capability_profile_version = 0; + let mut missing_window_policy = valid; + missing_window_policy.date_window_policy.clear(); + for invalid in [invalid_hash, missing_profile, missing_window_policy] { + assert!(matches!( + plan_sync(IncrementalPolicy::default(), &invalid, None, None, 2_000), + SyncPlan::FullSnapshot { + reason: FullSnapshotReason::ScopeIncomplete, + .. + } + )); + } + } + + #[test] + fn periodic_identity_sweep_is_due_at_interval_and_on_clock_regression() { + let scope = scope("7.0", "voucher-v1"); + let capability = observed_capability(scope.clone()); + let checkpoint = checkpoint(scope.clone(), 42); + for (now, expected) in [(999, true), (1_000, false), (10_999, false), (11_000, true)] { + let SyncPlan::Incremental { + identity_sweep_required, + .. + } = plan_sync( + IncrementalPolicy { + overlap_identifiers: 5, + identity_sweep_interval_ms: 10_000, + }, + &scope, + Some(&capability), + Some(&checkpoint), + now, + ) + else { + panic!("valid incremental plan"); + }; + assert_eq!(identity_sweep_required, expected, "now={now}"); + } + } + + #[test] + fn identifier_regression_or_missing_proof_invalidates_checkpoint() { + for observed in [Some(41), None] { + let scope = scope("7.0", "voucher-v1"); + let mut checkpoint = checkpoint(scope.clone(), 42); + let receipt = VerifiedIncrementalReceipt { + scope, + checkpoint_before: 42, + verification: VerificationState::Verified, + proof_sha256: "e".repeat(64), + observed_source_high_watermark: observed, + }; + let transition = apply_incremental_high_watermark(&mut checkpoint, &receipt); + assert!(matches!( + transition, + CheckpointTransition::Invalidated { .. } + )); + assert!(checkpoint.invalidated_reason.is_some()); + } + } + + #[test] + fn overlap_deduplication_is_idempotent_and_order_independent() { + let input = vec![ + CanonicalChange { + stable_identity: "a".to_string(), + change_id: 10, + canonical_sha256: "1".repeat(64), + }, + CanonicalChange { + stable_identity: "b".to_string(), + change_id: 11, + canonical_sha256: "2".repeat(64), + }, + CanonicalChange { + stable_identity: "a".to_string(), + change_id: 12, + canonical_sha256: "3".repeat(64), + }, + CanonicalChange { + stable_identity: "b".to_string(), + change_id: 11, + canonical_sha256: "2".repeat(64), + }, + ]; + let mut reversed = input.clone(); + reversed.reverse(); + + let first = deduplicate_changes(input).expect("unambiguous records"); + let second = deduplicate_changes(reversed).expect("unambiguous records"); + assert_eq!(first, second); + assert_eq!(deduplicate_changes(first.clone()).unwrap(), first); + assert_eq!(first[0].canonical_sha256, "3".repeat(64)); + } + + #[test] + fn same_change_id_with_different_content_fails_closed() { + let result = deduplicate_changes([ + CanonicalChange { + stable_identity: "voucher-1".to_string(), + change_id: 7, + canonical_sha256: "a".repeat(64), + }, + CanonicalChange { + stable_identity: "voucher-1".to_string(), + change_id: 7, + canonical_sha256: "b".repeat(64), + }, + ]); + assert_eq!( + result, + Err(AmbiguousChange { + change_id: 7, + reason: ChangeRejectionReason::ConflictingPayload, + }) + ); + } + + #[test] + fn absence_never_deletes_and_only_proven_tombstones_are_accepted() { + let scope = scope("7.0", "voucher-v1"); + let existing = ["unchanged", "edited", "deleted"] + .into_iter() + .map(str::to_string) + .collect(); + let changes = [CanonicalChange { + stable_identity: "edited".to_string(), + change_id: 44, + canonical_sha256: "a".repeat(64), + }]; + let tombstones = [ExplicitTombstone { + stable_identity: "deleted".to_string(), + rule_id: "explicit-deleted-collection-v1".to_string(), + }]; + + let without_proof = + reconcile_incremental(&scope, &existing, &changes, &tombstones, None).unwrap(); + assert!(without_proof.deleted_identities.is_empty()); + assert_eq!( + without_proof.retained_absent_identities, + ["deleted", "unchanged"] + .into_iter() + .map(str::to_string) + .collect() + ); + + let deletion_capability = DeletionCapabilityObservation { + scope: scope.clone(), + rule_id: "explicit-deleted-collection-v1".to_string(), + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + }; + let proven = reconcile_incremental( + &scope, + &existing, + &changes, + &tombstones, + Some(&deletion_capability), + ) + .unwrap(); + assert_eq!( + proven.deleted_identities, + ["deleted"].into_iter().map(str::to_string).collect() + ); + assert_eq!( + proven.retained_absent_identities, + ["unchanged"].into_iter().map(str::to_string).collect() + ); + } + + #[test] + fn only_verified_full_snapshot_can_establish_incremental_checkpoint() { + let scope = scope("7.0", "voucher-v1"); + let capability = observed_capability(scope.clone()); + for verification in [VerificationState::Partial, VerificationState::Unverified] { + assert_eq!( + establish_checkpoint_from_full_snapshot( + scope.clone(), + &capability, + full_receipt(scope.clone(), verification, Some(42)), + ), + Err(FullSnapshotReason::NoVerifiedCheckpoint) + ); + } + assert_eq!( + establish_checkpoint_from_full_snapshot( + scope.clone(), + &capability, + full_receipt(scope, VerificationState::Verified, Some(42)), + ) + .unwrap() + .high_watermark, + 42 + ); + } + + #[test] + fn receipt_scope_or_checkpoint_drift_invalidates_authority() { + let expected_scope = scope("7.0", "voucher-v1"); + let capability = observed_capability(expected_scope.clone()); + let different_scope = scope("7.1", "voucher-v1"); + assert_eq!( + establish_checkpoint_from_full_snapshot( + expected_scope.clone(), + &capability, + full_receipt( + different_scope.clone(), + VerificationState::Verified, + Some(42) + ), + ), + Err(FullSnapshotReason::ReceiptScopeMismatch) + ); + + let mut checkpoint = checkpoint(expected_scope, 42); + let transition = apply_incremental_high_watermark( + &mut checkpoint, + &VerifiedIncrementalReceipt { + scope: different_scope, + checkpoint_before: 41, + verification: VerificationState::Verified, + proof_sha256: "f".repeat(64), + observed_source_high_watermark: Some(43), + }, + ); + assert_eq!( + transition, + CheckpointTransition::Invalidated { + at: 42, + reason: CheckpointInvalidationReason::ReceiptScopeMismatch, + } + ); + } + + #[test] + fn one_identity_cannot_be_upserted_and_deleted_in_the_same_delta() { + let scope = scope("7.0", "voucher-v1"); + let identity = "same-identity"; + let changes = [CanonicalChange { + stable_identity: identity.to_string(), + change_id: 44, + canonical_sha256: "a".repeat(64), + }]; + let tombstones = [ExplicitTombstone { + stable_identity: identity.to_string(), + rule_id: "deleted_v1".to_string(), + }]; + let deletion_capability = DeletionCapabilityObservation { + scope: scope.clone(), + rule_id: "deleted_v1".to_string(), + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + }; + assert_eq!( + reconcile_incremental( + &scope, + &BTreeSet::new(), + &changes, + &tombstones, + Some(&deletion_capability), + ), + Err(IncrementalReconciliationError::ConflictingAction) + ); + } + + #[test] + fn reconciliation_cannot_bypass_overlap_deduplication() { + let scope = scope("7.0", "voucher-v1"); + let existing = BTreeSet::new(); + let conflicting = [ + CanonicalChange { + stable_identity: "voucher-1".to_string(), + change_id: 7, + canonical_sha256: "a".repeat(64), + }, + CanonicalChange { + stable_identity: "voucher-1".to_string(), + change_id: 7, + canonical_sha256: "b".repeat(64), + }, + ]; + assert_eq!( + reconcile_incremental(&scope, &existing, &conflicting, &[], None), + Err(IncrementalReconciliationError::AmbiguousChange { + change_id: 7, + reason: ChangeRejectionReason::ConflictingPayload, + }) + ); + + let ordered_overlap = [ + CanonicalChange { + stable_identity: "voucher-1".to_string(), + change_id: 9, + canonical_sha256: "c".repeat(64), + }, + CanonicalChange { + stable_identity: "voucher-1".to_string(), + change_id: 8, + canonical_sha256: "b".repeat(64), + }, + ]; + let result = reconcile_incremental(&scope, &existing, &ordered_overlap, &[], None) + .expect("newest unambiguous overlap record wins"); + assert_eq!( + result.upserted_identities, + ["voucher-1"].into_iter().map(str::to_string).collect() + ); + } + + #[test] + fn invalidated_checkpoint_is_terminal_for_later_receipts() { + let scope = scope("7.0", "voucher-v1"); + let mut checkpoint = checkpoint(scope.clone(), 42); + checkpoint.invalidated_reason = + Some(CheckpointInvalidationReason::IdentifierRegressedOrReset); + let prior_proof = checkpoint.last_transition_proof_sha256.clone(); + let transition = apply_incremental_high_watermark( + &mut checkpoint, + &VerifiedIncrementalReceipt { + scope, + checkpoint_before: 42, + verification: VerificationState::Verified, + proof_sha256: "f".repeat(64), + observed_source_high_watermark: Some(43), + }, + ); + assert_eq!( + transition, + CheckpointTransition::Invalidated { + at: 42, + reason: CheckpointInvalidationReason::IdentifierRegressedOrReset, + } + ); + assert_eq!(checkpoint.high_watermark, 42); + assert_eq!(checkpoint.last_transition_proof_sha256, prior_proof); + } +} diff --git a/src-tauri/crates/bridge-tally-live-read/Cargo.toml b/src-tauri/crates/bridge-tally-live-read/Cargo.toml new file mode 100644 index 0000000..93d3cc8 --- /dev/null +++ b/src-tauri/crates/bridge-tally-live-read/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "bridge-tally-live-read" +version = "0.1.0" +description = "Explicit-consent, read-only live Tally compatibility observation controller" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[features] +default = [] +bills-native-outstandings-probe-runner = [ + "bridge-tally-compatibility/bills-native-outstandings-probe-receipt", + "bridge-tally-protocol/bills-native-outstandings-probe", + "bridge-tally-read-transport/bills-native-outstandings-probe-transport", +] + +[[bin]] +name = "bridge-tally-native-outstandings-probe" +path = "src/bin/native_outstandings_probe.rs" +required-features = ["bills-native-outstandings-probe-runner"] + +[dependencies] +bridge-tally-compatibility = { path = "../bridge-tally-compatibility" } +bridge-tally-protocol = { path = "../bridge-tally-protocol" } +bridge-tally-read-transport = { path = "../bridge-tally-read-transport" } +getrandom = "0.3" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +thiserror = "2" +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } + +[dev-dependencies] +tally-protocol-simulator = { path = "../tally-protocol-simulator" } diff --git a/src-tauri/crates/bridge-tally-live-read/README.md b/src-tauri/crates/bridge-tally-live-read/README.md new file mode 100644 index 0000000..904cb5f --- /dev/null +++ b/src-tauri/crates/bridge-tally-live-read/README.md @@ -0,0 +1,46 @@ +# Bridge Tally live-read controller + +This standalone controller is the only PR14 component that performs live +network reads. Invoking it without the exact `run` command performs no network +operation. A run requires a local ignored config, a reviewed tracked synthetic +fixture manifest, the literal `--consent read-only-synthetic` option, a +single-use run-bound interactive challenge before network access, and a second +receipt-and-output-bound confirmation after preview and before save. The run +challenge uses fresh operating-system randomness, expires after five minutes, +and commits to the full config, endpoint, fixture, source/build surface, and +executable evidence. Exact About/profile values and an affirmative no-customer- +data attestation are mandatory; unknown or false values fail before consent or +network access. + +The dependency graph contains only the compatibility DTO/gate, portable +protocol, a typed read-only adapter over the production loopback transport, +serialization, hashing, and async runtime. It has no direct generic transport, +Tauri, database, sync, import, or write dependency. The controller can dispatch +only the closed `ReadOnlyProfile` variants shared with production; it accepts +no XML, report name, TDL, payload, or company identifier on the command line. + +Run from `src-tauri` after creating `.bridge-live/profile.json` from the +tracked example: + +```sh +cargo run --locked -p bridge-tally-live-read -- run \ + ../.bridge-live/profile.json ../.bridge-live/receipt.json \ + --consent read-only-synthetic +``` + +Raw responses remain bounded and memory-only. The previewed/saved receipt +excludes endpoint details and source identifiers and permanently disclaims +responder authenticity, accounting correctness, source completeness/atomicity, +performance support, writes, and automatic support authority. + +Both config and output must be direct children of the repository's canonical +ignored `.bridge-live` directory. The controller issues a non-cloneable output +target, revalidates it at save time, accepts JSON only, and never overwrites an +existing receipt. + +The non-default native outstandings qualification feature remains a separate +observation-only binary. Candidate application/HTTP/transport failures are +recorded as bounded attempt facts and receive trailing identity brackets with +zero retry while identity remains unchanged. Its receipt save likewise consumes +a repository-issued, exact-output-bound JSON target; it adds no parser, +accounting, runtime, mirror, write, or support authority. diff --git a/src-tauri/crates/bridge-tally-live-read/src/bin/native_outstandings_probe.rs b/src-tauri/crates/bridge-tally-live-read/src/bin/native_outstandings_probe.rs new file mode 100644 index 0000000..6f84fd1 --- /dev/null +++ b/src-tauri/crates/bridge-tally-live-read/src/bin/native_outstandings_probe.rs @@ -0,0 +1,125 @@ +use std::{ + io::{self, BufRead, Write}, + path::PathBuf, + process::ExitCode, +}; + +use bridge_tally_live_read::native_outstandings_qualification::{ + confirm_dispatch_challenge, confirm_preflight_challenge, confirm_ui_after_challenge, + native_probe_save_phrase, save_native_probe_receipt_no_replace, LoadedNativeOutstandingsProbe, +}; + +#[tokio::main] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(code) => { + eprintln!("bridge_tally_native_outstandings_probe_failed:{code}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), &'static str> { + let mut arguments = std::env::args().skip(1); + if arguments.next().as_deref() != Some("run") { + return Err("usage_run_required"); + } + let config_path = arguments + .next() + .map(PathBuf::from) + .ok_or("config_path_missing")?; + let output_path = arguments + .next() + .map(PathBuf::from) + .ok_or("output_path_missing")?; + if arguments.next().as_deref() != Some("--consent") + || arguments.next().as_deref() != Some("read-only-synthetic-native-outstandings") + || arguments.next().is_some() + { + return Err("explicit_native_probe_consent_option_required"); + } + if output_path.exists() { + return Err("receipt_output_exists"); + } + + let loaded = + LoadedNativeOutstandingsProbe::load(&config_path).map_err(|error| error.safe_code())?; + let output = loaded + .validate_receipt_output(&output_path) + .map_err(|error| error.safe_code())?; + + println!( + "CandidateV0 synthetic qualification observation. Accounting semantics and support remain unknown." + ); + println!( + "Stage 1 permits exactly two sealed identity preflight reads and no Candidate request." + ); + println!( + "The reviewed profile attests that no customer or personal data is loaded. Stop if that is inaccurate." + ); + println!("Type this exact run-bound preflight challenge:"); + println!("{}", loaded.preflight_challenge()); + let typed = read_line()?; + let consent = + confirm_preflight_challenge(&loaded, &typed).map_err(|error| error.safe_code())?; + let ready = loaded + .run_preflight(consent) + .await + .map_err(|error| error.safe_code())?; + + println!( + "Synthetic identities matched. Stage 2 permits exactly eleven reads: four company/party brackets and three CandidateV0 observations." + ); + println!("Type this distinct dispatch challenge:"); + println!("{}", ready.dispatch_challenge()); + let typed = read_line()?; + let consent = confirm_dispatch_challenge(&ready, &typed).map_err(|error| error.safe_code())?; + let pending = ready + .dispatch(consent) + .await + .map_err(|error| error.safe_code())?; + + println!( + "Capture the reviewed UI-after observation now. Raw Tally responses will not be printed or retained." + ); + println!("Type this exact UI-after binding challenge after the capture exists:"); + println!("{}", pending.ui_after_challenge()); + let typed = read_line()?; + let consent = + confirm_ui_after_challenge(&pending, &typed).map_err(|error| error.safe_code())?; + let receipt = pending + .finalize(consent) + .map_err(|error| error.safe_code())?; + let receipt_bytes = receipt + .to_pretty_json() + .map_err(|_| "native_probe_receipt_serialization_failed")?; + + println!("Privacy-reduced observation receipt preview follows:"); + io::stdout() + .write_all(&receipt_bytes) + .and_then(|_| io::stdout().write_all(b"\n")) + .and_then(|_| io::stdout().flush()) + .map_err(|_| "native_probe_receipt_preview_failed")?; + let save_phrase = + native_probe_save_phrase(&receipt, &output).map_err(|error| error.safe_code())?; + println!("Type this exact receipt-and-output-bound challenge to save without overwrite:"); + println!("{save_phrase}"); + let typed = read_line()?; + save_native_probe_receipt_no_replace(output, &receipt_bytes, &typed) + .map_err(|error| error.safe_code())?; + println!("bridge_tally_native_outstandings_probe_receipt_saved"); + Ok(()) +} + +fn read_line() -> Result { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .map_err(|_| "interactive_input_failed")?; + if line.len() > 256 { + return Err("interactive_input_too_long"); + } + Ok(line) +} diff --git a/src-tauri/crates/bridge-tally-live-read/src/lib.rs b/src-tauri/crates/bridge-tally-live-read/src/lib.rs new file mode 100644 index 0000000..f16bfcf --- /dev/null +++ b/src-tauri/crates/bridge-tally-live-read/src/lib.rs @@ -0,0 +1,1565 @@ +use std::{ + fs, + fs::OpenOptions, + io::Write, + path::{Path, PathBuf}, + process::Command, +}; + +use bridge_tally_compatibility::{ + now_unix_ms, sha256_file, ApplicationStatus, Architecture, CompatibilitySurfaceManifest, + CountBucket, DatasetTier, EvidenceAuthority, EvidenceConfidence, LiveCompatibilityReceipt, + LiveReadAuthority, LocaleProfile, LoopbackFamily, OdbcState, OperationEvidence, + OperationOutcome, Platform, ProductFamily, ProfileValue, ReadProfileId, SizeBucket, TallyMode, + TextEncoding, TransportProfile, LIVE_RECEIPT_SCHEMA_VERSION, MAX_ARTIFACT_BYTES, +}; +use bridge_tally_protocol::{ + export_failure_reason_code, export_status, parse_companies_with_evidence, + parse_ledger_source_records_with_evidence, parse_voucher_source_records_with_evidence, + verify_company_context, + xml_read_profiles::{ReadOnlyProfile, ValidatedCompanyName, ValidatedDateRange}, + ExportEvidence, TallyExportStatus, TallyTextEncoding, +}; +use bridge_tally_read_transport::{ + ReadLoopback, ReadOnlyResponse, ReadOnlyTransport, ReadOnlyTransportError, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +#[cfg(feature = "bills-native-outstandings-probe-runner")] +pub mod native_outstandings_qualification; + +const CONFIG_SCHEMA_VERSION: u16 = 1; +const FIXTURE_SCHEMA_VERSION: u16 = 1; +const MAX_LOCAL_INPUT_BYTES: usize = 64 * 1024; +const NETWORK_CONSENT_TTL_MS: i64 = 5 * 60 * 1000; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[error("live-read qualification failed ({code})")] +pub struct LiveReadError { + code: &'static str, +} + +impl LiveReadError { + pub fn safe_code(&self) -> &'static str { + self.code + } +} + +fn error(code: &'static str) -> LiveReadError { + LiveReadError { code } +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct LiveRunConfig { + schema_version: u16, + repository_root: PathBuf, + fixture_manifest: PathBuf, + endpoint_family: LoopbackFamily, + port: u16, + product: ProductFamily, + release: String, + mode: TallyMode, + odbc_state: OdbcState, + locale: LocaleProfile, + no_customer_data_attested: bool, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct DateWindow { + from_yyyymmdd: String, + to_yyyymmdd: String, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct SyntheticFixtureManifest { + schema_version: u16, + fixture_id: String, + dataset_tier: DatasetTier, + company_marker: String, + ledger_sentinel: String, + voucher_number_sentinel: String, + empty_voucher_range: DateWindow, + populated_voucher_range: DateWindow, + minimum_ledger_count: u64, + maximum_ledger_count: u64, + minimum_populated_voucher_count: u64, + maximum_populated_voucher_count: u64, +} + +struct RunMetadata { + observed_at_unix_ms: i64, + bridge_commit_sha: String, + working_tree_dirty: bool, + compatibility_surface_sha256: String, + executable_sha256: String, + cargo_lock_sha256: String, + fixture_manifest_sha256: String, +} + +pub struct LiveRunInputs { + config: LiveRunConfig, + fixture: SyntheticFixtureManifest, + metadata: RunMetadata, + repository_root: PathBuf, + challenge_phrase: String, + consent_binding: String, + consent_expires_at_unix_ms: i64, +} + +pub struct NetworkConsent { + binding: String, + expires_at_unix_ms: i64, +} + +pub struct LiveReceiptOutput { + output_path: PathBuf, + canonical_parent: PathBuf, +} + +impl LiveRunInputs { + pub fn load(config_path: &Path) -> Result { + let config_bytes = read_bounded(config_path, MAX_LOCAL_INPUT_BYTES, "config_unavailable")?; + let config: LiveRunConfig = + serde_json::from_slice(&config_bytes).map_err(|_| error("config_invalid"))?; + validate_config(&config)?; + + let base = config_path.parent().unwrap_or_else(|| Path::new(".")); + let repository_root = + canonical_join(base, &config.repository_root, "repository_unavailable")?; + let reviewed_local_root = repository_root + .join(".bridge-live") + .canonicalize() + .map_err(|_| error("local_evidence_root_unavailable"))?; + let canonical_config = config_path + .canonicalize() + .map_err(|_| error("config_unavailable"))?; + if !canonical_config.starts_with(&reviewed_local_root) { + return Err(error("config_path_outside_local_evidence_root")); + } + let fixture_path = canonical_join(base, &config.fixture_manifest, "fixture_unavailable")?; + let allowed_fixture_root = repository_root + .join("docs/tally/compatibility/fixtures") + .canonicalize() + .map_err(|_| error("fixture_root_unavailable"))?; + if !fixture_path.starts_with(&allowed_fixture_root) { + return Err(error("fixture_path_outside_reviewed_root")); + } + let fixture_bytes = + read_bounded(&fixture_path, MAX_LOCAL_INPUT_BYTES, "fixture_unavailable")?; + let fixture: SyntheticFixtureManifest = + serde_json::from_slice(&fixture_bytes).map_err(|_| error("fixture_invalid"))?; + validate_fixture(&fixture)?; + + let surface_path = + repository_root.join("docs/tally/compatibility/compatibility-surface.json"); + let surface = CompatibilitySurfaceManifest::from_json(&read_bounded( + &surface_path, + MAX_ARTIFACT_BYTES, + "surface_unavailable", + )?) + .map_err(|_| error("surface_invalid"))?; + surface + .validate_files(&repository_root) + .map_err(|_| error("surface_changed"))?; + + let observed_at_unix_ms = now_unix_ms().map_err(|_| error("system_clock_invalid"))?; + let bridge_commit_sha = git_output(&repository_root, &["rev-parse", "HEAD"])?; + if !valid_commit(&bridge_commit_sha) { + return Err(error("bridge_commit_invalid")); + } + let working_tree_dirty = !git_output( + &repository_root, + &["status", "--porcelain", "--untracked-files=all"], + )? + .is_empty(); + let executable = std::env::current_exe().map_err(|_| error("executable_unavailable"))?; + let metadata = RunMetadata { + observed_at_unix_ms, + bridge_commit_sha, + working_tree_dirty, + compatibility_surface_sha256: surface.manifest_sha256, + executable_sha256: sha256_file(&executable) + .map_err(|_| error("executable_unavailable"))?, + cargo_lock_sha256: sha256_file(&repository_root.join("src-tauri/Cargo.lock")) + .map_err(|_| error("cargo_lock_unavailable"))?, + fixture_manifest_sha256: sha256_hex(&fixture_bytes), + }; + let consent_expires_at_unix_ms = observed_at_unix_ms + NETWORK_CONSENT_TTL_MS; + let consent_binding = network_consent_binding( + &config, + &fixture.fixture_id, + &metadata, + consent_expires_at_unix_ms, + )?; + let challenge_phrase = format!("QUALIFY {} {}", fixture.fixture_id, &consent_binding[..16]); + Ok(Self { + config, + fixture, + metadata, + repository_root, + challenge_phrase, + consent_binding, + consent_expires_at_unix_ms, + }) + } + + pub fn challenge_phrase(&self) -> &str { + &self.challenge_phrase + } + + pub fn repository_root(&self) -> &Path { + &self.repository_root + } + + pub fn no_customer_data_attested(&self) -> bool { + self.config.no_customer_data_attested + } + + pub fn validate_receipt_output( + &self, + output_path: &Path, + ) -> Result { + if output_path.exists() { + return Err(error("receipt_output_exists")); + } + let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); + let canonical_parent = parent + .canonicalize() + .map_err(|_| error("receipt_output_parent_unavailable"))?; + let reviewed_local_root = self + .repository_root + .join(".bridge-live") + .canonicalize() + .map_err(|_| error("local_evidence_root_unavailable"))?; + if canonical_parent != reviewed_local_root + || output_path.extension().and_then(|value| value.to_str()) != Some("json") + { + return Err(error("receipt_output_outside_local_evidence_root")); + } + output_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| error("receipt_output_invalid"))?; + Ok(LiveReceiptOutput { + output_path: output_path.to_path_buf(), + canonical_parent, + }) + } + + pub async fn execute( + self, + consent: NetworkConsent, + ) -> Result { + verify_network_consent(&self, &consent)?; + validate_current_surface( + &self.repository_root, + &self.metadata.compatibility_surface_sha256, + )?; + let transport = + ReadOnlyTransport::new(read_loopback(self.config.endpoint_family), self.config.port) + .map_err(|_| error("endpoint_configuration_invalid"))?; + execute_with_transport(&self.config, &self.fixture, &self.metadata, &transport).await + } +} + +pub fn confirm_network_challenge( + inputs: &LiveRunInputs, + typed: &str, +) -> Result { + if typed.trim_end_matches(['\r', '\n']) != inputs.challenge_phrase { + return Err(error("network_consent_mismatch")); + } + ensure_not_expired(inputs.consent_expires_at_unix_ms)?; + Ok(NetworkConsent { + binding: inputs.consent_binding.clone(), + expires_at_unix_ms: inputs.consent_expires_at_unix_ms, + }) +} + +pub fn receipt_save_phrase( + receipt: &LiveCompatibilityReceipt, + output: &LiveReceiptOutput, +) -> Result { + output.revalidate()?; + let output_binding = live_receipt_output_binding(&output.output_path)?; + Ok(format!( + "SAVE {} {}", + &receipt.receipt_sha256[..12], + &output_binding[..12] + )) +} + +pub fn save_live_receipt_no_replace( + output: LiveReceiptOutput, + receipt_bytes: &[u8], + typed: &str, +) -> Result<(), LiveReadError> { + output.revalidate()?; + let receipt = + LiveCompatibilityReceipt::from_json(receipt_bytes).map_err(|_| error("receipt_invalid"))?; + let expected_phrase = receipt_save_phrase(&receipt, &output)?; + save_receipt_no_replace(&output.output_path, receipt_bytes, typed, &expected_phrase) +} + +impl LiveReceiptOutput { + fn revalidate(&self) -> Result<(), LiveReadError> { + if self.output_path.exists() { + return Err(error("receipt_output_exists")); + } + let parent = self.output_path.parent().unwrap_or_else(|| Path::new(".")); + let current_parent = parent + .canonicalize() + .map_err(|_| error("receipt_output_parent_unavailable"))?; + if current_parent != self.canonical_parent + || self + .output_path + .extension() + .and_then(|value| value.to_str()) + != Some("json") + { + return Err(error("receipt_output_changed_after_validation")); + } + Ok(()) + } +} + +pub(crate) fn save_receipt_no_replace( + output_path: &Path, + receipt_bytes: &[u8], + typed: &str, + expected_phrase: &str, +) -> Result<(), LiveReadError> { + if typed.trim_end_matches(['\r', '\n']) != expected_phrase { + return Err(error("save_consent_mismatch")); + } + if receipt_bytes.is_empty() || receipt_bytes.len() > MAX_ARTIFACT_BYTES { + return Err(error("receipt_size_invalid")); + } + if output_path.exists() { + return Err(error("receipt_output_exists")); + } + let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); + if !parent.is_dir() { + return Err(error("receipt_output_parent_unavailable")); + } + let file_name = output_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| error("receipt_output_invalid"))?; + let temporary = parent.join(format!( + ".{file_name}.{}.{}.tmp", + std::process::id(), + now_unix_ms().map_err(|_| error("system_clock_invalid"))? + )); + let result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|_| error("receipt_temporary_create_failed"))?; + file.write_all(receipt_bytes) + .and_then(|_| file.sync_all()) + .map_err(|_| error("receipt_temporary_write_failed"))?; + fs::hard_link(&temporary, output_path).map_err(|_| error("receipt_save_failed"))?; + Ok(()) + })(); + let _ = fs::remove_file(&temporary); + result +} + +fn live_receipt_output_binding(output_path: &Path) -> Result { + if output_path.extension().and_then(|value| value.to_str()) != Some("json") { + return Err(error("receipt_output_invalid")); + } + let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); + let canonical_parent = parent + .canonicalize() + .map_err(|_| error("receipt_output_parent_unavailable"))?; + if canonical_parent + .file_name() + .and_then(|value| value.to_str()) + != Some(".bridge-live") + { + return Err(error("receipt_output_outside_local_evidence_root")); + } + let file_name = output_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| error("receipt_output_invalid"))?; + let mut digest = Sha256::new(); + digest.update(b"bridge.tally.live-read-receipt-output/1\0"); + let parent_text = canonical_parent.to_string_lossy(); + for field in [parent_text.as_bytes(), file_name.as_bytes()] { + digest.update((field.len() as u64).to_be_bytes()); + digest.update(field); + } + Ok(sha256_digest_hex(digest.finalize())) +} + +async fn execute_with_transport( + config: &LiveRunConfig, + fixture: &SyntheticFixtureManifest, + metadata: &RunMetadata, + transport: &ReadOnlyTransport, +) -> Result { + let company_name = ValidatedCompanyName::new(fixture.company_marker.clone()) + .map_err(|_| error("fixture_company_marker_invalid"))?; + let empty_range = ValidatedDateRange::new( + fixture.empty_voucher_range.from_yyyymmdd.clone(), + fixture.empty_voucher_range.to_yyyymmdd.clone(), + ) + .map_err(|_| error("fixture_empty_range_invalid"))?; + let populated_range = ValidatedDateRange::new( + fixture.populated_voucher_range.from_yyyymmdd.clone(), + fixture.populated_voucher_range.to_yyyymmdd.clone(), + ) + .map_err(|_| error("fixture_populated_range_invalid"))?; + + let mut operations = Vec::with_capacity(5); + let mut endpoint_response_observed = false; + let mut loaded_company_count = CountBucket::Unknown; + let company_profile = ReadOnlyProfile::CompanyListV1; + let company_response = match transport.send(company_profile).await { + Ok(response) => { + endpoint_response_observed = true; + response + } + Err(transport_error) => { + operations.push(transport_failure( + ReadProfileId::XmlCompanyEnumerationV1, + company_profile.template_sha256(), + &transport_error, + )); + operations.push(not_attempted( + ReadProfileId::XmlSyntheticFixtureMarkerV1, + company_profile.template_sha256(), + "company_enumeration_not_passed", + )); + append_company_reads_not_attempted(&mut operations, &company_name, &empty_range); + return seal_receipt( + config, + fixture, + metadata, + operations, + false, + endpoint_response_observed, + loaded_company_count, + ); + } + }; + + let companies = match parse_companies_with_evidence(company_response.text()) { + Ok(parsed) => { + loaded_company_count = count_bucket(parsed.records.len()); + operations.push(passed_response( + ReadProfileId::XmlCompanyEnumerationV1, + company_profile.template_sha256(), + &company_response, + parsed.records.len(), + )); + parsed.records + } + Err(_) => { + operations.push(response_failure( + ReadProfileId::XmlCompanyEnumerationV1, + company_profile.template_sha256(), + &company_response, + application_status(company_response.text()), + "company_response_invalid", + )); + operations.push(not_attempted( + ReadProfileId::XmlSyntheticFixtureMarkerV1, + company_profile.template_sha256(), + "company_enumeration_not_passed", + )); + append_company_reads_not_attempted(&mut operations, &company_name, &empty_range); + return seal_receipt( + config, + fixture, + metadata, + operations, + false, + endpoint_response_observed, + loaded_company_count, + ); + } + }; + + let matching: Vec<_> = companies + .iter() + .filter(|company| company.name == fixture.company_marker) + .collect(); + let selected_guid = if matching.len() == 1 { + matching[0] + .guid + .as_deref() + .filter(|guid| !guid.trim().is_empty()) + .filter(|guid| { + companies + .iter() + .filter(|company| company.guid.as_deref() == Some(*guid)) + .count() + == 1 + }) + } else { + None + }; + let Some(selected_guid) = selected_guid else { + operations.push(derived_failure( + ReadProfileId::XmlSyntheticFixtureMarkerV1, + company_profile.template_sha256(), + &company_response, + "synthetic_fixture_unverified", + )); + append_company_reads_not_attempted(&mut operations, &company_name, &empty_range); + return seal_receipt( + config, + fixture, + metadata, + operations, + false, + endpoint_response_observed, + loaded_company_count, + ); + }; + operations.push(passed_response( + ReadProfileId::XmlSyntheticFixtureMarkerV1, + company_profile.template_sha256(), + &company_response, + 1, + )); + + let ledger_profile = ReadOnlyProfile::LedgersV1 { + company: &company_name, + }; + let ledger_response = match transport.send(ledger_profile).await { + Ok(response) => response, + Err(transport_error) => { + operations.push(transport_failure( + ReadProfileId::XmlLedgerReadV1, + ledger_profile.template_sha256(), + &transport_error, + )); + append_vouchers_not_attempted(&mut operations, &company_name, &empty_range); + return seal_receipt( + config, + fixture, + metadata, + operations, + true, + endpoint_response_observed, + loaded_company_count, + ); + } + }; + let ledger_count = match parse_ledger_source_records_with_evidence(ledger_response.text()) { + Ok(parsed) + if context_matches(&parsed.evidence, &fixture.company_marker, selected_guid) + && parsed.records.len() as u64 >= fixture.minimum_ledger_count + && parsed.records.len() as u64 <= fixture.maximum_ledger_count + && parsed + .records + .iter() + .filter(|record| record.record.name == fixture.ledger_sentinel) + .count() + == 1 => + { + parsed.records.len() + } + _ => { + operations.push(response_failure( + ReadProfileId::XmlLedgerReadV1, + ledger_profile.template_sha256(), + &ledger_response, + application_status(ledger_response.text()), + "ledger_fixture_or_context_invalid", + )); + append_vouchers_not_attempted(&mut operations, &company_name, &empty_range); + return seal_receipt( + config, + fixture, + metadata, + operations, + true, + endpoint_response_observed, + loaded_company_count, + ); + } + }; + operations.push(passed_response( + ReadProfileId::XmlLedgerReadV1, + ledger_profile.template_sha256(), + &ledger_response, + ledger_count, + )); + + let empty_profile = ReadOnlyProfile::VouchersV2 { + company: &company_name, + range: &empty_range, + }; + let empty_response = match transport.send(empty_profile).await { + Ok(response) => response, + Err(transport_error) => { + operations.push(transport_failure( + ReadProfileId::XmlVoucherEmptyRangeV1, + empty_profile.template_sha256(), + &transport_error, + )); + operations.push(not_attempted( + ReadProfileId::XmlVoucherPopulatedRangeV1, + empty_profile.template_sha256(), + "empty_voucher_profile_not_passed", + )); + return seal_receipt( + config, + fixture, + metadata, + operations, + true, + endpoint_response_observed, + loaded_company_count, + ); + } + }; + match parse_voucher_source_records_with_evidence(empty_response.text()) { + Ok(parsed) + if parsed.records.is_empty() + && context_matches(&parsed.evidence, &fixture.company_marker, selected_guid) => + { + operations.push(passed_response( + ReadProfileId::XmlVoucherEmptyRangeV1, + empty_profile.template_sha256(), + &empty_response, + 0, + )); + } + _ => { + operations.push(response_failure( + ReadProfileId::XmlVoucherEmptyRangeV1, + empty_profile.template_sha256(), + &empty_response, + application_status(empty_response.text()), + "empty_voucher_fixture_or_context_invalid", + )); + operations.push(not_attempted( + ReadProfileId::XmlVoucherPopulatedRangeV1, + empty_profile.template_sha256(), + "empty_voucher_profile_not_passed", + )); + return seal_receipt( + config, + fixture, + metadata, + operations, + true, + endpoint_response_observed, + loaded_company_count, + ); + } + } + + let populated_profile = ReadOnlyProfile::VouchersV2 { + company: &company_name, + range: &populated_range, + }; + let populated_response = match transport.send(populated_profile).await { + Ok(response) => response, + Err(transport_error) => { + operations.push(transport_failure( + ReadProfileId::XmlVoucherPopulatedRangeV1, + populated_profile.template_sha256(), + &transport_error, + )); + return seal_receipt( + config, + fixture, + metadata, + operations, + true, + endpoint_response_observed, + loaded_company_count, + ); + } + }; + match parse_voucher_source_records_with_evidence(populated_response.text()) { + Ok(parsed) + if context_matches(&parsed.evidence, &fixture.company_marker, selected_guid) + && parsed.records.len() as u64 >= fixture.minimum_populated_voucher_count + && parsed.records.len() as u64 <= fixture.maximum_populated_voucher_count + && parsed + .records + .iter() + .filter(|record| { + record.record.voucher_number.as_deref() + == Some(fixture.voucher_number_sentinel.as_str()) + }) + .count() + == 1 + && parsed.records.iter().all(|record| { + record.record.date.as_deref().is_some_and(|date| { + date >= populated_range.from_yyyymmdd() + && date <= populated_range.to_yyyymmdd() + }) + }) => + { + operations.push(passed_response( + ReadProfileId::XmlVoucherPopulatedRangeV1, + populated_profile.template_sha256(), + &populated_response, + parsed.records.len(), + )); + } + _ => operations.push(response_failure( + ReadProfileId::XmlVoucherPopulatedRangeV1, + populated_profile.template_sha256(), + &populated_response, + application_status(populated_response.text()), + "populated_voucher_fixture_range_or_context_invalid", + )), + } + + seal_receipt( + config, + fixture, + metadata, + operations, + true, + endpoint_response_observed, + loaded_company_count, + ) +} + +fn seal_receipt( + config: &LiveRunConfig, + fixture: &SyntheticFixtureManifest, + metadata: &RunMetadata, + operations: Vec, + fixture_marker_verified: bool, + endpoint_response_observed: bool, + loaded_company_count: CountBucket, +) -> Result { + LiveCompatibilityReceipt { + schema_version: LIVE_RECEIPT_SCHEMA_VERSION, + observed_at_unix_ms: metadata.observed_at_unix_ms, + bridge_commit_sha: metadata.bridge_commit_sha.clone(), + working_tree_dirty: metadata.working_tree_dirty, + compatibility_surface_sha256: metadata.compatibility_surface_sha256.clone(), + executable_sha256: metadata.executable_sha256.clone(), + cargo_lock_sha256: metadata.cargo_lock_sha256.clone(), + platform: current_platform(), + architecture: current_architecture(), + endpoint_family: config.endpoint_family, + transport: TransportProfile::XmlHttp, + product: product_profile(config.product), + release: release_profile(&config.release), + mode: mode_profile(config.mode), + odbc_state: odbc_profile(config.odbc_state), + locale: locale_profile(config.locale), + dataset_tier: configured(fixture.dataset_tier), + fixture_manifest_sha256: metadata.fixture_manifest_sha256.clone(), + fixture_marker_verified, + no_customer_data: attested(config.no_customer_data_attested), + loaded_company_count, + operations, + authority: if endpoint_response_observed { + LiveReadAuthority::observation_only() + } else { + LiveReadAuthority::attempt_only() + }, + receipt_sha256: String::new(), + } + .seal() + .map_err(|_| error("receipt_invalid")) +} + +fn attested(value: T) -> ProfileValue { + ProfileValue { + value, + authority: EvidenceAuthority::UserAttestation, + confidence: EvidenceConfidence::Attested, + } +} + +fn configured(value: T) -> ProfileValue { + ProfileValue { + value, + authority: EvidenceAuthority::BridgeConfiguration, + confidence: EvidenceConfidence::Attested, + } +} + +fn unknown(value: T) -> ProfileValue { + ProfileValue { + value, + authority: EvidenceAuthority::Unknown, + confidence: EvidenceConfidence::Unknown, + } +} + +fn product_profile(value: ProductFamily) -> ProfileValue { + if value == ProductFamily::Unknown { + unknown(value) + } else { + attested(value) + } +} + +fn release_profile(value: &str) -> ProfileValue { + if value == "unknown" { + unknown(value.to_string()) + } else { + attested(value.to_string()) + } +} + +fn mode_profile(value: TallyMode) -> ProfileValue { + if value == TallyMode::Unknown { + unknown(value) + } else { + attested(value) + } +} + +fn odbc_profile(value: OdbcState) -> ProfileValue { + if value == OdbcState::Unknown { + unknown(value) + } else { + attested(value) + } +} + +fn locale_profile(value: LocaleProfile) -> ProfileValue { + if value == LocaleProfile::Unknown { + unknown(value) + } else { + attested(value) + } +} + +fn append_company_reads_not_attempted( + operations: &mut Vec, + company: &ValidatedCompanyName, + range: &ValidatedDateRange, +) { + let ledger = ReadOnlyProfile::LedgersV1 { company }; + operations.push(not_attempted( + ReadProfileId::XmlLedgerReadV1, + ledger.template_sha256(), + "synthetic_fixture_unverified", + )); + append_vouchers_not_attempted(operations, company, range); +} + +fn append_vouchers_not_attempted( + operations: &mut Vec, + company: &ValidatedCompanyName, + range: &ValidatedDateRange, +) { + let vouchers = ReadOnlyProfile::VouchersV2 { company, range }; + operations.push(not_attempted( + ReadProfileId::XmlVoucherEmptyRangeV1, + vouchers.template_sha256(), + "prior_read_profile_not_passed", + )); + operations.push(not_attempted( + ReadProfileId::XmlVoucherPopulatedRangeV1, + vouchers.template_sha256(), + "prior_read_profile_not_passed", + )); +} + +fn passed_response( + profile: ReadProfileId, + template_sha256: String, + response: &ReadOnlyResponse, + records: usize, +) -> OperationEvidence { + OperationEvidence { + profile, + template_sha256, + outcome: OperationOutcome::Passed, + application_status: ApplicationStatus::Success, + encoding: encoding(response.encoding()), + response_size: size_bucket(response.encoded_bytes()), + record_count: count_bucket(records), + safe_reason_code: None, + } +} + +fn derived_failure( + profile: ReadProfileId, + template_sha256: String, + response: &ReadOnlyResponse, + reason: &'static str, +) -> OperationEvidence { + response_failure( + profile, + template_sha256, + response, + ApplicationStatus::Success, + reason, + ) +} + +fn response_failure( + profile: ReadProfileId, + template_sha256: String, + response: &ReadOnlyResponse, + application_status: ApplicationStatus, + reason: &'static str, +) -> OperationEvidence { + OperationEvidence { + profile, + template_sha256, + outcome: OperationOutcome::Failed, + application_status, + encoding: encoding(response.encoding()), + response_size: size_bucket(response.encoded_bytes()), + record_count: CountBucket::Unknown, + safe_reason_code: Some(if application_status == ApplicationStatus::Failure { + export_failure_reason_code(response.text()).to_string() + } else { + reason.to_string() + }), + } +} + +fn transport_failure( + profile: ReadProfileId, + template_sha256: String, + transport_error: &ReadOnlyTransportError, +) -> OperationEvidence { + OperationEvidence { + profile, + template_sha256, + outcome: OperationOutcome::Failed, + application_status: ApplicationStatus::NotApplicable, + encoding: TextEncoding::Unknown, + response_size: SizeBucket::Zero, + record_count: CountBucket::Unknown, + safe_reason_code: Some(transport_error.safe_code().to_string()), + } +} + +fn not_attempted( + profile: ReadProfileId, + template_sha256: String, + reason: &'static str, +) -> OperationEvidence { + OperationEvidence { + profile, + template_sha256, + outcome: OperationOutcome::NotAttempted, + application_status: ApplicationStatus::NotApplicable, + encoding: TextEncoding::Unknown, + response_size: SizeBucket::Zero, + record_count: CountBucket::Unknown, + safe_reason_code: Some(reason.to_string()), + } +} + +fn application_status(xml: &str) -> ApplicationStatus { + match export_status(xml) { + Ok(TallyExportStatus::Success) => ApplicationStatus::Success, + Ok(TallyExportStatus::Failure) => { + let _ = export_failure_reason_code(xml); + ApplicationStatus::Failure + } + Err(_) => ApplicationStatus::Unrecognized, + } +} + +fn context_matches(evidence: &ExportEvidence, expected_name: &str, expected_guid: &str) -> bool { + verify_company_context(evidence, expected_guid).is_ok() + && evidence + .company_context + .as_ref() + .and_then(|context| context.name.as_deref()) + == Some(expected_name) +} + +fn encoding(value: TallyTextEncoding) -> TextEncoding { + match value { + TallyTextEncoding::Utf8 => TextEncoding::Utf8, + TallyTextEncoding::Utf8Bom => TextEncoding::Utf8Bom, + TallyTextEncoding::Utf16LeBom => TextEncoding::Utf16Le, + TallyTextEncoding::Utf16BeBom => TextEncoding::Utf16Be, + } +} + +fn size_bucket(bytes: usize) -> SizeBucket { + match bytes { + 0 => SizeBucket::Zero, + 1..=4096 => SizeBucket::Bytes1To4096, + 4097..=65536 => SizeBucket::Bytes4097To65536, + 65537..=1_048_576 => SizeBucket::Bytes65537To1048576, + _ => SizeBucket::Over1048576, + } +} + +fn count_bucket(records: usize) -> CountBucket { + match records { + 0 => CountBucket::Zero, + 1 => CountBucket::One, + 2..=5 => CountBucket::TwoToFive, + 6..=20 => CountBucket::SixToTwenty, + _ => CountBucket::OverTwenty, + } +} + +fn validate_config(config: &LiveRunConfig) -> Result<(), LiveReadError> { + if config.schema_version != CONFIG_SCHEMA_VERSION + || config.port == 0 + || config.product == ProductFamily::Unknown + || !valid_release(&config.release) + || config.mode == TallyMode::Unknown + || config.odbc_state == OdbcState::Unknown + || config.locale == LocaleProfile::Unknown + || !config.no_customer_data_attested + || config.repository_root.as_os_str().is_empty() + || config.fixture_manifest.as_os_str().is_empty() + { + return Err(error("config_invalid")); + } + Ok(()) +} + +fn validate_fixture(fixture: &SyntheticFixtureManifest) -> Result<(), LiveReadError> { + if fixture.schema_version != FIXTURE_SCHEMA_VERSION + || !valid_slug(&fixture.fixture_id) + || fixture.dataset_tier == DatasetTier::Unknown + || !fixture.company_marker.starts_with("BRIDGE-PR14-SYNTHETIC-") + || fixture.company_marker.len() < 48 + || fixture.company_marker.len() > 128 + || fixture.company_marker.chars().any(char::is_control) + || !valid_fixture_sentinel(&fixture.ledger_sentinel, "BRIDGE-LEDGER-") + || !valid_fixture_sentinel(&fixture.voucher_number_sentinel, "BRIDGE-VOUCHER-") + || fixture.minimum_ledger_count == 0 + || fixture.minimum_ledger_count > fixture.maximum_ledger_count + || fixture.minimum_populated_voucher_count == 0 + || fixture.minimum_populated_voucher_count > fixture.maximum_populated_voucher_count + { + return Err(error("fixture_invalid")); + } + ValidatedCompanyName::new(fixture.company_marker.clone()) + .map_err(|_| error("fixture_invalid"))?; + let empty = ValidatedDateRange::new( + fixture.empty_voucher_range.from_yyyymmdd.clone(), + fixture.empty_voucher_range.to_yyyymmdd.clone(), + ) + .map_err(|_| error("fixture_invalid"))?; + let populated = ValidatedDateRange::new( + fixture.populated_voucher_range.from_yyyymmdd.clone(), + fixture.populated_voucher_range.to_yyyymmdd.clone(), + ) + .map_err(|_| error("fixture_invalid"))?; + if ranges_overlap(&empty, &populated) { + return Err(error("fixture_ranges_overlap")); + } + Ok(()) +} + +fn valid_fixture_sentinel(value: &str, prefix: &str) -> bool { + value.starts_with(prefix) + && value.len() >= prefix.len() + 36 + && value.len() <= 128 + && !value.chars().any(char::is_control) +} + +fn ranges_overlap(left: &ValidatedDateRange, right: &ValidatedDateRange) -> bool { + left.from_yyyymmdd() <= right.to_yyyymmdd() && right.from_yyyymmdd() <= left.to_yyyymmdd() +} + +fn valid_release(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && value.trim() == value + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-' | b'_')) + && !matches!(value.to_ascii_lowercase().as_str(), "unknown" | "latest") + && !value.to_ascii_lowercase().ends_with(".x") +} + +fn valid_slug(value: &str) -> bool { + !value.is_empty() + && value.len() <= 96 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'-' | b'_') + }) +} + +fn canonical_join(base: &Path, value: &Path, code: &'static str) -> Result { + let joined = if value.is_absolute() { + value.to_path_buf() + } else { + base.join(value) + }; + joined.canonicalize().map_err(|_| error(code)) +} + +fn read_bounded(path: &Path, maximum: usize, code: &'static str) -> Result, LiveReadError> { + let metadata = fs::metadata(path).map_err(|_| error(code))?; + if metadata.len() == 0 || metadata.len() > maximum as u64 { + return Err(error("local_input_size_invalid")); + } + fs::read(path).map_err(|_| error(code)) +} + +fn git_output(repository_root: &Path, arguments: &[&str]) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repository_root) + .args(arguments) + .output() + .map_err(|_| error("git_unavailable"))?; + if !output.status.success() || output.stdout.len() > MAX_LOCAL_INPUT_BYTES { + return Err(error("git_query_failed")); + } + String::from_utf8(output.stdout) + .map(|value| value.trim().to_string()) + .map_err(|_| error("git_query_failed")) +} + +fn valid_commit(value: &str) -> bool { + matches!(value.len(), 40 | 64) + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn network_consent_binding( + config: &LiveRunConfig, + fixture_id: &str, + metadata: &RunMetadata, + expires_at_unix_ms: i64, +) -> Result { + let config_bytes = serde_json::to_vec(config).map_err(|_| error("config_commitment_failed"))?; + let mut nonce = [0_u8; 32]; + getrandom::fill(&mut nonce).map_err(|_| error("secure_random_unavailable"))?; + let mut digest = Sha256::new(); + digest.update(b"bridge.tally.live-read-consent/2\0"); + for field in [ + config_bytes.as_slice(), + fixture_id.as_bytes(), + metadata.bridge_commit_sha.as_bytes(), + metadata.compatibility_surface_sha256.as_bytes(), + metadata.executable_sha256.as_bytes(), + metadata.cargo_lock_sha256.as_bytes(), + metadata.fixture_manifest_sha256.as_bytes(), + ] { + digest.update((field.len() as u64).to_be_bytes()); + digest.update(field); + } + digest.update([u8::from(metadata.working_tree_dirty)]); + digest.update(metadata.observed_at_unix_ms.to_be_bytes()); + digest.update(expires_at_unix_ms.to_be_bytes()); + digest.update(nonce); + Ok(sha256_digest_hex(digest.finalize())) +} + +fn verify_network_consent( + inputs: &LiveRunInputs, + consent: &NetworkConsent, +) -> Result<(), LiveReadError> { + ensure_not_expired(consent.expires_at_unix_ms)?; + if !inputs.config.no_customer_data_attested + || consent.binding != inputs.consent_binding + || consent.expires_at_unix_ms != inputs.consent_expires_at_unix_ms + { + return Err(error("network_consent_binding_mismatch")); + } + Ok(()) +} + +fn ensure_not_expired(expires_at_unix_ms: i64) -> Result<(), LiveReadError> { + let now = now_unix_ms().map_err(|_| error("system_clock_invalid"))?; + if now > expires_at_unix_ms { + return Err(error("network_consent_expired")); + } + Ok(()) +} + +fn validate_current_surface( + repository_root: &Path, + expected_manifest_sha256: &str, +) -> Result<(), LiveReadError> { + let path = repository_root.join("docs/tally/compatibility/compatibility-surface.json"); + let surface = CompatibilitySurfaceManifest::from_json(&read_bounded( + &path, + MAX_ARTIFACT_BYTES, + "surface_unavailable", + )?) + .map_err(|_| error("surface_invalid"))?; + if surface.manifest_sha256 != expected_manifest_sha256 { + return Err(error("surface_changed_after_consent")); + } + surface + .validate_files(repository_root) + .map_err(|_| error("surface_changed_after_consent")) +} + +fn sha256_hex(bytes: &[u8]) -> String { + sha256_digest_hex(Sha256::digest(bytes)) +} + +fn sha256_digest_hex(bytes: impl AsRef<[u8]>) -> String { + let mut output = String::with_capacity(64); + for byte in bytes.as_ref() { + use std::fmt::Write as _; + write!(&mut output, "{byte:02x}").expect("writing to a String cannot fail"); + } + output +} + +fn read_loopback(family: LoopbackFamily) -> ReadLoopback { + match family { + LoopbackFamily::LocalhostAlias => ReadLoopback::LocalhostAlias, + LoopbackFamily::Ipv4 => ReadLoopback::Ipv4, + LoopbackFamily::Ipv6 => ReadLoopback::Ipv6, + } +} + +fn current_platform() -> Platform { + if cfg!(target_os = "windows") { + Platform::Windows + } else if cfg!(target_os = "macos") { + Platform::Macos + } else { + Platform::Linux + } +} + +fn current_architecture() -> Architecture { + match std::env::consts::ARCH { + "x86_64" => Architecture::X86_64, + "aarch64" => Architecture::Aarch64, + _ => Architecture::Other, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tally_protocol_simulator::{Fixture, ScenarioPlan, Simulator}; + + const SHA: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + + fn config(port: u16) -> LiveRunConfig { + LiveRunConfig { + schema_version: CONFIG_SCHEMA_VERSION, + repository_root: PathBuf::from("."), + fixture_manifest: PathBuf::from("fixture.json"), + endpoint_family: LoopbackFamily::Ipv4, + port, + product: ProductFamily::TallyPrime, + release: "7.1".to_string(), + mode: TallyMode::Education, + odbc_state: OdbcState::Disabled, + locale: LocaleProfile::EnglishIndia, + no_customer_data_attested: true, + } + } + + fn fixture() -> SyntheticFixtureManifest { + SyntheticFixtureManifest { + schema_version: FIXTURE_SCHEMA_VERSION, + fixture_id: "education-small-v1".to_string(), + dataset_tier: DatasetTier::SyntheticSmall, + company_marker: "BRIDGE-PR14-SYNTHETIC-019f605f-e6cf-77b2-ac95-31722887a911" + .to_string(), + ledger_sentinel: "BRIDGE-LEDGER-019f605f-e6cf-77b2-ac95-31722887a911".to_string(), + voucher_number_sentinel: "BRIDGE-VOUCHER-019f605f-e6cf-77b2-ac95-31722887a911" + .to_string(), + empty_voucher_range: DateWindow { + from_yyyymmdd: "20260403".to_string(), + to_yyyymmdd: "20260403".to_string(), + }, + populated_voucher_range: DateWindow { + from_yyyymmdd: "20260401".to_string(), + to_yyyymmdd: "20260402".to_string(), + }, + minimum_ledger_count: 1, + maximum_ledger_count: 100, + minimum_populated_voucher_count: 1, + maximum_populated_voucher_count: 20, + } + } + + fn metadata() -> RunMetadata { + RunMetadata { + observed_at_unix_ms: 1_800_000_000_000, + bridge_commit_sha: "b".repeat(40), + working_tree_dirty: true, + compatibility_surface_sha256: SHA.to_string(), + executable_sha256: SHA.to_string(), + cargo_lock_sha256: SHA.to_string(), + fixture_manifest_sha256: SHA.to_string(), + } + } + + fn receipt() -> LiveCompatibilityReceipt { + let company = ValidatedCompanyName::new(fixture().company_marker).unwrap(); + let range = ValidatedDateRange::new("20260403", "20260403").unwrap(); + let profile = ReadOnlyProfile::CompanyListV1; + let mut operations = vec![not_attempted( + ReadProfileId::XmlCompanyEnumerationV1, + profile.template_sha256(), + "endpoint_not_queried", + )]; + operations.push(not_attempted( + ReadProfileId::XmlSyntheticFixtureMarkerV1, + profile.template_sha256(), + "endpoint_not_queried", + )); + append_company_reads_not_attempted(&mut operations, &company, &range); + seal_receipt( + &config(9001), + &fixture(), + &metadata(), + operations, + false, + false, + CountBucket::Unknown, + ) + .unwrap() + } + + fn inputs(port: u16, binding: &str, expires_at_unix_ms: i64) -> LiveRunInputs { + LiveRunInputs { + config: config(port), + fixture: fixture(), + metadata: metadata(), + repository_root: PathBuf::from("."), + challenge_phrase: "QUALIFY education-small-v1 test".to_string(), + consent_binding: binding.to_string(), + consent_expires_at_unix_ms: expires_at_unix_ms, + } + } + + #[test] + fn fixture_requires_reviewed_sentinels_bounded_counts_and_disjoint_ranges() { + assert!(validate_fixture(&fixture()).is_ok()); + let mut invalid = fixture(); + invalid.company_marker = "synthetic".to_string(); + assert_eq!(validate_fixture(&invalid), Err(error("fixture_invalid"))); + let mut overlap = fixture(); + overlap.empty_voucher_range = overlap.populated_voucher_range.clone(); + assert_eq!( + validate_fixture(&overlap), + Err(error("fixture_ranges_overlap")) + ); + let mut invalid_bounds = fixture(); + invalid_bounds.maximum_ledger_count = 0; + assert_eq!( + validate_fixture(&invalid_bounds), + Err(error("fixture_invalid")) + ); + } + + #[test] + fn dataset_tier_comes_from_the_reviewed_fixture_not_the_local_profile() { + let mut fixture = fixture(); + fixture.dataset_tier = DatasetTier::SyntheticLarge; + assert!(validate_fixture(&fixture).is_ok()); + assert_eq!(fixture.dataset_tier, DatasetTier::SyntheticLarge); + } + + #[test] + fn unknown_profile_fields_are_rejected_before_network_and_cannot_be_laundered() { + let mut config = config(9001); + config.product = ProductFamily::Unknown; + config.release = "unknown".to_string(); + config.mode = TallyMode::Unknown; + config.odbc_state = OdbcState::Unknown; + config.locale = LocaleProfile::Unknown; + assert_eq!(validate_config(&config), Err(error("config_invalid"))); + let company = ValidatedCompanyName::new(fixture().company_marker).unwrap(); + let range = ValidatedDateRange::new("20260403", "20260403").unwrap(); + let profile = ReadOnlyProfile::CompanyListV1; + let mut operations = vec![not_attempted( + ReadProfileId::XmlCompanyEnumerationV1, + profile.template_sha256(), + "endpoint_not_queried", + )]; + operations.push(not_attempted( + ReadProfileId::XmlSyntheticFixtureMarkerV1, + profile.template_sha256(), + "endpoint_not_queried", + )); + append_company_reads_not_attempted(&mut operations, &company, &range); + let receipt = seal_receipt( + &config, + &fixture(), + &metadata(), + operations, + false, + false, + CountBucket::Unknown, + ) + .unwrap(); + assert_eq!(receipt.product.authority, EvidenceAuthority::Unknown); + assert_eq!(receipt.release.confidence, EvidenceConfidence::Unknown); + assert_eq!(receipt.mode.authority, EvidenceAuthority::Unknown); + } + + #[tokio::test] + async fn empty_company_response_stops_after_one_request_and_retains_no_marker() { + let mut last_failure = "simulator_not_started"; + for _ in 0..5 { + let simulator = Simulator::spawn(ScenarioPlan::new(Fixture::EmptyExport)).unwrap(); + let config = config(simulator.address().port()); + let transport = + ReadOnlyTransport::new(ReadLoopback::Ipv4, simulator.address().port()).unwrap(); + let receipt = execute_with_transport(&config, &fixture(), &metadata(), &transport) + .await + .unwrap(); + let observed = match simulator.finish() { + Ok(observed) if observed.method == "POST" && observed.request_processed => observed, + Ok(_) => { + last_failure = "simulator_request_not_processed"; + continue; + } + Err(_) => { + last_failure = "simulator_request_unavailable"; + continue; + } + }; + assert_eq!(receipt.operations.len(), 5); + assert_eq!(receipt.operations[0].safe_reason_code.as_deref(), None); + assert_eq!(receipt.operations[0].outcome, OperationOutcome::Passed); + assert_eq!(receipt.operations[1].outcome, OperationOutcome::Failed); + assert!(receipt.operations[2..] + .iter() + .all(|operation| operation.outcome == OperationOutcome::NotAttempted)); + assert!(!receipt.fixture_marker_verified); + assert_eq!(observed.path, "/"); + let text = String::from_utf8(receipt.to_pretty_json().unwrap()).unwrap(); + assert!(!text.contains(&fixture().company_marker)); + return; + } + panic!("loopback simulator remained unstable: {last_failure}"); + } + + #[test] + fn false_customer_attestation_rejects_before_any_post() { + let simulator = Simulator::spawn(ScenarioPlan::new(Fixture::EmptyExport)).unwrap(); + let mut value = config(simulator.address().port()); + value.no_customer_data_attested = false; + assert_eq!(validate_config(&value), Err(error("config_invalid"))); + simulator.cancel(); + let observed = simulator.finish().unwrap(); + assert!(!observed.request_processed); + assert!(observed.cancelled); + } + + #[test] + fn consent_is_expiring_and_bound_to_one_loaded_run() { + let future = now_unix_ms().unwrap() + NETWORK_CONSENT_TTL_MS; + let run_a = inputs(9000, "a", future); + let run_b = inputs(9001, "b", future); + let token_a = confirm_network_challenge(&run_a, "QUALIFY education-small-v1 test").unwrap(); + assert_eq!( + verify_network_consent(&run_b, &token_a), + Err(error("network_consent_binding_mismatch")) + ); + assert_eq!( + confirm_network_challenge(&run_a, "wrong").err().unwrap(), + error("network_consent_mismatch") + ); + + let expired = inputs(9000, "expired", 1); + assert_eq!( + confirm_network_challenge(&expired, expired.challenge_phrase()) + .err() + .unwrap(), + error("network_consent_expired") + ); + } + + #[test] + fn live_receipt_paths_are_local_json_and_path_bound() { + let directory = std::env::temp_dir().join(format!( + "bridge-live-read-path-test-{}-{}", + std::process::id(), + now_unix_ms().unwrap() + )); + let local = directory.join(".bridge-live"); + fs::create_dir_all(&local).unwrap(); + let first = local.join("first.json"); + let second = local.join("second.json"); + assert_ne!( + live_receipt_output_binding(&first).unwrap(), + live_receipt_output_binding(&second).unwrap() + ); + assert_eq!( + live_receipt_output_binding(&directory.join("outside.json")), + Err(error("receipt_output_outside_local_evidence_root")) + ); + assert_eq!( + live_receipt_output_binding(&local.join("receipt.txt")), + Err(error("receipt_output_invalid")) + ); + fs::remove_dir(local).unwrap(); + fs::remove_dir(directory).unwrap(); + } + + #[test] + fn save_requires_exact_receipt_bound_confirmation_and_never_overwrites() { + let directory = std::env::temp_dir().join(format!( + "bridge-live-read-save-test-{}-{}", + std::process::id(), + now_unix_ms().unwrap() + )); + fs::create_dir(&directory).unwrap(); + let output = directory.join("receipt.json"); + assert_eq!( + save_receipt_no_replace(&output, b"{}", "wrong", "SAVE abc"), + Err(error("save_consent_mismatch")) + ); + save_receipt_no_replace(&output, b"{}", "SAVE abc", "SAVE abc").unwrap(); + assert_eq!(fs::read(&output).unwrap(), b"{}"); + assert_eq!( + save_receipt_no_replace(&output, b"new", "SAVE abc", "SAVE abc"), + Err(error("receipt_output_exists")) + ); + fs::remove_file(output).unwrap(); + fs::remove_dir(directory).unwrap(); + } + + #[test] + fn public_save_consumes_a_repository_bound_target_and_rechecks_no_overwrite() { + let directory = std::env::temp_dir().join(format!( + "bridge-live-read-public-save-test-{}-{}", + std::process::id(), + now_unix_ms().unwrap() + )); + let local = directory.join(".bridge-live"); + fs::create_dir_all(&local).unwrap(); + let output = local.join("receipt.json"); + let mut run = inputs( + 9001, + "binding", + now_unix_ms().unwrap() + NETWORK_CONSENT_TTL_MS, + ); + run.repository_root = directory.clone(); + let target = run.validate_receipt_output(&output).unwrap(); + let overwrite_attempt = run.validate_receipt_output(&output).unwrap(); + let receipt = receipt(); + let bytes = receipt.to_pretty_json().unwrap(); + let phrase = receipt_save_phrase(&receipt, &target).unwrap(); + save_live_receipt_no_replace(target, &bytes, &phrase).unwrap(); + assert_eq!( + save_live_receipt_no_replace(overwrite_attempt, &bytes, &phrase), + Err(error("receipt_output_exists")) + ); + fs::remove_file(output).unwrap(); + fs::remove_dir(local).unwrap(); + fs::remove_dir(directory).unwrap(); + } +} diff --git a/src-tauri/crates/bridge-tally-live-read/src/main.rs b/src-tauri/crates/bridge-tally-live-read/src/main.rs new file mode 100644 index 0000000..b7243d4 --- /dev/null +++ b/src-tauri/crates/bridge-tally-live-read/src/main.rs @@ -0,0 +1,94 @@ +use std::{ + io::{self, BufRead, Write}, + path::PathBuf, + process::ExitCode, +}; + +use bridge_tally_live_read::{ + confirm_network_challenge, receipt_save_phrase, save_live_receipt_no_replace, LiveRunInputs, +}; + +#[tokio::main] +async fn main() -> ExitCode { + match run().await { + Ok(()) => ExitCode::SUCCESS, + Err(code) => { + eprintln!("bridge_tally_live_read_failed:{code}"); + ExitCode::FAILURE + } + } +} + +async fn run() -> Result<(), &'static str> { + let mut arguments = std::env::args().skip(1); + if arguments.next().as_deref() != Some("run") { + return Err("usage_run_required"); + } + let config_path = arguments + .next() + .map(PathBuf::from) + .ok_or("config_path_missing")?; + let output_path = arguments + .next() + .map(PathBuf::from) + .ok_or("output_path_missing")?; + if arguments.next().as_deref() != Some("--consent") + || arguments.next().as_deref() != Some("read-only-synthetic") + || arguments.next().is_some() + { + return Err("explicit_consent_option_required"); + } + if output_path.exists() { + return Err("receipt_output_exists"); + } + + let inputs = LiveRunInputs::load(&config_path).map_err(|value| value.safe_code())?; + let output = inputs + .validate_receipt_output(&output_path) + .map_err(|value| value.safe_code())?; + println!( + "Read-only synthetic qualification is ready. No write/import request exists in this controller." + ); + println!( + "Your local profile attests no customer data: {}. Stop now if that assertion is inaccurate.", + inputs.no_customer_data_attested() + ); + println!("Type this exact run-bound challenge to permit network reads:"); + println!("{}", inputs.challenge_phrase()); + let typed = read_line()?; + let consent = confirm_network_challenge(&inputs, &typed).map_err(|value| value.safe_code())?; + let receipt = inputs + .execute(consent) + .await + .map_err(|value| value.safe_code())?; + let receipt_bytes = receipt + .to_pretty_json() + .map_err(|_| "receipt_serialization_failed")?; + + println!("Exact privacy-reduced receipt preview follows:"); + io::stdout() + .write_all(&receipt_bytes) + .and_then(|_| io::stdout().write_all(b"\n")) + .and_then(|_| io::stdout().flush()) + .map_err(|_| "receipt_preview_failed")?; + let save_phrase = receipt_save_phrase(&receipt, &output).map_err(|value| value.safe_code())?; + println!("Type this exact receipt-bound challenge to save without overwrite:"); + println!("{save_phrase}"); + let typed = read_line()?; + save_live_receipt_no_replace(output, &receipt_bytes, &typed) + .map_err(|value| value.safe_code())?; + println!("bridge_tally_live_read_receipt_saved"); + Ok(()) +} + +fn read_line() -> Result { + let mut line = String::new(); + io::stdin() + .lock() + .read_line(&mut line) + .map_err(|_| "interactive_input_failed")?; + if line.len() > 256 { + return Err("interactive_input_too_long"); + } + Ok(line) +} diff --git a/src-tauri/crates/bridge-tally-live-read/src/native_outstandings_qualification.rs b/src-tauri/crates/bridge-tally-live-read/src/native_outstandings_qualification.rs new file mode 100644 index 0000000..45609f2 --- /dev/null +++ b/src-tauri/crates/bridge-tally-live-read/src/native_outstandings_qualification.rs @@ -0,0 +1,1942 @@ +//! Qualification-only controller for the dormant native Ledger Outstandings candidate. +//! +//! This module is available only behind a non-default binary feature. It can +//! produce an observation receipt, but it has no runtime, parser, mirror, or +//! compatibility-support authority. + +use std::{ + path::{Path, PathBuf}, + time::{SystemTime, UNIX_EPOCH}, +}; + +use bridge_tally_compatibility::{ + bills_native_outstandings_probe_receipt::{ + BillsNativeOutstandingsProbeReceiptV0, ByteRepeatability, CandidateAttemptId, + CandidateAttemptOutcome, IdentityBracketState, IdentitySnapshotId, + ProbeAttestationAuthority, ProbeAttestedEnvironmentV0, ProbeCandidateAttemptV0, + ProbeCommitmentsV0, ProbeEndpointV0, ProbeIdentitySnapshotV0, ProbeInitialPreflightV0, + ProbeReceiptAuthorityV0, ProbeUiObservationV0, UiObservationPosition, UserAttestedUiChange, + BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_MAX_BYTES, + BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_SCHEMA_VERSION, + BILLS_NATIVE_OUTSTANDINGS_PROFILE_ID, BILLS_NATIVE_OUTSTANDINGS_REPORT_ID, + }, + now_unix_ms, sha256_file, ApplicationStatus, CompatibilitySurfaceManifest, DatasetTier, + LocaleProfile, LoopbackFamily, ProductFamily, TallyMode, MAX_ARTIFACT_BYTES, +}; +use bridge_tally_protocol::{ + bills_native_outstandings_probe::{ + NativeLedgerOutstandingsProbeScope, SealedNativeLedgerOutstandingsProbe, + ValidatedProbeCompanyName, ValidatedProbeLedgerName, ValidatedProbeToDate, + }, + export_status, parse_companies_with_evidence, parse_ledger_source_records_with_evidence, + verify_company_context, + xml_read_profiles::{ReadOnlyProfile, ValidatedCompanyName}, + ParsedSourceIdentityKind, TallyExportStatus, +}; +use bridge_tally_read_transport::{ + QualificationOnlyNativeOutstandingsResponse, QualificationOnlyNativeOutstandingsTransport, + ReadOnlyTransport, ReadOnlyTransportError, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use super::{ + canonical_join, current_architecture, current_platform, encoding, git_output, read_bounded, + read_loopback, save_receipt_no_replace, sha256_hex, valid_commit, valid_release, valid_slug, + MAX_LOCAL_INPUT_BYTES, +}; + +const CONFIG_SCHEMA_VERSION: u16 = 1; +const FIXTURE_SCHEMA_VERSION: u16 = 1; +const UI_SCHEMA_VERSION: u16 = 1; +const CONSENT_TTL_MS: i64 = 5 * 60 * 1000; +const UI_BEFORE_MAX_AGE_MS: i64 = 15 * 60 * 1000; +const UI_PROJECTION_MAX_ROWS: usize = 256; +const UI_PROJECTION_TEXT_MAX_CHARS: usize = 512; +const COMPANY_IDENTITY_DOMAIN: &[u8] = + b"bridge.tally.native-ledger-outstandings.company-identity/0\0"; +const PARTY_IDENTITY_DOMAIN: &[u8] = b"bridge.tally.native-ledger-outstandings.party-identity/0\0"; +const CHALLENGE_DOMAIN: &[u8] = b"bridge.tally.native-ledger-outstandings.consent/0\0"; +const EXPECTED_PROJECTION_FIELDS: [&str; 10] = [ + "row_index", + "row_kind", + "display_date_text", + "reference_text", + "opening_amount_text", + "pending_amount_text", + "due_date_text", + "overdue_text", + "voucher_details_text", + "dr_cr_text", +]; + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[error("native outstandings qualification failed ({code})")] +pub struct NativeOutstandingsQualificationError { + code: &'static str, +} + +impl NativeOutstandingsQualificationError { + pub fn safe_code(&self) -> &'static str { + self.code + } +} + +fn error(code: &'static str) -> NativeOutstandingsQualificationError { + NativeOutstandingsQualificationError { code } +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct NativeProbeConfig { + schema_version: u16, + repository_root: PathBuf, + fixture_manifest: PathBuf, + scenario_id: String, + endpoint_family: LoopbackFamily, + port: u16, + product: ProductFamily, + release: String, + mode: TallyMode, + locale: LocaleProfile, + configured_tdl_count: Option, + configured_add_on_count: Option, + expected_company_identity_sha256: String, + expected_party_identity_sha256: String, + identity_registration_id: String, + identity_registration_reviewed: bool, + no_customer_data_attested: bool, + ui_before_observation: PathBuf, + ui_after_observation: PathBuf, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureCandidate { + profile_id: String, + template_sha256: String, + observation_posture: String, + request_shape_immutable: bool, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureScope { + company_marker: String, + party_marker: String, + currency: String, + bill_by_bill_tracking_required: bool, + customer_or_personal_data_forbidden: bool, + expected_company_identity_commitment_source: String, + expected_party_identity_commitment_source: String, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureScenario { + scenario_id: String, + to_date: String, + request_sha256: String, + scope_sha256: String, + expected_accounting_facts: Vec, + inv_001_ui_presence_requirement: UiPresenceRequirement, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum UiPresenceRequirement { + MustBeObservedPresent, + MustRecordObservedPresentOrOmitted, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct FixtureBudget { + preflight_posts: u8, + dispatch_identity_posts: u8, + candidate_dispatch_posts: u8, + dispatch_posts: u8, + maximum_total_posts: u8, + automatic_retries: u8, + preflight_order: Vec, + dispatch_order: Vec, + dispatch_request: String, +} + +#[derive(Clone, Deserialize)] +#[serde(deny_unknown_fields)] +struct NativeFixtureManifest { + schema_version: u16, + fixture_id: String, + dataset_tier: DatasetTier, + candidate: FixtureCandidate, + synthetic_scope: FixtureScope, + education_constraints: Value, + fixture_facts: Vec, + scenarios: Vec, + one_scenario_per_invocation: bool, + request_budget: FixtureBudget, + ui_observation_contract: Value, + authority: Value, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct UiContext { + company_marker: String, + party_marker: String, + to_date: String, + report_name: String, + report_mode: String, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct UiVisibleColumns { + opening: bool, + pending: bool, + due: bool, + overdue: bool, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct UiProjectionRow { + row_index: u32, + row_kind: String, + display_date_text: String, + reference_text: String, + opening_amount_text: String, + pending_amount_text: String, + due_date_text: String, + overdue_text: String, + voucher_details_text: String, + dr_cr_text: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum SettledReferenceObservation { + Present, + Omitted, + Unobserved, +} + +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct UiObservation { + schema_version: u16, + fixture_id: String, + scenario_id: String, + phase: String, + evidence_complete: bool, + captured_unix_ms: i64, + screenshot_sha256: String, + context: UiContext, + visible_columns: UiVisibleColumns, + ordered_projection_fields: Vec, + ordered_projection: Vec, + inv_001_settled_reference_observation: SettledReferenceObservation, + operator_attests_no_tally_interaction_until_after_capture: Option, + operator_attests_no_tally_interaction_since_before_capture: Option, +} + +struct ProbeMetadata { + bridge_commit_sha: String, + working_tree_dirty: bool, + compatibility_surface_sha256: String, + executable_sha256: String, + cargo_lock_sha256: String, + fixture_manifest_sha256: String, +} + +pub struct LoadedNativeOutstandingsProbe { + config: NativeProbeConfig, + fixture: NativeFixtureManifest, + scenario: FixtureScenario, + candidate: SealedNativeLedgerOutstandingsProbe, + metadata: ProbeMetadata, + repository_root: PathBuf, + ui_before: UiObservation, + ui_after_path: PathBuf, + run_commitment: String, + preflight_challenge: String, + preflight_binding: String, + preflight_expires_at: i64, +} + +pub struct PreflightConsent { + binding: String, + expires_at: i64, +} +pub struct DispatchConsent { + binding: String, + expires_at: i64, +} +pub struct UiAfterConsent { + binding: String, + expires_at: i64, +} + +pub struct NativeProbeReceiptOutput { + output_path: PathBuf, + canonical_parent: PathBuf, +} + +pub struct DispatchReadyNativeOutstandingsProbe { + loaded: LoadedNativeOutstandingsProbe, + initial_preflight: ProbeInitialPreflightV0, + dispatch_challenge: String, + dispatch_binding: String, + dispatch_expires_at: i64, +} + +pub struct PendingNativeOutstandingsProbeReceipt { + loaded: LoadedNativeOutstandingsProbe, + initial_preflight: ProbeInitialPreflightV0, + snapshots: [ProbeIdentitySnapshotV0; 4], + attempts: [ProbeCandidateAttemptV0; 3], + byte_repeatability: ByteRepeatability, + ui_after_challenge: String, + ui_after_binding: String, + ui_after_expires_at: i64, +} + +impl LoadedNativeOutstandingsProbe { + pub fn load(config_path: &Path) -> Result { + let config_bytes = read_bounded( + config_path, + MAX_LOCAL_INPUT_BYTES, + "native_probe_config_unavailable", + ) + .map_err(|_| error("native_probe_config_unavailable"))?; + let config: NativeProbeConfig = serde_json::from_slice(&config_bytes) + .map_err(|_| error("native_probe_config_invalid"))?; + validate_config(&config)?; + let base = config_path.parent().unwrap_or_else(|| Path::new(".")); + let repository_root = canonical_join( + base, + &config.repository_root, + "native_probe_repository_unavailable", + ) + .map_err(|_| error("native_probe_repository_unavailable"))?; + let reviewed_local_root = repository_root + .join(".bridge-live") + .canonicalize() + .map_err(|_| error("native_probe_local_root_unavailable"))?; + let canonical_config = config_path + .canonicalize() + .map_err(|_| error("native_probe_config_unavailable"))?; + if !canonical_config.starts_with(&reviewed_local_root) { + return Err(error("native_probe_config_outside_local_root")); + } + + let fixture_path = canonical_join( + base, + &config.fixture_manifest, + "native_probe_fixture_unavailable", + ) + .map_err(|_| error("native_probe_fixture_unavailable"))?; + let fixture_root = repository_root + .join("docs/tally/compatibility/fixtures") + .canonicalize() + .map_err(|_| error("native_probe_fixture_root_unavailable"))?; + if !fixture_path.starts_with(&fixture_root) { + return Err(error("native_probe_fixture_outside_reviewed_root")); + } + let fixture_bytes = read_bounded( + &fixture_path, + MAX_LOCAL_INPUT_BYTES, + "native_probe_fixture_unavailable", + ) + .map_err(|_| error("native_probe_fixture_unavailable"))?; + let fixture: NativeFixtureManifest = serde_json::from_slice(&fixture_bytes) + .map_err(|_| error("native_probe_fixture_invalid"))?; + let scenario = validate_fixture(&fixture, &config.scenario_id)?.clone(); + + let company = + ValidatedProbeCompanyName::new(fixture.synthetic_scope.company_marker.clone()) + .map_err(|_| error("native_probe_company_marker_invalid"))?; + let ledger = ValidatedProbeLedgerName::new(fixture.synthetic_scope.party_marker.clone()) + .map_err(|_| error("native_probe_party_marker_invalid"))?; + let to_date = ValidatedProbeToDate::new(scenario.to_date.clone()) + .map_err(|_| error("native_probe_scenario_date_invalid"))?; + let candidate = NativeLedgerOutstandingsProbeScope::new(company, ledger, to_date).seal(); + if candidate.template_sha256() != fixture.candidate.template_sha256 + || candidate.request_sha256() != scenario.request_sha256 + || candidate.scope_sha256() != scenario.scope_sha256 + { + return Err(error("native_probe_candidate_commitment_mismatch")); + } + + let ui_before_path = canonical_join( + base, + &config.ui_before_observation, + "native_probe_ui_before_unavailable", + ) + .map_err(|_| error("native_probe_ui_before_unavailable"))?; + let ui_after_path = canonical_join( + base, + &config.ui_after_observation, + "native_probe_ui_after_unavailable", + ) + .map_err(|_| error("native_probe_ui_after_unavailable"))?; + if !ui_before_path.starts_with(&reviewed_local_root) + || !ui_after_path.starts_with(&reviewed_local_root) + || ui_before_path == ui_after_path + { + return Err(error("native_probe_ui_path_invalid")); + } + let ui_before = load_ui(&ui_before_path, "native_probe_ui_before_unavailable")?; + + let surface_path = + repository_root.join("docs/tally/compatibility/compatibility-surface.json"); + let surface = CompatibilitySurfaceManifest::from_json( + &read_bounded( + &surface_path, + MAX_ARTIFACT_BYTES, + "native_probe_surface_unavailable", + ) + .map_err(|_| error("native_probe_surface_unavailable"))?, + ) + .map_err(|_| error("native_probe_surface_invalid"))?; + surface + .validate_files(&repository_root) + .map_err(|_| error("native_probe_surface_changed"))?; + + let loaded_at_unix_ms = now_unix_ms().map_err(|_| error("native_probe_clock_invalid"))?; + validate_ui(&ui_before, "before", &fixture, &scenario, loaded_at_unix_ms)?; + if loaded_at_unix_ms - ui_before.captured_unix_ms > UI_BEFORE_MAX_AGE_MS { + return Err(error("native_probe_ui_before_stale")); + } + let bridge_commit_sha = git_output(&repository_root, &["rev-parse", "HEAD"]) + .map_err(|_| error("native_probe_git_query_failed"))?; + if !valid_commit(&bridge_commit_sha) { + return Err(error("native_probe_commit_invalid")); + } + let working_tree_dirty = !git_output( + &repository_root, + &["status", "--porcelain", "--untracked-files=all"], + ) + .map_err(|_| error("native_probe_git_query_failed"))? + .is_empty(); + let executable = + std::env::current_exe().map_err(|_| error("native_probe_executable_unavailable"))?; + let metadata = ProbeMetadata { + bridge_commit_sha, + working_tree_dirty, + compatibility_surface_sha256: surface.manifest_sha256, + executable_sha256: sha256_file(&executable) + .map_err(|_| error("native_probe_executable_unavailable"))?, + cargo_lock_sha256: sha256_file(&repository_root.join("src-tauri/Cargo.lock")) + .map_err(|_| error("native_probe_cargo_lock_unavailable"))?, + fixture_manifest_sha256: sha256_hex(&fixture_bytes), + }; + let run_commitment = qualification_run_commitment( + &config, &fixture, &scenario, &candidate, &metadata, &ui_before, + )?; + let preflight_expires_at = loaded_at_unix_ms + CONSENT_TTL_MS; + let preflight_binding = consent_binding( + "preflight", + &config, + &fixture.fixture_id, + candidate.request_sha256(), + &run_commitment, + preflight_expires_at, + )?; + let preflight_challenge = format!( + "PREFLIGHT {} {}", + fixture.fixture_id, + &preflight_binding[..16] + ); + Ok(Self { + config, + fixture, + scenario, + candidate, + metadata, + repository_root, + ui_before, + ui_after_path, + run_commitment, + preflight_challenge, + preflight_binding, + preflight_expires_at, + }) + } + + pub fn preflight_challenge(&self) -> &str { + &self.preflight_challenge + } + + pub fn validate_receipt_output( + &self, + output_path: &Path, + ) -> Result { + if output_path.exists() { + return Err(error("receipt_output_exists")); + } + let parent = output_path.parent().unwrap_or_else(|| Path::new(".")); + let canonical_parent = parent + .canonicalize() + .map_err(|_| error("receipt_output_parent_unavailable"))?; + let local_root = self + .repository_root + .join(".bridge-live") + .canonicalize() + .map_err(|_| error("native_probe_local_root_unavailable"))?; + if canonical_parent != local_root + || output_path.extension().and_then(|v| v.to_str()) != Some("json") + { + return Err(error("receipt_output_outside_local_root")); + } + output_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| error("receipt_output_invalid"))?; + Ok(NativeProbeReceiptOutput { + output_path: output_path.to_path_buf(), + canonical_parent, + }) + } + + pub async fn run_preflight( + self, + consent: PreflightConsent, + ) -> Result { + verify_consumed_consent( + &consent.binding, + consent.expires_at, + &self.preflight_binding, + self.preflight_expires_at, + )?; + let transport = + ReadOnlyTransport::new(read_loopback(self.config.endpoint_family), self.config.port) + .map_err(|_| error("native_probe_endpoint_configuration_invalid"))?; + let observation = observe_identity(&transport, &self.fixture).await?; + require_registered_identity(&self.config, &observation)?; + let initial_preflight = ProbeInitialPreflightV0 { + observed_at_unix_ms: observation.observed_at_unix_ms, + company_identity_commitment_sha256: observation.company_identity, + party_identity_commitment_sha256: observation.party_identity, + company_response_sha256: observation.company_response, + party_response_sha256: observation.party_response, + }; + let expires_at = + now_unix_ms().map_err(|_| error("native_probe_clock_invalid"))? + CONSENT_TTL_MS; + let dispatch_evidence = private_commitment( + "dispatch-evidence", + &[ + &self.run_commitment, + &initial_preflight.company_identity_commitment_sha256, + &initial_preflight.party_identity_commitment_sha256, + &initial_preflight.company_response_sha256, + &initial_preflight.party_response_sha256, + ], + ); + let dispatch_binding = consent_binding( + "dispatch", + &self.config, + &self.fixture.fixture_id, + self.candidate.request_sha256(), + &dispatch_evidence, + expires_at, + )?; + let dispatch_challenge = format!( + "DISPATCH {} {}", + self.fixture.fixture_id, + &dispatch_binding[..16] + ); + Ok(DispatchReadyNativeOutstandingsProbe { + loaded: self, + initial_preflight, + dispatch_challenge, + dispatch_binding, + dispatch_expires_at: expires_at, + }) + } +} + +pub fn confirm_preflight_challenge( + loaded: &LoadedNativeOutstandingsProbe, + typed: &str, +) -> Result { + require_typed(typed, &loaded.preflight_challenge)?; + ensure_unexpired(loaded.preflight_expires_at)?; + Ok(PreflightConsent { + binding: loaded.preflight_binding.clone(), + expires_at: loaded.preflight_expires_at, + }) +} + +impl DispatchReadyNativeOutstandingsProbe { + pub fn dispatch_challenge(&self) -> &str { + &self.dispatch_challenge + } + + pub async fn dispatch( + self, + consent: DispatchConsent, + ) -> Result { + verify_consumed_consent( + &consent.binding, + consent.expires_at, + &self.dispatch_binding, + self.dispatch_expires_at, + )?; + let loopback = read_loopback(self.loaded.config.endpoint_family); + let identity_transport = ReadOnlyTransport::new(loopback, self.loaded.config.port) + .map_err(|_| error("native_probe_endpoint_configuration_invalid"))?; + let candidate_transport = + QualificationOnlyNativeOutstandingsTransport::new(loopback, self.loaded.config.port) + .map_err(|_| error("native_probe_endpoint_configuration_invalid"))?; + + let b0 = observe_identity(&identity_transport, &self.loaded.fixture).await?; + require_registered_identity(&self.loaded.config, &b0)?; + let a1 = observe_candidate( + &candidate_transport, + &self.loaded.candidate, + CandidateAttemptId::A1, + ) + .await?; + let b1 = observe_identity(&identity_transport, &self.loaded.fixture).await?; + require_unchanged_identity(&b0, &b1)?; + let a2 = observe_candidate( + &candidate_transport, + &self.loaded.candidate, + CandidateAttemptId::A2, + ) + .await?; + let b2 = observe_identity(&identity_transport, &self.loaded.fixture).await?; + require_unchanged_identity(&b1, &b2)?; + let a3 = observe_candidate( + &candidate_transport, + &self.loaded.candidate, + CandidateAttemptId::A3, + ) + .await?; + let b3 = observe_identity(&identity_transport, &self.loaded.fixture).await?; + require_unchanged_identity(&b2, &b3)?; + + let snapshots = [ + snapshot(IdentitySnapshotId::B0, b0), + snapshot(IdentitySnapshotId::B1, b1), + snapshot(IdentitySnapshotId::B2, b2), + snapshot(IdentitySnapshotId::B3, b3), + ]; + let byte_repeatability = match (&a1.encoded_body, &a2.encoded_body, &a3.encoded_body) { + (Some(first), Some(second), Some(third)) if first == second && second == third => { + ByteRepeatability::Identical + } + (Some(_), Some(_), Some(_)) => ByteRepeatability::Different, + _ => ByteRepeatability::NotEstablished, + }; + let attempts = [a1.attempt, a2.attempt, a3.attempt]; + let attempt_evidence = [ + attempt_fact_commitment(&attempts[0])?, + attempt_fact_commitment(&attempts[1])?, + attempt_fact_commitment(&attempts[2])?, + ]; + let expires_at = + now_unix_ms().map_err(|_| error("native_probe_clock_invalid"))? + CONSENT_TTL_MS; + let ui_after_evidence = private_commitment( + "ui-after-evidence", + &[ + &self.loaded.run_commitment, + &self.initial_preflight.company_response_sha256, + &self.initial_preflight.party_response_sha256, + &snapshots[3].company_response_sha256, + &snapshots[3].party_response_sha256, + &attempt_evidence[0], + &attempt_evidence[1], + &attempt_evidence[2], + ], + ); + let ui_after_binding = consent_binding( + "ui-after", + &self.loaded.config, + &self.loaded.fixture.fixture_id, + self.loaded.candidate.request_sha256(), + &ui_after_evidence, + expires_at, + )?; + let ui_after_challenge = format!( + "UI-AFTER {} {}", + self.loaded.fixture.fixture_id, + &ui_after_binding[..16] + ); + Ok(PendingNativeOutstandingsProbeReceipt { + loaded: self.loaded, + initial_preflight: self.initial_preflight, + snapshots, + attempts, + byte_repeatability, + ui_after_challenge, + ui_after_binding, + ui_after_expires_at: expires_at, + }) + } +} + +pub fn confirm_dispatch_challenge( + ready: &DispatchReadyNativeOutstandingsProbe, + typed: &str, +) -> Result { + require_typed(typed, &ready.dispatch_challenge)?; + ensure_unexpired(ready.dispatch_expires_at)?; + Ok(DispatchConsent { + binding: ready.dispatch_binding.clone(), + expires_at: ready.dispatch_expires_at, + }) +} + +impl PendingNativeOutstandingsProbeReceipt { + pub fn ui_after_challenge(&self) -> &str { + &self.ui_after_challenge + } + + pub fn finalize( + self, + consent: UiAfterConsent, + ) -> Result { + verify_consumed_consent( + &consent.binding, + consent.expires_at, + &self.ui_after_binding, + self.ui_after_expires_at, + )?; + let now = now_unix_ms().map_err(|_| error("native_probe_clock_invalid"))?; + let ui_after = load_ui( + &self.loaded.ui_after_path, + "native_probe_ui_after_unavailable", + )?; + validate_ui( + &ui_after, + "after", + &self.loaded.fixture, + &self.loaded.scenario, + now, + )?; + if ui_after.captured_unix_ms < self.snapshots[3].observed_at_unix_ms { + return Err(error("native_probe_ui_after_precedes_dispatch")); + } + let before_hash = structured_ui_hash(&self.loaded.ui_before)?; + let after_hash = structured_ui_hash(&ui_after)?; + if before_hash != after_hash + || self.loaded.ui_before.screenshot_sha256 == ui_after.screenshot_sha256 + { + return Err(error("native_probe_ui_observation_changed_or_reused")); + } + let ui_before_receipt = receipt_ui( + &self.loaded.ui_before, + UiObservationPosition::Before, + &self.loaded.config, + &self.initial_preflight, + &before_hash, + ); + let ui_after_receipt = receipt_ui( + &ui_after, + UiObservationPosition::After, + &self.loaded.config, + &self.initial_preflight, + &after_hash, + ); + BillsNativeOutstandingsProbeReceiptV0 { + schema_version: BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_SCHEMA_VERSION, + observed_at_unix_ms: now, + bridge_commit_sha: self.loaded.metadata.bridge_commit_sha, + working_tree_dirty: self.loaded.metadata.working_tree_dirty, + compatibility_surface_sha256: self.loaded.metadata.compatibility_surface_sha256, + executable_sha256: self.loaded.metadata.executable_sha256, + cargo_lock_sha256: self.loaded.metadata.cargo_lock_sha256, + platform: current_platform(), + architecture: current_architecture(), + endpoint: ProbeEndpointV0 { + family: self.loaded.config.endpoint_family, + port: self.loaded.config.port, + canonical_origin: canonical_origin( + self.loaded.config.endpoint_family, + self.loaded.config.port, + ), + }, + attested_environment: ProbeAttestedEnvironmentV0 { + authority: ProbeAttestationAuthority::User, + product: self.loaded.config.product, + release: self.loaded.config.release, + mode: self.loaded.config.mode, + locale: self.loaded.config.locale, + configured_tdl_count: self.loaded.config.configured_tdl_count.unwrap_or_default(), + no_customer_data: self.loaded.config.no_customer_data_attested, + }, + commitments: ProbeCommitmentsV0 { + fixture_id: self.loaded.fixture.fixture_id, + fixture_manifest_sha256: self.loaded.metadata.fixture_manifest_sha256, + profile_id: BILLS_NATIVE_OUTSTANDINGS_PROFILE_ID.to_string(), + template_sha256: self.loaded.candidate.template_sha256(), + request_sha256: self.loaded.candidate.request_sha256().to_string(), + scope_sha256: self.loaded.candidate.scope_sha256().to_string(), + }, + initial_preflight: self.initial_preflight, + identity_snapshots: self.snapshots, + attempts: self.attempts, + byte_repeatability: self.byte_repeatability, + ui_before: ui_before_receipt, + ui_after: ui_after_receipt, + user_attested_ui_change: UserAttestedUiChange::Unchanged, + authority: ProbeReceiptAuthorityV0::observation_only(), + receipt_sha256: String::new(), + } + .seal() + .map_err(|_| error("native_probe_receipt_invalid")) + } +} + +pub fn confirm_ui_after_challenge( + pending: &PendingNativeOutstandingsProbeReceipt, + typed: &str, +) -> Result { + require_typed(typed, &pending.ui_after_challenge)?; + ensure_unexpired(pending.ui_after_expires_at)?; + Ok(UiAfterConsent { + binding: pending.ui_after_binding.clone(), + expires_at: pending.ui_after_expires_at, + }) +} + +pub fn native_probe_save_phrase( + receipt: &BillsNativeOutstandingsProbeReceiptV0, + output: &NativeProbeReceiptOutput, +) -> Result { + output.revalidate()?; + let output_binding = native_probe_output_binding(output)?; + Ok(format!( + "SAVE-NATIVE-OBSERVATION {} {}", + &receipt.receipt_sha256[..16], + &output_binding[..16] + )) +} + +pub fn save_native_probe_receipt_no_replace( + output: NativeProbeReceiptOutput, + receipt_bytes: &[u8], + typed: &str, +) -> Result<(), NativeOutstandingsQualificationError> { + output.revalidate()?; + if receipt_bytes.len() > BILLS_NATIVE_OUTSTANDINGS_PROBE_RECEIPT_MAX_BYTES { + return Err(error("native_probe_receipt_size_invalid")); + } + let receipt = BillsNativeOutstandingsProbeReceiptV0::from_json(receipt_bytes) + .map_err(|_| error("native_probe_receipt_invalid"))?; + let expected_phrase = native_probe_save_phrase(&receipt, &output)?; + save_receipt_no_replace(&output.output_path, receipt_bytes, typed, &expected_phrase) + .map_err(|failure| error(failure.safe_code())) +} + +impl NativeProbeReceiptOutput { + fn revalidate(&self) -> Result<(), NativeOutstandingsQualificationError> { + if self.output_path.exists() { + return Err(error("receipt_output_exists")); + } + let parent = self.output_path.parent().unwrap_or_else(|| Path::new(".")); + let current_parent = parent + .canonicalize() + .map_err(|_| error("receipt_output_parent_unavailable"))?; + if current_parent != self.canonical_parent + || self + .output_path + .extension() + .and_then(|value| value.to_str()) + != Some("json") + { + return Err(error("receipt_output_changed_after_validation")); + } + Ok(()) + } +} + +fn native_probe_output_binding( + output: &NativeProbeReceiptOutput, +) -> Result { + let file_name = output + .output_path + .file_name() + .and_then(|value| value.to_str()) + .ok_or_else(|| error("receipt_output_invalid"))?; + let parent = output.canonical_parent.to_string_lossy(); + Ok(identity_commitment( + b"bridge.tally.native-ledger-outstandings.receipt-output/0\0", + &[parent.as_bytes(), file_name.as_bytes()], + )) +} + +struct IdentityObservation { + observed_at_unix_ms: i64, + company_identity: String, + party_identity: String, + company_response: String, + party_response: String, +} + +async fn observe_identity( + transport: &ReadOnlyTransport, + fixture: &NativeFixtureManifest, +) -> Result { + let company_response = transport + .send(ReadOnlyProfile::CompanyListV1) + .await + .map_err(|_| error("native_probe_company_preflight_transport_failed"))?; + if !matches!( + export_status(company_response.text()), + Ok(TallyExportStatus::Success) + ) { + return Err(error("native_probe_company_preflight_status_failed")); + } + let companies = parse_companies_with_evidence(company_response.text()) + .map_err(|_| error("native_probe_company_preflight_invalid"))?; + let matches: Vec<_> = companies + .records + .iter() + .filter(|company| company.name == fixture.synthetic_scope.company_marker) + .collect(); + if matches.len() != 1 { + return Err(error("native_probe_company_identity_ambiguous")); + } + let guid = matches[0] + .guid + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| error("native_probe_company_identity_missing"))?; + if companies + .records + .iter() + .filter(|company| company.guid.as_deref() == Some(guid)) + .count() + != 1 + { + return Err(error("native_probe_company_identity_duplicate")); + } + let company_name = ValidatedCompanyName::new(fixture.synthetic_scope.company_marker.clone()) + .map_err(|_| error("native_probe_company_marker_invalid"))?; + let party_response = transport + .send(ReadOnlyProfile::LedgersV1 { + company: &company_name, + }) + .await + .map_err(|_| error("native_probe_party_preflight_transport_failed"))?; + if !matches!( + export_status(party_response.text()), + Ok(TallyExportStatus::Success) + ) { + return Err(error("native_probe_party_preflight_status_failed")); + } + let ledgers = parse_ledger_source_records_with_evidence(party_response.text()) + .map_err(|_| error("native_probe_party_preflight_invalid"))?; + if verify_company_context(&ledgers.evidence, guid).is_err() + || ledgers + .evidence + .company_context + .as_ref() + .and_then(|context| context.name.as_deref()) + != Some(fixture.synthetic_scope.company_marker.as_str()) + { + return Err(error("native_probe_party_context_mismatch")); + } + let matches: Vec<_> = ledgers + .records + .iter() + .filter(|record| record.record.name == fixture.synthetic_scope.party_marker) + .collect(); + if matches.len() != 1 { + return Err(error("native_probe_party_identity_ambiguous")); + } + let source_id = matches[0] + .source_id + .as_deref() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| error("native_probe_party_identity_missing"))?; + let identity_kind = matches[0] + .identity_kind + .ok_or_else(|| error("native_probe_party_identity_missing"))?; + if ledgers + .records + .iter() + .filter(|record| record.source_id.as_deref() == Some(source_id)) + .count() + != 1 + { + return Err(error("native_probe_party_identity_duplicate")); + } + Ok(IdentityObservation { + observed_at_unix_ms: now_unix_ms().map_err(|_| error("native_probe_clock_invalid"))?, + company_identity: identity_commitment(COMPANY_IDENTITY_DOMAIN, &[guid.as_bytes()]), + party_identity: identity_commitment( + PARTY_IDENTITY_DOMAIN, + &[ + identity_kind_label(identity_kind).as_bytes(), + source_id.as_bytes(), + ], + ), + company_response: sha256_hex(company_response.encoded_body()), + party_response: sha256_hex(party_response.encoded_body()), + }) +} + +struct CandidateObservation { + attempt: ProbeCandidateAttemptV0, + encoded_body: Option>, +} + +async fn observe_candidate( + transport: &QualificationOnlyNativeOutstandingsTransport, + candidate: &SealedNativeLedgerOutstandingsProbe, + attempt_id: CandidateAttemptId, +) -> Result { + match transport.send_candidate_v0(candidate).await { + Ok(response) => { + let attempt = observed_attempt(attempt_id, &response)?; + Ok(CandidateObservation { + attempt, + encoded_body: Some(response.encoded_body().to_vec()), + }) + } + Err(failure) => Ok(CandidateObservation { + attempt: failed_attempt(attempt_id, &failure)?, + encoded_body: None, + }), + } +} + +fn observed_attempt( + attempt_id: CandidateAttemptId, + response: &QualificationOnlyNativeOutstandingsResponse, +) -> Result { + let (application_status, safe_reason_code) = match export_status(response.text()) { + Ok(TallyExportStatus::Success) => (ApplicationStatus::Success, None), + Ok(TallyExportStatus::Failure) => ( + ApplicationStatus::Failure, + Some("tally_application_failure".to_string()), + ), + Err(_) => ( + ApplicationStatus::Unrecognized, + Some("tally_application_status_unrecognized".to_string()), + ), + }; + Ok(ProbeCandidateAttemptV0 { + attempt_id, + observed_at_unix_ms: now_unix_ms().map_err(|_| error("native_probe_clock_invalid"))?, + outcome: CandidateAttemptOutcome::ResponseObserved, + http_status: Some(response.http_status()), + application_status, + encoding: encoding(response.encoding()), + exact_encoded_bytes: response.encoded_body().len() as u64, + encoded_body_sha256: Some(sha256_hex(response.encoded_body())), + decoded_text_sha256: Some(sha256_hex(response.text().as_bytes())), + safe_reason_code, + bracket_state: IdentityBracketState::Unchanged, + }) +} + +fn failed_attempt( + attempt_id: CandidateAttemptId, + failure: &ReadOnlyTransportError, +) -> Result { + Ok(ProbeCandidateAttemptV0 { + attempt_id, + observed_at_unix_ms: now_unix_ms().map_err(|_| error("native_probe_clock_invalid"))?, + outcome: if failure.http_status().is_some() { + CandidateAttemptOutcome::HttpRejected + } else { + CandidateAttemptOutcome::TransportFailed + }, + http_status: failure.http_status(), + application_status: ApplicationStatus::NotApplicable, + encoding: bridge_tally_compatibility::TextEncoding::Unknown, + exact_encoded_bytes: 0, + encoded_body_sha256: None, + decoded_text_sha256: None, + safe_reason_code: Some(failure.safe_code().to_string()), + bracket_state: IdentityBracketState::Unchanged, + }) +} + +fn attempt_fact_commitment( + attempt: &ProbeCandidateAttemptV0, +) -> Result { + let bytes = + serde_json::to_vec(attempt).map_err(|_| error("native_probe_attempt_commitment_failed"))?; + Ok(identity_commitment( + b"bridge.tally.native-ledger-outstandings.attempt-fact/0\0", + &[&bytes], + )) +} + +fn snapshot(id: IdentitySnapshotId, value: IdentityObservation) -> ProbeIdentitySnapshotV0 { + ProbeIdentitySnapshotV0 { + snapshot_id: id, + observed_at_unix_ms: value.observed_at_unix_ms, + company_identity_commitment_sha256: value.company_identity, + party_identity_commitment_sha256: value.party_identity, + company_response_sha256: value.company_response, + party_response_sha256: value.party_response, + } +} + +fn require_registered_identity( + config: &NativeProbeConfig, + value: &IdentityObservation, +) -> Result<(), NativeOutstandingsQualificationError> { + if value.company_identity != config.expected_company_identity_sha256 + || value.party_identity != config.expected_party_identity_sha256 + { + return Err(error("native_probe_registered_identity_mismatch")); + } + Ok(()) +} + +fn require_unchanged_identity( + left: &IdentityObservation, + right: &IdentityObservation, +) -> Result<(), NativeOutstandingsQualificationError> { + if left.company_identity != right.company_identity + || left.party_identity != right.party_identity + { + return Err(error("native_probe_identity_drift_observed")); + } + Ok(()) +} + +fn validate_config(config: &NativeProbeConfig) -> Result<(), NativeOutstandingsQualificationError> { + if config.schema_version != CONFIG_SCHEMA_VERSION + || config.port == 0 + || config.product != ProductFamily::TallyPrime + || !valid_release(&config.release) + || config.mode != TallyMode::Education + || config.locale == LocaleProfile::Unknown + || config.configured_tdl_count != Some(0) + || config.configured_add_on_count != Some(0) + || !config.identity_registration_reviewed + || !valid_slug(&config.identity_registration_id) + || config.identity_registration_id == "unregistered" + || !config.no_customer_data_attested + || !valid_nonzero_sha256(&config.expected_company_identity_sha256) + || !valid_nonzero_sha256(&config.expected_party_identity_sha256) + || config.repository_root.as_os_str().is_empty() + || config.fixture_manifest.as_os_str().is_empty() + { + return Err(error("native_probe_config_invalid")); + } + Ok(()) +} + +fn validate_fixture<'a>( + fixture: &'a NativeFixtureManifest, + scenario_id: &str, +) -> Result<&'a FixtureScenario, NativeOutstandingsQualificationError> { + let expected_preflight = ["company_list_v1", "ledgers_v1"]; + let expected_dispatch = [ + "b0_company_list_v1", + "b0_ledgers_v1", + "candidate_1", + "b1_company_list_v1", + "b1_ledgers_v1", + "candidate_2", + "b2_company_list_v1", + "b2_ledgers_v1", + "candidate_3", + "b3_company_list_v1", + "b3_ledgers_v1", + ]; + if fixture.schema_version != FIXTURE_SCHEMA_VERSION || !valid_slug(&fixture.fixture_id) + || fixture.dataset_tier != DatasetTier::SyntheticSmall + || fixture.candidate.profile_id != BILLS_NATIVE_OUTSTANDINGS_PROFILE_ID + || fixture.candidate.observation_posture != "profile_unobserved" + || !fixture.candidate.request_shape_immutable + || !valid_nonzero_sha256(&fixture.candidate.template_sha256) + || !fixture.synthetic_scope.company_marker.starts_with("BRIDGE-PR18-NATIVE-OUTSTANDINGS-COMPANY-") + || !fixture.synthetic_scope.party_marker.starts_with("BRIDGE-PR18-NATIVE-OUTSTANDINGS-PARTY-") + || fixture.synthetic_scope.currency != "INR" + || !fixture.synthetic_scope.bill_by_bill_tracking_required + || !fixture.synthetic_scope.customer_or_personal_data_forbidden + || fixture.synthetic_scope.expected_company_identity_commitment_source != "separate_reviewed_local_registration" + || fixture.synthetic_scope.expected_party_identity_commitment_source != "separate_reviewed_local_registration" + || fixture.fixture_facts.is_empty() || !fixture.one_scenario_per_invocation + || fixture.request_budget.preflight_posts != 2 + || fixture.request_budget.dispatch_identity_posts != 8 + || fixture.request_budget.candidate_dispatch_posts != 3 + || fixture.request_budget.dispatch_posts != 11 + || fixture.request_budget.maximum_total_posts != 13 + || fixture.request_budget.automatic_retries != 0 + || fixture.request_budget.preflight_order.iter().map(String::as_str).ne(expected_preflight) + || fixture.request_budget.dispatch_order.iter().map(String::as_str).ne(expected_dispatch) + || fixture.request_budget.dispatch_request != "four_identity_brackets_and_three_byte_identical_native_ledger_outstandings_candidate_v0_requests" + || !fixture.education_constraints.is_object() || !fixture.ui_observation_contract.is_object() + || !fixture.authority.is_object() + { return Err(error("native_probe_fixture_invalid")); } + let matches: Vec<_> = fixture + .scenarios + .iter() + .filter(|scenario| scenario.scenario_id == scenario_id) + .collect(); + if matches.len() != 1 + || matches[0].expected_accounting_facts.is_empty() + || !valid_nonzero_sha256(&matches[0].request_sha256) + || !valid_nonzero_sha256(&matches[0].scope_sha256) + { + return Err(error("native_probe_scenario_invalid")); + } + Ok(matches[0]) +} + +fn load_ui( + path: &Path, + code: &'static str, +) -> Result { + let bytes = read_bounded(path, MAX_LOCAL_INPUT_BYTES, code).map_err(|_| error(code))?; + serde_json::from_slice(&bytes).map_err(|_| error("native_probe_ui_observation_invalid")) +} + +fn validate_ui( + ui: &UiObservation, + phase: &str, + fixture: &NativeFixtureManifest, + scenario: &FixtureScenario, + now: i64, +) -> Result<(), NativeOutstandingsQualificationError> { + let expected_fields: Vec = EXPECTED_PROJECTION_FIELDS + .iter() + .map(|v| (*v).to_string()) + .collect(); + let phase_attestation = match phase { + "before" => { + ui.operator_attests_no_tally_interaction_until_after_capture == Some(true) + && ui + .operator_attests_no_tally_interaction_since_before_capture + .is_none() + } + "after" => { + ui.operator_attests_no_tally_interaction_since_before_capture == Some(true) + && ui + .operator_attests_no_tally_interaction_until_after_capture + .is_none() + } + _ => false, + }; + let presence_matches_scenario = match scenario.inv_001_ui_presence_requirement { + UiPresenceRequirement::MustBeObservedPresent => { + ui.inv_001_settled_reference_observation == SettledReferenceObservation::Present + } + UiPresenceRequirement::MustRecordObservedPresentOrOmitted => matches!( + ui.inv_001_settled_reference_observation, + SettledReferenceObservation::Present | SettledReferenceObservation::Omitted + ), + }; + if ui.schema_version != UI_SCHEMA_VERSION + || ui.fixture_id != fixture.fixture_id + || ui.scenario_id != scenario.scenario_id + || ui.phase != phase + || !ui.evidence_complete + || ui.captured_unix_ms <= 0 + || ui.captured_unix_ms > now + || !valid_nonzero_sha256(&ui.screenshot_sha256) + || ui.context.company_marker != fixture.synthetic_scope.company_marker + || ui.context.party_marker != fixture.synthetic_scope.party_marker + || ui.context.to_date != scenario.to_date + || ui.context.report_name != "Ledger Outstandings" + || ui.context.report_mode != "detailed" + || !ui.visible_columns.opening + || !ui.visible_columns.pending + || !ui.visible_columns.due + || !ui.visible_columns.overdue + || ui.ordered_projection_fields != expected_fields + || !presence_matches_scenario + || !phase_attestation + { + return Err(error("native_probe_ui_observation_incomplete")); + } + validate_ui_projection(&ui.ordered_projection)?; + Ok(()) +} + +fn validate_ui_projection( + rows: &[UiProjectionRow], +) -> Result<(), NativeOutstandingsQualificationError> { + if rows.is_empty() || rows.len() > UI_PROJECTION_MAX_ROWS { + return Err(error("native_probe_ui_projection_invalid")); + } + for (index, row) in rows.iter().enumerate() { + if row.row_index != index as u32 + || !valid_ui_projection_text(&row.row_kind, false, 64) + || [ + &row.display_date_text, + &row.reference_text, + &row.opening_amount_text, + &row.pending_amount_text, + &row.due_date_text, + &row.overdue_text, + &row.voucher_details_text, + &row.dr_cr_text, + ] + .into_iter() + .any(|value| !valid_ui_projection_text(value, true, UI_PROJECTION_TEXT_MAX_CHARS)) + { + return Err(error("native_probe_ui_projection_invalid")); + } + } + Ok(()) +} + +fn valid_ui_projection_text(value: &str, allow_empty: bool, maximum_chars: usize) -> bool { + (allow_empty || !value.is_empty()) + && value.chars().count() <= maximum_chars + && !value.chars().any(char::is_control) +} + +fn structured_ui_hash(ui: &UiObservation) -> Result { + let value = serde_json::to_vec(&( + &ui.fixture_id, + &ui.scenario_id, + &ui.context, + &ui.visible_columns, + &ui.ordered_projection_fields, + &ui.ordered_projection, + &ui.inv_001_settled_reference_observation, + )) + .map_err(|_| error("native_probe_ui_observation_invalid"))?; + Ok(sha256_hex(&value)) +} + +fn receipt_ui( + ui: &UiObservation, + position: UiObservationPosition, + config: &NativeProbeConfig, + preflight: &ProbeInitialPreflightV0, + structured_hash: &str, +) -> ProbeUiObservationV0 { + ProbeUiObservationV0 { + position, + observed_at_unix_ms: ui.captured_unix_ms, + product: config.product, + release: config.release.clone(), + mode: config.mode, + report_id: BILLS_NATIVE_OUTSTANDINGS_REPORT_ID.to_string(), + opening_column_visible: ui.visible_columns.opening, + pending_column_visible: ui.visible_columns.pending, + due_column_visible: ui.visible_columns.due, + overdue_column_visible: ui.visible_columns.overdue, + company_identity_commitment_sha256: preflight.company_identity_commitment_sha256.clone(), + party_identity_commitment_sha256: preflight.party_identity_commitment_sha256.clone(), + structured_observation_sha256: structured_hash.to_string(), + screenshot_sha256: ui.screenshot_sha256.clone(), + } +} + +fn consent_binding( + stage: &str, + config: &NativeProbeConfig, + fixture_id: &str, + request_hash: &str, + prior_hash: &str, + expires_at: i64, +) -> Result { + let mut nonce = [0_u8; 32]; + getrandom::fill(&mut nonce).map_err(|_| error("native_probe_random_unavailable"))?; + let mut digest = Sha256::new(); + digest.update(CHALLENGE_DOMAIN); + for field in [ + stage.as_bytes(), + fixture_id.as_bytes(), + request_hash.as_bytes(), + prior_hash.as_bytes(), + config.expected_company_identity_sha256.as_bytes(), + config.expected_party_identity_sha256.as_bytes(), + ] { + hash_field(&mut digest, field); + } + digest.update(config.port.to_be_bytes()); + digest.update(expires_at.to_be_bytes()); + digest.update(nonce); + Ok(hex_lower(&digest.finalize())) +} + +fn qualification_run_commitment( + config: &NativeProbeConfig, + fixture: &NativeFixtureManifest, + scenario: &FixtureScenario, + candidate: &SealedNativeLedgerOutstandingsProbe, + metadata: &ProbeMetadata, + ui_before: &UiObservation, +) -> Result { + let config_commitment = + serde_json::to_vec(config).map_err(|_| error("native_probe_config_commitment_failed"))?; + let ui_commitment = structured_ui_hash(ui_before)?; + let template_commitment = candidate.template_sha256(); + Ok(private_commitment_bytes( + "reviewed-run", + &[ + &config_commitment, + fixture.fixture_id.as_bytes(), + scenario.scenario_id.as_bytes(), + template_commitment.as_bytes(), + candidate.request_sha256().as_bytes(), + candidate.scope_sha256().as_bytes(), + metadata.bridge_commit_sha.as_bytes(), + metadata.compatibility_surface_sha256.as_bytes(), + metadata.executable_sha256.as_bytes(), + metadata.cargo_lock_sha256.as_bytes(), + metadata.fixture_manifest_sha256.as_bytes(), + ui_commitment.as_bytes(), + ui_before.screenshot_sha256.as_bytes(), + ], + )) +} + +fn private_commitment(stage: &str, fields: &[&str]) -> String { + let fields: Vec<&[u8]> = fields.iter().map(|field| field.as_bytes()).collect(); + private_commitment_bytes(stage, &fields) +} + +fn private_commitment_bytes(stage: &str, fields: &[&[u8]]) -> String { + let mut digest = Sha256::new(); + digest.update(b"bridge.tally.native-ledger-outstandings.private-commitment/0\0"); + hash_field(&mut digest, stage.as_bytes()); + for field in fields { + hash_field(&mut digest, field); + } + hex_lower(&digest.finalize()) +} + +fn identity_commitment(domain: &[u8], fields: &[&[u8]]) -> String { + let mut digest = Sha256::new(); + digest.update(domain); + for field in fields { + hash_field(&mut digest, field); + } + hex_lower(&digest.finalize()) +} + +fn hash_field(digest: &mut Sha256, value: &[u8]) { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); +} + +fn hex_lower(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use std::fmt::Write as _; + write!(&mut output, "{byte:02x}").expect("string write"); + } + output +} + +fn identity_kind_label(kind: ParsedSourceIdentityKind) -> &'static str { + match kind { + ParsedSourceIdentityKind::Guid => "guid", + ParsedSourceIdentityKind::RemoteId => "remote_id", + ParsedSourceIdentityKind::MasterId => "master_id", + } +} + +fn require_typed(typed: &str, expected: &str) -> Result<(), NativeOutstandingsQualificationError> { + if typed.trim_end_matches(['\r', '\n']) != expected { + return Err(error("native_probe_consent_mismatch")); + } + Ok(()) +} + +fn ensure_unexpired(expires_at: i64) -> Result<(), NativeOutstandingsQualificationError> { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| error("native_probe_clock_invalid"))? + .as_millis(); + let now = i64::try_from(now).map_err(|_| error("native_probe_clock_invalid"))?; + if now > expires_at { + return Err(error("native_probe_consent_expired")); + } + Ok(()) +} + +fn verify_consumed_consent( + binding: &str, + expires_at: i64, + expected_binding: &str, + expected_expiry: i64, +) -> Result<(), NativeOutstandingsQualificationError> { + ensure_unexpired(expires_at)?; + if binding != expected_binding || expires_at != expected_expiry { + return Err(error("native_probe_consent_binding_mismatch")); + } + Ok(()) +} + +fn valid_nonzero_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) + && value.bytes().any(|byte| byte != b'0') +} + +fn canonical_origin(family: LoopbackFamily, port: u16) -> String { + match family { + LoopbackFamily::Ipv6 => format!("http://[::1]:{port}"), + LoopbackFamily::Ipv4 | LoopbackFamily::LocalhostAlias => format!("http://127.0.0.1:{port}"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tally_protocol_simulator::{Fixture, ScenarioPlan, SequenceSimulator}; + + const COMPANY: &str = + "BRIDGE-PR18-NATIVE-OUTSTANDINGS-COMPANY-019f605f-e6cf-77b2-ac95-31722887a911"; + const PARTY: &str = + "BRIDGE-PR18-NATIVE-OUTSTANDINGS-PARTY-019f605f-e6cf-77b2-ac95-31722887a911"; + const COMPANY_GUID: &str = "00000000-0000-4000-8000-000000000001"; + const PARTY_GUID: &str = "00000000-0000-4000-8000-000000000101"; + + fn company_xml() -> String { + format!( + "
1
{COMPANY}{COMPANY_GUID}
" + ) + } + + fn ledger_xml() -> String { + format!( + "
11
bridge.tally.ledgers/1LEDGER{COMPANY}{COMPANY_GUID}1Sundry Debtors
" + ) + } + + fn candidate() -> SealedNativeLedgerOutstandingsProbe { + NativeLedgerOutstandingsProbeScope::new( + ValidatedProbeCompanyName::new(COMPANY).unwrap(), + ValidatedProbeLedgerName::new(PARTY).unwrap(), + ValidatedProbeToDate::new("20260731").unwrap(), + ) + .seal() + } + + fn fixture(candidate: &SealedNativeLedgerOutstandingsProbe) -> NativeFixtureManifest { + NativeFixtureManifest { + schema_version: 1, + fixture_id: "education-native-outstandings-v0".to_string(), + dataset_tier: DatasetTier::SyntheticSmall, + candidate: FixtureCandidate { + profile_id: BILLS_NATIVE_OUTSTANDINGS_PROFILE_ID.to_string(), + template_sha256: candidate.template_sha256(), + observation_posture: "profile_unobserved".to_string(), + request_shape_immutable: true, + }, + synthetic_scope: FixtureScope { + company_marker: COMPANY.to_string(), + party_marker: PARTY.to_string(), + currency: "INR".to_string(), + bill_by_bill_tracking_required: true, + customer_or_personal_data_forbidden: true, + expected_company_identity_commitment_source: + "separate_reviewed_local_registration".to_string(), + expected_party_identity_commitment_source: + "separate_reviewed_local_registration".to_string(), + }, + education_constraints: serde_json::json!({}), + fixture_facts: vec![serde_json::json!({"synthetic": true})], + scenarios: vec![FixtureScenario { + scenario_id: "education-to-date-20260731".to_string(), + to_date: "20260731".to_string(), + request_sha256: candidate.request_sha256().to_string(), + scope_sha256: candidate.scope_sha256().to_string(), + expected_accounting_facts: vec!["synthetic fact".to_string()], + inv_001_ui_presence_requirement: + UiPresenceRequirement::MustRecordObservedPresentOrOmitted, + }], + one_scenario_per_invocation: true, + request_budget: FixtureBudget { + preflight_posts: 2, + dispatch_identity_posts: 8, + candidate_dispatch_posts: 3, + dispatch_posts: 11, + maximum_total_posts: 13, + automatic_retries: 0, + preflight_order: vec!["company_list_v1".into(), "ledgers_v1".into()], + dispatch_order: [ + "b0_company_list_v1", + "b0_ledgers_v1", + "candidate_1", + "b1_company_list_v1", + "b1_ledgers_v1", + "candidate_2", + "b2_company_list_v1", + "b2_ledgers_v1", + "candidate_3", + "b3_company_list_v1", + "b3_ledgers_v1", + ] + .into_iter() + .map(str::to_string) + .collect(), + dispatch_request: "four_identity_brackets_and_three_byte_identical_native_ledger_outstandings_candidate_v0_requests".into(), + }, + ui_observation_contract: serde_json::json!({}), + authority: serde_json::json!({}), + } + } + + fn config(port: u16) -> NativeProbeConfig { + NativeProbeConfig { + schema_version: 1, + repository_root: PathBuf::from("."), + fixture_manifest: PathBuf::from("fixture.json"), + scenario_id: "education-to-date-20260731".to_string(), + endpoint_family: LoopbackFamily::Ipv4, + port, + product: ProductFamily::TallyPrime, + release: "1.1.7.0".to_string(), + mode: TallyMode::Education, + locale: LocaleProfile::EnglishIndia, + configured_tdl_count: Some(0), + configured_add_on_count: Some(0), + expected_company_identity_sha256: identity_commitment( + COMPANY_IDENTITY_DOMAIN, + &[COMPANY_GUID.as_bytes()], + ), + expected_party_identity_sha256: identity_commitment( + PARTY_IDENTITY_DOMAIN, + &[b"guid", PARTY_GUID.as_bytes()], + ), + identity_registration_id: "reviewed-synthetic-registration".to_string(), + identity_registration_reviewed: true, + no_customer_data_attested: true, + ui_before_observation: PathBuf::from("before.json"), + ui_after_observation: PathBuf::from("after.json"), + } + } + + fn loaded(port: u16) -> LoadedNativeOutstandingsProbe { + let candidate = candidate(); + let fixture = fixture(&candidate); + let scenario = fixture.scenarios[0].clone(); + let expires = now_unix_ms().unwrap() + CONSENT_TTL_MS; + LoadedNativeOutstandingsProbe { + config: config(port), + fixture, + scenario, + candidate, + metadata: ProbeMetadata { + bridge_commit_sha: "a".repeat(40), + working_tree_dirty: true, + compatibility_surface_sha256: "b".repeat(64), + executable_sha256: "c".repeat(64), + cargo_lock_sha256: "d".repeat(64), + fixture_manifest_sha256: "e".repeat(64), + }, + repository_root: PathBuf::from("."), + ui_before: UiObservation { + schema_version: 1, + fixture_id: "education-native-outstandings-v0".into(), + scenario_id: "education-to-date-20260731".into(), + phase: "before".into(), + evidence_complete: true, + captured_unix_ms: now_unix_ms().unwrap(), + screenshot_sha256: "f".repeat(64), + context: UiContext { + company_marker: COMPANY.into(), + party_marker: PARTY.into(), + to_date: "20260731".into(), + report_name: "Ledger Outstandings".into(), + report_mode: "detailed".into(), + }, + visible_columns: UiVisibleColumns { + opening: true, + pending: true, + due: true, + overdue: true, + }, + ordered_projection_fields: EXPECTED_PROJECTION_FIELDS + .into_iter() + .map(str::to_string) + .collect(), + ordered_projection: vec![UiProjectionRow { + row_index: 0, + row_kind: "opening".into(), + display_date_text: String::new(), + reference_text: "OPEN-001".into(), + opening_amount_text: "250.00".into(), + pending_amount_text: "250.00".into(), + due_date_text: String::new(), + overdue_text: String::new(), + voucher_details_text: String::new(), + dr_cr_text: "Dr".into(), + }], + inv_001_settled_reference_observation: SettledReferenceObservation::Present, + operator_attests_no_tally_interaction_until_after_capture: Some(true), + operator_attests_no_tally_interaction_since_before_capture: None, + }, + ui_after_path: PathBuf::from("after.json"), + run_commitment: "1".repeat(64), + preflight_challenge: "PREFLIGHT test".into(), + preflight_binding: "binding".into(), + preflight_expires_at: expires, + } + } + + async fn run_exact_sequence_once() -> Result<(), &'static str> { + let mut plans = vec![ + ScenarioPlan::new(Fixture::SyntheticXml(company_xml())), + ScenarioPlan::new(Fixture::SyntheticXml(ledger_xml())), + ]; + for bracket in 0..4 { + plans.push(ScenarioPlan::new(Fixture::SyntheticXml(company_xml()))); + plans.push(ScenarioPlan::new(Fixture::SyntheticXml(ledger_xml()))); + if bracket < 3 { + plans.push(ScenarioPlan::new(Fixture::ExportStatusOne)); + } + } + assert_eq!(plans.len(), 13); + let simulator = SequenceSimulator::spawn(plans).unwrap(); + let loaded = loaded(simulator.address().port()); + let preflight = confirm_preflight_challenge(&loaded, "PREFLIGHT test").unwrap(); + let ready = match loaded.run_preflight(preflight).await { + Ok(ready) => ready, + Err(failure) => { + simulator.cancel(); + let _ = simulator.finish(); + return Err(failure.safe_code()); + } + }; + let dispatch_phrase = ready.dispatch_challenge().to_string(); + let dispatch = confirm_dispatch_challenge(&ready, &dispatch_phrase).unwrap(); + let pending = match ready.dispatch(dispatch).await { + Ok(pending) => pending, + Err(failure) => { + simulator.cancel(); + let _ = simulator.finish(); + return Err(failure.safe_code()); + } + }; + assert_eq!(pending.attempts.len(), 3); + assert!(pending + .attempts + .iter() + .all(|attempt| attempt.application_status == ApplicationStatus::Success)); + + let observed = simulator.finish().unwrap(); + let candidate_hash = candidate().request_sha256().to_string(); + if observed.len() != 13 + || !observed.iter().all(|request| request.request_processed) + || [4_usize, 7, 10] + .into_iter() + .any(|index| observed[index].request_body_sha256 != candidate_hash) + { + return Err("simulator_request_observation_incomplete"); + } + Ok(()) + } + + #[tokio::test] + async fn exact_thirteen_request_sequence_is_bracketed_and_zero_retry() { + // This retries only a disposable Windows socket harness. Each runner + // instance still has a fixed 13-request budget and zero request retry. + let mut last_failure = "simulator_not_started"; + for _ in 0..5 { + match run_exact_sequence_once().await { + Ok(()) => return, + Err(failure) => last_failure = failure, + } + } + panic!("sequence simulator remained unstable: {last_failure}"); + } + + async fn run_candidate_failure_sequence_once() -> Result<(), &'static str> { + let mut plans = vec![ + ScenarioPlan::new(Fixture::SyntheticXml(company_xml())), + ScenarioPlan::new(Fixture::SyntheticXml(ledger_xml())), + ]; + let candidates = [ + ScenarioPlan::new(Fixture::ExportStatusZero), + ScenarioPlan::new(Fixture::ExportStatusOne).with_http_status(503), + ScenarioPlan::new(Fixture::ExportStatusMissing), + ]; + for candidate_plan in candidates { + plans.push(ScenarioPlan::new(Fixture::SyntheticXml(company_xml()))); + plans.push(ScenarioPlan::new(Fixture::SyntheticXml(ledger_xml()))); + plans.push(candidate_plan); + } + plans.push(ScenarioPlan::new(Fixture::SyntheticXml(company_xml()))); + plans.push(ScenarioPlan::new(Fixture::SyntheticXml(ledger_xml()))); + let simulator = SequenceSimulator::spawn(plans).unwrap(); + let loaded = loaded(simulator.address().port()); + let preflight = confirm_preflight_challenge(&loaded, "PREFLIGHT test").unwrap(); + let ready = match loaded.run_preflight(preflight).await { + Ok(ready) => ready, + Err(failure) => { + simulator.cancel(); + let _ = simulator.finish(); + return Err(failure.safe_code()); + } + }; + let dispatch_phrase = ready.dispatch_challenge().to_string(); + let dispatch = confirm_dispatch_challenge(&ready, &dispatch_phrase).unwrap(); + let pending = match ready.dispatch(dispatch).await { + Ok(pending) => pending, + Err(failure) => { + simulator.cancel(); + let _ = simulator.finish(); + return Err(failure.safe_code()); + } + }; + assert_eq!(pending.snapshots.len(), 4); + assert_eq!( + pending.byte_repeatability, + ByteRepeatability::NotEstablished + ); + assert_eq!( + pending.attempts[0].outcome, + CandidateAttemptOutcome::ResponseObserved + ); + assert_eq!( + pending.attempts[0].application_status, + ApplicationStatus::Failure + ); + assert_eq!( + pending.attempts[0].safe_reason_code.as_deref(), + Some("tally_application_failure") + ); + assert_eq!( + pending.attempts[1].outcome, + CandidateAttemptOutcome::HttpRejected + ); + assert_eq!(pending.attempts[1].http_status, Some(503)); + assert_eq!( + pending.attempts[1].safe_reason_code.as_deref(), + Some("http_status_failure") + ); + assert_eq!( + pending.attempts[2].application_status, + ApplicationStatus::Unrecognized + ); + assert_eq!( + pending.attempts[2].safe_reason_code.as_deref(), + Some("tally_application_status_unrecognized") + ); + + let observed = simulator.finish().unwrap(); + if observed.len() != 13 + || !observed.iter().all(|request| request.request_processed) + || [4_usize, 7, 10] + .into_iter() + .any(|index| observed[index].request_body_sha256 != candidate().request_sha256()) + { + return Err("simulator_failure_sequence_incomplete"); + } + Ok(()) + } + + #[tokio::test] + async fn candidate_failures_are_receipted_and_each_gets_a_trailing_identity_bracket() { + let mut last_failure = "simulator_not_started"; + for _ in 0..5 { + match run_candidate_failure_sequence_once().await { + Ok(()) => return, + Err(failure) => last_failure = failure, + } + } + panic!("failure sequence simulator remained unstable: {last_failure}"); + } + + #[test] + fn customer_data_and_placeholder_identity_configs_fail_closed() { + let mut value = config(9000); + value.no_customer_data_attested = false; + assert_eq!( + validate_config(&value).unwrap_err().safe_code(), + "native_probe_config_invalid" + ); + value.no_customer_data_attested = true; + value.expected_party_identity_sha256 = "0".repeat(64); + assert_eq!( + validate_config(&value).unwrap_err().safe_code(), + "native_probe_config_invalid" + ); + } + + #[test] + fn consent_tokens_are_distinct_consumed_types_and_expire() { + let loaded = loaded(9000); + assert!(confirm_preflight_challenge(&loaded, "wrong").is_err()); + assert!(verify_consumed_consent("x", 1, "x", 1).is_err()); + assert_ne!( + std::any::type_name::(), + std::any::type_name::() + ); + assert_ne!( + std::any::type_name::(), + std::any::type_name::() + ); + } + + #[test] + fn ui_rows_and_settlement_presence_are_typed_bounded_and_scenario_specific() { + let run = loaded(9000); + let now = now_unix_ms().unwrap() + 1; + validate_ui(&run.ui_before, "before", &run.fixture, &run.scenario, now).unwrap(); + + let mut non_contiguous = run.ui_before.clone(); + non_contiguous.ordered_projection[0].row_index = 1; + assert_eq!( + validate_ui(&non_contiguous, "before", &run.fixture, &run.scenario, now,) + .unwrap_err() + .safe_code(), + "native_probe_ui_projection_invalid" + ); + + let mut present_required = run.scenario.clone(); + present_required.inv_001_ui_presence_requirement = + UiPresenceRequirement::MustBeObservedPresent; + let mut omitted = run.ui_before.clone(); + omitted.inv_001_settled_reference_observation = SettledReferenceObservation::Omitted; + assert!(validate_ui(&omitted, "before", &run.fixture, &present_required, now,).is_err()); + + let mut unknown_row_field = serde_json::to_value(&run.ui_before).unwrap(); + unknown_row_field["ordered_projection"][0]["invented"] = Value::Bool(true); + assert!(serde_json::from_value::(unknown_row_field).is_err()); + + let mut invented_settlement = serde_json::to_value(&run.ui_before).unwrap(); + invented_settlement["inv_001_settled_reference_observation"] = + Value::String("looks_settled".into()); + assert!(serde_json::from_value::(invented_settlement).is_err()); + } + + #[test] + fn native_receipt_output_target_is_repository_confined_and_path_bound() { + let directory = std::env::temp_dir().join(format!( + "bridge-native-probe-output-test-{}-{}", + std::process::id(), + now_unix_ms().unwrap() + )); + let local = directory.join(".bridge-live"); + std::fs::create_dir_all(&local).unwrap(); + let mut run = loaded(9000); + run.repository_root = directory.clone(); + let first = run + .validate_receipt_output(&local.join("first.json")) + .unwrap(); + let second = run + .validate_receipt_output(&local.join("second.json")) + .unwrap(); + assert_ne!( + native_probe_output_binding(&first).unwrap(), + native_probe_output_binding(&second).unwrap() + ); + assert!(run + .validate_receipt_output(&directory.join("outside.json")) + .is_err()); + assert!(run + .validate_receipt_output(&local.join("receipt.txt")) + .is_err()); + first.revalidate().unwrap(); + std::fs::remove_dir(local).unwrap(); + std::fs::remove_dir(directory).unwrap(); + } +} diff --git a/src-tauri/crates/bridge-tally-observability/Cargo.toml b/src-tauri/crates/bridge-tally-observability/Cargo.toml new file mode 100644 index 0000000..a7d84af --- /dev/null +++ b/src-tauri/crates/bridge-tally-observability/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "bridge-tally-observability" +version = "0.1.0" +description = "Portable bounded and privacy-reduced Tally telemetry contract for Bridge" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" diff --git a/src-tauri/crates/bridge-tally-observability/src/lib.rs b/src-tauri/crates/bridge-tally-observability/src/lib.rs new file mode 100644 index 0000000..1b38019 --- /dev/null +++ b/src-tauri/crates/bridge-tally-observability/src/lib.rs @@ -0,0 +1,851 @@ +//! Fixed-cardinality, local-only Tally transport observations. +//! +//! This crate deliberately has no runtime, HTTP, database, logging, tracing, +//! persistence, system-metrics, or exporter dependency. Its preview is a +//! privacy-reduced operational aid, not Proof of Sync or performance support. + +use std::{fmt, sync::Mutex, time::Duration}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +pub const PREVIEW_SCHEMA: &str = "bridge.tally.telemetry-preview/2"; +pub const MAX_SERIALIZED_PREVIEW_BYTES: usize = 64 * 1024; +pub const LATENCY_UPPER_BOUNDS_MICROS: [u64; 8] = [ + 1_000, 5_000, 25_000, 100_000, 500_000, 2_000_000, 10_000_000, 30_000_000, +]; +pub const RESPONSE_BYTE_UPPER_BOUNDS: [u64; 6] = [ + 0, + 1_024, + 64 * 1_024, + 1_024 * 1_024, + 8 * 1_024 * 1_024, + 32 * 1_024 * 1_024, +]; + +const LATENCY_BUCKETS: usize = LATENCY_UPPER_BOUNDS_MICROS.len() + 1; +const BYTE_BUCKETS: usize = RESPONSE_BYTE_UPPER_BOUNDS.len() + 1; +const QUEUE_OUTCOMES: usize = QueueOutcome::ALL.len(); +const RESPONSE_OUTCOMES: usize = ResponseOutcome::ALL.len(); +const CIRCUIT_REJECT_REASONS: usize = CircuitRejectReason::ALL.len(); +const REQUEST_CLASSES: usize = RequestClass::ALL.len(); +const QUEUE_CELLS: usize = REQUEST_CLASSES * QUEUE_OUTCOMES * LATENCY_BUCKETS; +const RESPONSE_LATENCY_CELLS: usize = REQUEST_CLASSES * RESPONSE_OUTCOMES * LATENCY_BUCKETS; +const RESPONSE_BYTE_CELLS: usize = REQUEST_CLASSES * RESPONSE_OUTCOMES * BYTE_BUCKETS; +const RESPONSE_BYTE_UNAVAILABLE_CELLS: usize = REQUEST_CLASSES * RESPONSE_OUTCOMES; +const CIRCUIT_REJECTION_CELLS: usize = REQUEST_CLASSES * CIRCUIT_REJECT_REASONS; +pub const FIXED_HISTOGRAM_CELL_COUNT: usize = QUEUE_CELLS + + RESPONSE_LATENCY_CELLS + + RESPONSE_BYTE_CELLS + + RESPONSE_BYTE_UNAVAILABLE_CELLS + + CIRCUIT_REJECTION_CELLS; +const EXPORT_HASH_DOMAIN: &[u8] = b"bridge.tally.telemetry-preview-payload/2\0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RequestClass { + Status, + Capability, + CompanyList, + MasterExport, + VoucherExport, + ReportExport, + Import, + OtherRead, +} + +impl RequestClass { + pub const ALL: [Self; 8] = [ + Self::Status, + Self::Capability, + Self::CompanyList, + Self::MasterExport, + Self::VoucherExport, + Self::ReportExport, + Self::Import, + Self::OtherRead, + ]; + + const fn index(self) -> usize { + self as usize + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum QueueOutcome { + Acquired, + Deadline, + Cancelled, +} + +impl QueueOutcome { + pub const ALL: [Self; 3] = [Self::Acquired, Self::Deadline, Self::Cancelled]; + + const fn index(self) -> usize { + self as usize + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ResponseOutcome { + Success, + Cancelled, + Timeout, + Transport, + HttpStatus, + SizeLimit, + Decode, + Application, + Parse, + Validation, +} + +impl ResponseOutcome { + pub const ALL: [Self; 10] = [ + Self::Success, + Self::Cancelled, + Self::Timeout, + Self::Transport, + Self::HttpStatus, + Self::SizeLimit, + Self::Decode, + Self::Application, + Self::Parse, + Self::Validation, + ]; + + const fn index(self) -> usize { + self as usize + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CircuitRejectReason { + Cooldown, + HalfOpenProbeInFlight, +} + +impl CircuitRejectReason { + pub const ALL: [Self; 2] = [Self::Cooldown, Self::HalfOpenProbeInFlight]; + + const fn index(self) -> usize { + self as usize + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyBytesObservation { + Observed(u64), + Unavailable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AttemptObservation { + CircuitRejected { + class: RequestClass, + reason: CircuitRejectReason, + }, + QueueDeadline { + class: RequestClass, + queue_wait: Duration, + }, + QueueCancelled { + class: RequestClass, + queue_wait: Duration, + }, + Response { + class: RequestClass, + queue_wait: Duration, + outcome: ResponseOutcome, + /// Custom Bridge pipeline duration from send start through bounded + /// body read and decode completion. This is not an OTel HTTP duration. + response_pipeline_elapsed: Duration, + /// Bytes consumed before the terminal outcome, including partial + /// failed bodies. This is not an OTel HTTP response-body-size metric. + observed_body_bytes: BodyBytesObservation, + }, +} + +/// A sink accepts one caller-supplied terminal attempt and no dynamic labels +/// or text. It aggregates observations; it does not authenticate provenance or +/// detect duplicate calls. +pub trait ObservationSink { + fn record_attempt(&self, observation: AttemptObservation); +} + +#[derive(Clone)] +struct AggregateState { + queue_latency: [u64; QUEUE_CELLS], + response_latency: [u64; RESPONSE_LATENCY_CELLS], + response_bytes: [u64; RESPONSE_BYTE_CELLS], + response_bytes_unavailable: [u64; RESPONSE_BYTE_UNAVAILABLE_CELLS], + circuit_rejections: [u64; CIRCUIT_REJECTION_CELLS], + saturated_cell_increments: u64, +} + +impl Default for AggregateState { + fn default() -> Self { + Self { + queue_latency: [0; QUEUE_CELLS], + response_latency: [0; RESPONSE_LATENCY_CELLS], + response_bytes: [0; RESPONSE_BYTE_CELLS], + response_bytes_unavailable: [0; RESPONSE_BYTE_UNAVAILABLE_CELLS], + circuit_rejections: [0; CIRCUIT_REJECTION_CELLS], + saturated_cell_increments: 0, + } + } +} + +/// Fixed-memory coherent aggregation. Poison recovery retains observations; +/// measurement failure never changes a Tally operation result. +#[derive(Default)] +pub struct TelemetryCollector { + state: Mutex, +} + +impl fmt::Debug for TelemetryCollector { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("TelemetryCollector") + .field("fixed_histogram_cell_count", &FIXED_HISTOGRAM_CELL_COUNT) + .finish_non_exhaustive() + } +} + +impl TelemetryCollector { + pub fn new() -> Self { + Self::default() + } + + pub fn preview_v2(&self) -> TallyTelemetryPreviewV2 { + let state = self.snapshot(); + let rows = RequestClass::ALL.map(|class| build_row(&state, class)); + TallyTelemetryPreviewV2 { + schema: PREVIEW_SCHEMA, + schema_version: 2, + privacy_profile: "fixed_dimensions_bucketed_values_v1", + collection_scope: "unstamped_collector_instance_lifetime", + snapshot_consistency: "coherent_mutex_snapshot", + observation_provenance: "caller_supplied_not_authenticated", + collection_completeness: "not_established", + lifecycle_consistency: "one_terminal_observation_per_attempt_duplicates_not_detected", + standards_mapping: "custom_lossy_summary_not_an_opentelemetry_histogram", + integrity_claim: "checksum_only", + authenticity_claim: "none", + collector_has_network_exporter: false, + establishes_performance_support: false, + rows_are_taxonomy_not_capability: true, + fixed_histogram_cell_count: FIXED_HISTOGRAM_CELL_COUNT as u16, + latency_upper_bounds_micros: LATENCY_UPPER_BOUNDS_MICROS, + response_byte_upper_bounds: RESPONSE_BYTE_UPPER_BOUNDS, + saturated_cell_increments: count_bucket(state.saturated_cell_increments), + rows, + } + } + + pub fn privacy_reduced_export_v2( + &self, + ) -> Result { + let preview = self.preview_v2(); + let json = + serde_json::to_string(&preview).map_err(|_| TelemetryExportError::Serialization)?; + if json.len() > MAX_SERIALIZED_PREVIEW_BYTES { + return Err(TelemetryExportError::PreviewTooLarge); + } + let payload_sha256 = hash_payload(json.as_bytes()); + Ok(PrivacyReducedTelemetryExport { + json, + payload_sha256, + }) + } + + fn snapshot(&self) -> AggregateState { + self.state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone() + } + + fn increment(state: &mut AggregateState, cell: &mut u64) { + if *cell == u64::MAX { + state.saturated_cell_increments = state.saturated_cell_increments.saturating_add(1); + } else { + *cell += 1; + } + } +} + +impl ObservationSink for TelemetryCollector { + fn record_attempt(&self, observation: AttemptObservation) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + match observation { + AttemptObservation::CircuitRejected { class, reason } => { + let index = class.index() * CIRCUIT_REJECT_REASONS + reason.index(); + let mut cell = state.circuit_rejections[index]; + Self::increment(&mut state, &mut cell); + state.circuit_rejections[index] = cell; + } + AttemptObservation::QueueDeadline { class, queue_wait } => { + increment_queue(&mut state, class, QueueOutcome::Deadline, queue_wait); + } + AttemptObservation::QueueCancelled { class, queue_wait } => { + increment_queue(&mut state, class, QueueOutcome::Cancelled, queue_wait); + } + AttemptObservation::Response { + class, + queue_wait, + outcome, + response_pipeline_elapsed, + observed_body_bytes, + } => { + increment_queue(&mut state, class, QueueOutcome::Acquired, queue_wait); + let latency = latency_bucket(response_pipeline_elapsed); + let series = class.index() * RESPONSE_OUTCOMES + outcome.index(); + let latency_index = series * LATENCY_BUCKETS + latency; + let mut latency_cell = state.response_latency[latency_index]; + Self::increment(&mut state, &mut latency_cell); + state.response_latency[latency_index] = latency_cell; + match observed_body_bytes { + BodyBytesObservation::Observed(bytes) => { + let byte_index = series * BYTE_BUCKETS + byte_bucket(bytes); + let mut byte_cell = state.response_bytes[byte_index]; + Self::increment(&mut state, &mut byte_cell); + state.response_bytes[byte_index] = byte_cell; + } + BodyBytesObservation::Unavailable => { + let mut cell = state.response_bytes_unavailable[series]; + Self::increment(&mut state, &mut cell); + state.response_bytes_unavailable[series] = cell; + } + } + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CountBucket { + Zero, + One, + TwoToFive, + SixToTwenty, + TwentyOneToHundred, + HundredOneToThousand, + OverThousand, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TallyTelemetryPreviewV2 { + schema: &'static str, + schema_version: u16, + privacy_profile: &'static str, + collection_scope: &'static str, + snapshot_consistency: &'static str, + observation_provenance: &'static str, + collection_completeness: &'static str, + lifecycle_consistency: &'static str, + standards_mapping: &'static str, + integrity_claim: &'static str, + authenticity_claim: &'static str, + collector_has_network_exporter: bool, + establishes_performance_support: bool, + rows_are_taxonomy_not_capability: bool, + fixed_histogram_cell_count: u16, + latency_upper_bounds_micros: [u64; 8], + response_byte_upper_bounds: [u64; 6], + saturated_cell_increments: CountBucket, + rows: [OperationTelemetryRow; 8], +} + +impl TallyTelemetryPreviewV2 { + pub fn rows(&self) -> &[OperationTelemetryRow; 8] { + &self.rows + } + + pub const fn establishes_performance_support(&self) -> bool { + self.establishes_performance_support + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct OperationTelemetryRow { + request_class: RequestClass, + circuit_rejections: [CircuitTelemetryRow; 2], + queue: [QueueTelemetryRow; 3], + response: [ResponseTelemetryRow; 10], +} + +impl OperationTelemetryRow { + pub const fn request_class(&self) -> RequestClass { + self.request_class + } +} + +#[derive(Debug, Clone, Serialize)] +struct QueueTelemetryRow { + outcome: QueueOutcome, + latency_buckets: [CountBucket; LATENCY_BUCKETS], +} + +#[derive(Debug, Clone, Serialize)] +struct ResponseTelemetryRow { + outcome: ResponseOutcome, + latency_buckets: [CountBucket; LATENCY_BUCKETS], + bytes_received_buckets: [CountBucket; BYTE_BUCKETS], + bytes_measurement_unavailable: CountBucket, +} + +#[derive(Debug, Clone, Serialize)] +struct CircuitTelemetryRow { + reason: CircuitRejectReason, + count: CountBucket, +} + +#[derive(Clone)] +pub struct PrivacyReducedTelemetryExport { + json: String, + payload_sha256: String, +} + +impl fmt::Debug for PrivacyReducedTelemetryExport { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PrivacyReducedTelemetryExport") + .field("json_bytes", &self.json.len()) + .field("payload_sha256", &self.payload_sha256) + .finish() + } +} + +impl PrivacyReducedTelemetryExport { + pub fn json(&self) -> &str { + &self.json + } + + pub fn payload_sha256(&self) -> &str { + &self.payload_sha256 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TelemetryExportError { + Serialization, + PreviewTooLarge, +} + +impl TelemetryExportError { + pub const fn safe_code(self) -> &'static str { + match self { + Self::Serialization => "tally_telemetry_serialization_failed", + Self::PreviewTooLarge => "tally_telemetry_preview_too_large", + } + } +} + +impl fmt::Display for TelemetryExportError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.safe_code()) + } +} + +impl std::error::Error for TelemetryExportError {} + +fn build_row(state: &AggregateState, class: RequestClass) -> OperationTelemetryRow { + let circuit_rejections = CircuitRejectReason::ALL.map(|reason| { + let index = class.index() * CIRCUIT_REJECT_REASONS + reason.index(); + CircuitTelemetryRow { + reason, + count: count_bucket(state.circuit_rejections[index]), + } + }); + let queue = QueueOutcome::ALL.map(|outcome| { + let base = (class.index() * QUEUE_OUTCOMES + outcome.index()) * LATENCY_BUCKETS; + QueueTelemetryRow { + outcome, + latency_buckets: std::array::from_fn(|offset| { + count_bucket(state.queue_latency[base + offset]) + }), + } + }); + let response = ResponseOutcome::ALL.map(|outcome| { + let series = class.index() * RESPONSE_OUTCOMES + outcome.index(); + let latency_base = series * LATENCY_BUCKETS; + let byte_base = series * BYTE_BUCKETS; + ResponseTelemetryRow { + outcome, + latency_buckets: std::array::from_fn(|offset| { + count_bucket(state.response_latency[latency_base + offset]) + }), + bytes_received_buckets: std::array::from_fn(|offset| { + count_bucket(state.response_bytes[byte_base + offset]) + }), + bytes_measurement_unavailable: count_bucket(state.response_bytes_unavailable[series]), + } + }); + OperationTelemetryRow { + request_class: class, + circuit_rejections, + queue, + response, + } +} + +fn increment_queue( + state: &mut AggregateState, + class: RequestClass, + outcome: QueueOutcome, + elapsed: Duration, +) { + let bucket = latency_bucket(elapsed); + let index = (class.index() * QUEUE_OUTCOMES + outcome.index()) * LATENCY_BUCKETS + bucket; + let mut cell = state.queue_latency[index]; + TelemetryCollector::increment(state, &mut cell); + state.queue_latency[index] = cell; +} + +fn latency_bucket(duration: Duration) -> usize { + let micros = u64::try_from(duration.as_micros()).unwrap_or(u64::MAX); + LATENCY_UPPER_BOUNDS_MICROS + .iter() + .position(|upper| micros <= *upper) + .unwrap_or(LATENCY_BUCKETS - 1) +} + +fn byte_bucket(bytes: u64) -> usize { + RESPONSE_BYTE_UPPER_BOUNDS + .iter() + .position(|upper| bytes <= *upper) + .unwrap_or(BYTE_BUCKETS - 1) +} + +fn count_bucket(value: u64) -> CountBucket { + match value { + 0 => CountBucket::Zero, + 1 => CountBucket::One, + 2..=5 => CountBucket::TwoToFive, + 6..=20 => CountBucket::SixToTwenty, + 21..=100 => CountBucket::TwentyOneToHundred, + 101..=1_000 => CountBucket::HundredOneToThousand, + _ => CountBucket::OverThousand, + } +} + +fn hash_payload(payload: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(EXPORT_HASH_DOMAIN); + hasher.update(payload); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(encoded, "{byte:02x}"); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{sync::Arc, thread}; + + #[test] + fn histogram_boundaries_are_inclusive_and_queue_semantics_are_explicit() { + let collector = TelemetryCollector::new(); + for micros in [1_000, 1_001, 30_000_000, 30_000_001] { + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::Status, + queue_wait: Duration::from_micros(micros), + outcome: ResponseOutcome::Success, + response_pipeline_elapsed: Duration::ZERO, + observed_body_bytes: BodyBytesObservation::Observed(0), + }); + } + for bytes in [ + 0, + 1, + 1_024, + 1_025, + 32 * 1_024 * 1_024, + 32 * 1_024 * 1_024 + 1, + ] { + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::VoucherExport, + queue_wait: Duration::ZERO, + outcome: ResponseOutcome::SizeLimit, + response_pipeline_elapsed: Duration::from_millis(10), + observed_body_bytes: BodyBytesObservation::Observed(bytes), + }); + } + let preview = collector.preview_v2(); + assert_eq!(preview.rows().len(), RequestClass::ALL.len()); + assert!(!preview.establishes_performance_support()); + let state = collector.snapshot(); + let queue_base = (RequestClass::Status.index() * QUEUE_OUTCOMES + + QueueOutcome::Acquired.index()) + * LATENCY_BUCKETS; + assert_eq!( + &state.queue_latency[queue_base..queue_base + LATENCY_BUCKETS], + &[1, 1, 0, 0, 0, 0, 0, 1, 1] + ); + let response_series = RequestClass::VoucherExport.index() * RESPONSE_OUTCOMES + + ResponseOutcome::SizeLimit.index(); + let latency_base = response_series * LATENCY_BUCKETS; + assert_eq!(state.response_latency[latency_base + 2], 6); + assert_eq!( + &state.response_bytes + [response_series * BYTE_BUCKETS..response_series * BYTE_BUCKETS + BYTE_BUCKETS], + &[1, 2, 1, 0, 0, 1, 1] + ); + let json = collector.privacy_reduced_export_v2().unwrap().json; + assert!(json.contains("\"unstamped_collector_instance_lifetime\"")); + assert!(json.contains("\"coherent_mutex_snapshot\"")); + assert!(!json.contains("post_request_spacing")); + } + + #[test] + fn cardinality_and_export_size_remain_fixed_after_many_observations() { + let collector = TelemetryCollector::new(); + for index in 0..100_000_u64 { + let class = RequestClass::ALL[index as usize % RequestClass::ALL.len()]; + collector.record_attempt(AttemptObservation::Response { + class, + queue_wait: Duration::from_micros(index), + outcome: ResponseOutcome::Success, + response_pipeline_elapsed: Duration::from_micros(index), + observed_body_bytes: BodyBytesObservation::Observed(index), + }); + } + let export = collector + .privacy_reduced_export_v2() + .expect("bounded export"); + assert_eq!(collector.preview_v2().rows().len(), RequestClass::ALL.len()); + assert!(export.json().len() <= MAX_SERIALIZED_PREVIEW_BYTES); + assert_eq!(FIXED_HISTOGRAM_CELL_COUNT, 1_592); + } + + #[test] + fn preview_has_no_input_surface_for_sensitive_or_high_cardinality_values() { + let collector = TelemetryCollector::new(); + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::CompanyList, + queue_wait: Duration::from_millis(2), + outcome: ResponseOutcome::Decode, + response_pipeline_elapsed: Duration::from_millis(7), + observed_body_bytes: BodyBytesObservation::Observed(777), + }); + let export = collector.privacy_reduced_export_v2().unwrap(); + let debug = format!("{collector:?} {export:?}"); + for forbidden in [ + "BRIDGE SECRET COMPANY", + "27ABCDE1234F1Z5", + "ABCDE1234F", + "", + "127.0.0.1:9000", + "developer-home-path-sentinel", + "request-secret-id", + ] { + assert!(!export.json().contains(forbidden)); + assert!(!debug.contains(forbidden)); + } + assert!(!export.json().contains("company_guid")); + assert!(!export.json().contains("endpoint")); + assert!(!export.json().contains("timestamp")); + assert!(!export.json().contains("payload")); + } + + #[test] + fn preview_is_coherent_under_concurrent_recording_and_repeatable_when_idle() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Barrier, + }; + + let collector = Arc::new(TelemetryCollector::new()); + let start = Arc::new(Barrier::new(9)); + let active = Arc::new(AtomicUsize::new(8)); + let threads = (0..8) + .map(|_| { + let collector = Arc::clone(&collector); + let start = Arc::clone(&start); + let active = Arc::clone(&active); + thread::spawn(move || { + start.wait(); + for index in 0..20_000 { + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::MasterExport, + queue_wait: Duration::from_millis(1), + outcome: ResponseOutcome::Success, + response_pipeline_elapsed: Duration::from_millis(5), + observed_body_bytes: BodyBytesObservation::Observed(1_024), + }); + if index % 100 == 0 { + thread::yield_now(); + } + } + active.fetch_sub(1, Ordering::Release); + }) + }) + .collect::>(); + start.wait(); + let response_series = RequestClass::MasterExport.index() * RESPONSE_OUTCOMES + + ResponseOutcome::Success.index(); + let mut concurrent_snapshots = 0_u64; + while active.load(Ordering::Acquire) > 0 { + let state = collector.snapshot(); + let latency_total = state.response_latency + [response_series * LATENCY_BUCKETS..(response_series + 1) * LATENCY_BUCKETS] + .iter() + .sum::(); + let byte_total = state.response_bytes + [response_series * BYTE_BUCKETS..(response_series + 1) * BYTE_BUCKETS] + .iter() + .sum::(); + assert_eq!(latency_total, byte_total); + assert_eq!(collector.preview_v2().rows().len(), RequestClass::ALL.len()); + concurrent_snapshots += 1; + thread::yield_now(); + } + for thread in threads { + thread.join().expect("observation thread"); + } + assert!(concurrent_snapshots > 0); + let first = collector.privacy_reduced_export_v2().unwrap(); + let second = collector.privacy_reduced_export_v2().unwrap(); + assert_eq!(first.json(), second.json()); + assert_eq!(first.payload_sha256(), second.payload_sha256()); + } + + #[test] + fn saturation_never_wraps_and_is_disclosed() { + let collector = TelemetryCollector::new(); + { + let mut state = collector.state.lock().unwrap(); + state.queue_latency[0] = u64::MAX; + } + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::Status, + queue_wait: Duration::ZERO, + outcome: ResponseOutcome::Success, + response_pipeline_elapsed: Duration::ZERO, + observed_body_bytes: BodyBytesObservation::Observed(0), + }); + let state = collector.snapshot(); + assert_eq!(state.queue_latency[0], u64::MAX); + assert_eq!(state.saturated_cell_increments, 1); + assert!(collector + .privacy_reduced_export_v2() + .unwrap() + .json() + .contains("\"saturated_cell_increments\":\"one\"")); + } + + #[test] + fn longest_serialized_count_bucket_still_fits_the_reviewed_preview_ceiling() { + let collector = TelemetryCollector::new(); + { + let mut state = collector.state.lock().unwrap(); + state.queue_latency.fill(101); + state.response_latency.fill(101); + state.response_bytes.fill(101); + state.response_bytes_unavailable.fill(101); + state.circuit_rejections.fill(101); + state.saturated_cell_increments = 101; + } + let export = collector + .privacy_reduced_export_v2() + .expect("worst textual bucket export"); + assert!(export.json().len() <= MAX_SERIALIZED_PREVIEW_BYTES); + assert_eq!(collector.preview_v2().rows().len(), RequestClass::ALL.len()); + } + + #[test] + fn schema_v2_taxonomy_bounds_and_zero_preview_bytes_are_golden() { + assert_eq!(PREVIEW_SCHEMA, "bridge.tally.telemetry-preview/2"); + assert_eq!( + LATENCY_UPPER_BOUNDS_MICROS, + [1_000, 5_000, 25_000, 100_000, 500_000, 2_000_000, 10_000_000, 30_000_000,] + ); + assert_eq!( + RESPONSE_BYTE_UPPER_BOUNDS, + [0, 1_024, 65_536, 1_048_576, 8_388_608, 33_554_432] + ); + assert_eq!(QueueOutcome::ALL.len(), 3); + assert_eq!(ResponseOutcome::ALL.len(), 10); + assert_eq!(CircuitRejectReason::ALL.len(), 2); + assert_eq!(FIXED_HISTOGRAM_CELL_COUNT, 1_592); + let export = TelemetryCollector::new() + .privacy_reduced_export_v2() + .expect("golden zero preview"); + assert_eq!( + export.payload_sha256(), + "013e4b52577f9b89c22a31c203ec8940a783b3ada13e3f9d5e508b79a92ed37a" + ); + } + + #[test] + fn terminal_attempt_shape_keeps_queue_failures_out_of_response_histograms() { + let collector = TelemetryCollector::new(); + collector.record_attempt(AttemptObservation::QueueDeadline { + class: RequestClass::Capability, + queue_wait: Duration::from_secs(30), + }); + collector.record_attempt(AttemptObservation::QueueCancelled { + class: RequestClass::Capability, + queue_wait: Duration::from_millis(5), + }); + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::Capability, + queue_wait: Duration::from_millis(1), + outcome: ResponseOutcome::Timeout, + response_pipeline_elapsed: Duration::from_secs(10), + observed_body_bytes: BodyBytesObservation::Unavailable, + }); + + let state = collector.snapshot(); + let queue_base = RequestClass::Capability.index() * QUEUE_OUTCOMES * LATENCY_BUCKETS; + let queue_total = state.queue_latency + [queue_base..queue_base + QUEUE_OUTCOMES * LATENCY_BUCKETS] + .iter() + .sum::(); + let response_base = RequestClass::Capability.index() * RESPONSE_OUTCOMES * LATENCY_BUCKETS; + let response_total = state.response_latency + [response_base..response_base + RESPONSE_OUTCOMES * LATENCY_BUCKETS] + .iter() + .sum::(); + assert_eq!(queue_total, 3); + assert_eq!(response_total, 1); + } + + #[test] + fn circuit_rejections_and_unavailable_byte_measurement_are_explicit() { + let collector = TelemetryCollector::new(); + collector.record_attempt(AttemptObservation::CircuitRejected { + class: RequestClass::CompanyList, + reason: CircuitRejectReason::Cooldown, + }); + collector.record_attempt(AttemptObservation::Response { + class: RequestClass::CompanyList, + queue_wait: Duration::ZERO, + outcome: ResponseOutcome::Application, + response_pipeline_elapsed: Duration::from_millis(2), + observed_body_bytes: BodyBytesObservation::Unavailable, + }); + let state = collector.snapshot(); + let circuit = RequestClass::CompanyList.index() * CIRCUIT_REJECT_REASONS + + CircuitRejectReason::Cooldown.index(); + let response = RequestClass::CompanyList.index() * RESPONSE_OUTCOMES + + ResponseOutcome::Application.index(); + assert_eq!(state.circuit_rejections[circuit], 1); + assert_eq!(state.response_bytes_unavailable[response], 1); + let json = collector.privacy_reduced_export_v2().unwrap().json; + assert!(json.contains("\"cooldown\"")); + assert!(json.contains("\"bytes_measurement_unavailable\":\"one\"")); + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/Cargo.toml b/src-tauri/crates/bridge-tally-protocol/Cargo.toml new file mode 100644 index 0000000..4f05c97 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "bridge-tally-protocol" +version = "0.1.0" +description = "Portable Tally wire decoding, XML protocol, and disabled JSONEX evidence tools for Bridge" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[features] +default = [] +jsonex-parser = ["dep:serde_json"] +jsonex-request-builder = ["dep:serde_json"] +india-tax-observation-parser = [] +bills-payments-observation-parser = [] +bills-native-outstandings-probe = [] + +[dependencies] +anyhow = "1" +quick-xml = { version = "0.41", features = ["serialize"] } +serde = { version = "1", features = ["derive"] } +serde_json = { version = "1", features = ["arbitrary_precision", "raw_value"], optional = true } +sha2 = "0.11" + +[dev-dependencies] +serde_json = { version = "1", features = ["arbitrary_precision", "raw_value"] } +tally-protocol-simulator = { path = "../tally-protocol-simulator" } diff --git a/src-tauri/crates/bridge-tally-protocol/README.md b/src-tauri/crates/bridge-tally-protocol/README.md new file mode 100644 index 0000000..9414f8d --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/README.md @@ -0,0 +1,51 @@ +# Bridge Tally protocol + +Portable production parsing for Bridge's Tally integration. This crate owns: + +- bounded byte decoding for UTF-8, UTF-8 BOM, UTF-16LE, and UTF-16BE; +- strict export-envelope `STATUS` handling; +- company, ledger, and voucher parsing; +- exact import counters without retaining raw `LINEERROR` text; +- optional company-context and duplicate-identity evidence. + +It intentionally has no HTTP client, Tauri, database, SQLCipher, or native +dependency. `tally-protocol-simulator` is a development dependency and supplies +the synthetic compatibility corpus. + +Bridge retains its existing `tally::xml_parser` API as a thin re-export of this +crate. New callers that need verification evidence can use +`parse_*_with_evidence`; legacy `parse_*` functions still return the original +record vectors. + +Duplicate source identities are exposed only as occurrence counts and SHA-256 +digests. Raw duplicate identifiers and raw line-error messages are not retained +in evidence or error strings. + +## Native JSONEX evidence boundary + +The optional `jsonex-parser` feature contains a portable, bounded parser for +the exact Ledger and Voucher collection-envelope shapes documented for +TallyPrime 7.0+. Its results are deliberately named `Unbound`: those official +examples do not echo enough evidence to bind the response to a requested +company, date range, Bridge query profile, or complete Core Accounting source +scope. + +The separate optional `jsonex-request-builder` feature produces deterministic +bytes for only two versioned official-example logical profiles: the Ledger +collection payload with exact `fetch_List` spelling, and the unbounded +`TSPLVoucherColl` payload. It fixes every request header/value and TDL literal, +requires an exact validated company name, binds BOM bytes to the declared +charset, and marks every result ineligible for dispatch, company verification, +or date-range claims. Company input also shares Bridge's 255-byte local safety +cap; this is an application resource bound, not a documented Tally limit. + +Every wire serialization remains live-unverified. In particular, the BOM modes +combine the plain DOCX logical body with Tally's documented charset/BOM rules; +they are not byte-for-byte copies of Tally's separate multilingual examples, +which currently use different export-format/fetch spellings. Those alternatives +require separate versioned profiles and evidence rather than silent aliases. + +The Bridge application enables neither feature. They add no HTTP client, +runtime transport selection, canonical adapter, mirror write, checkpoint, or +Tally write path. Both are exercised separately in CI so the evidence code +cannot silently decay while production JSONEX remains unavailable. diff --git a/src-tauri/crates/bridge-tally-protocol/src/bills_native_outstandings_probe.rs b/src-tauri/crates/bridge-tally-protocol/src/bills_native_outstandings_probe.rs new file mode 100644 index 0000000..90e4c11 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/bills_native_outstandings_probe.rs @@ -0,0 +1,542 @@ +//! Sealed candidate for observing Tally's native `Ledger Outstandings` report. +//! +//! This module deliberately provides no parser, transport integration, or +//! accounting authority. The request is available only through a non-default +//! feature and remains outside the production [`crate::xml_read_profiles::ReadOnlyProfile`]. + +use std::fmt; + +use sha2::{Digest, Sha256}; + +const TEMPLATE_COMPANY: &str = "BRIDGE TEMPLATE COMPANY"; +const TEMPLATE_LEDGER: &str = "BRIDGE TEMPLATE LEDGER"; +const TEMPLATE_TO_DATE: &str = "20000101"; +const SCOPE_HASH_DOMAIN: &[u8] = b"bridge.tally.native-ledger-outstandings.scope/1\0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeOutstandingsProbeValidationError { + CompanyInvalid, + LedgerInvalid, + DateInvalid, +} + +impl fmt::Display for NativeOutstandingsProbeValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::CompanyInvalid => "native outstandings probe company input was invalid", + Self::LedgerInvalid => "native outstandings probe ledger input was invalid", + Self::DateInvalid => "native outstandings probe date input was invalid", + }) + } +} + +impl std::error::Error for NativeOutstandingsProbeValidationError {} + +#[derive(Clone, PartialEq, Eq)] +pub struct ValidatedProbeCompanyName(String); + +impl ValidatedProbeCompanyName { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if !valid_name(&value) { + return Err(NativeOutstandingsProbeValidationError::CompanyInvalid); + } + Ok(Self(value)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ValidatedProbeCompanyName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ValidatedProbeCompanyName([redacted])") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ValidatedProbeLedgerName(String); + +impl ValidatedProbeLedgerName { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if !valid_name(&value) { + return Err(NativeOutstandingsProbeValidationError::LedgerInvalid); + } + Ok(Self(value)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ValidatedProbeLedgerName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ValidatedProbeLedgerName([redacted])") + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ValidatedProbeToDate(String); + +impl ValidatedProbeToDate { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if !valid_yyyymmdd(&value) { + return Err(NativeOutstandingsProbeValidationError::DateInvalid); + } + Ok(Self(value)) + } + + fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ValidatedProbeToDate { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ValidatedProbeToDate([redacted])") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeOutstandingsObservationPosture { + Unobserved, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeOutstandingsDispatchPosture { + CandidateOnlyNoTransport, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NativeOutstandingsProbeProfileId { + LedgerOutstandingsCandidateV0, +} + +impl NativeOutstandingsProbeProfileId { + pub fn as_str(self) -> &'static str { + match self { + Self::LedgerOutstandingsCandidateV0 => "native_ledger_outstandings_candidate_v0", + } + } + + pub fn template_sha256(self) -> String { + match self { + Self::LedgerOutstandingsCandidateV0 => sha256_hex( + render_ledger_outstandings(TEMPLATE_COMPANY, TEMPLATE_LEDGER, TEMPLATE_TO_DATE) + .as_bytes(), + ), + } + } +} + +/// Exact native-report scope. Values are retained only to seal the request and +/// compute its scope commitment; `Debug` never exposes them. +#[derive(Clone, PartialEq, Eq)] +pub struct NativeLedgerOutstandingsProbeScope { + company: ValidatedProbeCompanyName, + ledger: ValidatedProbeLedgerName, + to_date: ValidatedProbeToDate, +} + +impl NativeLedgerOutstandingsProbeScope { + pub fn new( + company: ValidatedProbeCompanyName, + ledger: ValidatedProbeLedgerName, + to_date: ValidatedProbeToDate, + ) -> Self { + Self { + company, + ledger, + to_date, + } + } + + pub fn seal(&self) -> SealedNativeLedgerOutstandingsProbe { + let rendered_xml = render_ledger_outstandings( + self.company.as_str(), + self.ledger.as_str(), + self.to_date.as_str(), + ); + SealedNativeLedgerOutstandingsProbe { + rendered_xml_sha256: sha256_hex(rendered_xml.as_bytes()), + scope_sha256: scope_sha256( + self.company.as_str(), + self.ledger.as_str(), + self.to_date.as_str(), + ), + rendered_xml, + } + } +} + +impl fmt::Debug for NativeLedgerOutstandingsProbeScope { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("NativeLedgerOutstandingsProbeScope") + .field("company", &"[redacted]") + .field("ledger", &"[redacted]") + .field("to_date", &"[redacted]") + .field( + "observation_posture", + &NativeOutstandingsObservationPosture::Unobserved, + ) + .field( + "dispatch_posture", + &NativeOutstandingsDispatchPosture::CandidateOnlyNoTransport, + ) + .finish() + } +} + +/// Immutable request candidate. Nothing in this crate can dispatch it. +#[derive(Clone, PartialEq, Eq)] +pub struct SealedNativeLedgerOutstandingsProbe { + rendered_xml: String, + rendered_xml_sha256: String, + scope_sha256: String, +} + +impl SealedNativeLedgerOutstandingsProbe { + pub fn profile_id(&self) -> NativeOutstandingsProbeProfileId { + NativeOutstandingsProbeProfileId::LedgerOutstandingsCandidateV0 + } + + pub fn observation_posture(&self) -> NativeOutstandingsObservationPosture { + NativeOutstandingsObservationPosture::Unobserved + } + + pub fn dispatch_posture(&self) -> NativeOutstandingsDispatchPosture { + NativeOutstandingsDispatchPosture::CandidateOnlyNoTransport + } + + pub fn template_sha256(&self) -> String { + self.profile_id().template_sha256() + } + + pub fn request_sha256(&self) -> &str { + &self.rendered_xml_sha256 + } + + pub fn scope_sha256(&self) -> &str { + &self.scope_sha256 + } + + /// Exact candidate bytes for offline review and golden testing. No Bridge + /// transport accepts this type in this feature. + pub fn rendered_xml(&self) -> &str { + &self.rendered_xml + } +} + +impl fmt::Debug for SealedNativeLedgerOutstandingsProbe { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("SealedNativeLedgerOutstandingsProbe") + .field("profile_id", &self.profile_id()) + .field("observation_posture", &self.observation_posture()) + .field("dispatch_posture", &self.dispatch_posture()) + .field("request_present", &!self.rendered_xml.is_empty()) + .finish() + } +} + +fn render_ledger_outstandings(company: &str, ledger: &str, to_date: &str) -> String { + format!( + r#" + +
+ 1 + Export + Data + Ledger Outstandings +
+ + + + {} + {} + {} + $$SysName:XML + Yes + + + +
+"#, + xml_escape(company), + xml_escape(ledger), + xml_escape(to_date), + ) + .trim() + .to_string() +} + +fn valid_name(value: &str) -> bool { + !value.is_empty() + && value.len() <= 255 + && value.trim() == value + && !value.chars().any(char::is_control) + && value.chars().all(valid_xml_1_0_scalar) +} + +fn valid_xml_1_0_scalar(value: char) -> bool { + matches!(value as u32, 0x9 | 0xA | 0xD | 0x20..=0xD7FF | 0xE000..=0xFFFD | 0x10000..=0x10FFFF) +} + +fn valid_yyyymmdd(value: &str) -> bool { + if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + let year = value[0..4].parse::().ok(); + let month = value[4..6].parse::().ok(); + let day = value[6..8].parse::().ok(); + let (Some(year), Some(month), Some(day)) = (year, month, day) else { + return false; + }; + if year == 0 || !(1..=12).contains(&month) { + return false; + } + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let maximum_day = match month { + 2 if leap => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + }; + (1..=maximum_day).contains(&day) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn scope_sha256(company: &str, ledger: &str, to_date: &str) -> String { + let mut digest = Sha256::new(); + digest.update(SCOPE_HASH_DOMAIN); + hash_field( + &mut digest, + NativeOutstandingsProbeProfileId::LedgerOutstandingsCandidateV0 + .as_str() + .as_bytes(), + ); + hash_field(&mut digest, company.as_bytes()); + hash_field(&mut digest, ledger.as_bytes()); + hash_field(&mut digest, to_date.as_bytes()); + hex_lower(&digest.finalize()) +} + +fn hash_field(digest: &mut Sha256, value: &[u8]) { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex_lower(&Sha256::digest(bytes)) +} + +fn hex_lower(bytes: &[u8]) -> String { + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + use fmt::Write as _; + write!(&mut output, "{byte:02x}").expect("writing to a String cannot fail"); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn probe(company: &str, ledger: &str, to_date: &str) -> SealedNativeLedgerOutstandingsProbe { + NativeLedgerOutstandingsProbeScope::new( + ValidatedProbeCompanyName::new(company).unwrap(), + ValidatedProbeLedgerName::new(ledger).unwrap(), + ValidatedProbeToDate::new(to_date).unwrap(), + ) + .seal() + } + + #[test] + fn golden_native_ledger_outstandings_request_is_exact() { + let sealed = probe("BRIDGE SYNTHETIC BOOK", "BRIDGE PARTY", "20260402"); + assert_eq!( + sealed.rendered_xml(), + r#" +
+ 1 + Export + Data + Ledger Outstandings +
+ + + + BRIDGE SYNTHETIC BOOK + BRIDGE PARTY + 20260402 + $$SysName:XML + Yes + + + +
"# + ); + assert_eq!( + sealed.profile_id().as_str(), + "native_ledger_outstandings_candidate_v0" + ); + assert_eq!( + sealed.observation_posture(), + NativeOutstandingsObservationPosture::Unobserved + ); + assert_eq!( + sealed.dispatch_posture(), + NativeOutstandingsDispatchPosture::CandidateOnlyNoTransport + ); + } + + #[test] + fn validation_rejects_ambiguous_names_and_invalid_dates() { + for value in [ + "", + " ", + " leading", + "trailing ", + "line\nbreak", + "\u{0}", + "\u{fffe}", + "\u{ffff}", + ] { + assert_eq!( + ValidatedProbeCompanyName::new(value), + Err(NativeOutstandingsProbeValidationError::CompanyInvalid) + ); + assert_eq!( + ValidatedProbeLedgerName::new(value), + Err(NativeOutstandingsProbeValidationError::LedgerInvalid) + ); + } + assert_eq!( + ValidatedProbeCompanyName::new("x".repeat(256)), + Err(NativeOutstandingsProbeValidationError::CompanyInvalid) + ); + for date in ["2026-04-02", "20260229", "20261301", "00000101"] { + assert_eq!( + ValidatedProbeToDate::new(date), + Err(NativeOutstandingsProbeValidationError::DateInvalid) + ); + } + assert!(ValidatedProbeToDate::new("20240229").is_ok()); + } + + #[test] + fn dynamic_values_are_escaped_and_cannot_inject_requests_or_variables() { + let sealed = probe( + "BRIDGE & \"Q\"", + "XImport'", + "20260402", + ); + let xml = sealed.rendered_xml(); + assert!(xml.contains("BRIDGE & <BOOK> "Q"")); + assert!(xml.contains("</LedgerName><TALLYREQUEST>Import")); + assert!(xml.contains("'")); + assert_eq!(xml.matches("").count(), 1); + assert_eq!(xml.matches("").count(), 1); + assert!(!xml.contains("Import")); + } + + #[test] + fn candidate_contains_only_the_documented_read_only_native_surface() { + let xml = probe("BRIDGE SYNTHETIC BOOK", "BRIDGE PARTY", "20260402") + .rendered_xml() + .to_ascii_uppercase(); + for required in [ + "EXPORT", + "DATA", + "LEDGER OUTSTANDINGS", + "", + "", + "", + "$$SYSNAME:XML", + "YES", + ] { + assert!(xml.contains(required), "missing {required}"); + } + for forbidden in [ + "IMPORT", + "CREATE", + "ALTER", + "DELETE", + "OBJECT", + "COLLECTION", + ] { + assert!(!xml.contains(&format!("{forbidden}"))); + assert!(!xml.contains(&format!("{forbidden}"))); + } + assert!(!xml.contains("")); + } + + #[test] + fn exact_request_template_and_scope_hashes_are_stable() { + let sealed = probe("BRIDGE SYNTHETIC BOOK", "BRIDGE PARTY", "20260402"); + assert_eq!( + sealed.template_sha256(), + "bc3b87484adb9a10cc15f6c9042853bb1047278896bcf0f495b93e7e6b428526" + ); + assert_eq!( + sealed.request_sha256(), + "e99eebe225f8b023fe55b4d151c0fd18315b61580df5d47945235a8a6bda3822" + ); + assert_eq!( + sealed.scope_sha256(), + "0d6c4c4ee82e34025c2ce3084d64b309dc6676440b41c749b0c0ab3531efe399" + ); + assert_eq!( + sealed.request_sha256(), + sha256_hex(sealed.rendered_xml().as_bytes()) + ); + + let other_scope = probe("BRIDGE SYNTHETIC BOOK", "BRIDGE PARTY 2", "20260402"); + assert_ne!(sealed.request_sha256(), other_scope.request_sha256()); + assert_ne!(sealed.scope_sha256(), other_scope.scope_sha256()); + assert_eq!(sealed.template_sha256(), other_scope.template_sha256()); + } + + #[test] + fn debug_output_redacts_scope_values_and_request_bytes() { + let company = ValidatedProbeCompanyName::new("SECRET COMPANY").unwrap(); + let ledger = ValidatedProbeLedgerName::new("SECRET LEDGER").unwrap(); + let to_date = ValidatedProbeToDate::new("20260402").unwrap(); + let company_debug = format!("{company:?}"); + let ledger_debug = format!("{ledger:?}"); + let date_debug = format!("{to_date:?}"); + let sealed = NativeLedgerOutstandingsProbeScope::new(company, ledger, to_date).seal(); + let hashes = [ + sealed.template_sha256(), + sealed.request_sha256().to_string(), + sealed.scope_sha256().to_string(), + ]; + for debug in [ + company_debug, + ledger_debug, + date_debug, + format!("{sealed:?}"), + ] { + assert!(!debug.contains("SECRET")); + assert!(!debug.contains("20260402")); + assert!(!debug.contains("")); + for hash in &hashes { + assert!(!debug.contains(hash)); + } + } + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/bills_payments_observation.rs b/src-tauri/crates/bridge-tally-protocol/src/bills_payments_observation.rs new file mode 100644 index 0000000..901d90c --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/bills_payments_observation.rs @@ -0,0 +1,1231 @@ +//! Dormant parser for one Bridge-owned Party Outstanding observation envelope. +//! +//! Output is deliberately unbound. There is no request artifact, TDL report, +//! production dispatch, canonical adapter, completeness authority, or support +//! promotion. The official Tally import examples inform vocabulary only; they +//! do not establish this read profile on any release. + +use std::collections::HashSet; +use std::fmt; + +use quick_xml::{events::Event, Reader}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::{decode_tally_text_bytes_limited, TallyTextDecodeError, TallyTextEncoding}; + +pub const BILLS_OBSERVED_RAW_SCHEMA_V1: &str = "bridge.tally.bills-observed-raw/1"; +pub const BILLS_OBSERVED_RAW_PROFILE_V1: &str = "bridge.bills-observed-raw-xml/1"; +const RESPONSE_HASH_DOMAIN: &[u8] = b"bridge.tally.bills-observed-raw-response/1\0"; +const FRAGMENT_HASH_DOMAIN: &[u8] = b"bridge.tally.bills-observed-raw-fragment/1\0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BillsObservationLimits { + pub max_encoded_bytes: usize, + pub max_decoded_bytes: usize, + pub max_records: usize, + pub max_field_bytes: usize, + pub max_nodes: usize, + pub max_depth: usize, + pub max_attributes: usize, +} + +impl Default for BillsObservationLimits { + fn default() -> Self { + Self { + max_encoded_bytes: 8 * 1024 * 1024, + max_decoded_bytes: 8 * 1024 * 1024, + max_records: 25_000, + max_field_bytes: 512, + max_nodes: 250_000, + max_depth: 4, + max_attributes: 32, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BillsObservationError { + InvalidLimits, + ResponseTooLarge, + DecodedResponseTooLarge, + InvalidEncoding, + MalformedXml, + ResourceLimitExceeded, + WrongGrammar, + DuplicateField, + ApplicationRejected, + ProfileMismatch, + InvalidValue, + CountMismatch, + DuplicateObservation, +} + +impl BillsObservationError { + pub const fn safe_code(self) -> &'static str { + match self { + Self::InvalidLimits => "bills_observation_limits_invalid", + Self::ResponseTooLarge => "bills_observation_response_too_large", + Self::DecodedResponseTooLarge => "bills_observation_decoded_too_large", + Self::InvalidEncoding => "bills_observation_encoding_invalid", + Self::MalformedXml => "bills_observation_xml_malformed", + Self::ResourceLimitExceeded => "bills_observation_resource_limit", + Self::WrongGrammar => "bills_observation_grammar_invalid", + Self::DuplicateField => "bills_observation_field_duplicate", + Self::ApplicationRejected => "bills_observation_application_rejected", + Self::ProfileMismatch => "bills_observation_profile_mismatch", + Self::InvalidValue => "bills_observation_value_invalid", + Self::CountMismatch => "bills_observation_count_mismatch", + Self::DuplicateObservation => "bills_observation_duplicate", + } + } +} + +impl fmt::Display for BillsObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.safe_code()) + } +} + +impl std::error::Error for BillsObservationError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BillsObservationBinding { + UnboundNoRequestArtifact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BillsCountAuthority { + ResponseInternalOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParsedOutstandingDirection { + Receivable, + Payable, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParsedBillWiseState { + Enabled, + Disabled, + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParsedBillOrigin { + Voucher, + LedgerOpening, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParsedBillReferenceKind { + Advance, + AgainstReference, + NewReference, + OnAccount, + Unclassified, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ParsedPolarity { + Debit, + Credit, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UnboundBillsEvidence { + profile_id: &'static str, + binding: BillsObservationBinding, + count_authority: BillsCountAuthority, + encoding: TallyTextEncoding, + encoded_bytes: u64, + decoded_bytes: u64, + claimed_company_guid: String, + claimed_party_ledger: String, + claimed_from_yyyymmdd: String, + claimed_to_yyyymmdd: String, + claimed_as_of_yyyymmdd: String, + claimed_direction: ParsedOutstandingDirection, + claimed_query_profile: String, + claimed_bill_wise_state: ParsedBillWiseState, + claimed_allocation_count: u64, + claimed_outstanding_count: u64, + response_sha256: String, +} + +impl fmt::Debug for UnboundBillsEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("UnboundBillsEvidence") + .field("profile_id", &self.profile_id) + .field("binding", &self.binding) + .field("count_authority", &self.count_authority) + .field("encoding", &self.encoding) + .field("encoded_bytes", &self.encoded_bytes) + .field("decoded_bytes", &self.decoded_bytes) + .field("claimed_direction", &self.claimed_direction) + .field("claimed_bill_wise_state", &self.claimed_bill_wise_state) + .field("claimed_allocation_count", &self.claimed_allocation_count) + .field("claimed_outstanding_count", &self.claimed_outstanding_count) + .field("response_sha256", &self.response_sha256) + .finish_non_exhaustive() + } +} + +impl UnboundBillsEvidence { + pub const fn binding(&self) -> BillsObservationBinding { + self.binding + } + pub const fn count_authority(&self) -> BillsCountAuthority { + self.count_authority + } + pub fn claimed_company_guid(&self) -> &str { + &self.claimed_company_guid + } + pub fn claimed_party_ledger(&self) -> &str { + &self.claimed_party_ledger + } + pub fn claimed_from_yyyymmdd(&self) -> &str { + &self.claimed_from_yyyymmdd + } + pub fn claimed_to_yyyymmdd(&self) -> &str { + &self.claimed_to_yyyymmdd + } + pub fn claimed_as_of_yyyymmdd(&self) -> &str { + &self.claimed_as_of_yyyymmdd + } + pub const fn claimed_direction(&self) -> ParsedOutstandingDirection { + self.claimed_direction + } + pub fn claimed_query_profile(&self) -> &str { + &self.claimed_query_profile + } + pub const fn claimed_bill_wise_state(&self) -> ParsedBillWiseState { + self.claimed_bill_wise_state + } + pub fn response_sha256(&self) -> &str { + &self.response_sha256 + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ObservedRawBillAllocation { + origin: ParsedBillOrigin, + voucher_identity: Option, + party_entry_ordinal: Option, + row_ordinal: u64, + reference_kind: ParsedBillReferenceKind, + raw_reference_kind: String, + reference_name: Option, + bill_date: Option, + effective_date: Option, + due_date: Option, + amount: String, + polarity: Option, + currency: Option, + decoded_fragment_sha256: String, +} + +impl fmt::Debug for ObservedRawBillAllocation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ObservedRawBillAllocation") + .field("origin", &self.origin) + .field("voucher_identity_present", &self.voucher_identity.is_some()) + .field("party_entry_ordinal", &self.party_entry_ordinal) + .field("row_ordinal", &self.row_ordinal) + .field("reference_kind", &self.reference_kind) + .field("reference_name_present", &self.reference_name.is_some()) + .field("bill_date_present", &self.bill_date.is_some()) + .field("effective_date_present", &self.effective_date.is_some()) + .field("due_date_present", &self.due_date.is_some()) + .field("polarity", &self.polarity) + .field("currency_present", &self.currency.is_some()) + .field("decoded_fragment_sha256", &self.decoded_fragment_sha256) + .finish() + } +} + +impl ObservedRawBillAllocation { + pub const fn origin(&self) -> ParsedBillOrigin { + self.origin + } + pub const fn row_ordinal(&self) -> u64 { + self.row_ordinal + } + pub const fn reference_kind(&self) -> ParsedBillReferenceKind { + self.reference_kind + } + pub fn raw_reference_kind(&self) -> &str { + &self.raw_reference_kind + } + pub fn reference_name(&self) -> Option<&str> { + self.reference_name.as_deref() + } + pub fn amount(&self) -> &str { + &self.amount + } + pub fn due_date(&self) -> Option<&str> { + self.due_date.as_deref() + } + pub fn decoded_fragment_sha256(&self) -> &str { + &self.decoded_fragment_sha256 + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ObservedRawOutstanding { + row_ordinal: u64, + reference_kind: ParsedBillReferenceKind, + raw_reference_kind: String, + reference_name: Option, + bill_date: Option, + effective_date: Option, + due_date: Option, + opening_amount: Option, + pending_amount: String, + polarity: Option, + currency: Option, + source_overdue_days: Option, + decoded_fragment_sha256: String, +} + +impl fmt::Debug for ObservedRawOutstanding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ObservedRawOutstanding") + .field("row_ordinal", &self.row_ordinal) + .field("reference_kind", &self.reference_kind) + .field("reference_name_present", &self.reference_name.is_some()) + .field("bill_date_present", &self.bill_date.is_some()) + .field("effective_date_present", &self.effective_date.is_some()) + .field("due_date_present", &self.due_date.is_some()) + .field("opening_amount_present", &self.opening_amount.is_some()) + .field("polarity", &self.polarity) + .field("currency_present", &self.currency.is_some()) + .field("source_overdue_days", &self.source_overdue_days) + .field("decoded_fragment_sha256", &self.decoded_fragment_sha256) + .finish() + } +} + +impl ObservedRawOutstanding { + pub const fn row_ordinal(&self) -> u64 { + self.row_ordinal + } + pub const fn reference_kind(&self) -> ParsedBillReferenceKind { + self.reference_kind + } + pub fn raw_reference_kind(&self) -> &str { + &self.raw_reference_kind + } + pub fn reference_name(&self) -> Option<&str> { + self.reference_name.as_deref() + } + pub fn opening_amount(&self) -> Option<&str> { + self.opening_amount.as_deref() + } + pub fn pending_amount(&self) -> &str { + &self.pending_amount + } + pub fn due_date(&self) -> Option<&str> { + self.due_date.as_deref() + } + pub fn decoded_fragment_sha256(&self) -> &str { + &self.decoded_fragment_sha256 + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UnboundPartyOutstandingObservation { + evidence: UnboundBillsEvidence, + allocations: Vec, + outstanding: Vec, +} + +impl fmt::Debug for UnboundPartyOutstandingObservation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("UnboundPartyOutstandingObservation") + .field("evidence", &self.evidence) + .field("allocation_count", &self.allocations.len()) + .field("outstanding_count", &self.outstanding.len()) + .finish() + } +} + +impl UnboundPartyOutstandingObservation { + pub fn evidence(&self) -> &UnboundBillsEvidence { + &self.evidence + } + pub fn allocations(&self) -> &[ObservedRawBillAllocation] { + &self.allocations + } + pub fn outstanding(&self) -> &[ObservedRawOutstanding] { + &self.outstanding + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEnvelope { + #[serde(rename = "HEADER")] + header: RawHeader, + #[serde(rename = "BODY")] + body: RawBody, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawHeader { + #[serde(rename = "STATUS")] + status: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawBody { + #[serde(rename = "BILLSPARTYCONTEXT")] + context: RawContext, + #[serde(rename = "BILLALLOCATION", default)] + allocations: Vec, + #[serde(rename = "BILLOUTSTANDING", default)] + outstanding: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawContext { + #[serde(rename = "@SCHEMA")] + schema: String, + #[serde(rename = "@PROFILE")] + profile: String, + #[serde(rename = "@OBJECTTYPE")] + object_type: String, + #[serde(rename = "@COMPANYGUID")] + company_guid: String, + #[serde(rename = "@PARTYLEDGER")] + party_ledger: String, + #[serde(rename = "@FROMDATE")] + from_date: String, + #[serde(rename = "@TODATE")] + to_date: String, + #[serde(rename = "@ASOFDATE")] + as_of_date: String, + #[serde(rename = "@DIRECTION")] + direction: String, + #[serde(rename = "@QUERYPROFILE")] + query_profile: String, + #[serde(rename = "@BILLWISESTATE")] + bill_wise_state: String, + #[serde(rename = "@ALLOCATIONCOUNT")] + allocation_count: String, + #[serde(rename = "@OUTSTANDINGCOUNT")] + outstanding_count: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawAllocation { + #[serde(rename = "@ORIGIN")] + origin: String, + #[serde(rename = "@VOUCHERIDENTITY", default)] + voucher_identity: Option, + #[serde(rename = "@PARTYENTRYORDINAL", default)] + party_entry_ordinal: Option, + #[serde(rename = "@ROWORDINAL")] + row_ordinal: String, + #[serde(rename = "@REFERENCEKIND")] + reference_kind: String, + #[serde(rename = "@REFERENCENAME", default)] + reference_name: Option, + #[serde(rename = "@BILLDATE", default)] + bill_date: Option, + #[serde(rename = "@EFFECTIVEDATE", default)] + effective_date: Option, + #[serde(rename = "@DUEDATE", default)] + due_date: Option, + #[serde(rename = "@AMOUNT")] + amount: String, + #[serde(rename = "@POLARITY", default)] + polarity: Option, + #[serde(rename = "@CURRENCY", default)] + currency: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct RawOutstanding { + #[serde(rename = "@ROWORDINAL")] + row_ordinal: String, + #[serde(rename = "@REFERENCEKIND")] + reference_kind: String, + #[serde(rename = "@REFERENCENAME", default)] + reference_name: Option, + #[serde(rename = "@BILLDATE", default)] + bill_date: Option, + #[serde(rename = "@EFFECTIVEDATE", default)] + effective_date: Option, + #[serde(rename = "@DUEDATE", default)] + due_date: Option, + #[serde(rename = "@OPENINGAMOUNT", default)] + opening_amount: Option, + #[serde(rename = "@PENDINGAMOUNT")] + pending_amount: String, + #[serde(rename = "@POLARITY", default)] + polarity: Option, + #[serde(rename = "@CURRENCY", default)] + currency: Option, + #[serde(rename = "@OVERDUEDAYS", default)] + overdue_days: Option, +} + +pub fn parse_unbound_party_outstanding_observation( + bytes: impl AsRef<[u8]>, + limits: BillsObservationLimits, +) -> Result { + validate_limits(limits)?; + let encoded = bytes.as_ref(); + let decoded = decode_tally_text_bytes_limited(encoded, limits.max_encoded_bytes) + .map_err(map_decode_error)?; + if decoded.text.len() > limits.max_decoded_bytes { + return Err(BillsObservationError::DecodedResponseTooLarge); + } + let fragments = scan_exact_grammar(&decoded.text, limits)?; + let raw: RawEnvelope = + quick_xml::de::from_str(&decoded.text).map_err(|_| BillsObservationError::MalformedXml)?; + if raw.header.status != "1" { + return Err(BillsObservationError::ApplicationRejected); + } + if raw.body.context.schema != BILLS_OBSERVED_RAW_SCHEMA_V1 + || raw.body.context.profile != BILLS_OBSERVED_RAW_PROFILE_V1 + || raw.body.context.object_type != "PARTYOUTSTANDING" + { + return Err(BillsObservationError::ProfileMismatch); + } + let context = raw.body.context; + for value in [ + context.company_guid.as_str(), + context.party_ledger.as_str(), + context.query_profile.as_str(), + ] { + validate_text(value, limits.max_field_bytes)?; + } + for value in [ + context.from_date.as_str(), + context.to_date.as_str(), + context.as_of_date.as_str(), + ] { + validate_date(value)?; + } + if context.from_date > context.to_date || context.to_date > context.as_of_date { + return Err(BillsObservationError::InvalidValue); + } + let direction = match context.direction.as_str() { + "RECEIVABLE" => ParsedOutstandingDirection::Receivable, + "PAYABLE" => ParsedOutstandingDirection::Payable, + _ => return Err(BillsObservationError::InvalidValue), + }; + let bill_wise_state = match context.bill_wise_state.as_str() { + "ENABLED" => ParsedBillWiseState::Enabled, + "DISABLED" => ParsedBillWiseState::Disabled, + "UNKNOWN" => ParsedBillWiseState::Unknown, + _ => return Err(BillsObservationError::InvalidValue), + }; + let claimed_allocation_count = parse_u64(&context.allocation_count, true)?; + let claimed_outstanding_count = parse_u64(&context.outstanding_count, true)?; + if raw + .body + .allocations + .len() + .saturating_add(raw.body.outstanding.len()) + > limits.max_records + { + return Err(BillsObservationError::ResourceLimitExceeded); + } + if claimed_allocation_count != raw.body.allocations.len() as u64 + || claimed_outstanding_count != raw.body.outstanding.len() as u64 + || fragments.len() != raw.body.allocations.len() + raw.body.outstanding.len() + { + return Err(BillsObservationError::CountMismatch); + } + + let mut fragments = fragments.into_iter(); + let mut allocation_keys = HashSet::new(); + let mut allocations = Vec::with_capacity(raw.body.allocations.len()); + for row in raw.body.allocations { + let origin = match row.origin.as_str() { + "VOUCHER" => ParsedBillOrigin::Voucher, + "LEDGEROPENING" => ParsedBillOrigin::LedgerOpening, + _ => return Err(BillsObservationError::InvalidValue), + }; + validate_optional_text(&row.voucher_identity, limits.max_field_bytes)?; + let party_entry_ordinal = row + .party_entry_ordinal + .as_deref() + .map(|value| parse_u64(value, false)) + .transpose()?; + match origin { + ParsedBillOrigin::Voucher + if row.voucher_identity.is_none() || party_entry_ordinal.is_none() => + { + return Err(BillsObservationError::InvalidValue); + } + ParsedBillOrigin::LedgerOpening + if row.voucher_identity.is_some() || party_entry_ordinal.is_some() => + { + return Err(BillsObservationError::InvalidValue); + } + _ => {} + } + let row_ordinal = parse_u64(&row.row_ordinal, false)?; + if !allocation_keys.insert((origin, row.voucher_identity.clone(), row_ordinal)) { + return Err(BillsObservationError::DuplicateObservation); + } + validate_text(&row.reference_kind, limits.max_field_bytes)?; + let reference_kind = parse_reference_kind(&row.reference_kind); + validate_reference(reference_kind, &row.reference_name, limits.max_field_bytes)?; + validate_optional_date(&row.bill_date)?; + validate_optional_date(&row.effective_date)?; + validate_optional_date(&row.due_date)?; + validate_decimal(&row.amount)?; + let polarity = parse_optional_polarity(row.polarity.as_deref())?; + validate_optional_text(&row.currency, limits.max_field_bytes)?; + let fragment = fragments + .next() + .ok_or(BillsObservationError::WrongGrammar)?; + allocations.push(ObservedRawBillAllocation { + origin, + voucher_identity: row.voucher_identity, + party_entry_ordinal, + row_ordinal, + reference_kind, + raw_reference_kind: row.reference_kind, + reference_name: row.reference_name, + bill_date: row.bill_date, + effective_date: row.effective_date, + due_date: row.due_date, + amount: row.amount, + polarity, + currency: row.currency, + decoded_fragment_sha256: hash_domain(FRAGMENT_HASH_DOMAIN, fragment.as_bytes()), + }); + } + + let mut outstanding_ordinals = HashSet::new(); + let mut outstanding = Vec::with_capacity(raw.body.outstanding.len()); + for row in raw.body.outstanding { + let row_ordinal = parse_u64(&row.row_ordinal, false)?; + if !outstanding_ordinals.insert(row_ordinal) { + return Err(BillsObservationError::DuplicateObservation); + } + validate_text(&row.reference_kind, limits.max_field_bytes)?; + let reference_kind = parse_reference_kind(&row.reference_kind); + validate_reference(reference_kind, &row.reference_name, limits.max_field_bytes)?; + validate_optional_date(&row.bill_date)?; + validate_optional_date(&row.effective_date)?; + validate_optional_date(&row.due_date)?; + if let Some(value) = &row.opening_amount { + validate_decimal(value)?; + } + validate_decimal(&row.pending_amount)?; + let polarity = parse_optional_polarity(row.polarity.as_deref())?; + validate_optional_text(&row.currency, limits.max_field_bytes)?; + let source_overdue_days = row + .overdue_days + .as_deref() + .map(|value| parse_u64(value, true)) + .transpose()? + .map(|value| u32::try_from(value).map_err(|_| BillsObservationError::InvalidValue)) + .transpose()?; + let fragment = fragments + .next() + .ok_or(BillsObservationError::WrongGrammar)?; + outstanding.push(ObservedRawOutstanding { + row_ordinal, + reference_kind, + raw_reference_kind: row.reference_kind, + reference_name: row.reference_name, + bill_date: row.bill_date, + effective_date: row.effective_date, + due_date: row.due_date, + opening_amount: row.opening_amount, + pending_amount: row.pending_amount, + polarity, + currency: row.currency, + source_overdue_days, + decoded_fragment_sha256: hash_domain(FRAGMENT_HASH_DOMAIN, fragment.as_bytes()), + }); + } + + Ok(UnboundPartyOutstandingObservation { + evidence: UnboundBillsEvidence { + profile_id: BILLS_OBSERVED_RAW_PROFILE_V1, + binding: BillsObservationBinding::UnboundNoRequestArtifact, + count_authority: BillsCountAuthority::ResponseInternalOnly, + encoding: decoded.encoding, + encoded_bytes: encoded.len() as u64, + decoded_bytes: decoded.text.len() as u64, + claimed_company_guid: context.company_guid, + claimed_party_ledger: context.party_ledger, + claimed_from_yyyymmdd: context.from_date, + claimed_to_yyyymmdd: context.to_date, + claimed_as_of_yyyymmdd: context.as_of_date, + claimed_direction: direction, + claimed_query_profile: context.query_profile, + claimed_bill_wise_state: bill_wise_state, + claimed_allocation_count, + claimed_outstanding_count, + response_sha256: hash_domain(RESPONSE_HASH_DOMAIN, encoded), + }, + allocations, + outstanding, + }) +} + +fn validate_limits(limits: BillsObservationLimits) -> Result<(), BillsObservationError> { + if limits.max_encoded_bytes == 0 + || limits.max_encoded_bytes > 8 * 1024 * 1024 + || limits.max_decoded_bytes == 0 + || limits.max_decoded_bytes > 8 * 1024 * 1024 + || limits.max_records == 0 + || limits.max_records > 25_000 + || limits.max_field_bytes == 0 + || limits.max_field_bytes > 512 + || limits.max_nodes == 0 + || limits.max_nodes > 250_000 + || !(3..=8).contains(&limits.max_depth) + || limits.max_attributes == 0 + || limits.max_attributes > 32 + { + return Err(BillsObservationError::InvalidLimits); + } + Ok(()) +} + +fn map_decode_error(error: TallyTextDecodeError) -> BillsObservationError { + match error { + TallyTextDecodeError::TooLarge => BillsObservationError::ResponseTooLarge, + _ => BillsObservationError::InvalidEncoding, + } +} + +fn parse_reference_kind(value: &str) -> ParsedBillReferenceKind { + match value { + "Advance" => ParsedBillReferenceKind::Advance, + "Agst Ref" => ParsedBillReferenceKind::AgainstReference, + "New Ref" => ParsedBillReferenceKind::NewReference, + "On Account" => ParsedBillReferenceKind::OnAccount, + _ => ParsedBillReferenceKind::Unclassified, + } +} + +fn validate_reference( + kind: ParsedBillReferenceKind, + name: &Option, + limit: usize, +) -> Result<(), BillsObservationError> { + validate_optional_text(name, limit)?; + let valid = match kind { + ParsedBillReferenceKind::OnAccount => name.is_none(), + ParsedBillReferenceKind::Advance + | ParsedBillReferenceKind::AgainstReference + | ParsedBillReferenceKind::NewReference => name.is_some(), + ParsedBillReferenceKind::Unclassified => true, + }; + if valid { + Ok(()) + } else { + Err(BillsObservationError::InvalidValue) + } +} + +fn parse_optional_polarity( + value: Option<&str>, +) -> Result, BillsObservationError> { + value + .map(|value| match value { + "DEBIT" => Ok(ParsedPolarity::Debit), + "CREDIT" => Ok(ParsedPolarity::Credit), + _ => Err(BillsObservationError::InvalidValue), + }) + .transpose() +} + +fn validate_optional_text( + value: &Option, + limit: usize, +) -> Result<(), BillsObservationError> { + if let Some(value) = value { + validate_text(value, limit)?; + } + Ok(()) +} + +fn validate_text(value: &str, limit: usize) -> Result<(), BillsObservationError> { + if value.is_empty() + || value.len() > limit + || value.trim() != value + || value.chars().any(char::is_control) + { + Err(BillsObservationError::InvalidValue) + } else { + Ok(()) + } +} + +fn validate_optional_date(value: &Option) -> Result<(), BillsObservationError> { + if let Some(value) = value { + validate_date(value)?; + } + Ok(()) +} + +fn validate_date(value: &str) -> Result<(), BillsObservationError> { + if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(BillsObservationError::InvalidValue); + } + let year = value[0..4] + .parse::() + .map_err(|_| BillsObservationError::InvalidValue)?; + let month = value[4..6] + .parse::() + .map_err(|_| BillsObservationError::InvalidValue)?; + let day = value[6..8] + .parse::() + .map_err(|_| BillsObservationError::InvalidValue)?; + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let maximum = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return Err(BillsObservationError::InvalidValue), + }; + if year == 0 || day == 0 || day > maximum { + return Err(BillsObservationError::InvalidValue); + } + Ok(()) +} + +fn validate_decimal(value: &str) -> Result<(), BillsObservationError> { + if value.is_empty() || value.len() > 256 || value.starts_with('+') { + return Err(BillsObservationError::InvalidValue); + } + let digits = value.strip_prefix('-').unwrap_or(value); + let mut parts = digits.split('.'); + let whole = parts.next().unwrap_or_default(); + let fraction = parts.next(); + if parts.next().is_some() + || whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || fraction + .is_some_and(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit())) + { + return Err(BillsObservationError::InvalidValue); + } + Ok(()) +} + +fn parse_u64(value: &str, allow_zero: bool) -> Result { + if value.is_empty() + || !value.bytes().all(|byte| byte.is_ascii_digit()) + || (value.len() > 1 && value.starts_with('0')) + { + return Err(BillsObservationError::InvalidValue); + } + let parsed = value + .parse::() + .map_err(|_| BillsObservationError::InvalidValue)?; + if !allow_zero && parsed == 0 { + return Err(BillsObservationError::InvalidValue); + } + Ok(parsed) +} + +fn hash_domain(domain: &[u8], bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update(bytes); + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RowKind { + Allocation, + Outstanding, +} + +fn scan_exact_grammar( + xml: &str, + limits: BillsObservationLimits, +) -> Result, BillsObservationError> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(false); + let mut stack = Vec::::new(); + let mut nodes = 0usize; + let mut root_closed = false; + let mut header_status_seen = false; + let mut context_seen = false; + let mut body_stage = 0_u8; + let mut fragments = Vec::new(); + loop { + let before = reader.buffer_position() as usize; + let event = reader + .read_event() + .map_err(|_| BillsObservationError::MalformedXml)?; + nodes = nodes.saturating_add(1); + if nodes > limits.max_nodes { + return Err(BillsObservationError::ResourceLimitExceeded); + } + match event { + Event::Start(start) => { + if root_closed { + return Err(BillsObservationError::WrongGrammar); + } + let name = event_name(start.name().as_ref())?; + validate_attributes(&start, &name, limits)?; + let path = stack.iter().map(String::as_str).collect::>(); + match (path.as_slice(), name.as_str()) { + ([], "ENVELOPE") => {} + (["ENVELOPE"], "HEADER") => {} + (["ENVELOPE", "HEADER"], "STATUS") if !header_status_seen => { + header_status_seen = true; + } + (["ENVELOPE"], "BODY") if header_status_seen => {} + _ => return Err(BillsObservationError::WrongGrammar), + } + stack.push(name); + if stack.len() > limits.max_depth { + return Err(BillsObservationError::ResourceLimitExceeded); + } + } + Event::Empty(empty) => { + if root_closed || stack.as_slice() != ["ENVELOPE", "BODY"] { + return Err(BillsObservationError::WrongGrammar); + } + let name = event_name(empty.name().as_ref())?; + validate_attributes(&empty, &name, limits)?; + let row_kind = match name.as_str() { + "BILLSPARTYCONTEXT" if !context_seen && body_stage == 0 => { + context_seen = true; + body_stage = 1; + None + } + "BILLALLOCATION" if context_seen && body_stage <= 1 => { + body_stage = 1; + Some(RowKind::Allocation) + } + "BILLOUTSTANDING" if context_seen => { + body_stage = 2; + Some(RowKind::Outstanding) + } + _ => return Err(BillsObservationError::WrongGrammar), + }; + if row_kind.is_some() { + if fragments.len() >= limits.max_records { + return Err(BillsObservationError::ResourceLimitExceeded); + } + let after = reader.buffer_position() as usize; + fragments.push( + xml.get(before..after) + .ok_or(BillsObservationError::MalformedXml)? + .to_string(), + ); + } + } + Event::End(end) => { + let name = event_name(end.name().as_ref())?; + if stack.last().map(String::as_str) != Some(name.as_str()) { + return Err(BillsObservationError::WrongGrammar); + } + stack.pop(); + if name == "ENVELOPE" { + root_closed = true; + } + } + Event::Text(text) => { + let decoded = text + .decode() + .map_err(|_| BillsObservationError::MalformedXml)?; + if decoded.len() > limits.max_field_bytes { + return Err(BillsObservationError::ResourceLimitExceeded); + } + if stack.last().map(String::as_str) != Some("STATUS") + && !decoded.chars().all(char::is_whitespace) + { + return Err(BillsObservationError::WrongGrammar); + } + } + Event::Decl(_) if nodes == 1 && stack.is_empty() => {} + Event::Eof => break, + Event::Decl(_) + | Event::Comment(_) + | Event::CData(_) + | Event::PI(_) + | Event::DocType(_) + | Event::GeneralRef(_) => return Err(BillsObservationError::WrongGrammar), + } + } + if !root_closed || !stack.is_empty() || !header_status_seen || !context_seen { + return Err(BillsObservationError::WrongGrammar); + } + Ok(fragments) +} + +fn event_name(bytes: &[u8]) -> Result { + let name = std::str::from_utf8(bytes).map_err(|_| BillsObservationError::MalformedXml)?; + if !name.bytes().all(|byte| byte.is_ascii_uppercase()) { + return Err(BillsObservationError::WrongGrammar); + } + Ok(name.to_string()) +} + +fn validate_attributes( + start: &quick_xml::events::BytesStart<'_>, + element: &str, + limits: BillsObservationLimits, +) -> Result<(), BillsObservationError> { + let allowed: &[&str] = match element { + "BILLSPARTYCONTEXT" => &[ + "SCHEMA", + "PROFILE", + "OBJECTTYPE", + "COMPANYGUID", + "PARTYLEDGER", + "FROMDATE", + "TODATE", + "ASOFDATE", + "DIRECTION", + "QUERYPROFILE", + "BILLWISESTATE", + "ALLOCATIONCOUNT", + "OUTSTANDINGCOUNT", + ], + "BILLALLOCATION" => &[ + "ORIGIN", + "VOUCHERIDENTITY", + "PARTYENTRYORDINAL", + "ROWORDINAL", + "REFERENCEKIND", + "REFERENCENAME", + "BILLDATE", + "EFFECTIVEDATE", + "DUEDATE", + "AMOUNT", + "POLARITY", + "CURRENCY", + ], + "BILLOUTSTANDING" => &[ + "ROWORDINAL", + "REFERENCEKIND", + "REFERENCENAME", + "BILLDATE", + "EFFECTIVEDATE", + "DUEDATE", + "OPENINGAMOUNT", + "PENDINGAMOUNT", + "POLARITY", + "CURRENCY", + "OVERDUEDAYS", + ], + _ => &[], + }; + let mut seen = HashSet::new(); + let mut count = 0usize; + for attribute in start.attributes().with_checks(false) { + let attribute = attribute.map_err(|_| BillsObservationError::MalformedXml)?; + count = count.saturating_add(1); + if count > limits.max_attributes { + return Err(BillsObservationError::ResourceLimitExceeded); + } + let key = std::str::from_utf8(attribute.key.as_ref()) + .map_err(|_| BillsObservationError::MalformedXml)?; + if key != key.to_ascii_uppercase() || !seen.insert(key.to_string()) { + return Err(BillsObservationError::DuplicateField); + } + if !allowed.contains(&key) { + return Err(BillsObservationError::WrongGrammar); + } + if attribute.value.len() > limits.max_field_bytes { + return Err(BillsObservationError::ResourceLimitExceeded); + } + } + if allowed.is_empty() && count != 0 { + return Err(BillsObservationError::WrongGrammar); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn envelope(allocation: &str, outstanding: &str, counts: (&str, &str)) -> String { + format!( + "
1
{allocation}{outstanding}
", + counts.0, counts.1 + ) + } + + fn voucher_allocation(kind: &str, name: Option<&str>, amount: &str) -> String { + let name = name.map_or(String::new(), |value| format!(" REFERENCENAME=\"{value}\"")); + format!( + "" + ) + } + + fn outstanding(kind: &str, name: Option<&str>, pending: &str) -> String { + let name = name.map_or(String::new(), |value| format!(" REFERENCENAME=\"{value}\"")); + format!( + "" + ) + } + + #[test] + fn parses_unbound_exact_scope_opening_optional_dates_and_on_account() { + let xml = envelope( + "", + &outstanding("On Account", None, "-50"), + ("1", "1"), + ); + let parsed = parse_unbound_party_outstanding_observation( + xml.as_bytes(), + BillsObservationLimits::default(), + ) + .unwrap(); + assert_eq!( + parsed.evidence().binding(), + BillsObservationBinding::UnboundNoRequestArtifact + ); + assert_eq!( + parsed.allocations()[0].origin(), + ParsedBillOrigin::LedgerOpening + ); + assert_eq!( + parsed.allocations()[0].reference_kind(), + ParsedBillReferenceKind::OnAccount + ); + assert!(parsed.allocations()[0].reference_name().is_none()); + assert_eq!(parsed.outstanding()[0].pending_amount(), "-50"); + } + + #[test] + fn preserves_unclassified_reference_without_coercing_it() { + let xml = envelope( + &voucher_allocation("Future Ref", Some("SYNTHETIC-1"), "-100"), + &outstanding("Future Ref", Some("SYNTHETIC-1"), "-100"), + ("1", "1"), + ); + let parsed = parse_unbound_party_outstanding_observation( + xml.as_bytes(), + BillsObservationLimits::default(), + ) + .unwrap(); + assert_eq!( + parsed.allocations()[0].reference_kind(), + ParsedBillReferenceKind::Unclassified + ); + assert_eq!(parsed.allocations()[0].raw_reference_kind(), "Future Ref"); + } + + #[test] + fn exact_reference_types_partial_amounts_and_utf16_are_preserved() { + for kind in ["Advance", "Agst Ref", "New Ref"] { + let xml = envelope( + &voucher_allocation(kind, Some("SYNTHETIC-1"), "-500.000"), + &outstanding(kind, Some("SYNTHETIC-1"), "-500"), + ("1", "1"), + ); + let mut encoded = vec![0xff, 0xfe]; + encoded.extend( + xml.encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(), + ); + let parsed = parse_unbound_party_outstanding_observation( + encoded, + BillsObservationLimits::default(), + ) + .unwrap(); + assert_eq!(parsed.allocations()[0].amount(), "-500.000"); + assert_eq!(parsed.outstanding()[0].opening_amount(), Some("-1000")); + } + } + + #[test] + fn counts_scope_reference_rules_and_grammar_fail_closed() { + let valid_allocation = voucher_allocation("New Ref", Some("SYNTHETIC-1"), "-100"); + let valid_outstanding = outstanding("New Ref", Some("SYNTHETIC-1"), "-100"); + for xml in [ + envelope(&valid_allocation, &valid_outstanding, ("2", "1")), + envelope( + &voucher_allocation("", Some("SYNTHETIC-1"), "-100"), + &valid_outstanding, + ("1", "1"), + ), + envelope( + &voucher_allocation(" New Ref ", Some("SYNTHETIC-1"), "-100"), + &valid_outstanding, + ("1", "1"), + ), + envelope( + &voucher_allocation("New Ref", None, "-100"), + &valid_outstanding, + ("1", "1"), + ), + envelope( + &valid_allocation, + "", + ("1", "1"), + ), + envelope( + &format!("{valid_allocation}{valid_allocation}"), + &valid_outstanding, + ("2", "1"), + ), + envelope( + &valid_allocation, + &valid_outstanding.replace("/>", " UNKNOWN=\"x\"/>"), + ("1", "1"), + ), + ] { + assert!(parse_unbound_party_outstanding_observation( + xml.as_bytes(), + BillsObservationLimits::default(), + ) + .is_err()); + } + } + + #[test] + fn resource_limits_and_errors_never_echo_sensitive_values() { + let sentinel = "SENSITIVE-PARTY-AND-BILL"; + let xml = envelope( + &voucher_allocation("New Ref", Some(sentinel), "-100"), + &outstanding("New Ref", Some(sentinel), "-100"), + ("1", "1"), + ); + let limits = BillsObservationLimits { + max_field_bytes: 8, + ..BillsObservationLimits::default() + }; + let error = + parse_unbound_party_outstanding_observation(xml.as_bytes(), limits).unwrap_err(); + assert!(!format!("{error:?} {error}").contains(sentinel)); + + let limits = BillsObservationLimits { + max_records: 1, + ..BillsObservationLimits::default() + }; + assert_eq!( + parse_unbound_party_outstanding_observation(xml.as_bytes(), limits).unwrap_err(), + BillsObservationError::ResourceLimitExceeded + ); + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/india_tax_observation.rs b/src-tauri/crates/bridge-tally-protocol/src/india_tax_observation.rs new file mode 100644 index 0000000..00097a7 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/india_tax_observation.rs @@ -0,0 +1,1028 @@ +//! Dormant parser for one Bridge-owned India Tax observation envelope. +//! +//! The parsed values are deliberately unbound: there is no request artifact, +//! TDL report, runtime dispatch, canonical adapter, or completeness authority. + +use std::{collections::HashSet, fmt}; + +use quick_xml::{events::Event, Reader}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; + +use crate::{decode_tally_text_bytes_limited, TallyTextDecodeError, TallyTextEncoding}; + +pub const INDIA_TAX_OBSERVED_RAW_SCHEMA_V1: &str = "bridge.tally.india-tax-observed-raw/1"; +pub const INDIA_TAX_OBSERVED_RAW_PROFILE_V1: &str = "bridge.india-tax-observed-raw-xml/1"; + +const RESPONSE_HASH_DOMAIN: &[u8] = b"bridge.tally.india-tax-observed-raw-response/1\0"; +const FRAGMENT_HASH_DOMAIN: &[u8] = b"bridge.tally.india-tax-observed-raw-fragment/1\0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct IndiaTaxObservationLimits { + pub max_encoded_bytes: usize, + pub max_decoded_bytes: usize, + pub max_records: usize, + pub max_field_bytes: usize, + pub max_nodes: usize, + pub max_depth: usize, + pub max_attributes: usize, +} + +impl Default for IndiaTaxObservationLimits { + fn default() -> Self { + Self { + max_encoded_bytes: 8 * 1024 * 1024, + max_decoded_bytes: 8 * 1024 * 1024, + max_records: 25_000, + max_field_bytes: 512, + max_nodes: 250_000, + max_depth: 8, + max_attributes: 16, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndiaTaxObservationError { + InvalidLimits, + ResponseTooLarge, + DecodedResponseTooLarge, + InvalidEncoding, + MalformedXml, + ResourceLimitExceeded, + WrongGrammar, + DuplicateField, + ApplicationRejected, + ProfileMismatch, + InvalidValue, + MissingIdentity, + CountMismatch, + DuplicateObservation, +} + +impl IndiaTaxObservationError { + pub const fn safe_code(self) -> &'static str { + match self { + Self::InvalidLimits => "india_tax_observation_limits_invalid", + Self::ResponseTooLarge => "india_tax_observation_response_too_large", + Self::DecodedResponseTooLarge => "india_tax_observation_decoded_too_large", + Self::InvalidEncoding => "india_tax_observation_encoding_invalid", + Self::MalformedXml => "india_tax_observation_xml_malformed", + Self::ResourceLimitExceeded => "india_tax_observation_resource_limit", + Self::WrongGrammar => "india_tax_observation_grammar_invalid", + Self::DuplicateField => "india_tax_observation_field_duplicate", + Self::ApplicationRejected => "india_tax_observation_application_rejected", + Self::ProfileMismatch => "india_tax_observation_profile_mismatch", + Self::InvalidValue => "india_tax_observation_value_invalid", + Self::MissingIdentity => "india_tax_observation_identity_missing", + Self::CountMismatch => "india_tax_observation_count_mismatch", + Self::DuplicateObservation => "india_tax_observation_duplicate", + } + } +} + +impl fmt::Display for IndiaTaxObservationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.safe_code()) + } +} + +impl std::error::Error for IndiaTaxObservationError {} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndiaTaxObservationBinding { + UnboundNoRequestArtifact, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum IndiaTaxCountAuthority { + ResponseInternalOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ObservedTaxOwnerKind { + Company, + Ledger, +} + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct ObservedIdentityCandidates { + guid: Option, + remote_id: Option, + master_id: Option, +} + +impl fmt::Debug for ObservedIdentityCandidates { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ObservedIdentityCandidates") + .field("guid_present", &self.guid.is_some()) + .field("remote_id_present", &self.remote_id.is_some()) + .field("master_id_present", &self.master_id.is_some()) + .finish() + } +} + +impl ObservedIdentityCandidates { + pub fn guid(&self) -> Option<&str> { + self.guid.as_deref() + } + + pub fn remote_id(&self) -> Option<&str> { + self.remote_id.as_deref() + } + + pub fn master_id(&self) -> Option<&str> { + self.master_id.as_deref() + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ObservedRawTaxRegistration { + owner_kind: ObservedTaxOwnerKind, + owner_identities: ObservedIdentityCandidates, + owner_alter_id: Option, + registration_type: String, + gstin: String, + raw_fragment_sha256: String, +} + +impl fmt::Debug for ObservedRawTaxRegistration { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ObservedRawTaxRegistration") + .field("owner_kind", &self.owner_kind) + .field("owner_identities", &self.owner_identities) + .field("owner_alter_id_present", &self.owner_alter_id.is_some()) + .field( + "registration_type_present", + &!self.registration_type.is_empty(), + ) + .field("gstin_present", &!self.gstin.is_empty()) + .field("raw_fragment_sha256", &self.raw_fragment_sha256) + .finish() + } +} + +impl ObservedRawTaxRegistration { + pub const fn owner_kind(&self) -> ObservedTaxOwnerKind { + self.owner_kind + } + pub fn owner_identities(&self) -> &ObservedIdentityCandidates { + &self.owner_identities + } + pub fn owner_alter_id(&self) -> Option<&str> { + self.owner_alter_id.as_deref() + } + pub fn registration_type(&self) -> &str { + &self.registration_type + } + pub fn gstin(&self) -> &str { + &self.gstin + } + pub fn raw_fragment_sha256(&self) -> &str { + &self.raw_fragment_sha256 + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ObservedRawVoucherTax { + voucher_identities: ObservedIdentityCandidates, + voucher_alter_id: Option, + tax_row_ordinal: u64, + place_of_supply: String, + assessable_value: String, + tax_component: String, + tax_rate: String, + tax_amount: String, + raw_fragment_sha256: String, +} + +impl fmt::Debug for ObservedRawVoucherTax { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ObservedRawVoucherTax") + .field("voucher_identities", &self.voucher_identities) + .field("voucher_alter_id_present", &self.voucher_alter_id.is_some()) + .field("tax_row_ordinal", &self.tax_row_ordinal) + .field("raw_fragment_sha256", &self.raw_fragment_sha256) + .finish() + } +} + +impl ObservedRawVoucherTax { + pub fn voucher_identities(&self) -> &ObservedIdentityCandidates { + &self.voucher_identities + } + pub fn voucher_alter_id(&self) -> Option<&str> { + self.voucher_alter_id.as_deref() + } + pub const fn tax_row_ordinal(&self) -> u64 { + self.tax_row_ordinal + } + pub fn place_of_supply(&self) -> &str { + &self.place_of_supply + } + pub fn assessable_value(&self) -> &str { + &self.assessable_value + } + pub fn tax_component(&self) -> &str { + &self.tax_component + } + pub fn tax_rate(&self) -> &str { + &self.tax_rate + } + pub fn tax_amount(&self) -> &str { + &self.tax_amount + } + pub fn raw_fragment_sha256(&self) -> &str { + &self.raw_fragment_sha256 + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UnboundIndiaTaxEvidence { + profile_id: &'static str, + binding: IndiaTaxObservationBinding, + count_authority: IndiaTaxCountAuthority, + encoding: TallyTextEncoding, + encoded_bytes: u64, + decoded_bytes: u64, + claimed_company_guid: String, + claimed_from_yyyymmdd: String, + claimed_to_yyyymmdd: String, + claimed_registration_count: u64, + claimed_voucher_tax_count: u64, + response_sha256: String, +} + +impl fmt::Debug for UnboundIndiaTaxEvidence { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("UnboundIndiaTaxEvidence") + .field("profile_id", &self.profile_id) + .field("binding", &self.binding) + .field("count_authority", &self.count_authority) + .field("encoding", &self.encoding) + .field("encoded_bytes", &self.encoded_bytes) + .field("decoded_bytes", &self.decoded_bytes) + .field( + "claimed_registration_count", + &self.claimed_registration_count, + ) + .field("claimed_voucher_tax_count", &self.claimed_voucher_tax_count) + .field("response_sha256", &self.response_sha256) + .finish() + } +} + +impl UnboundIndiaTaxEvidence { + pub const fn profile_id(&self) -> &'static str { + self.profile_id + } + pub const fn binding(&self) -> IndiaTaxObservationBinding { + self.binding + } + pub const fn count_authority(&self) -> IndiaTaxCountAuthority { + self.count_authority + } + pub const fn encoding(&self) -> TallyTextEncoding { + self.encoding + } + pub const fn encoded_bytes(&self) -> u64 { + self.encoded_bytes + } + pub const fn decoded_bytes(&self) -> u64 { + self.decoded_bytes + } + pub fn claimed_company_guid(&self) -> &str { + &self.claimed_company_guid + } + pub fn claimed_from_yyyymmdd(&self) -> &str { + &self.claimed_from_yyyymmdd + } + pub fn claimed_to_yyyymmdd(&self) -> &str { + &self.claimed_to_yyyymmdd + } + pub const fn claimed_registration_count(&self) -> u64 { + self.claimed_registration_count + } + pub const fn claimed_voucher_tax_count(&self) -> u64 { + self.claimed_voucher_tax_count + } + pub fn response_sha256(&self) -> &str { + &self.response_sha256 + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UnboundIndiaTaxObservation { + tax_registrations: Vec, + voucher_taxes: Vec, + evidence: UnboundIndiaTaxEvidence, +} + +impl fmt::Debug for UnboundIndiaTaxObservation { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("UnboundIndiaTaxObservation") + .field("tax_registration_count", &self.tax_registrations.len()) + .field("voucher_tax_count", &self.voucher_taxes.len()) + .field("evidence", &self.evidence) + .finish() + } +} + +impl UnboundIndiaTaxObservation { + pub fn tax_registrations(&self) -> &[ObservedRawTaxRegistration] { + &self.tax_registrations + } + pub fn voucher_taxes(&self) -> &[ObservedRawVoucherTax] { + &self.voucher_taxes + } + pub fn evidence(&self) -> &UnboundIndiaTaxEvidence { + &self.evidence + } + pub const fn canonicalization_eligible(&self) -> bool { + false + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawEnvelope { + #[serde(rename = "HEADER")] + header: RawHeader, + #[serde(rename = "BODY")] + body: RawBody, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawHeader { + #[serde(rename = "STATUS")] + status: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawBody { + #[serde(rename = "INDIATAXCONTEXT")] + context: RawContext, + #[serde(rename = "TAXREGISTRATION", default)] + registrations: Vec, + #[serde(rename = "VOUCHERTAX", default)] + voucher_taxes: Vec, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawContext { + #[serde(rename = "@SCHEMA")] + schema: String, + #[serde(rename = "@PROFILE")] + profile: String, + #[serde(rename = "@OBJECTTYPE")] + object_type: String, + #[serde(rename = "@COMPANYGUID")] + company_guid: String, + #[serde(rename = "@FROMDATE")] + from_date: String, + #[serde(rename = "@TODATE")] + to_date: String, + #[serde(rename = "@TAXREGISTRATIONCOUNT")] + registration_count: String, + #[serde(rename = "@VOUCHERTAXCOUNT")] + voucher_tax_count: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawRegistration { + #[serde(rename = "@OWNERKIND")] + owner_kind: String, + #[serde(rename = "@OWNERGUID", default)] + owner_guid: Option, + #[serde(rename = "@OWNERREMOTEID", default)] + owner_remote_id: Option, + #[serde(rename = "@OWNERMASTERID", default)] + owner_master_id: Option, + #[serde(rename = "@OWNERALTERID", default)] + owner_alter_id: Option, + #[serde(rename = "REGISTRATIONTYPE")] + registration_type: String, + #[serde(rename = "GSTIN")] + gstin: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawVoucherTax { + #[serde(rename = "@VOUCHERGUID", default)] + voucher_guid: Option, + #[serde(rename = "@VOUCHERREMOTEID", default)] + voucher_remote_id: Option, + #[serde(rename = "@VOUCHERMASTERID", default)] + voucher_master_id: Option, + #[serde(rename = "@VOUCHERALTERID", default)] + voucher_alter_id: Option, + #[serde(rename = "@TAXROWORDINAL")] + tax_row_ordinal: String, + #[serde(rename = "PLACEOFSUPPLY")] + place_of_supply: String, + #[serde(rename = "ASSESSABLEVALUE")] + assessable_value: String, + #[serde(rename = "TAXCOMPONENT")] + tax_component: String, + #[serde(rename = "TAXRATE")] + tax_rate: String, + #[serde(rename = "TAXAMOUNT")] + tax_amount: String, +} + +pub fn parse_unbound_india_tax_observation( + bytes: impl AsRef<[u8]>, + limits: IndiaTaxObservationLimits, +) -> Result { + validate_limits(limits)?; + let encoded = bytes.as_ref(); + let decoded = decode_tally_text_bytes_limited(encoded, limits.max_encoded_bytes) + .map_err(map_decode_error)?; + if decoded.text.len() > limits.max_decoded_bytes { + return Err(IndiaTaxObservationError::DecodedResponseTooLarge); + } + let fragments = scan_exact_grammar(&decoded.text, limits)?; + let raw: RawEnvelope = quick_xml::de::from_str(&decoded.text) + .map_err(|_| IndiaTaxObservationError::MalformedXml)?; + if raw.header.status != "1" { + return Err(IndiaTaxObservationError::ApplicationRejected); + } + if raw.body.context.schema != INDIA_TAX_OBSERVED_RAW_SCHEMA_V1 + || raw.body.context.profile != INDIA_TAX_OBSERVED_RAW_PROFILE_V1 + || raw.body.context.object_type != "INDIATAXOBSERVATION" + { + return Err(IndiaTaxObservationError::ProfileMismatch); + } + validate_text( + &raw.body.context.company_guid, + limits.max_field_bytes, + false, + )?; + validate_date(&raw.body.context.from_date)?; + validate_date(&raw.body.context.to_date)?; + if raw.body.context.from_date > raw.body.context.to_date { + return Err(IndiaTaxObservationError::InvalidValue); + } + let claimed_registration_count = parse_u64(&raw.body.context.registration_count, true)?; + let claimed_voucher_tax_count = parse_u64(&raw.body.context.voucher_tax_count, true)?; + if raw + .body + .registrations + .len() + .saturating_add(raw.body.voucher_taxes.len()) + > limits.max_records + { + return Err(IndiaTaxObservationError::ResourceLimitExceeded); + } + if claimed_registration_count != raw.body.registrations.len() as u64 + || claimed_voucher_tax_count != raw.body.voucher_taxes.len() as u64 + { + return Err(IndiaTaxObservationError::CountMismatch); + } + if fragments.len() != raw.body.registrations.len() + raw.body.voucher_taxes.len() { + return Err(IndiaTaxObservationError::WrongGrammar); + } + + let mut fragment_iter = fragments.into_iter(); + let mut registration_keys = HashSet::new(); + let mut tax_registrations = Vec::with_capacity(raw.body.registrations.len()); + for registration in raw.body.registrations { + let owner_kind = match registration.owner_kind.as_str() { + "COMPANY" => ObservedTaxOwnerKind::Company, + "LEDGER" => ObservedTaxOwnerKind::Ledger, + _ => return Err(IndiaTaxObservationError::InvalidValue), + }; + validate_optional_text(®istration.owner_guid, limits.max_field_bytes)?; + validate_optional_text(®istration.owner_remote_id, limits.max_field_bytes)?; + validate_optional_text(®istration.owner_master_id, limits.max_field_bytes)?; + validate_optional_text(®istration.owner_alter_id, limits.max_field_bytes)?; + let identities = ObservedIdentityCandidates { + guid: registration.owner_guid, + remote_id: registration.owner_remote_id, + master_id: registration.owner_master_id, + }; + match owner_kind { + ObservedTaxOwnerKind::Company + if identities.guid.as_deref() != Some(raw.body.context.company_guid.as_str()) + || identities.remote_id.is_some() + || identities.master_id.is_some() => + { + return Err(IndiaTaxObservationError::MissingIdentity) + } + ObservedTaxOwnerKind::Ledger + if identities.guid.is_none() + && identities.remote_id.is_none() + && identities.master_id.is_none() => + { + return Err(IndiaTaxObservationError::MissingIdentity) + } + _ => {} + } + validate_text( + ®istration.registration_type, + limits.max_field_bytes, + false, + )?; + validate_gstin_shape(®istration.gstin, limits.max_field_bytes)?; + let key = ( + owner_kind, + identities.clone(), + registration.registration_type.clone(), + registration.gstin.clone(), + ); + if !registration_keys.insert(key) { + return Err(IndiaTaxObservationError::DuplicateObservation); + } + let fragment = fragment_iter + .next() + .ok_or(IndiaTaxObservationError::WrongGrammar)?; + tax_registrations.push(ObservedRawTaxRegistration { + owner_kind, + owner_identities: identities, + owner_alter_id: registration.owner_alter_id, + registration_type: registration.registration_type, + gstin: registration.gstin, + raw_fragment_sha256: hash_domain(FRAGMENT_HASH_DOMAIN, fragment.as_bytes()), + }); + } + + let mut voucher_keys = HashSet::new(); + let mut voucher_taxes = Vec::with_capacity(raw.body.voucher_taxes.len()); + for tax in raw.body.voucher_taxes { + validate_optional_text(&tax.voucher_guid, limits.max_field_bytes)?; + validate_optional_text(&tax.voucher_remote_id, limits.max_field_bytes)?; + validate_optional_text(&tax.voucher_master_id, limits.max_field_bytes)?; + validate_optional_text(&tax.voucher_alter_id, limits.max_field_bytes)?; + let identities = ObservedIdentityCandidates { + guid: tax.voucher_guid, + remote_id: tax.voucher_remote_id, + master_id: tax.voucher_master_id, + }; + if identities.guid.is_none() + && identities.remote_id.is_none() + && identities.master_id.is_none() + { + return Err(IndiaTaxObservationError::MissingIdentity); + } + let ordinal = parse_u64(&tax.tax_row_ordinal, false)?; + validate_text(&tax.place_of_supply, limits.max_field_bytes, false)?; + validate_text(&tax.tax_component, limits.max_field_bytes, false)?; + validate_decimal(&tax.assessable_value, limits.max_field_bytes, true)?; + validate_decimal(&tax.tax_rate, limits.max_field_bytes, false)?; + validate_decimal(&tax.tax_amount, limits.max_field_bytes, true)?; + if !voucher_keys.insert((identities.clone(), ordinal)) { + return Err(IndiaTaxObservationError::DuplicateObservation); + } + let fragment = fragment_iter + .next() + .ok_or(IndiaTaxObservationError::WrongGrammar)?; + voucher_taxes.push(ObservedRawVoucherTax { + voucher_identities: identities, + voucher_alter_id: tax.voucher_alter_id, + tax_row_ordinal: ordinal, + place_of_supply: tax.place_of_supply, + assessable_value: tax.assessable_value, + tax_component: tax.tax_component, + tax_rate: tax.tax_rate, + tax_amount: tax.tax_amount, + raw_fragment_sha256: hash_domain(FRAGMENT_HASH_DOMAIN, fragment.as_bytes()), + }); + } + + Ok(UnboundIndiaTaxObservation { + tax_registrations, + voucher_taxes, + evidence: UnboundIndiaTaxEvidence { + profile_id: INDIA_TAX_OBSERVED_RAW_PROFILE_V1, + binding: IndiaTaxObservationBinding::UnboundNoRequestArtifact, + count_authority: IndiaTaxCountAuthority::ResponseInternalOnly, + encoding: decoded.encoding, + encoded_bytes: encoded.len() as u64, + decoded_bytes: decoded.text.len() as u64, + claimed_company_guid: raw.body.context.company_guid, + claimed_from_yyyymmdd: raw.body.context.from_date, + claimed_to_yyyymmdd: raw.body.context.to_date, + claimed_registration_count, + claimed_voucher_tax_count, + response_sha256: hash_domain(RESPONSE_HASH_DOMAIN, decoded.text.as_bytes()), + }, + }) +} + +fn validate_limits(limits: IndiaTaxObservationLimits) -> Result<(), IndiaTaxObservationError> { + if limits.max_encoded_bytes == 0 + || limits.max_decoded_bytes == 0 + || limits.max_field_bytes == 0 + || limits.max_nodes == 0 + || limits.max_depth < 4 + || limits.max_attributes == 0 + { + return Err(IndiaTaxObservationError::InvalidLimits); + } + Ok(()) +} + +fn map_decode_error(error: TallyTextDecodeError) -> IndiaTaxObservationError { + match error { + TallyTextDecodeError::TooLarge => IndiaTaxObservationError::ResponseTooLarge, + _ => IndiaTaxObservationError::InvalidEncoding, + } +} + +fn validate_optional_text( + value: &Option, + limit: usize, +) -> Result<(), IndiaTaxObservationError> { + if let Some(value) = value { + validate_text(value, limit, false)?; + } + Ok(()) +} + +fn validate_text( + value: &str, + limit: usize, + allow_empty: bool, +) -> Result<(), IndiaTaxObservationError> { + if value.len() > limit + || (!allow_empty && value.is_empty()) + || value.chars().any(|character| character.is_control()) + { + return Err(IndiaTaxObservationError::InvalidValue); + } + Ok(()) +} + +fn validate_gstin_shape(value: &str, limit: usize) -> Result<(), IndiaTaxObservationError> { + validate_text(value, limit, false)?; + if value.len() != 15 + || !value + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + return Err(IndiaTaxObservationError::InvalidValue); + } + Ok(()) +} + +fn parse_u64(value: &str, allow_zero: bool) -> Result { + if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(IndiaTaxObservationError::InvalidValue); + } + let parsed = value + .parse::() + .map_err(|_| IndiaTaxObservationError::InvalidValue)?; + if !allow_zero && parsed == 0 { + return Err(IndiaTaxObservationError::InvalidValue); + } + Ok(parsed) +} + +fn validate_date(value: &str) -> Result<(), IndiaTaxObservationError> { + if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(IndiaTaxObservationError::InvalidValue); + } + let year = value[0..4] + .parse::() + .map_err(|_| IndiaTaxObservationError::InvalidValue)?; + let month = value[4..6] + .parse::() + .map_err(|_| IndiaTaxObservationError::InvalidValue)?; + let day = value[6..8] + .parse::() + .map_err(|_| IndiaTaxObservationError::InvalidValue)?; + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let max_day = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return Err(IndiaTaxObservationError::InvalidValue), + }; + if day == 0 || day > max_day { + return Err(IndiaTaxObservationError::InvalidValue); + } + Ok(()) +} + +fn validate_decimal( + value: &str, + limit: usize, + signed: bool, +) -> Result<(), IndiaTaxObservationError> { + validate_text(value, limit, false)?; + let digits = if signed { + value.strip_prefix('-').unwrap_or(value) + } else { + value + }; + if digits.is_empty() || value.starts_with('+') || (!signed && value.starts_with('-')) { + return Err(IndiaTaxObservationError::InvalidValue); + } + let mut parts = digits.split('.'); + let whole = parts.next().unwrap_or_default(); + let fraction = parts.next(); + if parts.next().is_some() + || whole.is_empty() + || !whole.bytes().all(|byte| byte.is_ascii_digit()) + || (whole.len() > 1 && whole.starts_with('0')) + || fraction + .is_some_and(|part| part.is_empty() || !part.bytes().all(|byte| byte.is_ascii_digit())) + { + return Err(IndiaTaxObservationError::InvalidValue); + } + Ok(()) +} + +fn hash_domain(domain: &[u8], bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(domain); + hasher.update(bytes); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + use std::fmt::Write as _; + let _ = write!(encoded, "{byte:02x}"); + } + encoded +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum RowKind { + Registration, + VoucherTax, +} + +fn scan_exact_grammar( + xml: &str, + limits: IndiaTaxObservationLimits, +) -> Result, IndiaTaxObservationError> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(false); + let mut stack = Vec::::new(); + let mut nodes = 0usize; + let mut fragments = Vec::new(); + let mut row_start = None::<(RowKind, usize)>; + let mut body_stage = 0u8; + let mut header_status_seen = false; + let mut context_seen = false; + let mut current_child_index = 0usize; + let mut root_closed = false; + loop { + let before = reader.buffer_position() as usize; + let event = reader + .read_event() + .map_err(|_| IndiaTaxObservationError::MalformedXml)?; + nodes = nodes.saturating_add(1); + if nodes > limits.max_nodes { + return Err(IndiaTaxObservationError::ResourceLimitExceeded); + } + match event { + Event::Start(start) => { + if root_closed { + return Err(IndiaTaxObservationError::WrongGrammar); + } + let name = event_name(start.name().as_ref())?; + validate_attributes(&start, &name, limits.max_attributes, limits.max_field_bytes)?; + validate_start( + &stack, + &name, + &mut body_stage, + &mut header_status_seen, + &mut context_seen, + &mut current_child_index, + )?; + if name == "TAXREGISTRATION" { + row_start = Some((RowKind::Registration, before)); + } + if name == "VOUCHERTAX" { + row_start = Some((RowKind::VoucherTax, before)); + } + stack.push(name); + if stack.len() > limits.max_depth { + return Err(IndiaTaxObservationError::ResourceLimitExceeded); + } + } + Event::Empty(empty) => { + if root_closed { + return Err(IndiaTaxObservationError::WrongGrammar); + } + let name = event_name(empty.name().as_ref())?; + validate_attributes(&empty, &name, limits.max_attributes, limits.max_field_bytes)?; + if stack.as_slice() != ["ENVELOPE", "BODY"] + || name != "INDIATAXCONTEXT" + || context_seen + || body_stage != 0 + { + return Err(IndiaTaxObservationError::WrongGrammar); + } + context_seen = true; + body_stage = 1; + } + Event::End(end) => { + let name = event_name(end.name().as_ref())?; + if stack.last().map(String::as_str) != Some(name.as_str()) { + return Err(IndiaTaxObservationError::WrongGrammar); + } + if name == "TAXREGISTRATION" || name == "VOUCHERTAX" { + let expected = if name == "TAXREGISTRATION" { + RowKind::Registration + } else { + RowKind::VoucherTax + }; + let (kind, start) = row_start + .take() + .ok_or(IndiaTaxObservationError::WrongGrammar)?; + if kind != expected { + return Err(IndiaTaxObservationError::WrongGrammar); + } + let after = reader.buffer_position() as usize; + fragments.push( + xml.get(start..after) + .ok_or(IndiaTaxObservationError::MalformedXml)? + .to_owned(), + ); + current_child_index = 0; + } + stack.pop(); + if name == "ENVELOPE" { + root_closed = true; + } + } + Event::Text(text) => { + let decoded = text + .decode() + .map_err(|_| IndiaTaxObservationError::MalformedXml)?; + if decoded.len() > limits.max_field_bytes { + return Err(IndiaTaxObservationError::ResourceLimitExceeded); + } + let leaf = stack.last().map(String::as_str); + let allowed_leaf = matches!( + leaf, + Some( + "STATUS" + | "REGISTRATIONTYPE" + | "GSTIN" + | "PLACEOFSUPPLY" + | "ASSESSABLEVALUE" + | "TAXCOMPONENT" + | "TAXRATE" + | "TAXAMOUNT" + ) + ); + if !allowed_leaf && !decoded.chars().all(char::is_whitespace) { + return Err(IndiaTaxObservationError::WrongGrammar); + } + } + Event::Decl(_) => { + if nodes != 1 || !stack.is_empty() { + return Err(IndiaTaxObservationError::WrongGrammar); + } + } + Event::Eof => break, + Event::Comment(_) + | Event::CData(_) + | Event::PI(_) + | Event::DocType(_) + | Event::GeneralRef(_) => return Err(IndiaTaxObservationError::WrongGrammar), + } + } + if !root_closed + || !stack.is_empty() + || !header_status_seen + || !context_seen + || row_start.is_some() + { + return Err(IndiaTaxObservationError::WrongGrammar); + } + Ok(fragments) +} + +fn event_name(bytes: &[u8]) -> Result { + let name = std::str::from_utf8(bytes).map_err(|_| IndiaTaxObservationError::MalformedXml)?; + if !name.bytes().all(|byte| byte.is_ascii_uppercase()) { + return Err(IndiaTaxObservationError::WrongGrammar); + } + Ok(name.to_owned()) +} + +fn validate_start( + stack: &[String], + name: &str, + body_stage: &mut u8, + header_status_seen: &mut bool, + context_seen: &mut bool, + child_index: &mut usize, +) -> Result<(), IndiaTaxObservationError> { + let path = stack.iter().map(String::as_str).collect::>(); + match (path.as_slice(), name) { + ([], "ENVELOPE") => {} + (["ENVELOPE"], "HEADER") => {} + (["ENVELOPE", "HEADER"], "STATUS") if !*header_status_seen => *header_status_seen = true, + (["ENVELOPE"], "BODY") if *header_status_seen => {} + (["ENVELOPE", "BODY"], "INDIATAXCONTEXT") if !*context_seen && *body_stage == 0 => { + *context_seen = true; + *body_stage = 1; + } + (["ENVELOPE", "BODY"], "TAXREGISTRATION") if *context_seen && *body_stage <= 1 => { + *body_stage = 1; + *child_index = 0; + } + (["ENVELOPE", "BODY"], "VOUCHERTAX") if *context_seen => { + *body_stage = 2; + *child_index = 0; + } + (["ENVELOPE", "BODY", "TAXREGISTRATION"], child) => { + let expected = ["REGISTRATIONTYPE", "GSTIN"]; + if expected.get(*child_index) != Some(&child) { + return Err(IndiaTaxObservationError::WrongGrammar); + } + *child_index += 1; + } + (["ENVELOPE", "BODY", "VOUCHERTAX"], child) => { + let expected = [ + "PLACEOFSUPPLY", + "ASSESSABLEVALUE", + "TAXCOMPONENT", + "TAXRATE", + "TAXAMOUNT", + ]; + if expected.get(*child_index) != Some(&child) { + return Err(IndiaTaxObservationError::WrongGrammar); + } + *child_index += 1; + } + _ => return Err(IndiaTaxObservationError::WrongGrammar), + } + Ok(()) +} + +fn validate_attributes( + start: &quick_xml::events::BytesStart<'_>, + element: &str, + max_attributes: usize, + max_field_bytes: usize, +) -> Result<(), IndiaTaxObservationError> { + let allowed: &[&str] = match element { + "INDIATAXCONTEXT" => &[ + "SCHEMA", + "PROFILE", + "OBJECTTYPE", + "COMPANYGUID", + "FROMDATE", + "TODATE", + "TAXREGISTRATIONCOUNT", + "VOUCHERTAXCOUNT", + ], + "TAXREGISTRATION" => &[ + "OWNERKIND", + "OWNERGUID", + "OWNERREMOTEID", + "OWNERMASTERID", + "OWNERALTERID", + ], + "VOUCHERTAX" => &[ + "VOUCHERGUID", + "VOUCHERREMOTEID", + "VOUCHERMASTERID", + "VOUCHERALTERID", + "TAXROWORDINAL", + ], + _ => &[], + }; + let mut seen = HashSet::new(); + let mut count = 0usize; + for attribute in start.attributes().with_checks(false) { + let attribute = attribute.map_err(|_| IndiaTaxObservationError::MalformedXml)?; + count += 1; + if count > max_attributes { + return Err(IndiaTaxObservationError::ResourceLimitExceeded); + } + let key = std::str::from_utf8(attribute.key.as_ref()) + .map_err(|_| IndiaTaxObservationError::MalformedXml)?; + let folded = key.to_ascii_uppercase(); + if key != folded || !seen.insert(folded.clone()) { + return Err(IndiaTaxObservationError::DuplicateField); + } + if !allowed.contains(&folded.as_str()) { + return Err(IndiaTaxObservationError::WrongGrammar); + } + if attribute.value.len() > max_field_bytes { + return Err(IndiaTaxObservationError::ResourceLimitExceeded); + } + } + if allowed.is_empty() && count != 0 { + return Err(IndiaTaxObservationError::WrongGrammar); + } + Ok(()) +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/jsonex.rs b/src-tauri/crates/bridge-tally-protocol/src/jsonex.rs new file mode 100644 index 0000000..04f717b --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/jsonex.rs @@ -0,0 +1,1311 @@ +//! Feature-gated parsing for the native JSON collection envelopes documented +//! by TallyPrime 7.0+. +//! +//! The official Ledger and Voucher examples do not echo a stable company +//! identity, Bridge schema, exact query profile, source counts, or a requested +//! date range. Consequently every successful result in this module is named +//! `Unbound` and is ineligible for canonical Core accounting or transport +//! qualification. The production Bridge application does not enable this +//! Cargo feature. + +use std::{collections::BTreeMap, fmt}; + +use serde::{ + de::{DeserializeSeed, Error as _, MapAccess, SeqAccess, Visitor}, + Deserialize, Deserializer, +}; +use serde_json::value::RawValue; + +use crate::{decode_tally_text_bytes_limited, TallyTextDecodeError, TallyTextEncoding}; + +pub const DOCUMENTED_LEDGER_COLLECTION_PROFILE_V1: &str = + "tally.documented-jsonex-ledger-collection/1"; +pub const DOCUMENTED_VOUCHER_COLLECTION_PROFILE_V1: &str = + "tally.documented-jsonex-voucher-collection/1"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct JsonExLimits { + pub max_encoded_bytes: usize, + pub max_decoded_bytes: usize, + pub max_depth: usize, + pub max_nodes: usize, + pub max_object_members: usize, + pub max_array_items: usize, + pub max_string_bytes: usize, + pub max_records: usize, + pub max_record_decoded_bytes: usize, +} + +impl Default for JsonExLimits { + fn default() -> Self { + Self { + max_encoded_bytes: 8 * 1024 * 1024, + max_decoded_bytes: 8 * 1024 * 1024, + max_depth: 32, + max_nodes: 250_000, + max_object_members: 512, + max_array_items: 50_000, + max_string_bytes: 1024 * 1024, + max_records: 25_000, + max_record_decoded_bytes: 1024 * 1024, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsonExExpectedEncoding { + Utf8, + Utf16Le, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsonExProtocolError { + ResponseTooLarge, + DecodedResponseTooLarge, + UnsupportedContentType, + EncodingMismatch, + UnsupportedEncoding, + InvalidEncoding, + EmbeddedNull, + MalformedJson, + DuplicateField, + ResourceLimitExceeded, + StatusMissing, + StatusInvalid, + ApplicationRejected, + WrongContainer, + RecordLimitExceeded, + RecordTooLarge, + ProfileMismatch, + TypedValueMismatch, +} + +impl JsonExProtocolError { + pub fn safe_code(self) -> &'static str { + match self { + Self::ResponseTooLarge => "jsonex_response_too_large", + Self::DecodedResponseTooLarge => "jsonex_decoded_response_too_large", + Self::UnsupportedContentType => "jsonex_content_type_unsupported", + Self::EncodingMismatch => "jsonex_encoding_mismatch", + Self::UnsupportedEncoding => "jsonex_encoding_unsupported", + Self::InvalidEncoding => "jsonex_encoding_invalid", + Self::EmbeddedNull => "jsonex_embedded_null", + Self::MalformedJson => "jsonex_malformed_json", + Self::DuplicateField => "jsonex_duplicate_field", + Self::ResourceLimitExceeded => "jsonex_resource_limit_exceeded", + Self::StatusMissing => "jsonex_status_missing", + Self::StatusInvalid => "jsonex_status_invalid", + Self::ApplicationRejected => "jsonex_application_rejected", + Self::WrongContainer => "jsonex_wrong_container", + Self::RecordLimitExceeded => "jsonex_record_limit_exceeded", + Self::RecordTooLarge => "jsonex_record_too_large", + Self::ProfileMismatch => "jsonex_profile_mismatch", + Self::TypedValueMismatch => "jsonex_typed_value_mismatch", + } + } +} + +impl fmt::Display for JsonExProtocolError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.safe_code()) + } +} + +impl std::error::Error for JsonExProtocolError {} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum JsonExApplicationStatus { + Success, + Failure, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct JsonExEnvelopeEvidence { + pub profile_id: &'static str, + pub status: JsonExApplicationStatus, + pub encoding: TallyTextEncoding, + pub encoded_bytes: u64, + pub decoded_bytes: u64, + pub record_count: u64, +} + +#[derive(Clone, PartialEq, Eq)] +pub enum JsonExTextPresence { + Absent, + Empty, + Value(String), +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UnboundJsonExLedgerRecord { + pub name: String, + pub parent: JsonExTextPresence, + pub closing_balance: JsonExTextPresence, + pub opening_balance: JsonExTextPresence, + pub language_names: Vec>, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UnboundJsonExLedgerCollection { + pub records: Vec, + pub evidence: JsonExEnvelopeEvidence, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UnboundJsonExVoucherRecord { + pub remote_id: String, + pub guid: String, + pub date_yyyymmdd: String, + pub voucher_type: String, + pub voucher_number: Option, + pub amount: JsonExTextPresence, + pub all_ledger_entry_count: u64, + pub inventory_entry_count: u64, + pub invoice_ledger_entry_count: u64, + pub batch_allocation_count: u64, + pub accounting_allocation_count: u64, + pub bill_allocation_count: u64, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct UnboundJsonExVoucherCollection { + pub records: Vec, + pub evidence: JsonExEnvelopeEvidence, +} + +pub fn parse_documented_ledger_collection_v1( + bytes: &[u8], + observed_content_type: &str, + expected_encoding: JsonExExpectedEncoding, + limits: JsonExLimits, +) -> Result { + let parsed = parse_collection_envelope( + bytes, + observed_content_type, + expected_encoding, + limits, + DOCUMENTED_LEDGER_COLLECTION_PROFILE_V1, + )?; + validate_ledger_metadata(&parsed.metadata)?; + let records = parsed + .records + .iter() + .map(parse_ledger_record) + .collect::, _>>()?; + Ok(UnboundJsonExLedgerCollection { + records, + evidence: parsed.evidence, + }) +} + +pub fn parse_documented_voucher_collection_v1( + bytes: &[u8], + observed_content_type: &str, + expected_encoding: JsonExExpectedEncoding, + limits: JsonExLimits, +) -> Result { + let parsed = parse_collection_envelope( + bytes, + observed_content_type, + expected_encoding, + limits, + DOCUMENTED_VOUCHER_COLLECTION_PROFILE_V1, + )?; + validate_voucher_metadata(&parsed.metadata)?; + let records = parsed + .records + .iter() + .map(parse_voucher_record) + .collect::, _>>()?; + Ok(UnboundJsonExVoucherCollection { + records, + evidence: parsed.evidence, + }) +} + +struct ParsedCollectionEnvelope { + metadata: StrictJson, + records: Vec, + evidence: JsonExEnvelopeEvidence, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCollectionEnvelope<'a> { + status: &'a str, + #[serde(borrow)] + data: RawCollectionData<'a>, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawCollectionData<'a> { + #[serde(rename = "metadata", borrow)] + _metadata: &'a RawValue, + #[serde(borrow)] + collection: Vec<&'a RawValue>, +} + +fn parse_collection_envelope( + bytes: &[u8], + observed_content_type: &str, + expected_encoding: JsonExExpectedEncoding, + limits: JsonExLimits, + profile_id: &'static str, +) -> Result { + validate_limits_configuration(limits)?; + let decoded = decode_jsonex_bytes(bytes, observed_content_type, expected_encoding, limits)?; + let strict = parse_strict_json(&decoded.text, limits)?; + let root = as_object(&strict).ok_or(JsonExProtocolError::MalformedJson)?; + let status = root + .get("status") + .ok_or(JsonExProtocolError::StatusMissing) + .and_then(|value| as_str(value).ok_or(JsonExProtocolError::StatusInvalid))?; + match status { + "0" => return Err(JsonExProtocolError::ApplicationRejected), + "1" => {} + _ => return Err(JsonExProtocolError::StatusInvalid), + } + require_allowed_keys( + root, + &["status", "data"], + JsonExProtocolError::WrongContainer, + )?; + let data = root + .get("data") + .and_then(as_object) + .ok_or(JsonExProtocolError::WrongContainer)?; + require_allowed_keys( + data, + &["metadata", "collection"], + JsonExProtocolError::WrongContainer, + )?; + let strict_records = data + .get("collection") + .and_then(as_array) + .ok_or(JsonExProtocolError::WrongContainer)?; + if strict_records.len() > limits.max_records { + return Err(JsonExProtocolError::RecordLimitExceeded); + } + + let raw: RawCollectionEnvelope<'_> = + serde_json::from_str(&decoded.text).map_err(|_| JsonExProtocolError::WrongContainer)?; + if raw.status != "1" { + return Err(JsonExProtocolError::StatusInvalid); + } + for record in &raw.data.collection { + if record.get().len() > limits.max_record_decoded_bytes { + return Err(JsonExProtocolError::RecordTooLarge); + } + } + + let mut root = into_object(strict).ok_or(JsonExProtocolError::WrongContainer)?; + let mut data = root + .remove("data") + .and_then(into_object) + .ok_or(JsonExProtocolError::WrongContainer)?; + let metadata = data + .remove("metadata") + .ok_or(JsonExProtocolError::WrongContainer)?; + let records = data + .remove("collection") + .and_then(into_array) + .ok_or(JsonExProtocolError::WrongContainer)?; + Ok(ParsedCollectionEnvelope { + metadata, + evidence: JsonExEnvelopeEvidence { + profile_id, + status: JsonExApplicationStatus::Success, + encoding: decoded.encoding, + encoded_bytes: u64::try_from(bytes.len()) + .map_err(|_| JsonExProtocolError::ResourceLimitExceeded)?, + decoded_bytes: u64::try_from(decoded.text.len()) + .map_err(|_| JsonExProtocolError::ResourceLimitExceeded)?, + record_count: u64::try_from(records.len()) + .map_err(|_| JsonExProtocolError::ResourceLimitExceeded)?, + }, + records, + }) +} + +fn validate_limits_configuration(limits: JsonExLimits) -> Result<(), JsonExProtocolError> { + if limits.max_encoded_bytes == 0 + || limits.max_decoded_bytes == 0 + || limits.max_depth == 0 + || limits.max_nodes == 0 + || limits.max_object_members == 0 + || limits.max_array_items == 0 + || limits.max_string_bytes == 0 + || limits.max_records == 0 + || limits.max_record_decoded_bytes == 0 + { + return Err(JsonExProtocolError::ResourceLimitExceeded); + } + Ok(()) +} + +fn decode_jsonex_bytes( + bytes: &[u8], + observed_content_type: &str, + expected_encoding: JsonExExpectedEncoding, + limits: JsonExLimits, +) -> Result { + let declared_charset = parse_content_type(observed_content_type)?; + let decoded = + decode_tally_text_bytes_limited(bytes, limits.max_encoded_bytes).map_err(|error| { + match error { + TallyTextDecodeError::TooLarge => JsonExProtocolError::ResponseTooLarge, + TallyTextDecodeError::InvalidUtf8 + | TallyTextDecodeError::InvalidUtf16Le + | TallyTextDecodeError::InvalidUtf16Be => JsonExProtocolError::InvalidEncoding, + } + })?; + if decoded.text.len() > limits.max_decoded_bytes { + return Err(JsonExProtocolError::DecodedResponseTooLarge); + } + if decoded.text.contains('\0') { + return Err(JsonExProtocolError::EmbeddedNull); + } + if decoded.encoding == TallyTextEncoding::Utf16BeBom { + return Err(JsonExProtocolError::UnsupportedEncoding); + } + let actual_utf16 = decoded.encoding == TallyTextEncoding::Utf16LeBom; + let expected_utf16 = expected_encoding == JsonExExpectedEncoding::Utf16Le; + if actual_utf16 != expected_utf16 { + return Err(JsonExProtocolError::EncodingMismatch); + } + if declared_charset.is_some_and(|charset| charset != expected_encoding) { + return Err(JsonExProtocolError::EncodingMismatch); + } + Ok(decoded) +} + +fn parse_content_type( + content_type: &str, +) -> Result, JsonExProtocolError> { + if content_type.chars().any(char::is_control) { + return Err(JsonExProtocolError::UnsupportedContentType); + } + let mut parts = content_type.split(';'); + if !parts.next().is_some_and(|media| { + media + .trim_matches(' ') + .eq_ignore_ascii_case("application/json") + }) { + return Err(JsonExProtocolError::UnsupportedContentType); + } + let mut charset = None; + for parameter in parts { + let (name, value) = parameter + .split_once('=') + .ok_or(JsonExProtocolError::UnsupportedContentType)?; + if !name.trim_matches(' ').eq_ignore_ascii_case("charset") || charset.is_some() { + return Err(JsonExProtocolError::UnsupportedContentType); + } + let value = value.trim_matches(' '); + let starts_quoted = value.starts_with('"'); + let ends_quoted = value.ends_with('"'); + if starts_quoted != ends_quoted { + return Err(JsonExProtocolError::UnsupportedContentType); + } + let value = + if starts_quoted { + if value.len() < 2 { + return Err(JsonExProtocolError::UnsupportedContentType); + } + let inner = &value[1..value.len() - 1]; + if inner.chars().any(|character| { + character == '"' || character == '\\' || character.is_control() + }) { + return Err(JsonExProtocolError::UnsupportedContentType); + } + inner + } else { + if value.chars().any(|character| { + character == '"' || character == '\\' || character.is_control() + }) { + return Err(JsonExProtocolError::UnsupportedContentType); + } + value + }; + charset = Some( + if value.eq_ignore_ascii_case("utf-8") || value.eq_ignore_ascii_case("utf8") { + JsonExExpectedEncoding::Utf8 + } else if value.eq_ignore_ascii_case("utf-16") || value.eq_ignore_ascii_case("utf-16le") + { + JsonExExpectedEncoding::Utf16Le + } else { + return Err(JsonExProtocolError::UnsupportedContentType); + }, + ); + } + Ok(charset) +} + +#[derive(Clone, PartialEq)] +enum StrictJson { + Null, + Bool(bool), + Signed(i64), + Unsigned(u64), + Fractional, + String(String), + Array(Vec), + Object(BTreeMap), +} + +#[derive(Default)] +struct JsonLimitState { + nodes: usize, +} + +struct StrictJsonSeed<'a> { + limits: JsonExLimits, + state: &'a mut JsonLimitState, + depth: usize, + reject_before_value: bool, +} + +impl<'de> DeserializeSeed<'de> for StrictJsonSeed<'_> { + type Value = StrictJson; + + fn deserialize(self, deserializer: D) -> Result + where + D: Deserializer<'de>, + { + if self.reject_before_value || self.depth > self.limits.max_depth { + return Err(D::Error::custom("jsonex_resource_limit_exceeded")); + } + self.state.nodes = self + .state + .nodes + .checked_add(1) + .ok_or_else(|| D::Error::custom("jsonex_resource_limit_exceeded"))?; + if self.state.nodes > self.limits.max_nodes { + return Err(D::Error::custom("jsonex_resource_limit_exceeded")); + } + deserializer.deserialize_any(StrictJsonVisitor { + limits: self.limits, + state: self.state, + depth: self.depth, + }) + } +} + +struct StrictJsonVisitor<'a> { + limits: JsonExLimits, + state: &'a mut JsonLimitState, + depth: usize, +} + +impl<'de> Visitor<'de> for StrictJsonVisitor<'_> { + type Value = StrictJson; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a duplicate-free bounded JSON value") + } + + fn visit_unit(self) -> Result { + Ok(StrictJson::Null) + } + + fn visit_none(self) -> Result { + Ok(StrictJson::Null) + } + + fn visit_bool(self, value: bool) -> Result { + Ok(StrictJson::Bool(value)) + } + + fn visit_i64(self, value: i64) -> Result { + Ok(StrictJson::Signed(value)) + } + + fn visit_u64(self, value: u64) -> Result { + Ok(StrictJson::Unsigned(value)) + } + + fn visit_f64(self, _value: f64) -> Result + where + E: serde::de::Error, + { + Ok(StrictJson::Fractional) + } + + fn visit_str(self, value: &str) -> Result + where + E: serde::de::Error, + { + if value.len() > self.limits.max_string_bytes { + return Err(E::custom("jsonex_resource_limit_exceeded")); + } + Ok(StrictJson::String(value.to_string())) + } + + fn visit_string(self, value: String) -> Result + where + E: serde::de::Error, + { + if value.len() > self.limits.max_string_bytes { + return Err(E::custom("jsonex_resource_limit_exceeded")); + } + Ok(StrictJson::String(value)) + } + + fn visit_seq(self, mut sequence: A) -> Result + where + A: SeqAccess<'de>, + { + let mut values = Vec::new(); + loop { + let reject_before_value = values.len() >= self.limits.max_array_items; + let next = sequence.next_element_seed(StrictJsonSeed { + limits: self.limits, + state: &mut *self.state, + depth: self.depth + 1, + reject_before_value, + })?; + match next { + Some(value) => values.push(value), + None => break, + } + } + Ok(StrictJson::Array(values)) + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut values = BTreeMap::new(); + while let Some(key) = map.next_key::()? { + if values.len() >= self.limits.max_object_members + || key.len() > self.limits.max_string_bytes + { + return Err(A::Error::custom("jsonex_resource_limit_exceeded")); + } + if values.contains_key(&key) { + return Err(A::Error::custom("jsonex_duplicate_field")); + } + let value = map.next_value_seed(StrictJsonSeed { + limits: self.limits, + state: &mut *self.state, + depth: self.depth + 1, + reject_before_value: false, + })?; + values.insert(key, value); + } + Ok(StrictJson::Object(values)) + } +} + +fn parse_strict_json(text: &str, limits: JsonExLimits) -> Result { + let mut deserializer = serde_json::Deserializer::from_str(text); + let mut state = JsonLimitState::default(); + let value = StrictJsonSeed { + limits, + state: &mut state, + depth: 1, + reject_before_value: false, + } + .deserialize(&mut deserializer) + .map_err(|error| { + if error.to_string().contains("jsonex_duplicate_field") { + JsonExProtocolError::DuplicateField + } else if error.to_string().contains("jsonex_resource_limit_exceeded") { + JsonExProtocolError::ResourceLimitExceeded + } else { + JsonExProtocolError::MalformedJson + } + })?; + deserializer + .end() + .map_err(|_| JsonExProtocolError::MalformedJson)?; + Ok(value) +} + +fn validate_ledger_metadata(value: &StrictJson) -> Result<(), JsonExProtocolError> { + let metadata = required_object(value)?; + require_allowed_keys( + metadata, + &["is_mst_dep_type", "mst_dep_type"], + JsonExProtocolError::ProfileMismatch, + )?; + if !required_bool(metadata, "is_mst_dep_type")? + || required_string(metadata, "mst_dep_type")? != "8" + { + return Err(JsonExProtocolError::ProfileMismatch); + } + Ok(()) +} + +fn parse_ledger_record( + value: &StrictJson, +) -> Result { + let record = required_object(value)?; + require_allowed_keys( + record, + &[ + "metadata", + "parent", + "ledgerphone", + "ledgercontact", + "closingbalance", + "onaccountvalue", + "tbalopening", + "closingonacctvalue", + "closingdronacctvalue", + "ledopeningbalance", + "languagename", + ], + JsonExProtocolError::ProfileMismatch, + )?; + let metadata = required_object(required_field(record, "metadata")?)?; + require_allowed_keys( + metadata, + &["type", "name", "reservedname"], + JsonExProtocolError::ProfileMismatch, + )?; + if required_string(metadata, "type")? != "Ledger" { + return Err(JsonExProtocolError::ProfileMismatch); + } + let name = required_string(metadata, "name")?.to_string(); + required_string(metadata, "reservedname")?; + let parent = typed_text_presence(record, "parent", "String", TextRule::Any)?; + typed_text_presence(record, "ledgerphone", "String", TextRule::Any)?; + typed_text_presence(record, "ledgercontact", "String", TextRule::Any)?; + let closing_balance = typed_text_presence( + record, + "closingbalance", + "Amount", + TextRule::OptionalExactDecimal, + )?; + typed_text_presence( + record, + "onaccountvalue", + "Amount", + TextRule::OptionalExactDecimal, + )?; + typed_text_presence( + record, + "tbalopening", + "Amount", + TextRule::OptionalExactDecimal, + )?; + typed_text_presence( + record, + "closingonacctvalue", + "Amount", + TextRule::OptionalExactDecimal, + )?; + typed_logical_optional(record, "closingdronacctvalue")?; + let opening_balance = typed_text_presence( + record, + "ledopeningbalance", + "Amount", + TextRule::OptionalExactDecimal, + )?; + let language_names = match record.get("languagename") { + None => Vec::new(), + Some(value) => parse_language_names(value)?, + }; + Ok(UnboundJsonExLedgerRecord { + name, + parent, + closing_balance, + opening_balance, + language_names, + }) +} + +fn parse_language_names(value: &StrictJson) -> Result>, JsonExProtocolError> { + as_array(value) + .ok_or(JsonExProtocolError::ProfileMismatch)? + .iter() + .map(|entry| { + let entry = required_object(entry)?; + require_allowed_keys( + entry, + &["name", "languageid"], + JsonExProtocolError::ProfileMismatch, + )?; + if let Some(language_id) = entry.get("languageid") { + typed_text_value(language_id, "Number", TextRule::UnsignedInteger)?; + } + let names = as_array(required_field(entry, "name")?) + .ok_or(JsonExProtocolError::ProfileMismatch)?; + if names.len() < 2 { + return Err(JsonExProtocolError::ProfileMismatch); + } + let descriptor = required_object(&names[0])?; + require_allowed_keys( + descriptor, + &["metadata", "type"], + JsonExProtocolError::ProfileMismatch, + )?; + if !required_bool(descriptor, "metadata")? + || required_string(descriptor, "type")? != "String" + { + return Err(JsonExProtocolError::ProfileMismatch); + } + names[1..] + .iter() + .map(|name| { + as_str(name) + .map(str::to_string) + .ok_or(JsonExProtocolError::TypedValueMismatch) + }) + .collect() + }) + .collect() +} + +fn validate_voucher_metadata(value: &StrictJson) -> Result<(), JsonExProtocolError> { + let metadata = required_object(value)?; + require_allowed_keys( + metadata, + &["is_cmp_dep_type", "cmp_locus", "cmp_dep_type"], + JsonExProtocolError::ProfileMismatch, + )?; + if !required_bool(metadata, "is_cmp_dep_type")? + || required_unsigned(metadata, "cmp_locus")? != 4 + || required_unsigned(metadata, "cmp_dep_type")? != 64 + { + return Err(JsonExProtocolError::ProfileMismatch); + } + Ok(()) +} + +fn parse_voucher_record( + value: &StrictJson, +) -> Result { + let record = required_object(value)?; + require_allowed_keys( + record, + &[ + "metadata", + "date", + "guid", + "vouchertypename", + "vouchernumber", + "reference", + "serialmaster", + "areserialmaster", + "numberingstyle", + "persistedview", + "isdeleted", + "asoriginal", + "isdeemedpositive", + "isinvoice", + "aspayslip", + "isdeletedvchretained", + "isnegisposset", + "masterid", + "voucherkey", + "voucherretainkey", + "reuseholeid", + "amount", + "vouchernumberseries", + "allledgerentries", + "allinventoryentries", + "ledgerentries", + ], + JsonExProtocolError::ProfileMismatch, + )?; + let metadata = required_object(required_field(record, "metadata")?)?; + require_allowed_keys( + metadata, + &["type", "remoteid", "vchkey", "vchtype", "objview"], + JsonExProtocolError::ProfileMismatch, + )?; + if required_string(metadata, "type")? != "Voucher" { + return Err(JsonExProtocolError::ProfileMismatch); + } + let remote_id = required_string(metadata, "remoteid")?.to_string(); + required_string(metadata, "vchkey")?; + required_string(metadata, "vchtype")?; + required_string(metadata, "objview")?; + let date_yyyymmdd = typed_required_text(record, "date", "Date", TextRule::Date)?.to_string(); + let guid = required_string(record, "guid")?.to_string(); + let voucher_type = required_string(record, "vouchertypename")?.to_string(); + let voucher_number = optional_string(record, "vouchernumber")?.map(str::to_string); + for field in ["reference", "serialmaster", "areserialmaster"] { + typed_text_presence(record, field, "String", TextRule::Any)?; + } + for field in ["numberingstyle", "persistedview"] { + optional_string(record, field)?; + } + typed_text_presence(record, "vouchernumberseries", "String", TextRule::Any)?; + for field in [ + "isdeleted", + "asoriginal", + "isinvoice", + "aspayslip", + "isdeletedvchretained", + ] { + optional_bool(record, field)?; + } + typed_logical_optional(record, "isdeemedpositive")?; + typed_logical_optional(record, "isnegisposset")?; + for field in ["masterid", "voucherkey", "voucherretainkey", "reuseholeid"] { + typed_text_presence(record, field, "Number", TextRule::UnsignedInteger)?; + } + let amount = typed_text_presence(record, "amount", "Amount", TextRule::OptionalExactDecimal)?; + + let all_ledger_entry_count = validate_object_array(record, "allledgerentries", |entry| { + validate_ledger_allocation(entry, false)?; + Ok(()) + })?; + let mut batch_allocation_count = 0_u64; + let mut accounting_allocation_count = 0_u64; + let inventory_entry_count = validate_object_array(record, "allinventoryentries", |entry| { + let (batches, accounting) = validate_inventory_allocation(entry)?; + batch_allocation_count = batch_allocation_count + .checked_add(batches) + .ok_or(JsonExProtocolError::ResourceLimitExceeded)?; + accounting_allocation_count = accounting_allocation_count + .checked_add(accounting) + .ok_or(JsonExProtocolError::ResourceLimitExceeded)?; + Ok(()) + })?; + let mut bill_allocation_count = 0_u64; + let invoice_ledger_entry_count = validate_object_array(record, "ledgerentries", |entry| { + bill_allocation_count = bill_allocation_count + .checked_add(validate_ledger_allocation(entry, true)?) + .ok_or(JsonExProtocolError::ResourceLimitExceeded)?; + Ok(()) + })?; + + Ok(UnboundJsonExVoucherRecord { + remote_id, + guid, + date_yyyymmdd, + voucher_type, + voucher_number, + amount, + all_ledger_entry_count, + inventory_entry_count, + invoice_ledger_entry_count, + batch_allocation_count, + accounting_allocation_count, + bill_allocation_count, + }) +} + +fn validate_ledger_allocation( + value: &StrictJson, + allow_bills: bool, +) -> Result { + let entry = required_object(value)?; + let allowed = if allow_bills { + &[ + "ledgername", + "isdeemedpositive", + "islastdeemedpositive", + "amount", + "vatassessablevalue", + "billallocations", + ][..] + } else { + &[ + "ledgername", + "isdeemedpositive", + "islastdeemedpositive", + "amount", + "vatassessablevalue", + ][..] + }; + require_allowed_keys(entry, allowed, JsonExProtocolError::ProfileMismatch)?; + typed_required_text(entry, "ledgername", "String", TextRule::NonEmptyText)?; + typed_required_logical(entry, "isdeemedpositive")?; + typed_required_logical(entry, "islastdeemedpositive")?; + typed_required_text(entry, "amount", "Amount", TextRule::ExactDecimal)?; + typed_text_presence( + entry, + "vatassessablevalue", + "Amount", + TextRule::OptionalExactDecimal, + )?; + if allow_bills { + validate_object_array(entry, "billallocations", |allocation| { + let allocation = required_object(allocation)?; + require_allowed_keys( + allocation, + &["amount"], + JsonExProtocolError::ProfileMismatch, + )?; + typed_required_text(allocation, "amount", "Amount", TextRule::ExactDecimal)?; + Ok(()) + }) + } else { + Ok(0) + } +} + +fn validate_inventory_allocation(value: &StrictJson) -> Result<(u64, u64), JsonExProtocolError> { + let entry = required_object(value)?; + require_allowed_keys( + entry, + &[ + "stockitemname", + "addlamount", + "isdeemedpositive", + "islastdeemedpositive", + "rate", + "discount", + "amount", + "actualqty", + "billedqty", + "batchallocations", + "accountingallocations", + ], + JsonExProtocolError::ProfileMismatch, + )?; + typed_required_text(entry, "stockitemname", "String", TextRule::NonEmptyText)?; + typed_text_presence( + entry, + "addlamount", + "Amount", + TextRule::OptionalExactDecimal, + )?; + typed_required_logical(entry, "isdeemedpositive")?; + typed_required_logical(entry, "islastdeemedpositive")?; + typed_required_text(entry, "rate", "Rate", TextRule::NonEmpty)?; + typed_required_text(entry, "discount", "Number", TextRule::UnsignedInteger)?; + typed_required_text(entry, "amount", "Amount", TextRule::ExactDecimal)?; + typed_required_text(entry, "actualqty", "Quantity", TextRule::NonEmpty)?; + typed_required_text(entry, "billedqty", "Quantity", TextRule::NonEmpty)?; + let batches = validate_object_array(entry, "batchallocations", validate_batch_allocation)?; + let accounting = validate_object_array( + entry, + "accountingallocations", + validate_accounting_allocation, + )?; + Ok((batches, accounting)) +} + +fn validate_batch_allocation(value: &StrictJson) -> Result<(), JsonExProtocolError> { + let allocation = required_object(value)?; + require_allowed_keys( + allocation, + &[ + "batchname", + "indentno", + "orderno", + "trackingnumber", + "addlamount", + "batchdiscount", + "amount", + "actualqty", + "billedqty", + "batchrate", + ], + JsonExProtocolError::ProfileMismatch, + )?; + for field in ["batchname", "indentno", "orderno", "trackingnumber"] { + typed_required_text(allocation, field, "String", TextRule::Any)?; + } + typed_text_presence( + allocation, + "addlamount", + "Amount", + TextRule::OptionalExactDecimal, + )?; + typed_required_text( + allocation, + "batchdiscount", + "Number", + TextRule::UnsignedInteger, + )?; + typed_required_text(allocation, "amount", "Amount", TextRule::ExactDecimal)?; + typed_required_text(allocation, "actualqty", "Quantity", TextRule::NonEmpty)?; + typed_required_text(allocation, "billedqty", "Quantity", TextRule::NonEmpty)?; + typed_required_text(allocation, "batchrate", "Rate", TextRule::NonEmpty)?; + Ok(()) +} + +fn validate_accounting_allocation(value: &StrictJson) -> Result<(), JsonExProtocolError> { + let allocation = required_object(value)?; + require_allowed_keys( + allocation, + &[ + "ledgername", + "isdeemedpositive", + "islastdeemedpositive", + "amount", + ], + JsonExProtocolError::ProfileMismatch, + )?; + typed_required_text(allocation, "ledgername", "String", TextRule::NonEmptyText)?; + typed_required_logical(allocation, "isdeemedpositive")?; + typed_required_logical(allocation, "islastdeemedpositive")?; + typed_required_text(allocation, "amount", "Amount", TextRule::ExactDecimal)?; + Ok(()) +} + +fn validate_object_array( + object: &BTreeMap, + field: &str, + mut validate: F, +) -> Result +where + F: FnMut(&StrictJson) -> Result<(), JsonExProtocolError>, +{ + let Some(value) = object.get(field) else { + return Ok(0); + }; + let values = as_array(value).ok_or(JsonExProtocolError::ProfileMismatch)?; + for value in values { + if !matches!(value, StrictJson::Object(_)) { + return Err(JsonExProtocolError::ProfileMismatch); + } + validate(value)?; + } + u64::try_from(values.len()).map_err(|_| JsonExProtocolError::ResourceLimitExceeded) +} + +#[derive(Clone, Copy)] +enum TextRule { + Any, + NonEmpty, + NonEmptyText, + ExactDecimal, + OptionalExactDecimal, + UnsignedInteger, + Date, +} + +fn typed_text_presence( + object: &BTreeMap, + field: &str, + expected_type: &str, + rule: TextRule, +) -> Result { + match object.get(field) { + None => Ok(JsonExTextPresence::Absent), + Some(value) => { + let value = typed_text_value(value, expected_type, rule)?; + if value.is_empty() { + Ok(JsonExTextPresence::Empty) + } else { + Ok(JsonExTextPresence::Value(value.to_string())) + } + } + } +} + +fn typed_required_text<'a>( + object: &'a BTreeMap, + field: &str, + expected_type: &str, + rule: TextRule, +) -> Result<&'a str, JsonExProtocolError> { + typed_text_value(required_field(object, field)?, expected_type, rule) +} + +fn typed_text_value<'a>( + value: &'a StrictJson, + expected_type: &str, + rule: TextRule, +) -> Result<&'a str, JsonExProtocolError> { + let wrapper = required_object(value)?; + require_allowed_keys( + wrapper, + &["type", "value"], + JsonExProtocolError::TypedValueMismatch, + )?; + if required_string(wrapper, "type")? != expected_type { + return Err(JsonExProtocolError::TypedValueMismatch); + } + let value = required_string(wrapper, "value")?; + let valid = match rule { + TextRule::Any => true, + TextRule::NonEmpty => !value.is_empty(), + TextRule::NonEmptyText => !value.is_empty() && !value.chars().any(char::is_control), + TextRule::ExactDecimal => is_exact_decimal(value), + TextRule::OptionalExactDecimal => value.is_empty() || is_exact_decimal(value), + TextRule::UnsignedInteger => is_unsigned_integer(value.trim_matches(' ')), + TextRule::Date => is_valid_yyyymmdd(value), + }; + if !valid { + return Err(JsonExProtocolError::TypedValueMismatch); + } + Ok(value) +} + +fn typed_logical_optional( + object: &BTreeMap, + field: &str, +) -> Result, JsonExProtocolError> { + object + .get(field) + .map(|value| typed_logical_value(value).map(Some)) + .unwrap_or(Ok(None)) +} + +fn typed_required_logical( + object: &BTreeMap, + field: &str, +) -> Result { + typed_logical_value(required_field(object, field)?) +} + +fn typed_logical_value(value: &StrictJson) -> Result { + let wrapper = required_object(value)?; + require_allowed_keys( + wrapper, + &["type", "value"], + JsonExProtocolError::TypedValueMismatch, + )?; + if required_string(wrapper, "type")? != "Logical" { + return Err(JsonExProtocolError::TypedValueMismatch); + } + as_bool(required_field(wrapper, "value")?).ok_or(JsonExProtocolError::TypedValueMismatch) +} + +fn is_exact_decimal(value: &str) -> bool { + let bytes = value.as_bytes(); + let body = bytes.strip_prefix(b"-").unwrap_or(bytes); + let mut parts = body.split(|byte| *byte == b'.'); + let whole = parts.next().unwrap_or_default(); + let fraction = parts.next(); + !whole.is_empty() + && whole.iter().all(u8::is_ascii_digit) + && fraction.is_none_or(|part| !part.is_empty() && part.iter().all(u8::is_ascii_digit)) + && parts.next().is_none() +} + +fn is_unsigned_integer(value: &str) -> bool { + !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn is_valid_yyyymmdd(value: &str) -> bool { + if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + let year = value[0..4].parse::().unwrap_or_default(); + let month = value[4..6].parse::().unwrap_or_default(); + let day = value[6..8].parse::().unwrap_or_default(); + let leap = year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)); + let days = match month { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if leap => 29, + 2 => 28, + _ => return false, + }; + year != 0 && (1..=days).contains(&day) +} + +fn require_allowed_keys( + object: &BTreeMap, + allowed: &[&str], + error: JsonExProtocolError, +) -> Result<(), JsonExProtocolError> { + if object.keys().any(|key| !allowed.contains(&key.as_str())) { + return Err(error); + } + Ok(()) +} + +fn required_field<'a>( + object: &'a BTreeMap, + field: &str, +) -> Result<&'a StrictJson, JsonExProtocolError> { + object + .get(field) + .ok_or(JsonExProtocolError::ProfileMismatch) +} + +fn required_object( + value: &StrictJson, +) -> Result<&BTreeMap, JsonExProtocolError> { + as_object(value).ok_or(JsonExProtocolError::ProfileMismatch) +} + +fn required_string<'a>( + object: &'a BTreeMap, + field: &str, +) -> Result<&'a str, JsonExProtocolError> { + as_str(required_field(object, field)?).ok_or(JsonExProtocolError::TypedValueMismatch) +} + +fn optional_string<'a>( + object: &'a BTreeMap, + field: &str, +) -> Result, JsonExProtocolError> { + object + .get(field) + .map(|value| as_str(value).ok_or(JsonExProtocolError::TypedValueMismatch)) + .transpose() +} + +fn required_bool( + object: &BTreeMap, + field: &str, +) -> Result { + as_bool(required_field(object, field)?).ok_or(JsonExProtocolError::TypedValueMismatch) +} + +fn optional_bool( + object: &BTreeMap, + field: &str, +) -> Result, JsonExProtocolError> { + object + .get(field) + .map(|value| as_bool(value).ok_or(JsonExProtocolError::TypedValueMismatch)) + .transpose() +} + +fn required_unsigned( + object: &BTreeMap, + field: &str, +) -> Result { + match required_field(object, field)? { + StrictJson::Unsigned(value) => Ok(*value), + StrictJson::Signed(value) if *value >= 0 => Ok(*value as u64), + _ => Err(JsonExProtocolError::TypedValueMismatch), + } +} + +fn as_object(value: &StrictJson) -> Option<&BTreeMap> { + match value { + StrictJson::Object(value) => Some(value), + _ => None, + } +} + +fn as_array(value: &StrictJson) -> Option<&[StrictJson]> { + match value { + StrictJson::Array(value) => Some(value), + _ => None, + } +} + +fn as_str(value: &StrictJson) -> Option<&str> { + match value { + StrictJson::String(value) => Some(value), + _ => None, + } +} + +fn as_bool(value: &StrictJson) -> Option { + match value { + StrictJson::Bool(value) => Some(*value), + _ => None, + } +} + +fn into_object(value: StrictJson) -> Option> { + match value { + StrictJson::Object(value) => Some(value), + _ => None, + } +} + +fn into_array(value: StrictJson) -> Option> { + match value { + StrictJson::Array(value) => Some(value), + _ => None, + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/jsonex_request.rs b/src-tauri/crates/bridge-tally-protocol/src/jsonex_request.rs new file mode 100644 index 0000000..154a113 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/jsonex_request.rs @@ -0,0 +1,291 @@ +//! Dormant, deterministic request bytes for two native-JSON collection +//! examples documented by Tally Solutions. +//! +//! This module has no HTTP client and every output is explicitly ineligible +//! for dispatch. The documented requests do not bind a date range, and their +//! responses do not echo enough company/query evidence for Bridge Core. + +use std::fmt; + +use serde::Serialize; + +pub const DOCUMENTED_LEDGER_REQUEST_PROFILE_V1: &str = + "tally.documented-json-ledger-collection-docx-2025-11/1"; +pub const DOCUMENTED_VOUCHER_REQUEST_PROFILE_V1: &str = + "tally.documented-json-tspl-voucher-collection-docx-2025-11/1"; +const MAX_COMPANY_NAME_BYTES: usize = 255; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsonExRequestWireEncoding { + PlainAsciiUtf8, + Utf8Bom, + Utf16LeBom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsonExResponseEncodingExpectation { + Unspecified, + Utf16Le, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsonExRequestBuildError { + InvalidCompanyName, + MultilingualCompanyRequiresBomProfile, + InvalidByteLimit, + RequestTooLarge, + SerializationFailed, +} + +impl JsonExRequestBuildError { + pub fn safe_code(self) -> &'static str { + match self { + Self::InvalidCompanyName => "jsonex_request_company_invalid", + Self::MultilingualCompanyRequiresBomProfile => { + "jsonex_request_multilingual_company_requires_bom" + } + Self::InvalidByteLimit => "jsonex_request_byte_limit_invalid", + Self::RequestTooLarge => "jsonex_request_too_large", + Self::SerializationFailed => "jsonex_request_serialization_failed", + } + } +} + +impl fmt::Display for JsonExRequestBuildError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.safe_code()) + } +} + +impl std::error::Error for JsonExRequestBuildError {} + +#[derive(Clone, PartialEq, Eq)] +pub struct ValidatedJsonExCompanyName(String); + +impl ValidatedJsonExCompanyName { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_COMPANY_NAME_BYTES + || value.chars().all(char::is_whitespace) + || value.chars().any(char::is_control) + { + return Err(JsonExRequestBuildError::InvalidCompanyName); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct JsonExRequestHeaders { + pub content_type: &'static str, + pub version: &'static str, + pub tally_request: &'static str, + pub request_type: &'static str, + pub id: &'static str, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct DormantJsonExRequest { + pub profile_id: &'static str, + pub headers: JsonExRequestHeaders, + pub wire_encoding: JsonExRequestWireEncoding, + pub response_encoding_expectation: JsonExResponseEncodingExpectation, + pub body: Vec, +} + +impl DormantJsonExRequest { + pub const fn dispatch_eligible(&self) -> bool { + false + } + + pub const fn company_identity_verifiable_from_documented_response(&self) -> bool { + false + } + + pub const fn date_range_bound(&self) -> bool { + false + } +} + +pub fn build_documented_ledger_request_v1( + company: &ValidatedJsonExCompanyName, + wire_encoding: JsonExRequestWireEncoding, + max_encoded_bytes: usize, +) -> Result { + let body = LedgerRequestBody { + static_variables: static_variables(company.as_str()), + fetch_list: ["Name", "Parent", "Closing Balance"], + }; + build_request( + DOCUMENTED_LEDGER_REQUEST_PROFILE_V1, + "Ledger", + &body, + company, + wire_encoding, + max_encoded_bytes, + ) +} + +pub fn build_documented_voucher_request_v1( + company: &ValidatedJsonExCompanyName, + wire_encoding: JsonExRequestWireEncoding, + max_encoded_bytes: usize, +) -> Result { + let body = VoucherRequestBody { + static_variables: static_variables(company.as_str()), + tdl_message: [TdlMessage { + definitions: [Definition { + metadata: DefinitionMetadata { + name: "TSPLVoucherColl", + definition_type: "Collection", + }, + attributes: [ + DefinitionAttribute::Type { value: "Voucher" }, + DefinitionAttribute::NativeMethod { + value: "VoucherNumber, VoucherTypeName, Date, Amount", + }, + ], + }], + }], + }; + build_request( + DOCUMENTED_VOUCHER_REQUEST_PROFILE_V1, + "TSPLVoucherColl", + &body, + company, + wire_encoding, + max_encoded_bytes, + ) +} + +fn build_request( + profile_id: &'static str, + id: &'static str, + body: &T, + company: &ValidatedJsonExCompanyName, + wire_encoding: JsonExRequestWireEncoding, + max_encoded_bytes: usize, +) -> Result { + if max_encoded_bytes == 0 { + return Err(JsonExRequestBuildError::InvalidByteLimit); + } + if wire_encoding == JsonExRequestWireEncoding::PlainAsciiUtf8 && !company.as_str().is_ascii() { + return Err(JsonExRequestBuildError::MultilingualCompanyRequiresBomProfile); + } + let json = + serde_json::to_string(body).map_err(|_| JsonExRequestBuildError::SerializationFailed)?; + let (content_type, response_encoding_expectation, encoded) = match wire_encoding { + JsonExRequestWireEncoding::PlainAsciiUtf8 => ( + "application/json", + JsonExResponseEncodingExpectation::Unspecified, + json.into_bytes(), + ), + JsonExRequestWireEncoding::Utf8Bom => { + let mut bytes = Vec::with_capacity(json.len().saturating_add(3)); + bytes.extend_from_slice(&[0xef, 0xbb, 0xbf]); + bytes.extend_from_slice(json.as_bytes()); + ( + "application/json;charset=utf-8", + JsonExResponseEncodingExpectation::Utf16Le, + bytes, + ) + } + JsonExRequestWireEncoding::Utf16LeBom => { + let mut bytes = Vec::with_capacity(json.len().saturating_mul(2).saturating_add(2)); + bytes.extend_from_slice(&[0xff, 0xfe]); + for unit in json.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + ( + "application/json;charset=utf-16", + JsonExResponseEncodingExpectation::Utf16Le, + bytes, + ) + } + }; + if encoded.len() > max_encoded_bytes { + return Err(JsonExRequestBuildError::RequestTooLarge); + } + Ok(DormantJsonExRequest { + profile_id, + headers: JsonExRequestHeaders { + content_type, + version: "1", + tally_request: "Export", + request_type: "Collection", + id, + }, + wire_encoding, + response_encoding_expectation, + body: encoded, + }) +} + +#[derive(Serialize)] +struct StaticVariable<'a> { + name: &'static str, + value: &'a str, +} + +fn static_variables(company: &str) -> [StaticVariable<'_>; 2] { + [ + StaticVariable { + name: "svExportFormat", + value: "jsonex", + }, + StaticVariable { + name: "svCurrentCompany", + value: company, + }, + ] +} + +#[derive(Serialize)] +struct LedgerRequestBody<'a> { + static_variables: [StaticVariable<'a>; 2], + #[serde(rename = "fetch_List")] + fetch_list: [&'static str; 3], +} + +#[derive(Serialize)] +struct VoucherRequestBody<'a> { + static_variables: [StaticVariable<'a>; 2], + #[serde(rename = "tdlmessage")] + tdl_message: [TdlMessage; 1], +} + +#[derive(Serialize)] +struct TdlMessage { + definitions: [Definition; 1], +} + +#[derive(Serialize)] +struct Definition { + metadata: DefinitionMetadata, + attributes: [DefinitionAttribute; 2], +} + +#[derive(Serialize)] +struct DefinitionMetadata { + name: &'static str, + #[serde(rename = "type")] + definition_type: &'static str, +} + +#[derive(Serialize)] +#[serde(untagged)] +enum DefinitionAttribute { + Type { + #[serde(rename = "Type")] + value: &'static str, + }, + NativeMethod { + #[serde(rename = "Native Method")] + value: &'static str, + }, +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/lib.rs b/src-tauri/crates/bridge-tally-protocol/src/lib.rs new file mode 100644 index 0000000..d29de5f --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/lib.rs @@ -0,0 +1,3109 @@ +//! Portable decoding and strict application-level parsing for Tally responses. +//! +//! This crate has no HTTP, database, native-library, or Tauri dependency. HTTP +//! success must be checked separately; every export parser still requires Tally +//! application `STATUS=1`. + +use std::{ + collections::{HashMap, HashSet}, + fmt::Write as _, +}; + +use quick_xml::{events::Event, name::QName, Reader}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[cfg(feature = "bills-native-outstandings-probe")] +pub mod bills_native_outstandings_probe; +#[cfg(feature = "bills-payments-observation-parser")] +pub mod bills_payments_observation; +#[cfg(feature = "india-tax-observation-parser")] +pub mod india_tax_observation; +#[cfg(feature = "jsonex-parser")] +pub mod jsonex; +#[cfg(feature = "jsonex-request-builder")] +pub mod jsonex_request; +pub mod xml_read_profiles; + +pub const BRIDGE_LEDGER_EXPORT_SCHEMA: &str = "bridge.tally.ledgers/1"; +pub const BRIDGE_LEDGER_WRITE_READBACK_SCHEMA: &str = "bridge.tally.ledger-write-readback/1"; +pub const BRIDGE_GROUP_EXPORT_SCHEMA: &str = "bridge.tally.groups/1"; +pub const BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA: &str = "bridge.tally.voucher-types/1"; +pub const BRIDGE_VOUCHER_EXPORT_SCHEMA: &str = "bridge.tally.vouchers/2"; +pub const BRIDGE_SELECTED_VOUCHER_EXPORT_SCHEMA: &str = "bridge.tally.vouchers/3"; +pub const BRIDGE_LEDGER_PERIOD_BALANCE_SCHEMA: &str = "bridge.tally.ledger-period-balances/1"; + +#[derive(Debug, Deserialize, Serialize)] +pub struct TallyEnvelope { + #[serde(rename = "BODY")] + pub body: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TallyCompany { + pub name: String, + pub guid: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TallyLedger { + pub name: String, + pub parent: Option, + pub party_gstin: Option, + pub opening_balance: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TallyLedgerPeriodBalance { + pub opening_balance: String, + pub closing_balance: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct LedgerPeriodBalanceContext { + pub company_guid: String, + pub from_yyyymmdd: String, + pub to_yyyymmdd: String, + /// Bridge's requested comparison profile echoed by the custom report. + /// This is request binding, not source-observed proof of Tally semantics. + pub ordinary_books_requested: bool, + pub source_record_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ParsedLedgerPeriodBalanceReport { + pub context: LedgerPeriodBalanceContext, + pub records: Vec>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TallyNamedMaster { + pub name: String, + pub parent: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TallyLedgerEntry { + pub entry_index: u64, + pub ledger_name: String, + pub amount: String, + pub is_deemed_positive: bool, + pub raw_source_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TallyVoucher { + pub id: Option, + pub date: Option, + pub voucher_type: Option, + pub voucher_number: Option, + pub party_ledger_name: Option, + pub cancelled: Option, + pub optional: Option, + pub ledger_entry_count: Option, + pub ledger_entries: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TallyImportResult { + pub created: u64, + pub altered: u64, + pub deleted: u64, + pub ignored: u64, + pub errors: u64, + pub cancelled: u64, + pub exceptions: u64, + pub line_error_count: u64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum TallyImportApplicationStatus { + Success, + Failure, + NotReported, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct TallyImportOutcome { + application_status: TallyImportApplicationStatus, + counters: TallyImportResult, + exceptions_were_reported: bool, +} + +impl TallyImportOutcome { + pub fn application_status(&self) -> TallyImportApplicationStatus { + self.application_status + } + + pub fn counters(&self) -> &TallyImportResult { + &self.counters + } + + /// Distinguishes a source-observed `EXCEPTIONS` counter from the documented + /// direct profile's Bridge-defaulted zero when that field is absent. + pub fn exceptions_were_reported(&self) -> bool { + self.exceptions_were_reported + } + + pub fn into_counters(self) -> TallyImportResult { + self.counters + } +} + +/// Redacted, parser-derived evidence for one Tally import response. +/// +/// The raw response and raw `LINEERROR` text are deliberately not retained. +/// Callers cannot construct this type, so counter and digest evidence cannot be +/// mixed with a different response. +#[derive(Clone, PartialEq, Eq)] +pub struct ParsedImportEvidence { + application_status: TallyImportApplicationStatus, + counters: TallyImportResult, + exceptions_were_reported: bool, + response_sha256: String, + line_error_sha256: Vec, +} + +impl std::fmt::Debug for ParsedImportEvidence { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ParsedImportEvidence") + .field("application_status", &self.application_status) + .field("counters", &self.counters) + .field("exceptions_were_reported", &self.exceptions_were_reported) + .field("response_sha256", &self.response_sha256) + .field("line_error_count", &self.line_error_sha256.len()) + .finish() + } +} + +impl ParsedImportEvidence { + pub fn application_status(&self) -> TallyImportApplicationStatus { + self.application_status + } + + pub fn counters(&self) -> &TallyImportResult { + &self.counters + } + + pub fn exceptions_were_reported(&self) -> bool { + self.exceptions_were_reported + } + + pub fn response_sha256(&self) -> &str { + &self.response_sha256 + } + + pub fn line_error_sha256(&self) -> &[String] { + &self.line_error_sha256 + } +} + +impl TallyImportResult { + pub fn is_clean_success(&self) -> bool { + self.ignored == 0 + && self.errors == 0 + && self.cancelled == 0 + && self.exceptions == 0 + && self.line_error_count == 0 + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TallyExportStatus { + Success, + Failure, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +pub struct CompanyContextEvidence { + pub name: Option, + pub guid: Option, + /// Echo of the exact requested identity set for scoped write readbacks. + pub query_identity_set_sha256: Option, + /// Bridge request-binding echoes; they are not independent proof that Tally honored filters. + pub requested_from_yyyymmdd: Option, + pub requested_to_yyyymmdd: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DuplicateIdentityEvidence { + pub identity_sha256: String, + pub occurrences: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +pub struct ExportEvidence { + pub company_context: Option, + pub schema: Option, + pub object_type: Option, + pub source_record_count: Option, + pub identified_record_count: u64, + pub duplicate_identities: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ParsedExport { + pub records: Vec, + pub evidence: ExportEvidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ParsedSourceIdentityKind { + Guid, + RemoteId, + MasterId, +} + +#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)] +pub struct ParsedSourceIdentities { + pub guid: Option, + pub remote_id: Option, + pub master_id: Option, +} + +/// A parsed row plus source evidence that must survive canonicalisation. The hash covers the +/// exact XML record fragment consumed by the row parser, never a reconstructed representation. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ParsedSourceRecord { + pub record: T, + pub source_id: Option, + pub identity_kind: Option, + pub identities: ParsedSourceIdentities, + pub alter_id: Option, + pub raw_source_sha256: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TallyTextEncoding { + Utf8, + Utf8Bom, + Utf16LeBom, + Utf16BeBom, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DecodedTallyText { + pub text: String, + pub encoding: TallyTextEncoding, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TallyTextDecodeError { + TooLarge, + InvalidUtf8, + InvalidUtf16Le, + InvalidUtf16Be, +} + +/// Incremental, decoded-size-bounded Tally text decoder. +/// +/// The decoder retains the decoded UTF-8 text because the current protocol +/// parsers consume `&str`, but it never retains the complete encoded body. Its +/// boundary state is limited to a possible BOM, an incomplete UTF-8 scalar, +/// or one incomplete UTF-16 code unit/surrogate pair. +pub struct TallyTextStreamDecoder { + max_decoded_bytes: usize, + prefix: Vec, + mode: Option, + text: String, + decoded_sha256: Sha256, +} + +enum TallyTextStreamMode { + Utf8 { + encoding: TallyTextEncoding, + pending: Vec, + }, + Utf16 { + encoding: TallyTextEncoding, + little_endian: bool, + pending_byte: Option, + pending_high_surrogate: Option, + }, +} + +/// Completed incremental decoding evidence. The digest covers the decoded +/// text re-encoded as UTF-8, with any wire BOM removed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StreamDecodedTallyText { + pub text: String, + pub encoding: TallyTextEncoding, + pub decoded_bytes: usize, + pub decoded_sha256: String, +} + +impl TallyTextStreamDecoder { + pub fn new(max_decoded_bytes: usize) -> Self { + Self { + max_decoded_bytes, + prefix: Vec::with_capacity(3), + mode: None, + text: String::new(), + decoded_sha256: Sha256::new(), + } + } + + /// Consumes one encoded response chunk. An error makes the partial decoder + /// unsuitable for evidence; callers must discard it and must not interpret + /// the retained prefix as a partial response. + pub fn push_chunk(&mut self, chunk: &[u8]) -> Result<(), TallyTextDecodeError> { + let mut offset = 0; + while self.mode.is_none() && offset < chunk.len() { + self.prefix.push(chunk[offset]); + offset += 1; + match tally_text_prefix_decision(&self.prefix) { + TallyTextPrefixDecision::NeedMore => {} + TallyTextPrefixDecision::Utf8Bom => { + self.prefix.clear(); + self.mode = Some(TallyTextStreamMode::Utf8 { + encoding: TallyTextEncoding::Utf8Bom, + pending: Vec::with_capacity(3), + }); + } + TallyTextPrefixDecision::Utf16LeBom => { + self.prefix.clear(); + self.mode = Some(TallyTextStreamMode::Utf16 { + encoding: TallyTextEncoding::Utf16LeBom, + little_endian: true, + pending_byte: None, + pending_high_surrogate: None, + }); + } + TallyTextPrefixDecision::Utf16BeBom => { + self.prefix.clear(); + self.mode = Some(TallyTextStreamMode::Utf16 { + encoding: TallyTextEncoding::Utf16BeBom, + little_endian: false, + pending_byte: None, + pending_high_surrogate: None, + }); + } + TallyTextPrefixDecision::Utf8WithoutBom => { + self.mode = Some(TallyTextStreamMode::Utf8 { + encoding: TallyTextEncoding::Utf8, + pending: Vec::with_capacity(3), + }); + } + } + } + + if self.mode.is_none() { + return Ok(()); + } + if !self.prefix.is_empty() { + let prefix = std::mem::take(&mut self.prefix); + self.process_selected(&prefix)?; + } + self.process_selected(&chunk[offset..]) + } + + pub fn finish(mut self) -> Result { + if self.mode.is_none() { + self.mode = Some(TallyTextStreamMode::Utf8 { + encoding: TallyTextEncoding::Utf8, + pending: Vec::with_capacity(3), + }); + let prefix = std::mem::take(&mut self.prefix); + self.process_selected(&prefix)?; + } + let encoding = match self.mode.as_ref().expect("stream mode is selected") { + TallyTextStreamMode::Utf8 { encoding, pending } => { + if !pending.is_empty() { + return Err(TallyTextDecodeError::InvalidUtf8); + } + *encoding + } + TallyTextStreamMode::Utf16 { + encoding, + pending_byte, + pending_high_surrogate, + .. + } => { + if pending_byte.is_some() || pending_high_surrogate.is_some() { + return Err(invalid_utf16(*encoding)); + } + *encoding + } + }; + let decoded_bytes = self.text.len(); + let decoded_sha256 = encode_sha256(self.decoded_sha256.finalize()); + Ok(StreamDecodedTallyText { + text: self.text, + encoding, + decoded_bytes, + decoded_sha256, + }) + } + + fn process_selected(&mut self, bytes: &[u8]) -> Result<(), TallyTextDecodeError> { + match self.mode.as_mut().expect("stream mode is selected") { + TallyTextStreamMode::Utf8 { pending, .. } => process_utf8_chunk( + pending, + bytes, + self.max_decoded_bytes, + &mut self.text, + &mut self.decoded_sha256, + ), + TallyTextStreamMode::Utf16 { + encoding, + little_endian, + pending_byte, + pending_high_surrogate, + } => process_utf16_chunk( + *encoding, + *little_endian, + pending_byte, + pending_high_surrogate, + bytes, + self.max_decoded_bytes, + &mut self.text, + &mut self.decoded_sha256, + ), + } + } +} + +enum TallyTextPrefixDecision { + NeedMore, + Utf8Bom, + Utf16LeBom, + Utf16BeBom, + Utf8WithoutBom, +} + +fn tally_text_prefix_decision(prefix: &[u8]) -> TallyTextPrefixDecision { + const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF]; + const UTF16_LE_BOM: &[u8] = &[0xFF, 0xFE]; + const UTF16_BE_BOM: &[u8] = &[0xFE, 0xFF]; + if prefix == UTF8_BOM { + TallyTextPrefixDecision::Utf8Bom + } else if prefix == UTF16_LE_BOM { + TallyTextPrefixDecision::Utf16LeBom + } else if prefix == UTF16_BE_BOM { + TallyTextPrefixDecision::Utf16BeBom + } else if UTF8_BOM.starts_with(prefix) + || UTF16_LE_BOM.starts_with(prefix) + || UTF16_BE_BOM.starts_with(prefix) + { + TallyTextPrefixDecision::NeedMore + } else { + TallyTextPrefixDecision::Utf8WithoutBom + } +} + +fn process_utf8_chunk( + pending: &mut Vec, + mut bytes: &[u8], + max_decoded_bytes: usize, + text: &mut String, + digest: &mut Sha256, +) -> Result<(), TallyTextDecodeError> { + while !pending.is_empty() && !bytes.is_empty() { + pending.push(bytes[0]); + bytes = &bytes[1..]; + match std::str::from_utf8(pending) { + Ok(decoded) => { + append_decoded(decoded, max_decoded_bytes, text, digest)?; + pending.clear(); + } + Err(error) if error.error_len().is_some() => { + return Err(TallyTextDecodeError::InvalidUtf8) + } + Err(_) => {} + } + } + if bytes.is_empty() { + return Ok(()); + } + match std::str::from_utf8(bytes) { + Ok(decoded) => append_decoded(decoded, max_decoded_bytes, text, digest), + Err(error) if error.error_len().is_some() => Err(TallyTextDecodeError::InvalidUtf8), + Err(error) => { + let valid = &bytes[..error.valid_up_to()]; + let tail = &bytes[error.valid_up_to()..]; + let decoded = + std::str::from_utf8(valid).map_err(|_| TallyTextDecodeError::InvalidUtf8)?; + append_decoded(decoded, max_decoded_bytes, text, digest)?; + if tail.len() > 3 { + return Err(TallyTextDecodeError::InvalidUtf8); + } + pending.extend_from_slice(tail); + Ok(()) + } + } +} + +#[allow(clippy::too_many_arguments)] +fn process_utf16_chunk( + encoding: TallyTextEncoding, + little_endian: bool, + pending_byte: &mut Option, + pending_high_surrogate: &mut Option, + bytes: &[u8], + max_decoded_bytes: usize, + text: &mut String, + digest: &mut Sha256, +) -> Result<(), TallyTextDecodeError> { + let mut offset = 0; + if let Some(first) = pending_byte.take() { + let Some(second) = bytes.first().copied() else { + *pending_byte = Some(first); + return Ok(()); + }; + process_utf16_unit( + encoding, + if little_endian { + u16::from_le_bytes([first, second]) + } else { + u16::from_be_bytes([first, second]) + }, + pending_high_surrogate, + max_decoded_bytes, + text, + digest, + )?; + offset = 1; + } + while offset + 1 < bytes.len() { + let pair = [bytes[offset], bytes[offset + 1]]; + let unit = if little_endian { + u16::from_le_bytes(pair) + } else { + u16::from_be_bytes(pair) + }; + process_utf16_unit( + encoding, + unit, + pending_high_surrogate, + max_decoded_bytes, + text, + digest, + )?; + offset += 2; + } + if offset < bytes.len() { + *pending_byte = Some(bytes[offset]); + } + Ok(()) +} + +fn process_utf16_unit( + encoding: TallyTextEncoding, + unit: u16, + pending_high_surrogate: &mut Option, + max_decoded_bytes: usize, + text: &mut String, + digest: &mut Sha256, +) -> Result<(), TallyTextDecodeError> { + if (0xD800..=0xDBFF).contains(&unit) { + if pending_high_surrogate.replace(unit).is_some() { + return Err(invalid_utf16(encoding)); + } + return Ok(()); + } + let scalar = if (0xDC00..=0xDFFF).contains(&unit) { + let high = pending_high_surrogate + .take() + .ok_or_else(|| invalid_utf16(encoding))?; + 0x1_0000 + (((u32::from(high) - 0xD800) << 10) | (u32::from(unit) - 0xDC00)) + } else { + if pending_high_surrogate.take().is_some() { + return Err(invalid_utf16(encoding)); + } + u32::from(unit) + }; + let character = char::from_u32(scalar).ok_or_else(|| invalid_utf16(encoding))?; + let mut encoded = [0_u8; 4]; + append_decoded( + character.encode_utf8(&mut encoded), + max_decoded_bytes, + text, + digest, + ) +} + +fn append_decoded( + decoded: &str, + max_decoded_bytes: usize, + text: &mut String, + digest: &mut Sha256, +) -> Result<(), TallyTextDecodeError> { + if text.len().saturating_add(decoded.len()) > max_decoded_bytes { + return Err(TallyTextDecodeError::TooLarge); + } + text.push_str(decoded); + digest.update(decoded.as_bytes()); + Ok(()) +} + +fn invalid_utf16(encoding: TallyTextEncoding) -> TallyTextDecodeError { + match encoding { + TallyTextEncoding::Utf16LeBom => TallyTextDecodeError::InvalidUtf16Le, + TallyTextEncoding::Utf16BeBom => TallyTextDecodeError::InvalidUtf16Be, + TallyTextEncoding::Utf8 | TallyTextEncoding::Utf8Bom => TallyTextDecodeError::InvalidUtf8, + } +} + +fn encode_sha256(bytes: impl AsRef<[u8]>) -> String { + let mut encoded = String::with_capacity(64); + for byte in bytes.as_ref() { + write!(&mut encoded, "{byte:02x}").expect("writing to a String cannot fail"); + } + encoded +} + +pub fn decode_tally_text_bytes_limited( + bytes: impl AsRef<[u8]>, + max_bytes: usize, +) -> Result { + let bytes = bytes.as_ref(); + if bytes.len() > max_bytes { + return Err(TallyTextDecodeError::TooLarge); + } + if let Some(payload) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8(payload.to_vec()) + .map(|text| DecodedTallyText { + text, + encoding: TallyTextEncoding::Utf8Bom, + }) + .map_err(|_| TallyTextDecodeError::InvalidUtf8); + } + if let Some(payload) = bytes.strip_prefix(&[0xFF, 0xFE]) { + if payload.len() % 2 != 0 { + return Err(TallyTextDecodeError::InvalidUtf16Le); + } + let units = payload + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + return String::from_utf16(&units) + .map(|text| DecodedTallyText { + text, + encoding: TallyTextEncoding::Utf16LeBom, + }) + .map_err(|_| TallyTextDecodeError::InvalidUtf16Le); + } + if let Some(payload) = bytes.strip_prefix(&[0xFE, 0xFF]) { + if payload.len() % 2 != 0 { + return Err(TallyTextDecodeError::InvalidUtf16Be); + } + let units = payload + .chunks_exact(2) + .map(|pair| u16::from_be_bytes([pair[0], pair[1]])) + .collect::>(); + return String::from_utf16(&units) + .map(|text| DecodedTallyText { + text, + encoding: TallyTextEncoding::Utf16BeBom, + }) + .map_err(|_| TallyTextDecodeError::InvalidUtf16Be); + } + String::from_utf8(bytes.to_vec()) + .map(|text| DecodedTallyText { + text, + encoding: TallyTextEncoding::Utf8, + }) + .map_err(|_| TallyTextDecodeError::InvalidUtf8) +} + +pub fn decode_xml_bytes(bytes: impl AsRef<[u8]>) -> anyhow::Result { + decode_xml_bytes_limited(bytes, usize::MAX) +} + +pub fn decode_xml_bytes_limited( + bytes: impl AsRef<[u8]>, + max_bytes: usize, +) -> anyhow::Result { + match decode_tally_text_bytes_limited(bytes, max_bytes) { + Ok(decoded) => Ok(decoded.text), + Err(TallyTextDecodeError::TooLarge) => { + anyhow::bail!("Tally response exceeded the {max_bytes}-byte limit") + } + Err(TallyTextDecodeError::InvalidUtf8) => { + anyhow::bail!("Tally returned an invalid UTF-8 XML response") + } + Err(TallyTextDecodeError::InvalidUtf16Le) => { + anyhow::bail!("Tally returned an invalid UTF-16LE XML response") + } + Err(TallyTextDecodeError::InvalidUtf16Be) => { + anyhow::bail!("Tally returned an invalid UTF-16BE XML response") + } + } +} + +pub fn parse_xml(xml: &str) -> anyhow::Result +where + T: for<'de> Deserialize<'de>, +{ + Ok(quick_xml::de::from_str(xml)?) +} + +pub fn parse_companies(xml: &str) -> anyhow::Result> { + Ok(parse_companies_with_evidence(xml)?.records) +} + +pub fn parse_companies_with_evidence(xml: &str) -> anyhow::Result> { + validate_export_response(xml)?; + let evidence = scan_export_evidence(xml)?; + let mut reader = configured_reader(xml); + let mut records = Vec::new(); + loop { + match reader.read_event()? { + Event::Start(element) + if element.name().as_ref().eq_ignore_ascii_case(b"COMPANYINFO") => + { + records.push(parse_company_info(&mut reader)?); + } + Event::Eof => break, + _ => {} + } + } + Ok(ParsedExport { records, evidence }) +} + +pub fn parse_group_source_records_with_evidence( + xml: &str, +) -> anyhow::Result>> { + parse_named_master_source_records(xml, b"GROUP", BRIDGE_GROUP_EXPORT_SCHEMA, "GROUP") +} + +pub fn parse_voucher_type_source_records_with_evidence( + xml: &str, +) -> anyhow::Result>> { + parse_named_master_source_records( + xml, + b"VOUCHERTYPE", + BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA, + "VOUCHERTYPE", + ) +} + +fn parse_named_master_source_records( + xml: &str, + element_name: &[u8], + schema: &str, + object_type: &str, +) -> anyhow::Result>> { + validate_export_response(xml)?; + let evidence = scan_export_evidence(xml)?; + let mut reader = configured_reader(xml); + let mut path = Vec::>::new(); + let mut records = Vec::new(); + loop { + let record_start = reader.buffer_position() as usize; + match reader.read_event()? { + Event::Start(element) + if is_supported_export_parent(&path) + && element.name().as_ref().eq_ignore_ascii_case(element_name) => + { + validate_only_attributes( + &element, + &[b"NAME", b"GUID", b"REMOTEID", b"MASTERID", b"ALTERID"], + )?; + let identities = parsed_source_identities(&reader, &element)?; + let (source_id, identity_kind) = preferred_identity(&identities); + let alter_id = attr_value(&reader, &element, b"ALTERID"); + let name = attr_value(&reader, &element, b"NAME").unwrap_or_default(); + let record = parse_named_master(&mut reader, element_name, name)?; + records.push(ParsedSourceRecord { + record, + source_id, + identity_kind, + identities, + alter_id, + raw_source_sha256: source_fragment_sha256( + xml, + record_start, + reader.buffer_position() as usize, + )?, + }); + } + Event::Empty(element) + if is_supported_export_parent(&path) + && element.name().as_ref().eq_ignore_ascii_case(element_name) => + { + validate_only_attributes( + &element, + &[b"NAME", b"GUID", b"REMOTEID", b"MASTERID", b"ALTERID"], + )?; + let identities = parsed_source_identities(&reader, &element)?; + let (source_id, identity_kind) = preferred_identity(&identities); + records.push(ParsedSourceRecord { + record: TallyNamedMaster { + name: attr_value(&reader, &element, b"NAME").unwrap_or_default(), + parent: None, + }, + source_id, + identity_kind, + identities, + alter_id: attr_value(&reader, &element, b"ALTERID"), + raw_source_sha256: source_fragment_sha256( + xml, + record_start, + reader.buffer_position() as usize, + )?, + }); + } + Event::Start(element) => path.push(element.name().as_ref().to_ascii_uppercase()), + Event::End(element) => pop_expected_path(&mut path, element.name().as_ref())?, + Event::Eof => break, + _ => {} + } + } + validate_scoped_export(&evidence, schema, object_type, records.len())?; + Ok(ParsedExport { records, evidence }) +} + +pub fn parse_ledgers(xml: &str) -> anyhow::Result> { + Ok(parse_ledgers_with_evidence(xml)?.records) +} + +pub fn parse_ledgers_with_evidence(xml: &str) -> anyhow::Result> { + let parsed = parse_ledger_source_records_with_evidence(xml)?; + Ok(ParsedExport { + records: parsed + .records + .into_iter() + .map(|record| record.record) + .collect(), + evidence: parsed.evidence, + }) +} + +pub fn parse_ledger_source_records_with_evidence( + xml: &str, +) -> anyhow::Result>> { + parse_ledger_source_records_for_schema(xml, BRIDGE_LEDGER_EXPORT_SCHEMA) +} + +pub fn parse_ledger_write_readback_with_evidence( + xml: &str, +) -> anyhow::Result>> { + let mut reader = configured_reader(xml); + let mut path = Vec::>::new(); + let mut records = Vec::new(); + let mut context = None::; + let mut header_seen = false; + let mut body_seen = false; + let mut status_seen = false; + loop { + let record_start = reader.buffer_position() as usize; + match reader.read_event()? { + Event::Start(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if matches!( + name.as_slice(), + b"ENVELOPE" | b"HEADER" | b"BODY" | b"VERSION" | b"STATUS" + ) { + validate_only_attributes(&element, &[]).map_err(|_| { + anyhow::anyhow!("Tally export response attributes were invalid") + })?; + } + if path.is_empty() { + if name.as_slice() != b"ENVELOPE" { + anyhow::bail!("Tally write readback root was not ENVELOPE"); + } + path.push(name); + } else if path_eq(&path, &[b"ENVELOPE"]) && name.as_slice() == b"HEADER" { + if std::mem::replace(&mut header_seen, true) || body_seen { + anyhow::bail!("Tally write readback repeated or misplaced HEADER"); + } + path.push(name); + } else if path_eq(&path, &[b"ENVELOPE", b"HEADER"]) && name.as_slice() == b"STATUS" + { + if std::mem::replace(&mut status_seen, true) { + anyhow::bail!("Tally write readback repeated STATUS"); + } + if read_required_text(&mut reader, element.name())? != "1" { + anyhow::bail!("Tally write readback application STATUS was not successful"); + } + } else if path_eq(&path, &[b"ENVELOPE"]) && name.as_slice() == b"BODY" { + if !status_seen || std::mem::replace(&mut body_seen, true) { + anyhow::bail!("Tally write readback repeated or misplaced BODY"); + } + path.push(name); + } else if path_eq(&path, &[b"ENVELOPE", b"BODY"]) + && name.as_slice() == b"COMPANYCONTEXT" + { + if context.is_some() { + anyhow::bail!("Tally write readback repeated COMPANYCONTEXT"); + } + context = Some(parse_company_context(&mut reader, &element, false)?); + } else if path_eq(&path, &[b"ENVELOPE", b"BODY"]) && name.as_slice() == b"LEDGER" { + validate_only_attributes( + &element, + &[b"NAME", b"GUID", b"REMOTEID", b"MASTERID", b"ALTERID"], + )?; + let name = attr_value(&reader, &element, b"NAME"); + let identities = parsed_source_identities(&reader, &element)?; + let (source_id, identity_kind) = preferred_identity(&identities); + let alter_id = attr_value(&reader, &element, b"ALTERID"); + let record = parse_ledger_write_readback(&mut reader, name)?; + records.push(ParsedSourceRecord { + record, + identity_kind, + source_id, + identities, + alter_id, + raw_source_sha256: source_fragment_sha256( + xml, + record_start, + reader.buffer_position() as usize, + )?, + }); + } else { + anyhow::bail!( + "Tally write readback contained an element outside its exact profile" + ); + } + } + Event::Empty(element) + if path_eq(&path, &[b"ENVELOPE", b"BODY"]) + && element + .name() + .as_ref() + .eq_ignore_ascii_case(b"COMPANYCONTEXT") => + { + if context.is_some() { + anyhow::bail!("Tally write readback repeated COMPANYCONTEXT"); + } + context = Some(parse_company_context(&mut reader, &element, true)?); + } + Event::Empty(_) => { + anyhow::bail!( + "Tally write readback contained an empty element outside its exact profile" + ); + } + Event::End(element) => { + let Some(expected) = path.pop() else { + anyhow::bail!("Tally write readback closed an unexpected element"); + }; + if !element.name().as_ref().eq_ignore_ascii_case(&expected) { + anyhow::bail!("Tally write readback closed an unexpected element"); + } + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally write readback contained unexpected text"); + } + Event::Eof => break, + _ => {} + } + } + if !path.is_empty() || !header_seen || !status_seen || !body_seen { + anyhow::bail!("Tally write readback ended before its root closed"); + } + let context = + context.ok_or_else(|| anyhow::anyhow!("Tally write readback omitted COMPANYCONTEXT"))?; + let mut identities = HashMap::::new(); + let mut identified_record_count = 0_u64; + for record in &records { + let mut identified = false; + for identity in [ + record.identities.guid.as_ref(), + record.identities.remote_id.as_ref(), + record.identities.master_id.as_ref(), + ] + .into_iter() + .flatten() + { + *identities.entry(identity.clone()).or_insert(0) += 1; + identified = true; + } + identified_record_count += u64::from(identified); + } + let duplicate_identities = identities + .into_iter() + .filter(|(_, occurrences)| *occurrences > 1) + .map(|(identity, occurrences)| DuplicateIdentityEvidence { + identity_sha256: sha256_hex(identity.as_bytes()), + occurrences, + }) + .collect(); + let evidence = ExportEvidence { + company_context: Some(context.company), + schema: context.schema, + object_type: context.object_type, + source_record_count: context.source_record_count, + identified_record_count, + duplicate_identities, + }; + validate_scoped_export( + &evidence, + BRIDGE_LEDGER_WRITE_READBACK_SCHEMA, + "LEDGER", + records.len(), + )?; + Ok(ParsedExport { records, evidence }) +} + +fn parse_ledger_source_records_for_schema( + xml: &str, + expected_schema: &str, +) -> anyhow::Result>> { + validate_export_response(xml)?; + let evidence = scan_export_evidence(xml)?; + let mut reader = configured_reader(xml); + let mut path = Vec::>::new(); + let mut records = Vec::new(); + loop { + let record_start = reader.buffer_position() as usize; + match reader.read_event()? { + Event::Start(element) + if is_supported_export_parent(&path) + && element.name().as_ref().eq_ignore_ascii_case(b"LEDGER") => + { + validate_only_attributes( + &element, + &[b"NAME", b"GUID", b"REMOTEID", b"MASTERID", b"ALTERID"], + )?; + let name = attr_value(&reader, &element, b"NAME"); + let identities = parsed_source_identities(&reader, &element)?; + let (source_id, identity_kind) = preferred_identity(&identities); + let alter_id = attr_value(&reader, &element, b"ALTERID"); + let record = parse_ledger(&mut reader, name)?; + records.push(ParsedSourceRecord { + record, + identity_kind, + source_id, + identities, + alter_id, + raw_source_sha256: source_fragment_sha256( + xml, + record_start, + reader.buffer_position() as usize, + )?, + }); + } + Event::Empty(element) + if is_supported_export_parent(&path) + && element.name().as_ref().eq_ignore_ascii_case(b"LEDGER") => + { + validate_only_attributes( + &element, + &[b"NAME", b"GUID", b"REMOTEID", b"MASTERID", b"ALTERID"], + )?; + let identities = parsed_source_identities(&reader, &element)?; + let (source_id, identity_kind) = preferred_identity(&identities); + let alter_id = attr_value(&reader, &element, b"ALTERID"); + records.push(ParsedSourceRecord { + record: TallyLedger { + name: attr_value(&reader, &element, b"NAME").unwrap_or_default(), + parent: None, + party_gstin: None, + opening_balance: None, + }, + identity_kind, + source_id, + identities, + alter_id, + raw_source_sha256: source_fragment_sha256( + xml, + record_start, + reader.buffer_position() as usize, + )?, + }); + } + Event::Start(element) => path.push(element.name().as_ref().to_ascii_uppercase()), + Event::End(element) => pop_expected_path(&mut path, element.name().as_ref())?, + Event::Eof => break, + _ => {} + } + } + validate_scoped_export(&evidence, expected_schema, "LEDGER", records.len())?; + Ok(ParsedExport { records, evidence }) +} + +pub fn parse_ledger_period_balance_report( + xml: &str, +) -> anyhow::Result { + validate_export_response(xml)?; + let mut reader = configured_reader(xml); + let mut context = None; + let mut records = Vec::new(); + loop { + let record_start = reader.buffer_position() as usize; + match reader.read_event()? { + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"COMPANYCONTEXT") => + { + let parsed = parse_ledger_period_context(&mut reader, &element, false)?; + if context.replace(parsed).is_some() { + anyhow::bail!("Tally period report repeated its context"); + } + } + Event::Empty(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"COMPANYCONTEXT") => + { + let parsed = parse_ledger_period_context(&mut reader, &element, true)?; + if context.replace(parsed).is_some() { + anyhow::bail!("Tally period report repeated its context"); + } + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"LEDGERPERIODBALANCE") => + { + validate_only_attributes( + &element, + &[b"GUID", b"REMOTEID", b"MASTERID", b"ALTERID"], + )?; + let identities = parsed_source_identities(&reader, &element)?; + let (source_id, identity_kind) = preferred_identity(&identities); + let alter_id = attr_value(&reader, &element, b"ALTERID"); + let record = parse_ledger_period_balance(&mut reader)?; + records.push(ParsedSourceRecord { + record, + source_id, + identity_kind, + identities, + alter_id, + raw_source_sha256: source_fragment_sha256( + xml, + record_start, + reader.buffer_position() as usize, + )?, + }); + } + Event::Eof => break, + _ => {} + } + } + let context = context.ok_or_else(|| anyhow::anyhow!("Tally period report omitted context"))?; + if context.source_record_count != records.len() as u64 { + anyhow::bail!("Tally period report count did not match parsed rows"); + } + let mut identities = HashMap::new(); + for record in &records { + let source_id = record + .source_id + .as_deref() + .ok_or_else(|| anyhow::anyhow!("Tally period report row omitted stable identity"))?; + if identities.insert(source_id, ()).is_some() { + anyhow::bail!("Tally period report repeated a stable identity"); + } + } + Ok(ParsedLedgerPeriodBalanceReport { context, records }) +} + +fn parse_ledger_period_context( + reader: &mut Reader<&[u8]>, + element: &quick_xml::events::BytesStart<'_>, + is_empty: bool, +) -> anyhow::Result { + let mut fields = HashMap::<&'static str, String>::new(); + for attribute in element.attributes().with_checks(true) { + let attribute = attribute + .map_err(|_| anyhow::anyhow!("Tally period context contained malformed attributes"))?; + let key = period_context_field(attribute.key.as_ref()) + .ok_or_else(|| anyhow::anyhow!("Tally period context contained an unexpected field"))?; + let value = attribute + .decoded_and_normalized_value(quick_xml::XmlVersion::Implicit1_0, reader.decoder()) + .map_err(|_| anyhow::anyhow!("Tally period context contained invalid text"))?; + insert_period_context_field(&mut fields, key, value.trim())?; + } + if !is_empty { + loop { + match reader.read_event()? { + Event::Start(child) => { + let key = period_context_field(child.name().as_ref()).ok_or_else(|| { + anyhow::anyhow!("Tally period context contained an unexpected field") + })?; + let value = read_required_text(reader, child.name()).map_err(|_| { + anyhow::anyhow!("Tally period context contained an empty or invalid value") + })?; + insert_period_context_field(&mut fields, key, &value)?; + } + Event::Empty(_) => { + anyhow::bail!("Tally period context contained an empty field"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally period context contained unexpected text"); + } + Event::End(end) if end.name().as_ref().eq_ignore_ascii_case(b"COMPANYCONTEXT") => { + break; + } + Event::Eof => anyhow::bail!("Tally period context ended before closing"), + _ => {} + } + } + } + + let required = |key: &'static str| { + fields + .get(key) + .cloned() + .ok_or_else(|| anyhow::anyhow!("Tally period context omitted {key}")) + }; + let schema = required("SCHEMA")?; + let object_type = required("OBJECTTYPE")?; + if schema != BRIDGE_LEDGER_PERIOD_BALANCE_SCHEMA || object_type != "LEDGERPERIODBALANCE" { + anyhow::bail!("Tally period report scope was invalid"); + } + Ok(LedgerPeriodBalanceContext { + company_guid: required("GUID")?, + from_yyyymmdd: required("FROMDATE")?, + to_yyyymmdd: required("TODATE")?, + ordinary_books_requested: parse_tally_boolean(&required("ORDINARYBOOKSREQUESTED")?)?, + source_record_count: required("RECORDCOUNT")? + .parse::() + .map_err(|_| anyhow::anyhow!("Tally period report count was invalid"))?, + }) +} + +fn period_context_field(name: &[u8]) -> Option<&'static str> { + [ + "SCHEMA", + "OBJECTTYPE", + "GUID", + "FROMDATE", + "TODATE", + "ORDINARYBOOKSREQUESTED", + "RECORDCOUNT", + ] + .into_iter() + .find(|field| name.eq_ignore_ascii_case(field.as_bytes())) +} + +fn insert_period_context_field( + fields: &mut HashMap<&'static str, String>, + key: &'static str, + value: &str, +) -> anyhow::Result<()> { + if value.is_empty() { + anyhow::bail!("Tally period context contained an empty field"); + } + if fields.insert(key, value.to_string()).is_some() { + anyhow::bail!("Tally period context repeated {key}"); + } + Ok(()) +} + +pub fn parse_vouchers(xml: &str) -> anyhow::Result> { + Ok(parse_vouchers_with_evidence(xml)?.records) +} + +pub fn parse_vouchers_with_evidence(xml: &str) -> anyhow::Result> { + let parsed = parse_voucher_source_records_with_evidence(xml)?; + Ok(ParsedExport { + records: parsed + .records + .into_iter() + .map(|record| record.record) + .collect(), + evidence: parsed.evidence, + }) +} + +pub fn parse_voucher_source_records_with_evidence( + xml: &str, +) -> anyhow::Result>> { + parse_voucher_source_records_for_schema(xml, BRIDGE_VOUCHER_EXPORT_SCHEMA) +} + +pub fn parse_selected_voucher_source_records_with_evidence( + xml: &str, +) -> anyhow::Result>> { + parse_voucher_source_records_for_schema(xml, BRIDGE_SELECTED_VOUCHER_EXPORT_SCHEMA) +} + +fn parse_voucher_source_records_for_schema( + xml: &str, + expected_schema: &str, +) -> anyhow::Result>> { + validate_export_response(xml)?; + let evidence = scan_export_evidence(xml)?; + let mut reader = configured_reader(xml); + let mut path = Vec::>::new(); + let mut records = Vec::new(); + loop { + let record_start = reader.buffer_position() as usize; + match reader.read_event()? { + Event::Start(element) + if is_supported_export_parent(&path) + && element.name().as_ref().eq_ignore_ascii_case(b"VOUCHER") => + { + validate_only_attributes( + &element, + &[b"GUID", b"REMOTEID", b"MASTERID", b"ALTERID"], + )?; + let identities = parsed_source_identities(&reader, &element)?; + let (source_id, identity_kind) = preferred_identity(&identities); + let alter_id = attr_value(&reader, &element, b"ALTERID"); + let record = parse_voucher(&mut reader, source_id.clone(), xml)?; + records.push(ParsedSourceRecord { + record, + source_id, + identity_kind, + identities, + alter_id, + raw_source_sha256: source_fragment_sha256( + xml, + record_start, + reader.buffer_position() as usize, + )?, + }); + } + Event::Empty(element) + if is_supported_export_parent(&path) + && element.name().as_ref().eq_ignore_ascii_case(b"VOUCHER") => + { + validate_only_attributes( + &element, + &[b"GUID", b"REMOTEID", b"MASTERID", b"ALTERID"], + )?; + let identities = parsed_source_identities(&reader, &element)?; + let (source_id, identity_kind) = preferred_identity(&identities); + let alter_id = attr_value(&reader, &element, b"ALTERID"); + records.push(ParsedSourceRecord { + record: TallyVoucher { + id: source_id.clone(), + date: None, + voucher_type: None, + voucher_number: None, + party_ledger_name: None, + cancelled: None, + optional: None, + ledger_entry_count: None, + ledger_entries: Vec::new(), + }, + source_id, + identity_kind, + identities, + alter_id, + raw_source_sha256: source_fragment_sha256( + xml, + record_start, + reader.buffer_position() as usize, + )?, + }); + } + Event::Start(element) => path.push(element.name().as_ref().to_ascii_uppercase()), + Event::End(element) => pop_expected_path(&mut path, element.name().as_ref())?, + Event::Eof => break, + _ => {} + } + } + validate_scoped_export(&evidence, expected_schema, "VOUCHER", records.len())?; + Ok(ParsedExport { records, evidence }) +} + +pub fn verify_company_context( + evidence: &ExportEvidence, + expected_guid: &str, +) -> anyhow::Result<()> { + if expected_guid.trim().is_empty() { + anyhow::bail!("Expected Tally company identity is missing"); + } + match evidence + .company_context + .as_ref() + .and_then(|context| context.guid.as_deref()) + { + Some(actual) if actual.eq_ignore_ascii_case(expected_guid) => Ok(()), + Some(_) => anyhow::bail!("Tally response company context did not match the request"), + None => anyhow::bail!("Tally response did not include verifiable company context"), + } +} + +pub fn verify_selected_voucher_window_context( + evidence: &ExportEvidence, + expected_from_yyyymmdd: &str, + expected_to_yyyymmdd: &str, +) -> anyhow::Result<()> { + let company = evidence + .company_context + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Tally response did not include selected window context"))?; + if company.requested_from_yyyymmdd.as_deref() != Some(expected_from_yyyymmdd) + || company.requested_to_yyyymmdd.as_deref() != Some(expected_to_yyyymmdd) + { + anyhow::bail!("Tally response selected window context did not match the request"); + } + Ok(()) +} + +/// Enforces the narrow response skeleton used as selected-read capability evidence. Compatibility +/// parsers remain intentionally separate and may accept broader Tally wrapper shapes. +pub fn validate_exact_selected_export_structure( + xml: &str, + expected_primary_row: &str, +) -> anyhow::Result<()> { + let expected_primary_row = expected_primary_row.as_bytes().to_ascii_uppercase(); + if ![b"LEDGER".as_slice(), b"VOUCHER".as_slice()] + .iter() + .any(|candidate| *candidate == expected_primary_row) + { + anyhow::bail!("Selected Tally read primary row type was invalid"); + } + let mut reader = configured_reader(xml); + let mut path = Vec::>::new(); + loop { + match reader.read_event()? { + Event::Start(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if !selected_structure_child_allowed(&path, &name, &expected_primary_row) { + anyhow::bail!("Selected Tally read contained an unexpected structural element"); + } + validate_selected_wrapper_attributes(&element, &name)?; + path.push(name); + } + Event::Empty(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if !selected_structure_child_allowed(&path, &name, &expected_primary_row) { + anyhow::bail!("Selected Tally read contained an unexpected empty element"); + } + validate_selected_wrapper_attributes(&element, &name)?; + } + Event::End(element) => pop_expected_path(&mut path, element.name().as_ref())?, + Event::Text(text) + if !text.decode()?.trim().is_empty() + && !selected_structure_text_allowed(&path, &expected_primary_row) => + { + anyhow::bail!("Selected Tally read contained unexpected structural text"); + } + Event::CData(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Selected Tally read contained unexpected CDATA"); + } + Event::DocType(_) | Event::PI(_) => { + anyhow::bail!("Selected Tally read contained a forbidden XML construct"); + } + Event::Eof => break, + _ => {} + } + } + if !path.is_empty() { + anyhow::bail!("Selected Tally read ended before its structure closed"); + } + Ok(()) +} + +fn validate_selected_wrapper_attributes( + element: &quick_xml::events::BytesStart<'_>, + name: &[u8], +) -> anyhow::Result<()> { + if matches!( + name, + b"ENVELOPE" | b"HEADER" | b"BODY" | b"DATA" | b"COLLECTION" | b"VERSION" | b"STATUS" + ) { + validate_only_attributes(element, &[]) + .map_err(|_| anyhow::anyhow!("Selected Tally read wrapper attributes were invalid"))?; + } + Ok(()) +} + +fn selected_structure_child_allowed(path: &[Vec], name: &[u8], expected: &[u8]) -> bool { + if path + .iter() + .any(|part| part == expected || part.as_slice() == b"COMPANYCONTEXT") + { + return true; + } + match path { + [] => name == b"ENVELOPE", + [envelope] if envelope.as_slice() == b"ENVELOPE" => { + matches!(name, b"HEADER" | b"BODY") + } + [envelope, header] + if envelope.as_slice() == b"ENVELOPE" && header.as_slice() == b"HEADER" => + { + matches!(name, b"VERSION" | b"STATUS") + } + [envelope, body] if envelope.as_slice() == b"ENVELOPE" && body.as_slice() == b"BODY" => { + name == b"DATA" || name == b"COMPANYCONTEXT" || name == expected + } + [envelope, body, data] + if envelope.as_slice() == b"ENVELOPE" + && body.as_slice() == b"BODY" + && data.as_slice() == b"DATA" => + { + name == b"COMPANYCONTEXT" || name == b"COLLECTION" || name == expected + } + [envelope, body, data, collection] + if envelope.as_slice() == b"ENVELOPE" + && body.as_slice() == b"BODY" + && data.as_slice() == b"DATA" + && collection.as_slice() == b"COLLECTION" => + { + name == expected + } + _ => false, + } +} + +fn selected_structure_text_allowed(path: &[Vec], expected: &[u8]) -> bool { + path.iter().any(|part| { + part == expected + || part.as_slice() == b"COMPANYCONTEXT" + || matches!(part.as_slice(), b"VERSION" | b"STATUS") + }) +} + +pub fn parse_import_outcome(xml: &str) -> anyhow::Result { + let mut reader = configured_reader(xml); + let mut path = Vec::>::new(); + let mut root = None::>; + let mut root_closed = false; + let mut saw_import_result = false; + let mut saw_direct_data_result = false; + let mut envelope_header_seen = false; + let mut envelope_body_seen = false; + let mut status = None; + let mut created = None; + let mut altered = None; + let mut deleted = None; + let mut ignored = None; + let mut errors = None; + let mut cancelled = None; + let mut exceptions = None; + let mut line_error_count = 0_u64; + let mut documented_extra_fields = HashSet::new(); + + loop { + match reader.read_event()? { + Event::Start(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + validate_only_attributes(&element, &[]).map_err(|_| { + anyhow::anyhow!("Tally import response attributes were invalid") + })?; + if path.is_empty() { + if root.is_some() + || (name.as_slice() != b"RESPONSE" && name.as_slice() != b"ENVELOPE") + { + anyhow::bail!("Tally import response root must be RESPONSE or ENVELOPE"); + } + root = Some(name.clone()); + } + if root.as_deref() == Some(b"ENVELOPE") && path_eq(&path, &[b"ENVELOPE"]) { + if !envelope_header_seen && name.as_slice() != b"HEADER" { + anyhow::bail!("Tally import response expected HEADER before BODY"); + } + if envelope_header_seen && !envelope_body_seen && name.as_slice() != b"BODY" { + anyhow::bail!("Tally import response expected BODY after HEADER"); + } + if envelope_body_seen { + anyhow::bail!("Tally import response contained an extra ENVELOPE child"); + } + } + if name.as_slice() == b"HEADER" { + if !path_eq(&path, &[b"ENVELOPE"]) || envelope_header_seen { + anyhow::bail!("Tally import response repeated or misplaced HEADER"); + } + envelope_header_seen = true; + } + if name.as_slice() == b"BODY" { + if !path_eq(&path, &[b"ENVELOPE"]) || envelope_body_seen { + anyhow::bail!("Tally import response repeated or misplaced BODY"); + } + envelope_body_seen = true; + } + path.push(name.clone()); + if path_eq(&path, &[b"ENVELOPE", b"BODY", b"DATA", b"IMPORTRESULT"]) { + if saw_import_result || saw_direct_data_result { + anyhow::bail!("Tally import ENVELOPE repeated IMPORTRESULT"); + } + saw_import_result = true; + } + let is_response_field = path.len() == 2 && path[0].as_slice() == b"RESPONSE"; + let is_wrapped_envelope_field = path.len() == 5 + && path_eq_prefix(&path, &[b"ENVELOPE", b"BODY", b"DATA", b"IMPORTRESULT"]); + let is_direct_envelope_field = path.len() == 4 + && path_eq_prefix(&path, &[b"ENVELOPE", b"BODY", b"DATA"]) + && name.as_slice() != b"IMPORTRESULT"; + let is_counter_field = + is_response_field || is_wrapped_envelope_field || is_direct_envelope_field; + if path_eq(&path, &[b"ENVELOPE", b"HEADER", b"STATUS"]) { + let value = read_required_text(&mut reader, element.name())?; + set_import_once(&mut status, value, "STATUS")?; + path.pop(); + } else if is_counter_field { + let consumed = match name.as_slice() { + b"CREATED" => { + let value = read_counter(&mut reader, element.name(), "CREATED")?; + set_import_once(&mut created, value, "CREATED")?; + true + } + b"ALTERED" => { + let value = read_counter(&mut reader, element.name(), "ALTERED")?; + set_import_once(&mut altered, value, "ALTERED")?; + true + } + b"DELETED" => { + let value = read_counter(&mut reader, element.name(), "DELETED")?; + set_import_once(&mut deleted, value, "DELETED")?; + true + } + b"IGNORED" => { + let value = read_counter(&mut reader, element.name(), "IGNORED")?; + set_import_once(&mut ignored, value, "IGNORED")?; + true + } + b"ERRORS" => { + let value = read_counter(&mut reader, element.name(), "ERRORS")?; + set_import_once(&mut errors, value, "ERRORS")?; + true + } + b"CANCELLED" => { + let value = read_counter(&mut reader, element.name(), "CANCELLED")?; + set_import_once(&mut cancelled, value, "CANCELLED")?; + true + } + b"EXCEPTIONS" => { + let value = read_counter(&mut reader, element.name(), "EXCEPTIONS")?; + set_import_once(&mut exceptions, value, "EXCEPTIONS")?; + true + } + b"LINEERROR" => { + if read_optional_text(&mut reader, element.name())?.is_some() { + line_error_count = line_error_count.saturating_add(1); + } + true + } + _ => false, + }; + if consumed { + if is_direct_envelope_field { + if saw_import_result { + anyhow::bail!( + "Tally import ENVELOPE mixed direct and wrapped result profiles" + ); + } + saw_direct_data_result = true; + } + path.pop(); + } else if is_counter_field + && matches!( + name.as_slice(), + b"LASTVCHID" | b"LASTMID" | b"COMBINED" | b"VCHNUMBER" | b"DESC" + ) + { + if !documented_extra_fields.insert(name.clone()) { + anyhow::bail!("Tally import response duplicated a documented field"); + } + read_optional_text(&mut reader, element.name())?; + if is_direct_envelope_field { + if saw_import_result { + anyhow::bail!( + "Tally import ENVELOPE mixed direct and wrapped result profiles" + ); + } + saw_direct_data_result = true; + } + path.pop(); + } else { + anyhow::bail!("Tally import response contained an unexpected result field"); + } + } else if path.len() >= 2 + && (path_eq_prefix(&path, &[b"RESPONSE"]) + || path_eq_prefix(&path, &[b"ENVELOPE", b"HEADER"]) + || path_eq_prefix(&path, &[b"ENVELOPE", b"BODY"])) + && !path_eq(&path, &[b"ENVELOPE", b"HEADER"]) + && !path_eq(&path, &[b"ENVELOPE", b"BODY"]) + && !path_eq(&path, &[b"ENVELOPE", b"BODY", b"DATA"]) + && !path_eq(&path, &[b"ENVELOPE", b"BODY", b"DATA", b"IMPORTRESULT"]) + { + anyhow::bail!("Tally import response contained an unexpected element"); + } + } + Event::End(element) => { + let Some(expected) = path.pop() else { + anyhow::bail!("Tally import response contained an unexpected closing element"); + }; + if !element.name().as_ref().eq_ignore_ascii_case(&expected) { + anyhow::bail!("Tally import response closed an unexpected element"); + } + if path.is_empty() { + root_closed = true; + } + } + Event::Empty(element) + if path_eq(&path, &[b"ENVELOPE", b"BODY", b"DATA"]) + && element + .name() + .as_ref() + .eq_ignore_ascii_case(b"IMPORTRESULT") => + { + validate_only_attributes(&element, &[]).map_err(|_| { + anyhow::anyhow!("Tally import response attributes were invalid") + })?; + if saw_import_result || saw_direct_data_result { + anyhow::bail!("Tally import ENVELOPE repeated IMPORTRESULT"); + } + saw_import_result = true; + } + Event::Empty(_) if path_eq(&path, &[b"ENVELOPE"]) => { + anyhow::bail!( + "Tally import response contained an empty or unexpected ENVELOPE child" + ); + } + Event::Empty(element) + if element.name().as_ref().eq_ignore_ascii_case(b"HEADER") + || element.name().as_ref().eq_ignore_ascii_case(b"BODY") => + { + anyhow::bail!("Tally import response contained an empty critical container"); + } + Event::Empty(_) => { + anyhow::bail!("Tally import response contained an unexpected empty element"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally import response contained unexpected mixed text"); + } + Event::CData(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally import response contained unexpected mixed CDATA"); + } + Event::DocType(_) | Event::PI(_) => { + anyhow::bail!("Tally import response contained a forbidden XML construct"); + } + Event::Eof => break, + _ => {} + } + } + if !root_closed || !path.is_empty() { + anyhow::bail!("Tally import response ended before its root element closed"); + } + let application_status = if root.as_deref() == Some(b"ENVELOPE") { + if !envelope_header_seen || !envelope_body_seen { + anyhow::bail!("Tally import ENVELOPE omitted HEADER or BODY"); + } + if !saw_import_result && !saw_direct_data_result { + anyhow::bail!("Tally import ENVELOPE did not include a recognized result profile"); + } + match status.as_deref() { + Some("1") => TallyImportApplicationStatus::Success, + Some("0") => TallyImportApplicationStatus::Failure, + Some(_) => anyhow::bail!("Tally returned an invalid import application STATUS"), + None => anyhow::bail!("Tally import ENVELOPE did not include HEADER/STATUS"), + } + } else { + TallyImportApplicationStatus::NotReported + }; + let exceptions_were_reported = exceptions.is_some(); + let counters = TallyImportResult { + created: created.ok_or_else(|| anyhow::anyhow!("Tally import result omitted CREATED"))?, + altered: altered.ok_or_else(|| anyhow::anyhow!("Tally import result omitted ALTERED"))?, + deleted: deleted.unwrap_or(0), + ignored: ignored.ok_or_else(|| anyhow::anyhow!("Tally import result omitted IGNORED"))?, + errors: errors.ok_or_else(|| anyhow::anyhow!("Tally import result omitted ERRORS"))?, + cancelled: cancelled.unwrap_or(0), + exceptions: exceptions.unwrap_or(0), + line_error_count, + }; + Ok(TallyImportOutcome { + application_status, + counters, + exceptions_were_reported, + }) +} + +pub fn parse_import_result(xml: &str) -> anyhow::Result { + let outcome = parse_import_outcome(xml)?; + if outcome.application_status() == TallyImportApplicationStatus::Failure { + anyhow::bail!("Tally reported that the import request failed"); + } + Ok(outcome.into_counters()) +} + +fn path_eq(path: &[Vec], expected: &[&[u8]]) -> bool { + path.len() == expected.len() + && path + .iter() + .zip(expected) + .all(|(actual, expected)| actual.as_slice() == *expected) +} + +fn path_eq_prefix(path: &[Vec], expected: &[&[u8]]) -> bool { + path.len() >= expected.len() + && path + .iter() + .zip(expected) + .all(|(actual, expected)| actual.as_slice() == *expected) +} + +fn set_import_once(slot: &mut Option, value: T, label: &str) -> anyhow::Result<()> { + if slot.replace(value).is_some() { + anyhow::bail!("Tally import response duplicated {label}"); + } + Ok(()) +} + +/// Parses import counters and derives redacted evidence from the same exact +/// response. Digest domains prevent a response commitment from being confused +/// with a payload, intended-state, or readback-state commitment. +pub fn parse_import_evidence(xml: &str) -> anyhow::Result { + parse_import_evidence_inner(xml) + .map_err(|_| anyhow::anyhow!("Tally import response evidence was invalid")) +} + +fn parse_import_evidence_inner(xml: &str) -> anyhow::Result { + const MAX_IMPORT_RESPONSE_BYTES: usize = 1024 * 1024; + const MAX_LINE_ERRORS: usize = 256; + + if xml.len() > MAX_IMPORT_RESPONSE_BYTES { + anyhow::bail!("Tally import response exceeded the safe byte limit"); + } + let outcome = parse_import_outcome(xml)?; + let application_status = outcome.application_status(); + let exceptions_were_reported = outcome.exceptions_were_reported(); + let counters = outcome.into_counters(); + let mut reader = configured_reader(xml); + let mut line_error_sha256 = Vec::new(); + loop { + match reader.read_event()? { + Event::Start(element) if element.name().as_ref().eq_ignore_ascii_case(b"LINEERROR") => { + if let Some(value) = read_optional_text(&mut reader, element.name())? { + if line_error_sha256.len() == MAX_LINE_ERRORS { + anyhow::bail!("Tally import response exceeded the line-error limit"); + } + line_error_sha256.push(domain_sha256( + b"bridge.tally.import-line-error/1\0", + value.as_bytes(), + )); + } + } + Event::Eof => break, + _ => {} + } + } + if counters.line_error_count != line_error_sha256.len() as u64 { + anyhow::bail!("Tally import line-error evidence was inconsistent"); + } + Ok(ParsedImportEvidence { + application_status, + counters, + exceptions_were_reported, + response_sha256: domain_sha256(b"bridge.tally.import-response/1\0", xml.as_bytes()), + line_error_sha256, + }) +} + +fn domain_sha256(domain: &[u8], value: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(domain); + digest.update(value); + let mut encoded = String::with_capacity(64); + for byte in digest.finalize() { + write!(&mut encoded, "{byte:02x}").expect("writing to a String cannot fail"); + } + encoded +} + +pub fn export_status(xml: &str) -> anyhow::Result { + let mut reader = configured_reader(xml); + let mut path = Vec::>::new(); + let mut saw_envelope = false; + let mut envelope_closed = false; + let mut header_seen = false; + let mut body_seen = false; + let mut version = None; + let mut status = None; + loop { + match reader.read_event()? { + Event::Start(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if matches!( + name.as_slice(), + b"ENVELOPE" | b"HEADER" | b"BODY" | b"VERSION" | b"STATUS" + ) { + validate_only_attributes(&element, &[]).map_err(|_| { + anyhow::anyhow!("Tally export response attributes were invalid") + })?; + } + if path.is_empty() { + if !element.name().as_ref().eq_ignore_ascii_case(b"ENVELOPE") { + anyhow::bail!("Tally response root must be ENVELOPE"); + } + if saw_envelope || envelope_closed { + anyhow::bail!("Tally response contained multiple root elements"); + } + saw_envelope = true; + } + if path_eq(&path, &[b"ENVELOPE"]) { + if !header_seen && name.as_slice() != b"HEADER" { + anyhow::bail!("Tally export response expected HEADER before BODY"); + } + if header_seen && !body_seen && name.as_slice() != b"BODY" { + anyhow::bail!("Tally export response expected BODY after HEADER"); + } + if body_seen { + anyhow::bail!("Tally export response contained an extra ENVELOPE child"); + } + } + if name.as_slice() == b"HEADER" { + if !path_eq(&path, &[b"ENVELOPE"]) || header_seen { + anyhow::bail!("Tally export response repeated or misplaced HEADER"); + } + header_seen = true; + path.push(name); + } else if name.as_slice() == b"BODY" { + if !path_eq(&path, &[b"ENVELOPE"]) || !header_seen || body_seen { + anyhow::bail!("Tally export response repeated or misplaced BODY"); + } + body_seen = true; + path.push(name); + } else if name.as_slice() == b"VERSION" { + if !path_eq(&path, &[b"ENVELOPE", b"HEADER"]) { + anyhow::bail!("Tally export response misplaced VERSION"); + } + let value = read_required_text(&mut reader, element.name())?; + if version.replace(value).is_some() { + anyhow::bail!("Tally export response duplicated VERSION"); + } + } else if name.as_slice() == b"STATUS" { + if !path_eq(&path, &[b"ENVELOPE", b"HEADER"]) { + anyhow::bail!("Tally export response misplaced STATUS"); + } + let value = read_required_text(&mut reader, element.name())?; + if status.replace(value).is_some() { + anyhow::bail!("Tally export response duplicated STATUS"); + } + } else if path_eq(&path, &[b"ENVELOPE", b"HEADER"]) { + anyhow::bail!("Tally export response contained an unexpected HEADER field"); + } else { + path.push(name); + } + } + Event::Empty(element) => { + let name = element.name().as_ref().to_ascii_uppercase(); + if matches!( + name.as_slice(), + b"ENVELOPE" | b"HEADER" | b"BODY" | b"VERSION" | b"STATUS" + ) { + validate_only_attributes(&element, &[]).map_err(|_| { + anyhow::anyhow!("Tally export response attributes were invalid") + })?; + } + if path.is_empty() { + if name.as_slice() != b"ENVELOPE" || saw_envelope || envelope_closed { + anyhow::bail!("Tally response root must be one ENVELOPE"); + } + saw_envelope = true; + envelope_closed = true; + } else if path_eq(&path, &[b"ENVELOPE"]) && name.as_slice() == b"BODY" { + if !header_seen || body_seen { + anyhow::bail!("Tally export response repeated or misplaced BODY"); + } + body_seen = true; + } else if path_eq(&path, &[b"ENVELOPE"]) { + anyhow::bail!("Tally export response contained an unexpected ENVELOPE child"); + } else if matches!(name.as_slice(), b"HEADER" | b"VERSION" | b"STATUS") { + anyhow::bail!("Tally export response contained an empty critical header field"); + } + } + Event::End(element) => { + let Some(expected) = path.pop() else { + anyhow::bail!("Tally response contained an unexpected closing element"); + }; + if !element.name().as_ref().eq_ignore_ascii_case(&expected) { + anyhow::bail!("Tally response closed an unexpected element"); + } + if path.is_empty() { + envelope_closed = true; + } + } + Event::Text(text) + if (path.is_empty() + || path_eq(&path, &[b"ENVELOPE"]) + || path_eq(&path, &[b"ENVELOPE", b"HEADER"]) + || path_eq(&path, &[b"ENVELOPE", b"BODY"])) + && !text.decode()?.trim().is_empty() => + { + anyhow::bail!("Tally response contained unexpected structural text") + } + Event::CData(text) + if (path.is_empty() + || path_eq(&path, &[b"ENVELOPE"]) + || path_eq(&path, &[b"ENVELOPE", b"HEADER"]) + || path_eq(&path, &[b"ENVELOPE", b"BODY"])) + && !text.decode()?.trim().is_empty() => + { + anyhow::bail!("Tally response contained unexpected structural CDATA") + } + Event::DocType(_) | Event::PI(_) => { + anyhow::bail!("Tally export response contained a forbidden XML construct") + } + Event::Eof => break, + _ => {} + } + } + if !saw_envelope || !envelope_closed || !path.is_empty() { + anyhow::bail!("Tally response ended before ENVELOPE closed"); + } + if !header_seen { + anyhow::bail!("Tally export response did not include HEADER"); + } + if !body_seen { + anyhow::bail!("Tally export response did not include BODY"); + } + match version.as_deref() { + Some("1") => {} + Some(_) => anyhow::bail!("Tally export response used an unsupported VERSION"), + // Some observed/custom TDL export responses omit VERSION. Absence is + // accepted for compatibility, but duplicates and unsupported values + // are never merged or guessed. + None => {} + } + match status.as_deref() { + Some("1") => Ok(TallyExportStatus::Success), + Some("0") => Ok(TallyExportStatus::Failure), + Some(_) => anyhow::bail!("Tally returned an invalid application STATUS"), + None => anyhow::bail!("Tally export response did not include HEADER/STATUS"), + } +} + +pub fn export_failure_reason_code(xml: &str) -> &'static str { + if xml + .to_ascii_lowercase() + .contains("could not find company ''") + { + "company_not_loaded" + } else { + "tally_export_rejected" + } +} + +fn validate_export_response(xml: &str) -> anyhow::Result<()> { + match export_status(xml)? { + TallyExportStatus::Success => Ok(()), + TallyExportStatus::Failure => { + anyhow::bail!("Tally reported that the export request failed") + } + } +} + +fn configured_reader(xml: &str) -> Reader<&[u8]> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + reader +} + +fn scan_export_evidence(xml: &str) -> anyhow::Result { + let mut reader = configured_reader(xml); + let mut path = Vec::>::new(); + let mut company_context = None; + let mut schema = None; + let mut object_type = None; + let mut source_record_count = None; + let mut identities = HashMap::::new(); + let mut identified_record_count = 0_u64; + loop { + match reader.read_event()? { + Event::Start(element) => { + if is_supported_export_parent(&path) + && element + .name() + .as_ref() + .eq_ignore_ascii_case(b"COMPANYCONTEXT") + { + if company_context.is_some() { + anyhow::bail!("Tally response included multiple company contexts"); + } + let parsed = parse_company_context(&mut reader, &element, false)?; + company_context = Some(parsed.company); + schema = parsed.schema; + object_type = parsed.object_type; + source_record_count = parsed.source_record_count; + continue; + } + if is_supported_export_parent(&path) + && is_primary_export_row(element.name().as_ref()) + && record_identities(&reader, &element, &mut identities)? + { + identified_record_count = identified_record_count.saturating_add(1); + } + path.push(element.name().as_ref().to_ascii_uppercase()); + } + Event::Empty(element) + if is_supported_export_parent(&path) + && element + .name() + .as_ref() + .eq_ignore_ascii_case(b"COMPANYCONTEXT") => + { + if company_context.is_some() { + anyhow::bail!("Tally response included multiple company contexts"); + } + let parsed = parse_company_context(&mut reader, &element, true)?; + company_context = Some(parsed.company); + schema = parsed.schema; + object_type = parsed.object_type; + source_record_count = parsed.source_record_count; + } + Event::Empty(element) => { + if is_supported_export_parent(&path) + && is_primary_export_row(element.name().as_ref()) + && record_identities(&reader, &element, &mut identities)? + { + identified_record_count = identified_record_count.saturating_add(1); + } + } + Event::End(element) => pop_expected_path(&mut path, element.name().as_ref())?, + Event::Eof => break, + _ => {} + } + } + let mut duplicate_identities = identities + .into_iter() + .filter(|(_, occurrences)| *occurrences > 1) + .map(|(identity, occurrences)| DuplicateIdentityEvidence { + identity_sha256: sha256_hex(identity.as_bytes()), + occurrences, + }) + .collect::>(); + duplicate_identities.sort_by(|left, right| left.identity_sha256.cmp(&right.identity_sha256)); + Ok(ExportEvidence { + company_context, + schema, + object_type, + source_record_count, + identified_record_count, + duplicate_identities, + }) +} + +fn is_supported_export_parent(path: &[Vec]) -> bool { + path_eq(path, &[b"ENVELOPE", b"BODY"]) + || path_eq(path, &[b"ENVELOPE", b"BODY", b"DATA"]) + || path_eq(path, &[b"ENVELOPE", b"BODY", b"DATA", b"COLLECTION"]) +} + +fn is_primary_export_row(name: &[u8]) -> bool { + [ + b"GROUP".as_slice(), + b"VOUCHERTYPE".as_slice(), + b"LEDGER".as_slice(), + b"VOUCHER".as_slice(), + b"LEDGERPERIODBALANCE".as_slice(), + ] + .into_iter() + .any(|candidate| name.eq_ignore_ascii_case(candidate)) +} + +fn pop_expected_path(path: &mut Vec>, closing_name: &[u8]) -> anyhow::Result<()> { + let expected = path + .pop() + .ok_or_else(|| anyhow::anyhow!("Tally response closed an unexpected element"))?; + if !closing_name.eq_ignore_ascii_case(&expected) { + anyhow::bail!("Tally response closed an unexpected element"); + } + Ok(()) +} + +fn record_identities( + reader: &Reader<&[u8]>, + element: &quick_xml::events::BytesStart<'_>, + identities: &mut HashMap, +) -> anyhow::Result { + validate_unique_decodable_attributes(reader, element)?; + let mut observed = false; + for (identity_kind, key) in [ + ("guid", b"GUID".as_slice()), + ("remote_id", b"REMOTEID".as_slice()), + ("master_id", b"MASTERID".as_slice()), + ] { + let Some(identity) = attr_value(reader, element, key).filter(|value| !value.is_empty()) + else { + continue; + }; + observed = true; + let object_type = String::from_utf8_lossy(element.name().as_ref()).to_ascii_uppercase(); + let identity = if identity_kind == "guid" { + identity.to_ascii_lowercase() + } else { + identity + }; + let scoped_identity = format!("{object_type}\0{identity_kind}\0{identity}"); + identities + .entry(scoped_identity) + .and_modify(|count| *count = count.saturating_add(1)) + .or_insert(1); + } + Ok(observed) +} + +#[derive(Default)] +struct ParsedCompanyContext { + company: CompanyContextEvidence, + schema: Option, + object_type: Option, + source_record_count: Option, +} + +#[derive(Clone, Copy)] +enum ContextField { + Schema, + ObjectType, + Name, + Guid, + RecordCount, + QueryIdentitySetSha256, + RequestedFrom, + RequestedTo, +} + +fn parse_company_context( + reader: &mut Reader<&[u8]>, + element: &quick_xml::events::BytesStart<'_>, + is_empty: bool, +) -> anyhow::Result { + let mut context = ParsedCompanyContext::default(); + for attribute in element.attributes().with_checks(true) { + let attribute = attribute + .map_err(|_| anyhow::anyhow!("Tally company context contained malformed attributes"))?; + let Some(field) = context_field(attribute.key.as_ref()) else { + anyhow::bail!("Tally company context contained an unexpected attribute"); + }; + let value = attribute + .decoded_and_normalized_value(quick_xml::XmlVersion::Implicit1_0, reader.decoder()) + .map_err(|_| { + anyhow::anyhow!("Tally company context contained an invalid attribute value") + })?; + set_context_field(&mut context, field, value.trim())?; + } + if is_empty { + return Ok(context); + } + loop { + match reader.read_event()? { + Event::Start(element) => { + let Some(field) = context_field(element.name().as_ref()) else { + anyhow::bail!("Tally company context contained an unexpected child element"); + }; + let value = read_required_text(reader, element.name()).map_err(|_| { + anyhow::anyhow!("Tally company context contained an empty or invalid value") + })?; + set_context_field(&mut context, field, &value)?; + } + Event::Empty(element) if context_field(element.name().as_ref()).is_some() => { + anyhow::bail!("Tally company context contained an empty metadata value"); + } + Event::Empty(_) => { + anyhow::bail!("Tally company context contained an unexpected child element"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally company context contained unexpected text"); + } + Event::End(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"COMPANYCONTEXT") => + { + break + } + Event::Eof => anyhow::bail!("Tally response ended before COMPANYCONTEXT closed"), + _ => {} + } + } + Ok(context) +} + +fn context_field(name: &[u8]) -> Option { + if name.eq_ignore_ascii_case(b"SCHEMA") { + Some(ContextField::Schema) + } else if name.eq_ignore_ascii_case(b"OBJECTTYPE") { + Some(ContextField::ObjectType) + } else if name.eq_ignore_ascii_case(b"NAME") { + Some(ContextField::Name) + } else if name.eq_ignore_ascii_case(b"GUID") { + Some(ContextField::Guid) + } else if name.eq_ignore_ascii_case(b"RECORDCOUNT") { + Some(ContextField::RecordCount) + } else if name.eq_ignore_ascii_case(b"QUERYIDENTITYSETSHA256") { + Some(ContextField::QueryIdentitySetSha256) + } else if name.eq_ignore_ascii_case(b"FROMDATE") { + Some(ContextField::RequestedFrom) + } else if name.eq_ignore_ascii_case(b"TODATE") { + Some(ContextField::RequestedTo) + } else { + None + } +} + +fn set_context_field( + context: &mut ParsedCompanyContext, + field: ContextField, + value: &str, +) -> anyhow::Result<()> { + if value.is_empty() { + anyhow::bail!("Tally company context contained an empty metadata value"); + } + match field { + ContextField::Schema => set_once(&mut context.schema, value.to_owned())?, + ContextField::ObjectType => set_once(&mut context.object_type, value.to_owned())?, + ContextField::Name => set_once(&mut context.company.name, value.to_owned())?, + ContextField::Guid => set_once(&mut context.company.guid, value.to_owned())?, + ContextField::RecordCount => { + if !value.bytes().all(|byte| byte.is_ascii_digit()) { + anyhow::bail!("Tally company context RECORDCOUNT was not a non-negative integer"); + } + let count = value.parse::().map_err(|_| { + anyhow::anyhow!("Tally company context RECORDCOUNT was not a non-negative integer") + })?; + set_once(&mut context.source_record_count, count)?; + } + ContextField::QueryIdentitySetSha256 => { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + anyhow::bail!("Tally company context contained an invalid query identity digest"); + } + set_once( + &mut context.company.query_identity_set_sha256, + value.to_ascii_lowercase(), + )?; + } + ContextField::RequestedFrom => { + validate_yyyymmdd_context(value)?; + set_once( + &mut context.company.requested_from_yyyymmdd, + value.to_string(), + )?; + } + ContextField::RequestedTo => { + validate_yyyymmdd_context(value)?; + set_once( + &mut context.company.requested_to_yyyymmdd, + value.to_string(), + )?; + } + } + Ok(()) +} + +fn validate_yyyymmdd_context(value: &str) -> anyhow::Result<()> { + if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + anyhow::bail!("Tally company context contained an invalid date binding"); + } + Ok(()) +} + +fn set_once(slot: &mut Option, value: T) -> anyhow::Result<()> { + if slot.is_some() { + anyhow::bail!("Tally company context contained duplicate metadata"); + } + *slot = Some(value); + Ok(()) +} + +fn validate_scoped_export( + evidence: &ExportEvidence, + expected_schema: &str, + expected_object_type: &str, + parsed_record_count: usize, +) -> anyhow::Result<()> { + let company = evidence + .company_context + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Tally response omitted company context"))?; + if company.name.is_none() || company.guid.is_none() { + anyhow::bail!("Tally company context omitted required company identity"); + } + match evidence.schema.as_deref() { + Some(actual) if actual == expected_schema => {} + Some(_) => anyhow::bail!("Tally response export schema did not match the parser"), + None => anyhow::bail!("Tally company context omitted export schema"), + } + match evidence.object_type.as_deref() { + Some(actual) if actual == expected_object_type => {} + Some(_) => anyhow::bail!("Tally response object type did not match the parser"), + None => anyhow::bail!("Tally company context omitted object type"), + } + let reported = evidence + .source_record_count + .ok_or_else(|| anyhow::anyhow!("Tally company context omitted source record count"))?; + let parsed = u64::try_from(parsed_record_count) + .map_err(|_| anyhow::anyhow!("Tally parsed record count exceeded the supported range"))?; + if reported != parsed { + anyhow::bail!("Tally source record count did not match parsed primary rows"); + } + Ok(()) +} + +fn parsed_source_identities( + reader: &Reader<&[u8]>, + element: &quick_xml::events::BytesStart<'_>, +) -> anyhow::Result { + validate_unique_decodable_attributes(reader, element)?; + Ok(ParsedSourceIdentities { + guid: validated_optional_identifier(attr_value(reader, element, b"GUID"))? + .map(|guid| guid.to_ascii_lowercase()), + remote_id: validated_optional_identifier(attr_value(reader, element, b"REMOTEID"))?, + master_id: validated_optional_identifier(attr_value(reader, element, b"MASTERID"))?, + }) +} + +fn validated_optional_identifier(value: Option) -> anyhow::Result> { + let Some(value) = value else { + return Ok(None); + }; + if value.is_empty() { + return Ok(None); + } + if value.len() > 512 || value.trim() != value || value.chars().any(char::is_control) { + anyhow::bail!("Tally record contained an invalid source identifier"); + } + Ok(Some(value)) +} + +fn preferred_identity( + identities: &ParsedSourceIdentities, +) -> (Option, Option) { + if let Some(guid) = &identities.guid { + (Some(guid.clone()), Some(ParsedSourceIdentityKind::Guid)) + } else if let Some(remote_id) = &identities.remote_id { + ( + Some(remote_id.clone()), + Some(ParsedSourceIdentityKind::RemoteId), + ) + } else if let Some(master_id) = &identities.master_id { + ( + Some(master_id.clone()), + Some(ParsedSourceIdentityKind::MasterId), + ) + } else { + (None, None) + } +} + +fn source_fragment_sha256(xml: &str, start: usize, end: usize) -> anyhow::Result { + let fragment = xml + .as_bytes() + .get(start..end) + .ok_or_else(|| anyhow::anyhow!("Tally record fragment boundaries were invalid"))?; + if fragment.is_empty() { + anyhow::bail!("Tally record fragment was empty"); + } + Ok(sha256_hex(fragment)) +} + +fn parse_company_info(reader: &mut Reader<&[u8]>) -> anyhow::Result { + let mut company = TallyCompany { + name: String::new(), + guid: None, + }; + loop { + match reader.read_event()? { + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"COMPANYNAMEFIELD") => + { + company.name = read_optional_text(reader, element.name())?.unwrap_or_default() + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"COMPANYGUIDFIELD") + || element.name().as_ref().eq_ignore_ascii_case(b"GUIDFIELD") => + { + company.guid = read_optional_text(reader, element.name())? + } + Event::End(element) if element.name().as_ref().eq_ignore_ascii_case(b"COMPANYINFO") => { + break + } + Event::Eof => anyhow::bail!("Tally company response ended before COMPANYINFO closed"), + _ => {} + } + } + Ok(company) +} + +fn parse_named_master( + reader: &mut Reader<&[u8]>, + element_name: &[u8], + name: String, +) -> anyhow::Result { + let mut record = TallyNamedMaster { name, parent: None }; + let mut parent_seen = false; + loop { + match reader.read_event()? { + Event::Start(element) if element.name().as_ref().eq_ignore_ascii_case(b"PARENT") => { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut parent_seen, true) { + anyhow::bail!("Tally master row repeated PARENT"); + } + record.parent = read_optional_text(reader, element.name())?; + } + Event::End(element) if element.name().as_ref().eq_ignore_ascii_case(element_name) => { + break; + } + Event::Start(_) | Event::Empty(_) => { + anyhow::bail!("Tally master row contained an unexpected field"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally master row contained unexpected text"); + } + Event::Eof => anyhow::bail!("Tally master response ended before its row closed"), + _ => {} + } + } + Ok(record) +} + +fn parse_ledger(reader: &mut Reader<&[u8]>, name: Option) -> anyhow::Result { + let mut ledger = TallyLedger { + name: name.unwrap_or_default(), + parent: None, + party_gstin: None, + opening_balance: None, + }; + let mut parent_seen = false; + let mut gstin_seen = false; + let mut opening_seen = false; + loop { + match reader.read_event()? { + Event::Start(element) if element.name().as_ref().eq_ignore_ascii_case(b"PARENT") => { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut parent_seen, true) { + anyhow::bail!("Tally ledger row repeated PARENT"); + } + ledger.parent = read_optional_text(reader, element.name())? + } + Event::Start(element) + if element.name().as_ref().eq_ignore_ascii_case(b"PARTYGSTIN") => + { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut gstin_seen, true) { + anyhow::bail!("Tally ledger row repeated PARTYGSTIN"); + } + ledger.party_gstin = read_optional_text(reader, element.name())? + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"OPENINGBALANCE") => + { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut opening_seen, true) { + anyhow::bail!("Tally ledger row repeated OPENINGBALANCE"); + } + ledger.opening_balance = read_optional_text(reader, element.name())? + } + Event::End(element) if element.name().as_ref().eq_ignore_ascii_case(b"LEDGER") => break, + Event::Start(_) | Event::Empty(_) => { + anyhow::bail!("Tally ledger row contained an unexpected field"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally ledger row contained unexpected text"); + } + Event::Eof => anyhow::bail!("Tally ledger response ended before LEDGER closed"), + _ => {} + } + } + Ok(ledger) +} + +fn parse_ledger_write_readback( + reader: &mut Reader<&[u8]>, + name: Option, +) -> anyhow::Result { + let mut ledger = TallyLedger { + name: name.ok_or_else(|| anyhow::anyhow!("Tally write readback omitted ledger NAME"))?, + parent: None, + party_gstin: None, + opening_balance: None, + }; + let mut parent_seen = false; + let mut gstin_seen = false; + let mut opening_seen = false; + loop { + match reader.read_event()? { + Event::Start(element) if element.name().as_ref().eq_ignore_ascii_case(b"PARENT") => { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut parent_seen, true) { + anyhow::bail!("Tally write readback repeated PARENT"); + } + ledger.parent = read_optional_text(reader, element.name())?; + } + Event::Start(element) + if element.name().as_ref().eq_ignore_ascii_case(b"PARTYGSTIN") => + { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut gstin_seen, true) { + anyhow::bail!("Tally write readback repeated PARTYGSTIN"); + } + ledger.party_gstin = read_optional_text(reader, element.name())?; + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"OPENINGBALANCE") => + { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut opening_seen, true) { + anyhow::bail!("Tally write readback repeated OPENINGBALANCE"); + } + ledger.opening_balance = read_optional_text(reader, element.name())?; + } + Event::Empty(element) if element.name().as_ref().eq_ignore_ascii_case(b"PARENT") => { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut parent_seen, true) { + anyhow::bail!("Tally write readback repeated PARENT"); + } + } + Event::Empty(element) + if element.name().as_ref().eq_ignore_ascii_case(b"PARTYGSTIN") => + { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut gstin_seen, true) { + anyhow::bail!("Tally write readback repeated PARTYGSTIN"); + } + } + Event::Empty(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"OPENINGBALANCE") => + { + validate_only_attributes(&element, &[])?; + if std::mem::replace(&mut opening_seen, true) { + anyhow::bail!("Tally write readback repeated OPENINGBALANCE"); + } + } + Event::Start(_) | Event::Empty(_) => { + anyhow::bail!("Tally write readback contained an unexpected ledger field"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally write readback contained unexpected ledger text"); + } + Event::End(element) if element.name().as_ref().eq_ignore_ascii_case(b"LEDGER") => break, + Event::End(_) => anyhow::bail!("Tally write readback closed an unexpected element"), + Event::Eof => anyhow::bail!("Tally write readback ended before LEDGER closed"), + _ => {} + } + } + Ok(ledger) +} + +fn parse_ledger_period_balance( + reader: &mut Reader<&[u8]>, +) -> anyhow::Result { + let mut opening_balance = None; + let mut closing_balance = None; + loop { + match reader.read_event()? { + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"OPENINGBALANCE") => + { + if opening_balance.is_some() { + anyhow::bail!("Tally period-balance row repeated opening amount"); + } + opening_balance = Some(read_required_text(reader, element.name())?); + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"CLOSINGBALANCE") => + { + if closing_balance.is_some() { + anyhow::bail!("Tally period-balance row repeated closing amount"); + } + closing_balance = Some(read_required_text(reader, element.name())?); + } + Event::Empty(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"OPENINGBALANCE") => + { + anyhow::bail!("Tally period-balance row contained an empty opening amount"); + } + Event::Empty(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"CLOSINGBALANCE") => + { + anyhow::bail!("Tally period-balance row contained an empty closing amount"); + } + Event::End(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"LEDGERPERIODBALANCE") => + { + break; + } + Event::Start(_) | Event::Empty(_) => { + anyhow::bail!("Tally period-balance row contained an unexpected field"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally period-balance row contained unexpected text"); + } + Event::Eof => anyhow::bail!("Tally period-balance row ended before closing"), + _ => {} + } + } + Ok(TallyLedgerPeriodBalance { + opening_balance: opening_balance + .ok_or_else(|| anyhow::anyhow!("Tally period-balance row omitted opening amount"))?, + closing_balance: closing_balance + .ok_or_else(|| anyhow::anyhow!("Tally period-balance row omitted closing amount"))?, + }) +} + +fn parse_voucher( + reader: &mut Reader<&[u8]>, + id: Option, + xml: &str, +) -> anyhow::Result { + let mut voucher = TallyVoucher { + id, + date: None, + voucher_type: None, + voucher_number: None, + party_ledger_name: None, + cancelled: None, + optional: None, + ledger_entry_count: None, + ledger_entries: Vec::new(), + }; + let mut seen = HashSet::new(); + loop { + match reader.read_event()? { + Event::Start(element) if element.name().as_ref().eq_ignore_ascii_case(b"DATE") => { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "DATE", "voucher")?; + voucher.date = read_optional_text(reader, element.name())? + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"VOUCHERTYPENAME") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "VOUCHERTYPENAME", "voucher")?; + voucher.voucher_type = read_optional_text(reader, element.name())? + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"VOUCHERNUMBER") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "VOUCHERNUMBER", "voucher")?; + voucher.voucher_number = read_optional_text(reader, element.name())? + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"PARTYLEDGERNAME") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "PARTYLEDGERNAME", "voucher")?; + voucher.party_ledger_name = read_optional_text(reader, element.name())? + } + Event::Start(element) + if element.name().as_ref().eq_ignore_ascii_case(b"ISCANCELLED") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "ISCANCELLED", "voucher")?; + voucher.cancelled = read_optional_text(reader, element.name())? + .map(|value| parse_tally_boolean(&value)) + .transpose()?; + } + Event::Start(element) + if element.name().as_ref().eq_ignore_ascii_case(b"ISOPTIONAL") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "ISOPTIONAL", "voucher")?; + voucher.optional = read_optional_text(reader, element.name())? + .map(|value| parse_tally_boolean(&value)) + .transpose()?; + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"LEDGERENTRYCOUNT") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "LEDGERENTRYCOUNT", "voucher")?; + let value = read_optional_text(reader, element.name())? + .ok_or_else(|| anyhow::anyhow!("Tally voucher omitted ledger entry count"))?; + if !value.bytes().all(|byte| byte.is_ascii_digit()) { + anyhow::bail!("Tally voucher ledger entry count was invalid"); + } + voucher.ledger_entry_count = Some(value.parse().map_err(|_| { + anyhow::anyhow!("Tally voucher ledger entry count was invalid") + })?); + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"LEDGERENTRIES") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "LEDGERENTRIES", "voucher")?; + parse_ledger_entries(reader, xml, &mut voucher.ledger_entries)?; + } + Event::End(element) if element.name().as_ref().eq_ignore_ascii_case(b"VOUCHER") => { + break + } + Event::Start(_) | Event::Empty(_) => { + anyhow::bail!("Tally voucher row contained an unexpected field"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally voucher row contained unexpected text"); + } + Event::Eof => anyhow::bail!("Tally voucher response ended before VOUCHER closed"), + _ => {} + } + } + let reported = voucher + .ledger_entry_count + .ok_or_else(|| anyhow::anyhow!("Tally voucher omitted ledger entry count"))?; + if reported != voucher.ledger_entries.len() as u64 { + anyhow::bail!("Tally voucher ledger entry count did not match parsed rows"); + } + for (offset, entry) in voucher.ledger_entries.iter().enumerate() { + if entry.entry_index != (offset + 1) as u64 { + anyhow::bail!("Tally voucher ledger entry indexes were not contiguous"); + } + } + Ok(voucher) +} + +fn parse_ledger_entries( + reader: &mut Reader<&[u8]>, + xml: &str, + entries: &mut Vec, +) -> anyhow::Result<()> { + loop { + let element_start = reader.buffer_position() as usize; + match reader.read_event()? { + Event::Start(element) + if element.name().as_ref().eq_ignore_ascii_case(b"LEDGERENTRY") => + { + validate_only_attributes(&element, &[])?; + let mut entry = parse_ledger_entry(reader)?; + entry.raw_source_sha256 = + source_fragment_sha256(xml, element_start, reader.buffer_position() as usize)?; + entries.push(entry); + } + Event::End(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"LEDGERENTRIES") => + { + break; + } + Event::Start(_) | Event::Empty(_) => { + anyhow::bail!("Tally ledger-entry collection contained an unexpected field"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally ledger-entry collection contained unexpected text"); + } + Event::Eof => { + anyhow::bail!("Tally voucher ended before LEDGERENTRIES closed"); + } + _ => {} + } + } + Ok(()) +} + +fn parse_ledger_entry(reader: &mut Reader<&[u8]>) -> anyhow::Result { + let mut entry_index = None; + let mut ledger_name = None; + let mut amount = None; + let mut is_deemed_positive = None; + let mut seen = HashSet::new(); + loop { + match reader.read_event()? { + Event::Start(element) + if element.name().as_ref().eq_ignore_ascii_case(b"ENTRYINDEX") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "ENTRYINDEX", "ledger entry")?; + let value = read_optional_text(reader, element.name())? + .ok_or_else(|| anyhow::anyhow!("Tally ledger entry omitted its index"))?; + entry_index = Some( + value + .parse::() + .map_err(|_| anyhow::anyhow!("Tally ledger entry index was invalid"))?, + ); + } + Event::Start(element) + if element.name().as_ref().eq_ignore_ascii_case(b"LEDGERNAME") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "LEDGERNAME", "ledger entry")?; + ledger_name = read_optional_text(reader, element.name())?; + } + Event::Start(element) if element.name().as_ref().eq_ignore_ascii_case(b"AMOUNT") => { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "AMOUNT", "ledger entry")?; + amount = read_optional_text(reader, element.name())?; + } + Event::Start(element) + if element + .name() + .as_ref() + .eq_ignore_ascii_case(b"ISDEEMEDPOSITIVE") => + { + validate_only_attributes(&element, &[])?; + mark_unique_field(&mut seen, "ISDEEMEDPOSITIVE", "ledger entry")?; + is_deemed_positive = read_optional_text(reader, element.name())? + .map(|value| parse_tally_boolean(&value)) + .transpose()?; + } + Event::End(element) if element.name().as_ref().eq_ignore_ascii_case(b"LEDGERENTRY") => { + break; + } + Event::Start(_) | Event::Empty(_) => { + anyhow::bail!("Tally ledger entry contained an unexpected field"); + } + Event::Text(text) if !text.decode()?.trim().is_empty() => { + anyhow::bail!("Tally ledger entry contained unexpected text"); + } + Event::Eof => anyhow::bail!("Tally voucher ended before ledger entry closed"), + _ => {} + } + } + Ok(TallyLedgerEntry { + entry_index: entry_index + .filter(|index| *index > 0) + .ok_or_else(|| anyhow::anyhow!("Tally ledger entry index was invalid"))?, + ledger_name: ledger_name + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Tally ledger entry omitted ledger name"))?, + amount: amount + .filter(|value| !value.is_empty()) + .ok_or_else(|| anyhow::anyhow!("Tally ledger entry omitted amount"))?, + is_deemed_positive: is_deemed_positive + .ok_or_else(|| anyhow::anyhow!("Tally ledger entry omitted sign evidence"))?, + raw_source_sha256: String::new(), + }) +} + +fn mark_unique_field( + seen: &mut HashSet<&'static str>, + field: &'static str, + context: &str, +) -> anyhow::Result<()> { + if !seen.insert(field) { + anyhow::bail!("Tally {context} repeated {field}"); + } + Ok(()) +} + +fn parse_tally_boolean(value: &str) -> anyhow::Result { + if value.eq_ignore_ascii_case("yes") || value.eq_ignore_ascii_case("true") || value == "1" { + Ok(true) + } else if value.eq_ignore_ascii_case("no") + || value.eq_ignore_ascii_case("false") + || value == "0" + { + Ok(false) + } else { + anyhow::bail!("Tally voucher contained an invalid boolean value") + } +} + +fn attr_value( + reader: &Reader<&[u8]>, + element: &quick_xml::events::BytesStart<'_>, + key: &[u8], +) -> Option { + element + .attributes() + .flatten() + .find(|attr| attr.key.as_ref().eq_ignore_ascii_case(key)) + .and_then(|attr| { + attr.decoded_and_normalized_value(quick_xml::XmlVersion::Implicit1_0, reader.decoder()) + .ok() + }) + .map(|value| value.into_owned()) + .filter(|value| !value.trim().is_empty()) +} + +fn validate_unique_decodable_attributes( + reader: &Reader<&[u8]>, + element: &quick_xml::events::BytesStart<'_>, +) -> anyhow::Result<()> { + let mut seen = HashSet::new(); + for attribute in element.attributes().with_checks(true) { + let attribute = attribute + .map_err(|_| anyhow::anyhow!("Tally record contained malformed attributes"))?; + if !seen.insert(attribute.key.as_ref().to_ascii_lowercase()) { + anyhow::bail!("Tally record repeated an attribute"); + } + attribute + .decoded_and_normalized_value(quick_xml::XmlVersion::Implicit1_0, reader.decoder()) + .map_err(|_| anyhow::anyhow!("Tally record contained an invalid attribute value"))?; + } + Ok(()) +} + +fn validate_only_attributes( + element: &quick_xml::events::BytesStart<'_>, + allowed: &[&[u8]], +) -> anyhow::Result<()> { + let mut seen = HashSet::new(); + for attribute in element.attributes().with_checks(true) { + let attribute = attribute + .map_err(|_| anyhow::anyhow!("Tally period report attributes were malformed"))?; + if !allowed + .iter() + .any(|key| attribute.key.as_ref().eq_ignore_ascii_case(key)) + { + anyhow::bail!("Tally period report contained an unexpected attribute"); + } + if !seen.insert(attribute.key.as_ref().to_ascii_lowercase()) { + anyhow::bail!("Tally response repeated a case-insensitive attribute"); + } + } + Ok(()) +} + +fn read_optional_text( + reader: &mut Reader<&[u8]>, + name: QName<'_>, +) -> anyhow::Result> { + let value = reader.read_text(name)?; + let decoded = value.decode()?; + let unescaped = quick_xml::escape::unescape(&decoded)?; + let trimmed = unescaped.trim(); + Ok((!trimmed.is_empty()).then(|| trimmed.to_owned())) +} + +fn read_required_text(reader: &mut Reader<&[u8]>, name: QName<'_>) -> anyhow::Result { + read_optional_text(reader, name)? + .ok_or_else(|| anyhow::anyhow!("Tally response contained an empty required value")) +} + +fn read_counter(reader: &mut Reader<&[u8]>, name: QName<'_>, label: &str) -> anyhow::Result { + read_required_text(reader, name)? + .parse::() + .map_err(|_| anyhow::anyhow!("Tally import counter {label} was not a non-negative integer")) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut encoded = String::with_capacity(64); + for byte in Sha256::digest(bytes) { + write!(&mut encoded, "{byte:02x}").expect("writing to a String cannot fail"); + } + encoded +} diff --git a/src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs b/src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs new file mode 100644 index 0000000..7109c71 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs @@ -0,0 +1,692 @@ +//! Closed, read-only XML request profiles shared by the native app and portable tools. +//! +//! The public profile API accepts only validated company and date inputs. The +//! compatibility renderers are intentionally hidden from generated +//! documentation; they preserve the native app's existing string-based +//! function signatures while still exposing only fixed export profiles. + +use std::fmt; + +use sha2::{Digest, Sha256}; + +const TEMPLATE_COMPANY: &str = "BRIDGE TEMPLATE COMPANY"; +const TEMPLATE_FROM: &str = "20000101"; +const TEMPLATE_TO: &str = "20000102"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadProfileValidationError { + CompanyInvalid, + DateInvalid, + DateRangeInvalid, +} + +impl fmt::Display for ReadProfileValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(match self { + Self::CompanyInvalid => "read profile company input was invalid", + Self::DateInvalid => "read profile date input was invalid", + Self::DateRangeInvalid => "read profile date range was invalid", + }) + } +} + +impl std::error::Error for ReadProfileValidationError {} + +#[derive(Clone, PartialEq, Eq)] +pub struct ValidatedCompanyName(String); + +impl ValidatedCompanyName { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.trim().is_empty() || value.len() > 255 || value.chars().any(char::is_control) { + return Err(ReadProfileValidationError::CompanyInvalid); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for ValidatedCompanyName { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("ValidatedCompanyName([redacted])") + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ValidatedDateRange { + from_yyyymmdd: String, + to_yyyymmdd: String, +} + +impl ValidatedDateRange { + pub fn new( + from_yyyymmdd: impl Into, + to_yyyymmdd: impl Into, + ) -> Result { + let from_yyyymmdd = from_yyyymmdd.into(); + let to_yyyymmdd = to_yyyymmdd.into(); + if !valid_yyyymmdd(&from_yyyymmdd) || !valid_yyyymmdd(&to_yyyymmdd) { + return Err(ReadProfileValidationError::DateInvalid); + } + if from_yyyymmdd > to_yyyymmdd { + return Err(ReadProfileValidationError::DateRangeInvalid); + } + Ok(Self { + from_yyyymmdd, + to_yyyymmdd, + }) + } + + pub fn from_yyyymmdd(&self) -> &str { + &self.from_yyyymmdd + } + + pub fn to_yyyymmdd(&self) -> &str { + &self.to_yyyymmdd + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadOnlyProfileId { + CompanyListV1, + LedgersV1, + VouchersV2, + VouchersV3, +} + +impl ReadOnlyProfileId { + pub fn as_str(self) -> &'static str { + match self { + Self::CompanyListV1 => "company_list_v1", + Self::LedgersV1 => "ledgers_v1", + Self::VouchersV2 => "vouchers_v2", + Self::VouchersV3 => "vouchers_v3", + } + } + + /// SHA-256 of the exact request template rendered with fixed safe + /// sentinels in every dynamic slot. This changes if any emitted byte in the + /// fixed profile changes, but is independent of a live company or range. + pub fn template_sha256(self) -> String { + let template = match self { + Self::CompanyListV1 => render_company_list(), + Self::LedgersV1 => render_ledgers(TEMPLATE_COMPANY), + Self::VouchersV2 => render_vouchers(TEMPLATE_COMPANY, TEMPLATE_FROM, TEMPLATE_TO), + Self::VouchersV3 => { + render_selected_vouchers(TEMPLATE_COMPANY, TEMPLATE_FROM, TEMPLATE_TO) + } + }; + sha256_hex(template.as_bytes()) + } +} + +#[derive(Debug, Clone, Copy)] +pub enum ReadOnlyProfile<'a> { + CompanyListV1, + LedgersV1 { + company: &'a ValidatedCompanyName, + }, + VouchersV2 { + company: &'a ValidatedCompanyName, + range: &'a ValidatedDateRange, + }, + VouchersV3 { + company: &'a ValidatedCompanyName, + range: &'a ValidatedDateRange, + }, +} + +impl ReadOnlyProfile<'_> { + pub fn id(self) -> ReadOnlyProfileId { + match self { + Self::CompanyListV1 => ReadOnlyProfileId::CompanyListV1, + Self::LedgersV1 { .. } => ReadOnlyProfileId::LedgersV1, + Self::VouchersV2 { .. } => ReadOnlyProfileId::VouchersV2, + Self::VouchersV3 { .. } => ReadOnlyProfileId::VouchersV3, + } + } + + pub fn template_sha256(self) -> String { + self.id().template_sha256() + } + + pub fn render(self) -> String { + match self { + Self::CompanyListV1 => render_company_list(), + Self::LedgersV1 { company } => render_ledgers(company.as_str()), + Self::VouchersV2 { company, range } => { + render_vouchers(company.as_str(), range.from_yyyymmdd(), range.to_yyyymmdd()) + } + Self::VouchersV3 { company, range } => render_selected_vouchers( + company.as_str(), + range.from_yyyymmdd(), + range.to_yyyymmdd(), + ), + } + } +} + +/// Compatibility seam for the native app's existing function signatures. +/// These functions still expose only the three fixed read-only profiles and +/// XML-escape every dynamic value; no caller-provided XML can be dispatched. +#[doc(hidden)] +pub mod compatibility { + pub fn company_list_request() -> String { + super::render_company_list() + } + + pub fn ledgers_request(company: &str) -> String { + super::render_ledgers(company) + } + + pub fn vouchers_request(company: &str, from: &str, to: &str) -> String { + super::render_vouchers(company, from, to) + } + + pub fn selected_vouchers_request(company: &str, from: &str, to: &str) -> String { + super::render_selected_vouchers(company, from, to) + } +} + +fn render_company_list() -> String { + r#" + +
+ 1 + Export + Data + Company Report +
+ + + + $$SysName:XML + + + + + Company Form + "Company Details" + +
+ Company Part + 100% Page + 100% Page +
+ + Company Header, Company Details + Company Details : CompanyCollection + Vertical + Yes + + + + Company Name Header, Company GUID Header + + + "Company Name" + "Company GUID" + + + Company Name Field, Company GUID Field + + "CompanyInfo" + + $Name + $GUID + + Company + Name, GUID + +
+
+
+ +
+"# + .trim() + .to_string() +} + +fn render_ledgers(company: &str) -> String { + format!( + r#" + +
+ 1 + EXPORT + DATA + BRIDGE Ledger Export V1 +
+ + + + $$SysName:XML + {} + + + + + BRIDGE Ledger Export Form V1 + Yes + +
+ BRIDGE Ledger Context Part V1, BRIDGE Ledger Rows Part V1 +
+ + BRIDGE Ledger Context Line V1 + + + BRIDGE Ledger Row Line V1 + BRIDGE Ledger Row Line V1 : BRIDGE Ledger Collection V1 + + + BRIDGE Ledger Schema V1, BRIDGE Ledger Object Type V1, BRIDGE Ledger Company Name V1, BRIDGE Ledger Company GUID V1, BRIDGE Ledger Record Count V1 + "COMPANYCONTEXT" + + + "bridge.tally.ledgers/1" + "SCHEMA" + + + "LEDGER" + "OBJECTTYPE" + + + ##SVCurrentCompany + "NAME" + + + $GUID:Company:##SVCurrentCompany + "GUID" + + + $$NumItems:BRIDGE Ledger Collection V1 + "RECORDCOUNT" + + + BRIDGE Ledger Parent V1, BRIDGE Ledger GSTIN V1, BRIDGE Ledger Opening Balance V1 + "LEDGER" + "NAME" : $Name + "GUID" : $GUID + "REMOTEID" : $RemoteID + "MASTERID" : $MasterID + "ALTERID" : $AlterID + + + $Parent + "PARENT" + + + $PartyGSTIN + "PARTYGSTIN" + + + $OpeningBalance + "OPENINGBALANCE" + + + Ledger + Name, GUID, RemoteID, MasterID, AlterID, Parent, PartyGSTIN, OpeningBalance + +
+
+
+ +
+"#, + xml_escape(company) + ) + .trim() + .to_string() +} + +fn render_vouchers(company: &str, from: &str, to: &str) -> String { + format!( + r#" + +
+ 1 + EXPORT + DATA + BRIDGE Voucher Export V2 +
+ + + + {} + {} + {} + $$SysName:XML + + + + + BRIDGE Voucher Export Form V2 + Yes + +
+ BRIDGE Voucher Context Part V1, BRIDGE Voucher Rows Part V1 +
+ + BRIDGE Voucher Context Line V1 + + + BRIDGE Voucher Row Line V1 + BRIDGE Voucher Row Line V1 : BRIDGE Voucher Collection V1 + + + BRIDGE Voucher Schema V1, BRIDGE Voucher Object Type V1, BRIDGE Voucher Company Name V1, BRIDGE Voucher Company GUID V1, BRIDGE Voucher Record Count V1 + "COMPANYCONTEXT" + + + "bridge.tally.vouchers/2" + "SCHEMA" + + + "VOUCHER" + "OBJECTTYPE" + + + ##SVCurrentCompany + "NAME" + + + $GUID:Company:##SVCurrentCompany + "GUID" + + + $$NumItems:BRIDGE Voucher Collection V1 + "RECORDCOUNT" + + + BRIDGE Voucher Date V1, BRIDGE Voucher Type V1, BRIDGE Voucher Number V1, BRIDGE Voucher Cancelled V1, BRIDGE Voucher Optional V2, BRIDGE Voucher Ledger Entry Count V1 + "VOUCHER" + "REMOTEID" : $RemoteID + "GUID" : $GUID + "MASTERID" : $MasterID + "ALTERID" : $AlterID + BRIDGE Voucher Ledger Entries Part V1 : Yes + + + $Date + "DATE" + + + $VoucherTypeName + "VOUCHERTYPENAME" + + + $VoucherNumber + "VOUCHERNUMBER" + + + $IsCancelled + "ISCANCELLED" + + + $IsOptional + Logical + "ISOPTIONAL" + + + $$NumItems:AllLedgerEntries + "LEDGERENTRYCOUNT" + + + BRIDGE Voucher Ledger Entry Row V1 + BRIDGE Voucher Ledger Entry Row V1 : AllLedgerEntries + "LEDGERENTRIES" + + + BRIDGE Voucher Ledger Entry Index V1, BRIDGE Voucher Ledger Entry Name V1, BRIDGE Voucher Ledger Entry Amount V1, BRIDGE Voucher Ledger Entry Deemed Positive V1 + "LEDGERENTRY" + + + $$Line + "ENTRYINDEX" + + + $LedgerName + "LEDGERNAME" + + + $Amount + Amount + "No Symbol, No Comma" + "AMOUNT" + + + $IsDeemedPositive + Logical + "ISDEEMEDPOSITIVE" + + + Voucher + RemoteID, GUID, MasterID, AlterID, Date, VoucherTypeName, VoucherNumber, IsCancelled, AllLedgerEntries.* + +
+
+
+ +
+"#, + xml_escape(company), + xml_escape(from), + xml_escape(to) + ) + .trim() + .to_string() +} + +fn render_selected_vouchers(company: &str, from: &str, to: &str) -> String { + let request = render_vouchers(company, from, to) + .replace("BRIDGE Voucher Export V2", "BRIDGE Voucher Export V3") + .replace("bridge.tally.vouchers/2", "bridge.tally.vouchers/3") + .replace( + "BRIDGE Voucher Company GUID V1, BRIDGE Voucher Record Count V1", + "BRIDGE Voucher Company GUID V1, BRIDGE Voucher From Date V3, BRIDGE Voucher To Date V3, BRIDGE Voucher Record Count V1", + ); + let record_count_field = r#" "#; + let window_fields = r#" + $$String:##SVFromDate:"YYYYMMDD" + "FROMDATE" + + + $$String:##SVToDate:"YYYYMMDD" + "TODATE" + +"#; + request.replacen( + record_count_field, + &format!("{window_fields}{record_count_field}"), + 1, + ) +} + +fn xml_escape(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn valid_yyyymmdd(value: &str) -> bool { + if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return false; + } + let year = value[0..4].parse::().ok(); + let month = value[4..6].parse::().ok(); + let day = value[6..8].parse::().ok(); + let (Some(year), Some(month), Some(day)) = (year, month, day) else { + return false; + }; + if year == 0 || !(1..=12).contains(&month) { + return false; + } + let leap = year % 4 == 0 && (year % 100 != 0 || year % 400 == 0); + let maximum_day = match month { + 2 if leap => 29, + 2 => 28, + 4 | 6 | 9 | 11 => 30, + _ => 31, + }; + (1..=maximum_day).contains(&day) +} + +fn sha256_hex(bytes: &[u8]) -> String { + let mut output = String::with_capacity(64); + for byte in Sha256::digest(bytes) { + use fmt::Write as _; + write!(&mut output, "{byte:02x}").expect("writing to a String cannot fail"); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + fn profiles<'a>( + company: &'a ValidatedCompanyName, + range: &'a ValidatedDateRange, + ) -> [ReadOnlyProfile<'a>; 4] { + [ + ReadOnlyProfile::CompanyListV1, + ReadOnlyProfile::LedgersV1 { company }, + ReadOnlyProfile::VouchersV2 { company, range }, + ReadOnlyProfile::VouchersV3 { company, range }, + ] + } + + #[test] + fn validated_inputs_reject_arbitrary_or_invalid_values() { + for company in ["", " ", "line\nbreak", "\u{0}"] { + assert_eq!( + ValidatedCompanyName::new(company), + Err(ReadProfileValidationError::CompanyInvalid) + ); + } + assert_eq!( + ValidatedCompanyName::new("x".repeat(256)), + Err(ReadProfileValidationError::CompanyInvalid) + ); + for (from, to, expected) in [ + ( + "2026-01-01", + "20260102", + ReadProfileValidationError::DateInvalid, + ), + ( + "20260229", + "20260301", + ReadProfileValidationError::DateInvalid, + ), + ( + "20260402", + "20260401", + ReadProfileValidationError::DateRangeInvalid, + ), + ] { + assert_eq!(ValidatedDateRange::new(from, to), Err(expected)); + } + assert!(ValidatedDateRange::new("20240229", "20240229").is_ok()); + } + + #[test] + fn closed_profiles_emit_exports_only_and_escape_dynamic_values() { + let company = ValidatedCompanyName::new("BRIDGE & \"BOOK\"").unwrap(); + let range = ValidatedDateRange::new("20260401", "20260430").unwrap(); + for profile in profiles(&company, &range) { + let request = profile.render(); + let upper = request.to_ascii_uppercase(); + assert!(upper.contains("EXPORT")); + for forbidden in ["IMPORT", "CREATE", "ALTER", "DELETE"] { + assert!(!upper.contains(&format!("{forbidden}"))); + } + } + let ledger = ReadOnlyProfile::LedgersV1 { company: &company }.render(); + assert!(ledger.contains("BRIDGE & <SYNTHETIC> "BOOK"")); + assert!(!ledger.contains("BRIDGE & ")); + + let injection = + ValidatedCompanyName::new("X
IMPORT") + .unwrap(); + let escaped = ReadOnlyProfile::LedgersV1 { + company: &injection, + } + .render(); + assert_eq!(escaped.matches("").count(), 1); + assert!(!escaped.contains("IMPORT")); + assert!(escaped.contains("</SVCURRENTCOMPANY>")); + } + + #[test] + fn profile_ids_and_template_hashes_are_stable() { + let expected = [ + ( + ReadOnlyProfileId::CompanyListV1, + "d5c134051e1d298a278e27284fbb5ab1a9d00e0006a70f9777c4e38cebbb16de", + ), + ( + ReadOnlyProfileId::LedgersV1, + "aec4ffa397fde63e82ead885f70e1327d2b5f542d7ee167e291a2e86524c17b0", + ), + ( + ReadOnlyProfileId::VouchersV2, + "efd9b5f5148afff213090f57bd6bd5d3f58db6d1112ec27ef118dd29eac50385", + ), + ( + ReadOnlyProfileId::VouchersV3, + "2e68f0ab8e57ded8cc1948b6785598e2f1e0947fcc431975d15fd63131df478d", + ), + ]; + for (profile, digest) in expected { + assert_eq!(profile.template_sha256(), digest); + assert_eq!(profile.template_sha256().len(), 64); + } + } + + #[test] + fn compatibility_renderers_preserve_validated_profile_bytes() { + let company = ValidatedCompanyName::new("BRIDGE SYNTHETIC BOOK").unwrap(); + let range = ValidatedDateRange::new("20260401", "20260430").unwrap(); + assert_eq!( + compatibility::company_list_request(), + ReadOnlyProfile::CompanyListV1.render() + ); + assert_eq!( + compatibility::ledgers_request(company.as_str()), + ReadOnlyProfile::LedgersV1 { company: &company }.render() + ); + assert_eq!( + compatibility::vouchers_request( + company.as_str(), + range.from_yyyymmdd(), + range.to_yyyymmdd(), + ), + ReadOnlyProfile::VouchersV2 { + company: &company, + range: &range, + } + .render() + ); + assert_eq!( + compatibility::selected_vouchers_request( + company.as_str(), + range.from_yyyymmdd(), + range.to_yyyymmdd(), + ), + ReadOnlyProfile::VouchersV3 { + company: &company, + range: &range, + } + .render() + ); + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/jsonex/README.md b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/jsonex/README.md new file mode 100644 index 0000000..90dbf7a --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/jsonex/README.md @@ -0,0 +1,21 @@ +# Sanitized Tally JSONEX structure fixtures + +These fixtures are synthetic structural derivatives of Tally Solutions' +official TallyPrime 7.0+ JSON integration examples, reviewed on 2026-07-15: + +- https://help.tallysolutions.com/tally-prime-integration-using-json-1/ +- https://help.tallysolutions.com/wp-content/uploads/2025/11/Ledger-Collection-Response.docx +- https://help.tallysolutions.com/wp-content/uploads/2025/11/voucher-collection-response.docx + +The original downloadable examples are not committed. The checked-in files use +synthetic Bridge names, identifiers, and voucher numbers while retaining the +documented envelope, wrapper, omitted-versus-empty, multilingual, accounting- +value, and nested-array shapes needed for parser tests. They contain no live +Tally capture, customer data, phone/email/address, GST registration, bank +detail, local path, or developer identity. + +This corpus is structure evidence only. It does not prove Bridge's custom TDL +JSONEX profile, company identity binding, date-range filtering, completeness, +source atomicity, Education-mode availability, performance, or production +support. Redistribution of the official DOCX assets is not required; this +repository stores only independently authored synthetic test JSON. diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/jsonex/ledger_collection_sanitized.json b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/jsonex/ledger_collection_sanitized.json new file mode 100644 index 0000000..d18fe9e --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/jsonex/ledger_collection_sanitized.json @@ -0,0 +1,73 @@ +{ + "status": "1", + "data": { + "metadata": { + "is_mst_dep_type": true, + "mst_dep_type": "8" + }, + "collection": [ + { + "metadata": { + "type": "Ledger", + "name": "BRIDGE SYNTHETIC LEDGER A", + "reservedname": "" + }, + "parent": { + "type": "String", + "value": "BRIDGE SYNTHETIC GROUP" + }, + "closingbalance": { + "type": "Amount", + "value": "-243900.00" + }, + "onaccountvalue": { + "type": "Amount", + "value": "" + }, + "tbalopening": { + "type": "Amount", + "value": "0.00" + }, + "closingonacctvalue": { + "type": "Amount", + "value": "" + }, + "closingdronacctvalue": { + "type": "Logical", + "value": false + }, + "ledopeningbalance": { + "type": "Amount", + "value": "0.00" + }, + "languagename": [ + { + "name": [ + { + "metadata": true, + "type": "String" + }, + "BRIDGE SYNTHETIC LEDGER A", + "ब्रिज सिंथेटिक खाता" + ], + "languageid": { + "type": "Number", + "value": " 1033" + } + } + ] + }, + { + "metadata": { + "type": "Ledger", + "name": "BRIDGE SYNTHETIC LEDGER B", + "reservedname": "" + }, + "closingbalance": { + "type": "Amount", + "value": "" + } + } + ] + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/fixtures/jsonex/voucher_collection_nested_sanitized.json b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/jsonex/voucher_collection_nested_sanitized.json new file mode 100644 index 0000000..642d744 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/fixtures/jsonex/voucher_collection_nested_sanitized.json @@ -0,0 +1,235 @@ +{ + "status": "1", + "data": { + "metadata": { + "is_cmp_dep_type": true, + "cmp_locus": 4, + "cmp_dep_type": 64 + }, + "collection": [ + { + "metadata": { + "type": "Voucher", + "remoteid": "00000000-0000-4000-8000-000000000201", + "vchkey": "00000000-0000-4000-8000-000000000201:00000001", + "vchtype": "Sales", + "objview": "Invoice Voucher View" + }, + "date": { + "type": "Date", + "value": "20250401" + }, + "guid": "00000000-0000-4000-8000-000000000201", + "vouchertypename": "Sales", + "vouchernumber": "BRIDGE-SYNTHETIC-1", + "reference": { + "type": "String", + "value": "" + }, + "serialmaster": { + "type": "String", + "value": "" + }, + "areserialmaster": { + "type": "String", + "value": "" + }, + "numberingstyle": "Auto Retain", + "persistedview": "Invoice Voucher View", + "isdeleted": false, + "asoriginal": false, + "isdeemedpositive": { + "type": "Logical", + "value": true + }, + "isinvoice": true, + "aspayslip": false, + "isdeletedvchretained": false, + "isnegisposset": { + "type": "Logical", + "value": true + }, + "masterid": { + "type": "Number", + "value": " 50" + }, + "voucherkey": { + "type": "Number", + "value": "197134703919120" + }, + "voucherretainkey": { + "type": "Number", + "value": "41" + }, + "reuseholeid": { + "type": "Number", + "value": "0" + }, + "amount": { + "type": "Amount", + "value": "-180000.00" + }, + "vouchernumberseries": { + "type": "String", + "value": "Default" + }, + "allledgerentries": [ + { + "ledgername": { + "type": "String", + "value": "BRIDGE SYNTHETIC CASH" + }, + "isdeemedpositive": { + "type": "Logical", + "value": false + }, + "islastdeemedpositive": { + "type": "Logical", + "value": false + }, + "amount": { + "type": "Amount", + "value": "180000.00" + }, + "vatassessablevalue": { + "type": "Amount", + "value": "" + } + } + ], + "allinventoryentries": [ + { + "stockitemname": { + "type": "String", + "value": "BRIDGE SYNTHETIC ITEM" + }, + "addlamount": { + "type": "Amount", + "value": "" + }, + "isdeemedpositive": { + "type": "Logical", + "value": false + }, + "islastdeemedpositive": { + "type": "Logical", + "value": false + }, + "rate": { + "type": "Rate", + "value": "180000.00/Nos" + }, + "discount": { + "type": "Number", + "value": "0" + }, + "amount": { + "type": "Amount", + "value": "180000.00" + }, + "actualqty": { + "type": "Quantity", + "value": " 1 Nos" + }, + "billedqty": { + "type": "Quantity", + "value": " 1 Nos" + }, + "batchallocations": [ + { + "batchname": { + "type": "String", + "value": "BRIDGE SYNTHETIC BATCH" + }, + "indentno": { + "type": "String", + "value": "Not Applicable" + }, + "orderno": { + "type": "String", + "value": "Not Applicable" + }, + "trackingnumber": { + "type": "String", + "value": "Not Applicable" + }, + "addlamount": { + "type": "Amount", + "value": "" + }, + "batchdiscount": { + "type": "Number", + "value": "0" + }, + "amount": { + "type": "Amount", + "value": "180000.00" + }, + "actualqty": { + "type": "Quantity", + "value": " 1 Nos" + }, + "billedqty": { + "type": "Quantity", + "value": " 1 Nos" + }, + "batchrate": { + "type": "Rate", + "value": "180000.00/Nos" + } + } + ], + "accountingallocations": [ + { + "ledgername": { + "type": "String", + "value": "BRIDGE SYNTHETIC SALES" + }, + "isdeemedpositive": { + "type": "Logical", + "value": false + }, + "islastdeemedpositive": { + "type": "Logical", + "value": false + }, + "amount": { + "type": "Amount", + "value": "180000.00" + } + } + ] + } + ], + "ledgerentries": [ + { + "ledgername": { + "type": "String", + "value": "BRIDGE SYNTHETIC PARTY" + }, + "isdeemedpositive": { + "type": "Logical", + "value": true + }, + "islastdeemedpositive": { + "type": "Logical", + "value": true + }, + "amount": { + "type": "Amount", + "value": "-180000.00" + }, + "billallocations": [ + { + "amount": { + "type": "Amount", + "value": "-180000.00" + } + } + ] + } + ] + } + ] + } +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/import_evidence.rs b/src-tauri/crates/bridge-tally-protocol/tests/import_evidence.rs new file mode 100644 index 0000000..79a9f1c --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/import_evidence.rs @@ -0,0 +1,193 @@ +use bridge_tally_protocol::{ + parse_import_evidence, parse_import_outcome, parse_import_result, + parse_ledger_write_readback_with_evidence, TallyImportApplicationStatus, +}; + +#[test] +fn evidence_binds_counters_response_and_redacted_line_errors() { + let xml = "00110PRIVATE-SYNTHETIC-SENTINEL"; + let evidence = parse_import_evidence(xml).unwrap(); + + assert_eq!(evidence.counters().line_error_count, 1); + assert_eq!(evidence.line_error_sha256().len(), 1); + assert_eq!(evidence.response_sha256().len(), 64); + let debug = format!("{evidence:?}"); + assert!(!debug.contains("PRIVATE-SYNTHETIC-SENTINEL")); +} + +#[test] +fn response_and_line_error_commitments_change_with_exact_input() { + let make = |message: &str| { + parse_import_evidence(&format!( + "00110{message}" + )) + .unwrap() + }; + let first = make("synthetic-a"); + let second = make("synthetic-b"); + assert_ne!(first.response_sha256(), second.response_sha256()); + assert_ne!(first.line_error_sha256(), second.line_error_sha256()); +} + +#[test] +fn duplicate_or_wrongly_nested_status_and_counters_are_rejected() { + let duplicate_counter = "010000"; + assert!(parse_import_evidence(duplicate_counter).is_err()); + + let nested_counter = "10000"; + assert!(parse_import_evidence(nested_counter).is_err()); + + let duplicate_status = "
01
10000
"; + assert!(parse_import_evidence(duplicate_status).is_err()); + + let duplicate_container = "
1
10000
"; + assert!(parse_import_evidence(duplicate_container).is_err()); + + let duplicate_header = "
1
10000"; + assert!(parse_import_evidence(duplicate_header).is_err()); + + let doctype = "
1
10000
"; + assert!(parse_import_evidence(doctype).is_err()); + + let duplicate_body = "
1
1000
"; + assert!(parse_import_evidence(duplicate_body).is_err()); + + let body_before_header = "
1
"; + assert!(parse_import_evidence(body_before_header).is_err()); + + let attributed_status = "
1
1000
"; + assert!(parse_import_evidence(attributed_status).is_err()); + + let mixed_text = "
1
mixed1000
"; + assert!(parse_import_evidence(mixed_text).is_err()); + + let unknown_wrapper = "
1
1000
"; + assert!(parse_import_evidence(unknown_wrapper).is_err()); +} + +#[test] +fn exact_envelope_import_result_path_is_accepted() { + let xml = "
1
1000000
"; + let evidence = parse_import_evidence(xml).unwrap(); + assert_eq!(evidence.counters().created, 1); +} + +#[test] +fn documented_direct_data_import_result_path_is_accepted_without_profile_mixing() { + let xml = "
1
2100101BRIDGE SYNTHETIC ERROR
"; + let evidence = parse_import_evidence(xml).expect("documented direct DATA profile"); + assert_eq!(evidence.counters().created, 2); + assert_eq!(evidence.counters().altered, 1); + assert_eq!(evidence.counters().errors, 1); + assert_eq!(evidence.counters().exceptions, 1); + assert_eq!(evidence.counters().line_error_count, 1); + + let mixed = "
1
10000
"; + assert!(parse_import_evidence(mixed).is_err()); + + let direct_extra_then_wrapped = "
1
11000
"; + assert!(parse_import_evidence(direct_extra_then_wrapped).is_err()); + + let wrapped_then_direct_extra = "
1
10001
"; + assert!(parse_import_evidence(wrapped_then_direct_extra).is_err()); +} + +#[test] +fn official_legacy_and_wrapped_success_profiles_accept_auxiliary_fields() { + let legacy = "2000000"; + let legacy_evidence = parse_import_evidence(legacy).expect("official legacy RESPONSE profile"); + assert_eq!( + legacy_evidence.application_status(), + TallyImportApplicationStatus::NotReported + ); + assert_eq!(legacy_evidence.counters().created, 2); + assert!(!legacy_evidence.exceptions_were_reported()); + + let wrapped = "
1
201190000
"; + let wrapped_evidence = + parse_import_evidence(wrapped).expect("official wrapped IMPORTRESULT profile"); + assert_eq!( + wrapped_evidence.application_status(), + TallyImportApplicationStatus::Success + ); + assert_eq!(wrapped_evidence.counters().created, 2); + assert!(!wrapped_evidence.exceptions_were_reported()); +} + +#[test] +fn documented_direct_failure_shape_retains_counters_without_becoming_success() { + let xml = "
0
000000010BRIDGE SYNTHETIC FAILUREBRIDGE-SYNTHETIC-1BRIDGE SYNTHETIC FAILURE
"; + let outcome = parse_import_outcome(xml).expect("parse documented failure counters"); + assert_eq!( + outcome.application_status(), + TallyImportApplicationStatus::Failure + ); + assert_eq!(outcome.counters().created, 0); + assert_eq!(outcome.counters().errors, 1); + assert_eq!(outcome.counters().exceptions, 0); + assert!(!outcome.exceptions_were_reported()); + let evidence = parse_import_evidence(xml).expect("retain documented failure evidence"); + assert_eq!( + evidence.application_status(), + TallyImportApplicationStatus::Failure + ); + assert_eq!(evidence.counters().errors, 1); + assert_eq!(evidence.line_error_sha256().len(), 1); + assert!(!evidence.exceptions_were_reported()); + assert!( + parse_import_result(xml).is_err(), + "legacy success-oriented API must remain fail-closed" + ); +} + +#[test] +fn malformed_line_error_entities_return_only_a_safe_error() { + let xml = "00110&PRIVATE_SYNTHETIC_SENTINEL;"; + let error = parse_import_evidence(xml).unwrap_err().to_string(); + assert_eq!(error, "Tally import response evidence was invalid"); + assert!(!error.contains("PRIVATE")); + assert!(!error.contains("SENTINEL")); +} + +#[test] +fn write_readback_rejects_wrong_nesting_duplicate_fields_and_attributes() { + const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let wrap = |ledger: &str, count: usize| { + format!( + r#"
1
{ledger}
"# + ) + }; + let valid = r#"BRIDGE GROUP0"#; + let valid_result = parse_ledger_write_readback_with_evidence(&wrap(valid, 1)); + assert!(valid_result.is_ok(), "{valid_result:?}"); + + let nested = format!("{valid}"); + assert!(parse_ledger_write_readback_with_evidence(&wrap(&nested, 1)).is_err()); + let duplicate_field = valid.replace("", "0"); + assert!(parse_ledger_write_readback_with_evidence(&wrap(&duplicate_field, 1)).is_err()); + let duplicate_attribute = valid.replace( + "REMOTEID=\"remote-1\"", + "REMOTEID=\"remote-1\" REMOTEID=\"remote-1\"", + ); + assert!(parse_ledger_write_readback_with_evidence(&wrap(&duplicate_attribute, 1)).is_err()); + let unexpected_attribute = valid.replace( + "REMOTEID=\"remote-1\"", + "REMOTEID=\"remote-1\" UNSAFE=\"1\"", + ); + assert!(parse_ledger_write_readback_with_evidence(&wrap(&unexpected_attribute, 1)).is_err()); + + let case_variant_duplicate = valid.replace( + "REMOTEID=\"remote-1\"", + "REMOTEID=\"remote-1\" remoteid=\"other\"", + ); + assert!(parse_ledger_write_readback_with_evidence(&wrap(&case_variant_duplicate, 1)).is_err()); + + let nested_context = wrap("", 0) + .replace("", "/>"); + assert!(parse_ledger_write_readback_with_evidence(&nested_context).is_err()); + + let duplicate_status = + wrap("", 0).replace("1", "01"); + assert!(parse_ledger_write_readback_with_evidence(&duplicate_status).is_err()); +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/india_tax_observation.rs b/src-tauri/crates/bridge-tally-protocol/tests/india_tax_observation.rs new file mode 100644 index 0000000..ae68dc4 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/india_tax_observation.rs @@ -0,0 +1,262 @@ +#![cfg(feature = "india-tax-observation-parser")] + +use bridge_tally_protocol::india_tax_observation::{ + parse_unbound_india_tax_observation, IndiaTaxCountAuthority, IndiaTaxObservationBinding, + IndiaTaxObservationError, IndiaTaxObservationLimits, ObservedTaxOwnerKind, +}; + +const COMPANY: &str = "bridge-synthetic-company-guid"; +const GSTIN: &str = "27ABCDE1234F1Z5"; + +fn envelope( + registrations: &str, + voucher_taxes: &str, + registration_count: u64, + tax_count: u64, +) -> String { + format!( + r#"
1
{registrations}{voucher_taxes}
"#, + ) +} + +fn company_registration() -> String { + format!( + r#"Regular{GSTIN}"#, + ) +} + +fn ledger_registration() -> String { + r#"Composition29ABCDE1234F1Z5"#.to_owned() +} + +fn voucher_tax(ordinal: u64, component: &str, amount: &str) -> String { + format!( + r#"27-Maharashtra1000.00{component}9.00{amount}"#, + ) +} + +#[test] +fn parses_exact_unbound_observations_without_promoting_authority() { + let xml = envelope( + &(company_registration() + &ledger_registration()), + &(voucher_tax(1, "CGST", "90.00") + &voucher_tax(2, "SGST", "90.00")), + 2, + 2, + ); + let parsed = parse_unbound_india_tax_observation(xml.as_bytes(), Default::default()) + .expect("parse exact synthetic observation"); + + assert_eq!(parsed.tax_registrations().len(), 2); + assert_eq!(parsed.voucher_taxes().len(), 2); + assert_eq!( + parsed.tax_registrations()[0].owner_kind(), + ObservedTaxOwnerKind::Company + ); + assert_eq!(parsed.tax_registrations()[0].gstin(), GSTIN); + assert_eq!(parsed.voucher_taxes()[1].tax_component(), "SGST"); + assert_eq!(parsed.voucher_taxes()[1].tax_amount(), "90.00"); + assert_ne!( + parsed.voucher_taxes()[0].raw_fragment_sha256(), + parsed.voucher_taxes()[1].raw_fragment_sha256() + ); + assert_eq!( + parsed.evidence().binding(), + IndiaTaxObservationBinding::UnboundNoRequestArtifact + ); + assert_eq!( + parsed.evidence().count_authority(), + IndiaTaxCountAuthority::ResponseInternalOnly + ); + assert!(!parsed.canonicalization_eligible()); +} + +#[test] +fn zero_rows_is_only_an_internally_consistent_unbound_response() { + let parsed = + parse_unbound_india_tax_observation(envelope("", "", 0, 0).as_bytes(), Default::default()) + .expect("parse zero-row response"); + assert!(parsed.tax_registrations().is_empty()); + assert!(parsed.voucher_taxes().is_empty()); + assert_eq!(parsed.evidence().claimed_registration_count(), 0); + assert!(!parsed.canonicalization_eligible()); +} + +#[test] +fn rejects_count_mismatch_and_rows_out_of_order() { + let mismatch = envelope(&company_registration(), "", 0, 0); + assert_eq!( + parse_unbound_india_tax_observation(mismatch, Default::default()).unwrap_err(), + IndiaTaxObservationError::CountMismatch + ); + + let wrong_order = envelope( + &company_registration(), + &voucher_tax(1, "IGST", "180.00"), + 1, + 1, + ) + .replace( + &(company_registration() + &voucher_tax(1, "IGST", "180.00")), + &(voucher_tax(1, "IGST", "180.00") + &company_registration()), + ); + assert_eq!( + parse_unbound_india_tax_observation(wrong_order, Default::default()).unwrap_err(), + IndiaTaxObservationError::WrongGrammar + ); +} + +#[test] +fn rejects_nested_unknown_and_duplicate_case_variant_attributes() { + let nested = envelope( + &format!("{}", company_registration()), + "", + 1, + 0, + ); + assert_eq!( + parse_unbound_india_tax_observation(nested, Default::default()).unwrap_err(), + IndiaTaxObservationError::WrongGrammar + ); + + let duplicate = envelope(&company_registration(), "", 1, 0).replace( + "OWNERKIND=\"COMPANY\"", + "OWNERKIND=\"COMPANY\" ownerkind=\"COMPANY\"", + ); + assert_eq!( + parse_unbound_india_tax_observation(duplicate, Default::default()).unwrap_err(), + IndiaTaxObservationError::DuplicateField + ); + + let unknown = envelope(&company_registration(), "", 1, 0).replace( + " OWNERALTERID=\"7\"", + " OWNERALTERID=\"7\" SECRET=\"sentinel\"", + ); + assert_eq!( + parse_unbound_india_tax_observation(unknown, Default::default()).unwrap_err(), + IndiaTaxObservationError::WrongGrammar + ); +} + +#[test] +fn rejects_ambiguous_company_owner_and_missing_voucher_identity() { + let ambiguous = envelope( + &company_registration().replace( + " OWNERALTERID=\"7\"", + " OWNERREMOTEID=\"not-company-authority\" OWNERALTERID=\"7\"", + ), + "", + 1, + 0, + ); + assert_eq!( + parse_unbound_india_tax_observation(ambiguous, Default::default()).unwrap_err(), + IndiaTaxObservationError::MissingIdentity + ); + + let missing = envelope( + "", + &voucher_tax(1, "IGST", "180.00") + .replace(" VOUCHERGUID=\"bridge-synthetic-voucher-1\"", ""), + 0, + 1, + ); + assert_eq!( + parse_unbound_india_tax_observation(missing, Default::default()).unwrap_err(), + IndiaTaxObservationError::MissingIdentity + ); +} + +#[test] +fn rejects_duplicate_observation_keys_and_invalid_numeric_lexemes() { + let duplicated = company_registration() + &company_registration(); + assert_eq!( + parse_unbound_india_tax_observation(envelope(&duplicated, "", 2, 0), Default::default()) + .unwrap_err(), + IndiaTaxObservationError::DuplicateObservation + ); + + for invalid in ["1e2", "1,000.00", "NaN", "+10.00"] { + let xml = envelope("", &voucher_tax(1, "IGST", invalid), 0, 1); + assert_eq!( + parse_unbound_india_tax_observation(xml, Default::default()).unwrap_err(), + IndiaTaxObservationError::InvalidValue + ); + } + let negative_rate = envelope( + "", + &voucher_tax(1, "IGST", "180.00").replace("9.00", "-9.00"), + 0, + 1, + ); + assert_eq!( + parse_unbound_india_tax_observation(negative_rate, Default::default()).unwrap_err(), + IndiaTaxObservationError::InvalidValue + ); +} + +#[test] +fn enforces_response_field_and_record_limits_without_partial_results() { + let xml = envelope(&company_registration(), "", 1, 0); + let mut limits = IndiaTaxObservationLimits { + max_encoded_bytes: xml.len() - 1, + ..Default::default() + }; + assert_eq!( + parse_unbound_india_tax_observation(&xml, limits).unwrap_err(), + IndiaTaxObservationError::ResponseTooLarge + ); + + limits = IndiaTaxObservationLimits { + max_field_bytes: 8, + ..Default::default() + }; + assert_eq!( + parse_unbound_india_tax_observation(&xml, limits).unwrap_err(), + IndiaTaxObservationError::ResourceLimitExceeded + ); + + limits = IndiaTaxObservationLimits { + max_records: 0, + ..Default::default() + }; + assert_eq!( + parse_unbound_india_tax_observation(&xml, limits).unwrap_err(), + IndiaTaxObservationError::ResourceLimitExceeded + ); + + assert!( + parse_unbound_india_tax_observation(&xml[..xml.len() - 12], Default::default()).is_err() + ); +} + +#[test] +fn debug_display_and_errors_do_not_echo_sensitive_sentinels() { + let company_sentinel = "company-secret-guid"; + let identity_sentinel = "voucher-secret-identity"; + let gstin_sentinel = "27ABCDE1234F1Z5"; + let amount_sentinel = "987654321.99"; + let xml = envelope( + &company_registration(), + &voucher_tax(1, "IGST", amount_sentinel), + 1, + 1, + ) + .replace(COMPANY, company_sentinel) + .replace("bridge-synthetic-voucher-1", identity_sentinel); + let parsed = + parse_unbound_india_tax_observation(xml, Default::default()).expect("parse sentinels"); + let debug = format!("{parsed:?}"); + for sentinel in [ + company_sentinel, + identity_sentinel, + gstin_sentinel, + amount_sentinel, + ] { + assert!(!debug.contains(sentinel)); + } + + let malformed = format!("{identity_sentinel}"); + let error = parse_unbound_india_tax_observation(malformed, Default::default()).unwrap_err(); + let rendered = format!("{error} {error:?}"); + assert!(!rendered.contains(identity_sentinel)); +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/jsonex_documented_request.rs b/src-tauri/crates/bridge-tally-protocol/tests/jsonex_documented_request.rs new file mode 100644 index 0000000..69703ac --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/jsonex_documented_request.rs @@ -0,0 +1,178 @@ +#![cfg(feature = "jsonex-request-builder")] + +use bridge_tally_protocol::jsonex_request::{ + build_documented_ledger_request_v1, build_documented_voucher_request_v1, + JsonExRequestBuildError, JsonExRequestWireEncoding, JsonExResponseEncodingExpectation, + ValidatedJsonExCompanyName, DOCUMENTED_LEDGER_REQUEST_PROFILE_V1, + DOCUMENTED_VOUCHER_REQUEST_PROFILE_V1, +}; + +const LIMIT: usize = 128 * 1024; + +fn synthetic_company() -> ValidatedJsonExCompanyName { + ValidatedJsonExCompanyName::new("BRIDGE SYNTHETIC COMPANY") + .expect("synthetic company must be valid") +} + +#[test] +fn ledger_profile_preserves_exact_docx_keys_values_and_headers() { + let request = build_documented_ledger_request_v1( + &synthetic_company(), + JsonExRequestWireEncoding::PlainAsciiUtf8, + LIMIT, + ) + .expect("documented request should build"); + let body: serde_json::Value = serde_json::from_slice(&request.body).expect("valid JSON"); + + assert_eq!(request.profile_id, DOCUMENTED_LEDGER_REQUEST_PROFILE_V1); + assert_eq!(request.headers.content_type, "application/json"); + assert_eq!(request.headers.version, "1"); + assert_eq!(request.headers.tally_request, "Export"); + assert_eq!(request.headers.request_type, "Collection"); + assert_eq!(request.headers.id, "Ledger"); + assert_eq!(body["static_variables"][0]["name"], "svExportFormat"); + assert_eq!(body["static_variables"][0]["value"], "jsonex"); + assert_eq!(body["static_variables"][1]["name"], "svCurrentCompany"); + assert_eq!( + body["static_variables"][1]["value"], + "BRIDGE SYNTHETIC COMPANY" + ); + assert_eq!( + body["fetch_List"], + serde_json::json!(["Name", "Parent", "Closing Balance"]) + ); + assert!(body.get("fetchlist").is_none()); + assert!(body.get("fetch_list").is_none()); + assert!(!request.dispatch_eligible()); + assert!(!request.company_identity_verifiable_from_documented_response()); + assert!(!request.date_range_bound()); +} + +#[test] +fn voucher_profile_preserves_exact_fixed_tdl_definition() { + let request = build_documented_voucher_request_v1( + &synthetic_company(), + JsonExRequestWireEncoding::PlainAsciiUtf8, + LIMIT, + ) + .expect("documented request should build"); + let body: serde_json::Value = serde_json::from_slice(&request.body).expect("valid JSON"); + + assert_eq!(request.profile_id, DOCUMENTED_VOUCHER_REQUEST_PROFILE_V1); + assert_eq!(request.headers.id, "TSPLVoucherColl"); + let definition = &body["tdlmessage"][0]["definitions"][0]; + assert_eq!(definition["metadata"]["name"], "TSPLVoucherColl"); + assert_eq!(definition["metadata"]["type"], "Collection"); + assert_eq!( + definition["attributes"][0], + serde_json::json!({"Type": "Voucher"}) + ); + assert_eq!( + definition["attributes"][1], + serde_json::json!({"Native Method": "VoucherNumber, VoucherTypeName, Date, Amount"}) + ); + assert!(body.get("repeat_variables").is_none()); + assert!(!request.dispatch_eligible()); + assert!(!request.date_range_bound()); +} + +#[test] +fn bom_profiles_bind_wire_bytes_headers_and_documented_response_expectation() { + let company = ValidatedJsonExCompanyName::new("ब्रिज सिंथेटिक कंपनी") + .expect("Unicode company must be preserved"); + let utf8 = + build_documented_ledger_request_v1(&company, JsonExRequestWireEncoding::Utf8Bom, LIMIT) + .expect("UTF-8 BOM profile should build"); + assert!(utf8.body.starts_with(&[0xef, 0xbb, 0xbf])); + assert_eq!(utf8.headers.content_type, "application/json;charset=utf-8"); + assert_eq!( + utf8.response_encoding_expectation, + JsonExResponseEncodingExpectation::Utf16Le + ); + let utf8_body: serde_json::Value = + serde_json::from_slice(&utf8.body[3..]).expect("BOM body must be UTF-8 JSON"); + assert_eq!(utf8_body["static_variables"][1]["value"], company.as_str()); + + let utf16 = + build_documented_ledger_request_v1(&company, JsonExRequestWireEncoding::Utf16LeBom, LIMIT) + .expect("UTF-16LE BOM profile should build"); + assert!(utf16.body.starts_with(&[0xff, 0xfe])); + assert_eq!( + utf16.headers.content_type, + "application/json;charset=utf-16" + ); + assert_eq!( + utf16.response_encoding_expectation, + JsonExResponseEncodingExpectation::Utf16Le + ); + let units = utf16.body[2..] + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + let decoded = String::from_utf16(&units).expect("body must be UTF-16LE"); + let utf16_body: serde_json::Value = serde_json::from_str(&decoded).expect("valid JSON"); + assert_eq!(utf16_body, utf8_body); +} + +#[test] +fn plain_profile_rejects_multilingual_company_without_transforming_it() { + let company = ValidatedJsonExCompanyName::new("BRIDGE कंपनी").expect("valid Unicode name"); + let error = build_documented_ledger_request_v1( + &company, + JsonExRequestWireEncoding::PlainAsciiUtf8, + LIMIT, + ) + .err(); + assert_eq!( + error, + Some(JsonExRequestBuildError::MultilingualCompanyRequiresBomProfile) + ); +} + +#[test] +fn company_validation_and_json_serialization_are_injection_safe() { + for invalid in [ + "", + " ", + "BRIDGE\0COMPANY", + "BRIDGE\rCOMPANY", + "BRIDGE\nCOMPANY", + ] { + assert!(matches!( + ValidatedJsonExCompanyName::new(invalid), + Err(JsonExRequestBuildError::InvalidCompanyName) + )); + } + assert!(matches!( + ValidatedJsonExCompanyName::new("A".repeat(256)), + Err(JsonExRequestBuildError::InvalidCompanyName) + )); + + let exact = "BRIDGE \"SYNTHETIC\" \\ COMPANY"; + let company = ValidatedJsonExCompanyName::new(exact).expect("quotes are JSON-escaped"); + let request = build_documented_ledger_request_v1( + &company, + JsonExRequestWireEncoding::PlainAsciiUtf8, + LIMIT, + ) + .expect("safe exact name should build"); + let body: serde_json::Value = serde_json::from_slice(&request.body).expect("valid JSON"); + assert_eq!(body["static_variables"][1]["value"], exact); +} + +#[test] +fn byte_limits_and_errors_are_fail_closed_and_redacted() { + let company = ValidatedJsonExCompanyName::new("BRIDGE PRIVATE SENTINEL") + .expect("sentinel company must be valid"); + assert_eq!( + build_documented_ledger_request_v1(&company, JsonExRequestWireEncoding::PlainAsciiUtf8, 0,) + .err(), + Some(JsonExRequestBuildError::InvalidByteLimit) + ); + let error = + build_documented_ledger_request_v1(&company, JsonExRequestWireEncoding::Utf16LeBom, 8) + .err() + .expect("small byte limit must fail"); + assert_eq!(error.safe_code(), "jsonex_request_too_large"); + assert!(!error.to_string().contains(company.as_str())); +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/jsonex_official_envelope.rs b/src-tauri/crates/bridge-tally-protocol/tests/jsonex_official_envelope.rs new file mode 100644 index 0000000..19385a3 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/jsonex_official_envelope.rs @@ -0,0 +1,560 @@ +#![cfg(feature = "jsonex-parser")] + +use bridge_tally_protocol::{ + jsonex::{ + parse_documented_ledger_collection_v1, parse_documented_voucher_collection_v1, + JsonExExpectedEncoding, JsonExLimits, JsonExProtocolError, JsonExTextPresence, + DOCUMENTED_LEDGER_COLLECTION_PROFILE_V1, DOCUMENTED_VOUCHER_COLLECTION_PROFILE_V1, + }, + TallyTextEncoding, +}; + +const LEDGER: &str = include_str!("fixtures/jsonex/ledger_collection_sanitized.json"); +const VOUCHER: &str = include_str!("fixtures/jsonex/voucher_collection_nested_sanitized.json"); + +fn parse_ledger( + bytes: &[u8], +) -> Result { + parse_documented_ledger_collection_v1( + bytes, + "application/json; charset=utf-8", + JsonExExpectedEncoding::Utf8, + JsonExLimits::default(), + ) +} + +fn parse_ledger_text( + text: &str, +) -> Result { + parse_ledger(text.as_bytes()) +} + +fn utf16_le_bom(text: &str) -> Vec { + let mut bytes = vec![0xff, 0xfe]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + bytes +} + +fn utf16_be_bom(text: &str) -> Vec { + let mut bytes = vec![0xfe, 0xff]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + bytes +} + +#[test] +fn ledger_fixture_preserves_absent_empty_zero_and_multilingual_values() { + let parsed = parse_ledger(LEDGER.as_bytes()).expect("documented ledger shape should parse"); + + assert_eq!( + parsed.evidence.profile_id, + DOCUMENTED_LEDGER_COLLECTION_PROFILE_V1 + ); + assert_eq!(parsed.evidence.encoding, TallyTextEncoding::Utf8); + assert_eq!(parsed.evidence.record_count, 2); + assert_eq!(parsed.records.len(), 2); + assert_eq!(parsed.records[0].name, "BRIDGE SYNTHETIC LEDGER A"); + assert!(matches!( + &parsed.records[0].parent, + JsonExTextPresence::Value(value) if value == "BRIDGE SYNTHETIC GROUP" + )); + assert!(matches!( + &parsed.records[0].closing_balance, + JsonExTextPresence::Value(value) if value == "-243900.00" + )); + assert!(matches!( + &parsed.records[0].opening_balance, + JsonExTextPresence::Value(value) if value == "0.00" + )); + assert_eq!( + parsed.records[0].language_names[0][1], + "\u{092c}\u{094d}\u{0930}\u{093f}\u{091c} \u{0938}\u{093f}\u{0902}\u{0925}\u{0947}\u{091f}\u{093f}\u{0915} \u{0916}\u{093e}\u{0924}\u{093e}" + ); + assert!(matches!( + parsed.records[1].parent, + JsonExTextPresence::Absent + )); + assert!(matches!( + parsed.records[1].closing_balance, + JsonExTextPresence::Empty + )); + assert!(matches!( + parsed.records[1].opening_balance, + JsonExTextPresence::Absent + )); +} + +#[test] +fn voucher_fixture_validates_documented_nested_allocation_shapes() { + let parsed = parse_documented_voucher_collection_v1( + VOUCHER.as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf8, + JsonExLimits::default(), + ) + .expect("documented voucher shape should parse"); + + assert_eq!( + parsed.evidence.profile_id, + DOCUMENTED_VOUCHER_COLLECTION_PROFILE_V1 + ); + assert_eq!(parsed.evidence.record_count, 1); + let voucher = &parsed.records[0]; + assert_eq!(voucher.date_yyyymmdd, "20250401"); + assert_eq!(voucher.voucher_type, "Sales"); + assert_eq!( + voucher.voucher_number.as_deref(), + Some("BRIDGE-SYNTHETIC-1") + ); + assert!(matches!( + &voucher.amount, + JsonExTextPresence::Value(value) if value == "-180000.00" + )); + assert_eq!(voucher.all_ledger_entry_count, 1); + assert_eq!(voucher.inventory_entry_count, 1); + assert_eq!(voucher.invoice_ledger_entry_count, 1); + assert_eq!(voucher.batch_allocation_count, 1); + assert_eq!(voucher.accounting_allocation_count, 1); + assert_eq!(voucher.bill_allocation_count, 1); +} + +#[test] +fn documented_collection_metadata_values_are_profile_exact() { + for changed in [ + LEDGER.replacen( + r#""is_mst_dep_type": true"#, + r#""is_mst_dep_type": false"#, + 1, + ), + LEDGER.replacen(r#""mst_dep_type": "8""#, r#""mst_dep_type": "9""#, 1), + ] { + assert_eq!( + parse_ledger_text(&changed).err(), + Some(JsonExProtocolError::ProfileMismatch) + ); + } + + for changed in [ + VOUCHER.replacen( + r#""is_cmp_dep_type": true"#, + r#""is_cmp_dep_type": false"#, + 1, + ), + VOUCHER.replacen(r#""cmp_locus": 4"#, r#""cmp_locus": 5"#, 1), + VOUCHER.replacen(r#""cmp_dep_type": 64"#, r#""cmp_dep_type": 65"#, 1), + ] { + assert_eq!( + parse_documented_voucher_collection_v1( + changed.as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf8, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::ProfileMismatch) + ); + } +} + +#[test] +fn accepts_utf8_bom_and_utf16_le_only_when_the_contract_matches() { + let mut utf8_bom = vec![0xef, 0xbb, 0xbf]; + utf8_bom.extend_from_slice(LEDGER.as_bytes()); + let parsed = parse_ledger(&utf8_bom).expect("UTF-8 BOM is documented"); + assert_eq!(parsed.evidence.encoding, TallyTextEncoding::Utf8Bom); + + let utf16 = utf16_le_bom(LEDGER); + let parsed = parse_documented_ledger_collection_v1( + &utf16, + "application/json; charset=utf-16", + JsonExExpectedEncoding::Utf16Le, + JsonExLimits::default(), + ) + .expect("UTF-16LE BOM is documented"); + assert_eq!(parsed.evidence.encoding, TallyTextEncoding::Utf16LeBom); + + assert_eq!( + parse_documented_ledger_collection_v1( + LEDGER.as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf16Le, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::EncodingMismatch) + ); + assert_eq!( + parse_documented_ledger_collection_v1( + &utf16, + "application/json; charset=utf-8", + JsonExExpectedEncoding::Utf16Le, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::EncodingMismatch) + ); +} + +#[test] +fn rejects_unsupported_or_invalid_encodings_and_content_types() { + assert_eq!( + parse_documented_ledger_collection_v1( + &utf16_be_bom(LEDGER), + "application/json; charset=utf-16", + JsonExExpectedEncoding::Utf16Le, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::UnsupportedEncoding) + ); + assert_eq!( + parse_documented_ledger_collection_v1( + &[0xff, 0xfe, b'{'], + "application/json; charset=utf-16", + JsonExExpectedEncoding::Utf16Le, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::InvalidEncoding) + ); + assert_eq!( + parse_documented_ledger_collection_v1( + LEDGER.as_bytes(), + "text/json", + JsonExExpectedEncoding::Utf8, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::UnsupportedContentType) + ); + for malformed in [ + "application/json; charset=\"utf-8", + "application/json; charset=utf-8\"", + "application/json; charset=\"utf\\-8\"", + "application/json\r\n; charset=utf-8", + "application/json; charset=\rutf-8", + "application/json; \tcharset=utf-8", + ] { + assert_eq!( + parse_documented_ledger_collection_v1( + LEDGER.as_bytes(), + malformed, + JsonExExpectedEncoding::Utf8, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::UnsupportedContentType) + ); + } +} + +#[test] +fn application_failure_is_classified_before_success_payload_shape() { + for body in [ + r#"{"status":"0"}"#, + r#"{"status":"0","result":{"unexpected":"BRIDGE SECRET SENTINEL"}}"#, + r#"{"status":"0","detail":1.5}"#, + r#"{"status":"0","detail":1e400}"#, + ] { + assert_eq!( + parse_ledger_text(body).err(), + Some(JsonExProtocolError::ApplicationRejected) + ); + } + + assert_eq!( + parse_ledger_text(r#"{"data":{}}"#).err(), + Some(JsonExProtocolError::StatusMissing) + ); + assert_eq!( + parse_ledger_text(r#"{"status":0}"#).err(), + Some(JsonExProtocolError::StatusInvalid) + ); + assert_eq!( + parse_ledger_text(r#"{"status":"2"}"#).err(), + Some(JsonExProtocolError::StatusInvalid) + ); + assert_eq!( + parse_ledger_text(r#"{"status":"1","tallymessage":[]}"#).err(), + Some(JsonExProtocolError::WrongContainer) + ); +} + +#[test] +fn duplicate_keys_are_rejected_recursively() { + let duplicate_root = LEDGER.replacen(r#""status": "1""#, r#""status": "1", "status": "1""#, 1); + assert_eq!( + parse_ledger_text(&duplicate_root).err(), + Some(JsonExProtocolError::DuplicateField) + ); + + let duplicate_nested = LEDGER.replacen( + r#""type": "Amount", + "value": "-243900.00""#, + r#""type": "Amount", "type": "Amount", + "value": "-243900.00""#, + 1, + ); + assert_eq!( + parse_ledger_text(&duplicate_nested).err(), + Some(JsonExProtocolError::DuplicateField) + ); +} + +#[test] +fn malformed_trailing_and_wrong_container_documents_are_rejected() { + assert_eq!( + parse_ledger_text("{").err(), + Some(JsonExProtocolError::MalformedJson) + ); + assert_eq!( + parse_ledger_text(&format!("{LEDGER} true")).err(), + Some(JsonExProtocolError::MalformedJson) + ); + let mut wrong_container: serde_json::Value = + serde_json::from_str(LEDGER).expect("fixture must be JSON"); + wrong_container["data"]["collection"] = serde_json::json!({}); + assert_eq!( + parse_ledger_text(&wrong_container.to_string()).err(), + Some(JsonExProtocolError::WrongContainer) + ); +} + +#[test] +fn typed_wrappers_require_exact_labels_and_value_kinds() { + let wrong_label = LEDGER.replacen(r#""type": "Amount""#, r#""type": "String""#, 1); + assert_eq!( + parse_ledger_text(&wrong_label).err(), + Some(JsonExProtocolError::TypedValueMismatch) + ); + + let null_value = LEDGER.replacen(r#""value": "-243900.00""#, r#""value": null"#, 1); + assert_eq!( + parse_ledger_text(&null_value).err(), + Some(JsonExProtocolError::TypedValueMismatch) + ); + + let imprecise = LEDGER.replacen(r#""value": "-243900.00""#, r#""value": -243900.00"#, 1); + assert_eq!( + parse_ledger_text(&imprecise).err(), + Some(JsonExProtocolError::TypedValueMismatch) + ); + + let mut singleton: serde_json::Value = + serde_json::from_str(VOUCHER).expect("fixture must be JSON"); + let first_entry = singleton["data"]["collection"][0]["allledgerentries"][0].clone(); + singleton["data"]["collection"][0]["allledgerentries"] = first_entry; + assert_eq!( + parse_documented_voucher_collection_v1( + singleton.to_string().as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf8, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::ProfileMismatch) + ); + + let mut empty_required_amount: serde_json::Value = + serde_json::from_str(VOUCHER).expect("fixture must be JSON"); + empty_required_amount["data"]["collection"][0]["allledgerentries"][0]["amount"]["value"] = + serde_json::json!(""); + assert_eq!( + parse_documented_voucher_collection_v1( + empty_required_amount.to_string().as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf8, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::TypedValueMismatch) + ); +} + +#[test] +fn exact_decimal_date_and_integer_rules_reject_ambiguous_values() { + let exponent = LEDGER.replacen(r#""value": "-243900.00""#, r#""value": "-2.439e5""#, 1); + assert_eq!( + parse_ledger_text(&exponent).err(), + Some(JsonExProtocolError::TypedValueMismatch) + ); + + let bad_date = VOUCHER.replacen(r#""value": "20250401""#, r#""value": "20250229""#, 1); + assert_eq!( + parse_documented_voucher_collection_v1( + bad_date.as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf8, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::TypedValueMismatch) + ); + + let signed_number = VOUCHER.replacen(r#""value": " 50""#, r#""value": " -50""#, 1); + assert_eq!( + parse_documented_voucher_collection_v1( + signed_number.as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf8, + JsonExLimits::default(), + ) + .err(), + Some(JsonExProtocolError::TypedValueMismatch) + ); +} + +#[test] +fn every_resource_limit_is_fail_closed() { + let cases = [ + ( + JsonExLimits { + max_encoded_bytes: LEDGER.len() - 1, + ..JsonExLimits::default() + }, + JsonExProtocolError::ResponseTooLarge, + ), + ( + JsonExLimits { + max_decoded_bytes: LEDGER.len() - 1, + ..JsonExLimits::default() + }, + JsonExProtocolError::DecodedResponseTooLarge, + ), + ( + JsonExLimits { + max_depth: 2, + ..JsonExLimits::default() + }, + JsonExProtocolError::ResourceLimitExceeded, + ), + ( + JsonExLimits { + max_nodes: 5, + ..JsonExLimits::default() + }, + JsonExProtocolError::ResourceLimitExceeded, + ), + ( + JsonExLimits { + max_object_members: 1, + ..JsonExLimits::default() + }, + JsonExProtocolError::ResourceLimitExceeded, + ), + ( + JsonExLimits { + max_array_items: 1, + ..JsonExLimits::default() + }, + JsonExProtocolError::ResourceLimitExceeded, + ), + ( + JsonExLimits { + max_string_bytes: 5, + ..JsonExLimits::default() + }, + JsonExProtocolError::ResourceLimitExceeded, + ), + ( + JsonExLimits { + max_records: 1, + ..JsonExLimits::default() + }, + JsonExProtocolError::RecordLimitExceeded, + ), + ( + JsonExLimits { + max_record_decoded_bytes: 100, + ..JsonExLimits::default() + }, + JsonExProtocolError::RecordTooLarge, + ), + ]; + + for (limits, expected) in cases { + let result = parse_documented_ledger_collection_v1( + LEDGER.as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf8, + limits, + ); + assert_eq!(result.err(), Some(expected)); + } + + let zero_limit = JsonExLimits { + max_nodes: 0, + ..JsonExLimits::default() + }; + assert_eq!( + parse_documented_ledger_collection_v1( + LEDGER.as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf8, + zero_limit, + ) + .err(), + Some(JsonExProtocolError::ResourceLimitExceeded) + ); +} + +#[test] +fn array_limit_rejects_before_deserializing_the_over_limit_value() { + let mut value: serde_json::Value = serde_json::from_str(LEDGER).expect("fixture must be JSON"); + value["data"]["collection"] + .as_array_mut() + .expect("collection must be an array") + .push(serde_json::json!(1.5)); + let limits = JsonExLimits { + max_array_items: 2, + ..JsonExLimits::default() + }; + + assert_eq!( + parse_documented_ledger_collection_v1( + value.to_string().as_bytes(), + "application/json", + JsonExExpectedEncoding::Utf8, + limits, + ) + .err(), + Some(JsonExProtocolError::ResourceLimitExceeded) + ); +} + +#[test] +fn parser_errors_expose_only_stable_safe_codes() { + let secret = "BRIDGE-DO-NOT-ECHO-SENTINEL"; + let body = format!(r#"{{"status":"0","detail":"{secret}"}}"#); + let error = match parse_ledger_text(&body) { + Err(error) => error, + Ok(_) => panic!("status zero must fail"), + }; + assert_eq!(error.to_string(), "jsonex_application_rejected"); + assert_eq!(error.safe_code(), "jsonex_application_rejected"); + assert!(!error.to_string().contains(secret)); +} + +#[test] +fn checked_in_corpus_contains_only_synthetic_identity_markers() { + let corpus = format!("{LEDGER}\n{VOUCHER}"); + let normalized_paths = corpus.replace("\\\\", "\\"); + for forbidden in [ + "C:\\Users\\", + "C:/Users/", + "@gmail.com", + "@outlook.com", + "GSTIN", + "PAN", + "BANK ACCOUNT", + "MOBILE NO", + ] { + let forbidden = forbidden.to_ascii_uppercase(); + assert!(!corpus.to_ascii_uppercase().contains(&forbidden)); + assert!(!normalized_paths.to_ascii_uppercase().contains(&forbidden)); + } + assert!(corpus.contains("BRIDGE SYNTHETIC")); +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/ledger_period_balance.rs b/src-tauri/crates/bridge-tally-protocol/tests/ledger_period_balance.rs new file mode 100644 index 0000000..4bf11ce --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/ledger_period_balance.rs @@ -0,0 +1,77 @@ +use bridge_tally_protocol::{ + parse_ledger_period_balance_report, BRIDGE_LEDGER_PERIOD_BALANCE_SCHEMA, +}; + +fn report(rows: &str, count: u64) -> String { + format!( + r#"
1
{}LEDGERPERIODBALANCEcompany-guid2026070120260731Yes{}{}
"#, + BRIDGE_LEDGER_PERIOD_BALANCE_SCHEMA, count, rows + ) +} + +#[test] +fn parses_strict_identity_and_exact_period_amounts() { + let parsed = parse_ledger_period_balance_report(&report( + r#"-100.00099.999"#, + 1, + )) + .unwrap(); + assert_eq!(parsed.context.company_guid, "company-guid"); + assert_eq!(parsed.context.from_yyyymmdd, "20260701"); + assert_eq!(parsed.context.to_yyyymmdd, "20260731"); + assert!(parsed.context.ordinary_books_requested); + assert_eq!(parsed.records[0].source_id.as_deref(), Some("ledger-guid")); + assert_eq!(parsed.records[0].record.opening_balance, "-100.000"); + assert_eq!(parsed.records[0].record.closing_balance, "99.999"); +} + +#[test] +fn rejects_count_schema_status_and_required_field_failures() { + let row = r#"00"#; + assert!(parse_ledger_period_balance_report(&report(row, 2)).is_err()); + + let wrong_schema = report(row, 1).replace( + BRIDGE_LEDGER_PERIOD_BALANCE_SCHEMA, + "bridge.tally.ledger-period-balances/999", + ); + assert!(parse_ledger_period_balance_report(&wrong_schema).is_err()); + + let failure = report(row, 1).replace("1", "0"); + assert!(parse_ledger_period_balance_report(&failure).is_err()); + + let missing_closing = report( + r#"0"#, + 1, + ); + assert!(parse_ledger_period_balance_report(&missing_closing).is_err()); +} + +#[test] +fn rejects_duplicate_context_and_invalid_boolean() { + let base = report("", 0); + let context = r#"bridge.tally.ledger-period-balances/1LEDGERPERIODBALANCEcompany-guid2026070120260731Yes0"#; + assert!(parse_ledger_period_balance_report( + &base.replace("", &format!("{context}")) + ) + .is_err()); + assert!(parse_ledger_period_balance_report(&base.replace( + "Yes", + "Maybe" + )) + .is_err()); +} + +#[test] +fn rejects_empty_and_duplicate_amounts() { + let empty = report( + r#"0"#, + 1, + ); + assert!(parse_ledger_period_balance_report(&empty).is_err()); + + let duplicate = report( + r#"010"#, + 1, + ); + assert!(parse_ledger_period_balance_report(&duplicate).is_err()); +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs b/src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs new file mode 100644 index 0000000..2f41d76 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/simulator_corpus.rs @@ -0,0 +1,582 @@ +use bridge_tally_protocol::{ + decode_xml_bytes, decode_xml_bytes_limited, export_status, parse_companies, + parse_group_source_records_with_evidence, parse_import_result, + parse_ledger_source_records_with_evidence, parse_ledgers, parse_ledgers_with_evidence, + parse_selected_voucher_source_records_with_evidence, + parse_voucher_source_records_with_evidence, parse_voucher_type_source_records_with_evidence, + parse_vouchers, parse_vouchers_with_evidence, validate_exact_selected_export_structure, + verify_company_context, verify_selected_voucher_window_context, ParsedSourceIdentityKind, + TallyExportStatus, BRIDGE_GROUP_EXPORT_SCHEMA, BRIDGE_LEDGER_EXPORT_SCHEMA, + BRIDGE_SELECTED_VOUCHER_EXPORT_SCHEMA, BRIDGE_VOUCHER_EXPORT_SCHEMA, + BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA, +}; +use sha2::{Digest, Sha256}; +use tally_protocol_simulator::{ + generate_master_corpus, Fixture, MasterCorpusSpec, ScenarioPlan, WireEncoding, +}; + +#[test] +fn production_status_parser_distinguishes_all_application_states() { + assert_eq!( + export_status(&Fixture::ExportStatusOne.body()).unwrap(), + TallyExportStatus::Success + ); + assert_eq!( + export_status(&Fixture::ExportStatusZero.body()).unwrap(), + TallyExportStatus::Failure + ); + let missing = export_status(&Fixture::ExportStatusMissing.body()).unwrap_err(); + assert!(missing + .to_string() + .contains("did not include HEADER/STATUS")); + let invalid = export_status(&Fixture::ExportStatusInvalid.body()).unwrap_err(); + assert!(invalid.to_string().contains("invalid application STATUS")); +} + +#[test] +fn export_status_rejects_duplicate_misplaced_and_active_xml_constructs() { + for xml in [ + "
10
11
", + "
1
1
", + "]>
1&x;
", + "
110
", + "
111
", + "
11
", + "
11
", + "
1
", + "
mixed1
", + "
11
", + ] { + assert!(export_status(xml).is_err(), "must reject {xml}"); + } +} + +#[test] +fn primary_rows_and_company_context_must_use_supported_export_parents() { + let nested_ledger = format!( + r#"
1
Primary
"#, + BRIDGE_LEDGER_EXPORT_SCHEMA + ); + assert!(parse_ledger_source_records_with_evidence(&nested_ledger).is_err()); + + let nested_group = format!( + r#"
1
Primary
"#, + BRIDGE_GROUP_EXPORT_SCHEMA + ); + assert!(parse_group_source_records_with_evidence(&nested_group).is_err()); + + let nested_voucher = format!( + r#"
1
0
"#, + BRIDGE_VOUCHER_EXPORT_SCHEMA + ); + assert!(parse_voucher_source_records_with_evidence(&nested_voucher).is_err()); + + let nested_context = format!( + r#"
1
Primary
"#, + BRIDGE_LEDGER_EXPORT_SCHEMA + ); + assert!(parse_ledger_source_records_with_evidence(&nested_context).is_err()); +} + +#[test] +fn selected_voucher_v3_binds_exact_window_and_rejects_structural_siblings() { + let xml = format!( + r#"
11
+ +20260715ReceiptNoNo0 +
"#, + BRIDGE_SELECTED_VOUCHER_EXPORT_SCHEMA + ); + validate_exact_selected_export_structure(&xml, "VOUCHER").unwrap(); + let parsed = parse_selected_voucher_source_records_with_evidence(&xml).unwrap(); + verify_company_context(&parsed.evidence, "COMPANY-GUID").unwrap(); + verify_selected_voucher_window_context(&parsed.evidence, "20260701", "20260731").unwrap(); + assert!( + verify_selected_voucher_window_context(&parsed.evidence, "20260702", "20260731").is_err() + ); + + let with_sibling = xml.replace( + "", + "", + ); + assert!(validate_exact_selected_export_structure(&with_sibling, "VOUCHER").is_err()); + + for invalid in [ + xml.replace("", ""), + xml.replace(" assert_eq!(&parsed.records, expected), + None => reference_records = Some(parsed.records), + } + } +} + +fn parsed_master_semantic_sha256( + records: &[bridge_tally_protocol::ParsedSourceRecord], +) -> String { + let mut digest = Sha256::new(); + digest.update(b"bridge.tally.synthetic-master-semantics/1\0"); + for (index, source) in records.iter().enumerate() { + for (label, value) in [ + ("record_index", index.to_string()), + ("name", source.record.name.clone()), + ("guid", source.identities.guid.clone().unwrap_or_default()), + ( + "remote_id", + source.identities.remote_id.clone().unwrap_or_default(), + ), + ( + "master_id", + source.identities.master_id.clone().unwrap_or_default(), + ), + ("alter_id", source.alter_id.clone().unwrap_or_default()), + ("parent", source.record.parent.clone().unwrap_or_default()), + ( + "opening_balance", + source.record.opening_balance.clone().unwrap_or_default(), + ), + ] { + semantic_field(&mut digest, label.as_bytes(), value.as_bytes()); + } + } + digest + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn semantic_field(digest: &mut Sha256, label: &[u8], value: &[u8]) { + digest.update((label.len() as u16).to_be_bytes()); + digest.update(label); + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); +} + +#[test] +fn production_byte_limit_is_checked_before_decoding() { + let bytes = ScenarioPlan::new(Fixture::Oversized { + minimum_bytes: 4096, + }) + .response_bytes(); + let error = decode_xml_bytes_limited(&bytes, 1024).unwrap_err(); + assert_eq!( + error.to_string(), + "Tally response exceeded the 1024-byte limit" + ); + assert!(decode_xml_bytes_limited(&bytes, bytes.len()).is_ok()); +} + +#[test] +fn malformed_and_truncated_corpus_never_returns_partial_records() { + assert!(parse_ledgers(&Fixture::MalformedXml.body()).is_err()); + assert!(parse_ledgers(&Fixture::TruncatedXml.body()).is_err()); +} + +#[test] +fn ledger_parser_preserves_exact_decimal_text_and_company_evidence() { + let parsed = parse_ledgers_with_evidence(&Fixture::NormalExport.body()).unwrap(); + assert_eq!(parsed.records.len(), 1); + assert_eq!(parsed.records[0].name, "BRIDGE SYNTHETIC LEDGER"); + assert_eq!( + parsed.records[0].opening_balance.as_deref(), + Some("-1180.00") + ); + assert_eq!(parsed.evidence.identified_record_count, 1); + assert_eq!( + parsed.evidence.schema.as_deref(), + Some(BRIDGE_LEDGER_EXPORT_SCHEMA) + ); + assert_eq!(parsed.evidence.object_type.as_deref(), Some("LEDGER")); + assert_eq!(parsed.evidence.source_record_count, Some(1)); + let context = parsed.evidence.company_context.as_ref().unwrap(); + assert_eq!(context.name.as_deref(), Some("BRIDGE SYNTHETIC BOOK")); + assert_eq!( + context.guid.as_deref(), + Some("00000000-0000-4000-8000-000000000001") + ); + verify_company_context(&parsed.evidence, "00000000-0000-4000-8000-000000000001").unwrap(); +} + +#[test] +fn source_record_parsers_preserve_identity_kind_and_exact_fragment_hash() { + let xml = Fixture::NormalExport.body(); + let ledgers = parse_ledger_source_records_with_evidence(&xml).unwrap(); + assert_eq!(ledgers.records.len(), 1); + assert_eq!( + ledgers.records[0].identity_kind, + Some(ParsedSourceIdentityKind::Guid) + ); + assert_eq!( + ledgers.records[0].source_id.as_deref(), + Some("00000000-0000-4000-8000-000000000101") + ); + assert_eq!(ledgers.records[0].raw_source_sha256.len(), 64); + assert_eq!( + ledgers.records[0].raw_source_sha256, + parse_ledger_source_records_with_evidence(&xml) + .unwrap() + .records[0] + .raw_source_sha256 + ); + + let vouchers = + parse_voucher_source_records_with_evidence(&Fixture::VoucherExport.body()).unwrap(); + assert_eq!( + vouchers.records[0].identity_kind, + Some(ParsedSourceIdentityKind::Guid) + ); + assert_eq!( + vouchers.records[0].source_id.as_deref(), + Some("00000000-0000-4000-8000-000000000201") + ); + assert_eq!( + vouchers.records[0].identities.remote_id.as_deref(), + Some("bridge-synthetic-voucher-001") + ); + assert_eq!(vouchers.records[0].raw_source_sha256.len(), 64); + + let mixed_case_guid = xml.replace( + "00000000-0000-4000-8000-000000000101", + "00000000-0000-4000-8000-000000000AaB", + ); + let mixed_case = parse_ledger_source_records_with_evidence(&mixed_case_guid).unwrap(); + assert_eq!( + mixed_case.records[0].source_id.as_deref(), + Some("00000000-0000-4000-8000-000000000aab") + ); + assert_eq!( + mixed_case.records[0].identities.guid.as_deref(), + Some("00000000-0000-4000-8000-000000000aab") + ); +} + +#[test] +fn full_core_master_and_nested_entry_contracts_are_strict() { + let group_xml = format!( + r#"
1
Primary
"#, + BRIDGE_GROUP_EXPORT_SCHEMA + ); + let groups = parse_group_source_records_with_evidence(&group_xml).unwrap(); + assert_eq!(groups.records[0].source_id.as_deref(), Some("group-guid")); + assert_eq!(groups.records[0].identities.master_id.as_deref(), Some("7")); + assert_eq!(groups.records[0].alter_id.as_deref(), Some("11")); + + let voucher_type_xml = format!( + r#"
1
Receipt
"#, + BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA + ); + let voucher_types = parse_voucher_type_source_records_with_evidence(&voucher_type_xml).unwrap(); + assert_eq!( + voucher_types.records[0].identity_kind, + Some(ParsedSourceIdentityKind::MasterId) + ); + + let voucher_xml = format!( + r#"
1
20260701ReceiptBRIDGE-1NoNo21Cash-100.00Yes2Sales100.00No
"#, + BRIDGE_VOUCHER_EXPORT_SCHEMA + ); + let vouchers = parse_voucher_source_records_with_evidence(&voucher_xml).unwrap(); + assert_eq!(vouchers.records[0].record.ledger_entries.len(), 2); + assert_eq!(vouchers.records[0].record.ledger_entries[0].entry_index, 1); + assert_eq!( + vouchers.records[0].record.ledger_entries[0].amount, + "-100.00" + ); + assert_eq!( + vouchers.records[0].record.ledger_entries[0] + .raw_source_sha256 + .len(), + 64 + ); + + let bad_count = voucher_xml.replace( + "2", + "1", + ); + assert!(parse_voucher_source_records_with_evidence(&bad_count).is_err()); + + let missing_polarity = voucher_xml.replace("Yes", ""); + assert!(parse_voucher_source_records_with_evidence(&missing_polarity).is_err()); + let invalid_polarity = voucher_xml.replace( + "Yes", + "Maybe", + ); + assert!(parse_voucher_source_records_with_evidence(&invalid_polarity).is_err()); +} + +#[test] +fn wrong_company_and_duplicate_identities_are_explicit_safe_evidence() { + let wrong = parse_ledgers_with_evidence(&Fixture::WrongCompany.body()).unwrap(); + let error = verify_company_context(&wrong.evidence, "00000000-0000-4000-8000-000000000001") + .unwrap_err(); + assert_eq!( + error.to_string(), + "Tally response company context did not match the request" + ); + assert!(!error.to_string().contains("00000000")); + + let duplicate = parse_ledgers_with_evidence(&Fixture::DuplicateIdentity.body()).unwrap(); + assert_eq!(duplicate.records.len(), 2); + assert_eq!(duplicate.evidence.identified_record_count, 2); + assert_eq!(duplicate.evidence.duplicate_identities.len(), 1); + assert_eq!(duplicate.evidence.duplicate_identities[0].occurrences, 2); + assert_eq!( + duplicate.evidence.duplicate_identities[0] + .identity_sha256 + .len(), + 64 + ); + let serialized = serde_json::to_string(&duplicate.evidence).unwrap(); + assert!(!serialized.contains("00000000-0000-4000-8000-000000000199")); +} + +#[test] +fn child_and_attribute_metadata_shapes_are_strictly_equivalent() { + let child = parse_ledgers_with_evidence(&Fixture::NormalExport.body()).unwrap(); + assert_eq!( + child.evidence.schema.as_deref(), + Some(BRIDGE_LEDGER_EXPORT_SCHEMA) + ); + assert_eq!(child.evidence.source_record_count, Some(1)); + + let attributes = parse_vouchers_with_evidence(&Fixture::VoucherExport.body()).unwrap(); + assert_eq!(attributes.records.len(), 1); + assert_eq!( + attributes.evidence.schema.as_deref(), + Some(BRIDGE_VOUCHER_EXPORT_SCHEMA) + ); + assert_eq!(attributes.evidence.object_type.as_deref(), Some("VOUCHER")); + assert_eq!(attributes.evidence.source_record_count, Some(1)); + assert_eq!( + attributes.records[0].id.as_deref(), + Some("00000000-0000-4000-8000-000000000201") + ); +} + +#[test] +fn a_proven_empty_export_is_not_confused_with_missing_evidence() { + let empty = parse_ledgers_with_evidence(&Fixture::EmptyExport.body()).unwrap(); + assert!(empty.records.is_empty()); + assert_eq!(empty.evidence.source_record_count, Some(0)); + assert_eq!(empty.evidence.object_type.as_deref(), Some("LEDGER")); + + let missing = r#"
1
"#; + let error = parse_ledgers_with_evidence(missing).unwrap_err(); + assert_eq!(error.to_string(), "Tally response omitted company context"); +} + +#[test] +fn count_mismatch_and_malformed_or_duplicate_metadata_fail_closed() { + let mismatch = parse_ledgers_with_evidence(&Fixture::RecordCountMismatch.body()).unwrap_err(); + assert_eq!( + mismatch.to_string(), + "Tally source record count did not match parsed primary rows" + ); + + let malformed = + parse_ledgers_with_evidence(&Fixture::MalformedExportMetadata.body()).unwrap_err(); + assert_eq!( + malformed.to_string(), + "Tally company context RECORDCOUNT was not a non-negative integer" + ); + + let duplicate = + parse_ledgers_with_evidence(&Fixture::DuplicateExportMetadata.body()).unwrap_err(); + assert_eq!( + duplicate.to_string(), + "Tally company context contained duplicate metadata" + ); +} + +#[test] +fn primary_rows_reject_case_insensitive_duplicate_identity_attributes() { + let xml = format!( + r#"
1
Primary
"#, + BRIDGE_LEDGER_EXPORT_SCHEMA + ); + let error = parse_ledger_source_records_with_evidence(&xml) + .expect_err("case-insensitive duplicate identity attributes must fail closed"); + assert!(error.to_string().contains("repeated an attribute")); +} + +#[test] +fn schema_and_object_type_are_bound_to_the_public_parser() { + let wrong_schema = Fixture::NormalExport + .body() + .replace(BRIDGE_LEDGER_EXPORT_SCHEMA, BRIDGE_VOUCHER_EXPORT_SCHEMA); + let error = parse_ledgers_with_evidence(&wrong_schema).unwrap_err(); + assert_eq!( + error.to_string(), + "Tally response export schema did not match the parser" + ); + + let wrong_object = Fixture::NormalExport.body().replace( + "LEDGER", + "VOUCHER", + ); + let error = parse_ledgers_with_evidence(&wrong_object).unwrap_err(); + assert_eq!( + error.to_string(), + "Tally response object type did not match the parser" + ); +} + +#[test] +fn multiple_company_contexts_are_rejected_as_ambiguous() { + let xml = r#"
1
+ bridge.tally.ledgers/1LEDGERBRIDGE SYNTHETIC COMPANY Aguid-a0 + bridge.tally.ledgers/1LEDGERBRIDGE SYNTHETIC COMPANY Bguid-b0 +
"#; + let error = parse_ledgers_with_evidence(xml).expect_err("ambiguous context must fail closed"); + assert!(error.to_string().contains("multiple company contexts")); +} + +#[test] +fn duplicate_identity_evidence_is_object_scoped_and_uses_guid_fallback() { + let cross_type = r#"
1
+ + + +
"#; + let evidence = parse_ledgers_with_evidence(cross_type).unwrap().evidence; + assert_eq!(evidence.identified_record_count, 2); + assert!(evidence.duplicate_identities.is_empty()); + + let duplicate_guid = r#"
1
+ + + +
"#; + let evidence = parse_ledgers_with_evidence(duplicate_guid) + .unwrap() + .evidence; + assert_eq!(evidence.identified_record_count, 2); + assert_eq!(evidence.duplicate_identities.len(), 1); + assert_eq!(evidence.duplicate_identities[0].occurrences, 2); + + let case_variant_guid = duplicate_guid.replace( + "
1
BRIDGE SYNTHETIC BOOK00000000-0000-4000-8000-000000000001
"#, + ) + .unwrap(); + assert_eq!(companies.len(), 1); + assert_eq!(companies[0].name, "BRIDGE SYNTHETIC BOOK"); + + let vouchers = parse_vouchers( + r#"
1
20260701ReceiptBRIDGE-001BRIDGE SYNTHETIC LEDGERNoNo0
"#, + ) + .unwrap(); + assert_eq!(vouchers.len(), 1); + assert_eq!( + vouchers[0].id.as_deref(), + Some("bridge-synthetic-voucher-001") + ); + assert_eq!(vouchers[0].date.as_deref(), Some("20260701")); + assert_eq!(vouchers[0].cancelled, Some(false)); +} + +#[test] +fn incomplete_or_invalid_imports_are_rejected() { + assert!(parse_import_result("1").is_err()); + assert!(parse_import_result("1").is_err()); + assert!(parse_import_result("-10000").is_err()); +} diff --git a/src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs b/src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs new file mode 100644 index 0000000..b90f632 --- /dev/null +++ b/src-tauri/crates/bridge-tally-protocol/tests/stream_text_decoder.rs @@ -0,0 +1,229 @@ +use bridge_tally_protocol::{ + decode_tally_text_bytes_limited, StreamDecodedTallyText, TallyTextDecodeError, + TallyTextEncoding, TallyTextStreamDecoder, +}; +use sha2::{Digest, Sha256}; + +#[derive(Clone, Copy)] +enum WireEncoding { + Utf8, + Utf8Bom, + Utf16Le, + Utf16Be, +} + +fn encode(text: &str, encoding: WireEncoding) -> Vec { + match encoding { + WireEncoding::Utf8 => text.as_bytes().to_vec(), + WireEncoding::Utf8Bom => [vec![0xEF, 0xBB, 0xBF], text.as_bytes().to_vec()].concat(), + WireEncoding::Utf16Le => { + let mut bytes = vec![0xFF, 0xFE]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_le_bytes()); + } + bytes + } + WireEncoding::Utf16Be => { + let mut bytes = vec![0xFE, 0xFF]; + for unit in text.encode_utf16() { + bytes.extend_from_slice(&unit.to_be_bytes()); + } + bytes + } + } +} + +fn expected_encoding(encoding: WireEncoding) -> TallyTextEncoding { + match encoding { + WireEncoding::Utf8 => TallyTextEncoding::Utf8, + WireEncoding::Utf8Bom => TallyTextEncoding::Utf8Bom, + WireEncoding::Utf16Le => TallyTextEncoding::Utf16LeBom, + WireEncoding::Utf16Be => TallyTextEncoding::Utf16BeBom, + } +} + +fn decode_chunks( + chunks: impl IntoIterator>, + max_decoded_bytes: usize, +) -> Result { + let mut decoder = TallyTextStreamDecoder::new(max_decoded_bytes); + for chunk in chunks { + decoder.push_chunk(&chunk)?; + } + decoder.finish() +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[test] +fn every_single_split_matches_one_shot_decoding_for_all_encodings() { + let text = "ASCII € 🧾 日本語"; + for encoding in [ + WireEncoding::Utf8, + WireEncoding::Utf8Bom, + WireEncoding::Utf16Le, + WireEncoding::Utf16Be, + ] { + let bytes = encode(text, encoding); + let one_shot = decode_tally_text_bytes_limited(&bytes, bytes.len()) + .expect("one-shot reference decoding"); + for split in 0..=bytes.len() { + let streamed = decode_chunks( + [bytes[..split].to_vec(), bytes[split..].to_vec()], + text.len(), + ) + .unwrap_or_else(|error| panic!("split {split} failed: {error:?}")); + assert_eq!(streamed.text, one_shot.text, "split {split}"); + assert_eq!(streamed.encoding, expected_encoding(encoding)); + assert_eq!(streamed.decoded_bytes, text.len()); + assert_eq!(streamed.decoded_sha256, sha256_hex(text.as_bytes())); + } + } +} + +#[test] +fn one_byte_chunks_cross_boms_utf8_scalars_utf16_units_and_surrogate_pairs() { + let text = "A€🧾Z"; + for encoding in [ + WireEncoding::Utf8, + WireEncoding::Utf8Bom, + WireEncoding::Utf16Le, + WireEncoding::Utf16Be, + ] { + let bytes = encode(text, encoding); + let chunks = bytes.iter().map(|byte| vec![*byte]).collect::>(); + let decoded = decode_chunks(chunks, text.len()).expect("decode one-byte chunks"); + assert_eq!(decoded.text, text); + assert_eq!(decoded.encoding, expected_encoding(encoding)); + } +} + +#[test] +fn decoded_cap_is_inclusive_and_independent_of_wire_encoding() { + let text = "A€🧾"; + assert_eq!(text.len(), 8); + for encoding in [ + WireEncoding::Utf8, + WireEncoding::Utf8Bom, + WireEncoding::Utf16Le, + WireEncoding::Utf16Be, + ] { + let bytes = encode(text, encoding); + let exact = decode_chunks( + bytes.iter().map(|byte| vec![*byte]).collect::>(), + text.len(), + ) + .expect("exact decoded cap is inclusive"); + assert_eq!(exact.decoded_bytes, text.len()); + + let error = + decode_chunks([bytes], text.len() - 1).expect_err("decoded cap plus one must fail"); + assert_eq!(error, TallyTextDecodeError::TooLarge); + } + + assert_eq!( + decode_chunks([Vec::new()], 0) + .expect("empty response at zero cap") + .text, + "" + ); + assert_eq!( + decode_chunks([b"x".to_vec()], 0).expect_err("non-empty response exceeds zero cap"), + TallyTextDecodeError::TooLarge + ); +} + +#[test] +fn invalid_and_truncated_utf8_never_finish_as_partial_text() { + for bytes in [ + vec![0xE2, 0x82], + vec![0xF0, 0x28, 0x8C, 0xBC], + vec![0xEF], + vec![0xEF, 0xBB], + ] { + assert_eq!( + decode_chunks(bytes.iter().map(|byte| vec![*byte]), 64) + .expect_err("invalid UTF-8 must fail"), + TallyTextDecodeError::InvalidUtf8 + ); + } +} + +#[test] +fn invalid_and_truncated_utf16_tails_are_encoding_specific() { + let cases = [ + (vec![0xFF, 0xFE, 0x41], TallyTextDecodeError::InvalidUtf16Le), + ( + vec![0xFF, 0xFE, 0x3D, 0xD8], + TallyTextDecodeError::InvalidUtf16Le, + ), + ( + vec![0xFF, 0xFE, 0x00, 0xDC], + TallyTextDecodeError::InvalidUtf16Le, + ), + ( + vec![0xFF, 0xFE, 0x3D, 0xD8, 0x41, 0x00], + TallyTextDecodeError::InvalidUtf16Le, + ), + (vec![0xFE, 0xFF, 0x00], TallyTextDecodeError::InvalidUtf16Be), + ( + vec![0xFE, 0xFF, 0xD8, 0x3D], + TallyTextDecodeError::InvalidUtf16Be, + ), + ( + vec![0xFE, 0xFF, 0xDC, 0x00], + TallyTextDecodeError::InvalidUtf16Be, + ), + ( + vec![0xFE, 0xFF, 0xD8, 0x3D, 0x00, 0x41], + TallyTextDecodeError::InvalidUtf16Be, + ), + ]; + for (bytes, expected) in cases { + assert_eq!( + decode_chunks(bytes.iter().map(|byte| vec![*byte]), 64) + .expect_err("invalid UTF-16 must fail"), + expected + ); + } +} + +#[test] +fn decoded_digest_is_chunk_and_wire_encoding_independent() { + let text = "€🧾"; + let expected = sha256_hex(text.as_bytes()); + let mut observed = Vec::new(); + for encoding in [ + WireEncoding::Utf8, + WireEncoding::Utf8Bom, + WireEncoding::Utf16Le, + WireEncoding::Utf16Be, + ] { + let bytes = encode(text, encoding); + let decoded = decode_chunks( + bytes.chunks(3).map(<[u8]>::to_vec).collect::>(), + text.len(), + ) + .expect("streamed digest evidence"); + assert_eq!(decoded.decoded_sha256, expected); + assert_eq!(decoded.decoded_sha256, sha256_hex(decoded.text.as_bytes())); + observed.push(decoded.decoded_sha256); + } + assert!(observed.iter().all(|digest| digest == &observed[0])); +} + +#[test] +fn non_bom_utf8_prefixes_that_resemble_a_bom_remain_data() { + let text = "ﻠ retained"; + let bytes = text.as_bytes(); + assert_eq!(&bytes[..2], &[0xEF, 0xBB]); + let decoded = decode_chunks(bytes.iter().map(|byte| vec![*byte]), text.len()) + .expect("valid non-BOM UTF-8"); + assert_eq!(decoded.text, text); + assert_eq!(decoded.encoding, TallyTextEncoding::Utf8); +} diff --git a/src-tauri/crates/bridge-tally-qualification/Cargo.toml b/src-tauri/crates/bridge-tally-qualification/Cargo.toml new file mode 100644 index 0000000..acb8f44 --- /dev/null +++ b/src-tauri/crates/bridge-tally-qualification/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "bridge-tally-qualification" +version = "0.1.0" +description = "Portable synthetic Tally parser qualification evidence for Bridge" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[dependencies] +bridge-tally-protocol = { path = "../bridge-tally-protocol" } +hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +tally-protocol-simulator = { path = "../tally-protocol-simulator" } +tempfile = "3" +thiserror = "2" + +[target.'cfg(unix)'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.61", features = [ + "Win32_System_ProcessStatus", + "Win32_System_Threading", +] } diff --git a/src-tauri/crates/bridge-tally-qualification/README.md b/src-tauri/crates/bridge-tally-qualification/README.md new file mode 100644 index 0000000..c7d2ebe --- /dev/null +++ b/src-tauri/crates/bridge-tally-qualification/README.md @@ -0,0 +1,38 @@ +# Synthetic Tally parser qualification + +This portable crate produces versioned evidence for one narrow claim: a fresh +Bridge protocol worker decoded and parsed a deterministic repository-generated +voucher corpus with exact counts and hashes. + +It does **not** contact Tally, exercise HTTP, the Tauri runtime, SQLCipher, +canonical persistence, reconciliation, resume, or the UI. A receipt is +hard-coded and revalidated with all of these claims false: + +- live Tally was observed; +- Tally support or capability was established; +- accounting correctness was established; +- a performance budget was established; +- the qualification response cap is bound to the production runtime. + +The controller generates UTF-8 windows into a temporary directory, verifies +each window is at most 32 MiB, then starts a fresh worker for every sample. The +worker verifies the domain-separated payload digest before parsing and reports +only exact counts, an independently expected semantic-output digest, monotonic +non-steady elapsed nanoseconds for the whole file-read/digest/decode/parse/hash +pipeline, and method-labelled process-lifetime peak resident memory. The delta +between two lifetime maxima is diagnostic, not an allocation measure. Paths +and raw payloads are never written to the receipt. Generator files are deleted +with the temporary directory. + +Run a correctness smoke receipt locally: + +```text +cargo run --locked --release -p bridge-tally-qualification -- run ci-smoke target/tally-qualification-smoke.json 7 3 +``` + +Supported scenario names are `ci-smoke`, `small-1k`, `medium-50k`, +`large-500k`, and `large-voucher`. The deprecated `deep-voucher` spelling is +accepted as an alias for `large-voucher`; no parser-depth claim is made. The +50k and 500k cases are explicitly windowed corpora, never one enormous Tally +response. Debug or hosted-runner measurements are diagnostic only. No baseline +comparator or performance budget exists in schema v1. diff --git a/src-tauri/crates/bridge-tally-qualification/build.rs b/src-tauri/crates/bridge-tally-qualification/build.rs new file mode 100644 index 0000000..9014cd7 --- /dev/null +++ b/src-tauri/crates/bridge-tally-qualification/build.rs @@ -0,0 +1,35 @@ +use std::process::Command; + +fn main() { + let rustc = std::env::var_os("RUSTC").expect("Cargo must provide RUSTC"); + let output = Command::new(rustc) + .arg("--version") + .output() + .expect("rustc --version must run"); + assert!(output.status.success(), "rustc --version must succeed"); + let version = String::from_utf8(output.stdout).expect("rustc version must be UTF-8"); + println!( + "cargo:rustc-env=BRIDGE_QUALIFICATION_RUSTC_VERSION={}", + version.trim() + ); + println!( + "cargo:rustc-env=BRIDGE_QUALIFICATION_TARGET={}", + std::env::var("TARGET").expect("Cargo must provide TARGET") + ); + println!( + "cargo:rustc-env=BRIDGE_QUALIFICATION_PROFILE={}", + std::env::var("PROFILE").expect("Cargo must provide PROFILE") + ); + if let Ok(commit) = std::env::var("GITHUB_SHA") { + assert!( + matches!(commit.len(), 40 | 64) + && commit + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)), + "GITHUB_SHA must be a lower-case Git object ID" + ); + println!("cargo:rustc-env=BRIDGE_QUALIFICATION_COMMIT={commit}"); + } + println!("cargo:rerun-if-env-changed=RUSTC"); + println!("cargo:rerun-if-env-changed=GITHUB_SHA"); +} diff --git a/src-tauri/crates/bridge-tally-qualification/src/lib.rs b/src-tauri/crates/bridge-tally-qualification/src/lib.rs new file mode 100644 index 0000000..538600d --- /dev/null +++ b/src-tauri/crates/bridge-tally-qualification/src/lib.rs @@ -0,0 +1,931 @@ +//! Synthetic, parser-only qualification receipts for Bridge's Tally integration. +//! +//! This crate cannot observe Tally and its receipts cannot establish Tally +//! compatibility or performance. Payload generation happens before a fresh +//! worker process measures the Bridge parser. + +use std::io::Read as _; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tally_protocol_simulator::{GeneratedWindow, VoucherCorpusSpec}; +use thiserror::Error; + +pub const RECEIPT_SCHEMA: &str = "bridge.tally.synthetic-qualification/1"; +pub const GENERATOR_SCHEMA: &str = "bridge.tally.synthetic-vouchers/1"; +pub const MAX_RECEIPT_BYTES: usize = 256 * 1024; +pub const RESPONSE_LIMIT_BYTES: u64 = 32 * 1024 * 1024; + +#[derive(Debug, PartialEq, Eq)] +pub enum BoundedBodyRead { + Accepted(Vec), + SizeLimit, +} + +/// Reads at most the qualification contract's inclusive 32 MiB encoded-body +/// limit plus one detection byte. This is not yet shared with the production +/// HTTP runtime, so receipts keep `runtime_cap_binding` false. +pub fn read_qualification_body( + reader: R, + declared_bytes: Option, +) -> std::io::Result { + if declared_bytes.is_some_and(|bytes| bytes > RESPONSE_LIMIT_BYTES) { + return Ok(BoundedBodyRead::SizeLimit); + } + let capacity = declared_bytes + .unwrap_or(RESPONSE_LIMIT_BYTES + 1) + .min(RESPONSE_LIMIT_BYTES + 1) as usize; + let mut body = Vec::with_capacity(capacity); + reader + .take(RESPONSE_LIMIT_BYTES + 1) + .read_to_end(&mut body)?; + if body.len() as u64 > RESPONSE_LIMIT_BYTES { + Ok(BoundedBodyRead::SizeLimit) + } else { + Ok(BoundedBodyRead::Accepted(body)) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Scenario { + CiSmoke, + Small1k, + Medium50k, + Large500k, + LargeVoucher, +} + +impl Scenario { + pub fn corpus(self, seed: u64) -> VoucherCorpusSpec { + match self { + Self::CiSmoke => corpus(50, 50, 2, 16, 0, seed), + Self::Small1k => corpus(1_000, 1_000, 2, 16, 0, seed), + Self::Medium50k => corpus(50_000, 1_000, 2, 16, 0, seed), + Self::Large500k => corpus(500_000, 1_000, 2, 16, 0, seed), + Self::LargeVoucher => corpus(1, 1, 256, 256, 0, seed), + } + } + + pub fn parse(value: &str) -> Option { + match value { + "ci-smoke" => Some(Self::CiSmoke), + "small-1k" => Some(Self::Small1k), + "medium-50k" => Some(Self::Medium50k), + "large-500k" => Some(Self::Large500k), + "large-voucher" | "deep-voucher" => Some(Self::LargeVoucher), + _ => None, + } + } + + pub fn worker_timeout(self) -> std::time::Duration { + match self { + Self::CiSmoke => std::time::Duration::from_secs(60), + Self::Small1k | Self::LargeVoucher => std::time::Duration::from_secs(120), + Self::Medium50k => std::time::Duration::from_secs(600), + Self::Large500k => std::time::Duration::from_secs(1_800), + } + } +} + +fn corpus( + total_records: u64, + records_per_window: u32, + entries_per_voucher: u16, + text_width: u16, + nesting_depth: u16, + seed: u64, +) -> VoucherCorpusSpec { + VoucherCorpusSpec { + total_records, + records_per_window, + entries_per_voucher, + text_width, + nesting_depth, + seed, + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WindowEvidence { + pub ordinal: u32, + pub first_record: u64, + pub records: u32, + pub ledger_entries: u64, + pub wire_bytes: u64, + pub sha256: String, + pub expected_semantic_sha256: String, +} + +impl From for WindowEvidence { + fn from(value: GeneratedWindow) -> Self { + Self { + ordinal: value.window_index, + first_record: value.first_record, + records: value.record_count, + ledger_entries: value.ledger_entry_count, + wire_bytes: value.wire_bytes, + sha256: value.sha256, + expected_semantic_sha256: value.expected_semantic_sha256, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CorpusEvidence { + pub generator_schema: String, + pub scenario: Scenario, + pub seed: u64, + pub encoding: String, + pub requested_records: u64, + pub emitted_records: u64, + pub windowed_corpus: bool, + pub entries_per_voucher: u16, + pub text_width: u16, + pub nesting_depth: u16, + pub window_limit_records: u32, + pub response_limit_bytes: u64, + pub total_wire_bytes: u64, + pub manifest_sha256: String, + pub expected_output_sha256: String, + pub windows: Vec, +} + +impl CorpusEvidence { + pub fn from_windows( + scenario: Scenario, + spec: VoucherCorpusSpec, + windows: Vec, + ) -> Result { + spec.validate() + .map_err(|_| QualificationError::InvalidCorpus)?; + let emitted_records = windows.iter().try_fold(0_u64, |sum, window| { + sum.checked_add(u64::from(window.records)) + .ok_or(QualificationError::InvalidCorpus) + })?; + let total_wire_bytes = windows.iter().try_fold(0_u64, |sum, window| { + sum.checked_add(window.wire_bytes) + .ok_or(QualificationError::InvalidCorpus) + })?; + let manifest_sha256 = manifest_sha256(&windows)?; + let expected_output_sha256 = semantic_output_sha256(&windows)?; + let evidence = Self { + generator_schema: GENERATOR_SCHEMA.to_owned(), + scenario, + seed: spec.seed, + encoding: "utf8_no_bom".to_owned(), + requested_records: spec.total_records, + emitted_records, + windowed_corpus: windows.len() > 1, + entries_per_voucher: spec.entries_per_voucher, + text_width: spec.text_width, + nesting_depth: spec.nesting_depth, + window_limit_records: spec.records_per_window, + response_limit_bytes: RESPONSE_LIMIT_BYTES, + total_wire_bytes, + manifest_sha256, + expected_output_sha256, + windows, + }; + evidence.validate()?; + Ok(evidence) + } + + pub fn validate(&self) -> Result<(), QualificationError> { + if self.generator_schema != GENERATOR_SCHEMA + || self.encoding != "utf8_no_bom" + || self.requested_records == 0 + || self.requested_records != self.emitted_records + || self.response_limit_bytes != RESPONSE_LIMIT_BYTES + || self.windows.is_empty() + || self.windowed_corpus != (self.windows.len() > 1) + { + return Err(QualificationError::InvalidCorpus); + } + let expected = self.scenario.corpus(self.seed); + if self.requested_records != expected.total_records + || self.window_limit_records != expected.records_per_window + || self.entries_per_voucher != expected.entries_per_voucher + || self.text_width != expected.text_width + || self.nesting_depth != expected.nesting_depth + || self.windows.len() + != expected + .window_count() + .map_err(|_| QualificationError::InvalidCorpus)? as usize + { + return Err(QualificationError::InvalidCorpus); + } + let mut expected_first = 0_u64; + let mut emitted = 0_u64; + let mut wire_bytes = 0_u64; + for (ordinal, window) in self.windows.iter().enumerate() { + let (_, regenerated) = tally_protocol_simulator::generate_voucher_window( + std::io::sink(), + expected + .window(ordinal as u32) + .map_err(|_| QualificationError::InvalidCorpus)?, + ) + .map_err(|_| QualificationError::InvalidCorpus)?; + if WindowEvidence::from(regenerated) != *window { + return Err(QualificationError::InvalidCorpus); + } + if window.ordinal != ordinal as u32 + || window.first_record != expected_first + || window.records == 0 + || window.records > self.window_limit_records + || window.wire_bytes == 0 + || window.wire_bytes > self.response_limit_bytes + || window.ledger_entries + != u64::from(window.records) + .checked_mul(u64::from(self.entries_per_voucher)) + .ok_or(QualificationError::InvalidCorpus)? + || !is_sha256(&window.sha256) + || !is_sha256(&window.expected_semantic_sha256) + { + return Err(QualificationError::InvalidCorpus); + } + expected_first = expected_first + .checked_add(u64::from(window.records)) + .ok_or(QualificationError::InvalidCorpus)?; + emitted = emitted + .checked_add(u64::from(window.records)) + .ok_or(QualificationError::InvalidCorpus)?; + wire_bytes = wire_bytes + .checked_add(window.wire_bytes) + .ok_or(QualificationError::InvalidCorpus)?; + } + if emitted != self.emitted_records + || wire_bytes != self.total_wire_bytes + || manifest_sha256(&self.windows)? != self.manifest_sha256 + || semantic_output_sha256(&self.windows)? != self.expected_output_sha256 + { + return Err(QualificationError::InvalidCorpus); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct EnvironmentEvidence { + build_embedded_bridge_commit: Option, + executable_sha256: String, + cargo_lock_sha256: String, + rustc_version: String, + target_triple: String, + target_os: String, + target_arch: String, + build_profile: String, + enabled_features: Vec, +} + +impl EnvironmentEvidence { + pub fn current() -> Result { + let executable = + std::env::current_exe().map_err(|_| QualificationError::InvalidEnvironment)?; + let executable_bytes = + std::fs::read(executable).map_err(|_| QualificationError::InvalidEnvironment)?; + Ok(Self { + build_embedded_bridge_commit: option_env!("BRIDGE_QUALIFICATION_COMMIT") + .map(str::to_owned), + executable_sha256: hex::encode(Sha256::digest(executable_bytes)), + cargo_lock_sha256: hex::encode(Sha256::digest(include_bytes!("../../../Cargo.lock"))), + rustc_version: env!("BRIDGE_QUALIFICATION_RUSTC_VERSION").to_owned(), + target_triple: env!("BRIDGE_QUALIFICATION_TARGET").to_owned(), + target_os: std::env::consts::OS.to_owned(), + target_arch: std::env::consts::ARCH.to_owned(), + build_profile: env!("BRIDGE_QUALIFICATION_PROFILE").to_owned(), + enabled_features: Vec::new(), + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MemoryEvidence { + pub lifetime_peak_resident_bytes: Option, + pub baseline_lifetime_peak_resident_bytes: Option, + pub lifetime_peak_delta_bytes: Option, + pub method: Option, + pub unavailable_reason: Option, +} + +impl MemoryEvidence { + pub fn unavailable(reason: &str) -> Self { + Self { + lifetime_peak_resident_bytes: None, + baseline_lifetime_peak_resident_bytes: None, + lifetime_peak_delta_bytes: None, + method: None, + unavailable_reason: Some(reason.to_owned()), + } + } + + pub fn validate(&self) -> bool { + match ( + self.lifetime_peak_resident_bytes, + self.baseline_lifetime_peak_resident_bytes, + self.lifetime_peak_delta_bytes, + self.method.as_deref(), + self.unavailable_reason.as_deref(), + ) { + (Some(peak), Some(base), Some(delta), Some(method), None) => { + peak >= base + && peak - base == delta + && matches!( + method, + "windows_get_process_memory_info_peak_working_set_bytes" + | "macos_getrusage_ru_maxrss_bytes" + | "linux_getrusage_ru_maxrss_kib_normalized_to_bytes" + ) + } + (None, None, None, None, Some(reason)) => { + matches!( + reason, + "platform_peak_resident_measurement_unavailable" | "unsupported_test_platform" + ) + } + _ => false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SampleEvidence { + pub ordinal: u16, + pub elapsed_ns: u64, + pub parsed_records: u64, + pub parsed_ledger_entries: u64, + pub output_sha256: String, + pub outcome: String, + pub memory: MemoryEvidence, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SummaryEvidence { + pub sample_count: u16, + pub min_elapsed_ns: u64, + pub median_elapsed_ns: u64, + pub max_elapsed_ns: u64, + pub p95_elapsed_ns: Option, + pub quantile_method: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct QualificationReceipt { + schema: String, + authority: String, + operation: String, + execution_target: String, + live_tally_observed: bool, + establishes_tally_support: bool, + establishes_tally_capability: bool, + establishes_accounting_correctness: bool, + establishes_performance_budget: bool, + runtime_cap_binding: bool, + budget_verdict: String, + integrity: String, + environment: EnvironmentEvidence, + corpus: CorpusEvidence, + samples: Vec, + summary: SummaryEvidence, + receipt_sha256: String, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct UncheckedQualificationReceipt { + schema: String, + authority: String, + operation: String, + execution_target: String, + live_tally_observed: bool, + establishes_tally_support: bool, + establishes_tally_capability: bool, + establishes_accounting_correctness: bool, + establishes_performance_budget: bool, + runtime_cap_binding: bool, + budget_verdict: String, + integrity: String, + environment: EnvironmentEvidence, + corpus: CorpusEvidence, + samples: Vec, + summary: SummaryEvidence, + receipt_sha256: String, +} + +impl<'de> Deserialize<'de> for QualificationReceipt { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let unchecked = UncheckedQualificationReceipt::deserialize(deserializer)?; + let receipt = Self { + schema: unchecked.schema, + authority: unchecked.authority, + operation: unchecked.operation, + execution_target: unchecked.execution_target, + live_tally_observed: unchecked.live_tally_observed, + establishes_tally_support: unchecked.establishes_tally_support, + establishes_tally_capability: unchecked.establishes_tally_capability, + establishes_accounting_correctness: unchecked.establishes_accounting_correctness, + establishes_performance_budget: unchecked.establishes_performance_budget, + runtime_cap_binding: unchecked.runtime_cap_binding, + budget_verdict: unchecked.budget_verdict, + integrity: unchecked.integrity, + environment: unchecked.environment, + corpus: unchecked.corpus, + samples: unchecked.samples, + summary: unchecked.summary, + receipt_sha256: unchecked.receipt_sha256, + }; + receipt.validate().map_err(serde::de::Error::custom)?; + Ok(receipt) + } +} + +#[derive(Serialize)] +struct UnsignedReceipt<'a> { + schema: &'a str, + authority: &'a str, + operation: &'a str, + execution_target: &'a str, + live_tally_observed: bool, + establishes_tally_support: bool, + establishes_tally_capability: bool, + establishes_accounting_correctness: bool, + establishes_performance_budget: bool, + runtime_cap_binding: bool, + budget_verdict: &'a str, + integrity: &'a str, + environment: &'a EnvironmentEvidence, + corpus: &'a CorpusEvidence, + samples: &'a [SampleEvidence], + summary: &'a SummaryEvidence, +} + +impl QualificationReceipt { + pub fn corpus(&self) -> &CorpusEvidence { + &self.corpus + } + + pub fn samples(&self) -> &[SampleEvidence] { + &self.samples + } + + pub fn receipt_sha256(&self) -> &str { + &self.receipt_sha256 + } + pub fn build( + environment: EnvironmentEvidence, + corpus: CorpusEvidence, + samples: Vec, + ) -> Result { + let summary = summarize(&samples)?; + let mut receipt = Self { + schema: RECEIPT_SCHEMA.to_owned(), + authority: "repository_synthetic_bridge_parser_qualification".to_owned(), + operation: "qualification_worker_pipeline".to_owned(), + execution_target: "file_read_digest_decode_voucher_parse_and_semantic_digest" + .to_owned(), + live_tally_observed: false, + establishes_tally_support: false, + establishes_tally_capability: false, + establishes_accounting_correctness: false, + establishes_performance_budget: false, + runtime_cap_binding: false, + budget_verdict: "not_evaluated_no_baseline".to_owned(), + integrity: "checksum_only_not_authenticated".to_owned(), + environment, + corpus, + samples, + summary, + receipt_sha256: String::new(), + }; + receipt.receipt_sha256 = receipt.calculate_sha256()?; + receipt.validate()?; + Ok(receipt) + } + + pub fn validate(&self) -> Result<(), QualificationError> { + if self.schema != RECEIPT_SCHEMA + || self.authority != "repository_synthetic_bridge_parser_qualification" + || self.operation != "qualification_worker_pipeline" + || self.execution_target != "file_read_digest_decode_voucher_parse_and_semantic_digest" + || self.live_tally_observed + || self.establishes_tally_support + || self.establishes_tally_capability + || self.establishes_accounting_correctness + || self.establishes_performance_budget + || self.runtime_cap_binding + || self.budget_verdict != "not_evaluated_no_baseline" + || self.integrity != "checksum_only_not_authenticated" + || self.samples.is_empty() + || self.samples.len() > 25 + { + return Err(QualificationError::InvalidReceipt); + } + self.corpus.validate()?; + if self + .environment + .build_embedded_bridge_commit + .as_deref() + .is_some_and(|value| !is_commit(value)) + || !is_sha256(&self.environment.executable_sha256) + || !is_sha256(&self.environment.cargo_lock_sha256) + || !self.environment.rustc_version.starts_with("rustc ") + || self.environment.rustc_version.len() > 128 + || !self.environment.rustc_version.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b' ' | b'.' | b'-' | b'_' | b'(' | b')' | b'+') + }) + || self.environment.target_triple.len() > 96 + || !self + .environment + .target_triple + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + || !self.environment.enabled_features.is_empty() + || !matches!( + self.environment.target_os.as_str(), + "windows" | "macos" | "linux" + ) + || !matches!( + self.environment.target_arch.as_str(), + "x86" | "x86_64" | "arm" | "aarch64" + ) + || self.environment.build_profile.is_empty() + || self.environment.build_profile.len() > 64 + || !self + .environment + .build_profile + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_')) + { + return Err(QualificationError::InvalidEnvironment); + } + let expected_entries = self + .corpus + .emitted_records + .checked_mul(u64::from(self.corpus.entries_per_voucher)) + .ok_or(QualificationError::InvalidReceipt)?; + let expected_output = &self.corpus.expected_output_sha256; + for (ordinal, sample) in self.samples.iter().enumerate() { + if sample.ordinal != ordinal as u16 + || sample.elapsed_ns == 0 + || sample.parsed_records != self.corpus.emitted_records + || sample.parsed_ledger_entries != expected_entries + || sample.outcome != "passed" + || !is_sha256(&sample.output_sha256) + || sample.output_sha256 != *expected_output + || !sample.memory.validate() + || !memory_method_matches_os(&sample.memory, &self.environment.target_os) + { + return Err(QualificationError::InvalidReceipt); + } + } + if summarize(&self.samples)? != self.summary + || self.calculate_sha256()? != self.receipt_sha256 + { + return Err(QualificationError::InvalidReceipt); + } + let encoded = serde_json::to_vec(self).map_err(|_| QualificationError::Serialization)?; + if encoded.len() > MAX_RECEIPT_BYTES { + return Err(QualificationError::ReceiptTooLarge); + } + Ok(()) + } + + pub fn to_pretty_json(&self) -> Result, QualificationError> { + self.validate()?; + serde_json::to_vec_pretty(self).map_err(|_| QualificationError::Serialization) + } + + pub fn from_json_limited(reader: R) -> Result { + let mut bytes = Vec::with_capacity(MAX_RECEIPT_BYTES + 1); + reader + .take((MAX_RECEIPT_BYTES + 1) as u64) + .read_to_end(&mut bytes) + .map_err(|_| QualificationError::Serialization)?; + if bytes.len() > MAX_RECEIPT_BYTES { + return Err(QualificationError::ReceiptTooLarge); + } + serde_json::from_slice(&bytes).map_err(|_| QualificationError::InvalidReceipt) + } + + fn calculate_sha256(&self) -> Result { + let unsigned = UnsignedReceipt { + schema: &self.schema, + authority: &self.authority, + operation: &self.operation, + execution_target: &self.execution_target, + live_tally_observed: self.live_tally_observed, + establishes_tally_support: self.establishes_tally_support, + establishes_tally_capability: self.establishes_tally_capability, + establishes_accounting_correctness: self.establishes_accounting_correctness, + establishes_performance_budget: self.establishes_performance_budget, + runtime_cap_binding: self.runtime_cap_binding, + budget_verdict: &self.budget_verdict, + integrity: &self.integrity, + environment: &self.environment, + corpus: &self.corpus, + samples: &self.samples, + summary: &self.summary, + }; + let encoded = + serde_json::to_vec(&unsigned).map_err(|_| QualificationError::Serialization)?; + let mut digest = Sha256::new(); + digest.update(b"bridge.tally.synthetic-qualification-receipt/1\0"); + digest.update(encoded); + Ok(hex::encode(digest.finalize())) + } +} + +fn summarize(samples: &[SampleEvidence]) -> Result { + if samples.is_empty() || samples.len() > 25 { + return Err(QualificationError::InvalidReceipt); + } + let mut elapsed = samples + .iter() + .map(|sample| sample.elapsed_ns) + .collect::>(); + elapsed.sort_unstable(); + let middle = elapsed.len() / 2; + let median = if elapsed.len() % 2 == 0 { + elapsed[middle - 1] + .checked_add(elapsed[middle]) + .ok_or(QualificationError::InvalidReceipt)? + / 2 + } else { + elapsed[middle] + }; + let (p95, method) = if elapsed.len() >= 20 { + let rank = (95 * elapsed.len()).div_ceil(100).saturating_sub(1); + (Some(elapsed[rank]), Some("nearest_rank".to_owned())) + } else { + (None, None) + }; + Ok(SummaryEvidence { + sample_count: elapsed.len() as u16, + min_elapsed_ns: elapsed[0], + median_elapsed_ns: median, + max_elapsed_ns: *elapsed.last().expect("samples are not empty"), + p95_elapsed_ns: p95, + quantile_method: method, + }) +} + +fn manifest_sha256(windows: &[WindowEvidence]) -> Result { + let encoded = serde_json::to_vec(windows).map_err(|_| QualificationError::Serialization)?; + let mut digest = Sha256::new(); + digest.update(b"bridge.tally.synthetic-corpus-manifest/1\0"); + digest.update(encoded); + Ok(hex::encode(digest.finalize())) +} + +pub fn window_manifest_sha256(windows: &[WindowEvidence]) -> Result { + manifest_sha256(windows) +} + +fn semantic_output_sha256(windows: &[WindowEvidence]) -> Result { + let mut digest = Sha256::new(); + digest.update(b"bridge.tally.synthetic-corpus-semantics/1\0"); + for window in windows { + if !is_sha256(&window.expected_semantic_sha256) { + return Err(QualificationError::InvalidCorpus); + } + digest.update(window.expected_semantic_sha256.as_bytes()); + } + Ok(hex::encode(digest.finalize())) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn memory_method_matches_os(memory: &MemoryEvidence, target_os: &str) -> bool { + match memory.method.as_deref() { + None => memory.unavailable_reason.is_some(), + Some("windows_get_process_memory_info_peak_working_set_bytes") => target_os == "windows", + Some("macos_getrusage_ru_maxrss_bytes") => target_os == "macos", + Some("linux_getrusage_ru_maxrss_kib_normalized_to_bytes") => target_os == "linux", + Some(_) => false, + } +} + +fn is_commit(value: &str) -> bool { + matches!(value.len(), 40 | 64) && is_lower_hex(value) +} + +fn is_lower_hex(value: &str) -> bool { + value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +#[derive(Debug, Error)] +pub enum QualificationError { + #[error("synthetic corpus evidence was invalid")] + InvalidCorpus, + #[error("qualification environment evidence was invalid")] + InvalidEnvironment, + #[error("qualification receipt was invalid")] + InvalidReceipt, + #[error("qualification receipt exceeded its size limit")] + ReceiptTooLarge, + #[error("qualification evidence serialization failed")] + Serialization, +} + +#[cfg(test)] +mod tests { + use super::*; + + fn corpus_evidence() -> CorpusEvidence { + let scenario = Scenario::CiSmoke; + let spec = scenario.corpus(7); + let windows = (0..spec.window_count().unwrap()) + .map(|index| { + let (_, generated) = tally_protocol_simulator::generate_voucher_window( + Vec::new(), + spec.window(index).unwrap(), + ) + .unwrap(); + generated.into() + }) + .collect(); + CorpusEvidence::from_windows(scenario, spec, windows).unwrap() + } + + fn sample(ordinal: u16, elapsed_ns: u64) -> SampleEvidence { + SampleEvidence { + ordinal, + elapsed_ns, + parsed_records: 50, + parsed_ledger_entries: 100, + output_sha256: corpus_evidence().expected_output_sha256, + outcome: "passed".to_owned(), + memory: MemoryEvidence::unavailable("unsupported_test_platform"), + } + } + + #[test] + fn receipt_is_checksum_bound_and_cannot_claim_live_tally() { + let receipt = QualificationReceipt::build( + EnvironmentEvidence::current().unwrap(), + corpus_evidence(), + vec![sample(0, 10), sample(1, 20), sample(2, 30)], + ) + .unwrap(); + assert!(!receipt.live_tally_observed); + assert!(!receipt.establishes_tally_support); + assert!(!receipt.establishes_performance_budget); + receipt.validate().unwrap(); + + let mut tampered = receipt; + tampered.samples[0].parsed_records = 49; + assert!(tampered.validate().is_err()); + } + + #[test] + fn p95_requires_twenty_samples_and_uses_nearest_rank() { + let few = (0..19) + .map(|index| sample(index, u64::from(index + 1))) + .collect::>(); + assert_eq!(summarize(&few).unwrap().p95_elapsed_ns, None); + let enough = (0..20) + .map(|index| sample(index, u64::from(index + 1))) + .collect::>(); + let summary = summarize(&enough).unwrap(); + assert_eq!(summary.p95_elapsed_ns, Some(19)); + assert_eq!(summary.quantile_method.as_deref(), Some("nearest_rank")); + } + + #[test] + fn receipt_fields_do_not_accept_paths_or_arbitrary_features() { + let receipt = QualificationReceipt::build( + EnvironmentEvidence::current().unwrap(), + corpus_evidence(), + vec![sample(0, 1)], + ) + .unwrap(); + let text = String::from_utf8(receipt.to_pretty_json().unwrap()).unwrap(); + for forbidden in ["C:\\Users\\", "/Users/", "@example", "GSTIN", "PAN"] { + assert!(!text.contains(forbidden)); + } + } + + #[test] + fn manifest_rejects_reordering_and_response_limit_overflow() { + let valid = corpus_evidence(); + let mut reordered = valid.clone(); + reordered.windows[0].ordinal = 1; + assert!(reordered.validate().is_err()); + let mut oversized = valid; + oversized.windows[0].wire_bytes = RESPONSE_LIMIT_BYTES + 1; + assert!(oversized.validate().is_err()); + + let mut wrong_scenario = corpus_evidence(); + wrong_scenario.scenario = Scenario::Small1k; + assert!(wrong_scenario.validate().is_err()); + + let mut wrong_entries = corpus_evidence(); + wrong_entries.windows[0].ledger_entries -= 1; + assert!(wrong_entries.validate().is_err()); + + let mut forged_semantics = corpus_evidence(); + forged_semantics.windows[0].expected_semantic_sha256 = "f".repeat(64); + forged_semantics.expected_output_sha256 = + semantic_output_sha256(&forged_semantics.windows).unwrap(); + assert!(forged_semantics.validate().is_err()); + } + + #[test] + fn receipt_deserialization_rejects_unknown_fields() { + let receipt = QualificationReceipt::build( + EnvironmentEvidence::current().unwrap(), + corpus_evidence(), + vec![sample(0, 1)], + ) + .unwrap(); + let encoded = receipt.to_pretty_json().unwrap(); + QualificationReceipt::from_json_limited(std::io::Cursor::new(encoded)).unwrap(); + let mut value = serde_json::to_value(receipt).unwrap(); + value["establishes_tally_support"] = serde_json::json!(true); + assert!(serde_json::from_value::(value.clone()).is_err()); + value["establishes_tally_support"] = serde_json::json!(false); + value.as_object_mut().unwrap().insert( + "local_path".to_owned(), + serde_json::json!("C:\\Users\\person"), + ); + assert!(serde_json::from_value::(value).is_err()); + assert!(matches!( + QualificationReceipt::from_json_limited( + std::io::repeat(0x20).take((MAX_RECEIPT_BYTES + 1) as u64) + ), + Err(QualificationError::ReceiptTooLarge) + )); + } + + #[test] + fn memory_method_must_match_the_receipt_os() { + let wrong_method = if std::env::consts::OS == "windows" { + "macos_getrusage_ru_maxrss_bytes" + } else { + "windows_get_process_memory_info_peak_working_set_bytes" + }; + let mut evidence = sample(0, 1); + evidence.memory = MemoryEvidence { + lifetime_peak_resident_bytes: Some(2), + baseline_lifetime_peak_resident_bytes: Some(1), + lifetime_peak_delta_bytes: Some(1), + method: Some(wrong_method.to_owned()), + unavailable_reason: None, + }; + assert!(QualificationReceipt::build( + EnvironmentEvidence::current().unwrap(), + corpus_evidence(), + vec![evidence], + ) + .is_err()); + } + + #[test] + fn qualification_body_limit_is_inclusive_and_detects_one_extra_byte() { + let exact = read_qualification_body( + std::io::repeat(0x58).take(RESPONSE_LIMIT_BYTES), + Some(RESPONSE_LIMIT_BYTES), + ) + .unwrap(); + assert!(matches!( + exact, + BoundedBodyRead::Accepted(body) if body.len() as u64 == RESPONSE_LIMIT_BYTES + )); + + let overflow = + read_qualification_body(std::io::repeat(0x58).take(RESPONSE_LIMIT_BYTES + 1), None) + .unwrap(); + assert_eq!(overflow, BoundedBodyRead::SizeLimit); + } + + #[test] + fn declared_overflow_is_rejected_before_reading() { + struct PanicReader; + impl std::io::Read for PanicReader { + fn read(&mut self, _: &mut [u8]) -> std::io::Result { + panic!("declared overflow must not read the body") + } + } + assert_eq!( + read_qualification_body(PanicReader, Some(RESPONSE_LIMIT_BYTES + 1)).unwrap(), + BoundedBodyRead::SizeLimit + ); + } +} diff --git a/src-tauri/crates/bridge-tally-qualification/src/main.rs b/src-tauri/crates/bridge-tally-qualification/src/main.rs new file mode 100644 index 0000000..c829c97 --- /dev/null +++ b/src-tauri/crates/bridge-tally-qualification/src/main.rs @@ -0,0 +1,637 @@ +use std::{ + fs::{self, File}, + io::{Read, Write}, + path::PathBuf, + process::{Command, ExitCode, Stdio}, + thread, + time::Duration, + time::Instant, +}; + +use bridge_tally_protocol::{ + decode_tally_text_bytes_limited, parse_voucher_source_records_with_evidence, + ParsedSourceIdentityKind, ParsedSourceRecord, TallyVoucher, +}; +use bridge_tally_qualification::{ + read_qualification_body, window_manifest_sha256, BoundedBodyRead, CorpusEvidence, + EnvironmentEvidence, MemoryEvidence, QualificationReceipt, SampleEvidence, Scenario, + WindowEvidence, RESPONSE_LIMIT_BYTES, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tally_protocol_simulator::generate_voucher_window; + +const MAX_CHILD_OUTPUT_BYTES: usize = 64 * 1024; +const MAX_WORKER_INPUT_BYTES: usize = 256 * 1024; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkerInput { + files: Vec, + windows: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct WorkerResult { + elapsed_ns: u64, + parsed_records: u64, + parsed_ledger_entries: u64, + output_sha256: String, + verified_manifest_sha256: String, + memory: MemoryEvidence, +} + +fn main() -> ExitCode { + match run() { + Ok(()) => ExitCode::SUCCESS, + Err(code) => { + eprintln!("bridge_tally_qualification_failed:{code}"); + ExitCode::FAILURE + } + } +} + +fn run() -> Result<(), &'static str> { + let mut args = std::env::args().skip(1); + match args.next().as_deref() { + Some("run") => { + let scenario = args + .next() + .as_deref() + .and_then(Scenario::parse) + .ok_or("invalid_scenario")?; + let output = args.next().map(PathBuf::from).ok_or("missing_output")?; + let seed = args + .next() + .as_deref() + .unwrap_or("1") + .parse::() + .map_err(|_| "invalid_seed")?; + let sample_count = args + .next() + .as_deref() + .unwrap_or("3") + .parse::() + .map_err(|_| "invalid_sample_count")?; + if !(1..=25).contains(&sample_count) { + return Err("invalid_sample_count"); + } + if args.next().is_some() { + return Err("unexpected_argument"); + } + controller(scenario, output, seed, sample_count) + } + Some("worker") => { + let input = args + .next() + .map(PathBuf::from) + .ok_or("missing_worker_input")?; + if args.next().is_some() { + return Err("unexpected_argument"); + } + worker(input) + } + _ => Err("usage_run_scenario_output_seed_samples"), + } +} + +fn controller( + scenario: Scenario, + output: PathBuf, + seed: u64, + sample_count: u16, +) -> Result<(), &'static str> { + let environment = EnvironmentEvidence::current().map_err(|_| "invalid_environment")?; + let spec = scenario.corpus(seed); + spec.validate().map_err(|_| "invalid_corpus")?; + let temp = tempfile::tempdir().map_err(|_| "tempdir_failed")?; + let mut files = Vec::new(); + let mut windows = Vec::new(); + for index in 0..spec.window_count().map_err(|_| "invalid_corpus")? { + let path = temp.path().join(format!("window-{index:06}.xml")); + let file = File::create(&path).map_err(|_| "corpus_create_failed")?; + let (_, generated) = + generate_voucher_window(file, spec.window(index).map_err(|_| "invalid_corpus")?) + .map_err(|_| "corpus_generation_failed")?; + if generated.wire_bytes > RESPONSE_LIMIT_BYTES { + return Err("generated_window_exceeded_response_limit"); + } + files.push(path); + windows.push(generated.into()); + } + let corpus = CorpusEvidence::from_windows(scenario, spec, windows.clone()) + .map_err(|_| "invalid_corpus_evidence")?; + let worker_input = WorkerInput { files, windows }; + let input_path = temp.path().join("worker-input.json"); + let input_bytes = serde_json::to_vec(&worker_input).map_err(|_| "worker_input_failed")?; + if input_bytes.len() > MAX_WORKER_INPUT_BYTES { + return Err("worker_input_too_large"); + } + fs::write(&input_path, input_bytes).map_err(|_| "worker_input_failed")?; + + let executable = std::env::current_exe().map_err(|_| "executable_unavailable")?; + let mut samples = Vec::with_capacity(usize::from(sample_count)); + for ordinal in 0..sample_count { + let child = run_worker(&executable, &input_path, scenario.worker_timeout())?; + if child.stdout.len() > MAX_CHILD_OUTPUT_BYTES + || child.stderr.len() > MAX_CHILD_OUTPUT_BYTES + { + return Err("worker_failed"); + } + if !child.status.success() { + return Err(classify_worker_failure(&child.stderr)); + } + let result: WorkerResult = + serde_json::from_slice(&child.stdout).map_err(|_| "worker_output_invalid")?; + if result.verified_manifest_sha256 != corpus.manifest_sha256 { + return Err("worker_manifest_mismatch"); + } + samples.push(SampleEvidence { + ordinal, + elapsed_ns: result.elapsed_ns, + parsed_records: result.parsed_records, + parsed_ledger_entries: result.parsed_ledger_entries, + output_sha256: result.output_sha256, + outcome: "passed".to_owned(), + memory: result.memory, + }); + } + + let receipt = + QualificationReceipt::build(environment, corpus, samples).map_err(|_| "receipt_invalid")?; + let bytes = receipt + .to_pretty_json() + .map_err(|_| "receipt_serialization_failed")?; + let parent = output + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| std::path::Path::new(".")); + let mut target = + tempfile::NamedTempFile::new_in(parent).map_err(|_| "receipt_create_failed")?; + target + .write_all(&bytes) + .map_err(|_| "receipt_write_failed")?; + target.flush().map_err(|_| "receipt_write_failed")?; + target + .as_file() + .sync_all() + .map_err(|_| "receipt_write_failed")?; + target + .persist(output) + .map_err(|_| "receipt_replace_failed")?; + Ok(()) +} + +fn classify_worker_failure(stderr: &[u8]) -> &'static str { + let Ok(message) = std::str::from_utf8(stderr) else { + return "worker_failed"; + }; + let Some(code) = message + .lines() + .find_map(|line| line.strip_prefix("bridge_tally_qualification_failed:")) + else { + return "worker_failed"; + }; + match code { + "worker_input_read_failed" => "worker_input_read_failed", + "worker_input_too_large" => "worker_input_too_large", + "worker_input_invalid" => "worker_input_invalid", + "window_read_failed" => "window_read_failed", + "window_size_limit" => "window_size_limit", + "window_manifest_mismatch" => "window_manifest_mismatch", + "window_decode_failed" => "window_decode_failed", + "window_parse_failed" => "window_parse_failed", + "parsed_record_count_mismatch" => "parsed_record_count_mismatch", + "parsed_count_overflow" => "parsed_count_overflow", + "parsed_entry_count_mismatch" => "parsed_entry_count_mismatch", + "parsed_semantics_mismatch" => "parsed_semantics_mismatch", + "elapsed_overflow" => "elapsed_overflow", + "zero_elapsed" => "zero_elapsed", + "worker_output_invalid" => "worker_output_invalid", + "worker_output_too_large" => "worker_output_too_large", + "worker_output_failed" => "worker_output_failed", + _ => "worker_failed", + } +} + +fn run_worker( + executable: &std::path::Path, + input_path: &std::path::Path, + timeout: Duration, +) -> Result { + let mut child = Command::new(executable) + .arg("worker") + .arg(input_path) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|_| "worker_spawn_failed")?; + let deadline = Instant::now() + .checked_add(timeout) + .ok_or("worker_timeout_invalid")?; + loop { + if child + .try_wait() + .map_err(|_| "worker_wait_failed")? + .is_some() + { + return child.wait_with_output().map_err(|_| "worker_output_failed"); + } + if Instant::now() >= deadline { + if child.kill().is_err() + && child + .try_wait() + .map_err(|_| "worker_wait_failed")? + .is_none() + { + return Err("worker_termination_failed"); + } + child.wait().map_err(|_| "worker_wait_failed")?; + return Err("worker_timeout"); + } + thread::sleep(Duration::from_millis(10)); + } +} + +fn worker(input_path: PathBuf) -> Result<(), &'static str> { + let mut input_bytes = Vec::with_capacity(MAX_WORKER_INPUT_BYTES + 1); + File::open(input_path) + .map_err(|_| "worker_input_read_failed")? + .take((MAX_WORKER_INPUT_BYTES + 1) as u64) + .read_to_end(&mut input_bytes) + .map_err(|_| "worker_input_read_failed")?; + if input_bytes.len() > MAX_WORKER_INPUT_BYTES { + return Err("worker_input_too_large"); + } + let input: WorkerInput = + serde_json::from_slice(&input_bytes).map_err(|_| "worker_input_invalid")?; + if input.files.len() != input.windows.len() || input.files.is_empty() { + return Err("worker_input_invalid"); + } + let baseline_peak = peak_resident_bytes(); + let started = Instant::now(); + let mut parsed_records = 0_u64; + let mut parsed_ledger_entries = 0_u64; + let verified_manifest_sha256 = + window_manifest_sha256(&input.windows).map_err(|_| "worker_input_invalid")?; + let mut output_digest = Sha256::new(); + output_digest.update(b"bridge.tally.synthetic-corpus-semantics/1\0"); + + for (path, expected) in input.files.iter().zip(&input.windows) { + let file = File::open(path).map_err(|_| "window_read_failed")?; + let bytes = match read_qualification_body(file, Some(expected.wire_bytes)) + .map_err(|_| "window_read_failed")? + { + BoundedBodyRead::Accepted(bytes) => bytes, + BoundedBodyRead::SizeLimit => return Err("window_size_limit"), + }; + let mut payload_digest = Sha256::new(); + payload_digest.update(b"bridge.tally.synthetic-voucher-window/1\0"); + payload_digest.update(&bytes); + if bytes.len() as u64 != expected.wire_bytes + || bytes.len() as u64 > RESPONSE_LIMIT_BYTES + || hex::encode(payload_digest.finalize()) != expected.sha256 + { + return Err("window_manifest_mismatch"); + } + let decoded = decode_tally_text_bytes_limited(&bytes, RESPONSE_LIMIT_BYTES as usize) + .map_err(|_| "window_decode_failed")?; + let parsed = parse_voucher_source_records_with_evidence(&decoded.text) + .map_err(|_| "window_parse_failed")?; + if parsed.records.len() != expected.records as usize { + return Err("parsed_record_count_mismatch"); + } + let mut window_semantics = Sha256::new(); + window_semantics.update(b"bridge.tally.synthetic-voucher-semantics/1\0"); + let mut window_entries = 0_u64; + for (local_index, record) in parsed.records.iter().enumerate() { + parsed_ledger_entries = parsed_ledger_entries + .checked_add(record.record.ledger_entries.len() as u64) + .ok_or("parsed_count_overflow")?; + window_entries = window_entries + .checked_add(record.record.ledger_entries.len() as u64) + .ok_or("parsed_count_overflow")?; + update_semantic_digest( + &mut window_semantics, + expected.first_record + local_index as u64, + record, + ); + } + if window_entries != expected.ledger_entries { + return Err("parsed_entry_count_mismatch"); + } + let window_semantic_sha256 = hex::encode(window_semantics.finalize()); + if window_semantic_sha256 != expected.expected_semantic_sha256 { + return Err("parsed_semantics_mismatch"); + } + output_digest.update(window_semantic_sha256.as_bytes()); + parsed_records = parsed_records + .checked_add(parsed.records.len() as u64) + .ok_or("parsed_count_overflow")?; + } + let elapsed_ns = u64::try_from(started.elapsed().as_nanos()).map_err(|_| "elapsed_overflow")?; + if elapsed_ns == 0 { + return Err("zero_elapsed"); + } + let final_peak = peak_resident_bytes(); + let memory = memory_evidence(baseline_peak, final_peak); + let result = WorkerResult { + elapsed_ns, + parsed_records, + parsed_ledger_entries, + output_sha256: hex::encode(output_digest.finalize()), + verified_manifest_sha256, + memory, + }; + let bytes = serde_json::to_vec(&result).map_err(|_| "worker_output_invalid")?; + if bytes.len() > MAX_CHILD_OUTPUT_BYTES { + return Err("worker_output_too_large"); + } + std::io::stdout() + .write_all(&bytes) + .map_err(|_| "worker_output_failed")?; + Ok(()) +} + +fn update_semantic_digest( + digest: &mut Sha256, + record_index: u64, + source: &ParsedSourceRecord, +) { + semantic_field(digest, b"record_index", record_index.to_string().as_bytes()); + semantic_optional(digest, b"source_id", source.source_id.as_deref()); + semantic_field( + digest, + b"identity_kind", + match source.identity_kind { + Some(ParsedSourceIdentityKind::Guid) => b"guid", + Some(ParsedSourceIdentityKind::RemoteId) => b"remote_id", + Some(ParsedSourceIdentityKind::MasterId) => b"master_id", + None => b"", + }, + ); + semantic_optional(digest, b"guid", source.identities.guid.as_deref()); + semantic_optional(digest, b"remote_id", source.identities.remote_id.as_deref()); + semantic_optional(digest, b"master_id", source.identities.master_id.as_deref()); + semantic_optional(digest, b"alter_id", source.alter_id.as_deref()); + semantic_field( + digest, + b"raw_source_sha256", + source.raw_source_sha256.as_bytes(), + ); + semantic_optional(digest, b"voucher_id", source.record.id.as_deref()); + semantic_optional(digest, b"date", source.record.date.as_deref()); + semantic_optional( + digest, + b"voucher_type", + source.record.voucher_type.as_deref(), + ); + semantic_optional( + digest, + b"voucher_number", + source.record.voucher_number.as_deref(), + ); + semantic_optional( + digest, + b"party_ledger_name", + source.record.party_ledger_name.as_deref(), + ); + semantic_optional_bool(digest, b"cancelled", source.record.cancelled); + semantic_optional_bool(digest, b"optional", source.record.optional); + let ledger_count = source + .record + .ledger_entry_count + .map(|value| value.to_string()); + semantic_optional(digest, b"ledger_entry_count", ledger_count.as_deref()); + for entry in &source.record.ledger_entries { + semantic_field( + digest, + b"entry_index", + entry.entry_index.to_string().as_bytes(), + ); + semantic_field(digest, b"ledger_name", entry.ledger_name.as_bytes()); + semantic_field(digest, b"amount", entry.amount.as_bytes()); + semantic_field( + digest, + b"is_deemed_positive", + if entry.is_deemed_positive { + b"true" + } else { + b"false" + }, + ); + semantic_field( + digest, + b"entry_raw_source_sha256", + entry.raw_source_sha256.as_bytes(), + ); + } +} + +fn semantic_optional(digest: &mut Sha256, label: &[u8], value: Option<&str>) { + semantic_field(digest, label, value.unwrap_or_default().as_bytes()); +} + +fn semantic_optional_bool(digest: &mut Sha256, label: &[u8], value: Option) { + semantic_field( + digest, + label, + match value { + Some(true) => b"true", + Some(false) => b"false", + None => b"", + }, + ); +} + +fn semantic_field(digest: &mut Sha256, label: &[u8], value: &[u8]) { + digest.update((label.len() as u16).to_be_bytes()); + digest.update(label); + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); +} + +fn memory_evidence( + baseline: Option<(u64, &'static str)>, + peak: Option<(u64, &'static str)>, +) -> MemoryEvidence { + match (baseline, peak) { + (Some((baseline, method)), Some((peak, final_method))) + if method == final_method && peak >= baseline => + { + MemoryEvidence { + lifetime_peak_resident_bytes: Some(peak), + baseline_lifetime_peak_resident_bytes: Some(baseline), + lifetime_peak_delta_bytes: Some(peak - baseline), + method: Some(method.to_owned()), + unavailable_reason: None, + } + } + _ => MemoryEvidence::unavailable("platform_peak_resident_measurement_unavailable"), + } +} + +#[cfg(any(unix, test))] +const MACOS_RU_MAXRSS_METHOD: &str = "macos_getrusage_ru_maxrss_bytes"; +#[cfg(any(unix, test))] +const LINUX_RU_MAXRSS_METHOD: &str = "linux_getrusage_ru_maxrss_kib_normalized_to_bytes"; + +#[cfg(any(unix, test))] +fn normalize_ru_maxrss(value: u64, target_os: &str) -> Option<(u64, &'static str)> { + match target_os { + // Darwin reports ru_maxrss in bytes. Multiplying it by 1024 would + // overstate the process-lifetime peak by three orders of magnitude. + "macos" => Some((value, MACOS_RU_MAXRSS_METHOD)), + // Linux reports ru_maxrss in KiB. + "linux" => value + .checked_mul(1024) + .map(|bytes| (bytes, LINUX_RU_MAXRSS_METHOD)), + _ => None, + } +} + +#[cfg(windows)] +fn peak_resident_bytes() -> Option<(u64, &'static str)> { + use std::mem::{size_of, zeroed}; + use windows_sys::Win32::System::{ + ProcessStatus::{GetProcessMemoryInfo, PROCESS_MEMORY_COUNTERS}, + Threading::GetCurrentProcess, + }; + let mut counters: PROCESS_MEMORY_COUNTERS = unsafe { zeroed() }; + counters.cb = size_of::() as u32; + let ok = unsafe { + GetProcessMemoryInfo( + GetCurrentProcess(), + &mut counters, + size_of::() as u32, + ) + }; + (ok != 0).then_some(( + counters.PeakWorkingSetSize as u64, + "windows_get_process_memory_info_peak_working_set_bytes", + )) +} + +#[cfg(unix)] +fn peak_resident_bytes() -> Option<(u64, &'static str)> { + let mut usage = std::mem::MaybeUninit::::zeroed(); + let result = unsafe { libc::getrusage(libc::RUSAGE_SELF, usage.as_mut_ptr()) }; + if result != 0 { + return None; + } + let value = unsafe { usage.assume_init() }.ru_maxrss; + normalize_ru_maxrss(u64::try_from(value).ok()?, std::env::consts::OS) +} + +#[cfg(not(any(unix, windows)))] +fn peak_resident_bytes() -> Option<(u64, &'static str)> { + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ru_maxrss_units_are_normalized_per_operating_system() { + assert_eq!( + normalize_ru_maxrss(4_096, "macos"), + Some((4_096, MACOS_RU_MAXRSS_METHOD)) + ); + assert_eq!( + normalize_ru_maxrss(4_096, "linux"), + Some((4_194_304, LINUX_RU_MAXRSS_METHOD)) + ); + assert_eq!(normalize_ru_maxrss(4_096, "freebsd"), None); + } + + #[test] + fn linux_ru_maxrss_normalization_rejects_overflow() { + assert_eq!(normalize_ru_maxrss(u64::MAX, "linux"), None); + assert_eq!( + normalize_ru_maxrss(u64::MAX, "macos"), + Some((u64::MAX, MACOS_RU_MAXRSS_METHOD)) + ); + } + + #[test] + fn ci_smoke_generated_window_is_consumable_by_reviewed_parser() { + let spec = Scenario::CiSmoke.corpus(7); + let (bytes, generated) = + generate_voucher_window(Vec::new(), spec.window(0).unwrap()).unwrap(); + let decoded = + decode_tally_text_bytes_limited(&bytes, RESPONSE_LIMIT_BYTES as usize).unwrap(); + let parsed = parse_voucher_source_records_with_evidence(&decoded.text).unwrap(); + assert_eq!(parsed.records.len(), generated.record_count as usize); + assert_eq!( + parsed + .records + .iter() + .map(|record| record.record.ledger_entries.len() as u64) + .sum::(), + generated.ledger_entry_count + ); + } + + #[test] + fn large_voucher_alias_is_schema_valid_and_consumable() { + assert_eq!( + Scenario::parse("deep-voucher"), + Some(Scenario::LargeVoucher) + ); + assert_eq!( + Scenario::parse("large-voucher"), + Some(Scenario::LargeVoucher) + ); + let spec = Scenario::LargeVoucher.corpus(7); + assert_eq!(spec.nesting_depth, 0); + let (bytes, generated) = + generate_voucher_window(Vec::new(), spec.window(0).unwrap()).unwrap(); + let decoded = + decode_tally_text_bytes_limited(&bytes, RESPONSE_LIMIT_BYTES as usize).unwrap(); + let parsed = parse_voucher_source_records_with_evidence(&decoded.text).unwrap(); + assert_eq!(parsed.records.len(), 1); + assert_eq!(generated.ledger_entry_count, 256); + assert_eq!(parsed.records[0].record.ledger_entries.len(), 256); + } + + #[test] + fn worker_failure_classification_is_allowlisted() { + assert_eq!( + classify_worker_failure(b"bridge_tally_qualification_failed:window_parse_failed\n"), + "window_parse_failed" + ); + assert_eq!( + classify_worker_failure(b"bridge_tally_qualification_failed:secret-value\n"), + "worker_failed" + ); + assert_eq!(classify_worker_failure(&[0xff]), "worker_failed"); + } + + #[test] + fn maximum_window_manifest_fits_the_dedicated_worker_input_cap() { + let files = (0..500) + .map(|index| { + PathBuf::from(format!( + "synthetic-qualification-temporary-root/window-{index:06}.xml" + )) + }) + .collect::>(); + let windows = (0..500) + .map(|ordinal| WindowEvidence { + ordinal, + first_record: u64::from(ordinal) * 1_000, + records: 1_000, + ledger_entries: 2_000, + wire_bytes: 750_000, + sha256: "a".repeat(64), + expected_semantic_sha256: "b".repeat(64), + }) + .collect::>(); + let encoded = serde_json::to_vec(&WorkerInput { files, windows }).unwrap(); + assert!(encoded.len() > MAX_CHILD_OUTPUT_BYTES); + assert!(encoded.len() <= MAX_WORKER_INPUT_BYTES); + } +} diff --git a/src-tauri/crates/bridge-tally-read-transport/Cargo.toml b/src-tauri/crates/bridge-tally-read-transport/Cargo.toml new file mode 100644 index 0000000..4296515 --- /dev/null +++ b/src-tauri/crates/bridge-tally-read-transport/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "bridge-tally-read-transport" +version = "0.1.0" +description = "Closed read-only HTTP adapter for Bridge Tally qualification profiles" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[features] +default = [] +bills-native-outstandings-probe-transport = ["bridge-tally-protocol/bills-native-outstandings-probe"] + +[dependencies] +bridge-tally-protocol = { path = "../bridge-tally-protocol" } +bridge-tally-transport = { path = "../bridge-tally-transport" } +thiserror = "2" +sha2 = "0.11" + +[dev-dependencies] +tally-protocol-simulator = { path = "../tally-protocol-simulator" } +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/src-tauri/crates/bridge-tally-read-transport/README.md b/src-tauri/crates/bridge-tally-read-transport/README.md new file mode 100644 index 0000000..9420462 --- /dev/null +++ b/src-tauri/crates/bridge-tally-read-transport/README.md @@ -0,0 +1,6 @@ +# Bridge Tally read transport + +This crate is the only network boundary used by the live compatibility reader. +Its public API accepts a sealed `ReadOnlyProfile`; it cannot accept arbitrary +XML, imports, or write requests. The lower-level generic HTTP transport remains +private behind this adapter. diff --git a/src-tauri/crates/bridge-tally-read-transport/src/lib.rs b/src-tauri/crates/bridge-tally-read-transport/src/lib.rs new file mode 100644 index 0000000..418438d --- /dev/null +++ b/src-tauri/crates/bridge-tally-read-transport/src/lib.rs @@ -0,0 +1,405 @@ +//! Typed, loopback-only transport for sealed Tally read profiles. +//! +//! The generic XML POST capability is deliberately private to this crate. A +//! default caller can submit only a production-reviewed [`ReadOnlyProfile`]. +//! A non-default feature adds one separately typed, qualification-only native +//! outstandings candidate without widening that profile enum. + +#[cfg(feature = "bills-native-outstandings-probe-transport")] +use bridge_tally_protocol::bills_native_outstandings_probe::{ + NativeOutstandingsProbeProfileId, SealedNativeLedgerOutstandingsProbe, +}; +use bridge_tally_protocol::{xml_read_profiles::ReadOnlyProfile, TallyTextEncoding}; +#[cfg(feature = "bills-native-outstandings-probe-transport")] +use bridge_tally_transport::TransportPolicy; +use bridge_tally_transport::{ + TallyEndpointConfig, TallyHttpResponse, TallyHttpTransport, TallyTransportError, +}; +#[cfg(feature = "bills-native-outstandings-probe-transport")] +use sha2::{Digest, Sha256}; +#[cfg(feature = "bills-native-outstandings-probe-transport")] +use std::time::Duration; +use thiserror::Error; + +#[cfg(feature = "bills-native-outstandings-probe-transport")] +pub const NATIVE_OUTSTANDINGS_REQUEST_MAX_BYTES: usize = 64 * 1024; +#[cfg(feature = "bills-native-outstandings-probe-transport")] +pub const NATIVE_OUTSTANDINGS_RESPONSE_MAX_BYTES: usize = 1024 * 1024; +#[cfg(feature = "bills-native-outstandings-probe-transport")] +pub const NATIVE_OUTSTANDINGS_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +#[cfg(feature = "bills-native-outstandings-probe-transport")] +const CANDIDATE_V0_TEMPLATE_SHA256: &str = + "bc3b87484adb9a10cc15f6c9042853bb1047278896bcf0f495b93e7e6b428526"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadLoopback { + LocalhostAlias, + Ipv4, + Ipv6, +} + +impl ReadLoopback { + fn host(self) -> &'static str { + match self { + Self::LocalhostAlias => "localhost", + Self::Ipv4 => "127.0.0.1", + Self::Ipv6 => "::1", + } + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +#[error("Tally read-only transport failed ({code})")] +pub struct ReadOnlyTransportError { + code: &'static str, + http_status: Option, +} + +impl ReadOnlyTransportError { + pub fn safe_code(&self) -> &'static str { + self.code + } + + pub fn http_status(&self) -> Option { + self.http_status + } +} + +impl From for ReadOnlyTransportError { + fn from(value: TallyTransportError) -> Self { + let code = value.safe_code(); + let http_status = match value { + TallyTransportError::HttpStatus { status } => Some(status), + _ => None, + }; + Self { code, http_status } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReadOnlyResponse { + inner: TallyHttpResponse, +} + +impl ReadOnlyResponse { + pub fn text(&self) -> &str { + self.inner.text() + } + + pub fn encoding(&self) -> TallyTextEncoding { + self.inner.encoding() + } + + pub fn encoded_body(&self) -> &[u8] { + self.inner.encoded_body() + } + + pub fn encoded_bytes(&self) -> usize { + self.inner.encoded_bytes() + } + + pub fn http_status(&self) -> u16 { + self.inner.http_status() + } +} + +/// Feature-gated transport for the unobserved native outstandings candidate. +/// It is intentionally separate from [`ReadOnlyProfile`] and accepts no raw +/// string or caller-authored XML. +#[cfg(feature = "bills-native-outstandings-probe-transport")] +#[derive(Clone)] +pub struct QualificationOnlyNativeOutstandingsTransport { + inner: TallyHttpTransport, +} + +#[cfg(feature = "bills-native-outstandings-probe-transport")] +impl QualificationOnlyNativeOutstandingsTransport { + pub fn new(loopback: ReadLoopback, port: u16) -> Result { + let inner = TallyHttpTransport::with_policy( + TallyEndpointConfig { + host: loopback.host().to_string(), + port, + }, + TransportPolicy { + request_timeout: NATIVE_OUTSTANDINGS_REQUEST_TIMEOUT, + status_response_max_bytes: NATIVE_OUTSTANDINGS_RESPONSE_MAX_BYTES, + xml_request_max_bytes: NATIVE_OUTSTANDINGS_REQUEST_MAX_BYTES, + xml_response_max_bytes: NATIVE_OUTSTANDINGS_RESPONSE_MAX_BYTES, + }, + )?; + Ok(Self { inner }) + } + + pub async fn send_candidate_v0( + &self, + candidate: &SealedNativeLedgerOutstandingsProbe, + ) -> Result { + validate_candidate_v0(candidate)?; + let inner = self + .inner + .post_xml(candidate.rendered_xml().to_owned()) + .await?; + Ok(QualificationOnlyNativeOutstandingsResponse { inner }) + } +} + +#[cfg(feature = "bills-native-outstandings-probe-transport")] +#[derive(Clone, PartialEq, Eq)] +pub struct QualificationOnlyNativeOutstandingsResponse { + inner: TallyHttpResponse, +} + +#[cfg(feature = "bills-native-outstandings-probe-transport")] +impl std::fmt::Debug for QualificationOnlyNativeOutstandingsResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("QualificationOnlyNativeOutstandingsResponse") + .field("encoding", &self.encoding()) + .field("encoded_bytes", &self.encoded_body().len()) + .field("decoded_bytes", &self.text().len()) + .field("http_status", &self.http_status()) + .finish() + } +} + +#[cfg(feature = "bills-native-outstandings-probe-transport")] +impl QualificationOnlyNativeOutstandingsResponse { + pub fn text(&self) -> &str { + self.inner.text() + } + + pub fn encoded_body(&self) -> &[u8] { + self.inner.encoded_body() + } + + pub fn encoding(&self) -> TallyTextEncoding { + self.inner.encoding() + } + + pub fn http_status(&self) -> u16 { + self.inner.http_status() + } +} + +#[cfg(feature = "bills-native-outstandings-probe-transport")] +fn validate_candidate_v0( + candidate: &SealedNativeLedgerOutstandingsProbe, +) -> Result<(), ReadOnlyTransportError> { + if candidate.profile_id() != NativeOutstandingsProbeProfileId::LedgerOutstandingsCandidateV0 { + return Err(error("native_outstandings_candidate_profile_changed")); + } + if candidate.template_sha256() != CANDIDATE_V0_TEMPLATE_SHA256 { + return Err(error("native_outstandings_candidate_template_changed")); + } + let request = candidate.rendered_xml().as_bytes(); + if request.is_empty() || request.len() > NATIVE_OUTSTANDINGS_REQUEST_MAX_BYTES { + return Err(error("native_outstandings_candidate_request_size_invalid")); + } + if sha256_hex(request) != candidate.request_sha256() { + return Err(error("native_outstandings_candidate_request_changed")); + } + Ok(()) +} + +#[cfg(feature = "bills-native-outstandings-probe-transport")] +fn error(code: &'static str) -> ReadOnlyTransportError { + ReadOnlyTransportError { + code, + http_status: None, + } +} + +#[cfg(feature = "bills-native-outstandings-probe-transport")] +fn sha256_hex(bytes: &[u8]) -> String { + let mut output = String::with_capacity(64); + for byte in Sha256::digest(bytes) { + use std::fmt::Write as _; + write!(&mut output, "{byte:02x}").expect("writing to a String cannot fail"); + } + output +} + +#[derive(Clone)] +pub struct ReadOnlyTransport { + inner: TallyHttpTransport, +} + +impl ReadOnlyTransport { + pub fn new(loopback: ReadLoopback, port: u16) -> Result { + let inner = TallyHttpTransport::new(TallyEndpointConfig { + host: loopback.host().to_string(), + port, + })?; + Ok(Self { inner }) + } + + pub async fn send( + &self, + profile: ReadOnlyProfile<'_>, + ) -> Result { + let inner = self.inner.post_xml(profile.render()).await?; + Ok(ReadOnlyResponse { inner }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tally_protocol_simulator::{Fixture, ScenarioPlan, Simulator}; + + #[tokio::test] + async fn sends_only_the_rendered_sealed_read_profile() { + let profile = ReadOnlyProfile::CompanyListV1; + let expected = profile.render(); + for attempt in 1..=5 { + let simulator = Simulator::spawn(ScenarioPlan::new(Fixture::EmptyExport)).unwrap(); + let transport = + ReadOnlyTransport::new(ReadLoopback::Ipv4, simulator.address().port()).unwrap(); + match transport.send(profile).await { + Ok(response) => { + assert_eq!(response.http_status(), 200); + let observed = simulator.finish().unwrap(); + assert_eq!(observed.method, "POST"); + assert_eq!(observed.path, "/"); + assert!(observed.request_processed); + assert!(observed.bytes_received > expected.len()); + return; + } + Err(error) if attempt < 5 && error.safe_code() == "request_failed" => { + // Windows endpoint-security software can abort a newly opened synthetic + // loopback flow before the request reaches the listener. Recreate only the + // test fixture; the production read transport remains single-attempt. + drop(simulator); + } + Err(error) => panic!("sealed read fixture failed: {error:?}"), + } + } + unreachable!("bounded fixture attempts always return") + } +} + +#[cfg(all(test, feature = "bills-native-outstandings-probe-transport"))] +mod native_outstandings_tests { + use super::*; + use bridge_tally_protocol::bills_native_outstandings_probe::{ + NativeLedgerOutstandingsProbeScope, ValidatedProbeCompanyName, ValidatedProbeLedgerName, + ValidatedProbeToDate, + }; + use tally_protocol_simulator::{ + Fixture, ResponseFraming, ScenarioPlan, Simulator, WireEncoding, + }; + + fn candidate() -> SealedNativeLedgerOutstandingsProbe { + NativeLedgerOutstandingsProbeScope::new( + ValidatedProbeCompanyName::new("BRIDGE SYNTHETIC BOOK").unwrap(), + ValidatedProbeLedgerName::new("BRIDGE PARTY").unwrap(), + ValidatedProbeToDate::new("20260402").unwrap(), + ) + .seal() + } + + async fn deterministic_failure_fixture( + plan: ScenarioPlan, + ) -> (Simulator, ReadOnlyTransportError) { + const MAX_HOST_ABORT_ATTEMPTS: usize = 3; + for attempt in 1..=MAX_HOST_ABORT_ATTEMPTS { + let simulator = Simulator::spawn(plan.clone()).unwrap(); + let transport = QualificationOnlyNativeOutstandingsTransport::new( + ReadLoopback::Ipv4, + simulator.address().port(), + ) + .unwrap(); + let failure = transport.send_candidate_v0(&candidate()).await.unwrap_err(); + if attempt < MAX_HOST_ABORT_ATTEMPTS && failure.safe_code() == "request_failed" { + // Windows endpoint-security software can abort a newly opened + // loopback flow before a response exists. Recreate only this + // synthetic read-only fixture; production retry remains off. + drop(simulator); + continue; + } + return (simulator, failure); + } + unreachable!("bounded synthetic attempts always return") + } + + #[tokio::test] + async fn dispatches_exact_frozen_candidate_once_and_retains_exact_response() { + let response_xml = "opaque-π"; + let plan = ScenarioPlan::new(Fixture::SyntheticXml(response_xml.to_string())) + .with_encoding(WireEncoding::Utf16Le); + let expected_response = plan.response_bytes(); + let simulator = Simulator::spawn(plan).unwrap(); + let transport = QualificationOnlyNativeOutstandingsTransport::new( + ReadLoopback::Ipv4, + simulator.address().port(), + ) + .unwrap(); + let candidate = candidate(); + let response = transport.send_candidate_v0(&candidate).await.unwrap(); + assert_eq!(response.http_status(), 200); + assert_eq!(response.text(), response_xml); + assert_eq!(response.encoded_body(), expected_response); + + let observed = simulator.finish().unwrap(); + assert_eq!(observed.method, "POST"); + assert_eq!(observed.path, "/"); + assert_eq!(observed.request_body_bytes, candidate.rendered_xml().len()); + assert_eq!(observed.request_body_sha256, candidate.request_sha256()); + assert!(observed.request_processed); + } + + #[tokio::test] + async fn qualification_caps_reject_oversized_response_without_retry() { + let simulator = Simulator::spawn(ScenarioPlan::new(Fixture::Oversized { + minimum_bytes: NATIVE_OUTSTANDINGS_RESPONSE_MAX_BYTES + 1, + })) + .unwrap(); + let transport = QualificationOnlyNativeOutstandingsTransport::new( + ReadLoopback::Ipv4, + simulator.address().port(), + ) + .unwrap(); + let error = transport.send_candidate_v0(&candidate()).await.unwrap_err(); + assert_eq!(error.safe_code(), "response_size_limit_exceeded"); + let observed = simulator.finish().unwrap(); + assert_eq!(observed.request_body_sha256, candidate().request_sha256()); + } + + #[tokio::test] + async fn qualification_transport_does_not_follow_redirects_or_retry_failures() { + for (plan, expected_status) in [ + ( + ScenarioPlan::new(Fixture::SyntheticXml("".to_string())) + .with_http_status(302) + .with_redirect_location("/must-not-be-followed"), + Some(302), + ), + ( + ScenarioPlan::new(Fixture::SyntheticXml("".to_string())) + .with_http_status(500), + Some(500), + ), + ( + ScenarioPlan::new(Fixture::SyntheticXml("".to_string())) + .with_framing(ResponseFraming::DeclaredContentLength { bytes: 4096 }), + None, + ), + ] { + let (simulator, failure) = deterministic_failure_fixture(plan).await; + assert_eq!(failure.http_status(), expected_status); + let observed = simulator.finish().unwrap(); + assert_eq!(observed.method, "POST"); + assert_eq!(observed.path, "/"); + assert_eq!(observed.request_body_sha256, candidate().request_sha256()); + } + } + + #[test] + fn qualification_policy_is_fixed_and_candidate_is_bounded() { + let candidate = candidate(); + assert!(candidate.rendered_xml().len() <= NATIVE_OUTSTANDINGS_REQUEST_MAX_BYTES); + assert_eq!(NATIVE_OUTSTANDINGS_RESPONSE_MAX_BYTES, 1024 * 1024); + assert_eq!(NATIVE_OUTSTANDINGS_REQUEST_TIMEOUT, Duration::from_secs(20)); + validate_candidate_v0(&candidate).unwrap(); + } +} diff --git a/src-tauri/crates/bridge-tally-runtime/Cargo.toml b/src-tauri/crates/bridge-tally-runtime/Cargo.toml new file mode 100644 index 0000000..beccb70 --- /dev/null +++ b/src-tauri/crates/bridge-tally-runtime/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bridge-tally-runtime" +version = "0.1.0" +description = "Portable read-only endpoint execution control plane for Bridge Tally" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[dependencies] +bridge-tally-observability = { path = "../bridge-tally-observability" } +tokio = { version = "1", features = ["macros", "sync", "time"] } +tokio-util = { version = "0.7", features = ["rt"] } + +[dev-dependencies] +tokio = { version = "1", features = ["macros", "rt-multi-thread"] } diff --git a/src-tauri/crates/bridge-tally-runtime/README.md b/src-tauri/crates/bridge-tally-runtime/README.md new file mode 100644 index 0000000..0c8ce1a --- /dev/null +++ b/src-tauri/crates/bridge-tally-runtime/README.md @@ -0,0 +1,12 @@ +# Bridge Tally portable runtime + +This crate owns the read-side endpoint execution control plane independently of +Tauri, SQLCipher, and the native application. It provides per-endpoint +serialization, queue deadlines, cancellation, request spacing, circuit +admission, deterministic bounded transient-read retry, and fixed-cardinality +privacy-reduced observations. + +The public operation enum contains no import or write variant. The generic +closure is an integration seam rather than proof about the closure's behavior; +native dependency and source checks must continue to ensure that write paths do +not call the read retry API. diff --git a/src-tauri/crates/bridge-tally-runtime/src/lib.rs b/src-tauri/crates/bridge-tally-runtime/src/lib.rs new file mode 100644 index 0000000..85a57d4 --- /dev/null +++ b/src-tauri/crates/bridge-tally-runtime/src/lib.rs @@ -0,0 +1,1241 @@ +//! Portable execution control for read-only Tally operations. +//! +//! This crate authenticates no endpoint and establishes no accounting or +//! support claim. It controls local execution and emits only fixed-cardinality, +//! privacy-reduced observations. + +use std::{ + collections::{hash_map::DefaultHasher, HashMap}, + fmt, + future::Future, + hash::{Hash, Hasher}, + sync::{Arc, Mutex}, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, +}; + +use bridge_tally_observability::{ + AttemptObservation, ObservationSink, RequestClass, ResponseOutcome, TelemetryCollector, +}; +pub use bridge_tally_observability::{BodyBytesObservation, CircuitRejectReason}; +use tokio::sync::{Mutex as AsyncMutex, MutexGuard}; +use tokio_util::sync::CancellationToken; + +const MAX_ENDPOINT_IDENTITY_BYTES: usize = 512; +const MAX_QUEUE_DEADLINE: Duration = Duration::from_secs(120); +const MAX_REQUEST_SPACING: Duration = Duration::from_secs(10); +const MAX_CIRCUIT_COOLDOWN: Duration = Duration::from_secs(10 * 60); +const MAX_RETRY_DELAY: Duration = Duration::from_secs(60); +pub const TELEMETRY_PREVIEW_SCHEMA: &str = bridge_tally_observability::PREVIEW_SCHEMA; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct EndpointIdentity(String); + +impl EndpointIdentity { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.is_empty() + || value.len() > MAX_ENDPOINT_IDENTITY_BYTES + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(RuntimeConfigurationError::EndpointIdentityInvalid); + } + Ok(Self(value)) + } + + fn private_value(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for EndpointIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("EndpointIdentity([redacted])") + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadOperation { + Status, + Capability, + CompanyList, + MasterExport, + VoucherExport, + ReportExport, + OtherRead, +} + +impl ReadOperation { + pub const fn request_class(self) -> RequestClass { + match self { + Self::Status => RequestClass::Status, + Self::Capability => RequestClass::Capability, + Self::CompanyList => RequestClass::CompanyList, + Self::MasterExport => RequestClass::MasterExport, + Self::VoucherExport => RequestClass::VoucherExport, + Self::ReportExport => RequestClass::ReportExport, + Self::OtherRead => RequestClass::OtherRead, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ReadFailureClass { + Connection, + RequestTimeout, + RequestFailed, + HttpServer, + RateLimited, + HttpClient, + SizeLimit, + Decode, + Application, + Parse, + Validation, + CompanyMismatch, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EndpointCircuitState { + Closed, + Open, + HalfOpen, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EndpointRuntimeSnapshot { + pub consecutive_failures: u32, + pub circuit_state: EndpointCircuitState, + pub circuit_retry_after_unix_ms: Option, + pub half_open_probe_in_flight: bool, + pub last_failure_unix_ms: Option, +} + +impl ReadFailureClass { + pub const fn retryable(self) -> bool { + matches!( + self, + Self::Connection + | Self::RequestTimeout + | Self::RequestFailed + | Self::HttpServer + | Self::RateLimited + ) + } + + const fn response_outcome(self) -> ResponseOutcome { + match self { + Self::Connection | Self::RequestFailed => ResponseOutcome::Transport, + Self::RequestTimeout => ResponseOutcome::Timeout, + Self::HttpServer | Self::RateLimited | Self::HttpClient => ResponseOutcome::HttpStatus, + Self::SizeLimit => ResponseOutcome::SizeLimit, + Self::Decode => ResponseOutcome::Decode, + Self::Application => ResponseOutcome::Application, + Self::Parse => ResponseOutcome::Parse, + Self::Validation | Self::CompanyMismatch => ResponseOutcome::Validation, + } + } + + const fn circuit_outcome(self) -> CircuitOutcome { + match self { + Self::Connection + | Self::RequestTimeout + | Self::RequestFailed + | Self::HttpServer + | Self::RateLimited => CircuitOutcome::TransportFailure, + Self::HttpClient + | Self::SizeLimit + | Self::Decode + | Self::Application + | Self::Parse + | Self::Validation + | Self::CompanyMismatch => CircuitOutcome::ApplicationRejected, + } + } +} + +pub enum ReadAttempt { + Success { + value: T, + observed_body_bytes: BodyBytesObservation, + }, + Failure { + error: E, + class: ReadFailureClass, + observed_body_bytes: BodyBytesObservation, + }, +} + +impl ReadAttempt { + fn observation(&self) -> (ResponseOutcome, BodyBytesObservation) { + match self { + Self::Success { + observed_body_bytes, + .. + } => (ResponseOutcome::Success, *observed_body_bytes), + Self::Failure { + class, + observed_body_bytes, + .. + } => (class.response_outcome(), *observed_body_bytes), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReadRetryPolicy { + maximum_attempts: u8, + base_delay: Duration, + maximum_delay: Duration, + jitter_percent: u8, +} + +impl ReadRetryPolicy { + pub const SINGLE_ATTEMPT: Self = Self { + maximum_attempts: 1, + base_delay: Duration::ZERO, + maximum_delay: Duration::ZERO, + jitter_percent: 0, + }; + + pub fn transient_default() -> Self { + Self { + maximum_attempts: 3, + base_delay: Duration::from_millis(250), + maximum_delay: Duration::from_secs(2), + jitter_percent: 20, + } + } + + pub fn new( + maximum_attempts: u8, + base_delay: Duration, + maximum_delay: Duration, + jitter_percent: u8, + ) -> Result { + if maximum_attempts == 0 + || maximum_attempts > 5 + || base_delay > maximum_delay + || maximum_delay > MAX_RETRY_DELAY + || jitter_percent > 25 + { + return Err(RuntimeConfigurationError::RetryPolicyInvalid); + } + Ok(Self { + maximum_attempts, + base_delay, + maximum_delay, + jitter_percent, + }) + } + + pub const fn maximum_attempts(self) -> u8 { + self.maximum_attempts + } + + fn delay_after_failure(self, completed_attempt: u8, entropy: u64) -> Duration { + if self.base_delay.is_zero() || completed_attempt >= self.maximum_attempts { + return Duration::ZERO; + } + let exponent = u32::from(completed_attempt.saturating_sub(1)); + let multiplier = 1_u32.checked_shl(exponent).unwrap_or(u32::MAX); + let base = self + .base_delay + .saturating_mul(multiplier) + .min(self.maximum_delay); + let jitter_ceiling = base + .as_millis() + .saturating_mul(u128::from(self.jitter_percent)) + / 100; + let jitter = if jitter_ceiling == 0 { + 0 + } else { + u128::from(entropy) % (jitter_ceiling + 1) + }; + base.saturating_add(Duration::from_millis( + u64::try_from(jitter).unwrap_or(u64::MAX), + )) + .min(MAX_RETRY_DELAY) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RuntimePolicy { + pub queue_deadline: Duration, + pub request_spacing: Duration, + pub circuit_failure_threshold: u32, + pub circuit_cooldown: Duration, + pub maximum_endpoint_sessions: usize, +} + +impl Default for RuntimePolicy { + fn default() -> Self { + Self { + queue_deadline: Duration::from_secs(30), + request_spacing: Duration::from_millis(500), + circuit_failure_threshold: 3, + circuit_cooldown: Duration::from_secs(10), + maximum_endpoint_sessions: 32, + } + } +} + +impl RuntimePolicy { + fn validate(self) -> Result { + if self.queue_deadline.is_zero() + || self.queue_deadline > MAX_QUEUE_DEADLINE + || self.request_spacing > MAX_REQUEST_SPACING + || self.circuit_failure_threshold == 0 + || self.circuit_failure_threshold > 100 + || self.circuit_cooldown.is_zero() + || self.circuit_cooldown > MAX_CIRCUIT_COOLDOWN + || self.maximum_endpoint_sessions == 0 + || self.maximum_endpoint_sessions > 128 + { + return Err(RuntimeConfigurationError::RuntimePolicyInvalid); + } + Ok(self) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeConfigurationError { + EndpointIdentityInvalid, + RetryPolicyInvalid, + RuntimePolicyInvalid, +} + +impl RuntimeConfigurationError { + pub const fn safe_code(self) -> &'static str { + match self { + Self::EndpointIdentityInvalid => "endpoint_identity_invalid", + Self::RetryPolicyInvalid => "read_retry_policy_invalid", + Self::RuntimePolicyInvalid => "endpoint_runtime_policy_invalid", + } + } +} + +impl fmt::Display for RuntimeConfigurationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.safe_code()) + } +} + +impl std::error::Error for RuntimeConfigurationError {} + +#[derive(Debug)] +pub enum ReadExecutionError { + QueueDeadline, + Cancelled, + CircuitRejected { + reason: CircuitRejectReason, + retry_after_unix_ms: Option, + }, + EndpointSessionLimit, + Attempt(E), +} + +impl ReadExecutionError { + pub const fn safe_code(&self) -> &'static str { + match self { + Self::QueueDeadline => "endpoint_queue_deadline_exceeded", + Self::Cancelled => "read_request_cancelled", + Self::CircuitRejected { + reason: CircuitRejectReason::Cooldown, + .. + } => "endpoint_circuit_cooldown", + Self::CircuitRejected { + reason: CircuitRejectReason::HalfOpenProbeInFlight, + .. + } => "endpoint_half_open_probe_in_flight", + Self::EndpointSessionLimit => "endpoint_session_limit_in_use", + Self::Attempt(_) => "read_attempt_failed", + } + } + + pub fn into_attempt_error(self) -> Option { + match self { + Self::Attempt(error) => Some(error), + _ => None, + } + } +} + +struct GateState { + next_request_not_before: Option, +} + +struct SpacingGuard<'a> { + state: MutexGuard<'a, GateState>, + spacing: Duration, +} + +impl Drop for SpacingGuard<'_> { + fn drop(&mut self) { + self.state.next_request_not_before = Some(Instant::now() + self.spacing); + } +} + +#[derive(Default)] +struct CircuitState { + consecutive_failures: u32, + last_failure_unix_ms: Option, + half_open_probe_in_flight: bool, +} + +struct CircuitBreaker { + state: Mutex, + threshold: u32, + cooldown: Duration, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CircuitOutcome { + TransportSuccess, + TransportFailure, + ApplicationRejected, + Cancelled, +} + +struct CircuitPermit<'a> { + circuit: &'a CircuitBreaker, + half_open: bool, + completed: bool, +} + +impl CircuitBreaker { + fn admit( + &self, + now_unix_ms: i64, + ) -> Result, (CircuitRejectReason, Option)> { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if state.consecutive_failures < self.threshold { + return Ok(CircuitPermit { + circuit: self, + half_open: false, + completed: false, + }); + } + let retry_after = state + .last_failure_unix_ms + .unwrap_or(now_unix_ms) + .saturating_add(duration_millis_i64(self.cooldown)); + if now_unix_ms < retry_after { + return Err((CircuitRejectReason::Cooldown, Some(retry_after))); + } + if state.half_open_probe_in_flight { + return Err((CircuitRejectReason::HalfOpenProbeInFlight, None)); + } + state.half_open_probe_in_flight = true; + Ok(CircuitPermit { + circuit: self, + half_open: true, + completed: false, + }) + } + + fn record(&self, outcome: CircuitOutcome, now_unix_ms: i64) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.half_open_probe_in_flight = false; + match outcome { + CircuitOutcome::TransportSuccess => { + state.consecutive_failures = 0; + } + CircuitOutcome::TransportFailure => { + state.consecutive_failures = state.consecutive_failures.saturating_add(1); + state.last_failure_unix_ms = Some(now_unix_ms); + } + CircuitOutcome::ApplicationRejected | CircuitOutcome::Cancelled => {} + } + } + + fn release_half_open(&self) { + let mut state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + state.half_open_probe_in_flight = false; + } + + fn snapshot(&self, now_unix_ms: i64) -> EndpointRuntimeSnapshot { + let state = self + .state + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let retry_after = state + .last_failure_unix_ms + .map(|last_failure| last_failure.saturating_add(duration_millis_i64(self.cooldown))); + let circuit_state = if state.consecutive_failures < self.threshold { + EndpointCircuitState::Closed + } else if retry_after.is_some_and(|deadline| now_unix_ms < deadline) { + EndpointCircuitState::Open + } else { + EndpointCircuitState::HalfOpen + }; + EndpointRuntimeSnapshot { + consecutive_failures: state.consecutive_failures, + circuit_state, + circuit_retry_after_unix_ms: match circuit_state { + EndpointCircuitState::Open => retry_after, + EndpointCircuitState::Closed | EndpointCircuitState::HalfOpen => None, + }, + half_open_probe_in_flight: state.half_open_probe_in_flight, + last_failure_unix_ms: state.last_failure_unix_ms, + } + } +} + +impl CircuitPermit<'_> { + fn complete(mut self, outcome: CircuitOutcome, now_unix_ms: i64) { + self.circuit.record(outcome, now_unix_ms); + self.completed = true; + } +} + +impl Drop for CircuitPermit<'_> { + fn drop(&mut self) { + if self.half_open && !self.completed { + self.circuit.release_half_open(); + } + } +} + +struct EndpointSession { + gate: AsyncMutex, + circuit: CircuitBreaker, + sequence: std::sync::atomic::AtomicU64, +} + +impl EndpointSession { + fn new(policy: RuntimePolicy) -> Self { + Self { + gate: AsyncMutex::new(GateState { + next_request_not_before: None, + }), + circuit: CircuitBreaker { + state: Mutex::new(CircuitState::default()), + threshold: policy.circuit_failure_threshold, + cooldown: policy.circuit_cooldown, + }, + sequence: std::sync::atomic::AtomicU64::new(0), + } + } +} + +struct SessionSlot { + session: Arc, + last_used: Instant, +} + +#[derive(Clone)] +pub struct PortableReadRuntime { + sessions: Arc>>, + collector: Arc, + policy: RuntimePolicy, +} + +impl fmt::Debug for PortableReadRuntime { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("PortableReadRuntime") + .field("policy", &self.policy) + .finish_non_exhaustive() + } +} + +impl Default for PortableReadRuntime { + fn default() -> Self { + Self::new(RuntimePolicy::default()).expect("default runtime policy is valid") + } +} + +impl PortableReadRuntime { + pub fn new(policy: RuntimePolicy) -> Result { + Self::with_collector(policy, Arc::new(TelemetryCollector::new())) + } + + pub fn with_collector( + policy: RuntimePolicy, + collector: Arc, + ) -> Result { + Ok(Self { + sessions: Arc::new(Mutex::new(HashMap::new())), + collector, + policy: policy.validate()?, + }) + } + + pub fn collector(&self) -> Arc { + Arc::clone(&self.collector) + } + + pub fn endpoint_snapshot( + &self, + endpoint: &EndpointIdentity, + ) -> Option { + let sessions = self + .sessions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + sessions + .get(endpoint) + .map(|slot| slot.session.circuit.snapshot(now_unix_ms())) + } + + pub async fn execute_read( + &self, + endpoint: EndpointIdentity, + operation: ReadOperation, + retry: ReadRetryPolicy, + cancellation: CancellationToken, + mut request: F, + ) -> Result> + where + F: FnMut(u8) -> Fut, + Fut: Future>, + { + let session = self + .session(&endpoint) + .map_err(|()| ReadExecutionError::EndpointSessionLimit)?; + let sequence = session + .sequence + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + .saturating_add(1); + let class = operation.request_class(); + let mut attempt_number = 1_u8; + loop { + let attempt = match self + .run_queued(&session, class, &cancellation, || request(attempt_number)) + .await + { + Ok(attempt) => attempt, + Err(error) => return Err(error), + }; + match attempt { + ReadAttempt::Success { value, .. } => return Ok(value), + ReadAttempt::Failure { error, class, .. } => { + if !class.retryable() || attempt_number >= retry.maximum_attempts { + return Err(ReadExecutionError::Attempt(error)); + } + let entropy = retry_entropy(&endpoint, sequence, attempt_number); + let delay = retry.delay_after_failure(attempt_number, entropy); + attempt_number = attempt_number.saturating_add(1); + if !delay.is_zero() { + tokio::select! { + _ = cancellation.cancelled() => return Err(ReadExecutionError::Cancelled), + _ = tokio::time::sleep(delay) => {} + } + } + } + } + } + } + + fn session(&self, endpoint: &EndpointIdentity) -> Result, ()> { + let mut sessions = self + .sessions + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(slot) = sessions.get_mut(endpoint) { + slot.last_used = Instant::now(); + return Ok(Arc::clone(&slot.session)); + } + if sessions.len() >= self.policy.maximum_endpoint_sessions { + let oldest = sessions + .iter() + .filter(|(_, slot)| Arc::strong_count(&slot.session) == 1) + .min_by_key(|(_, slot)| slot.last_used) + .map(|(identity, _)| identity.clone()); + if let Some(identity) = oldest { + sessions.remove(&identity); + } else { + return Err(()); + } + } + let session = Arc::new(EndpointSession::new(self.policy)); + sessions.insert( + endpoint.clone(), + SessionSlot { + session: Arc::clone(&session), + last_used: Instant::now(), + }, + ); + Ok(session) + } + + async fn run_queued( + &self, + session: &EndpointSession, + class: RequestClass, + cancellation: &CancellationToken, + request: F, + ) -> Result, ReadExecutionError> + where + F: FnOnce() -> Fut, + Fut: Future>, + { + let queued_at = Instant::now(); + let state = tokio::select! { + _ = cancellation.cancelled() => { + self.collector.record_attempt(AttemptObservation::QueueCancelled { + class, + queue_wait: queued_at.elapsed(), + }); + return Err(ReadExecutionError::Cancelled); + } + result = tokio::time::timeout(self.policy.queue_deadline, session.gate.lock()) => { + match result { + Ok(state) => state, + Err(_) => { + self.collector.record_attempt(AttemptObservation::QueueDeadline { + class, + queue_wait: queued_at.elapsed(), + }); + return Err(ReadExecutionError::QueueDeadline); + } + } + } + }; + let queue_wait = queued_at.elapsed(); + if let Some(spacing_wait) = state + .next_request_not_before + .and_then(|not_before| not_before.checked_duration_since(Instant::now())) + { + let remaining = self + .policy + .queue_deadline + .checked_sub(queued_at.elapsed()) + .ok_or_else(|| { + self.collector + .record_attempt(AttemptObservation::QueueDeadline { class, queue_wait }); + ReadExecutionError::QueueDeadline + })?; + let wait = spacing_wait.min(remaining); + tokio::select! { + _ = cancellation.cancelled() => { + self.collector.record_attempt(AttemptObservation::QueueCancelled { + class, + queue_wait, + }); + return Err(ReadExecutionError::Cancelled); + } + _ = tokio::time::sleep(wait) => {} + } + if wait < spacing_wait { + self.collector + .record_attempt(AttemptObservation::QueueDeadline { class, queue_wait }); + return Err(ReadExecutionError::QueueDeadline); + } + } + let permit = match session.circuit.admit(now_unix_ms()) { + Ok(permit) => permit, + Err((reason, retry_after_unix_ms)) => { + self.collector + .record_attempt(AttemptObservation::CircuitRejected { class, reason }); + return Err(ReadExecutionError::CircuitRejected { + reason, + retry_after_unix_ms, + }); + } + }; + let _guard = SpacingGuard { + state, + spacing: self.policy.request_spacing, + }; + let response_started = Instant::now(); + let attempt = tokio::select! { + _ = cancellation.cancelled() => { + self.collector.record_attempt(AttemptObservation::Response { + class, + queue_wait, + outcome: ResponseOutcome::Cancelled, + response_pipeline_elapsed: response_started.elapsed(), + observed_body_bytes: BodyBytesObservation::Unavailable, + }); + permit.complete(CircuitOutcome::Cancelled, now_unix_ms()); + return Err(ReadExecutionError::Cancelled); + } + attempt = request() => attempt, + }; + let (outcome, observed_body_bytes) = attempt.observation(); + self.collector.record_attempt(AttemptObservation::Response { + class, + queue_wait, + outcome, + response_pipeline_elapsed: response_started.elapsed(), + observed_body_bytes, + }); + let circuit_outcome = match &attempt { + ReadAttempt::Success { .. } => CircuitOutcome::TransportSuccess, + ReadAttempt::Failure { class, .. } => class.circuit_outcome(), + }; + permit.complete(circuit_outcome, now_unix_ms()); + Ok(attempt) + } +} + +fn now_unix_ms() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|duration| i64::try_from(duration.as_millis()).ok()) + .unwrap_or(i64::MAX) +} + +fn duration_millis_i64(duration: Duration) -> i64 { + i64::try_from(duration.as_millis()).unwrap_or(i64::MAX) +} + +fn retry_entropy(endpoint: &EndpointIdentity, sequence: u64, attempt: u8) -> u64 { + let mut hasher = DefaultHasher::new(); + endpoint.private_value().hash(&mut hasher); + sequence.hash(&mut hasher); + attempt.hash(&mut hasher); + hasher.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + fn test_runtime(spacing: Duration, threshold: u32) -> PortableReadRuntime { + PortableReadRuntime::new(RuntimePolicy { + queue_deadline: Duration::from_secs(1), + request_spacing: spacing, + circuit_failure_threshold: threshold, + circuit_cooldown: Duration::from_millis(50), + maximum_endpoint_sessions: 4, + }) + .unwrap() + } + + fn endpoint(value: &str) -> EndpointIdentity { + EndpointIdentity::new(value).unwrap() + } + + #[tokio::test] + async fn same_endpoint_serializes_while_distinct_endpoints_are_independent() { + let runtime = test_runtime(Duration::ZERO, 3); + let in_flight = Arc::new(AtomicUsize::new(0)); + let same_max = Arc::new(AtomicUsize::new(0)); + let run = |runtime: PortableReadRuntime, + endpoint: EndpointIdentity, + in_flight: Arc, + maximum: Arc| async move { + runtime + .execute_read( + endpoint, + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| { + let in_flight = Arc::clone(&in_flight); + let maximum = Arc::clone(&maximum); + async move { + let active = in_flight.fetch_add(1, Ordering::SeqCst) + 1; + maximum.fetch_max(active, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(20)).await; + in_flight.fetch_sub(1, Ordering::SeqCst); + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(10), + } + } + }, + ) + .await + }; + let (first, second) = tokio::join!( + run( + runtime.clone(), + endpoint("loopback-a"), + Arc::clone(&in_flight), + Arc::clone(&same_max) + ), + run( + runtime.clone(), + endpoint("loopback-a"), + Arc::clone(&in_flight), + Arc::clone(&same_max) + ) + ); + first.unwrap(); + second.unwrap(); + assert_eq!(same_max.load(Ordering::SeqCst), 1); + + let distinct_max = Arc::new(AtomicUsize::new(0)); + let (first, second) = tokio::join!( + run( + runtime.clone(), + endpoint("loopback-a"), + Arc::clone(&in_flight), + Arc::clone(&distinct_max) + ), + run( + runtime, + endpoint("loopback-b"), + in_flight, + Arc::clone(&distinct_max) + ) + ); + first.unwrap(); + second.unwrap(); + assert_eq!(distinct_max.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn transient_reads_retry_exactly_but_validation_never_retries() { + let runtime = test_runtime(Duration::ZERO, 100); + let attempts = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&attempts); + let result = runtime + .execute_read( + endpoint("retry-endpoint"), + ReadOperation::VoucherExport, + ReadRetryPolicy::new(3, Duration::ZERO, Duration::ZERO, 0).unwrap(), + CancellationToken::new(), + move |_| { + let observed = Arc::clone(&observed); + async move { + let attempt = observed.fetch_add(1, Ordering::SeqCst) + 1; + if attempt < 3 { + ReadAttempt::Failure { + error: "transient", + class: ReadFailureClass::RequestTimeout, + observed_body_bytes: BodyBytesObservation::Unavailable, + } + } else { + ReadAttempt::Success { + value: "ok", + observed_body_bytes: BodyBytesObservation::Observed(12), + } + } + } + }, + ) + .await + .unwrap(); + assert_eq!(result, "ok"); + assert_eq!(attempts.load(Ordering::SeqCst), 3); + + let attempts = Arc::new(AtomicUsize::new(0)); + let observed = Arc::clone(&attempts); + let error = runtime + .execute_read( + endpoint("validation-endpoint"), + ReadOperation::MasterExport, + ReadRetryPolicy::new(3, Duration::ZERO, Duration::ZERO, 0).unwrap(), + CancellationToken::new(), + move |_| { + let observed = Arc::clone(&observed); + async move { + observed.fetch_add(1, Ordering::SeqCst); + ReadAttempt::<(), _>::Failure { + error: "company_mismatch", + class: ReadFailureClass::CompanyMismatch, + observed_body_bytes: BodyBytesObservation::Observed(100), + } + } + }, + ) + .await + .unwrap_err(); + assert!(matches!( + error, + ReadExecutionError::Attempt("company_mismatch") + )); + assert_eq!(attempts.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn cancellation_is_terminal_and_preserves_follow_up_spacing() { + let spacing = Duration::from_millis(60); + let runtime = test_runtime(spacing, 3); + let cancellation = CancellationToken::new(); + let cancel = cancellation.clone(); + let running = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("cancel-endpoint"), + ReadOperation::ReportExport, + ReadRetryPolicy::SINGLE_ATTEMPT, + cancellation, + |_| async { std::future::pending::>().await }, + ) + .await + }) + }; + tokio::time::sleep(Duration::from_millis(10)).await; + cancel.cancel(); + assert!(matches!( + running.await.unwrap(), + Err(ReadExecutionError::Cancelled) + )); + let started = Instant::now(); + runtime + .execute_read( + endpoint("cancel-endpoint"), + ReadOperation::ReportExport, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| async { + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(0), + } + }, + ) + .await + .unwrap(); + assert!(started.elapsed() >= spacing.saturating_sub(Duration::from_millis(10))); + } + + #[tokio::test] + async fn circuit_cooldown_and_single_half_open_probe_are_enforced() { + let runtime = test_runtime(Duration::ZERO, 1); + let fail = || async { + ReadAttempt::<(), _>::Failure { + error: "offline", + class: ReadFailureClass::Connection, + observed_body_bytes: BodyBytesObservation::Unavailable, + } + }; + let _ = runtime + .execute_read( + endpoint("circuit-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| fail(), + ) + .await; + let rejected = runtime + .execute_read( + endpoint("circuit-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| fail(), + ) + .await + .unwrap_err(); + assert!(matches!( + rejected, + ReadExecutionError::CircuitRejected { + reason: CircuitRejectReason::Cooldown, + .. + } + )); + tokio::time::sleep(Duration::from_millis(60)).await; + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let first = { + let runtime = runtime.clone(); + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("circuit-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| { + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + async move { + entered.notify_one(); + release.notified().await; + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(1), + } + } + }, + ) + .await + }) + }; + entered.notified().await; + let second = { + let runtime = runtime.clone(); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("circuit-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| async { + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(1), + } + }, + ) + .await + }) + }; + tokio::task::yield_now().await; + assert!( + !second.is_finished(), + "a second probe must remain serialized" + ); + release.notify_one(); + first.await.unwrap().unwrap(); + second.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn queued_request_cannot_use_a_stale_closed_circuit_admission() { + let runtime = test_runtime(Duration::ZERO, 1); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let first = { + let runtime = runtime.clone(); + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("stale-admission-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + |_| { + let entered = Arc::clone(&entered); + let release = Arc::clone(&release); + async move { + entered.notify_one(); + release.notified().await; + ReadAttempt::<(), _>::Failure { + error: "offline", + class: ReadFailureClass::Connection, + observed_body_bytes: BodyBytesObservation::Unavailable, + } + } + }, + ) + .await + }) + }; + entered.notified().await; + let executed = Arc::new(AtomicUsize::new(0)); + let second = { + let runtime = runtime.clone(); + let executed = Arc::clone(&executed); + tokio::spawn(async move { + runtime + .execute_read( + endpoint("stale-admission-endpoint"), + ReadOperation::CompanyList, + ReadRetryPolicy::SINGLE_ATTEMPT, + CancellationToken::new(), + move |_| { + let executed = Arc::clone(&executed); + async move { + executed.fetch_add(1, Ordering::SeqCst); + ReadAttempt::<_, ()>::Success { + value: (), + observed_body_bytes: BodyBytesObservation::Observed(1), + } + } + }, + ) + .await + }) + }; + tokio::task::yield_now().await; + release.notify_one(); + assert!(matches!( + first.await.unwrap(), + Err(ReadExecutionError::Attempt("offline")) + )); + assert!(matches!( + second.await.unwrap(), + Err(ReadExecutionError::CircuitRejected { + reason: CircuitRejectReason::Cooldown, + .. + }) + )); + assert_eq!(executed.load(Ordering::SeqCst), 0); + } + + #[test] + fn circuit_breaker_rejects_a_concurrent_half_open_permit() { + let breaker = CircuitBreaker { + state: Mutex::new(CircuitState { + consecutive_failures: 1, + last_failure_unix_ms: Some(now_unix_ms().saturating_sub(100)), + half_open_probe_in_flight: false, + }), + threshold: 1, + cooldown: Duration::from_millis(50), + }; + let permit = breaker + .admit(now_unix_ms()) + .expect("first half-open permit"); + assert!(matches!( + breaker.admit(now_unix_ms()), + Err((CircuitRejectReason::HalfOpenProbeInFlight, None)) + )); + drop(permit); + assert!(breaker.admit(now_unix_ms()).is_ok()); + } + + #[tokio::test] + async fn deterministic_runtime_sequence_retries_server_failure_then_succeeds() { + let runtime = test_runtime(Duration::ZERO, 3); + let attempts = Arc::new(AtomicUsize::new(0)); + let observed_attempts = Arc::clone(&attempts); + let xml = runtime + .execute_read( + endpoint("sequence-endpoint"), + ReadOperation::ReportExport, + ReadRetryPolicy::new(2, Duration::ZERO, Duration::ZERO, 0).unwrap(), + CancellationToken::new(), + move |_| { + let observed_attempts = Arc::clone(&observed_attempts); + async move { + if observed_attempts.fetch_add(1, Ordering::SeqCst) == 0 { + ReadAttempt::Failure { + error: "http_server_failure", + class: ReadFailureClass::HttpServer, + observed_body_bytes: BodyBytesObservation::Observed(64), + } + } else { + ReadAttempt::Success { + value: "1", + observed_body_bytes: BodyBytesObservation::Observed(18), + } + } + } + }, + ) + .await + .expect("second deterministic response succeeds"); + assert!(xml.contains("1")); + assert_eq!(attempts.load(Ordering::SeqCst), 2); + } + + #[test] + fn operation_and_retry_surface_are_read_only_and_redacted() { + assert_eq!(ReadOperation::Status.request_class(), RequestClass::Status); + assert!(ReadFailureClass::HttpServer.retryable()); + assert!(!ReadFailureClass::Application.retryable()); + assert!(format!("{:?}", endpoint("sensitive-loopback")).contains("[redacted]")); + assert_eq!( + ReadRetryPolicy::new(0, Duration::ZERO, Duration::ZERO, 0), + Err(RuntimeConfigurationError::RetryPolicyInvalid) + ); + } +} diff --git a/src-tauri/crates/bridge-tally-transport/Cargo.toml b/src-tauri/crates/bridge-tally-transport/Cargo.toml new file mode 100644 index 0000000..adcf958 --- /dev/null +++ b/src-tauri/crates/bridge-tally-transport/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "bridge-tally-transport" +version = "0.1.0" +description = "Portable, loopback-only bounded HTTP transport for Bridge Tally integration" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[dependencies] +bridge-tally-protocol = { path = "../bridge-tally-protocol" } +reqwest = { version = "0.12", features = ["stream"] } +serde = { version = "1", features = ["derive"] } +sha2 = "0.11" +thiserror = "2" + +[dev-dependencies] +tally-protocol-simulator = { path = "../tally-protocol-simulator" } +tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] } diff --git a/src-tauri/crates/bridge-tally-transport/README.md b/src-tauri/crates/bridge-tally-transport/README.md new file mode 100644 index 0000000..2092240 --- /dev/null +++ b/src-tauri/crates/bridge-tally-transport/README.md @@ -0,0 +1,18 @@ +# Bridge Tally transport + +This crate owns Bridge's production XML-over-HTTP boundary for Tally. It is +portable and deliberately has no Tauri, database, credential, or native-library +dependency. + +The transport: + +- accepts only localhost or literal loopback endpoints; +- disables proxy inheritance and redirects; +- applies one whole-request deadline; +- bounds outbound XML and encoded response bytes; +- handles UTF-8 and BOM-marked UTF-16 through `bridge-tally-protocol`; +- returns closed, privacy-safe error codes rather than URLs or response bodies. + +It proves transport behavior only. HTTP success does not establish Tally +application success; callers must still parse the response envelope and require +the appropriate Tally application status and company evidence. diff --git a/src-tauri/crates/bridge-tally-transport/src/lib.rs b/src-tauri/crates/bridge-tally-transport/src/lib.rs new file mode 100644 index 0000000..d4160e8 --- /dev/null +++ b/src-tauri/crates/bridge-tally-transport/src/lib.rs @@ -0,0 +1,653 @@ +//! Portable, loopback-only and bounded HTTP transport for Tally. +//! +//! This crate establishes HTTP delivery and decoding facts only. A successful +//! return is not evidence that Tally accepted an import or completed an export; +//! callers must still validate the application envelope. + +use std::{net::IpAddr, time::Duration}; + +use bridge_tally_protocol::{ + decode_tally_text_bytes_limited, TallyTextDecodeError, TallyTextEncoding, + TallyTextStreamDecoder, +}; +use reqwest::{ + header::{CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE}, + redirect::Policy, + Client, ClientBuilder, Response, Url, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +pub const STATUS_RESPONSE_MAX_BYTES: usize = 1024 * 1024; +pub const XML_REQUEST_MAX_BYTES: usize = 32 * 1024 * 1024; +pub const XML_RESPONSE_MAX_BYTES: usize = 32 * 1024 * 1024; +pub const DEFAULT_REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const MAX_REQUEST_TIMEOUT: Duration = Duration::from_secs(120); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct TallyEndpointConfig { + pub host: String, + pub port: u16, +} + +impl Default for TallyEndpointConfig { + fn default() -> Self { + Self { + host: "localhost".to_owned(), + port: 9000, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TransportPolicy { + pub request_timeout: Duration, + pub status_response_max_bytes: usize, + pub xml_request_max_bytes: usize, + pub xml_response_max_bytes: usize, +} + +impl Default for TransportPolicy { + fn default() -> Self { + Self { + request_timeout: DEFAULT_REQUEST_TIMEOUT, + status_response_max_bytes: STATUS_RESPONSE_MAX_BYTES, + xml_request_max_bytes: XML_REQUEST_MAX_BYTES, + xml_response_max_bytes: XML_RESPONSE_MAX_BYTES, + } + } +} + +impl TransportPolicy { + fn validate(self) -> Result { + if self.request_timeout.is_zero() || self.request_timeout > MAX_REQUEST_TIMEOUT { + return Err(TallyTransportError::PolicyInvalid { + code: "request_timeout_out_of_range", + }); + } + for (value, maximum, code) in [ + ( + self.status_response_max_bytes, + STATUS_RESPONSE_MAX_BYTES, + "status_response_limit_out_of_range", + ), + ( + self.xml_request_max_bytes, + XML_REQUEST_MAX_BYTES, + "xml_request_limit_out_of_range", + ), + ( + self.xml_response_max_bytes, + XML_RESPONSE_MAX_BYTES, + "xml_response_limit_out_of_range", + ), + ] { + if value == 0 || value > maximum { + return Err(TallyTransportError::PolicyInvalid { code }); + } + } + Ok(self) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct TallyHttpResponse { + text: String, + encoding: TallyTextEncoding, + encoded_body: Vec, + encoded_bytes: usize, + http_status: u16, +} + +impl std::fmt::Debug for TallyHttpResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TallyHttpResponse") + .field("encoding", &self.encoding) + .field("encoded_bytes", &self.encoded_bytes) + .field("decoded_bytes", &self.text.len()) + .field("http_status", &self.http_status) + .finish() + } +} + +impl TallyHttpResponse { + pub fn text(&self) -> &str { + &self.text + } + + pub fn into_text(self) -> String { + self.text + } + + pub fn encoding(&self) -> TallyTextEncoding { + self.encoding + } + + /// Exact HTTP entity bytes received before Tally text decoding. + pub fn encoded_body(&self) -> &[u8] { + &self.encoded_body + } + + pub fn encoded_bytes(&self) -> usize { + self.encoded_bytes + } + + pub fn http_status(&self) -> u16 { + self.http_status + } +} + +#[derive(Clone, PartialEq, Eq)] +pub struct TallyDecodedHttpResponse { + text: String, + encoding: TallyTextEncoding, + encoded_bytes: usize, + decoded_bytes: usize, + encoded_sha256: String, + decoded_sha256: String, + http_status: u16, +} + +impl std::fmt::Debug for TallyDecodedHttpResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("TallyDecodedHttpResponse") + .field("encoding", &self.encoding) + .field("encoded_bytes", &self.encoded_bytes) + .field("decoded_bytes", &self.decoded_bytes) + .field("http_status", &self.http_status) + .finish() + } +} + +impl TallyDecodedHttpResponse { + pub fn text(&self) -> &str { + &self.text + } + + pub fn into_text(self) -> String { + self.text + } + + pub fn encoding(&self) -> TallyTextEncoding { + self.encoding + } + + pub fn encoded_bytes(&self) -> usize { + self.encoded_bytes + } + + pub fn decoded_bytes(&self) -> usize { + self.decoded_bytes + } + + pub fn encoded_sha256(&self) -> &str { + &self.encoded_sha256 + } + + pub fn decoded_sha256(&self) -> &str { + &self.decoded_sha256 + } + + pub fn http_status(&self) -> u16 { + self.http_status + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum TallyTransportError { + #[error("Tally endpoint validation failed ({code})")] + EndpointInvalid { code: &'static str }, + #[error("Tally transport policy is invalid ({code})")] + PolicyInvalid { code: &'static str }, + #[error("Tally HTTP client could not be initialized")] + ClientInitializationFailed, + #[error("Tally request exceeded the {limit}-byte limit")] + RequestTooLarge { limit: usize }, + #[error("Tally endpoint did not accept the connection")] + ConnectionFailed, + #[error("Tally request exceeded its deadline")] + RequestTimedOut, + #[error("Tally request failed before a response was available")] + RequestFailed, + #[error("Tally returned HTTP status {status}")] + HttpStatus { status: u16 }, + #[error("Tally response exceeded the {limit}-byte limit")] + ResponseTooLarge { + limit: usize, + declared_by_peer: bool, + }, + #[error("Tally response ended before its declared HTTP body was complete")] + ResponseTruncated, + #[error("Tally response body could not be read")] + ResponseReadFailed, + #[error("Tally response used an unsupported content encoding")] + UnsupportedContentEncoding, + #[error("Tally response encoding was invalid ({code})")] + InvalidEncoding { code: &'static str }, +} + +impl TallyTransportError { + pub fn safe_code(&self) -> &'static str { + match self { + Self::EndpointInvalid { .. } => "endpoint_invalid", + Self::PolicyInvalid { .. } => "transport_policy_invalid", + Self::ClientInitializationFailed => "http_client_initialization_failed", + Self::RequestTooLarge { .. } => "request_size_limit_exceeded", + Self::ConnectionFailed => "endpoint_unreachable", + Self::RequestTimedOut => "request_deadline_exceeded", + Self::RequestFailed => "request_failed", + Self::HttpStatus { .. } => "http_status_failure", + Self::ResponseTooLarge { .. } => "response_size_limit_exceeded", + Self::ResponseTruncated => "response_truncated", + Self::ResponseReadFailed => "response_read_failed", + Self::UnsupportedContentEncoding => "response_content_encoding_unsupported", + Self::InvalidEncoding { .. } => "response_encoding_invalid", + } + } +} + +#[derive(Clone)] +pub struct TallyHttpTransport { + config: TallyEndpointConfig, + policy: TransportPolicy, + client: Client, +} + +impl TallyHttpTransport { + pub fn new(config: TallyEndpointConfig) -> Result { + Self::with_builder(config, TransportPolicy::default(), Client::builder()) + } + + pub fn with_policy( + config: TallyEndpointConfig, + policy: TransportPolicy, + ) -> Result { + Self::with_builder(config, policy, Client::builder()) + } + + /// Test seam that still applies all production proxy, redirect, and timeout + /// controls after the supplied builder customizations. + #[doc(hidden)] + pub fn with_builder( + config: TallyEndpointConfig, + policy: TransportPolicy, + builder: ClientBuilder, + ) -> Result { + endpoint_url(&config, "/")?; + let policy = policy.validate()?; + let client = builder + .no_proxy() + .no_gzip() + .no_brotli() + .no_deflate() + .no_zstd() + .timeout(policy.request_timeout) + .redirect(Policy::none()) + .build() + .map_err(|_| TallyTransportError::ClientInitializationFailed)?; + Ok(Self { + config, + policy, + client, + }) + } + + pub fn canonical_origin(&self) -> Result { + canonical_loopback_origin(&self.config) + } + + pub async fn get_status(&self) -> Result { + let url = endpoint_url(&self.config, "/status")?; + let response = self + .client + .get(url) + .header(CONTENT_TYPE, "text/xml") + .send() + .await + .map_err(classify_request_error)?; + read_response(response, self.policy.status_response_max_bytes).await + } + + pub async fn get_status_decoded( + &self, + ) -> Result { + let url = endpoint_url(&self.config, "/status")?; + let response = self + .client + .get(url) + .header(CONTENT_TYPE, "text/xml") + .send() + .await + .map_err(classify_request_error)?; + read_decoded_response(response, self.policy.status_response_max_bytes).await + } + + pub async fn post_xml(&self, xml: String) -> Result { + if xml.len() > self.policy.xml_request_max_bytes { + return Err(TallyTransportError::RequestTooLarge { + limit: self.policy.xml_request_max_bytes, + }); + } + let content_length = xml.len(); + let url = endpoint_url(&self.config, "/")?; + let response = self + .client + .post(url) + .header(CONTENT_TYPE, "text/xml; charset=utf-8") + .header(CONTENT_LENGTH, content_length) + .body(xml) + .send() + .await + .map_err(classify_request_error)?; + read_response(response, self.policy.xml_response_max_bytes).await + } + + pub async fn post_xml_decoded( + &self, + xml: String, + ) -> Result { + if xml.len() > self.policy.xml_request_max_bytes { + return Err(TallyTransportError::RequestTooLarge { + limit: self.policy.xml_request_max_bytes, + }); + } + let content_length = xml.len(); + let url = endpoint_url(&self.config, "/")?; + let response = self + .client + .post(url) + .header(CONTENT_TYPE, "text/xml; charset=utf-8") + .header(CONTENT_LENGTH, content_length) + .body(xml) + .send() + .await + .map_err(classify_request_error)?; + read_decoded_response(response, self.policy.xml_response_max_bytes).await + } +} + +pub fn canonical_loopback_origin( + config: &TallyEndpointConfig, +) -> Result { + Ok(endpoint_url(config, "/")?.origin().ascii_serialization()) +} + +fn endpoint_url(config: &TallyEndpointConfig, path: &str) -> Result { + let host = config.host.trim(); + if host.is_empty() + || host.len() > 253 + || host.chars().any(char::is_control) + || host.contains(['/', '\\', '?', '#', '@']) + { + return Err(TallyTransportError::EndpointInvalid { + code: "host_syntax_invalid", + }); + } + if config.port == 0 { + return Err(TallyTransportError::EndpointInvalid { + code: "port_out_of_range", + }); + } + + let mut url = + Url::parse("http://localhost").map_err(|_| TallyTransportError::EndpointInvalid { + code: "base_url_invalid", + })?; + if let Ok(ip_address) = host.parse::() { + if !ip_address.is_loopback() { + return Err(TallyTransportError::EndpointInvalid { + code: "non_loopback_forbidden", + }); + } + url.set_ip_host(ip_address) + .map_err(|_| TallyTransportError::EndpointInvalid { + code: "ip_address_invalid", + })?; + } else { + if !host.eq_ignore_ascii_case("localhost") { + return Err(TallyTransportError::EndpointInvalid { + code: "non_loopback_forbidden", + }); + } + let loopback = "127.0.0.1" + .parse::() + .expect("static loopback address is valid"); + url.set_ip_host(loopback) + .map_err(|_| TallyTransportError::EndpointInvalid { + code: "ip_address_invalid", + })?; + } + url.set_port(Some(config.port)) + .map_err(|_| TallyTransportError::EndpointInvalid { + code: "port_out_of_range", + })?; + url.set_path(path); + Ok(url) +} + +async fn read_response( + mut response: Response, + max_bytes: usize, +) -> Result { + let status = validate_response_head(&response, max_bytes)?; + let initial_capacity = response + .content_length() + .and_then(|length| usize::try_from(length).ok()) + .unwrap_or(0) + .min(max_bytes); + let mut bytes = Vec::with_capacity(initial_capacity); + loop { + let chunk = response.chunk().await.map_err(classify_body_error)?; + let Some(chunk) = chunk else { break }; + if bytes.len().saturating_add(chunk.len()) > max_bytes { + return Err(TallyTransportError::ResponseTooLarge { + limit: max_bytes, + declared_by_peer: false, + }); + } + bytes.extend_from_slice(&chunk); + } + + let encoded_bytes = bytes.len(); + let decoded = decode_tally_text_bytes_limited(&bytes, max_bytes).map_err(map_decode_error)?; + if decoded.text.len() > max_bytes { + return Err(TallyTransportError::ResponseTooLarge { + limit: max_bytes, + declared_by_peer: false, + }); + } + Ok(TallyHttpResponse { + text: decoded.text, + encoding: decoded.encoding, + encoded_body: bytes, + encoded_bytes, + http_status: status, + }) +} + +fn validate_response_head( + response: &Response, + max_bytes: usize, +) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(TallyTransportError::HttpStatus { + status: status.as_u16(), + }); + } + let mut content_encodings = response.headers().get_all(CONTENT_ENCODING).iter(); + if let Some(value) = content_encodings.next() { + let exactly_identity = value + .to_str() + .map(|encoding| encoding.trim().eq_ignore_ascii_case("identity")) + .unwrap_or(false); + if !exactly_identity || content_encodings.next().is_some() { + return Err(TallyTransportError::UnsupportedContentEncoding); + } + } + if response + .content_length() + .is_some_and(|length| length > max_bytes as u64) + { + return Err(TallyTransportError::ResponseTooLarge { + limit: max_bytes, + declared_by_peer: true, + }); + } + Ok(status.as_u16()) +} + +async fn read_decoded_response( + mut response: Response, + max_bytes: usize, +) -> Result { + let http_status = validate_response_head(&response, max_bytes)?; + let mut decoder = TallyTextStreamDecoder::new(max_bytes); + let mut encoded_bytes = 0_usize; + let mut encoded_sha256 = Sha256::new(); + loop { + let chunk = response.chunk().await.map_err(classify_body_error)?; + let Some(chunk) = chunk else { break }; + if encoded_bytes.saturating_add(chunk.len()) > max_bytes { + return Err(TallyTransportError::ResponseTooLarge { + limit: max_bytes, + declared_by_peer: false, + }); + } + encoded_bytes += chunk.len(); + encoded_sha256.update(&chunk); + decoder + .push_chunk(&chunk) + .map_err(|error| map_stream_decode_error(error, max_bytes))?; + } + let decoded = decoder + .finish() + .map_err(|error| map_stream_decode_error(error, max_bytes))?; + Ok(TallyDecodedHttpResponse { + text: decoded.text, + encoding: decoded.encoding, + encoded_bytes, + decoded_bytes: decoded.decoded_bytes, + encoded_sha256: hex_digest(encoded_sha256.finalize()), + decoded_sha256: decoded.decoded_sha256, + http_status, + }) +} + +fn hex_digest(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn classify_request_error(error: reqwest::Error) -> TallyTransportError { + if error.is_timeout() { + TallyTransportError::RequestTimedOut + } else if error.is_connect() { + TallyTransportError::ConnectionFailed + } else { + TallyTransportError::RequestFailed + } +} + +fn classify_body_error(error: reqwest::Error) -> TallyTransportError { + if error.is_timeout() { + TallyTransportError::RequestTimedOut + } else if error.is_body() || error.is_decode() { + TallyTransportError::ResponseTruncated + } else { + TallyTransportError::ResponseReadFailed + } +} + +fn map_decode_error(error: TallyTextDecodeError) -> TallyTransportError { + let code = match error { + TallyTextDecodeError::TooLarge => "decoded_body_too_large", + TallyTextDecodeError::InvalidUtf8 => "invalid_utf8", + TallyTextDecodeError::InvalidUtf16Le => "invalid_utf16le", + TallyTextDecodeError::InvalidUtf16Be => "invalid_utf16be", + }; + TallyTransportError::InvalidEncoding { code } +} + +fn map_stream_decode_error(error: TallyTextDecodeError, max_bytes: usize) -> TallyTransportError { + match error { + TallyTextDecodeError::TooLarge => TallyTransportError::ResponseTooLarge { + limit: max_bytes, + declared_by_peer: false, + }, + error => map_decode_error(error), + } +} + +#[cfg(test)] +mod unit_tests { + use super::*; + + #[test] + fn endpoint_identity_normalizes_names_without_collapsing_ip_families() { + for host in ["localhost", "LOCALHOST", "127.0.0.1"] { + assert_eq!( + canonical_loopback_origin(&TallyEndpointConfig { + host: host.to_owned(), + port: 9000, + }) + .expect("valid loopback endpoint"), + "http://127.0.0.1:9000" + ); + } + assert_eq!( + canonical_loopback_origin(&TallyEndpointConfig { + host: "::1".to_owned(), + port: 9000, + }) + .expect("valid IPv6 loopback endpoint"), + "http://[::1]:9000" + ); + } + + #[test] + fn remote_and_url_shaped_hosts_are_rejected() { + for host in [ + "", + "http://localhost", + "localhost/path", + "user@localhost", + "192.168.1.10", + "tally.internal", + ] { + let error = canonical_loopback_origin(&TallyEndpointConfig { + host: host.to_owned(), + port: 9000, + }) + .expect_err("endpoint must fail closed"); + assert_eq!(error.safe_code(), "endpoint_invalid"); + } + } + + #[test] + fn policy_cannot_expand_production_caps_or_deadline() { + let expanded = TransportPolicy { + request_timeout: MAX_REQUEST_TIMEOUT + Duration::from_millis(1), + ..TransportPolicy::default() + }; + assert!(matches!( + expanded.validate(), + Err(TallyTransportError::PolicyInvalid { .. }) + )); + let expanded = TransportPolicy { + xml_response_max_bytes: XML_RESPONSE_MAX_BYTES + 1, + ..TransportPolicy::default() + }; + assert!(matches!( + expanded.validate(), + Err(TallyTransportError::PolicyInvalid { .. }) + )); + } +} diff --git a/src-tauri/crates/bridge-tally-transport/tests/http_transport.rs b/src-tauri/crates/bridge-tally-transport/tests/http_transport.rs new file mode 100644 index 0000000..ad9a9b2 --- /dev/null +++ b/src-tauri/crates/bridge-tally-transport/tests/http_transport.rs @@ -0,0 +1,363 @@ +use std::time::Duration; + +use bridge_tally_protocol::TallyTextEncoding; +use bridge_tally_transport::{ + TallyDecodedHttpResponse, TallyEndpointConfig, TallyHttpResponse, TallyHttpTransport, + TallyTransportError, TransportPolicy, +}; +use sha2::{Digest, Sha256}; +use tally_protocol_simulator::{ + Delivery, Fixture, ResponseContentEncoding, ResponseFraming, ScenarioPlan, Simulator, + WireEncoding, +}; + +fn endpoint(simulator: &Simulator) -> TallyEndpointConfig { + TallyEndpointConfig { + host: simulator.address().ip().to_string(), + port: simulator.address().port(), + } +} + +fn policy(response_limit: usize, timeout: Duration) -> TransportPolicy { + TransportPolicy { + request_timeout: timeout, + status_response_max_bytes: response_limit, + xml_request_max_bytes: response_limit, + xml_response_max_bytes: response_limit, + } +} + +async fn post_synthetic( + plan: ScenarioPlan, + transport_policy: Option, + xml: &str, +) -> (Simulator, Result) { + const MAX_HOST_ABORT_ATTEMPTS: usize = 3; + for attempt in 1..=MAX_HOST_ABORT_ATTEMPTS { + let simulator = Simulator::spawn(plan.clone()).expect("spawn loopback simulator"); + let transport = match transport_policy { + Some(policy) => TallyHttpTransport::with_policy(endpoint(&simulator), policy), + None => TallyHttpTransport::new(endpoint(&simulator)), + } + .expect("build synthetic transport"); + let result = transport.post_xml(xml.to_owned()).await; + if attempt < MAX_HOST_ABORT_ATTEMPTS + && matches!(&result, Err(TallyTransportError::RequestFailed)) + { + // Windows endpoint-security software can occasionally abort a new + // loopback TCP flow before any response exists. Recreate the + // read-only synthetic fixture so the assertion still exercises a + // completed deterministic exchange. Production retry policy is + // tested separately and is not changed by this harness helper. + drop(simulator); + continue; + } + return (simulator, result); + } + unreachable!("bounded synthetic attempts always return") +} + +async fn post_synthetic_decoded( + plan: ScenarioPlan, + transport_policy: Option, + xml: &str, +) -> ( + Simulator, + Result, +) { + const MAX_HOST_ABORT_ATTEMPTS: usize = 3; + for attempt in 1..=MAX_HOST_ABORT_ATTEMPTS { + let simulator = Simulator::spawn(plan.clone()).expect("spawn loopback simulator"); + let transport = match transport_policy { + Some(policy) => TallyHttpTransport::with_policy(endpoint(&simulator), policy), + None => TallyHttpTransport::new(endpoint(&simulator)), + } + .expect("build synthetic decoded transport"); + let result = transport.post_xml_decoded(xml.to_owned()).await; + if attempt < MAX_HOST_ABORT_ATTEMPTS + && matches!(&result, Err(TallyTransportError::RequestFailed)) + { + drop(simulator); + continue; + } + return (simulator, result); + } + unreachable!("bounded synthetic attempts always return") +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[tokio::test] +async fn production_transport_decodes_utf16_across_closed_framing_modes() { + let cases = [ + ( + WireEncoding::Utf16Le, + ResponseFraming::ContentLength, + TallyTextEncoding::Utf16LeBom, + ), + ( + WireEncoding::Utf16Be, + ResponseFraming::Chunked { chunk_bytes: 7 }, + TallyTextEncoding::Utf16BeBom, + ), + ( + WireEncoding::Utf8Bom, + ResponseFraming::ConnectionClose, + TallyTextEncoding::Utf8Bom, + ), + ]; + + for (wire_encoding, framing, expected_encoding) in cases { + let plan = ScenarioPlan::new(Fixture::ExportStatusOne) + .with_encoding(wire_encoding) + .with_framing(framing); + let expected_encoded_body = plan.response_bytes(); + let (simulator, result) = post_synthetic(plan, None, "").await; + let response = result.expect("read encoded synthetic response"); + assert_eq!(response.encoding(), expected_encoding); + assert_eq!(response.http_status(), 200); + assert!(response.text().contains("1")); + assert!(response.encoded_bytes() > 0); + assert_eq!(response.encoded_body(), expected_encoded_body); + let observed = simulator.finish().expect("finish simulator"); + assert_eq!(observed.method, "POST"); + assert_eq!(observed.path, "/"); + assert!(observed.request_processed); + } +} + +#[tokio::test] +async fn decoded_only_transport_streams_all_encodings_without_retaining_wire_body() { + for (encoding, framing, expected_encoding) in [ + ( + WireEncoding::Utf8, + ResponseFraming::ContentLength, + TallyTextEncoding::Utf8, + ), + ( + WireEncoding::Utf8Bom, + ResponseFraming::ConnectionClose, + TallyTextEncoding::Utf8Bom, + ), + ( + WireEncoding::Utf16Le, + ResponseFraming::Chunked { chunk_bytes: 1 }, + TallyTextEncoding::Utf16LeBom, + ), + ( + WireEncoding::Utf16Be, + ResponseFraming::Chunked { chunk_bytes: 3 }, + TallyTextEncoding::Utf16BeBom, + ), + ] { + let plan = ScenarioPlan::new(Fixture::ExportStatusOne) + .with_encoding(encoding) + .with_framing(framing); + let encoded = plan.response_bytes(); + let expected_text = Fixture::ExportStatusOne.body(); + let (simulator, result) = post_synthetic_decoded(plan, None, "").await; + let response = result.expect("stream decoded-only response"); + + assert_eq!(response.text(), expected_text); + assert_eq!(response.encoding(), expected_encoding); + assert_eq!(response.encoded_bytes(), encoded.len()); + assert_eq!(response.decoded_bytes(), expected_text.len()); + assert_eq!(response.encoded_sha256(), sha256_hex(&encoded)); + assert_eq!( + response.decoded_sha256(), + sha256_hex(expected_text.as_bytes()) + ); + assert_eq!(response.http_status(), 200); + assert!(!format!("{response:?}").contains("")); + simulator.finish().expect("finish decoded-only simulator"); + } +} + +#[tokio::test] +async fn decoded_only_transport_enforces_decoded_cap_during_streaming() { + const LIMIT: usize = 100; + let plan = ScenarioPlan::new(Fixture::SyntheticXml("\u{20ac}".repeat(40))) + .with_encoding(WireEncoding::Utf16Le) + .with_framing(ResponseFraming::Chunked { chunk_bytes: 1 }); + assert!(plan.response_bytes().len() <= LIMIT); + let (simulator, result) = + post_synthetic_decoded(plan, Some(policy(LIMIT, Duration::from_secs(2))), "").await; + assert_eq!( + result.expect_err("decoded cap must fail while streaming"), + TallyTransportError::ResponseTooLarge { + limit: LIMIT, + declared_by_peer: false, + } + ); + simulator.finish().expect("finish decoded-cap simulator"); +} + +#[tokio::test] +async fn decoded_response_cap_is_enforced_independently_of_encoded_cap() { + const LIMIT: usize = 100; + let plan = ScenarioPlan::new(Fixture::SyntheticXml("€".repeat(40))) + .with_encoding(WireEncoding::Utf16Le); + assert!(plan.response_bytes().len() <= LIMIT); + let (simulator, result) = + post_synthetic(plan, Some(policy(LIMIT, Duration::from_secs(2))), "").await; + assert_eq!( + result.expect_err("decoded cap must fail"), + TallyTransportError::ResponseTooLarge { + limit: LIMIT, + declared_by_peer: false, + } + ); + simulator.finish().expect("finish decoded-cap simulator"); +} + +#[tokio::test] +async fn exact_encoded_cap_is_inclusive_for_all_framing_modes() { + const LIMIT: usize = 512; + for framing in [ + ResponseFraming::ContentLength, + ResponseFraming::ConnectionClose, + ResponseFraming::Chunked { chunk_bytes: 31 }, + ] { + let (simulator, result) = post_synthetic( + ScenarioPlan::new(Fixture::Oversized { + minimum_bytes: LIMIT, + }) + .with_framing(framing), + Some(policy(LIMIT, Duration::from_secs(2))), + "", + ) + .await; + let response = result.expect("accept exact response cap"); + assert_eq!(response.encoded_bytes(), LIMIT); + simulator.finish().expect("finish exact-cap simulator"); + } +} + +#[tokio::test] +async fn declared_and_streamed_cap_plus_one_fail_closed() { + const LIMIT: usize = 512; + for (framing, declared_by_peer) in [ + (ResponseFraming::ContentLength, true), + (ResponseFraming::ConnectionClose, false), + (ResponseFraming::Chunked { chunk_bytes: 29 }, false), + ] { + let (simulator, result) = post_synthetic( + ScenarioPlan::new(Fixture::Oversized { + minimum_bytes: LIMIT + 1, + }) + .with_framing(framing), + Some(policy(LIMIT, Duration::from_secs(2))), + "", + ) + .await; + let error = result.expect_err("cap plus one must fail"); + assert_eq!( + error, + TallyTransportError::ResponseTooLarge { + limit: LIMIT, + declared_by_peer, + } + ); + drop(simulator); + } +} + +#[tokio::test] +async fn truncated_declared_body_is_never_partial_success() { + let fixture = Fixture::ExportStatusOne; + let actual_bytes = fixture.body().len(); + let (simulator, result) = post_synthetic( + ScenarioPlan::new(fixture).with_framing(ResponseFraming::DeclaredContentLength { + bytes: actual_bytes + 17, + }), + None, + "", + ) + .await; + let error = result.expect_err("truncated response must fail"); + assert!(matches!( + error, + TallyTransportError::ResponseTruncated | TallyTransportError::ResponseReadFailed + )); + simulator.finish().expect("finish truncated simulator"); +} + +#[tokio::test] +async fn whole_request_deadline_covers_slow_headers() { + let (simulator, result) = post_synthetic( + ScenarioPlan::new(Fixture::ExportStatusOne) + .with_delivery(Delivery::SlowHeaders(Duration::from_millis(150))), + Some(policy(1024, Duration::from_millis(30))), + "", + ) + .await; + let error = result.expect_err("slow headers must time out"); + assert_eq!(error, TallyTransportError::RequestTimedOut); + simulator.cancel(); + let observed = simulator.finish().expect("cancel slow simulator"); + assert!(observed.cancelled); +} + +#[tokio::test] +async fn non_success_and_redirect_statuses_are_typed_failures() { + for status in [302, 500] { + let (simulator, result) = post_synthetic( + ScenarioPlan::new(Fixture::ExportStatusOne).with_http_status(status), + None, + "", + ) + .await; + let error = result.expect_err("non-success HTTP status must fail"); + assert_eq!(error, TallyTransportError::HttpStatus { status }); + drop(simulator); + } +} + +#[tokio::test] +async fn non_identity_content_encoding_is_rejected_before_body_interpretation() { + for (encoding, expected) in [ + (ResponseContentEncoding::Identity, None), + ( + ResponseContentEncoding::Gzip, + Some(TallyTransportError::UnsupportedContentEncoding), + ), + ( + ResponseContentEncoding::DuplicateIdentityThenGzip, + Some(TallyTransportError::UnsupportedContentEncoding), + ), + ] { + let (simulator, result) = post_synthetic( + ScenarioPlan::new(Fixture::ExportStatusOne).with_content_encoding(encoding), + None, + "", + ) + .await; + match expected { + Some(error) => assert_eq!(result.expect_err("encoding must fail"), error), + None => assert!(result.is_ok(), "identity encoding must pass"), + } + drop(simulator); + } +} + +#[tokio::test] +async fn outbound_request_cap_is_checked_before_connecting() { + let transport = TallyHttpTransport::with_policy( + TallyEndpointConfig { + host: "127.0.0.1".to_owned(), + port: 9, + }, + policy(64, Duration::from_millis(50)), + ) + .expect("build bounded transport"); + let error = transport + .post_xml("X".repeat(65)) + .await + .expect_err("oversized request must be rejected locally"); + assert_eq!(error, TallyTransportError::RequestTooLarge { limit: 64 }); +} diff --git a/src-tauri/crates/bridge-tally-write/Cargo.toml b/src-tauri/crates/bridge-tally-write/Cargo.toml new file mode 100644 index 0000000..4058660 --- /dev/null +++ b/src-tauri/crates/bridge-tally-write/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "bridge-tally-write" +version = "0.1.0" +description = "Portable, network-free qualification contract for controlled Tally ledger writes" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[dependencies] +bridge-tally-core = { path = "../bridge-tally-core" } +bridge-tally-protocol = { path = "../bridge-tally-protocol" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sha2 = "0.11" +thiserror = "2" diff --git a/src-tauri/crates/bridge-tally-write/src/lib.rs b/src-tauri/crates/bridge-tally-write/src/lib.rs new file mode 100644 index 0000000..25576ce --- /dev/null +++ b/src-tauri/crates/bridge-tally-write/src/lib.rs @@ -0,0 +1,1174 @@ +//! Portable, network-free qualification for controlled Tally ledger writes. +//! +//! This crate cannot dispatch HTTP. It binds a deterministic import preview to +//! typed intent, strict preflight state, parser-derived import evidence, and a +//! company-bound readback before it can return an exact verdict. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +use bridge_tally_core::ExactDecimal; +use bridge_tally_protocol::{ + parse_import_evidence, parse_ledger_write_readback_with_evidence, ParsedImportEvidence, + TallyImportApplicationStatus, TallyImportResult, BRIDGE_LEDGER_WRITE_READBACK_SCHEMA, +}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +pub const MAX_LEDGER_WRITE_BATCH: usize = 10; +pub const LEDGER_WRITE_PROJECTION: &str = "bridge.tally.ledger-write-state/1"; +pub const LEDGER_READBACK_PROFILE: &str = BRIDGE_LEDGER_WRITE_READBACK_SCHEMA; + +macro_rules! digest_type { + ($name:ident) => { + #[derive(Clone, PartialEq, Eq)] + pub struct $name(String); + + impl $name { + pub fn as_hex(&self) -> &str { + &self.0 + } + } + + impl std::fmt::Debug for $name { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(concat!(stringify!($name), "()")) + } + } + }; +} + +digest_type!(WirePayloadDigest); +digest_type!(IntendedStateDigest); +digest_type!(ImportResponseDigest); +digest_type!(ReadbackStateDigest); +digest_type!(IdentityCoverageDigest); +digest_type!(LineErrorDigest); +digest_type!(ApprovalEvidenceDigest); +digest_type!(IdentityQueryDigest); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum LedgerOperation { + Create, + Alter, +} + +impl LedgerOperation { + fn tally_action(self) -> &'static str { + match self { + Self::Create => "Create", + Self::Alter => "Alter", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct LedgerState { + name: String, + parent: Option, + party_gstin: Option, + opening_balance: Option, +} + +impl LedgerState { + pub fn new( + name: impl Into, + parent: Option, + party_gstin: Option, + opening_balance: Option, + ) -> Result { + let state = Self { + name: name.into(), + parent, + party_gstin, + opening_balance, + }; + validate_value(&state.name, "ledger_name")?; + for (value, field) in [ + (state.parent.as_deref(), "parent"), + (state.party_gstin.as_deref(), "party_gstin"), + (state.opening_balance.as_deref(), "opening_balance"), + ] { + if let Some(value) = value { + validate_value(value, field)?; + } + } + if let Some(value) = state.party_gstin.as_deref() { + validate_gstin(value)?; + } + if let Some(value) = state.opening_balance.as_deref() { + ExactDecimal::parse(value.to_owned()) + .map_err(|_| QualificationError::InvalidField("opening_balance"))?; + } + Ok(state) + } + + pub fn name(&self) -> &str { + &self.name + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SourceLineage { + system: String, + record_id: String, + version: String, +} + +impl SourceLineage { + pub fn new( + system: impl Into, + record_id: impl Into, + version: impl Into, + ) -> Result { + let lineage = Self { + system: system.into(), + record_id: record_id.into(), + version: version.into(), + }; + validate_value(&lineage.system, "source_system")?; + validate_value(&lineage.record_id, "source_record_id")?; + validate_value(&lineage.version, "source_version")?; + Ok(lineage) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LedgerMutation { + operation: LedgerOperation, + remote_id: String, + before: Option, + after: LedgerState, + source_lineage: SourceLineage, +} + +impl LedgerMutation { + pub fn create( + remote_id: impl Into, + after: LedgerState, + source_lineage: SourceLineage, + ) -> Result { + Self::new( + LedgerOperation::Create, + remote_id.into(), + None, + after, + source_lineage, + ) + } + + pub fn alter( + remote_id: impl Into, + before: LedgerState, + after: LedgerState, + source_lineage: SourceLineage, + ) -> Result { + if before == after { + return Err(QualificationError::NoOpMutation); + } + Self::new( + LedgerOperation::Alter, + remote_id.into(), + Some(before), + after, + source_lineage, + ) + } + + fn new( + operation: LedgerOperation, + remote_id: String, + before: Option, + after: LedgerState, + source_lineage: SourceLineage, + ) -> Result { + validate_value(&remote_id, "remote_id")?; + Ok(Self { + operation, + remote_id, + before, + after, + source_lineage, + }) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SyntheticCompany { + name: String, + guid: String, +} + +impl SyntheticCompany { + pub fn new( + name: impl Into, + guid: impl Into, + ) -> Result { + let company = Self { + name: name.into(), + guid: guid.into(), + }; + validate_value(&company.name, "company_name")?; + validate_value(&company.guid, "company_guid")?; + Ok(company) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteCapability { + Observed, + Documented, + Unknown, + Unsupported, +} + +#[derive(Debug, Clone)] +pub struct WriteAuthorizationRequest { + pub explicit_opt_in: bool, + pub synthetic_company_confirmed: bool, + pub company_guid: String, + pub capability: WriteCapability, + pub backup_guidance_acknowledged: bool, + pub approval_evidence_sha256: String, + pub approved_wire_sha256: String, + pub approved_intended_state_sha256: String, + pub approved_identity_query_sha256: String, + pub idempotency_key: String, + pub outbox_id: String, + pub mapping_version: String, +} + +#[derive(Clone)] +pub struct WriteAuthorization { + company_guid: String, + approval_evidence_sha256: String, + idempotency_key: String, + outbox_id: String, + mapping_version: String, + approved_wire_sha256: String, + approved_intended_state_sha256: String, + approved_identity_query_sha256: String, +} + +impl std::fmt::Debug for WriteAuthorization { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("WriteAuthorization") + .field("company_guid", &"") + .field("approval_evidence_sha256", &"") + .field("idempotency_key", &"") + .field("outbox_id", &"") + .field("mapping_version", &self.mapping_version) + .finish() + } +} + +pub fn authorize_synthetic_write( + request: WriteAuthorizationRequest, +) -> Result { + if !request.explicit_opt_in { + return Err(QualificationError::ExplicitOptInRequired); + } + if !request.synthetic_company_confirmed { + return Err(QualificationError::SyntheticCompanyRequired); + } + if request.capability != WriteCapability::Observed { + return Err(QualificationError::ObservedCapabilityRequired); + } + if !request.backup_guidance_acknowledged { + return Err(QualificationError::BackupAcknowledgementRequired); + } + validate_value(&request.company_guid, "company_guid")?; + validate_sha256( + &request.approval_evidence_sha256, + "approval_evidence_sha256", + )?; + validate_sha256(&request.approved_wire_sha256, "approved_wire_sha256")?; + validate_sha256( + &request.approved_intended_state_sha256, + "approved_intended_state_sha256", + )?; + validate_sha256( + &request.approved_identity_query_sha256, + "approved_identity_query_sha256", + )?; + validate_value(&request.idempotency_key, "idempotency_key")?; + validate_value(&request.outbox_id, "outbox_id")?; + validate_value(&request.mapping_version, "mapping_version")?; + Ok(WriteAuthorization { + company_guid: request.company_guid, + approval_evidence_sha256: request.approval_evidence_sha256, + idempotency_key: request.idempotency_key, + outbox_id: request.outbox_id, + mapping_version: request.mapping_version, + approved_wire_sha256: request.approved_wire_sha256, + approved_intended_state_sha256: request.approved_intended_state_sha256, + approved_identity_query_sha256: request.approved_identity_query_sha256, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ReservationState { + Reserved, + Sent, + AwaitingReadback, + OutcomeUnknown, + Terminal, +} + +#[derive(Debug, Clone)] +struct Reservation { + wire_digest: WirePayloadDigest, + outbox_id: String, + state: ReservationState, +} + +#[derive(Debug, Default)] +pub struct IdempotencyRegistry { + reservations: HashMap, +} + +impl IdempotencyRegistry { + fn reserve( + &mut self, + authorization: &WriteAuthorization, + wire_digest: &WirePayloadDigest, + ) -> Result<(), QualificationError> { + if let Some(existing) = self.reservations.get(&authorization.idempotency_key) { + if existing.wire_digest != *wire_digest || existing.outbox_id != authorization.outbox_id + { + return Err(QualificationError::IdempotencyConflict); + } + return Err(QualificationError::DuplicateSubmission); + } + self.reservations.insert( + authorization.idempotency_key.clone(), + Reservation { + wire_digest: wire_digest.clone(), + outbox_id: authorization.outbox_id.clone(), + state: ReservationState::Reserved, + }, + ); + Ok(()) + } + + fn transition( + &mut self, + key: &str, + expected: ReservationState, + next: ReservationState, + ) -> Result<(), QualificationError> { + let reservation = self + .reservations + .get_mut(key) + .ok_or(QualificationError::MissingIdempotencyReservation)?; + if reservation.state != expected { + return Err(QualificationError::DuplicateSubmission); + } + reservation.state = next; + Ok(()) + } +} + +#[derive(Clone)] +pub struct PreparedLedgerImport { + company: SyntheticCompany, + mutations: Vec, + wire_digest: WirePayloadDigest, + intended_state_digest: IntendedStateDigest, + expected_before: BTreeMap, + expected_after: BTreeMap, + authorization: WriteAuthorization, + approval_evidence_digest: ApprovalEvidenceDigest, + identity_query_digest: IdentityQueryDigest, +} + +impl std::fmt::Debug for PreparedLedgerImport { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PreparedLedgerImport") + .field("mutation_count", &self.mutations.len()) + .field("wire_digest", &self.wire_digest) + .field("intended_state_digest", &self.intended_state_digest) + .field("approval_evidence_digest", &self.approval_evidence_digest) + .field("identity_query_digest", &self.identity_query_digest) + .field("dispatch_eligible", &false) + .finish() + } +} + +impl PreparedLedgerImport { + pub fn wire_digest(&self) -> &WirePayloadDigest { + &self.wire_digest + } + + pub fn intended_state_digest(&self) -> &IntendedStateDigest { + &self.intended_state_digest + } + + pub fn approval_evidence_digest(&self) -> &ApprovalEvidenceDigest { + &self.approval_evidence_digest + } + + pub fn identity_query_digest(&self) -> &IdentityQueryDigest { + &self.identity_query_digest + } + + pub const fn dispatch_eligible(&self) -> bool { + false + } + + pub fn qualify_preflight( + self, + readback_xml: &str, + ) -> Result { + let observed = parse_readback(&self, readback_xml)?; + if observed.projections != self.expected_before { + return Err(QualificationError::PreflightMismatch); + } + Ok(QualifiedLedgerImport { + prepared: self, + before_readback_digest: observed.state_digest, + before_coverage_digest: observed.coverage_digest, + }) + } +} + +#[derive(Clone)] +pub struct QualifiedLedgerImport { + prepared: PreparedLedgerImport, + before_readback_digest: ReadbackStateDigest, + before_coverage_digest: IdentityCoverageDigest, +} + +impl std::fmt::Debug for QualifiedLedgerImport { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("QualifiedLedgerImport") + .field("prepared", &self.prepared) + .field("before_readback_digest", &self.before_readback_digest) + .field("before_coverage_digest", &self.before_coverage_digest) + .finish() + } +} + +impl QualifiedLedgerImport { + pub fn identity_query_digest(&self) -> &IdentityQueryDigest { + &self.prepared.identity_query_digest + } + + pub fn record_dispatch_attempt( + self, + request_id: impl Into, + registry: &mut IdempotencyRegistry, + ) -> Result { + let request_id = request_id.into(); + validate_value(&request_id, "request_id")?; + registry.transition( + &self.prepared.authorization.idempotency_key, + ReservationState::Reserved, + ReservationState::Sent, + )?; + Ok(SentLedgerImport { + qualified: self, + request_id, + }) + } +} + +#[derive(Clone)] +pub struct SentLedgerImport { + qualified: QualifiedLedgerImport, + request_id: String, +} + +impl SentLedgerImport { + pub fn request_id(&self) -> &str { + &self.request_id + } + + pub fn identity_query_digest(&self) -> &IdentityQueryDigest { + self.qualified.identity_query_digest() + } + + pub fn record_import_receipt( + self, + response_xml: &str, + registry: &mut IdempotencyRegistry, + ) -> Result { + let receipt = parse_import_receipt(response_xml)?; + registry.transition( + &self.qualified.prepared.authorization.idempotency_key, + ReservationState::Sent, + ReservationState::AwaitingReadback, + )?; + Ok(AwaitingLedgerReadback { + sent: self, + receipt, + }) + } + + pub fn record_outcome_unknown( + self, + registry: &mut IdempotencyRegistry, + ) -> Result { + registry.transition( + &self.qualified.prepared.authorization.idempotency_key, + ReservationState::Sent, + ReservationState::OutcomeUnknown, + )?; + Ok(OutcomeUnknownLedgerImport { sent: self }) + } +} + +#[derive(Clone)] +pub struct AwaitingLedgerReadback { + sent: SentLedgerImport, + receipt: ParsedImportReceipt, +} + +impl AwaitingLedgerReadback { + pub fn identity_query_digest(&self) -> &IdentityQueryDigest { + self.sent.identity_query_digest() + } + + pub fn verify_readback( + self, + readback_xml: &str, + registry: &mut IdempotencyRegistry, + ) -> Result { + let prepared = &self.sent.qualified.prepared; + let observed = parse_readback(prepared, readback_xml)?; + let exact_after = observed.projections == prepared.expected_after; + let exact_before = observed.projections == prepared.expected_before; + let expected_created = prepared + .mutations + .iter() + .filter(|mutation| mutation.operation == LedgerOperation::Create) + .count() as u64; + let expected_altered = prepared.mutations.len() as u64 - expected_created; + let exact_counters = self.receipt.is_clean() + && self.receipt.counters.created == expected_created + && self.receipt.counters.altered == expected_altered + && self.receipt.counters.deleted == 0; + let zero_counters = self.receipt.counters.created == 0 + && self.receipt.counters.altered == 0 + && self.receipt.counters.deleted == 0; + let outcome = if exact_after && exact_counters { + WriteOutcome::ExactApplied + } else if exact_before && zero_counters { + WriteOutcome::ExactNotApplied + } else { + WriteOutcome::Mismatch + }; + registry.transition( + &prepared.authorization.idempotency_key, + ReservationState::AwaitingReadback, + ReservationState::Terminal, + )?; + Ok(verdict(prepared, Some(&self.receipt), observed, outcome)) + } +} + +#[derive(Clone)] +pub struct OutcomeUnknownLedgerImport { + sent: SentLedgerImport, +} + +impl OutcomeUnknownLedgerImport { + pub fn identity_query_digest(&self) -> &IdentityQueryDigest { + self.sent.identity_query_digest() + } + + /// A readback may add commitments for investigation, but without a parsed + /// import receipt the terminal write outcome remains unknown. + pub fn observe_readback( + &self, + readback_xml: &str, + ) -> Result { + let prepared = &self.sent.qualified.prepared; + let observed = parse_readback(prepared, readback_xml)?; + Ok(verdict( + prepared, + None, + observed, + WriteOutcome::OutcomeUnknown, + )) + } +} + +fn verdict( + prepared: &PreparedLedgerImport, + receipt: Option<&ParsedImportReceipt>, + observed: ParsedReadback, + outcome: WriteOutcome, +) -> DerivedWriteVerdict { + DerivedWriteVerdict { + outcome, + wire_digest: prepared.wire_digest.clone(), + intended_state_digest: prepared.intended_state_digest.clone(), + import_response_digest: receipt.map(|value| value.response_digest.clone()), + readback_state_digest: observed.state_digest, + identity_coverage_digest: observed.coverage_digest, + auto_retry_allowed: false, + } +} + +#[derive(Clone)] +pub struct ParsedImportReceipt { + application_status: TallyImportApplicationStatus, + counters: TallyImportResult, + exceptions_were_reported: bool, + response_digest: ImportResponseDigest, + line_error_digests: Vec, +} + +impl std::fmt::Debug for ParsedImportReceipt { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ParsedImportReceipt") + .field("application_status", &self.application_status) + .field("counters", &self.counters) + .field("exceptions_were_reported", &self.exceptions_were_reported) + .field("response_digest", &self.response_digest) + .field("line_error_count", &self.line_error_digests.len()) + .finish() + } +} + +impl ParsedImportReceipt { + pub fn application_status(&self) -> TallyImportApplicationStatus { + self.application_status + } + + pub fn counters(&self) -> &TallyImportResult { + &self.counters + } + + pub fn exceptions_were_reported(&self) -> bool { + self.exceptions_were_reported + } + + pub fn response_digest(&self) -> &ImportResponseDigest { + &self.response_digest + } + + pub fn line_error_digests(&self) -> &[LineErrorDigest] { + &self.line_error_digests + } + + fn is_clean(&self) -> bool { + self.application_status != TallyImportApplicationStatus::Failure + && self.counters.is_clean_success() + } +} + +pub fn parse_import_receipt(xml: &str) -> Result { + let evidence = + parse_import_evidence(xml).map_err(|_| QualificationError::InvalidImportReceipt)?; + Ok(receipt_from_evidence(evidence)) +} + +fn receipt_from_evidence(evidence: ParsedImportEvidence) -> ParsedImportReceipt { + ParsedImportReceipt { + application_status: evidence.application_status(), + counters: evidence.counters().clone(), + exceptions_were_reported: evidence.exceptions_were_reported(), + response_digest: ImportResponseDigest(evidence.response_sha256().to_owned()), + line_error_digests: evidence + .line_error_sha256() + .iter() + .cloned() + .map(LineErrorDigest) + .collect(), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteOutcome { + ExactApplied, + ExactNotApplied, + Mismatch, + OutcomeUnknown, +} + +#[derive(Clone)] +pub struct DerivedWriteVerdict { + outcome: WriteOutcome, + wire_digest: WirePayloadDigest, + intended_state_digest: IntendedStateDigest, + import_response_digest: Option, + readback_state_digest: ReadbackStateDigest, + identity_coverage_digest: IdentityCoverageDigest, + auto_retry_allowed: bool, +} + +impl std::fmt::Debug for DerivedWriteVerdict { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DerivedWriteVerdict") + .field("outcome", &self.outcome) + .field("wire_digest", &self.wire_digest) + .field("intended_state_digest", &self.intended_state_digest) + .field("import_response_digest", &self.import_response_digest) + .field("readback_state_digest", &self.readback_state_digest) + .field("identity_coverage_digest", &self.identity_coverage_digest) + .field("auto_retry_allowed", &self.auto_retry_allowed) + .finish() + } +} + +impl DerivedWriteVerdict { + pub fn outcome(&self) -> WriteOutcome { + self.outcome + } + + pub const fn auto_retry_allowed(&self) -> bool { + self.auto_retry_allowed + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum QualificationError { + #[error("explicit operator opt-in is required")] + ExplicitOptInRequired, + #[error("controlled writes require an explicitly confirmed synthetic company")] + SyntheticCompanyRequired, + #[error("controlled writes require an observed capability")] + ObservedCapabilityRequired, + #[error("backup guidance acknowledgement is required")] + BackupAcknowledgementRequired, + #[error("approval evidence does not bind the exact preview commitments")] + ApprovalMismatch, + #[error("controlled ledger write batch must contain between one and ten items")] + InvalidBatchSize, + #[error("controlled ledger write input is invalid: {0}")] + InvalidField(&'static str), + #[error("controlled ledger write contains a duplicate remote identity")] + DuplicateIdentity, + #[error("idempotency key is already bound to another write")] + IdempotencyConflict, + #[error("duplicate write submission is blocked")] + DuplicateSubmission, + #[error("idempotency reservation is missing")] + MissingIdempotencyReservation, + #[error("controlled ledger alter must change at least one verified field")] + NoOpMutation, + #[error("preflight readback did not exactly match the declared before state")] + PreflightMismatch, + #[error("Tally import receipt was invalid")] + InvalidImportReceipt, + #[error("Tally ledger readback was invalid or outside the expected company/profile")] + InvalidReadback, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct Projection { + contract: &'static str, + company_guid: String, + profile: &'static str, + operation: LedgerOperation, + remote_id: String, + source_lineage: SourceLineage, + mapping_version: String, + state: LedgerState, +} + +#[derive(Serialize)] +struct MutationIntentPreimage<'a> { + contract: &'static str, + before: &'a BTreeMap, + after: &'a BTreeMap, +} + +#[derive(Debug)] +struct ParsedReadback { + projections: BTreeMap, + state_digest: ReadbackStateDigest, + coverage_digest: IdentityCoverageDigest, +} + +#[derive(Clone, Debug)] +pub struct LedgerImportPreview { + wire_digest: WirePayloadDigest, + intended_state_digest: IntendedStateDigest, + identity_query_digest: IdentityQueryDigest, +} + +impl LedgerImportPreview { + pub fn wire_digest(&self) -> &WirePayloadDigest { + &self.wire_digest + } + + pub fn intended_state_digest(&self) -> &IntendedStateDigest { + &self.intended_state_digest + } + + pub fn identity_query_digest(&self) -> &IdentityQueryDigest { + &self.identity_query_digest + } +} + +pub fn preview_ledger_import( + company: &SyntheticCompany, + mutations: &[LedgerMutation], + mapping_version: &str, +) -> Result { + validate_value(mapping_version, "mapping_version")?; + if mutations.is_empty() || mutations.len() > MAX_LEDGER_WRITE_BATCH { + return Err(QualificationError::InvalidBatchSize); + } + let identities = mutations + .iter() + .map(|mutation| mutation.remote_id.clone()) + .collect::>(); + if identities.len() != mutations.len() { + return Err(QualificationError::DuplicateIdentity); + } + let expected_after = mutations + .iter() + .map(|mutation| { + ( + mutation.remote_id.clone(), + projection(company, mutation, mutation.after.clone(), mapping_version), + ) + }) + .collect::>(); + let expected_before = mutations + .iter() + .filter_map(|mutation| { + mutation.before.as_ref().map(|before| { + ( + mutation.remote_id.clone(), + projection(company, mutation, before.clone(), mapping_version), + ) + }) + }) + .collect::>(); + let wire_bytes = build_import_xml(company, mutations).into_bytes(); + let intended_bytes = serde_json::to_vec(&MutationIntentPreimage { + contract: "bridge.tally.ledger-mutation-intent/1", + before: &expected_before, + after: &expected_after, + }) + .expect("versioned ledger projections are always serializable"); + let identity_bytes = + serde_json::to_vec(&identities).expect("identity query sets are always serializable"); + Ok(LedgerImportPreview { + wire_digest: WirePayloadDigest(domain_digest( + b"bridge.tally.ledger-import-wire/1\0", + &wire_bytes, + )), + intended_state_digest: IntendedStateDigest(domain_digest( + b"bridge.tally.ledger-intended-state/1\0", + &intended_bytes, + )), + identity_query_digest: IdentityQueryDigest(domain_digest( + b"bridge.tally.ledger-readback-query-identities/1\0", + &identity_bytes, + )), + }) +} + +pub fn prepare_ledger_import( + company: SyntheticCompany, + mutations: Vec, + authorization: WriteAuthorization, + registry: &mut IdempotencyRegistry, +) -> Result { + if mutations.is_empty() || mutations.len() > MAX_LEDGER_WRITE_BATCH { + return Err(QualificationError::InvalidBatchSize); + } + if authorization.company_guid != company.guid { + return Err(QualificationError::SyntheticCompanyRequired); + } + let mut identities = BTreeSet::new(); + let mut expected_before = BTreeMap::new(); + let mut expected_after = BTreeMap::new(); + for mutation in &mutations { + if !identities.insert(mutation.remote_id.clone()) { + return Err(QualificationError::DuplicateIdentity); + } + if let Some(before) = &mutation.before { + expected_before.insert( + mutation.remote_id.clone(), + projection( + &company, + mutation, + before.clone(), + &authorization.mapping_version, + ), + ); + } + expected_after.insert( + mutation.remote_id.clone(), + projection( + &company, + mutation, + mutation.after.clone(), + &authorization.mapping_version, + ), + ); + } + let wire_bytes = build_import_xml(&company, &mutations).into_bytes(); + let wire_digest = WirePayloadDigest(domain_digest( + b"bridge.tally.ledger-import-wire/1\0", + &wire_bytes, + )); + let intended_bytes = serde_json::to_vec(&MutationIntentPreimage { + contract: "bridge.tally.ledger-mutation-intent/1", + before: &expected_before, + after: &expected_after, + }) + .expect("versioned ledger projections are always serializable"); + let intended_state_digest = IntendedStateDigest(domain_digest( + b"bridge.tally.ledger-intended-state/1\0", + &intended_bytes, + )); + let identity_bytes = + serde_json::to_vec(&identities).expect("identity query sets are always serializable"); + let identity_query_digest = IdentityQueryDigest(domain_digest( + b"bridge.tally.ledger-readback-query-identities/1\0", + &identity_bytes, + )); + if authorization.approved_wire_sha256 != wire_digest.as_hex() + || authorization.approved_intended_state_sha256 != intended_state_digest.as_hex() + || authorization.approved_identity_query_sha256 != identity_query_digest.as_hex() + { + return Err(QualificationError::ApprovalMismatch); + } + let approval_evidence_digest = + ApprovalEvidenceDigest(authorization.approval_evidence_sha256.clone()); + registry.reserve(&authorization, &wire_digest)?; + Ok(PreparedLedgerImport { + company, + mutations, + wire_digest, + intended_state_digest, + expected_before, + expected_after, + authorization, + approval_evidence_digest, + identity_query_digest, + }) +} + +fn projection( + company: &SyntheticCompany, + mutation: &LedgerMutation, + state: LedgerState, + mapping_version: &str, +) -> Projection { + Projection { + contract: LEDGER_WRITE_PROJECTION, + company_guid: company.guid.clone(), + profile: LEDGER_READBACK_PROFILE, + operation: mutation.operation, + remote_id: mutation.remote_id.clone(), + source_lineage: mutation.source_lineage.clone(), + mapping_version: mapping_version.to_owned(), + state, + } +} + +fn parse_readback( + prepared: &PreparedLedgerImport, + xml: &str, +) -> Result { + let parsed = parse_ledger_write_readback_with_evidence(xml) + .map_err(|_| QualificationError::InvalidReadback)?; + let context = parsed + .evidence + .company_context + .as_ref() + .ok_or(QualificationError::InvalidReadback)?; + if context.guid.as_deref() != Some(prepared.company.guid.as_str()) + || context.query_identity_set_sha256.as_deref() + != Some(prepared.identity_query_digest.as_hex()) + || parsed.evidence.schema.as_deref() != Some(LEDGER_READBACK_PROFILE) + || parsed.evidence.object_type.as_deref() != Some("LEDGER") + || parsed.evidence.source_record_count != Some(parsed.records.len() as u64) + || !parsed.evidence.duplicate_identities.is_empty() + { + return Err(QualificationError::InvalidReadback); + } + + let by_id: BTreeMap<&str, &LedgerMutation> = prepared + .mutations + .iter() + .map(|mutation| (mutation.remote_id.as_str(), mutation)) + .collect(); + let mut projections = BTreeMap::new(); + for record in parsed.records { + let remote_id = record + .identities + .remote_id + .ok_or(QualificationError::InvalidReadback)?; + let mutation = by_id + .get(remote_id.as_str()) + .ok_or(QualificationError::InvalidReadback)?; + if projections.contains_key(&remote_id) { + return Err(QualificationError::InvalidReadback); + } + let state = LedgerState::new( + record.record.name, + record.record.parent, + record.record.party_gstin, + record.record.opening_balance, + )?; + projections.insert( + remote_id.clone(), + projection( + &prepared.company, + mutation, + state, + &prepared.authorization.mapping_version, + ), + ); + } + let state_bytes = serde_json::to_vec(&projections) + .expect("versioned ledger projections are always serializable"); + let identities = projections.keys().cloned().collect::>(); + let coverage_bytes = + serde_json::to_vec(&identities).expect("identity coverage is always serializable"); + Ok(ParsedReadback { + projections, + state_digest: ReadbackStateDigest(domain_digest( + b"bridge.tally.ledger-readback-state/1\0", + &state_bytes, + )), + coverage_digest: IdentityCoverageDigest(domain_digest( + b"bridge.tally.ledger-identity-coverage/1\0", + &coverage_bytes, + )), + }) +} + +fn build_import_xml(company: &SyntheticCompany, mutations: &[LedgerMutation]) -> String { + let mut xml = String::from( + "
1ImportDataAll Masters
", + ); + push_xml_text(&mut xml, &company.name); + xml.push_str(""); + for mutation in mutations { + xml.push_str(""); + if let Some(value) = &mutation.after.parent { + push_element(&mut xml, "PARENT", value); + } + if let Some(value) = &mutation.after.party_gstin { + push_element(&mut xml, "PARTYGSTIN", value); + } + if let Some(value) = &mutation.after.opening_balance { + push_element(&mut xml, "OPENINGBALANCE", value); + } + xml.push_str(""); + } + xml.push_str("
"); + xml +} + +fn push_element(xml: &mut String, name: &str, value: &str) { + xml.push('<'); + xml.push_str(name); + xml.push('>'); + push_xml_text(xml, value); + xml.push_str("'); +} + +fn push_xml_text(xml: &mut String, value: &str) { + for character in value.chars() { + match character { + '&' => xml.push_str("&"), + '<' => xml.push_str("<"), + '>' => xml.push_str(">"), + _ => xml.push(character), + } + } +} + +fn push_xml_attribute(xml: &mut String, value: &str) { + for character in value.chars() { + match character { + '&' => xml.push_str("&"), + '<' => xml.push_str("<"), + '>' => xml.push_str(">"), + '\"' => xml.push_str("""), + '\'' => xml.push_str("'"), + _ => xml.push(character), + } + } +} + +fn validate_value(value: &str, field: &'static str) -> Result<(), QualificationError> { + if value.trim().is_empty() + || value.len() > 255 + || value.chars().any(|character| character.is_control()) + { + return Err(QualificationError::InvalidField(field)); + } + Ok(()) +} + +fn validate_sha256(value: &str, field: &'static str) -> Result<(), QualificationError> { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(QualificationError::InvalidField(field)); + } + Ok(()) +} + +fn validate_gstin(value: &str) -> Result<(), QualificationError> { + let bytes = value.as_bytes(); + let valid = bytes.len() == 15 + && bytes[0..2].iter().all(u8::is_ascii_digit) + && bytes[2..7].iter().all(u8::is_ascii_uppercase) + && bytes[7..11].iter().all(u8::is_ascii_digit) + && bytes[11].is_ascii_uppercase() + && bytes[12].is_ascii_alphanumeric() + && bytes[13] == b'Z' + && bytes[14].is_ascii_alphanumeric(); + if !valid { + return Err(QualificationError::InvalidField("party_gstin")); + } + Ok(()) +} + +fn domain_digest(domain: &[u8], value: &[u8]) -> String { + let mut digest = Sha256::new(); + digest.update(domain); + digest.update(value); + let mut encoded = String::with_capacity(64); + for byte in digest.finalize() { + use std::fmt::Write as _; + write!(&mut encoded, "{byte:02x}").expect("writing to a String cannot fail"); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn private_wire_builder_escapes_text_and_attributes() { + let company = SyntheticCompany::new("BRIDGE & BOOK", "company-guid").unwrap(); + let mutation = LedgerMutation::create( + "remote-\"<&", + LedgerState::new("LEDGER <&", None, None, Some("0".to_owned())).unwrap(), + SourceLineage::new("synthetic", "record", "v1").unwrap(), + ) + .unwrap(); + let xml = build_import_xml(&company, &[mutation]); + assert!(xml.contains("BRIDGE & BOOK")); + assert!(xml.contains("remote-"<&")); + assert!(xml.contains("LEDGER <&")); + } +} diff --git a/src-tauri/crates/bridge-tally-write/tests/qualification.rs b/src-tauri/crates/bridge-tally-write/tests/qualification.rs new file mode 100644 index 0000000..fc3e70c --- /dev/null +++ b/src-tauri/crates/bridge-tally-write/tests/qualification.rs @@ -0,0 +1,513 @@ +use bridge_tally_write::{ + authorize_synthetic_write, prepare_ledger_import, preview_ledger_import, IdempotencyRegistry, + LedgerMutation, LedgerState, PreparedLedgerImport, QualificationError, SourceLineage, + SyntheticCompany, WriteAuthorizationRequest, WriteCapability, WriteOutcome, + MAX_LEDGER_WRITE_BATCH, +}; + +const COMPANY_GUID: &str = "00000000-0000-4000-8000-000000000001"; +const REMOTE_ID: &str = "bridge-synthetic-ledger-001"; +const HASH: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const PROFILE: &str = "bridge.tally.ledger-write-readback/1"; + +fn company() -> SyntheticCompany { + SyntheticCompany::new("BRIDGE SYNTHETIC BOOK", COMPANY_GUID).unwrap() +} + +fn lineage(index: usize) -> SourceLineage { + SourceLineage::new("synthetic-source", format!("record-{index}"), "version-1").unwrap() +} + +fn state(name: &str, balance: &str) -> LedgerState { + LedgerState::new( + name, + Some("BRIDGE SYNTHETIC GROUP".to_owned()), + Some("29ABCDE1234F1Z5".to_owned()), + Some(balance.to_owned()), + ) + .unwrap() +} + +fn create(remote_id: impl Into, after: LedgerState, index: usize) -> LedgerMutation { + LedgerMutation::create(remote_id, after, lineage(index)).unwrap() +} + +fn alter(remote_id: impl Into, before: LedgerState, after: LedgerState) -> LedgerMutation { + LedgerMutation::alter(remote_id, before, after, lineage(1)).unwrap() +} + +fn authorization( + key: &str, + company: &SyntheticCompany, + mutations: &[LedgerMutation], +) -> bridge_tally_write::WriteAuthorization { + let preview = preview_ledger_import(company, mutations, "mapping-v1").unwrap(); + authorize_synthetic_write(WriteAuthorizationRequest { + explicit_opt_in: true, + synthetic_company_confirmed: true, + company_guid: COMPANY_GUID.to_owned(), + capability: WriteCapability::Observed, + backup_guidance_acknowledged: true, + approval_evidence_sha256: HASH.to_owned(), + approved_wire_sha256: preview.wire_digest().as_hex().to_owned(), + approved_intended_state_sha256: preview.intended_state_digest().as_hex().to_owned(), + approved_identity_query_sha256: preview.identity_query_digest().as_hex().to_owned(), + idempotency_key: key.to_owned(), + outbox_id: format!("outbox-{key}"), + mapping_version: "mapping-v1".to_owned(), + }) + .unwrap() +} + +fn prepare( + key: &str, + mutations: Vec, + registry: &mut IdempotencyRegistry, +) -> Result { + let company = company(); + let authorization = authorization(key, &company, &mutations); + prepare_ledger_import(company, mutations, authorization, registry) +} + +fn export( + company_guid: &str, + schema: &str, + query_digest: &str, + ledgers: &str, + count: usize, +) -> String { + format!( + r#"
1
{ledgers}
"# + ) +} + +fn ledger(remote_id: &str, name: &str, balance: &str) -> String { + format!( + r#"BRIDGE SYNTHETIC GROUP29ABCDE1234F1Z5{balance}"# + ) +} + +fn receipt(created: u64, altered: u64, deleted: u64) -> String { + format!( + "{created}{altered}{deleted}0000" + ) +} + +#[test] +fn authorization_gates_are_mandatory() { + let base = WriteAuthorizationRequest { + explicit_opt_in: true, + synthetic_company_confirmed: true, + company_guid: COMPANY_GUID.to_owned(), + capability: WriteCapability::Observed, + backup_guidance_acknowledged: true, + approval_evidence_sha256: HASH.to_owned(), + approved_wire_sha256: HASH.to_owned(), + approved_intended_state_sha256: HASH.to_owned(), + approved_identity_query_sha256: HASH.to_owned(), + idempotency_key: "key".to_owned(), + outbox_id: "outbox".to_owned(), + mapping_version: "mapping-v1".to_owned(), + }; + let mut request = base.clone(); + request.explicit_opt_in = false; + assert_eq!( + authorize_synthetic_write(request).unwrap_err(), + QualificationError::ExplicitOptInRequired + ); + let mut request = base.clone(); + request.synthetic_company_confirmed = false; + assert_eq!( + authorize_synthetic_write(request).unwrap_err(), + QualificationError::SyntheticCompanyRequired + ); + let mut request = base.clone(); + request.capability = WriteCapability::Documented; + assert_eq!( + authorize_synthetic_write(request).unwrap_err(), + QualificationError::ObservedCapabilityRequired + ); + let mut request = base; + request.backup_guidance_acknowledged = false; + assert_eq!( + authorize_synthetic_write(request).unwrap_err(), + QualificationError::BackupAcknowledgementRequired + ); +} + +#[test] +fn approval_must_bind_the_exact_preview_commitments() { + let company = company(); + let mutations = vec![create(REMOTE_ID, state("BRIDGE LEDGER", "10.00"), 1)]; + let preview = preview_ledger_import(&company, &mutations, "mapping-v1").unwrap(); + let authorization = authorize_synthetic_write(WriteAuthorizationRequest { + explicit_opt_in: true, + synthetic_company_confirmed: true, + company_guid: COMPANY_GUID.to_owned(), + capability: WriteCapability::Observed, + backup_guidance_acknowledged: true, + approval_evidence_sha256: HASH.to_owned(), + approved_wire_sha256: HASH.to_owned(), + approved_intended_state_sha256: preview.intended_state_digest().as_hex().to_owned(), + approved_identity_query_sha256: preview.identity_query_digest().as_hex().to_owned(), + idempotency_key: "key-mismatched-approval".to_owned(), + outbox_id: "outbox-mismatched-approval".to_owned(), + mapping_version: "mapping-v1".to_owned(), + }) + .unwrap(); + assert_eq!( + prepare_ledger_import( + company, + mutations, + authorization, + &mut IdempotencyRegistry::default(), + ) + .unwrap_err(), + QualificationError::ApprovalMismatch + ); +} + +#[test] +fn alter_approval_binds_the_exact_declared_before_state() { + let company = company(); + let approved = vec![alter( + REMOTE_ID, + state("BRIDGE LEDGER", "10.00"), + state("BRIDGE LEDGER", "20.00"), + )]; + let authorization = authorization("key-before-binding", &company, &approved); + let changed_before = vec![alter( + REMOTE_ID, + state("BRIDGE LEDGER", "11.00"), + state("BRIDGE LEDGER", "20.00"), + )]; + assert_eq!( + prepare_ledger_import( + company, + changed_before, + authorization, + &mut IdempotencyRegistry::default(), + ) + .unwrap_err(), + QualificationError::ApprovalMismatch + ); +} + +#[test] +fn prepared_import_is_deterministic_and_bytes_are_not_publicly_exposed() { + let mutation = create( + "bridge-&-identity", + LedgerState::new("BRIDGE ", None, None, Some("0".to_owned())).unwrap(), + 1, + ); + let mut first_registry = IdempotencyRegistry::default(); + let first = prepare("key-1", vec![mutation.clone()], &mut first_registry).unwrap(); + let mut second_registry = IdempotencyRegistry::default(); + let second = prepare("key-1", vec![mutation], &mut second_registry).unwrap(); + assert!(!first.dispatch_eligible()); + assert_eq!(first.wire_digest().as_hex(), second.wire_digest().as_hex()); + assert_ne!( + first.wire_digest().as_hex(), + first.intended_state_digest().as_hex() + ); + let query = first.identity_query_digest().as_hex().to_owned(); + first + .qualify_preflight(&export(COMPANY_GUID, PROFILE, &query, "", 0)) + .unwrap(); +} + +#[test] +fn exact_company_bound_create_requires_lifecycle_and_exact_counters() { + let mut registry = IdempotencyRegistry::default(); + let prepared = prepare( + "key-applied", + vec![create(REMOTE_ID, state("BRIDGE LEDGER", "10.00"), 1)], + &mut registry, + ) + .unwrap(); + let query = prepared.identity_query_digest().as_hex().to_owned(); + let sent = prepared + .qualify_preflight(&export(COMPANY_GUID, PROFILE, &query, "", 0)) + .unwrap() + .record_dispatch_attempt("request-1", &mut registry) + .unwrap(); + let awaiting = sent + .record_import_receipt(&receipt(1, 0, 0), &mut registry) + .unwrap(); + let after = ledger(REMOTE_ID, "BRIDGE LEDGER", "10.00"); + let verdict = awaiting + .verify_readback( + &export(COMPANY_GUID, PROFILE, &query, &after, 1), + &mut registry, + ) + .unwrap(); + assert_eq!(verdict.outcome(), WriteOutcome::ExactApplied); + assert!(!verdict.auto_retry_allowed()); +} + +#[test] +fn contradictory_operation_counters_cannot_verify() { + for (key, counters) in [ + ("wrong-alter", receipt(0, 1, 0)), + ("wrong-delete", receipt(0, 0, 1)), + ] { + let mut registry = IdempotencyRegistry::default(); + let prepared = prepare( + key, + vec![create(REMOTE_ID, state("BRIDGE LEDGER", "10.00"), 1)], + &mut registry, + ) + .unwrap(); + let query = prepared.identity_query_digest().as_hex().to_owned(); + let awaiting = prepared + .qualify_preflight(&export(COMPANY_GUID, PROFILE, &query, "", 0)) + .unwrap() + .record_dispatch_attempt(format!("request-{key}"), &mut registry) + .unwrap() + .record_import_receipt(&counters, &mut registry) + .unwrap(); + let after = ledger(REMOTE_ID, "BRIDGE LEDGER", "10.00"); + assert_eq!( + awaiting + .verify_readback( + &export(COMPANY_GUID, PROFILE, &query, &after, 1), + &mut registry, + ) + .unwrap() + .outcome(), + WriteOutcome::Mismatch + ); + } +} + +#[test] +fn clean_counters_plus_unchanged_alter_state_cannot_verify() { + let mut registry = IdempotencyRegistry::default(); + let old = ledger(REMOTE_ID, "BRIDGE LEDGER", "10.00"); + let prepared = prepare( + "key-stale", + vec![alter( + REMOTE_ID, + state("BRIDGE LEDGER", "10.00"), + state("BRIDGE LEDGER", "20.00"), + )], + &mut registry, + ) + .unwrap(); + let query = prepared.identity_query_digest().as_hex().to_owned(); + let awaiting = prepared + .qualify_preflight(&export(COMPANY_GUID, PROFILE, &query, &old, 1)) + .unwrap() + .record_dispatch_attempt("request-stale", &mut registry) + .unwrap() + .record_import_receipt(&receipt(0, 1, 0), &mut registry) + .unwrap(); + assert_eq!( + awaiting + .verify_readback( + &export(COMPANY_GUID, PROFILE, &query, &old, 1), + &mut registry, + ) + .unwrap() + .outcome(), + WriteOutcome::Mismatch + ); +} + +#[test] +fn lost_response_remains_outcome_unknown_even_with_exact_readback() { + let mut registry = IdempotencyRegistry::default(); + let prepared = prepare( + "key-unknown", + vec![create(REMOTE_ID, state("BRIDGE LEDGER", "10.00"), 1)], + &mut registry, + ) + .unwrap(); + let query = prepared.identity_query_digest().as_hex().to_owned(); + let unknown = prepared + .qualify_preflight(&export(COMPANY_GUID, PROFILE, &query, "", 0)) + .unwrap() + .record_dispatch_attempt("request-unknown", &mut registry) + .unwrap() + .record_outcome_unknown(&mut registry) + .unwrap(); + let after = ledger(REMOTE_ID, "BRIDGE LEDGER", "10.00"); + assert_eq!( + unknown + .observe_readback(&export(COMPANY_GUID, PROFILE, &query, &after, 1)) + .unwrap() + .outcome(), + WriteOutcome::OutcomeUnknown + ); + assert_eq!( + unknown + .observe_readback(&export(COMPANY_GUID, PROFILE, &query, "", 0)) + .unwrap() + .outcome(), + WriteOutcome::OutcomeUnknown + ); +} + +#[test] +fn parsed_zero_mutation_receipt_can_prove_exact_not_applied() { + let mut registry = IdempotencyRegistry::default(); + let prepared = prepare( + "key-not-applied", + vec![create(REMOTE_ID, state("BRIDGE LEDGER", "10.00"), 1)], + &mut registry, + ) + .unwrap(); + let query = prepared.identity_query_digest().as_hex().to_owned(); + let awaiting = prepared + .qualify_preflight(&export(COMPANY_GUID, PROFILE, &query, "", 0)) + .unwrap() + .record_dispatch_attempt("request-not-applied", &mut registry) + .unwrap() + .record_import_receipt(&receipt(0, 0, 0), &mut registry) + .unwrap(); + assert_eq!( + awaiting + .verify_readback(&export(COMPANY_GUID, PROFILE, &query, "", 0), &mut registry,) + .unwrap() + .outcome(), + WriteOutcome::ExactNotApplied + ); +} + +#[test] +fn wrong_company_profile_identity_or_field_fails_closed_or_mismatches() { + let mut registry = IdempotencyRegistry::default(); + let prepared = prepare( + "key-scope", + vec![create(REMOTE_ID, state("BRIDGE LEDGER", "10.00"), 1)], + &mut registry, + ) + .unwrap(); + let query = prepared.identity_query_digest().as_hex().to_owned(); + assert_eq!( + prepared + .clone() + .qualify_preflight(&export( + "00000000-0000-4000-8000-000000000099", + PROFILE, + &query, + "", + 0, + )) + .unwrap_err(), + QualificationError::InvalidReadback + ); + assert_eq!( + prepared + .clone() + .qualify_preflight(&export(COMPANY_GUID, PROFILE, HASH, "", 0)) + .unwrap_err(), + QualificationError::InvalidReadback + ); + assert_eq!( + prepared + .clone() + .qualify_preflight(&export( + COMPANY_GUID, + "bridge.tally.ledgers/1", + &query, + "", + 0, + )) + .unwrap_err(), + QualificationError::InvalidReadback + ); + let sent = prepared + .qualify_preflight(&export(COMPANY_GUID, PROFILE, &query, "", 0)) + .unwrap() + .record_dispatch_attempt("request-scope", &mut registry) + .unwrap(); + let awaiting = sent + .record_import_receipt(&receipt(1, 0, 0), &mut registry) + .unwrap(); + let changed = ledger(REMOTE_ID, "BRIDGE LEDGER", "10.01"); + assert_eq!( + awaiting + .verify_readback( + &export(COMPANY_GUID, PROFILE, &query, &changed, 1), + &mut registry, + ) + .unwrap() + .outcome(), + WriteOutcome::Mismatch + ); +} + +#[test] +fn invalid_decimal_gstin_limits_duplicates_noops_and_replays_are_rejected() { + assert_eq!( + LedgerState::new("BRIDGE", None, None, Some("NaN".to_owned())).unwrap_err(), + QualificationError::InvalidField("opening_balance") + ); + assert_eq!( + LedgerState::new("BRIDGE", None, Some("not-a-gstin".to_owned()), None).unwrap_err(), + QualificationError::InvalidField("party_gstin") + ); + let unchanged = state("BRIDGE", "0"); + assert_eq!( + LedgerMutation::alter(REMOTE_ID, unchanged.clone(), unchanged.clone(), lineage(1),) + .unwrap_err(), + QualificationError::NoOpMutation + ); + let duplicate = create(REMOTE_ID, unchanged.clone(), 1); + let mut registry = IdempotencyRegistry::default(); + assert_eq!( + preview_ledger_import(&company(), &[duplicate.clone(), duplicate], "mapping-v1",) + .unwrap_err(), + QualificationError::DuplicateIdentity + ); + let too_many: Vec<_> = (0..=MAX_LEDGER_WRITE_BATCH) + .map(|index| create(format!("bridge-id-{index}"), unchanged.clone(), index)) + .collect(); + assert_eq!( + preview_ledger_import(&company(), &too_many, "mapping-v1").unwrap_err(), + QualificationError::InvalidBatchSize + ); + let mutation = create(REMOTE_ID, unchanged, 2); + prepare("key-replay", vec![mutation.clone()], &mut registry).unwrap(); + assert_eq!( + prepare("key-replay", vec![mutation], &mut registry).unwrap_err(), + QualificationError::DuplicateSubmission + ); +} + +#[test] +fn line_error_evidence_is_derived_and_never_debugs_raw_text() { + let first = bridge_tally_write::parse_import_receipt( + "00110PRIVATE-SYNTHETIC-SENTINEL-A", + ) + .unwrap(); + let second = bridge_tally_write::parse_import_receipt( + "00110PRIVATE-SYNTHETIC-SENTINEL-B", + ) + .unwrap(); + assert_ne!( + first.line_error_digests()[0].as_hex(), + second.line_error_digests()[0].as_hex() + ); + let debug = format!("{first:?}"); + assert!(!debug.contains("PRIVATE-SYNTHETIC")); + assert!(!debug.contains("SENTINEL")); +} + +#[test] +fn documented_direct_failure_receipt_retains_redacted_evidence_without_becoming_clean() { + let receipt = bridge_tally_write::parse_import_receipt( + "
0
000010PRIVATE-SYNTHETIC-FAILURE
", + ) + .expect("documented failure receipt remains auditable"); + assert_eq!( + receipt.application_status(), + bridge_tally_protocol::TallyImportApplicationStatus::Failure + ); + assert_eq!(receipt.counters().errors, 1); + assert_eq!(receipt.line_error_digests().len(), 1); + assert!(!receipt.exceptions_were_reported()); + let debug = format!("{receipt:?}"); + assert!(!debug.contains("PRIVATE-SYNTHETIC-FAILURE")); +} diff --git a/src-tauri/crates/tally-protocol-simulator/Cargo.toml b/src-tauri/crates/tally-protocol-simulator/Cargo.toml new file mode 100644 index 0000000..1240bbc --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "tally-protocol-simulator" +version = "0.1.0" +description = "Deterministic, synthetic Tally protocol simulator for Bridge tests" +license = "Apache-2.0" +repository = "https://github.com/lamemustafa/bridge" +publish = false +edition = "2021" +rust-version = "1.96" + +[dependencies] +bridge-tally-core = { path = "../bridge-tally-core" } +hex = "0.4" +quick-xml = "0.41" +serde_json = "1" +sha2 = "0.11" +thiserror = "2" diff --git a/src-tauri/crates/tally-protocol-simulator/README.md b/src-tauri/crates/tally-protocol-simulator/README.md new file mode 100644 index 0000000..713d653 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/README.md @@ -0,0 +1,70 @@ +# Deterministic Tally protocol simulator + +This crate supplies loopback-only one-request and bounded sequential HTTP +simulators plus a synthetic fixture corpus for Bridge's Tally protocol tests. It has no dependency on Tally, +SQLCipher, OpenSSL, Tauri, a network service, or customer books. + +## Scenario recipe + +A scenario is composed from six explicit dimensions: + +1. `Fixture` selects the application payload and its expected meaning. +2. `WireEncoding` selects UTF-8, UTF-8 with BOM, UTF-16LE, or UTF-16BE. +3. `Delivery` selects immediate delivery, slow headers, slow body, reset before + response, or delayed reset after the request is considered processed. +4. `http_status` keeps HTTP transport status separate from Tally's application + `STATUS`. +5. `ResponseFraming` selects Content-Length, close-delimited, chunked, or a + deliberately mismatched declared length. +6. `ResponseContentEncoding` selects no header, `identity`, or a synthetic + unsupported `gzip` claim. + +The simulators always bind an ephemeral `127.0.0.1` port. Production code does +not depend on this crate. `Simulator` handles exactly one request; +`SequenceSimulator` handles an explicit ordered plan of 1–64 requests. + +The versioned voucher generator is a separate scale-test input. It writes a +bounded corpus one window at a time, rejects a window above the 32 MiB encoded +body limit before touching the caller's writer, and returns only counts, byte +length, and a domain-separated SHA-256 digest. It never turns simulator output +into live-Tally evidence. The qualification controller pre-generates these +windows before starting a fresh parser worker so generator memory and time are +outside the measured process. + +The master generator produces up to 50,000 deterministic ledger masters with +strict company/schema/count evidence. It preflights the selected UTF-8 or +BOM-qualified UTF-16 wire representation against the same 32 MiB encoded-body +ceiling before returning bytes. Its counts and hashes are repository-synthetic +evidence, not a claim that a real Tally profile can export that size. + +```rust +use std::time::Duration; +use tally_protocol_simulator::{Delivery, Fixture, ScenarioPlan, Simulator}; + +let plan = ScenarioPlan::new(Fixture::ExportStatusZero) + .with_delivery(Delivery::SlowBody { + chunk_bytes: 8, + delay: Duration::from_millis(20), + }); +let simulator = Simulator::spawn(plan)?; +// Point a test client at simulator.address(). +# Ok::<(), std::io::Error>(()) +``` + +## Fixture rules + +- Use only names prefixed with `BRIDGE SYNTHETIC` and UUIDs in the reserved + synthetic range used by this directory. +- Do not copy output from a real company or Tally installation. +- Do not add emails, phone numbers, GST registrations, home-directory paths, or + developer usernames. +- Keep transport failure separate from application failure. HTTP 200 plus + `STATUS=0` is an application rejection, not a successful empty collection. +- Add a deterministic assertion whenever a fixture is added. +- Use `Fixture::InconsistentDateFilter` when a test needs a declared July + window whose synthetic response improperly contains a June voucher; accepting + that row as canonical state is always a defect. + +UTF-16 fixtures are derived byte-for-byte from their UTF-8 source by +`ScenarioPlan::response_bytes`; binary duplicates are intentionally not checked +in. This keeps the canonical fixture reviewable while testing both endian BOMs. diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/duplicate_export_metadata.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/duplicate_export_metadata.xml new file mode 100644 index 0000000..7ad5d8d --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/duplicate_export_metadata.xml @@ -0,0 +1,3 @@ +
11
+bridge.tally.ledgers/1LEDGERBRIDGE SYNTHETIC BOOK00000000-0000-4000-8000-0000000000010 +
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/duplicate_identity.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/duplicate_identity.xml new file mode 100644 index 0000000..5c01397 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/duplicate_identity.xml @@ -0,0 +1,6 @@ +
11
+ + + 10.00 + 20.00 +
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/empty_export.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/empty_export.xml new file mode 100644 index 0000000..0d74440 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/empty_export.xml @@ -0,0 +1 @@ +
11
bridge.tally.ledgers/1LEDGERBRIDGE SYNTHETIC BOOK00000000-0000-4000-8000-0000000000010
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/exact_decimals.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/exact_decimals.xml new file mode 100644 index 0000000..bab2e61 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/exact_decimals.xml @@ -0,0 +1,3 @@ +
11
+ 00.00-1180.00999999999999.9999 +
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_0.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_0.xml new file mode 100644 index 0000000..af214dc --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_0.xml @@ -0,0 +1 @@ +
10
BRIDGE_SYNTHETIC_APPLICATION_REJECTION
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_1.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_1.xml new file mode 100644 index 0000000..41c7fa8 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_1.xml @@ -0,0 +1 @@ +
11
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_invalid.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_invalid.xml new file mode 100644 index 0000000..bcba935 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_invalid.xml @@ -0,0 +1 @@ +
1-1
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_missing.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_missing.xml new file mode 100644 index 0000000..b95ee93 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/export_status_missing.xml @@ -0,0 +1 @@ +
1
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/import_counters.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/import_counters.xml new file mode 100644 index 0000000..4245549 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/import_counters.xml @@ -0,0 +1 @@ +2310000 diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/import_duplicate.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/import_duplicate.xml new file mode 100644 index 0000000..7b3ad58 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/import_duplicate.xml @@ -0,0 +1 @@ +0001100BRIDGE_SYNTHETIC_DUPLICATE_IDENTITY diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/import_partial.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/import_partial.xml new file mode 100644 index 0000000..7415e67 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/import_partial.xml @@ -0,0 +1 @@ +1101101BRIDGE_SYNTHETIC_VALIDATION_REJECTION diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/inconsistent_date_filter.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/inconsistent_date_filter.xml new file mode 100644 index 0000000..d157f21 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/inconsistent_date_filter.xml @@ -0,0 +1,16 @@ + +
1
+ + + + + 20260630 + Receipt + BRIDGE-DATE-FILTER-NEGATIVE-001 + No + No + 0 + + + +
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/malformed.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/malformed.xml new file mode 100644 index 0000000..e410567 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/malformed.xml @@ -0,0 +1 @@ +
1
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/malformed_export_metadata.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/malformed_export_metadata.xml new file mode 100644 index 0000000..61540e5 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/malformed_export_metadata.xml @@ -0,0 +1,3 @@ +
11
+bridge.tally.ledgers/1LEDGERBRIDGE SYNTHETIC BOOK00000000-0000-4000-8000-000000000001-1 +
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/normal_export.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/normal_export.xml new file mode 100644 index 0000000..70b1b94 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/normal_export.xml @@ -0,0 +1,11 @@ + +
11
+ + bridge.tally.ledgers/1LEDGERBRIDGE SYNTHETIC BOOK00000000-0000-4000-8000-0000000000011 + + + BRIDGE SYNTHETIC GROUP-1180.00 + + + +
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/record_count_mismatch.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/record_count_mismatch.xml new file mode 100644 index 0000000..7421e35 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/record_count_mismatch.xml @@ -0,0 +1,4 @@ +
11
+bridge.tally.ledgers/1LEDGERBRIDGE SYNTHETIC BOOK00000000-0000-4000-8000-0000000000012 +1.00 +
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/synthetic_json_semantic_reference.json b/src-tauri/crates/tally-protocol-simulator/fixtures/synthetic_json_semantic_reference.json new file mode 100644 index 0000000..00357bb --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/synthetic_json_semantic_reference.json @@ -0,0 +1 @@ +{"company":{"guid":"00000000-0000-4000-8000-000000000001","name":"BRIDGE SYNTHETIC BOOK"},"ledgers":[{"name":"BRIDGE SYNTHETIC LEDGER","opening_balance":"-1180.00","parent":"BRIDGE SYNTHETIC GROUP","guid":"00000000-0000-4000-8000-000000000101"}]} diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/truncated.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/truncated.xml new file mode 100644 index 0000000..bcba98e --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/truncated.xml @@ -0,0 +1 @@ +
1
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/voucher_export.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/voucher_export.xml new file mode 100644 index 0000000..c4f91a5 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/voucher_export.xml @@ -0,0 +1,4 @@ +
11
+ +20260701ReceiptBRIDGE-001BRIDGE SYNTHETIC LEDGERNoNo0 +
diff --git a/src-tauri/crates/tally-protocol-simulator/fixtures/wrong_company.xml b/src-tauri/crates/tally-protocol-simulator/fixtures/wrong_company.xml new file mode 100644 index 0000000..729f61e --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/fixtures/wrong_company.xml @@ -0,0 +1 @@ +
11
bridge.tally.ledgers/1LEDGERBRIDGE SYNTHETIC OTHER BOOK00000000-0000-4000-8000-0000000000020
diff --git a/src-tauri/crates/tally-protocol-simulator/src/fixtures.rs b/src-tauri/crates/tally-protocol-simulator/src/fixtures.rs new file mode 100644 index 0000000..cd9aea3 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/src/fixtures.rs @@ -0,0 +1,262 @@ +use std::{borrow::Cow, time::Duration}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProductStatus { + TallyPrime, + TallyErp9, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Fixture { + ProductStatus(ProductStatus), + ExportStatusOne, + ExportStatusZero, + ExportStatusMissing, + ExportStatusInvalid, + NormalExport, + EmptyExport, + DuplicateIdentity, + WrongCompany, + VoucherExport, + InconsistentDateFilter, + RecordCountMismatch, + MalformedExportMetadata, + DuplicateExportMetadata, + ExactDecimals, + ImportCounters, + ImportDuplicate, + ImportPartial, + MalformedXml, + TruncatedXml, + /// Bridge-shaped JSON used only to test semantic-reference plumbing. It + /// is not an official Tally JSONEX response envelope or a parity fixture. + SyntheticJsonSemanticReference, + UnsupportedCapability, + /// Caller-owned synthetic XML for qualification runner and transport tests. + /// The simulator never treats this as a real Tally grammar fixture. + SyntheticXml(String), + Oversized { + minimum_bytes: usize, + }, +} + +impl Fixture { + pub fn body(&self) -> Cow<'_, str> { + match self { + Self::ProductStatus(ProductStatus::TallyPrime) => { + Cow::Borrowed("TallyPrime Server is Running") + } + Self::ProductStatus(ProductStatus::TallyErp9) => { + Cow::Borrowed("Tally ERP 9 Server is Running") + } + Self::ProductStatus(ProductStatus::Unknown) => { + Cow::Borrowed("BRIDGE SYNTHETIC UNKNOWN PRODUCT") + } + Self::ExportStatusOne => { + Cow::Borrowed(include_str!("../fixtures/export_status_1.xml")) + } + Self::ExportStatusZero => { + Cow::Borrowed(include_str!("../fixtures/export_status_0.xml")) + } + Self::ExportStatusMissing => { + Cow::Borrowed(include_str!("../fixtures/export_status_missing.xml")) + } + Self::ExportStatusInvalid => { + Cow::Borrowed(include_str!("../fixtures/export_status_invalid.xml")) + } + Self::NormalExport => Cow::Borrowed(include_str!("../fixtures/normal_export.xml")), + Self::EmptyExport => Cow::Borrowed(include_str!("../fixtures/empty_export.xml")), + Self::DuplicateIdentity => { + Cow::Borrowed(include_str!("../fixtures/duplicate_identity.xml")) + } + Self::WrongCompany => Cow::Borrowed(include_str!("../fixtures/wrong_company.xml")), + Self::VoucherExport => Cow::Borrowed(include_str!("../fixtures/voucher_export.xml")), + Self::InconsistentDateFilter => Cow::Borrowed(include_str!( + "../fixtures/inconsistent_date_filter.xml" + )), + Self::RecordCountMismatch => { + Cow::Borrowed(include_str!("../fixtures/record_count_mismatch.xml")) + } + Self::MalformedExportMetadata => Cow::Borrowed(include_str!( + "../fixtures/malformed_export_metadata.xml" + )), + Self::DuplicateExportMetadata => Cow::Borrowed(include_str!( + "../fixtures/duplicate_export_metadata.xml" + )), + Self::ExactDecimals => { + Cow::Borrowed(include_str!("../fixtures/exact_decimals.xml")) + } + Self::ImportCounters => { + Cow::Borrowed(include_str!("../fixtures/import_counters.xml")) + } + Self::ImportDuplicate => { + Cow::Borrowed(include_str!("../fixtures/import_duplicate.xml")) + } + Self::ImportPartial => { + Cow::Borrowed(include_str!("../fixtures/import_partial.xml")) + } + Self::MalformedXml => Cow::Borrowed(include_str!("../fixtures/malformed.xml")), + Self::TruncatedXml => Cow::Borrowed(include_str!("../fixtures/truncated.xml")), + Self::SyntheticJsonSemanticReference => { + Cow::Borrowed(include_str!("../fixtures/synthetic_json_semantic_reference.json")) + } + Self::UnsupportedCapability => Cow::Borrowed( + "
10
BRIDGE_SYNTHETIC_CAPABILITY_UNSUPPORTED
", + ), + Self::SyntheticXml(xml) => Cow::Borrowed(xml.as_str()), + Self::Oversized { minimum_bytes } => { + let prefix = "
1
"; + let suffix = "
"; + let padding = minimum_bytes.saturating_sub(prefix.len() + suffix.len()); + Cow::Owned(format!("{prefix}{}{suffix}", "X".repeat(padding))) + } + } + } + + pub fn content_type(&self) -> &'static str { + if matches!(self, Self::SyntheticJsonSemanticReference) { + "application/json" + } else { + "text/xml" + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WireEncoding { + Utf8, + Utf8Bom, + Utf16Le, + Utf16Be, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Delivery { + Immediate, + SlowHeaders(Duration), + SlowBody { chunk_bytes: usize, delay: Duration }, + ResetBeforeBody, + ResetAfterRequestProcessed { delay: Duration }, +} + +/// Synthetic HTTP response framing. These modes characterize Bridge's client +/// resilience; they are not claims about framing guaranteed by Tally. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponseFraming { + ContentLength, + ConnectionClose, + Chunked { chunk_bytes: usize }, + DeclaredContentLength { bytes: usize }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponseContentEncoding { + None, + Identity, + Gzip, + DuplicateIdentityThenGzip, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScenarioPlan { + pub fixture: Fixture, + pub encoding: WireEncoding, + pub delivery: Delivery, + pub http_status: u16, + pub framing: ResponseFraming, + pub content_encoding: ResponseContentEncoding, + pub redirect_location: Option, +} + +impl ScenarioPlan { + pub fn new(fixture: Fixture) -> Self { + Self { + fixture, + encoding: WireEncoding::Utf8, + delivery: Delivery::Immediate, + http_status: 200, + framing: ResponseFraming::ContentLength, + content_encoding: ResponseContentEncoding::None, + redirect_location: None, + } + } + + pub fn with_encoding(mut self, encoding: WireEncoding) -> Self { + self.encoding = encoding; + self + } + + pub fn with_delivery(mut self, delivery: Delivery) -> Self { + self.delivery = delivery; + self + } + + pub fn with_http_status(mut self, status: u16) -> Self { + self.http_status = status; + self + } + + pub fn with_framing(mut self, framing: ResponseFraming) -> Self { + self.framing = framing; + self + } + + pub fn with_content_encoding(mut self, encoding: ResponseContentEncoding) -> Self { + self.content_encoding = encoding; + self + } + + pub fn with_redirect_location(mut self, location: impl Into) -> Self { + self.redirect_location = Some(location.into()); + self + } + + pub fn response_bytes(&self) -> Vec { + encode(self.fixture.body().as_ref(), self.encoding) + } +} + +pub fn encode(text: &str, encoding: WireEncoding) -> Vec { + match encoding { + WireEncoding::Utf8 => text.as_bytes().to_vec(), + WireEncoding::Utf8Bom => [b"\xEF\xBB\xBF".as_slice(), text.as_bytes()].concat(), + WireEncoding::Utf16Le => { + let mut bytes = vec![0xFF, 0xFE]; + bytes.extend(text.encode_utf16().flat_map(u16::to_le_bytes)); + bytes + } + WireEncoding::Utf16Be => { + let mut bytes = vec![0xFE, 0xFF]; + bytes.extend(text.encode_utf16().flat_map(u16::to_be_bytes)); + bytes + } + } +} + +pub fn decode(bytes: &[u8]) -> Result { + if let Some(payload) = bytes.strip_prefix(&[0xEF, 0xBB, 0xBF]) { + return String::from_utf8(payload.to_vec()).map_err(|_| "invalid_utf8"); + } + if let Some(payload) = bytes.strip_prefix(&[0xFF, 0xFE]) { + if payload.len() % 2 != 0 { + return Err("invalid_utf16le"); + } + let units = payload + .chunks_exact(2) + .map(|pair| u16::from_le_bytes([pair[0], pair[1]])) + .collect::>(); + return String::from_utf16(&units).map_err(|_| "invalid_utf16le"); + } + if let Some(payload) = bytes.strip_prefix(&[0xFE, 0xFF]) { + if payload.len() % 2 != 0 { + return Err("invalid_utf16be"); + } + let units = payload + .chunks_exact(2) + .map(|pair| u16::from_be_bytes([pair[0], pair[1]])) + .collect::>(); + return String::from_utf16(&units).map_err(|_| "invalid_utf16be"); + } + String::from_utf8(bytes.to_vec()).map_err(|_| "invalid_utf8") +} diff --git a/src-tauri/crates/tally-protocol-simulator/src/generator.rs b/src-tauri/crates/tally-protocol-simulator/src/generator.rs new file mode 100644 index 0000000..f173bd3 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/src/generator.rs @@ -0,0 +1,515 @@ +use std::{fmt::Write as _, io}; + +use sha2::{Digest, Sha256}; +use thiserror::Error; + +pub const MAX_GENERATED_RECORDS: u64 = 500_000; +pub const MAX_GENERATED_RECORDS_PER_WINDOW: u32 = 10_000; +pub const MAX_GENERATED_WINDOW_BYTES: u64 = 32 * 1024 * 1024; +const MAX_ENTRIES_PER_VOUCHER: u16 = 256; +const MAX_TEXT_WIDTH: u16 = 256; +const MAX_NESTING_DEPTH: u16 = 256; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VoucherCorpusSpec { + pub total_records: u64, + pub records_per_window: u32, + pub entries_per_voucher: u16, + pub text_width: u16, + pub nesting_depth: u16, + pub seed: u64, +} + +impl VoucherCorpusSpec { + pub fn validate(self) -> Result { + if !(1..=MAX_GENERATED_RECORDS).contains(&self.total_records) { + return Err(VoucherGenerationError::InvalidTotalRecords); + } + if !(1..=MAX_GENERATED_RECORDS_PER_WINDOW).contains(&self.records_per_window) { + return Err(VoucherGenerationError::InvalidRecordsPerWindow); + } + if self.entries_per_voucher > MAX_ENTRIES_PER_VOUCHER { + return Err(VoucherGenerationError::InvalidEntriesPerVoucher); + } + if self.text_width > MAX_TEXT_WIDTH { + return Err(VoucherGenerationError::InvalidTextWidth); + } + if self.nesting_depth > MAX_NESTING_DEPTH { + return Err(VoucherGenerationError::InvalidNestingDepth); + } + Ok(self) + } + + pub fn window_count(self) -> Result { + let validated = self.validate()?; + let divisor = u64::from(validated.records_per_window); + let windows = validated.total_records.div_ceil(divisor); + u32::try_from(windows).map_err(|_| VoucherGenerationError::InvalidTotalRecords) + } + + pub fn window(self, index: u32) -> Result { + let validated = self.validate()?; + if index >= validated.window_count()? { + return Err(VoucherGenerationError::InvalidWindowIndex); + } + let first_record = u64::from(index) * u64::from(validated.records_per_window); + let remaining = validated.total_records - first_record; + let record_count = remaining.min(u64::from(validated.records_per_window)) as u32; + Ok(VoucherWindowSpec { + window_index: index, + first_record, + record_count, + entries_per_voucher: validated.entries_per_voucher, + text_width: validated.text_width, + nesting_depth: validated.nesting_depth, + seed: validated.seed, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VoucherWindowSpec { + pub window_index: u32, + pub first_record: u64, + pub record_count: u32, + pub entries_per_voucher: u16, + pub text_width: u16, + pub nesting_depth: u16, + pub seed: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedWindow { + pub window_index: u32, + pub first_record: u64, + pub record_count: u32, + pub ledger_entry_count: u64, + pub wire_bytes: u64, + pub sha256: String, + pub expected_semantic_sha256: String, +} + +#[derive(Debug, Error)] +pub enum VoucherGenerationError { + #[error("total records must be between 1 and 500000")] + InvalidTotalRecords, + #[error("records per window must be between 1 and 10000")] + InvalidRecordsPerWindow, + #[error("entries per voucher must not exceed 256")] + InvalidEntriesPerVoucher, + #[error("synthetic text width must not exceed 256")] + InvalidTextWidth, + #[error("synthetic nesting depth must not exceed 256")] + InvalidNestingDepth, + #[error("window index was outside the corpus")] + InvalidWindowIndex, + #[error("record range exceeded the supported integer range")] + InvalidFirstRecord, + #[error("generated window exceeded the 32 MiB encoded-body limit")] + WindowTooLarge, + #[error("synthetic output failed")] + Io(#[from] io::Error), +} + +struct DigestWriter { + inner: W, + digest: Sha256, + bytes: u64, +} + +impl DigestWriter { + fn new(inner: W) -> Self { + let mut digest = Sha256::new(); + digest.update(b"bridge.tally.synthetic-voucher-window/1\0"); + Self { + inner, + digest, + bytes: 0, + } + } + + fn finish(self) -> (W, u64, String) { + (self.inner, self.bytes, hex::encode(self.digest.finalize())) + } +} + +impl io::Write for DigestWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let written = self.inner.write(bytes)?; + self.digest.update(&bytes[..written]); + self.bytes = self.bytes.saturating_add(written as u64); + Ok(written) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +struct BoundedCounter { + bytes: u64, +} + +impl io::Write for BoundedCounter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let next = self + .bytes + .checked_add(bytes.len() as u64) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "window too large"))?; + if next > MAX_GENERATED_WINDOW_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "window too large", + )); + } + self.bytes = next; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub fn generate_voucher_window( + target: W, + spec: VoucherWindowSpec, +) -> Result<(W, GeneratedWindow), VoucherGenerationError> { + if spec.record_count == 0 || spec.record_count > MAX_GENERATED_RECORDS_PER_WINDOW { + return Err(VoucherGenerationError::InvalidRecordsPerWindow); + } + if spec.entries_per_voucher > MAX_ENTRIES_PER_VOUCHER { + return Err(VoucherGenerationError::InvalidEntriesPerVoucher); + } + if spec.text_width > MAX_TEXT_WIDTH { + return Err(VoucherGenerationError::InvalidTextWidth); + } + if spec.nesting_depth > MAX_NESTING_DEPTH { + return Err(VoucherGenerationError::InvalidNestingDepth); + } + spec.first_record + .checked_add(u64::from(spec.record_count) - 1) + .ok_or(VoucherGenerationError::InvalidFirstRecord)?; + + let mut preflight = BoundedCounter { bytes: 0 }; + if write_voucher_xml(&mut preflight, spec, None).is_err() { + return Err(VoucherGenerationError::WindowTooLarge); + } + let preflight_bytes = preflight.bytes; + + let mut output = DigestWriter::new(target); + let mut semantic_digest = Sha256::new(); + semantic_digest.update(b"bridge.tally.synthetic-voucher-semantics/1\0"); + write_voucher_xml(&mut output, spec, Some(&mut semantic_digest))?; + io::Write::flush(&mut output)?; + + let (target, wire_bytes, sha256) = output.finish(); + debug_assert_eq!(wire_bytes, preflight_bytes); + Ok(( + target, + GeneratedWindow { + window_index: spec.window_index, + first_record: spec.first_record, + record_count: spec.record_count, + ledger_entry_count: u64::from(spec.record_count) + .saturating_mul(u64::from(spec.entries_per_voucher)), + wire_bytes, + sha256, + expected_semantic_sha256: hex::encode(semantic_digest.finalize()), + }, + )) +} + +fn write_voucher_xml( + output: &mut W, + spec: VoucherWindowSpec, + mut semantic_digest: Option<&mut Sha256>, +) -> Result<(), io::Error> { + io::Write::write_all( + output, + format!( + "
11
", + spec.record_count + ) + .as_bytes(), + )?; + + let padding = "X".repeat(usize::from(spec.text_width)); + for local_index in 0..u64::from(spec.record_count) { + let record = spec.first_record + local_index; + let identity = synthetic_identity(spec.seed, record); + let remote_id = format!("bridge-synthetic-{identity:016x}"); + let guid = format!( + "00000000-0000-4000-8{:03x}-{:012x}", + spec.window_index & 0x0fff, + identity & 0x0000_ffff_ffff_ffff + ); + let voucher_number = format!("BRIDGE-{identity:016X}-{padding}"); + let mut voucher = String::with_capacity( + 320 + padding.len() + + usize::from(spec.entries_per_voucher) * 180 + + usize::from(spec.nesting_depth) * 48, + ); + write!( + voucher, + "20260701Journal{voucher_number}BRIDGE SYNTHETIC PARTYNoNo{}", + spec.entries_per_voucher + ) + .expect("writing to String cannot fail"); + voucher.push_str(""); + for _ in 0..spec.nesting_depth { + voucher.push_str(""); + } + let mut entry_hashes = Vec::with_capacity(usize::from(spec.entries_per_voucher)); + for entry_index in 1..=spec.entries_per_voucher { + let ledger_name = format!("BRIDGE SYNTHETIC LEDGER {entry_index:03}"); + let entry = format!( + "{entry_index}{ledger_name}-1.00Yes" + ); + entry_hashes.push(( + entry_index, + ledger_name, + hex::encode(Sha256::digest(entry.as_bytes())), + )); + voucher.push_str(&entry); + } + for _ in 0..spec.nesting_depth { + voucher.push_str(""); + } + voucher.push_str(""); + if let Some(digest) = semantic_digest.as_deref_mut() { + semantic_field(digest, b"record_index", record.to_string().as_bytes()); + semantic_field(digest, b"source_id", guid.as_bytes()); + semantic_field(digest, b"identity_kind", b"guid"); + semantic_field(digest, b"guid", guid.as_bytes()); + semantic_field(digest, b"remote_id", remote_id.as_bytes()); + semantic_field(digest, b"master_id", b""); + semantic_field(digest, b"alter_id", b""); + semantic_field( + digest, + b"raw_source_sha256", + hex::encode(Sha256::digest(voucher.as_bytes())).as_bytes(), + ); + semantic_field(digest, b"voucher_id", guid.as_bytes()); + semantic_field(digest, b"date", b"20260701"); + semantic_field(digest, b"voucher_type", b"Journal"); + semantic_field(digest, b"voucher_number", voucher_number.as_bytes()); + semantic_field(digest, b"party_ledger_name", b"BRIDGE SYNTHETIC PARTY"); + semantic_field(digest, b"cancelled", b"false"); + semantic_field(digest, b"optional", b"false"); + semantic_field( + digest, + b"ledger_entry_count", + spec.entries_per_voucher.to_string().as_bytes(), + ); + for (entry_index, ledger_name, raw_sha256) in &entry_hashes { + semantic_field(digest, b"entry_index", entry_index.to_string().as_bytes()); + semantic_field(digest, b"ledger_name", ledger_name.as_bytes()); + semantic_field(digest, b"amount", b"-1.00"); + semantic_field(digest, b"is_deemed_positive", b"true"); + semantic_field(digest, b"entry_raw_source_sha256", raw_sha256.as_bytes()); + } + } + io::Write::write_all(output, voucher.as_bytes())?; + } + io::Write::write_all(output, b"
") +} + +fn semantic_field(digest: &mut Sha256, label: &[u8], value: &[u8]) { + digest.update((label.len() as u16).to_be_bytes()); + digest.update(label); + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); +} + +fn synthetic_identity(seed: u64, record: u64) -> u64 { + let mut value = seed ^ record.wrapping_mul(0x9e37_79b9_7f4a_7c15); + value ^= value >> 30; + value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9); + value ^= value >> 27; + value = value.wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn windows_cover_the_requested_corpus_exactly() { + let spec = VoucherCorpusSpec { + total_records: 2_501, + records_per_window: 1_000, + entries_per_voucher: 2, + text_width: 16, + nesting_depth: 0, + seed: 7, + }; + assert_eq!(spec.window_count().unwrap(), 3); + let windows = (0..3) + .map(|index| spec.window(index).unwrap()) + .collect::>(); + assert_eq!(windows[0].first_record, 0); + assert_eq!(windows[1].first_record, 1_000); + assert_eq!(windows[2].first_record, 2_000); + assert_eq!(windows[2].record_count, 501); + assert_eq!( + windows + .iter() + .map(|window| window.record_count) + .sum::(), + 2_501 + ); + } + + #[test] + fn generation_is_deterministic_with_expected_xml_shape() { + let spec = VoucherCorpusSpec { + total_records: 2, + records_per_window: 2, + entries_per_voucher: 3, + text_width: 8, + nesting_depth: 0, + seed: 42, + }; + let (_, first) = generate_voucher_window(Vec::new(), spec.window(0).unwrap()).unwrap(); + let (bytes, second) = generate_voucher_window(Vec::new(), spec.window(0).unwrap()).unwrap(); + assert_eq!(first, second); + assert_eq!(first.record_count, 2); + assert_eq!(first.ledger_entry_count, 6); + assert_eq!(first.wire_bytes, bytes.len() as u64); + let xml = String::from_utf8(bytes).unwrap(); + assert_eq!(xml.matches("").count(), 6); + assert!(!xml.contains("C:\\Users\\")); + assert!(!xml.contains("/Users/")); + } + + #[test] + fn ci_smoke_window_has_a_versioned_golden_digest() { + let spec = VoucherCorpusSpec { + total_records: 50, + records_per_window: 50, + entries_per_voucher: 2, + text_width: 16, + nesting_depth: 0, + seed: 7, + }; + let (_, generated) = generate_voucher_window(Vec::new(), spec.window(0).unwrap()).unwrap(); + assert_eq!(generated.wire_bytes, 38_164); + assert_eq!( + generated.sha256, + "fde2fda2ca5df0468ca95a1f7e7f184ee311ee2bcc43e7d771109031762ead2a" + ); + + let changed_seed = VoucherCorpusSpec { seed: 8, ..spec }; + let (_, changed) = + generate_voucher_window(Vec::new(), changed_seed.window(0).unwrap()).unwrap(); + assert_ne!(generated.sha256, changed.sha256); + } + + #[test] + fn deep_characterization_uses_bounded_synthetic_wrappers() { + let spec = VoucherCorpusSpec { + total_records: 1, + records_per_window: 1, + entries_per_voucher: 2, + text_width: 0, + nesting_depth: MAX_NESTING_DEPTH, + seed: 9, + }; + let (bytes, generated) = + generate_voucher_window(Vec::new(), spec.window(0).unwrap()).unwrap(); + let xml = String::from_utf8(bytes).unwrap(); + assert_eq!(xml.matches("").count(), 256); + assert_eq!(xml.matches("").count(), 256); + assert_eq!(generated.record_count, 1); + assert_eq!(generated.ledger_entry_count, 2); + } + + #[test] + fn all_dimensions_are_bounded() { + for invalid in [ + VoucherCorpusSpec { + total_records: 0, + records_per_window: 1, + entries_per_voucher: 0, + text_width: 0, + nesting_depth: 0, + seed: 0, + }, + VoucherCorpusSpec { + total_records: MAX_GENERATED_RECORDS + 1, + records_per_window: 1, + entries_per_voucher: 0, + text_width: 0, + nesting_depth: 0, + seed: 0, + }, + VoucherCorpusSpec { + total_records: 1, + records_per_window: 0, + entries_per_voucher: 0, + text_width: 0, + nesting_depth: 0, + seed: 0, + }, + VoucherCorpusSpec { + total_records: 1, + records_per_window: 1, + entries_per_voucher: MAX_ENTRIES_PER_VOUCHER + 1, + text_width: 0, + nesting_depth: 0, + seed: 0, + }, + VoucherCorpusSpec { + total_records: 1, + records_per_window: 1, + entries_per_voucher: 0, + text_width: MAX_TEXT_WIDTH + 1, + nesting_depth: 0, + seed: 0, + }, + VoucherCorpusSpec { + total_records: 1, + records_per_window: 1, + entries_per_voucher: 0, + text_width: 0, + nesting_depth: MAX_NESTING_DEPTH + 1, + seed: 0, + }, + ] { + assert!(invalid.validate().is_err()); + } + + let oversized = VoucherWindowSpec { + window_index: 0, + first_record: 0, + record_count: MAX_GENERATED_RECORDS_PER_WINDOW, + entries_per_voucher: MAX_ENTRIES_PER_VOUCHER, + text_width: MAX_TEXT_WIDTH, + nesting_depth: MAX_NESTING_DEPTH, + seed: 0, + }; + let mut untouched = Vec::new(); + assert!(matches!( + generate_voucher_window(&mut untouched, oversized), + Err(VoucherGenerationError::WindowTooLarge) + )); + assert!(untouched.is_empty()); + + let overflowing = VoucherWindowSpec { + window_index: 0, + first_record: u64::MAX, + record_count: 2, + entries_per_voucher: 0, + text_width: 0, + nesting_depth: 0, + seed: 0, + }; + assert!(matches!( + generate_voucher_window(Vec::new(), overflowing), + Err(VoucherGenerationError::InvalidFirstRecord) + )); + } +} diff --git a/src-tauri/crates/tally-protocol-simulator/src/lib.rs b/src-tauri/crates/tally-protocol-simulator/src/lib.rs new file mode 100644 index 0000000..fc37ad0 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/src/lib.rs @@ -0,0 +1,23 @@ +//! Deterministic, synthetic Tally protocol fixtures and a one-request loopback server. +//! +//! This crate never launches Tally and cannot bind a non-loopback interface. + +mod fixtures; +mod generator; +mod master_generator; +mod server; + +pub use fixtures::{ + decode, encode, Delivery, Fixture, ProductStatus, ResponseContentEncoding, ResponseFraming, + ScenarioPlan, WireEncoding, +}; +pub use generator::{ + generate_voucher_window, GeneratedWindow, VoucherCorpusSpec, VoucherGenerationError, + VoucherWindowSpec, MAX_GENERATED_RECORDS, MAX_GENERATED_RECORDS_PER_WINDOW, + MAX_GENERATED_WINDOW_BYTES, +}; +pub use master_generator::{ + generate_master_corpus, GeneratedMasterCorpus, MasterCorpusSpec, MasterGenerationError, + MAX_GENERATED_MASTERS, MAX_MASTER_TEXT_WIDTH, +}; +pub use server::{ObservedRequest, SequenceSimulator, Simulator, MAX_SEQUENCE_REQUESTS}; diff --git a/src-tauri/crates/tally-protocol-simulator/src/master_generator.rs b/src-tauri/crates/tally-protocol-simulator/src/master_generator.rs new file mode 100644 index 0000000..0aff9cf --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/src/master_generator.rs @@ -0,0 +1,258 @@ +use std::io; + +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::{encode, WireEncoding}; + +pub const MAX_GENERATED_MASTERS: u32 = 50_000; +pub const MAX_MASTER_TEXT_WIDTH: u16 = 64; +const MAX_ENCODED_MASTER_BYTES: usize = 32 * 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MasterCorpusSpec { + pub total_records: u32, + pub text_width: u16, + pub seed: u64, +} + +impl MasterCorpusSpec { + pub fn validate(self) -> Result { + if !(1..=MAX_GENERATED_MASTERS).contains(&self.total_records) { + return Err(MasterGenerationError::InvalidRecordCount); + } + if self.text_width > MAX_MASTER_TEXT_WIDTH { + return Err(MasterGenerationError::InvalidTextWidth); + } + Ok(self) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedMasterCorpus { + pub records: u32, + pub encoding: WireEncoding, + pub encoded_bytes: u64, + pub sha256: String, + pub expected_semantic_sha256: String, +} + +#[derive(Debug, Error)] +pub enum MasterGenerationError { + #[error("master records must be between 1 and 50000")] + InvalidRecordCount, + #[error("master synthetic text width must not exceed 64")] + InvalidTextWidth, + #[error("encoded master corpus exceeded the 32 MiB production response limit")] + EncodedBodyTooLarge, + #[error("synthetic master generation failed")] + Io(#[from] io::Error), +} + +pub fn generate_master_corpus( + spec: MasterCorpusSpec, + encoding: WireEncoding, +) -> Result<(Vec, GeneratedMasterCorpus), MasterGenerationError> { + let spec = spec.validate()?; + let mut counter = CountingWriter { bytes: 0 }; + write_master_xml(&mut counter, spec, None)?; + let encoded_bytes = predicted_encoded_bytes(counter.bytes, encoding) + .ok_or(MasterGenerationError::EncodedBodyTooLarge)?; + if encoded_bytes > MAX_ENCODED_MASTER_BYTES { + return Err(MasterGenerationError::EncodedBodyTooLarge); + } + + let mut utf8 = Vec::with_capacity(counter.bytes); + let mut semantic_digest = Sha256::new(); + semantic_digest.update(b"bridge.tally.synthetic-master-semantics/1\0"); + write_master_xml(&mut utf8, spec, Some(&mut semantic_digest))?; + debug_assert_eq!(utf8.len(), counter.bytes); + let text = String::from_utf8(utf8).expect("synthetic master XML is ASCII-compatible UTF-8"); + let bytes = encode(&text, encoding); + debug_assert_eq!(bytes.len(), encoded_bytes); + + let mut wire_digest = Sha256::new(); + wire_digest.update(b"bridge.tally.synthetic-master-wire/1\0"); + wire_digest.update(&bytes); + Ok(( + bytes, + GeneratedMasterCorpus { + records: spec.total_records, + encoding, + encoded_bytes: encoded_bytes as u64, + sha256: hex::encode(wire_digest.finalize()), + expected_semantic_sha256: hex::encode(semantic_digest.finalize()), + }, + )) +} + +fn predicted_encoded_bytes(utf8_bytes: usize, encoding: WireEncoding) -> Option { + match encoding { + WireEncoding::Utf8 => Some(utf8_bytes), + WireEncoding::Utf8Bom => utf8_bytes.checked_add(3), + // Every generated code point is ASCII, so each UTF-8 byte becomes one + // UTF-16 code unit plus a two-byte BOM. + WireEncoding::Utf16Le | WireEncoding::Utf16Be => utf8_bytes.checked_mul(2)?.checked_add(2), + } +} + +fn write_master_xml( + output: &mut W, + spec: MasterCorpusSpec, + mut semantic_digest: Option<&mut Sha256>, +) -> io::Result<()> { + write!( + output, + "
11
", + spec.total_records + )?; + let padding = "X".repeat(usize::from(spec.text_width)); + for record in 0..spec.total_records { + let identity = synthetic_identity(spec.seed, u64::from(record)); + let guid = format!( + "00000000-0000-4000-8{:03x}-{:012x}", + record & 0x0fff, + identity & 0x0000_ffff_ffff_ffff + ); + let remote_id = format!("bridge-synthetic-master-{identity:016x}"); + let master_id = u64::from(record) + 1; + let alter_id = master_id.saturating_mul(2); + let name = format!("BRIDGE SYNTHETIC LEDGER {record:05}-{padding}"); + let parent = "BRIDGE SYNTHETIC GROUP"; + let opening_balance = if record % 2 == 0 { + "-123.450" + } else { + "123.450" + }; + write!( + output, + "{parent}{opening_balance}" + )?; + if let Some(digest) = semantic_digest.as_deref_mut() { + semantic_field(digest, b"record_index", record.to_string().as_bytes()); + semantic_field(digest, b"name", name.as_bytes()); + semantic_field(digest, b"guid", guid.as_bytes()); + semantic_field(digest, b"remote_id", remote_id.as_bytes()); + semantic_field(digest, b"master_id", master_id.to_string().as_bytes()); + semantic_field(digest, b"alter_id", alter_id.to_string().as_bytes()); + semantic_field(digest, b"parent", parent.as_bytes()); + semantic_field(digest, b"opening_balance", opening_balance.as_bytes()); + } + } + output.write_all(b"
") +} + +fn semantic_field(digest: &mut Sha256, label: &[u8], value: &[u8]) { + digest.update((label.len() as u16).to_be_bytes()); + digest.update(label); + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); +} + +fn synthetic_identity(seed: u64, record: u64) -> u64 { + let mut value = seed ^ record.wrapping_mul(0x9e37_79b9_7f4a_7c15); + value ^= value >> 30; + value = value.wrapping_mul(0xbf58_476d_1ce4_e5b9); + value ^= value >> 27; + value = value.wrapping_mul(0x94d0_49bb_1331_11eb); + value ^ (value >> 31) +} + +struct CountingWriter { + bytes: usize, +} + +impl io::Write for CountingWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.bytes = self + .bytes + .checked_add(bytes.len()) + .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "master corpus too large"))?; + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decode; + + #[test] + fn master_generation_is_deterministic_and_parser_valid() { + let spec = MasterCorpusSpec { + total_records: 1_000, + text_width: 8, + seed: 17, + }; + let (first_bytes, first) = generate_master_corpus(spec, WireEncoding::Utf8).unwrap(); + let (second_bytes, second) = generate_master_corpus(spec, WireEncoding::Utf8).unwrap(); + assert_eq!(first_bytes, second_bytes); + assert_eq!(first, second); + let xml = decode(&first_bytes).unwrap(); + assert_eq!(xml.matches(" assert_eq!(&generated.expected_semantic_sha256, expected), + None => semantic = Some(generated.expected_semantic_sha256), + } + } + } + + #[test] + fn maximum_dimensions_are_preflighted_before_output() { + assert!(MasterCorpusSpec { + total_records: 0, + text_width: 0, + seed: 0, + } + .validate() + .is_err()); + assert!(MasterCorpusSpec { + total_records: MAX_GENERATED_MASTERS + 1, + text_width: 0, + seed: 0, + } + .validate() + .is_err()); + assert!(MasterCorpusSpec { + total_records: 1, + text_width: MAX_MASTER_TEXT_WIDTH + 1, + seed: 0, + } + .validate() + .is_err()); + + let maximum = MasterCorpusSpec { + total_records: MAX_GENERATED_MASTERS, + text_width: MAX_MASTER_TEXT_WIDTH, + seed: 23, + }; + let (bytes, generated) = generate_master_corpus(maximum, WireEncoding::Utf16Le).unwrap(); + assert_eq!(generated.records, MAX_GENERATED_MASTERS); + assert_eq!(generated.encoded_bytes, bytes.len() as u64); + assert!(bytes.len() <= MAX_ENCODED_MASTER_BYTES); + } +} diff --git a/src-tauri/crates/tally-protocol-simulator/src/server.rs b/src-tauri/crates/tally-protocol-simulator/src/server.rs new file mode 100644 index 0000000..d19976b --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/src/server.rs @@ -0,0 +1,501 @@ +use std::{ + io::{self, Read, Write}, + net::{Ipv4Addr, Shutdown, SocketAddr, SocketAddrV4, TcpListener, TcpStream}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, + }, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; + +use crate::{Delivery, ResponseContentEncoding, ResponseFraming, ScenarioPlan, WireEncoding}; +use sha2::{Digest, Sha256}; + +// Full workspace runs can briefly starve the simulator thread while Windows links or scans +// several test binaries in parallel. Keep the synthetic peer patient enough that scheduler +// delay is not misclassified as a Tally transport failure; cancellation still wakes accept. +const ACCEPT_DEADLINE: Duration = Duration::from_secs(30); +const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(2); +const MAX_REQUEST_BYTES: usize = 128 * 1024; +pub const MAX_SEQUENCE_REQUESTS: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservedRequest { + pub method: String, + pub path: String, + pub bytes_received: usize, + pub request_body_bytes: usize, + pub request_body_sha256: String, + pub request_processed: bool, + pub cancelled: bool, +} + +pub struct Simulator { + address: SocketAddr, + cancelled: Arc, + worker: Option>>, +} + +pub struct SequenceSimulator { + address: SocketAddr, + cancelled: Arc, + worker: Option>>>, +} + +impl Simulator { + pub fn spawn(plan: ScenarioPlan) -> io::Result { + let listener = bind_loopback_listener()?; + listener.set_nonblocking(true)?; + let address = listener.local_addr()?; + debug_assert!(address.ip().is_loopback()); + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let (ready_tx, ready_rx) = mpsc::channel(); + let worker = thread::Builder::new() + .name("tally-protocol-simulator".to_owned()) + .spawn(move || { + let _ = ready_tx.send(()); + serve_once(listener, plan, worker_cancelled) + })?; + ready_rx + .recv_timeout(Duration::from_secs(1)) + .map_err(|_| io::Error::other("simulator worker did not become ready"))?; + Ok(Self { + address, + cancelled, + worker: Some(worker), + }) + } + + pub fn address(&self) -> SocketAddr { + self.address + } + + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + let _ = TcpStream::connect_timeout(&self.address, Duration::from_millis(50)); + } + + pub fn finish(mut self) -> io::Result { + let worker = self + .worker + .take() + .ok_or_else(|| io::Error::other("simulator worker already joined"))?; + worker + .join() + .map_err(|_| io::Error::other("simulator worker panicked"))? + } +} + +impl SequenceSimulator { + pub fn spawn(plans: Vec) -> io::Result { + if plans.is_empty() || plans.len() > MAX_SEQUENCE_REQUESTS { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "simulator sequence request count is out of range", + )); + } + let listener = bind_loopback_listener()?; + listener.set_nonblocking(true)?; + let address = listener.local_addr()?; + debug_assert!(address.ip().is_loopback()); + let cancelled = Arc::new(AtomicBool::new(false)); + let worker_cancelled = Arc::clone(&cancelled); + let (ready_tx, ready_rx) = mpsc::channel(); + let worker = thread::Builder::new() + .name("tally-protocol-sequence-simulator".to_owned()) + .spawn(move || { + let _ = ready_tx.send(()); + serve_sequence(listener, plans, worker_cancelled) + })?; + ready_rx + .recv_timeout(Duration::from_secs(1)) + .map_err(|_| io::Error::other("simulator sequence worker did not become ready"))?; + Ok(Self { + address, + cancelled, + worker: Some(worker), + }) + } + + pub fn address(&self) -> SocketAddr { + self.address + } + + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + let _ = TcpStream::connect_timeout(&self.address, Duration::from_millis(50)); + } + + pub fn finish(mut self) -> io::Result> { + let worker = self + .worker + .take() + .ok_or_else(|| io::Error::other("sequence simulator worker already joined"))?; + worker + .join() + .map_err(|_| io::Error::other("sequence simulator worker panicked"))? + } +} + +fn bind_loopback_listener() -> io::Result { + TcpListener::bind(SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0)) +} + +impl Drop for SequenceSimulator { + fn drop(&mut self) { + if let Some(worker) = self.worker.take() { + if !worker.is_finished() { + self.cancel(); + } + let _ = worker.join(); + } + } +} + +impl Drop for Simulator { + fn drop(&mut self) { + if let Some(worker) = self.worker.take() { + if !worker.is_finished() { + self.cancel(); + } + let _ = worker.join(); + } + } +} + +fn serve_once( + listener: TcpListener, + plan: ScenarioPlan, + cancelled: Arc, +) -> io::Result { + serve_request(&listener, plan, &cancelled) +} + +fn serve_sequence( + listener: TcpListener, + plans: Vec, + cancelled: Arc, +) -> io::Result> { + let mut observed = Vec::with_capacity(plans.len()); + for plan in plans { + if cancelled.load(Ordering::Acquire) { + break; + } + observed.push(serve_request(&listener, plan, &cancelled)?); + } + Ok(observed) +} + +fn serve_request( + listener: &TcpListener, + plan: ScenarioPlan, + cancelled: &AtomicBool, +) -> io::Result { + let started = Instant::now(); + let (mut stream, request) = loop { + let (mut stream, _) = match listener.accept() { + Ok(accepted) => accepted, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + if started.elapsed() >= ACCEPT_DEADLINE { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "simulator received no request", + )); + } + thread::sleep(Duration::from_millis(2)); + continue; + } + Err(error) => return Err(error), + }; + stream.set_nodelay(true)?; + stream.set_read_timeout(Some(REQUEST_READ_TIMEOUT))?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + match read_request(&mut stream) { + Ok(request) if request.is_empty() && !cancelled.load(Ordering::Acquire) => continue, + Ok(request) => break (stream, request), + Err(_) if cancelled.load(Ordering::Acquire) => break (stream, Vec::new()), + Err(error) + if error.kind() == io::ErrorKind::TimedOut + && !cancelled.load(Ordering::Acquire) => + { + continue; + } + Err(error) => return Err(error), + } + }; + let (method, path) = request_line(&request); + let request_body = request_body(&request); + let mut observed = ObservedRequest { + method, + path, + bytes_received: request.len(), + request_body_bytes: request_body.len(), + request_body_sha256: hex::encode(Sha256::digest(request_body)), + request_processed: false, + cancelled: cancelled.load(Ordering::Acquire), + }; + if observed.cancelled { + return Ok(observed); + } + + let body = plan.response_bytes(); + let headers = response_headers(&plan, body.len()); + match plan.delivery { + Delivery::Immediate => { + observed.request_processed = true; + stream.write_all(headers.as_bytes())?; + stream.flush()?; + observed.cancelled = + write_framed_body(&mut stream, &body, plan.framing, None, cancelled)?; + finish_complete_response(&mut stream, observed.cancelled)?; + } + Delivery::SlowHeaders(delay) => { + if sleep_cancellable(delay, cancelled) { + observed.cancelled = true; + return Ok(observed); + } + observed.request_processed = true; + stream.write_all(headers.as_bytes())?; + stream.flush()?; + observed.cancelled = + write_framed_body(&mut stream, &body, plan.framing, None, cancelled)?; + finish_complete_response(&mut stream, observed.cancelled)?; + } + Delivery::SlowBody { chunk_bytes, delay } => { + if chunk_bytes == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "slow-body chunk size must be positive", + )); + } + observed.request_processed = true; + stream.write_all(headers.as_bytes())?; + stream.flush()?; + observed.cancelled = write_framed_body( + &mut stream, + &body, + plan.framing, + Some((chunk_bytes, delay)), + cancelled, + )?; + finish_complete_response(&mut stream, observed.cancelled)?; + } + Delivery::ResetBeforeBody => { + stream.write_all(headers.as_bytes())?; + stream.flush()?; + // A declared body length with no body exercises truncated HTTP delivery. + } + Delivery::ResetAfterRequestProcessed { delay } => { + observed.request_processed = true; + if sleep_cancellable(delay, cancelled) { + observed.cancelled = true; + } + // No response is emitted: a write client must treat this as ambiguous. + } + } + Ok(observed) +} + +fn finish_complete_response(stream: &mut TcpStream, cancelled: bool) -> io::Result<()> { + if !cancelled { + stream.flush()?; + stream.shutdown(Shutdown::Write)?; + // Give Windows' loopback stack time to deliver the FIN and buffered body before the + // server thread drops the socket. Immediate drop is observably flaky under parallel CI. + thread::sleep(Duration::from_millis(5)); + } + Ok(()) +} + +fn read_request(stream: &mut TcpStream) -> io::Result> { + let mut request = Vec::new(); + let mut buffer = [0_u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(read) => { + if request.len().saturating_add(read) > MAX_REQUEST_BYTES { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "synthetic request exceeded simulator limit", + )); + } + request.extend_from_slice(&buffer[..read]); + if request_complete(&request) { + break; + } + } + Err(error) + if matches!( + error.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "synthetic request body was incomplete (received {}, expected {:?})", + request.len(), + expected_request_bytes(&request) + ), + )); + } + Err(error) => return Err(error), + } + } + Ok(request) +} + +fn request_complete(request: &[u8]) -> bool { + expected_request_bytes(request).is_some_and(|expected| request.len() >= expected) +} + +fn expected_request_bytes(request: &[u8]) -> Option { + let header_end = find_bytes(request, b"\r\n\r\n")?; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + Some(header_end + 4 + content_length) +} + +fn request_line(request: &[u8]) -> (String, String) { + let text = String::from_utf8_lossy(request); + let mut parts = text.lines().next().unwrap_or_default().split_whitespace(); + ( + parts.next().unwrap_or_default().to_owned(), + parts.next().unwrap_or_default().to_owned(), + ) +} + +fn request_body(request: &[u8]) -> &[u8] { + find_bytes(request, b"\r\n\r\n") + .and_then(|header_end| request.get(header_end + 4..)) + .unwrap_or_default() +} + +fn response_headers(plan: &ScenarioPlan, body_len: usize) -> String { + let reason = match plan.http_status { + 200 => "OK", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Synthetic Status", + }; + let charset = match plan.encoding { + WireEncoding::Utf16Le | WireEncoding::Utf16Be => "; charset=utf-16", + WireEncoding::Utf8 | WireEncoding::Utf8Bom + if plan.fixture.content_type() == "application/json" => + { + "; charset=utf-8" + } + WireEncoding::Utf8 | WireEncoding::Utf8Bom => "", + }; + let framing = match plan.framing { + ResponseFraming::ContentLength => format!("Content-Length: {body_len}\r\n"), + ResponseFraming::ConnectionClose => String::new(), + ResponseFraming::Chunked { .. } => "Transfer-Encoding: chunked\r\n".to_owned(), + ResponseFraming::DeclaredContentLength { bytes } => { + format!("Content-Length: {bytes}\r\n") + } + }; + let content_encoding = match plan.content_encoding { + ResponseContentEncoding::None => "", + ResponseContentEncoding::Identity => "Content-Encoding: identity\r\n", + ResponseContentEncoding::Gzip => "Content-Encoding: gzip\r\n", + ResponseContentEncoding::DuplicateIdentityThenGzip => { + "Content-Encoding: identity\r\nContent-Encoding: gzip\r\n" + } + }; + let redirect_location = plan + .redirect_location + .as_deref() + .filter(|value| !value.chars().any(char::is_control)) + .map(|value| format!("Location: {value}\r\n")) + .unwrap_or_default(); + format!( + "HTTP/1.1 {} {}\r\nContent-Type: {}{}\r\n{}{}{}Connection: close\r\nX-Bridge-Synthetic: 1\r\n\r\n", + plan.http_status, + reason, + plan.fixture.content_type(), + charset, + framing, + content_encoding, + redirect_location, + ) +} + +fn write_framed_body( + stream: &mut TcpStream, + body: &[u8], + framing: ResponseFraming, + slow_delivery: Option<(usize, Duration)>, + cancelled: &AtomicBool, +) -> io::Result { + let framing_chunk = match framing { + ResponseFraming::Chunked { chunk_bytes: 0 } => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "chunked framing size must be positive", + )); + } + ResponseFraming::Chunked { chunk_bytes } => Some(chunk_bytes), + _ => None, + }; + let delivery_chunk = slow_delivery.map(|(chunk_bytes, _)| chunk_bytes); + let chunk_bytes = match (framing_chunk, delivery_chunk) { + (Some(left), Some(right)) => left.min(right), + (Some(value), None) | (None, Some(value)) => value, + (None, None) => body.len().max(1), + }; + + for chunk in body.chunks(chunk_bytes) { + if cancelled.load(Ordering::Acquire) { + return Ok(true); + } + if matches!(framing, ResponseFraming::Chunked { .. }) { + write!(stream, "{:X}\r\n", chunk.len())?; + stream.write_all(chunk)?; + stream.write_all(b"\r\n")?; + } else { + stream.write_all(chunk)?; + } + if let Some((_, delay)) = slow_delivery { + if sleep_cancellable(delay, cancelled) { + return Ok(true); + } + } + } + if matches!(framing, ResponseFraming::Chunked { .. }) { + stream.write_all(b"0\r\n\r\n")?; + } + Ok(false) +} + +fn sleep_cancellable(duration: Duration, cancelled: &AtomicBool) -> bool { + let deadline = Instant::now() + duration; + loop { + if cancelled.load(Ordering::Acquire) { + return true; + } + let now = Instant::now(); + if now >= deadline { + return false; + } + thread::sleep((deadline - now).min(Duration::from_millis(5))); + } +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} diff --git a/src-tauri/crates/tally-protocol-simulator/tests/protocol_simulator.rs b/src-tauri/crates/tally-protocol-simulator/tests/protocol_simulator.rs new file mode 100644 index 0000000..91a52a3 --- /dev/null +++ b/src-tauri/crates/tally-protocol-simulator/tests/protocol_simulator.rs @@ -0,0 +1,462 @@ +use std::{ + collections::BTreeMap, + io::{Read, Write}, + net::TcpStream, + time::{Duration, Instant}, +}; + +use bridge_tally_core::ExactDecimal; +use quick_xml::{events::Event, Reader}; +use tally_protocol_simulator::{ + decode, Delivery, Fixture, ProductStatus, ScenarioPlan, SequenceSimulator, Simulator, + WireEncoding, MAX_SEQUENCE_REQUESTS, +}; + +fn request(simulator: Simulator, method: &str, path: &str) -> (Vec, bool) { + let mut stream = TcpStream::connect(simulator.address()).expect("connect loopback simulator"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set read timeout"); + write!( + stream, + "{method} {path} HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .expect("write synthetic request"); + let mut response = Vec::new(); + stream.read_to_end(&mut response).expect("read response"); + let observed = simulator.finish().expect("finish simulator"); + assert_eq!(observed.method, method); + assert_eq!(observed.path, path); + (response, observed.request_processed) +} + +fn response_body(response: &[u8]) -> &[u8] { + let marker = b"\r\n\r\n"; + let offset = response + .windows(marker.len()) + .position(|window| window == marker) + .expect("HTTP header terminator"); + &response[offset + marker.len()..] +} + +#[test] +fn bounded_sequence_serves_plans_in_exact_request_order() { + let simulator = SequenceSimulator::spawn(vec![ + ScenarioPlan::new(Fixture::ExportStatusOne).with_http_status(500), + ScenarioPlan::new(Fixture::ExportStatusOne), + ]) + .expect("spawn sequence simulator"); + for expected_status in ["500 Internal Server Error", "200 OK"] { + let mut stream = TcpStream::connect(simulator.address()).expect("connect sequence"); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("set sequence read timeout"); + write!( + stream, + "POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + ) + .expect("write sequence request"); + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .expect("read sequence response"); + assert!(String::from_utf8_lossy(&response) + .starts_with(&format!("HTTP/1.1 {expected_status}\r\n"))); + } + let observed = simulator.finish().expect("finish sequence simulator"); + assert_eq!(observed.len(), 2); + assert!(observed.iter().all(|request| { + request.method == "POST" && request.path == "/" && request.request_processed + })); +} + +#[test] +fn sequence_request_count_is_fail_closed() { + assert!(SequenceSimulator::spawn(Vec::new()).is_err()); + assert!(SequenceSimulator::spawn( + (0..=MAX_SEQUENCE_REQUESTS) + .map(|_| ScenarioPlan::new(Fixture::ExportStatusOne)) + .collect() + ) + .is_err()); +} + +fn element_values(xml: &str, element_name: &[u8]) -> Result, String> { + let mut reader = Reader::from_str(xml); + reader.config_mut().trim_text(true); + let mut values = Vec::new(); + loop { + match reader.read_event().map_err(|error| error.to_string())? { + Event::Start(element) if element.name().as_ref() == element_name => { + values.push( + reader + .read_text(element.name()) + .map_err(|error| error.to_string())? + .decode() + .map_err(|error| error.to_string())? + .into_owned(), + ); + } + Event::Eof => break, + _ => {} + } + } + Ok(values) +} + +fn assert_well_formed(xml: &str) -> Result<(), String> { + let mut reader = Reader::from_str(xml); + let mut open = Vec::>::new(); + loop { + match reader.read_event().map_err(|error| error.to_string())? { + Event::Start(element) => open.push(element.name().as_ref().to_vec()), + Event::End(element) => { + let expected = open + .pop() + .ok_or_else(|| "unexpected closing element".to_owned())?; + if expected != element.name().as_ref() { + return Err("mismatched closing element".to_owned()); + } + } + Event::Eof if open.is_empty() => return Ok(()), + Event::Eof => return Err("document ended before all elements closed".to_owned()), + _ => {} + } + } +} + +#[test] +fn application_status_matrix_is_explicit_and_independent_of_http_200() { + let cases = [ + (Fixture::ExportStatusOne, Some("1")), + (Fixture::ExportStatusZero, Some("0")), + (Fixture::ExportStatusMissing, None), + (Fixture::ExportStatusInvalid, Some("-1")), + ]; + for (fixture, expected) in cases { + let body = fixture.body(); + let status = element_values(&body, b"STATUS").expect("status fixture must be XML"); + assert_eq!(status.first().map(String::as_str), expected); + + let simulator = Simulator::spawn(ScenarioPlan::new(fixture)).expect("spawn simulator"); + let (response, processed) = request(simulator, "POST", "/"); + assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); + assert!(processed); + } + + let simulator = + Simulator::spawn(ScenarioPlan::new(Fixture::ExportStatusOne).with_http_status(500)) + .expect("spawn HTTP failure simulator"); + let (response, _) = request(simulator, "POST", "/"); + assert!(response.starts_with(b"HTTP/1.1 500 Internal Server Error\r\n")); + assert_eq!( + element_values( + std::str::from_utf8(response_body(&response)).expect("UTF-8 body"), + b"STATUS", + ) + .unwrap(), + ["1"] + ); +} + +#[test] +fn product_status_catalog_covers_prime_erp9_and_unknown() { + let cases = [ + (ProductStatus::TallyPrime, "TallyPrime Server is Running"), + (ProductStatus::TallyErp9, "Tally ERP 9 Server is Running"), + (ProductStatus::Unknown, "BRIDGE SYNTHETIC UNKNOWN PRODUCT"), + ]; + for (product, marker) in cases { + let simulator = Simulator::spawn(ScenarioPlan::new(Fixture::ProductStatus(product))) + .expect("spawn status simulator"); + let (response, _) = request(simulator, "GET", "/status"); + let body = std::str::from_utf8(response_body(&response)).expect("status UTF-8"); + assert!(body.contains(marker)); + } +} + +#[test] +fn utf8_bom_and_both_utf16_endiannesses_have_identical_text() { + let source = Fixture::NormalExport.body().into_owned(); + for encoding in [ + WireEncoding::Utf8, + WireEncoding::Utf8Bom, + WireEncoding::Utf16Le, + WireEncoding::Utf16Be, + ] { + let plan = ScenarioPlan::new(Fixture::NormalExport).with_encoding(encoding); + let bytes = plan.response_bytes(); + assert_eq!(decode(&bytes).expect("decode supported encoding"), source); + + let simulator = Simulator::spawn(plan).expect("spawn encoded simulator"); + let (response, _) = request(simulator, "POST", "/"); + assert_eq!( + decode(response_body(&response)).expect("decode HTTP body"), + source + ); + } + assert!(decode(&[0xFF, 0xFE, 0x00]).is_err()); + assert!(decode(&[0x80]).is_err()); +} + +#[test] +fn malformed_and_truncated_xml_are_not_well_formed() { + assert!(assert_well_formed(&Fixture::MalformedXml.body()).is_err()); + assert!(assert_well_formed(&Fixture::TruncatedXml.body()).is_err()); + assert!(assert_well_formed(&Fixture::NormalExport.body()).is_ok()); +} + +#[test] +fn company_context_and_duplicate_identity_anomalies_are_deterministic() { + let normal = Fixture::NormalExport.body(); + assert!(normal.contains("BRIDGE SYNTHETIC BOOK")); + assert!(normal.contains("00000000-0000-4000-8000-000000000001")); + + let wrong = Fixture::WrongCompany.body(); + assert!(wrong.contains("BRIDGE SYNTHETIC OTHER BOOK")); + assert!(wrong.contains("00000000-0000-4000-8000-000000000002")); + assert!(!wrong.contains("00000000-0000-4000-8000-000000000001")); + + let duplicate = Fixture::DuplicateIdentity.body(); + let duplicated_guid = "00000000-0000-4000-8000-000000000199"; + assert_eq!(duplicate.matches(duplicated_guid).count(), 2); +} + +#[test] +fn exact_decimal_forms_never_use_floating_point() { + let values = element_values(&Fixture::ExactDecimals.body(), b"AMOUNT") + .expect("read exact decimal fixtures"); + assert_eq!(values, ["0", "0.00", "-1180.00", "999999999999.9999"]); + for value in values { + ExactDecimal::parse(value).expect("valid exact decimal"); + } + for invalid in ["1e3", "+1.00", "1,000.00", "NaN", ""] { + assert!(ExactDecimal::parse(invalid).is_err()); + } +} + +#[test] +fn import_counter_fixtures_preserve_success_duplicate_and_partial_results() { + fn counters(fixture: Fixture) -> BTreeMap<&'static str, u64> { + [ + "CREATED", + "ALTERED", + "DELETED", + "IGNORED", + "ERRORS", + "CANCELLED", + "EXCEPTIONS", + ] + .into_iter() + .map(|name| { + let values = element_values(&fixture.body(), name.as_bytes()).unwrap(); + (name, values[0].parse::().unwrap()) + }) + .collect() + } + + let success = counters(Fixture::ImportCounters); + assert_eq!(success["CREATED"], 2); + assert_eq!(success["ALTERED"], 3); + assert_eq!(success["DELETED"], 1); + assert_eq!(success["ERRORS"], 0); + + let duplicate = counters(Fixture::ImportDuplicate); + assert_eq!(duplicate["IGNORED"], 1); + assert_eq!(duplicate["ERRORS"], 1); + assert_eq!( + element_values(&Fixture::ImportDuplicate.body(), b"LINEERROR") + .unwrap() + .len(), + 1 + ); + + let partial = counters(Fixture::ImportPartial); + assert_eq!(partial["CREATED"], 1); + assert_eq!(partial["ALTERED"], 1); + assert_eq!(partial["IGNORED"], 1); + assert_eq!(partial["ERRORS"], 1); + assert_eq!(partial["EXCEPTIONS"], 1); +} + +#[test] +fn oversized_slow_body_and_reset_before_body_are_real_http_behaviors() { + let minimum = 64 * 1024; + let simulator = Simulator::spawn(ScenarioPlan::new(Fixture::Oversized { + minimum_bytes: minimum, + })) + .unwrap(); + let (response, processed) = request(simulator, "POST", "/"); + assert!(processed); + assert!(response_body(&response).len() >= minimum); + + let simulator = Simulator::spawn(ScenarioPlan::new(Fixture::ExportStatusOne).with_delivery( + Delivery::SlowBody { + chunk_bytes: 7, + delay: Duration::from_millis(1), + }, + )) + .unwrap(); + let (response, processed) = request(simulator, "POST", "/"); + assert!(processed); + assert_eq!( + response_body(&response), + Fixture::ExportStatusOne.body().as_bytes() + ); + + let simulator = Simulator::spawn( + ScenarioPlan::new(Fixture::ExportStatusOne).with_delivery(Delivery::ResetBeforeBody), + ) + .unwrap(); + let (response, processed) = request(simulator, "POST", "/"); + assert!(!processed); + assert!(response.starts_with(b"HTTP/1.1 200 OK\r\n")); + assert!(response_body(&response).is_empty()); +} + +#[test] +fn cancellation_interrupts_slow_headers_without_deadlocking() { + let simulator = Simulator::spawn( + ScenarioPlan::new(Fixture::ExportStatusOne) + .with_delivery(Delivery::SlowHeaders(Duration::from_secs(2))), + ) + .unwrap(); + let mut stream = TcpStream::connect(simulator.address()).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(1))) + .unwrap(); + stream + .write_all(b"POST / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\n\r\n") + .unwrap(); + std::thread::sleep(Duration::from_millis(30)); + let started = Instant::now(); + simulator.cancel(); + let mut response = Vec::new(); + stream.read_to_end(&mut response).unwrap(); + let observed = simulator.finish().unwrap(); + assert!(observed.cancelled); + assert!(!observed.request_processed); + assert!(response.is_empty()); + assert!(started.elapsed() < Duration::from_secs(1)); +} + +#[test] +fn delayed_reset_after_processing_is_explicitly_ambiguous() { + let simulator = Simulator::spawn(ScenarioPlan::new(Fixture::ImportCounters).with_delivery( + Delivery::ResetAfterRequestProcessed { + delay: Duration::from_millis(20), + }, + )) + .unwrap(); + let (response, processed) = request(simulator, "POST", "/"); + assert!(response.is_empty()); + assert!( + processed, + "the simulator records that the request may have committed" + ); +} + +#[test] +fn synthetic_json_reference_contains_selected_xml_fixture_values() { + let xml = Fixture::NormalExport.body(); + let json: serde_json::Value = + serde_json::from_str(&Fixture::SyntheticJsonSemanticReference.body()) + .expect("valid synthetic JSON reference"); + + assert!(xml.contains(json["company"]["guid"].as_str().unwrap())); + assert!(xml.contains(json["company"]["name"].as_str().unwrap())); + let ledger = &json["ledgers"][0]; + for field in ["name", "opening_balance", "parent", "guid"] { + assert!(xml.contains(ledger[field].as_str().unwrap())); + } +} + +#[test] +fn scoped_export_metadata_scenarios_are_explicit_and_minimized() { + let empty = Fixture::EmptyExport.body(); + assert!(empty.contains("0")); + assert!(!empty.contains("2")); + assert_eq!(mismatch.matches("-1")); + let duplicate = Fixture::DuplicateExportMetadata.body(); + assert!(duplicate.contains("SCHEMA=\"bridge.tally.ledgers/1\"")); + assert!(duplicate.contains("bridge.tally.ledgers/1")); + + let voucher = Fixture::VoucherExport.body(); + assert!(voucher.contains("SCHEMA=\"bridge.tally.vouchers/2\"")); + assert!(voucher.contains("OBJECTTYPE=\"VOUCHER\"")); + for forbidden in [ + "NARRATION", + "ADDRESS", + "EMAIL", + "PHONE", + "MOBILE", + "PINCODE", + ] { + assert!(!voucher.contains(forbidden)); + } +} + +#[test] +fn inconsistent_date_filter_fixture_returns_a_row_outside_its_declared_window() { + let xml = Fixture::InconsistentDateFilter.body(); + assert_eq!( + element_values(&xml, b"DATE").unwrap(), + vec!["20260630".to_string()] + ); + assert!(xml.contains("FROMDATE=\"20260701\"")); + assert!(xml.contains("TODATE=\"20260731\"")); + assert!(assert_well_formed(&xml).is_ok()); +} + +#[test] +fn entire_corpus_is_synthetic_and_contains_no_local_identity_markers() { + let fixtures = [ + Fixture::ProductStatus(ProductStatus::TallyPrime), + Fixture::ProductStatus(ProductStatus::TallyErp9), + Fixture::ProductStatus(ProductStatus::Unknown), + Fixture::ExportStatusOne, + Fixture::ExportStatusZero, + Fixture::ExportStatusMissing, + Fixture::ExportStatusInvalid, + Fixture::NormalExport, + Fixture::EmptyExport, + Fixture::DuplicateIdentity, + Fixture::WrongCompany, + Fixture::VoucherExport, + Fixture::InconsistentDateFilter, + Fixture::RecordCountMismatch, + Fixture::MalformedExportMetadata, + Fixture::DuplicateExportMetadata, + Fixture::ExactDecimals, + Fixture::ImportCounters, + Fixture::ImportDuplicate, + Fixture::ImportPartial, + Fixture::MalformedXml, + Fixture::TruncatedXml, + Fixture::SyntheticJsonSemanticReference, + Fixture::UnsupportedCapability, + ]; + for fixture in fixtures { + let body = fixture.body(); + for forbidden in [ + "C:\\Users\\", + "/Users/", + "@example", + "PARTYGSTIN", + "PHONE", + "MOBILE", + ] { + assert!(!body.contains(forbidden), "forbidden marker: {forbidden}"); + } + if body.contains("NAME>") || body.contains("name\"") { + assert!(body.contains("BRIDGE SYNTHETIC")); + } + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 1c44b80..29fc3d6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,68 +1,1726 @@ +use crate::db::tally_incremental::IncrementalFoundationEvidence; +use crate::db::tally_mirror::{ + company_profile_correlation_key, selected_read_scope_commitment_sha256, CapabilityItemInput, + CapabilityKind as MirrorCapabilityKind, CapabilitySnapshotInput, + CapabilityState as MirrorCapabilityState, Confidence, FreshnessState, + LocalReconciliationMismatch, ProofSummary, RedactedProofExport, ReviewedSetupInput, + SelectedReadObservationCommitmentMaterial, SelectedReadObservationInput, + SelectedReadScopeCommitmentMaterial, SelectedReadScopeInput, SourceIdentityInput, + TallyMirrorRepository, +}; use crate::gst::{GstDraftRequest, GstReturnDraft}; +use crate::sync::coordinator::{SnapshotCoordinator, SnapshotJobStatus}; +use crate::sync::reconciliation::ExternalReferenceCatalog; +use crate::sync::snapshot::{ + capability_profile_sha256, AdaptiveWindowPolicy, PlannedWindow, SnapshotPlan, + SqliteSnapshotStateStore, +}; +use crate::tally::runtime::TallyRuntimeControlError; +use crate::tally::validators::{ + normalize_company_guid, validate_company_name, validate_date_range, +}; use crate::tally::{ - ConnectionStatus, TallyClient, TallyCompany, TallyConfig, TallyLedger, TallyVoucher, + company_source_identity, core_snapshot_start_authorized, source_lineage, + CachedProbeReservation, ConnectionStatus, EndpointKey, RuntimeTallyConnector, + SelectedReadObservation, SelectedReadScopeEvidence, TallyCompany, TallyConfig, TallyLedger, + TallyRuntime, TallySessionSnapshot, TallyTelemetryPreviewExport, TallyVoucher, + SELECTED_LEDGER_QUERY_PROFILE_ID, SELECTED_VOUCHER_QUERY_PROFILE_ID, +}; +use bridge_tally_core::{ + CapabilityEvidence, CapabilityFeatureId, CapabilityPackId, CapabilityState, + CompanyRef as CoreCompanyRef, EvidenceConfidence, ReadWindow, RequestContext, TallyConnector, + TransportId, CORE_ACCOUNTING_SCHEMA_VERSION, }; -use serde::Deserialize; -use zeroize::Zeroize; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use tauri::State; +use zeroize::Zeroizing; const MAX_DSC_PIN_BYTES: usize = 128; +#[derive(Debug, Serialize)] +pub struct TallyCommandError { + pub code: &'static str, + pub category: &'static str, + pub message: String, + pub retry: &'static str, + pub local_state_changed: bool, + pub tally_state_may_have_changed: bool, + pub remediation: &'static str, +} + +fn tally_command_error( + code: &'static str, + category: &'static str, + message: impl Into, + retry: &'static str, + local_state_changed: bool, + remediation: &'static str, +) -> TallyCommandError { + TallyCommandError { + code, + category, + message: message.into(), + retry, + local_state_changed, + tally_state_may_have_changed: false, + remediation, + } +} + +fn tally_runtime_command_error(error: anyhow::Error) -> TallyCommandError { + if let Some(control) = error.downcast_ref::() { + return match control { + TallyRuntimeControlError::Cancelled => tally_command_error( + "request_cancelled", + "Operation", + "The read-only Tally request was cancelled.", + "safe", + true, + "Refresh the scoped run or runtime status before starting another request.", + ), + TallyRuntimeControlError::QueueDeadline => tally_command_error( + "tally_runtime_temporarily_unavailable", + "Operation", + "The local Tally request queue deadline was exceeded.", + "safe", + true, + "Refresh runtime status before retrying; the failed queue operation was recorded in local runtime health.", + ), + TallyRuntimeControlError::CircuitCooldown + | TallyRuntimeControlError::HalfOpenProbeInFlight + | TallyRuntimeControlError::EndpointSessionCapacity => tally_command_error( + "tally_runtime_temporarily_unavailable", + "Operation", + "The local Tally request runtime is temporarily unavailable.", + "safe", + false, + "Wait for active requests or the circuit retry time, then refresh runtime status.", + ), + }; + } + let lower = error.to_string().to_ascii_lowercase(); + let (code, category, message, retry, local_state_changed, remediation) = if lower + .contains("cancel") + { + ( + "request_cancelled", + "Operation", + "The read-only Tally request was cancelled.", + "safe", + true, + "Refresh the scoped run or runtime status before starting another request.", + ) + } else if lower.contains("host") + || lower.contains("port") + || lower.contains("loopback") + || lower.contains("endpoint") && lower.contains("invalid") + { + ( + "endpoint_configuration_invalid", + "Endpoint configuration", + "The local Tally endpoint configuration is invalid.", + "after_change", + false, + "Use localhost or a loopback IP and a port from 1 to 65535, then probe again.", + ) + } else if lower.contains("parse") + || lower.contains("xml") + || lower.contains("decode") + || lower.contains("schema") + || lower.contains("response exceeded") + { + ( + "response_validation_failed", + "Response validation", + "The Tally response did not satisfy Bridge's bounded protocol contract.", + "after_change", + true, + "Keep the result unverified and inspect redacted diagnostics before retrying.", + ) + } else if lower.contains("company") { + ( + "tally_company_context_failed", + "Tally application", + "Tally did not confirm the selected company context.", + "after_change", + true, + "Load the intended company in Tally, probe again, and reselect its observed identity.", + ) + } else if lower.contains("queue deadline") { + ( + "tally_runtime_temporarily_unavailable", + "Operation", + "The local Tally request queue deadline was exceeded.", + "safe", + true, + "Refresh runtime status before retrying; the failed queue operation was recorded in local runtime health.", + ) + } else if lower.contains("capacity") + || lower.contains("circuit") + || lower.contains("registry") + || lower.contains("cache") + { + ( + "tally_runtime_temporarily_unavailable", + "Operation", + "The local Tally request runtime is temporarily unavailable.", + "safe", + false, + "Wait for active requests or the circuit retry time, then refresh runtime status.", + ) + } else { + ( + "endpoint_unreachable", + "Endpoint configuration", + "The local Tally endpoint could not complete the read-only request.", + "after_change", + true, + "Confirm Tally is running with the XML server enabled, then probe the loopback endpoint again.", + ) + }; + TallyCommandError { + code, + category, + message: message.to_string(), + retry, + local_state_changed, + tally_state_may_have_changed: false, + remediation, + } +} + #[tauri::command] -pub async fn check_tally_connection(config: TallyConfig) -> Result { - TallyClient::new(config) - .check_connection() +pub async fn check_tally_connection( + config: TallyConfig, + runtime: State<'_, TallyRuntime>, +) -> Result { + runtime + .check_connection(config) .await - .map_err(|error| error.to_string()) + .map_err(tally_runtime_command_error) } #[tauri::command] -pub async fn fetch_tally_companies(config: TallyConfig) -> Result, String> { - TallyClient::new(config) - .fetch_companies() +pub async fn probe_tally( + config: TallyConfig, + runtime: State<'_, TallyRuntime>, +) -> Result { + let canonical_origin = EndpointKey::from_config(&config) + .map(|endpoint| endpoint.as_str().to_string()) + .map_err(|_| { + tally_command_error( + "endpoint_configuration_invalid", + "Endpoint configuration", + "Tally endpoint validation failed", + "after_change", + false, + "Use localhost or a loopback IP and a port from 1 to 65535, then probe again.", + ) + })?; + let (review_id, observed_at_unix_ms, probe) = runtime + .probe_with_observation(config) .await - .map_err(|error| error.to_string()) + .map_err(tally_runtime_command_error)?; + let profile_sha256 = capability_profile_sha256(&probe.profile).map_err(|_| { + tally_command_error( + "capability_profile_commitment_failed", + "Operation", + "The observed Capability Passport could not be committed for review.", + "safe", + false, + "Probe again before selecting and saving a company scope.", + ) + })?; + let review_commitment_sha256 = reviewed_probe_commitment_sha256( + &review_id, + &canonical_origin, + observed_at_unix_ms, + &probe, + ) + .map_err(|_| { + tally_command_error( + "reviewed_probe_commitment_failed", + "Operation", + "The exact endpoint, Passport, and company scope could not be committed for review.", + "safe", + false, + "Probe again before selecting and saving a company scope.", + ) + })?; + let mut companies = Vec::with_capacity(probe.companies.len()); + for company in probe.companies { + let identity_confidence = if company + .guid + .as_deref() + .is_some_and(|guid| !guid.trim().is_empty()) + { + "observed" + } else { + "unknown" + }; + let correlation_key = company + .guid + .as_deref() + .map(|guid| company_profile_correlation_key(&canonical_origin, guid)); + companies.push(PersistedTallyCompany { + name: company.name, + guid: company.guid, + mirror_company_id: None, + correlation_key, + identity_confidence, + }); + } + Ok(PersistedTallyProbeResult { + review_id, + canonical_origin, + observed_at_unix_ms, + connection: probe.connection, + companies, + profile: probe.profile, + selected_read_scope: probe.selected_read_scope, + profile_sha256, + review_commitment_sha256, + passport_snapshot_id: None, + }) +} + +#[derive(Debug, Deserialize)] +pub struct QualifySelectedReadsRequest { + pub config: TallyConfig, + pub expected_review_id: String, + pub expected_review_commitment_sha256: String, + pub selected_company_guid: String, + pub voucher_from_yyyymmdd: String, + pub voucher_to_yyyymmdd: String, +} + +#[derive(Debug, Serialize)] +pub struct SelectedReadQualificationResult { + pub review_id: String, + pub observed_at_unix_ms: i64, + pub profile: bridge_tally_core::CapabilityProfile, + pub profile_sha256: String, + pub review_commitment_sha256: String, + pub selected_read_scope: SelectedReadScopeEvidence, + pub no_writes_attempted: bool, + pub raw_records_retained: bool, + pub completeness_claimed: bool, +} + +#[tauri::command] +pub async fn qualify_selected_tally_reads( + request: QualifySelectedReadsRequest, + runtime: State<'_, TallyRuntime>, +) -> Result { + validate_date_range(&request.voucher_from_yyyymmdd, &request.voucher_to_yyyymmdd).map_err( + |message| { + tally_command_error( + "selected_read_window_invalid", + "Endpoint configuration", + message, + "after_change", + false, + "Choose a valid inclusive voucher window and qualify again.", + ) + }, + )?; + let from_date = chrono::NaiveDate::parse_from_str(&request.voucher_from_yyyymmdd, "%Y%m%d") + .map_err(|_| selected_read_window_too_large_error())?; + let to_date = chrono::NaiveDate::parse_from_str(&request.voucher_to_yyyymmdd, "%Y%m%d") + .map_err(|_| selected_read_window_too_large_error())?; + if (to_date - from_date).num_days() > 30 { + return Err(selected_read_window_too_large_error()); + } + let canonical_origin = EndpointKey::from_config(&request.config) + .map(|endpoint| endpoint.as_str().to_string()) + .map_err(|_| { + tally_command_error( + "endpoint_configuration_invalid", + "Endpoint configuration", + "Tally endpoint validation failed", + "after_change", + false, + "Use localhost or a loopback IP and a valid port, then probe again.", + ) + })?; + let mut reservation = runtime + .reserve_cached_probe_fresh( + &request.config, + &request.expected_review_id, + SETUP_PROBE_MAX_AGE_MS, + ) + .map_err(tally_runtime_command_error)? + .ok_or_else(reviewed_probe_expired_error)?; + let parent_observed_at_unix_ms = reservation.observed_at_unix_ms(); + let mut probe = reservation.result().clone(); + + let parent_commitment = match reviewed_probe_commitment_sha256( + &request.expected_review_id, + &canonical_origin, + parent_observed_at_unix_ms, + &probe, + ) { + Ok(commitment) => commitment, + Err(_) => return Err(reviewed_probe_changed_error()), + }; + if parent_commitment != request.expected_review_commitment_sha256 { + return Err(reviewed_probe_changed_error()); + } + let selected_guid = match normalize_company_guid(&request.selected_company_guid) { + Ok(guid) => guid, + Err(_) => { + return Err(tally_command_error( + "stable_company_identity_required", + "Tally application", + "The selected company does not have a safe observed GUID.", + "after_change", + false, + "Select one GUID-bearing company from the current probe.", + )); + } + }; + let matching_companies = probe + .companies + .iter() + .filter(|company| { + company + .guid + .as_deref() + .is_some_and(|guid| guid.eq_ignore_ascii_case(&selected_guid)) + }) + .cloned() + .collect::>(); + let [company] = matching_companies.as_slice() else { + return Err(tally_command_error( + "reviewed_company_scope_changed", + "Tally application", + "The selected company is absent or ambiguous in the reviewed probe.", + "safe", + false, + "Probe again and select one company from the replacement result.", + )); + }; + let Some(observed_guid) = company.guid.clone() else { + return Err(reviewed_probe_changed_error()); + }; + + let ledger_result = runtime + .qualify_selected_ledgers( + request.config.clone(), + &reservation, + company.name.clone(), + observed_guid.clone(), + ) + .await; + let ledger_result = match ledger_result { + Err(error) if selected_read_cancelled(&error) => { + return Err(tally_runtime_command_error(error)); + } + result => result, + }; + if ledger_result + .as_ref() + .is_err_and(selected_read_identity_failure) + { + consume_selected_read_reservation(&mut reservation)?; + return Err(selected_read_company_context_error()); + } + let ledger_observation = selected_read_observation( + "selected_ledger_read", + ledger_result, + false, + "selected_ledger_read_empty_observed", + "selected_ledger_read_non_empty_observed", + ); + let voucher_observation = if ledger_observation.state == CapabilityState::Supported { + let result = runtime + .qualify_selected_vouchers( + request.config.clone(), + &reservation, + company.name.clone(), + observed_guid.clone(), + request.voucher_from_yyyymmdd.clone(), + request.voucher_to_yyyymmdd.clone(), + ) + .await; + let result = match result { + Err(error) if selected_read_cancelled(&error) => { + return Err(tally_runtime_command_error(error)); + } + result => result, + }; + if result.as_ref().is_err_and(selected_read_identity_failure) { + consume_selected_read_reservation(&mut reservation)?; + return Err(selected_read_company_context_error()); + } + selected_read_observation( + "selected_voucher_window_read", + result, + true, + "selected_voucher_window_empty_observed", + "selected_voucher_window_non_empty_observed", + ) + } else { + crate::tally::connection::SelectedReadCapabilityObservation { + capability_key: "selected_voucher_window_read", + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Unknown, + safe_reason_code: "qualification_prerequisite_failed", + result_bucket: "skipped", + request_sha256: None, + decoded_response_sha256: None, + response_encoding: None, + company_context_verified: false, + schema_verified: false, + record_count_verified: false, + identity_evidence_state: "unverified", + date_window_verified: false, + } + }; + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + let observations = vec![ledger_observation, voucher_observation]; + let commitment_observations = observations + .iter() + .map(|observation| SelectedReadObservationCommitmentMaterial { + capability_key: observation.capability_key.to_string(), + state: capability_state_label(observation.state).to_string(), + confidence: evidence_confidence_label(observation.confidence).to_string(), + safe_reason_code: observation.safe_reason_code.to_string(), + result_bucket: observation.result_bucket.to_string(), + request_sha256: observation.request_sha256.clone(), + decoded_response_sha256: observation.decoded_response_sha256.clone(), + response_encoding: observation.response_encoding.map(str::to_string), + company_context_verified: observation.company_context_verified, + schema_verified: observation.schema_verified, + record_count_verified: observation.record_count_verified, + identity_evidence_state: observation.identity_evidence_state.to_string(), + date_window_verified: observation.date_window_verified, + }) + .collect::>(); + let casefolded_guid = observed_guid.to_ascii_lowercase(); + let scope_commitment_sha256 = + match selected_read_scope_commitment_sha256(&SelectedReadScopeCommitmentMaterial { + parent_review_commitment_sha256: parent_commitment.clone(), + canonical_origin: canonical_origin.clone(), + company_guid_ascii_casefolded: casefolded_guid.clone(), + company_name: company.name.clone(), + ledger_profile_id: SELECTED_LEDGER_QUERY_PROFILE_ID.to_string(), + voucher_profile_id: SELECTED_VOUCHER_QUERY_PROFILE_ID.to_string(), + voucher_from_yyyymmdd: request.voucher_from_yyyymmdd.clone(), + voucher_to_yyyymmdd: request.voucher_to_yyyymmdd.clone(), + observed_at_unix_ms, + observations: commitment_observations, + }) { + Ok(commitment) => commitment, + Err(_) => { + let _ = reservation.consume(); + return Err(selected_read_review_state_uncertain_error()); + } + }; + for observation in &observations { + probe.profile.features.insert( + if observation.capability_key == "selected_ledger_read" { + CapabilityFeatureId::SelectedLedgerRead + } else { + CapabilityFeatureId::SelectedVoucherWindowRead + }, + CapabilityEvidence { + state: observation.state, + confidence: observation.confidence, + safe_reason_code: Some(observation.safe_reason_code.to_string()), + }, + ); + } + probe.profile.profile_version = 3; + let selected_read_scope = SelectedReadScopeEvidence { + scope_version: 1, + ledger_profile_id: SELECTED_LEDGER_QUERY_PROFILE_ID.to_string(), + voucher_profile_id: SELECTED_VOUCHER_QUERY_PROFILE_ID.to_string(), + voucher_from_yyyymmdd: request.voucher_from_yyyymmdd.clone(), + voucher_to_yyyymmdd: request.voucher_to_yyyymmdd.clone(), + scope_commitment_sha256, + parent_review_sha256: parent_commitment, + company_guid_ascii_casefolded: casefolded_guid, + observations, + }; + probe.selected_read_scope = Some(selected_read_scope.clone()); + let replacement_review_id = uuid::Uuid::new_v4().to_string(); + let profile_sha256 = match capability_profile_sha256(&probe.profile) { + Ok(hash) => hash, + Err(_) => { + let _ = reservation.consume(); + return Err(selected_read_review_state_uncertain_error()); + } + }; + let review_commitment_sha256 = match reviewed_probe_commitment_sha256( + &replacement_review_id, + &canonical_origin, + observed_at_unix_ms, + &probe, + ) { + Ok(commitment) => commitment, + Err(_) => { + let _ = reservation.consume(); + return Err(selected_read_review_state_uncertain_error()); + } + }; + let replaced = match reservation.replace( + replacement_review_id.clone(), + observed_at_unix_ms, + probe.clone(), + ) { + Ok(replaced) => replaced, + Err(_) => return Err(selected_read_review_state_uncertain_error()), + }; + if !replaced { + return Err(selected_read_review_state_uncertain_error()); + } + Ok(SelectedReadQualificationResult { + review_id: replacement_review_id, + observed_at_unix_ms, + profile: probe.profile, + profile_sha256, + review_commitment_sha256, + selected_read_scope, + no_writes_attempted: true, + raw_records_retained: false, + completeness_claimed: false, + }) +} + +fn selected_read_observation( + capability_key: &'static str, + result: anyhow::Result, + date_window: bool, + empty_reason: &'static str, + non_empty_reason: &'static str, +) -> crate::tally::connection::SelectedReadCapabilityObservation { + match result { + Ok(observed) => crate::tally::connection::SelectedReadCapabilityObservation { + capability_key, + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: if observed.result_bucket == "empty_observed" { + empty_reason + } else { + non_empty_reason + }, + result_bucket: observed.result_bucket, + request_sha256: Some(observed.request_sha256), + decoded_response_sha256: Some(observed.decoded_response_sha256), + response_encoding: Some(observed.response_encoding), + company_context_verified: true, + schema_verified: true, + record_count_verified: true, + identity_evidence_state: if observed.result_bucket == "empty_observed" { + "not_applicable_empty" + } else { + "verified" + }, + date_window_verified: date_window, + }, + Err(error) => crate::tally::connection::SelectedReadCapabilityObservation { + capability_key, + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: selected_read_failure_reason(&error, date_window), + result_bucket: "rejected", + request_sha256: None, + decoded_response_sha256: None, + response_encoding: None, + company_context_verified: false, + schema_verified: false, + record_count_verified: false, + identity_evidence_state: "unverified", + date_window_verified: false, + }, + } +} + +fn selected_read_identity_failure(error: &anyhow::Error) -> bool { + let message = error.to_string().to_ascii_lowercase(); + message.contains("company") && (message.contains("context") || message.contains("identity")) +} + +fn selected_read_cancelled(error: &anyhow::Error) -> bool { + matches!( + error.downcast_ref::(), + Some(TallyRuntimeControlError::Cancelled) + ) +} + +fn consume_selected_read_reservation( + reservation: &mut CachedProbeReservation, +) -> Result<(), TallyCommandError> { + match reservation.consume() { + Ok(true) => Ok(()), + Ok(false) | Err(_) => Err(selected_read_review_state_uncertain_error()), + } +} + +fn selected_read_failure_reason(error: &anyhow::Error, voucher: bool) -> &'static str { + let message = error.to_string(); + if voucher && message.contains("voucher_date_outside_requested_window") { + "selected_voucher_date_outside_window" + } else if message.contains("stable") || message.contains("identity") { + "selected_read_identity_unavailable" + } else if message.contains("schema") || message.contains("structural") { + "selected_read_schema_rejected" + } else { + "selected_read_transport_or_validation_failed" + } +} + +fn capability_state_label(state: CapabilityState) -> &'static str { + match state { + CapabilityState::Supported => "supported", + CapabilityState::Unsupported => "unsupported", + CapabilityState::Unknown => "unknown", + CapabilityState::NotConfigured => "not_configured", + } +} + +fn evidence_confidence_label(confidence: EvidenceConfidence) -> &'static str { + match confidence { + EvidenceConfidence::Documented => "documented", + EvidenceConfidence::Observed => "observed", + EvidenceConfidence::Inferred => "inferred", + EvidenceConfidence::Unknown => "unknown", + } +} + +fn selected_read_window_too_large_error() -> TallyCommandError { + tally_command_error( + "selected_read_window_invalid", + "Endpoint configuration", + "Selected-read qualification is limited to one inclusive 31-day voucher window.", + "after_change", + false, + "Choose a valid window of 31 days or fewer.", + ) +} + +fn reviewed_probe_expired_error() -> TallyCommandError { + tally_command_error( + "reviewed_probe_expired", + "Operation", + "The reviewed Capability Passport is missing, busy, or older than five minutes.", + "safe", + false, + "Probe again and review the exact company scope before qualifying.", + ) +} + +fn reviewed_probe_changed_error() -> TallyCommandError { + tally_command_error( + "reviewed_probe_changed", + "Operation", + "The reviewed Capability Passport no longer matches the cached observation.", + "safe", + false, + "Probe again and review the replacement Passport before qualifying.", + ) +} + +fn selected_read_company_context_error() -> TallyCommandError { + tally_command_error( + "selected_read_company_context_changed", + "Tally application", + "A selected read did not prove the exact reviewed company context.", + "after_change", + true, + "Stop using this review, verify the loaded Tally company, and probe again.", + ) +} + +fn selected_read_review_state_uncertain_error() -> TallyCommandError { + tally_command_error( + "selected_read_review_state_uncertain", + "Operation", + "The read-only qualification finished, but its reviewed state could not be installed.", + "after_change", + true, + "Probe again before qualifying or saving any company scope.", + ) +} + +const SETUP_PROBE_MAX_AGE_MS: i64 = 5 * 60 * 1_000; + +#[derive(Debug, Deserialize)] +pub struct SaveTallySetupRequest { + pub config: TallyConfig, + pub expected_review_id: String, + pub expected_review_commitment_sha256: String, + pub selected_company_guid: String, +} + +#[derive(Debug, Serialize)] +pub struct SavedTallySetup { + pub passport_snapshot_id: String, + pub canonical_origin: String, + pub observed_at_unix_ms: i64, + pub company: PersistedTallyCompany, + pub review_cleanup_warning: Option<&'static str>, +} + +#[tauri::command] +pub async fn save_tally_setup( + request: SaveTallySetupRequest, + mirror: State<'_, TallyMirrorRepository>, + runtime: State<'_, TallyRuntime>, +) -> Result { + let canonical_origin = EndpointKey::from_config(&request.config) + .map(|endpoint| endpoint.as_str().to_string()) + .map_err(|_| { + tally_command_error( + "endpoint_configuration_invalid", + "Endpoint configuration", + "Tally endpoint validation failed", + "after_change", + false, + "Use localhost or a loopback IP and a port from 1 to 65535, then probe again.", + ) + })?; + let mut reservation = runtime + .reserve_cached_probe_fresh( + &request.config, + &request.expected_review_id, + SETUP_PROBE_MAX_AGE_MS, + ) + .map_err(tally_runtime_command_error)? + .ok_or_else(|| { + tally_command_error( + "reviewed_probe_expired", + "Operation", + "The reviewed Capability Passport is missing or older than five minutes.", + "safe", + false, + "Probe again, review the exact Passport and company scope, then save.", + ) + })?; + let observed_at_unix_ms = reservation.observed_at_unix_ms(); + let probe = reservation.result().clone(); + let save_result: Result = async { + let actual_review_commitment_sha256 = reviewed_probe_commitment_sha256( + &request.expected_review_id, + &canonical_origin, + observed_at_unix_ms, + &probe, + ) + .map_err( + |_| { + tally_command_error( + "reviewed_probe_commitment_failed", + "Operation", + "The cached endpoint, Passport, and company scope could not be verified.", + "safe", + false, + "Probe again before selecting and saving a company scope.", + ) + }, + )?; + if request.expected_review_commitment_sha256 != actual_review_commitment_sha256 { + return Err(tally_command_error( + "reviewed_probe_changed", + "Operation", + "The reviewed Capability Passport no longer matches the cached probe.", + "safe", + false, + "Probe again and review the replacement Passport before saving.", + )); + } + let selected_guid = normalize_company_guid(&request.selected_company_guid).map_err(|_| { + tally_command_error( + "stable_company_identity_required", + "Tally application", + "The selected company does not have an observed stable GUID.", + "after_change", + false, + "Select a GUID-bearing company from the current probe.", + ) + })?; + let mut matches = probe.companies.iter().filter(|company| { + company + .guid + .as_deref() + .is_some_and(|guid| guid.eq_ignore_ascii_case(&selected_guid)) + }); + let company = matches.next().cloned().ok_or_else(|| { + tally_command_error( + "reviewed_company_scope_changed", + "Tally application", + "The selected company is not present in the reviewed probe.", + "safe", + false, + "Probe again and select a company from the current result.", + ) + })?; + if matches.next().is_some() { + return Err(tally_command_error( + "company_identity_ambiguous", + "Tally application", + "The reviewed probe returned the selected GUID more than once.", + "not_recommended", + false, + "Do not save this scope; inspect the synthetic or source company identities.", + )); + } + if probe.selected_read_scope.as_ref().is_some_and(|scope| { + !company.guid.as_deref().is_some_and(|guid| { + guid.to_ascii_lowercase() == scope.company_guid_ascii_casefolded + }) + }) { + return Err(tally_command_error( + "qualified_company_scope_changed", + "Tally application", + "The selected company does not match the qualified read scope.", + "after_change", + false, + "Select the qualified company or probe and qualify the replacement company.", + )); + } + + let saved = mirror + .save_reviewed_setup(ReviewedSetupInput { + review_commitment_sha256: request.expected_review_commitment_sha256.clone(), + capability: CapabilitySnapshotInput { + canonical_origin: canonical_origin.clone(), + observed_at_unix_ms, + profile_version: probe.profile.profile_version, + product: probe.profile.product.clone(), + release: probe.profile.release.clone(), + mode: probe.profile.mode.clone(), + mode_confidence: if probe.profile.mode.is_some() { + Confidence::Observed + } else { + Confidence::Unknown + }, + items: capability_items(&probe.profile), + }, + company_display_name: company.name.clone(), + company_identity: SourceIdentityInput { + // Persist the spelling observed from Tally, not caller-controlled casing. + guid: company.guid.clone(), + confidence: Some(Confidence::Observed), + ..SourceIdentityInput::default() + }, + selected_read_scope: probe.selected_read_scope.as_ref().map(|scope| { + SelectedReadScopeInput { + scope_commitment_sha256: scope.scope_commitment_sha256.clone(), + parent_review_sha256: scope.parent_review_sha256.clone(), + ledger_profile_id: scope.ledger_profile_id.clone(), + voucher_profile_id: scope.voucher_profile_id.clone(), + voucher_from_yyyymmdd: scope.voucher_from_yyyymmdd.clone(), + voucher_to_yyyymmdd: scope.voucher_to_yyyymmdd.clone(), + observed_at_unix_ms, + observations: scope + .observations + .iter() + .map(|observation| SelectedReadObservationInput { + capability_key: observation.capability_key.to_string(), + state: mirror_capability_state(observation.state), + confidence: mirror_confidence(observation.confidence), + safe_reason_code: observation.safe_reason_code.to_string(), + result_bucket: observation.result_bucket.to_string(), + request_sha256: observation.request_sha256.clone(), + decoded_response_sha256: observation + .decoded_response_sha256 + .clone(), + response_encoding: observation + .response_encoding + .map(str::to_string), + company_context_verified: observation.company_context_verified, + schema_verified: observation.schema_verified, + record_count_verified: observation.record_count_verified, + identity_evidence_state: observation + .identity_evidence_state + .to_string(), + date_window_verified: observation.date_window_verified, + }) + .collect(), + } + }), + }) + .await + .map_err(|_| { + tally_command_error( + "reviewed_setup_store_failed", + "Operation", + "The reviewed Passport and selected company scope could not be stored atomically.", + "after_change", + false, + "Verify encrypted storage, then retry this reviewed scope while it is fresh.", + ) + })?; + let correlation_key = company + .guid + .as_deref() + .map(|guid| company_profile_correlation_key(&canonical_origin, guid)); + Ok(SavedTallySetup { + passport_snapshot_id: saved.snapshot.id, + canonical_origin, + observed_at_unix_ms, + company: PersistedTallyCompany { + name: company.name, + correlation_key, + guid: company.guid, + mirror_company_id: Some(saved.company.id), + identity_confidence: "observed", + }, + review_cleanup_warning: None, + }) + } + .await; + let consume = save_result.is_ok(); + let cleanup_succeeded = if consume { + reservation.consume().unwrap_or(false) + } else { + reservation.release().unwrap_or(false) + }; + reconcile_review_cleanup(save_result, cleanup_succeeded) +} + +fn reconcile_review_cleanup( + save_result: Result, + cleanup_succeeded: bool, +) -> Result { + match save_result { + Ok(mut saved) => { + if !cleanup_succeeded { + saved.review_cleanup_warning = Some("review_cache_cleanup_failed_after_save"); + } + Ok(saved) + } + Err(_error) if !cleanup_succeeded => Err(tally_command_error( + "reviewed_setup_retry_state_uncertain", + "Operation", + "The local setup was not stored, and the in-memory review reservation could not be released.", + "after_change", + true, + "Restart Bridge, probe again, review the exact scope, and save again.", + )), + Err(error) => Err(error), + } +} + +#[derive(Debug, Serialize)] +pub struct PersistedTallyCompany { + pub name: String, + pub guid: Option, + pub mirror_company_id: Option, + pub correlation_key: Option, + pub identity_confidence: &'static str, +} + +#[derive(Debug, Serialize)] +pub struct PersistedTallyProbeResult { + pub review_id: String, + pub canonical_origin: String, + pub observed_at_unix_ms: i64, + pub connection: ConnectionStatus, + pub companies: Vec, + pub profile: bridge_tally_core::CapabilityProfile, + pub selected_read_scope: Option, + pub profile_sha256: String, + pub review_commitment_sha256: String, + pub passport_snapshot_id: Option, +} + +#[derive(Serialize)] +struct ReviewedProbeCommitment<'a> { + schema: &'static str, + review_id: &'a str, + canonical_origin: &'a str, + observed_at_unix_ms: i64, + connection: &'a ConnectionStatus, + companies: &'a [TallyCompany], + profile: &'a bridge_tally_core::CapabilityProfile, +} + +fn reviewed_probe_commitment_sha256( + review_id: &str, + canonical_origin: &str, + observed_at_unix_ms: i64, + probe: &crate::tally::TallyProbeResult, +) -> Result { + let bytes = serde_json::to_vec(&ReviewedProbeCommitment { + schema: "bridge.tally.reviewed-setup-probe/1", + review_id, + canonical_origin, + observed_at_unix_ms, + connection: &probe.connection, + companies: &probe.companies, + profile: &probe.profile, + })?; + Ok(Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +fn capability_items(profile: &bridge_tally_core::CapabilityProfile) -> Vec { + let mut items = Vec::new(); + for (transport, evidence) in &profile.transports { + items.push(CapabilityItemInput { + kind: MirrorCapabilityKind::Transport, + key: transport_key(*transport).to_string(), + state: mirror_capability_state(evidence.state), + confidence: mirror_confidence(evidence.confidence), + safe_reason_code: evidence.safe_reason_code.clone(), + }); + } + for (pack, evidence) in &profile.packs { + items.push(CapabilityItemInput { + kind: MirrorCapabilityKind::Pack, + key: pack_key(*pack).to_string(), + state: mirror_capability_state(evidence.state), + confidence: mirror_confidence(evidence.confidence), + safe_reason_code: evidence.safe_reason_code.clone(), + }); + } + for (feature, evidence) in &profile.features { + items.push(CapabilityItemInput { + kind: MirrorCapabilityKind::Feature, + key: feature_key(*feature).to_string(), + state: mirror_capability_state(evidence.state), + confidence: mirror_confidence(evidence.confidence), + safe_reason_code: evidence.safe_reason_code.clone(), + }); + } + items +} + +#[tauri::command] +pub async fn tally_persisted_company_profiles( + mirror: State<'_, TallyMirrorRepository>, +) -> Result { + mirror + .persisted_company_profiles() + .await + .map_err(|_| "persisted_tally_company_profiles_unavailable".to_string()) +} + +#[derive(Debug, Deserialize)] +pub struct TallyMirrorExplorerRequest { + pub mirror_company_id: String, + pub pack_id: String, + pub offset: u32, + pub limit: u32, +} + +#[tauri::command] +pub async fn tally_mirror_explorer_page( + request: TallyMirrorExplorerRequest, + mirror: State<'_, TallyMirrorRepository>, +) -> Result { + mirror + .mirror_explorer_page( + &request.mirror_company_id, + &request.pack_id, + request.offset, + request.limit, + ) + .await + .map_err(|_| "tally_mirror_explorer_unavailable".to_string()) +} + +#[derive(Debug, Deserialize)] +pub struct TallyEvidenceRequest { + pub mirror_company_id: String, +} + +#[derive(Debug, Serialize)] +pub struct TallyFreshnessEvidence { + pub state: &'static str, + pub verified_at_unix_ms: Option, + pub age_seconds: Option, + pub checkpoint_present: bool, + pub proof_present: bool, +} + +#[derive(Debug, Serialize)] +pub struct TallyEvidenceResponse { + pub latest_proofs: Vec, + pub latest_reconciliation_mismatches: Vec, + pub core_accounting_freshness: TallyFreshnessEvidence, + pub incremental: IncrementalFoundationEvidence, +} + +#[tauri::command] +pub async fn tally_sync_evidence( + request: TallyEvidenceRequest, + mirror: State<'_, TallyMirrorRepository>, +) -> Result { + if request.mirror_company_id.trim().is_empty() { + return Err("Select a company with an observed stable identity".to_string()); + } + mirror + .snapshot_source_pin(&request.mirror_company_id) + .await + .map_err(|_| "The selected encrypted Tally company pin is unavailable".to_string())?; + let freshness = mirror + .freshness( + &request.mirror_company_id, + "core_accounting", + chrono::Utc::now().timestamp_millis(), + ) + .await + .map_err(|_| "Encrypted Tally freshness evidence could not be read".to_string())?; + let latest_proofs = mirror + .latest_proofs(&request.mirror_company_id, 20) + .await + .map_err(|_| "Encrypted Tally proof evidence could not be read".to_string())?; + let latest_reconciliation_mismatches = match latest_proofs.first() { + Some(proof) => mirror + .local_reconciliation_mismatches( + &request.mirror_company_id, + &proof.selection_token, + chrono::Utc::now().timestamp_millis(), + ) + .await + .map_err(|_| { + "The latest proof lacks a valid durable reconciliation receipt".to_string() + })?, + None => Vec::new(), + }; + let incremental = mirror + .incremental_foundation_evidence(&request.mirror_company_id) + .await + .map_err(|_| "Encrypted incremental evidence could not be read".to_string())?; + let state = match freshness.state { + FreshnessState::Fresh => "fresh", + FreshnessState::Stale => "stale", + FreshnessState::NeverVerified => "never_verified", + }; + Ok(TallyEvidenceResponse { + latest_proofs, + latest_reconciliation_mismatches, + incremental, + core_accounting_freshness: TallyFreshnessEvidence { + state, + verified_at_unix_ms: freshness.verified_at_unix_ms, + age_seconds: freshness.age_seconds, + checkpoint_present: freshness.checkpoint_token.is_some(), + proof_present: freshness.proof_id.is_some(), + }, + }) +} + +#[derive(Debug, Deserialize)] +pub struct RedactedProofExportRequest { + pub mirror_company_id: String, + pub proof_id: String, +} + +#[tauri::command] +pub async fn preview_tally_redacted_proof( + request: RedactedProofExportRequest, + mirror: State<'_, TallyMirrorRepository>, +) -> Result { + if request.mirror_company_id.trim().is_empty() || request.proof_id.trim().is_empty() { + return Err("Select a proof for an observed Tally company".to_string()); + } + mirror + .snapshot_source_pin(&request.mirror_company_id) + .await + .map_err(|_| "The selected encrypted Tally company pin is unavailable".to_string())?; + mirror + .redacted_proof_export( + &request.mirror_company_id, + &request.proof_id, + chrono::Utc::now().timestamp_millis(), + ) + .await + .map_err(|_| { + "The proof failed local integrity validation and cannot be exported".to_string() + }) +} + +#[derive(Debug, Deserialize)] +pub struct StartCoreSnapshotRequest { + pub config: TallyConfig, + pub mirror_company_id: String, + pub from: String, + pub to: String, +} + +fn first_calendar_day_canary_window( + requested_from_yyyymmdd: &str, +) -> Result { + let first_day = chrono::NaiveDate::parse_from_str(requested_from_yyyymmdd, "%Y%m%d") + .map_err(|_| "The requested snapshot start date is invalid".to_string())?; + let first_day_yyyymmdd = first_day.format("%Y%m%d").to_string(); + if first_day_yyyymmdd != requested_from_yyyymmdd { + return Err("The requested snapshot start date is invalid".to_string()); + } + Ok(PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + ReadWindow { + from_yyyymmdd: first_day_yyyymmdd.clone(), + to_yyyymmdd: first_day_yyyymmdd, + }, + )) +} + +#[tauri::command] +pub async fn start_tally_core_snapshot( + request: StartCoreSnapshotRequest, + mirror: State<'_, TallyMirrorRepository>, + runtime: State<'_, TallyRuntime>, + coordinator: State<'_, SnapshotCoordinator>, +) -> Result { + validate_date_range(&request.from, &request.to)?; + let pin = mirror + .snapshot_source_pin(&request.mirror_company_id) + .await + .map_err(|_| "The selected encrypted Tally company pin is unavailable".to_string())?; + validate_company_name(&pin.display_name)?; + let request_origin = EndpointKey::from_config(&request.config) + .map_err(|_| "Tally endpoint validation failed".to_string())?; + if request_origin.as_str() != pin.canonical_origin { + return Err("The selected company pin belongs to a different Tally endpoint".to_string()); + } + + let lineage = source_lineage(&request.config).map_err(|_| "Tally source lineage is invalid")?; + let company = CoreCompanyRef { + identity: company_source_identity(&lineage, &pin.company_guid), + display_name: pin.display_name, + }; + let run_id = uuid::Uuid::new_v4().to_string(); + let capability_canary_window = first_calendar_day_canary_window(&request.from)?; + let planned = PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + ReadWindow { + from_yyyymmdd: request.from, + to_yyyymmdd: request.to, + }, + ); + let context = RequestContext { + run_id: run_id.clone(), + company: company.clone(), + pack: CapabilityPackId::CoreAccounting, + schema_version: CORE_ACCOUNTING_SCHEMA_VERSION, + window: capability_canary_window.range.clone(), + query_profile: capability_canary_window.query_profile.clone(), + filters_sha256: capability_canary_window.filters_sha256.clone(), + }; + let connector = RuntimeTallyConnector::new( + runtime.inner().clone(), + request.config, + company.clone(), + context, + ) + .map_err(|_| "The Core Accounting snapshot profile is invalid".to_string())?; + + // Persist only the profile produced by the exact canary used for this run. A prior generic + // endpoint probe intentionally cannot authorize a pack snapshot. + let canary = connector + .probe() + .await + .map_err(|_| "The read-only Core Accounting canary could not complete".to_string())?; + if !canary.reachable + || !canary + .profile + .transports + .get(&TransportId::XmlHttp) + .is_some_and(|evidence| { + evidence.state == CapabilityState::Supported + && evidence.confidence == bridge_tally_core::EvidenceConfidence::Observed + }) + || !canary + .profile + .packs + .get(&CapabilityPackId::CoreAccounting) + .is_some_and(core_snapshot_start_authorized) + { + return Err( + "Core Accounting remains unverified for this company, release, and query profile" + .to_string(), + ); + } + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + let mut items = Vec::new(); + for (transport, evidence) in &canary.profile.transports { + items.push(CapabilityItemInput { + kind: MirrorCapabilityKind::Transport, + key: transport_key(*transport).to_string(), + state: mirror_capability_state(evidence.state), + confidence: mirror_confidence(evidence.confidence), + safe_reason_code: evidence.safe_reason_code.clone(), + }); + } + for (pack, evidence) in &canary.profile.packs { + items.push(CapabilityItemInput { + kind: MirrorCapabilityKind::Pack, + key: pack_key(*pack).to_string(), + state: mirror_capability_state(evidence.state), + confidence: mirror_confidence(evidence.confidence), + safe_reason_code: evidence.safe_reason_code.clone(), + }); + } + let snapshot = mirror + .save_capability_snapshot(CapabilitySnapshotInput { + canonical_origin: pin.canonical_origin, + observed_at_unix_ms, + profile_version: canary.profile.profile_version, + product: canary.profile.product.clone(), + release: canary.profile.release.clone(), + mode: canary.profile.mode.clone(), + mode_confidence: if canary.profile.mode.is_some() { + Confidence::Observed + } else { + Confidence::Unknown + }, + items, + }) + .await + .map_err(|_| "The read-only canary passed, but its encrypted evidence was not stored")?; + + let capability_profile_sha256 = capability_profile_sha256(&canary.profile) + .map_err(|_| "The capability profile could not be bound to the snapshot plan")?; + let plan = SnapshotPlan { + resume_key: format!("snapshot:{run_id}"), + run_id, + capability_snapshot_id: snapshot.id, + mirror_company_id: pin.company_id, + company, + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: CORE_ACCOUNTING_SCHEMA_VERSION, + capability_profile_version: canary.profile.profile_version, + capability_profile_sha256, + source_product: canary.profile.product, + source_transport: "xml_http".to_string(), + source_release: canary.profile.release, + source_mode: canary.profile.mode, + external_references: ExternalReferenceCatalog::Unavailable, + adaptive_window_policy: Some(AdaptiveWindowPolicy::bounded_default()), + capability_canary_window: Some(capability_canary_window), + windows: vec![planned], + started_at_unix_ms: observed_at_unix_ms, + freshness_target_seconds: 86_400, + }; + coordinator + .start(plan, connector, mirror.inner().clone()) + .await + .map_err(str::to_string) +} + +#[tauri::command] +pub async fn tally_snapshot_status( + run_id: String, + mirror: State<'_, TallyMirrorRepository>, + coordinator: State<'_, SnapshotCoordinator>, +) -> Result { + coordinator + .status(&run_id, mirror.inner()) + .await + .map_err(str::to_string) +} + +#[tauri::command] +pub async fn tally_recent_snapshot_runs( + mirror: State<'_, TallyMirrorRepository>, + coordinator: State<'_, SnapshotCoordinator>, +) -> Result, String> { + coordinator + .recent(mirror.inner(), 20) + .await + .map_err(str::to_string) +} + +#[derive(Debug, Deserialize)] +pub struct ResumeCoreSnapshotRequest { + pub config: TallyConfig, + pub run_id: String, +} + +#[tauri::command] +pub async fn resume_tally_core_snapshot( + request: ResumeCoreSnapshotRequest, + mirror: State<'_, TallyMirrorRepository>, + runtime: State<'_, TallyRuntime>, + coordinator: State<'_, SnapshotCoordinator>, +) -> Result { + let store = SqliteSnapshotStateStore::new(mirror.pool_clone()); + store + .migrate() + .await + .map_err(|_| "Restart-safe snapshot recovery is not installed".to_string())?; + let state = store + .load_by_run_id(&request.run_id) + .await + .map_err(|_| "The encrypted snapshot recovery state is invalid".to_string())? + .ok_or_else(|| "The snapshot recovery state was not found".to_string())?; + if state.progress.phase.is_terminal() { + return Err("A terminal snapshot cannot be resumed".to_string()); + } + let plan = state + .recoverable_plan() + .map_err(|_| "This snapshot predates restart-safe recovery or its plan is invalid")?; + if plan.pack != CapabilityPackId::CoreAccounting + || plan.pack_schema_version != CORE_ACCOUNTING_SCHEMA_VERSION + || plan.source_transport != "xml_http" + { + return Err("The stored snapshot profile is not resumable by this build".to_string()); + } + + let pin = mirror + .snapshot_source_pin(&plan.mirror_company_id) + .await + .map_err(|_| "The encrypted company pin for this snapshot is unavailable".to_string())?; + validate_company_name(&pin.display_name)?; + let request_origin = EndpointKey::from_config(&request.config) + .map_err(|_| "Tally endpoint validation failed".to_string())?; + let lineage = source_lineage(&request.config).map_err(|_| "Tally source lineage is invalid")?; + let observed_company = CoreCompanyRef { + identity: company_source_identity(&lineage, &pin.company_guid), + display_name: pin.display_name.clone(), + }; + if request_origin.as_str() != pin.canonical_origin + || plan.mirror_company_id != pin.company_id + || plan.company != observed_company + { + return Err( + "The current endpoint or encrypted company pin does not match the immutable snapshot plan" + .to_string(), + ); + } + if !mirror + .core_snapshot_resume_evidence_matches_plan( + &plan.capability_snapshot_id, + &plan.mirror_company_id, + plan.capability_profile_version, + &plan.source_product, + plan.source_release.as_deref(), + plan.source_mode.as_deref(), + ) + .await + .map_err(|_| "The stored capability evidence could not be validated".to_string())? + { + return Err( + "The stored capability evidence is not bound to the pinned company endpoint" + .to_string(), + ); + } + + let canary_window = plan + .capability_canary_window + .clone() + .ok_or_else(|| "The stored snapshot plan contains no canary window".to_string())?; + let context = RequestContext { + run_id: plan.run_id.clone(), + company: plan.company.clone(), + pack: plan.pack, + schema_version: plan.pack_schema_version, + window: canary_window.range, + query_profile: canary_window.query_profile, + filters_sha256: canary_window.filters_sha256, + }; + let connector = RuntimeTallyConnector::new( + runtime.inner().clone(), + request.config, + plan.company.clone(), + context, + ) + .map_err(|_| "The stored Core Accounting snapshot profile is invalid".to_string())?; + coordinator + .start(plan, connector, mirror.inner().clone()) + .await + .map_err(str::to_string) +} + +#[tauri::command] +pub fn cancel_tally_snapshot( + run_id: String, + coordinator: State<'_, SnapshotCoordinator>, +) -> Result { + coordinator.cancel(&run_id).map_err(str::to_string) +} + +fn mirror_capability_state(state: bridge_tally_core::CapabilityState) -> MirrorCapabilityState { + match state { + bridge_tally_core::CapabilityState::Supported => MirrorCapabilityState::Supported, + bridge_tally_core::CapabilityState::Unsupported => MirrorCapabilityState::Unsupported, + bridge_tally_core::CapabilityState::Unknown => MirrorCapabilityState::Unknown, + bridge_tally_core::CapabilityState::NotConfigured => MirrorCapabilityState::NotConfigured, + } +} + +fn mirror_confidence(confidence: bridge_tally_core::EvidenceConfidence) -> Confidence { + match confidence { + bridge_tally_core::EvidenceConfidence::Documented => Confidence::Documented, + bridge_tally_core::EvidenceConfidence::Observed => Confidence::Observed, + bridge_tally_core::EvidenceConfidence::Inferred => Confidence::Inferred, + bridge_tally_core::EvidenceConfidence::Unknown => Confidence::Unknown, + } +} + +fn transport_key(transport: bridge_tally_core::TransportId) -> &'static str { + match transport { + bridge_tally_core::TransportId::XmlHttp => "xml_http", + bridge_tally_core::TransportId::JsonEx => "json_ex", + bridge_tally_core::TransportId::TdlCompanion => "tdl_companion", + bridge_tally_core::TransportId::Odbc => "odbc", + } +} + +fn pack_key(pack: bridge_tally_core::CapabilityPackId) -> &'static str { + match pack { + bridge_tally_core::CapabilityPackId::CoreAccounting => "core_accounting", + bridge_tally_core::CapabilityPackId::IndiaTax => "india_tax", + bridge_tally_core::CapabilityPackId::BillsAndPayments => "bills_and_payments", + bridge_tally_core::CapabilityPackId::Inventory => "inventory", + } +} + +fn feature_key(feature: bridge_tally_core::CapabilityFeatureId) -> &'static str { + match feature { + bridge_tally_core::CapabilityFeatureId::EndpointReachability => "endpoint_reachability", + bridge_tally_core::CapabilityFeatureId::LoadedCompanies => "loaded_companies", + bridge_tally_core::CapabilityFeatureId::StableCompanyIdentity => "stable_company_identity", + bridge_tally_core::CapabilityFeatureId::EncodingBehaviour => "encoding_behaviour", + bridge_tally_core::CapabilityFeatureId::PracticalResponseLimit => { + "practical_response_limit" + } + bridge_tally_core::CapabilityFeatureId::CompanyRead => "company_read", + bridge_tally_core::CapabilityFeatureId::LedgerRead => "ledger_read", + bridge_tally_core::CapabilityFeatureId::VoucherRead => "voucher_read", + bridge_tally_core::CapabilityFeatureId::SelectedLedgerRead => "selected_ledger_read", + bridge_tally_core::CapabilityFeatureId::SelectedVoucherWindowRead => { + "selected_voucher_window_read" + } + bridge_tally_core::CapabilityFeatureId::Write => "write", + } +} + +#[tauri::command] +pub async fn fetch_tally_companies( + config: TallyConfig, + runtime: State<'_, TallyRuntime>, +) -> Result, TallyCommandError> { + runtime + .fetch_companies(config) + .await + .map_err(tally_runtime_command_error) } #[derive(Debug, Deserialize)] pub struct CompanyRequest { pub config: TallyConfig, pub company: String, + pub expected_company_guid: String, } #[derive(Debug, Deserialize)] pub struct VoucherRequest { pub config: TallyConfig, pub company: String, + pub expected_company_guid: String, pub from: String, pub to: String, } #[tauri::command] -pub async fn fetch_tally_ledgers(request: CompanyRequest) -> Result, String> { - TallyClient::new(request.config) - .fetch_ledgers(&request.company) +pub async fn fetch_tally_ledgers( + request: CompanyRequest, + runtime: State<'_, TallyRuntime>, +) -> Result, TallyCommandError> { + validate_company_name(&request.company).map_err(|message| { + tally_command_error( + "company_selection_invalid", + "Tally application", + message, + "after_change", + false, + "Select the intended GUID-bearing company and repeat the read-only action.", + ) + })?; + runtime + .fetch_ledgers( + request.config, + request.company, + request.expected_company_guid, + ) .await - .map_err(|error| error.to_string()) + .map_err(tally_runtime_command_error) } #[tauri::command] -pub async fn fetch_tally_vouchers(request: VoucherRequest) -> Result, String> { - TallyClient::new(request.config) - .fetch_vouchers(&request.company, &request.from, &request.to) +pub async fn fetch_tally_vouchers( + request: VoucherRequest, + runtime: State<'_, TallyRuntime>, +) -> Result, TallyCommandError> { + validate_company_name(&request.company).map_err(|message| { + tally_command_error( + "company_selection_invalid", + "Tally application", + message, + "after_change", + false, + "Select the intended GUID-bearing company and repeat the read-only action.", + ) + })?; + validate_date_range(&request.from, &request.to).map_err(|message| { + tally_command_error( + "accounting_period_invalid", + "Endpoint configuration", + message, + "after_change", + false, + "Choose a valid accounting period, then repeat the read-only action.", + ) + })?; + runtime + .fetch_vouchers( + request.config, + request.company, + request.expected_company_guid, + request.from, + request.to, + ) .await - .map_err(|error| error.to_string()) + .map_err(tally_runtime_command_error) +} + +#[tauri::command] +pub fn cancel_tally_request( + request_id: String, + runtime: State<'_, TallyRuntime>, +) -> Result { + runtime + .cancel_request(&request_id) + .map_err(tally_runtime_command_error) +} + +#[tauri::command] +pub fn tally_runtime_snapshots( + runtime: State<'_, TallyRuntime>, +) -> Result, TallyCommandError> { + runtime.snapshots().map_err(tally_runtime_command_error) +} + +#[tauri::command] +pub fn tally_telemetry_preview( + runtime: State<'_, TallyRuntime>, +) -> Result { + runtime + .telemetry_preview() + .map_err(tally_runtime_command_error) } #[tauri::command] pub async fn prepare_gst_return_draft(request: GstDraftRequest) -> Result { - Ok(GstReturnDraft::empty(request)) + let _ = request; + Err("GST return drafting is not implemented; Bridge did not produce a GST result".to_string()) } async fn run_dsc_probe( detect_only: bool, - pins: Option>, + pins: Option>>, ) -> Result { tokio::task::spawn_blocking(move || { + let pins = pins.map(|mut pins| std::mem::take(&mut *pins)); crate::dsc::run_probe_isolated(detect_only, None, pins, true) .map_err(|error| error.to_string()) }) @@ -79,11 +1737,10 @@ pub async fn detect_dsc_token() -> Result { pub async fn extract_dsc_certificates( pins: Option>, ) -> Result { - let mut pins = pins.ok_or_else(|| "PIN is required to extract DSC certificates".to_string())?; - if let Err(error) = validate_dsc_pins(&pins) { - pins.zeroize(); - return Err(error); - } + let pins = Zeroizing::new( + pins.ok_or_else(|| "PIN is required to extract DSC certificates".to_string())?, + ); + validate_dsc_pins(&pins)?; run_dsc_probe(false, Some(pins)).await } @@ -190,7 +1847,16 @@ pub async fn select_document_folder() -> Result anyhow::Result>>>; + fn save(&self, key: &[u8]) -> anyhow::Result<()>; + fn delete(&self) -> anyhow::Result<()>; +} + +#[derive(Clone)] +pub struct OsMirrorKeyStore { + account: String, +} + +impl OsMirrorKeyStore { + pub fn for_database(database_path: &Path) -> Self { + let mut hasher = Sha256::new(); + hasher.update(database_path.as_os_str().to_string_lossy().as_bytes()); + let path_id = hex_key(&hasher.finalize()); + Self { + account: format!("{KEYRING_ACCOUNT_PREFIX}:{path_id}"), + } + } + + fn entry(&self) -> anyhow::Result { + keyring::Entry::new(KEYRING_SERVICE, &self.account) + .map_err(|_| anyhow::anyhow!("The operating-system credential store is unavailable")) + } +} + +impl MirrorKeyStore for OsMirrorKeyStore { + fn load(&self) -> anyhow::Result>>> { + match self.entry()?.get_secret() { + Ok(secret) => Ok(Some(Zeroizing::new(secret))), + Err(keyring::Error::NoEntry) => Ok(None), + Err(_) => anyhow::bail!( + "The Tally mirror key could not be read from the operating-system credential store" + ), + } + } + + fn save(&self, key: &[u8]) -> anyhow::Result<()> { + self.entry()? + .set_secret(key) + .map_err(|_| anyhow::anyhow!("The Tally mirror key could not be stored securely")) + } + + fn delete(&self) -> anyhow::Result<()> { + match self.entry()?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(_) => anyhow::bail!("The Tally mirror key could not be removed from the operating-system credential store"), + } + } +} + +pub struct MirrorInitializationLock { + _file: File, +} + +pub fn lock_mirror_initialization( + database_path: &Path, +) -> anyhow::Result { + let file_name = database_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("The Tally mirror path has no file name"))?; + let mut lock_name = file_name.to_os_string(); + lock_name.push(".init.lock"); + let lock_path = database_path.with_file_name(lock_name); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(lock_path) + .map_err(|_| anyhow::anyhow!("The Tally mirror initialization lock could not be opened"))?; + file.lock() + .map_err(|_| anyhow::anyhow!("The Tally mirror is being initialized by another process"))?; + Ok(MirrorInitializationLock { _file: file }) +} + +pub struct ResolvedMirrorKey { + pub key: Zeroizing>, + pub created: bool, +} + +pub fn resolve_mirror_key( + database_path: &Path, + key_store: &dyn MirrorKeyStore, +) -> anyhow::Result { + if let Some(key) = key_store.load()? { + validate_key(&key)?; + return Ok(ResolvedMirrorKey { + key, + created: false, + }); + } + + if database_path.exists() { + anyhow::bail!( + "The encrypted Tally mirror exists but its operating-system key is missing; an explicit local reset is required" + ); + } + + let mut key = Zeroizing::new(vec![0_u8; MIRROR_KEY_BYTES]); + getrandom::fill(&mut key).map_err(|_| { + anyhow::anyhow!("The operating system could not generate a Tally mirror key") + })?; + key_store.save(&key)?; + Ok(ResolvedMirrorKey { key, created: true }) +} + +fn validate_key(key: &[u8]) -> anyhow::Result<()> { + if key.len() != MIRROR_KEY_BYTES { + anyhow::bail!( + "The stored Tally mirror key is invalid; an explicit local reset is required" + ); + } + Ok(()) +} + +pub async fn connect_encrypted( + database_path: &Path, + key: Zeroizing>, +) -> anyhow::Result { + validate_key(&key)?; + // SqliteConnectOptions owns PRAGMA values as ordinary strings and the pool keeps those + // options for its lifetime so that it can replace connections. Keeping SQLCipher's key in + // `.pragma("key", ...)` would therefore leave an unprotected copy in the pool. Retain the + // original bytes only in zeroizing storage and apply them to every new SQLite handle through + // SQLCipher's C API instead. + let connection_key = Arc::new(key); + let options = SqliteConnectOptions::new() + .filename(database_path) + .create_if_missing(true) + .disable_statement_logging(); + + let key_for_connections = Arc::clone(&connection_key); + let pool = SqlitePoolOptions::new() + .max_connections(5) + .after_connect(move |connection, _metadata| { + let key = Arc::clone(&key_for_connections); + Box::pin(async move { + { + let mut handle = connection.lock_handle().await?; + let key_length = i32::try_from(key.len()) + .map_err(|_| sqlx::Error::Protocol("SQLCipher key is too long".into()))?; + // SAFETY: `lock_handle()` excludes the SQLx worker for the duration of this + // call, the handle is live, and `key` remains allocated for the whole call. + // SQLCipher copies the key into its per-connection codec state. + let status = unsafe { + libsqlite3_sys::sqlite3_key( + handle.as_raw_handle().as_ptr(), + key.as_ptr().cast(), + key_length, + ) + }; + if status != libsqlite3_sys::SQLITE_OK { + return Err(sqlx::Error::Protocol( + "SQLCipher rejected the Tally mirror key".into(), + )); + } + } + + // These settings must be applied after sqlite3_key(). Executing them here also + // means replacement connections receive the same hardened configuration without + // putting key material in SqliteConnectOptions. + connection + .execute("PRAGMA cipher_memory_security = ON;") + .await?; + connection.execute("PRAGMA secure_delete = ON;").await?; + connection.execute("PRAGMA foreign_keys = ON;").await?; + connection.execute("PRAGMA journal_mode = WAL;").await?; + Ok(()) + }) + }) + .connect_with(options) + .await?; + + let validation = validate_encrypted_connection(&pool).await; + if let Err(error) = validation { + pool.close().await; + return Err(error); + } + Ok(pool) +} + +async fn validate_encrypted_connection(pool: &SqlitePool) -> anyhow::Result<()> { + let cipher_version = sqlx::query_scalar::<_, String>("PRAGMA cipher_version;") + .fetch_one(pool) + .await + .map_err(|_| { + anyhow::anyhow!("SQLCipher is unavailable or the Tally mirror key is invalid") + })?; + if cipher_version.trim().is_empty() { + anyhow::bail!("SQLCipher did not report an active cipher version"); + } + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM sqlite_master;") + .fetch_one(pool) + .await + .map_err(|_| { + anyhow::anyhow!("The encrypted Tally mirror could not be opened with its stored key") + })?; + let integrity_errors = sqlx::query_scalar::<_, String>("PRAGMA cipher_integrity_check;") + .fetch_all(pool) + .await + .map_err(|_| anyhow::anyhow!("The encrypted Tally mirror could not be verified"))?; + if !integrity_errors.is_empty() { + anyhow::bail!("The encrypted Tally mirror failed its integrity check"); + } + + let foreign_keys = sqlx::query_scalar::<_, i64>("PRAGMA foreign_keys;") + .fetch_one(pool) + .await?; + let secure_delete = sqlx::query_scalar::<_, i64>("PRAGMA secure_delete;") + .fetch_one(pool) + .await?; + if foreign_keys != 1 || secure_delete != 1 { + anyhow::bail!("The encrypted Tally mirror security settings were not applied"); + } + Ok(()) +} + +fn hex_key(key: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(key.len() * 2); + for byte in key { + encoded.push(HEX[(byte >> 4) as usize] as char); + encoded.push(HEX[(byte & 0x0f) as usize] as char); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::{connect_encrypted, resolve_mirror_key, MirrorKeyStore, MIRROR_KEY_BYTES}; + use std::fs; + use std::path::{Path, PathBuf}; + use std::sync::Mutex; + use zeroize::Zeroizing; + + #[derive(Default)] + struct FakeKeyStore { + key: Mutex>>, + } + + impl FakeKeyStore { + fn with_key(key: Vec) -> Self { + Self { + key: Mutex::new(Some(key)), + } + } + } + + impl MirrorKeyStore for FakeKeyStore { + fn load(&self) -> anyhow::Result>>> { + Ok(self + .key + .lock() + .expect("fake key store lock") + .clone() + .map(Zeroizing::new)) + } + + fn save(&self, key: &[u8]) -> anyhow::Result<()> { + *self.key.lock().expect("fake key store lock") = Some(key.to_vec()); + Ok(()) + } + + fn delete(&self) -> anyhow::Result<()> { + *self.key.lock().expect("fake key store lock") = None; + Ok(()) + } + } + + fn mirror_artifacts(database: &Path) -> Vec { + let mut wal = database.as_os_str().to_os_string(); + wal.push("-wal"); + let mut shared_memory = database.as_os_str().to_os_string(); + shared_memory.push("-shm"); + vec![ + database.to_path_buf(), + PathBuf::from(wal), + PathBuf::from(shared_memory), + ] + } + + fn assert_no_plaintext_artifacts(database: &Path) { + for artifact in mirror_artifacts(database) { + if !artifact.exists() { + continue; + } + let bytes = fs::read(&artifact).expect("read mirror artifact"); + for marker in [ + b"SQLite format 3".as_slice(), + b"SENSITIVE_TALLY_MARKER".as_slice(), + b"1111111111111111111111111111111111111111111111111111111111111111".as_slice(), + ] { + assert!( + !bytes.windows(marker.len()).any(|window| window == marker), + "plaintext marker found in {}", + artifact.display() + ); + } + } + } + + #[test] + fn creates_a_key_only_for_a_new_mirror() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("mirror.db"); + let store = FakeKeyStore::default(); + let resolved = resolve_mirror_key(&database, &store).expect("resolve new key"); + assert!(resolved.created); + assert_eq!(resolved.key.len(), MIRROR_KEY_BYTES); + assert!(store.load().expect("reload key").is_some()); + } + + #[test] + fn missing_or_invalid_key_for_existing_mirror_fails_closed() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("mirror.db"); + fs::write(&database, b"existing mirror marker").expect("create existing mirror"); + let missing = match resolve_mirror_key(&database, &FakeKeyStore::default()) { + Ok(_) => panic!("missing key must not be replaced"), + Err(error) => error, + }; + assert!(missing.to_string().contains("explicit local reset")); + + let invalid_store = FakeKeyStore::with_key(vec![7_u8; 16]); + let invalid = match resolve_mirror_key(&database, &invalid_store) { + Ok(_) => panic!("invalid key must not be replaced"), + Err(error) => error, + }; + assert!(invalid.to_string().contains("explicit local reset")); + } + + #[tokio::test] + async fn sqlcipher_encrypts_contents_and_rejects_the_wrong_key() { + let directory = tempfile::tempdir().expect("temporary directory"); + let database = directory.path().join("mirror.db"); + let key = Zeroizing::new(vec![0x11_u8; MIRROR_KEY_BYTES]); + let pool = connect_encrypted(&database, key.clone()) + .await + .expect("open encrypted mirror"); + let retained_options = format!("{:?}", pool.connect_options()); + assert!( + !retained_options + .contains("1111111111111111111111111111111111111111111111111111111111111111"), + "pool connection options must not retain a hexadecimal copy of the key" + ); + sqlx::query("CREATE TABLE proof(value TEXT NOT NULL);") + .execute(&pool) + .await + .expect("create encrypted table"); + sqlx::query("INSERT INTO proof(value) VALUES (?1);") + .bind("SENSITIVE_TALLY_MARKER") + .execute(&pool) + .await + .expect("insert encrypted value"); + + // Make SQLx open the full pool, close every live connection, then require a replacement. + // This proves that per-connection keying works without retaining the key in connect + // options, including after the initial connection has gone away. + let mut connections = Vec::new(); + for _ in 0..5 { + connections.push(pool.acquire().await.expect("acquire encrypted connection")); + } + for mut connection in connections { + let value = sqlx::query_scalar::<_, String>("SELECT value FROM proof;") + .fetch_one(&mut *connection) + .await + .expect("read through encrypted pooled connection"); + assert_eq!(value, "SENSITIVE_TALLY_MARKER"); + connection + .close() + .await + .expect("close encrypted pooled connection"); + } + let replacement_value = sqlx::query_scalar::<_, String>("SELECT value FROM proof;") + .fetch_one(&pool) + .await + .expect("read through replacement encrypted connection"); + assert_eq!(replacement_value, "SENSITIVE_TALLY_MARKER"); + + assert_no_plaintext_artifacts(&database); + pool.close().await; + + assert_no_plaintext_artifacts(&database); + let before_wrong_key = fs::read(&database).expect("read encrypted database before retry"); + + let wrong_key = Zeroizing::new(vec![0x22_u8; MIRROR_KEY_BYTES]); + assert!(connect_encrypted(&database, wrong_key).await.is_err()); + assert_eq!( + fs::read(&database).expect("read encrypted database after wrong key"), + before_wrong_key, + "a wrong-key attempt must not mutate the encrypted mirror" + ); + assert_no_plaintext_artifacts(&database); + + let reopened = connect_encrypted(&database, key) + .await + .expect("reopen with correct key"); + let value = sqlx::query_scalar::<_, String>("SELECT value FROM proof;") + .fetch_one(&reopened) + .await + .expect("read encrypted value"); + assert_eq!(value, "SENSITIVE_TALLY_MARKER"); + reopened.close().await; + } +} diff --git a/src-tauri/src/db/migrations/0002_tally_mirror.sql b/src-tauri/src/db/migrations/0002_tally_mirror.sql new file mode 100644 index 0000000..81708ab --- /dev/null +++ b/src-tauri/src/db/migrations/0002_tally_mirror.sql @@ -0,0 +1,251 @@ +CREATE TABLE IF NOT EXISTS tally_schema_migrations ( + version INTEGER PRIMARY KEY, + description TEXT NOT NULL, + applied_at_unix_ms INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS tally_endpoints ( + id TEXT PRIMARY KEY, + canonical_origin TEXT NOT NULL UNIQUE, + created_at_unix_ms INTEGER NOT NULL, + last_observed_at_unix_ms INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS tally_capability_snapshots ( + id TEXT PRIMARY KEY, + endpoint_id TEXT NOT NULL, + observed_at_unix_ms INTEGER NOT NULL, + profile_version INTEGER NOT NULL CHECK (profile_version > 0), + product TEXT NOT NULL, + release TEXT, + mode TEXT, + mode_confidence TEXT NOT NULL CHECK ( + mode_confidence IN ('documented', 'observed', 'inferred', 'unknown') + ), + FOREIGN KEY (endpoint_id) REFERENCES tally_endpoints(id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_tally_capability_snapshots_endpoint_observed + ON tally_capability_snapshots(endpoint_id, observed_at_unix_ms DESC); + +CREATE TABLE IF NOT EXISTS tally_capability_items ( + snapshot_id TEXT NOT NULL, + capability_kind TEXT NOT NULL CHECK ( + capability_kind IN ('transport', 'pack', 'feature') + ), + capability_key TEXT NOT NULL, + capability_state TEXT NOT NULL CHECK ( + capability_state IN ('supported', 'unsupported', 'unknown', 'not_configured') + ), + confidence TEXT NOT NULL CHECK ( + confidence IN ('documented', 'observed', 'inferred', 'unknown') + ), + safe_reason_code TEXT, + PRIMARY KEY (snapshot_id, capability_kind, capability_key), + FOREIGN KEY (snapshot_id) REFERENCES tally_capability_snapshots(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS tally_companies ( + id TEXT PRIMARY KEY, + endpoint_id TEXT NOT NULL, + display_name TEXT NOT NULL, + company_guid TEXT, + remote_id TEXT, + master_id TEXT, + fallback_fingerprint TEXT, + identity_confidence TEXT NOT NULL CHECK ( + identity_confidence IN ('documented', 'observed', 'inferred', 'unknown') + ), + first_observed_at_unix_ms INTEGER NOT NULL, + last_observed_at_unix_ms INTEGER NOT NULL, + CHECK ( + company_guid IS NOT NULL OR remote_id IS NOT NULL OR + master_id IS NOT NULL OR fallback_fingerprint IS NOT NULL + ), + FOREIGN KEY (endpoint_id) REFERENCES tally_endpoints(id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_tally_companies_guid + ON tally_companies(endpoint_id, company_guid) WHERE company_guid IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_tally_companies_remote_id + ON tally_companies(endpoint_id, remote_id) WHERE remote_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_tally_companies_master_id + ON tally_companies(endpoint_id, master_id) WHERE master_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_tally_companies_fallback + ON tally_companies(endpoint_id, fallback_fingerprint) + WHERE fallback_fingerprint IS NOT NULL; + +CREATE TABLE IF NOT EXISTS tally_observation_batches ( + id TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + capability_snapshot_id TEXT NOT NULL, + company_id TEXT NOT NULL, + pack_id TEXT NOT NULL, + pack_schema_major INTEGER NOT NULL CHECK (pack_schema_major >= 0), + pack_schema_minor INTEGER NOT NULL CHECK (pack_schema_minor >= 0), + source_transport TEXT NOT NULL, + source_release TEXT, + requested_from_yyyymmdd TEXT, + requested_to_yyyymmdd TEXT, + started_at_unix_ms INTEGER NOT NULL, + completed_at_unix_ms INTEGER, + state TEXT NOT NULL CHECK (state IN ('staging', 'verified', 'partial', 'failed')), + snapshot_sha256 TEXT, + accepted_records INTEGER NOT NULL DEFAULT 0 CHECK (accepted_records >= 0), + rejected_records INTEGER NOT NULL DEFAULT 0 CHECK (rejected_records >= 0), + UNIQUE (run_id, pack_id), + FOREIGN KEY (capability_snapshot_id) REFERENCES tally_capability_snapshots(id) ON DELETE RESTRICT, + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_tally_batches_company_pack_started + ON tally_observation_batches(company_id, pack_id, started_at_unix_ms DESC); + +CREATE TABLE IF NOT EXISTS tally_source_records ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + object_type TEXT NOT NULL, + display_name TEXT, + source_guid TEXT, + remote_id TEXT, + master_id TEXT, + fallback_fingerprint TEXT, + identity_confidence TEXT NOT NULL CHECK ( + identity_confidence IN ('documented', 'observed', 'inferred', 'unknown') + ), + first_seen_batch_id TEXT NOT NULL, + last_seen_batch_id TEXT NOT NULL, + tombstoned_at_unix_ms INTEGER, + CHECK ( + source_guid IS NOT NULL OR remote_id IS NOT NULL OR + master_id IS NOT NULL OR fallback_fingerprint IS NOT NULL + ), + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT, + FOREIGN KEY (first_seen_batch_id) REFERENCES tally_observation_batches(id) ON DELETE RESTRICT, + FOREIGN KEY (last_seen_batch_id) REFERENCES tally_observation_batches(id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_tally_records_guid + ON tally_source_records(company_id, object_type, source_guid) + WHERE source_guid IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_tally_records_remote_id + ON tally_source_records(company_id, object_type, remote_id) + WHERE remote_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_tally_records_master_id + ON tally_source_records(company_id, object_type, master_id) + WHERE master_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS uq_tally_records_fallback + ON tally_source_records(company_id, object_type, fallback_fingerprint) + WHERE fallback_fingerprint IS NOT NULL; + +CREATE TABLE IF NOT EXISTS tally_record_observations ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL, + source_record_id TEXT NOT NULL, + observed_at_unix_ms INTEGER NOT NULL, + raw_source_sha256 TEXT NOT NULL, + canonical_sha256 TEXT, + canonical_payload_json TEXT, + exact_decimals_json TEXT NOT NULL DEFAULT '{}', + observed_alter_id TEXT, + validation_status TEXT NOT NULL CHECK (validation_status IN ('accepted', 'rejected')), + safe_rejection_code TEXT, + UNIQUE (batch_id, source_record_id), + CHECK ( + (validation_status = 'accepted' AND canonical_sha256 IS NOT NULL AND + canonical_payload_json IS NOT NULL AND safe_rejection_code IS NULL) OR + (validation_status = 'rejected' AND canonical_payload_json IS NULL AND + safe_rejection_code IS NOT NULL) + ), + FOREIGN KEY (batch_id) REFERENCES tally_observation_batches(id) ON DELETE RESTRICT, + FOREIGN KEY (source_record_id) REFERENCES tally_source_records(id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_tally_observations_batch + ON tally_record_observations(batch_id, validation_status); + +CREATE TABLE IF NOT EXISTS tally_proof_ledger ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + proof_contract_version INTEGER NOT NULL CHECK (proof_contract_version > 0), + previous_entry_sha256 TEXT, + entry_sha256 TEXT NOT NULL UNIQUE, + run_id TEXT NOT NULL, + batch_id TEXT NOT NULL UNIQUE, + capability_snapshot_id TEXT NOT NULL, + company_id TEXT NOT NULL, + pack_id TEXT NOT NULL, + outcome TEXT NOT NULL CHECK ( + outcome IN ('completed', 'failed', 'cancelled', 'outcome_unknown') + ), + verification_state TEXT NOT NULL CHECK ( + verification_state IN ('verified', 'partial', 'unverified') + ), + started_at_unix_ms INTEGER NOT NULL, + completed_at_unix_ms INTEGER, + accepted_records INTEGER NOT NULL CHECK (accepted_records >= 0), + rejected_records INTEGER NOT NULL CHECK (rejected_records >= 0), + snapshot_sha256 TEXT, + checkpoint_before TEXT, + checkpoint_after TEXT, + gap_codes_json TEXT NOT NULL DEFAULT '[]', + warning_codes_json TEXT NOT NULL DEFAULT '[]', + created_at_unix_ms INTEGER NOT NULL, + FOREIGN KEY (batch_id) REFERENCES tally_observation_batches(id) ON DELETE RESTRICT, + FOREIGN KEY (capability_snapshot_id) REFERENCES tally_capability_snapshots(id) ON DELETE RESTRICT, + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS tally_checkpoints ( + company_id TEXT NOT NULL, + pack_id TEXT NOT NULL, + checkpoint_token TEXT NOT NULL, + run_id TEXT NOT NULL, + proof_id TEXT NOT NULL, + snapshot_sha256 TEXT NOT NULL, + verified_at_unix_ms INTEGER NOT NULL, + freshness_target_seconds INTEGER NOT NULL CHECK (freshness_target_seconds > 0), + generation INTEGER NOT NULL CHECK (generation > 0), + PRIMARY KEY (company_id, pack_id), + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT, + FOREIGN KEY (proof_id) REFERENCES tally_proof_ledger(id) ON DELETE RESTRICT +); + +CREATE TRIGGER IF NOT EXISTS tally_capability_snapshots_no_update +BEFORE UPDATE ON tally_capability_snapshots +BEGIN + SELECT RAISE(ABORT, 'capability snapshots are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_capability_snapshots_no_delete +BEFORE DELETE ON tally_capability_snapshots +BEGIN + SELECT RAISE(ABORT, 'capability snapshots are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_record_observations_no_update +BEFORE UPDATE ON tally_record_observations +BEGIN + SELECT RAISE(ABORT, 'record observations are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_record_observations_no_delete +BEFORE DELETE ON tally_record_observations +BEGIN + SELECT RAISE(ABORT, 'record observations are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_proof_ledger_no_update +BEFORE UPDATE ON tally_proof_ledger +BEGIN + SELECT RAISE(ABORT, 'proof ledger entries are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_proof_ledger_no_delete +BEFORE DELETE ON tally_proof_ledger +BEGIN + SELECT RAISE(ABORT, 'proof ledger entries are immutable'); +END; + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (2, 'encrypted Tally mirror and proof ledger', 0); diff --git a/src-tauri/src/db/migrations/0003_tally_safe_writes.sql b/src-tauri/src/db/migrations/0003_tally_safe_writes.sql new file mode 100644 index 0000000..cfea361 --- /dev/null +++ b/src-tauri/src/db/migrations/0003_tally_safe_writes.sql @@ -0,0 +1,425 @@ +CREATE TABLE IF NOT EXISTS tally_write_mapping_versions ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + object_type TEXT NOT NULL, + mapping_key TEXT NOT NULL, + version INTEGER NOT NULL CHECK (version > 0), + mapping_sha256 TEXT NOT NULL CHECK ( + length(mapping_sha256) = 64 AND mapping_sha256 NOT GLOB '*[^0-9a-f]*' + ), + supersedes_id TEXT, + created_at_unix_ms INTEGER NOT NULL, + UNIQUE (company_id, object_type, mapping_key, version), + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT, + FOREIGN KEY (supersedes_id) REFERENCES tally_write_mapping_versions(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS tally_write_mapping_heads ( + company_id TEXT NOT NULL, + object_type TEXT NOT NULL, + mapping_key TEXT NOT NULL, + mapping_version_id TEXT NOT NULL UNIQUE, + activated_at_unix_ms INTEGER NOT NULL, + PRIMARY KEY (company_id, object_type, mapping_key), + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT, + FOREIGN KEY (mapping_version_id) REFERENCES tally_write_mapping_versions(id) ON DELETE RESTRICT +); + +CREATE TRIGGER IF NOT EXISTS tally_write_mapping_heads_scope_insert +BEFORE INSERT ON tally_write_mapping_heads +WHEN NOT EXISTS ( + SELECT 1 FROM tally_write_mapping_versions AS version + WHERE version.id = NEW.mapping_version_id AND version.company_id = NEW.company_id AND + version.object_type = NEW.object_type AND version.mapping_key = NEW.mapping_key +) +BEGIN + SELECT RAISE(ABORT, 'mapping head scope mismatch'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_write_mapping_heads_scope_update +BEFORE UPDATE ON tally_write_mapping_heads +WHEN NOT EXISTS ( + SELECT 1 FROM tally_write_mapping_versions AS version + WHERE version.id = NEW.mapping_version_id AND version.company_id = NEW.company_id AND + version.object_type = NEW.object_type AND version.mapping_key = NEW.mapping_key +) +BEGIN + SELECT RAISE(ABORT, 'mapping head scope mismatch'); +END; + +CREATE TABLE IF NOT EXISTS tally_import_outbox_jobs ( + id TEXT PRIMARY KEY, + company_id TEXT NOT NULL, + mapping_version_id TEXT NOT NULL, + request_id TEXT NOT NULL UNIQUE, + payload_sha256 TEXT NOT NULL CHECK ( + length(payload_sha256) = 64 AND payload_sha256 NOT GLOB '*[^0-9a-f]*' + ), + diff_sha256 TEXT NOT NULL CHECK ( + length(diff_sha256) = 64 AND diff_sha256 NOT GLOB '*[^0-9a-f]*' + ), + approval_digest TEXT CHECK ( + approval_digest IS NULL OR + (length(approval_digest) = 64 AND approval_digest NOT GLOB '*[^0-9a-f]*') + ), + state TEXT NOT NULL CHECK (state IN ( + 'prepared', 'approved', 'ready_to_send', 'send_started', + 'confirmed_success', 'confirmed_failure', 'outcome_unknown', + 'recovered_success', 'recovered_not_applied', 'failed_pre_send', 'cancelled' + )), + dispatch_attempts INTEGER NOT NULL DEFAULT 0 CHECK (dispatch_attempts IN (0, 1)), + created_at_unix_ms INTEGER NOT NULL, + approved_at_unix_ms INTEGER, + send_started_at_unix_ms INTEGER, + completed_at_unix_ms INTEGER, + CHECK ( + (state = 'prepared' AND approval_digest IS NULL AND approved_at_unix_ms IS NULL) OR + (state <> 'prepared' AND state <> 'cancelled' AND state <> 'failed_pre_send' AND + approval_digest IS NOT NULL AND approved_at_unix_ms IS NOT NULL) OR + (state IN ('cancelled', 'failed_pre_send')) + ), + CHECK ( + (state IN ('prepared', 'approved', 'ready_to_send', 'cancelled', 'failed_pre_send') AND + dispatch_attempts = 0 AND send_started_at_unix_ms IS NULL) OR + (state IN ('send_started', 'confirmed_success', 'confirmed_failure', 'outcome_unknown', + 'recovered_success', 'recovered_not_applied') AND dispatch_attempts = 1 AND + send_started_at_unix_ms IS NOT NULL) + ), + CHECK ( + (state IN ('confirmed_success', 'confirmed_failure', 'recovered_success', + 'recovered_not_applied', 'failed_pre_send', 'cancelled') AND + completed_at_unix_ms IS NOT NULL) OR + (state NOT IN ('confirmed_success', 'confirmed_failure', 'recovered_success', + 'recovered_not_applied', 'failed_pre_send', 'cancelled') AND + completed_at_unix_ms IS NULL) + ), + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT, + FOREIGN KEY (mapping_version_id) REFERENCES tally_write_mapping_versions(id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_tally_import_jobs_company_state + ON tally_import_outbox_jobs(company_id, state, created_at_unix_ms); + +CREATE TABLE IF NOT EXISTS tally_import_outbox_items ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + ordinal INTEGER NOT NULL CHECK (ordinal >= 0), + object_type TEXT NOT NULL, + operation TEXT NOT NULL CHECK (operation IN ('create', 'alter', 'delete')), + source_identity_sha256 TEXT NOT NULL CHECK ( + length(source_identity_sha256) = 64 AND source_identity_sha256 NOT GLOB '*[^0-9a-f]*' + ), + payload_sha256 TEXT NOT NULL CHECK ( + length(payload_sha256) = 64 AND payload_sha256 NOT GLOB '*[^0-9a-f]*' + ), + diff_sha256 TEXT NOT NULL CHECK ( + length(diff_sha256) = 64 AND diff_sha256 NOT GLOB '*[^0-9a-f]*' + ), + expected_before_sha256 TEXT CHECK ( + expected_before_sha256 IS NULL OR + (length(expected_before_sha256) = 64 AND expected_before_sha256 NOT GLOB '*[^0-9a-f]*') + ), + CHECK ( + (operation = 'create' AND expected_before_sha256 IS NULL) OR + (operation IN ('alter', 'delete') AND expected_before_sha256 IS NOT NULL) + ), + UNIQUE (job_id, ordinal), + UNIQUE (job_id, source_identity_sha256, operation), + FOREIGN KEY (job_id) REFERENCES tally_import_outbox_jobs(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS tally_import_idempotency_state ( + idempotency_key_sha256 TEXT PRIMARY KEY CHECK ( + length(idempotency_key_sha256) = 64 AND + idempotency_key_sha256 NOT GLOB '*[^0-9a-f]*' + ), + job_id TEXT NOT NULL UNIQUE, + state TEXT NOT NULL CHECK (state IN ( + 'reserved', 'send_started', 'outcome_unknown', 'terminal', 'abandoned_before_send' + )), + reserved_at_unix_ms INTEGER NOT NULL, + send_started_at_unix_ms INTEGER, + terminal_at_unix_ms INTEGER, + CHECK ( + (state = 'reserved' AND send_started_at_unix_ms IS NULL AND terminal_at_unix_ms IS NULL) OR + (state IN ('send_started', 'outcome_unknown') AND send_started_at_unix_ms IS NOT NULL AND + terminal_at_unix_ms IS NULL) OR + (state = 'terminal' AND send_started_at_unix_ms IS NOT NULL AND terminal_at_unix_ms IS NOT NULL) OR + (state = 'abandoned_before_send' AND send_started_at_unix_ms IS NULL AND + terminal_at_unix_ms IS NOT NULL) + ), + FOREIGN KEY (job_id) REFERENCES tally_import_outbox_jobs(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS tally_import_conflicts ( + id TEXT PRIMARY KEY, + job_id TEXT NOT NULL, + source_identity_sha256 TEXT NOT NULL CHECK ( + length(source_identity_sha256) = 64 AND source_identity_sha256 NOT GLOB '*[^0-9a-f]*' + ), + diff_sha256 TEXT NOT NULL CHECK ( + length(diff_sha256) = 64 AND diff_sha256 NOT GLOB '*[^0-9a-f]*' + ), + conflict_code TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('open', 'resolved', 'rejected')), + resolution_code TEXT, + resolution_digest TEXT CHECK ( + resolution_digest IS NULL OR + (length(resolution_digest) = 64 AND resolution_digest NOT GLOB '*[^0-9a-f]*') + ), + created_at_unix_ms INTEGER NOT NULL, + resolved_at_unix_ms INTEGER, + CHECK ( + (state = 'open' AND resolution_code IS NULL AND resolution_digest IS NULL AND + resolved_at_unix_ms IS NULL) OR + (state IN ('resolved', 'rejected') AND resolution_code IS NOT NULL AND + resolution_digest IS NOT NULL AND resolved_at_unix_ms IS NOT NULL) + ), + UNIQUE (job_id, source_identity_sha256, conflict_code), + FOREIGN KEY (job_id) REFERENCES tally_import_outbox_jobs(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS tally_import_results ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + job_id TEXT NOT NULL, + phase TEXT NOT NULL CHECK (phase IN ('initial', 'recovery')), + verification_id TEXT NOT NULL UNIQUE, + outcome TEXT NOT NULL CHECK (outcome IN ( + 'confirmed_success', 'confirmed_failure', 'outcome_unknown', + 'recovered_success', 'recovered_not_applied', 'recovery_inconclusive' + )), + result_sha256 TEXT NOT NULL CHECK ( + length(result_sha256) = 64 AND result_sha256 NOT GLOB '*[^0-9a-f]*' + ), + intended_payload_sha256 TEXT CHECK ( + intended_payload_sha256 IS NULL OR + (length(intended_payload_sha256) = 64 AND intended_payload_sha256 NOT GLOB '*[^0-9a-f]*') + ), + observed_payload_sha256 TEXT CHECK ( + observed_payload_sha256 IS NULL OR + (length(observed_payload_sha256) = 64 AND observed_payload_sha256 NOT GLOB '*[^0-9a-f]*') + ), + identity_coverage_sha256 TEXT CHECK ( + identity_coverage_sha256 IS NULL OR + (length(identity_coverage_sha256) = 64 AND identity_coverage_sha256 NOT GLOB '*[^0-9a-f]*') + ), + observed_version_digest TEXT CHECK ( + observed_version_digest IS NULL OR + (length(observed_version_digest) = 64 AND observed_version_digest NOT GLOB '*[^0-9a-f]*') + ), + safe_result_code TEXT NOT NULL, + counters_observed INTEGER NOT NULL CHECK (counters_observed IN (0, 1)), + created_count INTEGER CHECK (created_count IS NULL OR created_count >= 0), + altered_count INTEGER CHECK (altered_count IS NULL OR altered_count >= 0), + deleted_count INTEGER CHECK (deleted_count IS NULL OR deleted_count >= 0), + ignored_count INTEGER CHECK (ignored_count IS NULL OR ignored_count >= 0), + error_count INTEGER CHECK (error_count IS NULL OR error_count >= 0), + cancelled_count INTEGER CHECK (cancelled_count IS NULL OR cancelled_count >= 0), + exception_count INTEGER CHECK (exception_count IS NULL OR exception_count >= 0), + line_error_count INTEGER CHECK (line_error_count IS NULL OR line_error_count >= 0), + observed_at_unix_ms INTEGER NOT NULL, + CHECK (length(safe_result_code) > 0 AND length(safe_result_code) <= 200), + CHECK ( + (phase = 'initial' AND outcome IN + ('confirmed_success', 'confirmed_failure', 'outcome_unknown')) OR + (phase = 'recovery' AND outcome IN + ('recovered_success', 'recovered_not_applied', 'recovery_inconclusive')) + ), + CHECK ( + (phase = 'initial' AND intended_payload_sha256 IS NULL AND + observed_payload_sha256 IS NULL AND identity_coverage_sha256 IS NULL AND + observed_version_digest IS NULL) OR + (phase = 'recovery' AND intended_payload_sha256 IS NOT NULL AND + observed_payload_sha256 IS NOT NULL AND identity_coverage_sha256 IS NOT NULL AND + observed_version_digest IS NOT NULL) + ), + CHECK ( + (counters_observed = 0 AND created_count IS NULL AND altered_count IS NULL AND + deleted_count IS NULL AND ignored_count IS NULL AND error_count IS NULL AND + cancelled_count IS NULL AND exception_count IS NULL AND line_error_count IS NULL) OR + (counters_observed = 1 AND created_count IS NOT NULL AND altered_count IS NOT NULL AND + deleted_count IS NOT NULL AND ignored_count IS NOT NULL AND error_count IS NOT NULL AND + cancelled_count IS NOT NULL AND exception_count IS NOT NULL AND + line_error_count IS NOT NULL) + ), + UNIQUE (job_id, phase, verification_id), + FOREIGN KEY (job_id) REFERENCES tally_import_outbox_jobs(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS tally_import_job_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + job_id TEXT NOT NULL, + from_state TEXT, + to_state TEXT NOT NULL CHECK (to_state IN ( + 'prepared', 'approved', 'ready_to_send', 'send_started', + 'confirmed_success', 'confirmed_failure', 'outcome_unknown', + 'recovered_success', 'recovered_not_applied', 'failed_pre_send', 'cancelled' + )), + request_id TEXT NOT NULL, + verification_id TEXT, + safe_reason_code TEXT, + evidence_sha256 TEXT NOT NULL CHECK ( + length(evidence_sha256) = 64 AND evidence_sha256 NOT GLOB '*[^0-9a-f]*' + ), + observed_at_unix_ms INTEGER NOT NULL, + CHECK (from_state IS NULL OR from_state IN ( + 'prepared', 'approved', 'ready_to_send', 'send_started', + 'confirmed_success', 'confirmed_failure', 'outcome_unknown', + 'recovered_success', 'recovered_not_applied', 'failed_pre_send', 'cancelled' + )), + CHECK ( + (from_state IS NULL AND to_state = 'prepared') OR + (from_state = 'prepared' AND to_state IN ('approved', 'cancelled')) OR + (from_state = 'approved' AND to_state IN ('ready_to_send', 'cancelled')) OR + (from_state = 'ready_to_send' AND to_state IN ('send_started', 'failed_pre_send', 'cancelled')) OR + (from_state = 'send_started' AND to_state IN + ('confirmed_success', 'confirmed_failure', 'outcome_unknown')) OR + (from_state = 'outcome_unknown' AND to_state IN + ('outcome_unknown', 'recovered_success', 'recovered_not_applied')) + ), + FOREIGN KEY (job_id) REFERENCES tally_import_outbox_jobs(id) ON DELETE RESTRICT +); + +CREATE TRIGGER IF NOT EXISTS tally_write_mapping_versions_no_update +BEFORE UPDATE ON tally_write_mapping_versions +BEGIN + SELECT RAISE(ABORT, 'write mapping versions are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_write_mapping_versions_no_delete +BEFORE DELETE ON tally_write_mapping_versions +BEGIN + SELECT RAISE(ABORT, 'write mapping versions are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_items_no_update +BEFORE UPDATE ON tally_import_outbox_items +BEGIN + SELECT RAISE(ABORT, 'import outbox items are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_items_no_delete +BEFORE DELETE ON tally_import_outbox_items +BEGIN + SELECT RAISE(ABORT, 'import outbox items are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_results_no_update +BEFORE UPDATE ON tally_import_results +BEGIN + SELECT RAISE(ABORT, 'import results are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_results_no_delete +BEFORE DELETE ON tally_import_results +BEGIN + SELECT RAISE(ABORT, 'import results are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_events_no_update +BEFORE UPDATE ON tally_import_job_events +BEGIN + SELECT RAISE(ABORT, 'import job events are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_events_no_delete +BEFORE DELETE ON tally_import_job_events +BEGIN + SELECT RAISE(ABORT, 'import job events are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_jobs_state_machine +BEFORE UPDATE OF state ON tally_import_outbox_jobs +WHEN NOT ( + (OLD.state = 'prepared' AND NEW.state IN ('approved', 'cancelled')) OR + (OLD.state = 'approved' AND NEW.state IN ('ready_to_send', 'cancelled')) OR + (OLD.state = 'ready_to_send' AND NEW.state IN ('send_started', 'failed_pre_send', 'cancelled')) OR + (OLD.state = 'send_started' AND NEW.state IN + ('confirmed_success', 'confirmed_failure', 'outcome_unknown')) OR + (OLD.state = 'outcome_unknown' AND NEW.state IN + ('recovered_success', 'recovered_not_applied')) +) +BEGIN + SELECT RAISE(ABORT, 'invalid import job state transition'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_jobs_evidence_immutable +BEFORE UPDATE ON tally_import_outbox_jobs +WHEN OLD.id IS NOT NEW.id OR + OLD.company_id IS NOT NEW.company_id OR + OLD.mapping_version_id IS NOT NEW.mapping_version_id OR + OLD.request_id IS NOT NEW.request_id OR + OLD.payload_sha256 IS NOT NEW.payload_sha256 OR + OLD.diff_sha256 IS NOT NEW.diff_sha256 OR + OLD.created_at_unix_ms IS NOT NEW.created_at_unix_ms OR + (OLD.approval_digest IS NOT NEW.approval_digest AND OLD.state <> 'prepared') OR + (OLD.approved_at_unix_ms IS NOT NEW.approved_at_unix_ms AND OLD.state <> 'prepared') OR + (OLD.dispatch_attempts IS NOT NEW.dispatch_attempts AND OLD.state <> 'ready_to_send') OR + (OLD.send_started_at_unix_ms IS NOT NEW.send_started_at_unix_ms AND + OLD.state <> 'ready_to_send') OR + (OLD.completed_at_unix_ms IS NOT NEW.completed_at_unix_ms AND + OLD.completed_at_unix_ms IS NOT NULL) +BEGIN + SELECT RAISE(ABORT, 'import job evidence is immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_jobs_no_delete +BEFORE DELETE ON tally_import_outbox_jobs +BEGIN + SELECT RAISE(ABORT, 'import jobs cannot be deleted'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_idempotency_state_machine +BEFORE UPDATE OF state ON tally_import_idempotency_state +WHEN NOT ( + (OLD.state = 'reserved' AND NEW.state IN ('send_started', 'abandoned_before_send')) OR + (OLD.state = 'send_started' AND NEW.state IN ('outcome_unknown', 'terminal')) OR + (OLD.state = 'outcome_unknown' AND NEW.state = 'terminal') +) +BEGIN + SELECT RAISE(ABORT, 'invalid import idempotency transition'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_idempotency_evidence_immutable +BEFORE UPDATE ON tally_import_idempotency_state +WHEN OLD.idempotency_key_sha256 IS NOT NEW.idempotency_key_sha256 OR + OLD.job_id IS NOT NEW.job_id OR + OLD.reserved_at_unix_ms IS NOT NEW.reserved_at_unix_ms OR + (OLD.send_started_at_unix_ms IS NOT NEW.send_started_at_unix_ms AND + OLD.state <> 'reserved') OR + (OLD.terminal_at_unix_ms IS NOT NEW.terminal_at_unix_ms AND + OLD.state NOT IN ('reserved', 'send_started', 'outcome_unknown')) +BEGIN + SELECT RAISE(ABORT, 'import idempotency evidence is immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_idempotency_no_delete +BEFORE DELETE ON tally_import_idempotency_state +BEGIN + SELECT RAISE(ABORT, 'import idempotency state cannot be deleted'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_conflicts_state_machine +BEFORE UPDATE OF state ON tally_import_conflicts +WHEN NOT (OLD.state = 'open' AND NEW.state IN ('resolved', 'rejected')) +BEGIN + SELECT RAISE(ABORT, 'invalid import conflict state transition'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_conflicts_prepared_only +BEFORE INSERT ON tally_import_conflicts +WHEN (SELECT state FROM tally_import_outbox_jobs WHERE id = NEW.job_id) <> 'prepared' +BEGIN + SELECT RAISE(ABORT, 'conflicts can only be recorded before approval'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_import_conflicts_no_delete +BEFORE DELETE ON tally_import_conflicts +BEGIN + SELECT RAISE(ABORT, 'import conflicts cannot be deleted'); +END; + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (3, 'durable safe Tally write outbox and recovery evidence', 0); diff --git a/src-tauri/src/db/migrations/0004_tally_snapshot_state.sql b/src-tauri/src/db/migrations/0004_tally_snapshot_state.sql new file mode 100644 index 0000000..4fe524d --- /dev/null +++ b/src-tauri/src/db/migrations/0004_tally_snapshot_state.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS tally_snapshot_run_states ( + resume_key TEXT PRIMARY KEY, + run_id TEXT NOT NULL, + generation INTEGER NOT NULL CHECK (generation > 0), + state_sha256 TEXT NOT NULL CHECK ( + length(state_sha256) = 64 AND state_sha256 NOT GLOB '*[^0-9a-f]*' + ), + state_json TEXT NOT NULL CHECK (json_valid(state_json)), + updated_at_unix_ms INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_tally_snapshot_run_states_run + ON tally_snapshot_run_states(run_id); + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (4, 'durable resumable Tally snapshot run state', 0); diff --git a/src-tauri/src/db/migrations/0005_tally_snapshot_recovery.sql b/src-tauri/src/db/migrations/0005_tally_snapshot_recovery.sql new file mode 100644 index 0000000..879d358 --- /dev/null +++ b/src-tauri/src/db/migrations/0005_tally_snapshot_recovery.sql @@ -0,0 +1,45 @@ +ALTER TABLE tally_snapshot_run_states + ADD COLUMN row_sha256 TEXT CHECK ( + row_sha256 IS NULL OR ( + length(row_sha256) = 64 AND row_sha256 NOT GLOB '*[^0-9a-f]*' + ) + ); + +ALTER TABLE tally_snapshot_run_states + ADD COLUMN lease_owner TEXT CHECK ( + lease_owner IS NULL OR (length(lease_owner) BETWEEN 1 AND 128) + ); + +ALTER TABLE tally_snapshot_run_states + ADD COLUMN lease_expires_at_unix_ms INTEGER CHECK ( + (lease_owner IS NULL AND lease_expires_at_unix_ms IS NULL) OR + (lease_owner IS NOT NULL AND lease_expires_at_unix_ms IS NOT NULL) + ); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_tally_snapshot_run_states_unique_run + ON tally_snapshot_run_states(run_id); + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_run_identity_immutable +BEFORE UPDATE OF resume_key, run_id ON tally_snapshot_run_states +WHEN NEW.resume_key <> OLD.resume_key OR NEW.run_id <> OLD.run_id +BEGIN + SELECT RAISE(ABORT, 'snapshot recovery identity is immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_terminal_immutable +BEFORE UPDATE OF generation, state_sha256, state_json, row_sha256 +ON tally_snapshot_run_states +WHEN json_extract(OLD.state_json, '$.progress.phase') IN + ('completed', 'partial', 'failed', 'cancelled') +BEGIN + SELECT RAISE(ABORT, 'terminal snapshot recovery state is immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_state_no_delete +BEFORE DELETE ON tally_snapshot_run_states +BEGIN + SELECT RAISE(ABORT, 'snapshot recovery state is immutable'); +END; + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (5, 'snapshot recovery CAS lease and row integrity', 0); diff --git a/src-tauri/src/db/migrations/0006_tally_incremental_foundation.sql b/src-tauri/src/db/migrations/0006_tally_incremental_foundation.sql new file mode 100644 index 0000000..abbb035 --- /dev/null +++ b/src-tauri/src/db/migrations/0006_tally_incremental_foundation.sql @@ -0,0 +1,269 @@ +CREATE TABLE IF NOT EXISTS tally_incremental_capability_observations ( + id TEXT PRIMARY KEY, + scope_sha256 TEXT NOT NULL CHECK ( + length(scope_sha256) = 64 AND scope_sha256 NOT GLOB '*[^0-9a-f]*' + ), + scope_json TEXT NOT NULL CHECK (json_valid(scope_json)), + capability_snapshot_id TEXT NOT NULL, + company_id TEXT NOT NULL, + verifier_contract_version INTEGER NOT NULL CHECK (verifier_contract_version > 0), + response_sha256 TEXT NOT NULL CHECK ( + length(response_sha256) = 64 AND response_sha256 NOT GLOB '*[^0-9a-f]*' + ), + capability_state TEXT NOT NULL CHECK ( + capability_state IN ('supported', 'unsupported', 'unknown', 'not_configured') + ), + confidence TEXT NOT NULL CHECK ( + confidence IN ('documented', 'observed', 'inferred', 'unknown') + ), + identifier_semantics TEXT NOT NULL CHECK ( + identifier_semantics IN ('monotonic_per_object', 'unknown') + ), + inclusive_lower_bound_observed INTEGER NOT NULL CHECK ( + inclusive_lower_bound_observed IN (0, 1) + ), + explicit_source_high_watermark_observed INTEGER NOT NULL CHECK ( + explicit_source_high_watermark_observed IN (0, 1) + ), + observed_at_unix_ms INTEGER NOT NULL CHECK (observed_at_unix_ms > 0), + UNIQUE(scope_sha256, capability_snapshot_id, observed_at_unix_ms), + FOREIGN KEY (capability_snapshot_id) + REFERENCES tally_capability_snapshots(id) ON DELETE RESTRICT, + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_tally_incremental_capability_scope_observed + ON tally_incremental_capability_observations(scope_sha256, observed_at_unix_ms DESC); + +-- This receipt is the immutable generation-1 establishment event. It is intentionally +-- separate from the generic proof ledger: a pack proof alone cannot attest exact-query +-- AlterID coverage or an explicit source high watermark. +CREATE TABLE IF NOT EXISTS tally_incremental_establishment_receipts ( + id TEXT PRIMARY KEY, + scope_sha256 TEXT NOT NULL CHECK ( + length(scope_sha256) = 64 AND scope_sha256 NOT GLOB '*[^0-9a-f]*' + ), + scope_json TEXT NOT NULL CHECK (json_valid(scope_json)), + capability_observation_id TEXT NOT NULL, + proof_id TEXT NOT NULL, + proof_sha256 TEXT NOT NULL CHECK ( + length(proof_sha256) = 64 AND proof_sha256 NOT GLOB '*[^0-9a-f]*' + ), + batch_id TEXT NOT NULL, + snapshot_plan_sha256 TEXT NOT NULL CHECK ( + length(snapshot_plan_sha256) = 64 AND snapshot_plan_sha256 NOT GLOB '*[^0-9a-f]*' + ), + source_response_sha256 TEXT NOT NULL CHECK ( + length(source_response_sha256) = 64 AND source_response_sha256 NOT GLOB '*[^0-9a-f]*' + ), + coverage_manifest_sha256 TEXT NOT NULL CHECK ( + length(coverage_manifest_sha256) = 64 AND coverage_manifest_sha256 NOT GLOB '*[^0-9a-f]*' + ), + source_high_watermark_decimal TEXT NOT NULL CHECK ( + source_high_watermark_decimal = '0' OR ( + source_high_watermark_decimal NOT GLOB '*[^0-9]*' AND + substr(source_high_watermark_decimal, 1, 1) BETWEEN '1' AND '9' AND + (length(source_high_watermark_decimal) < 20 OR ( + length(source_high_watermark_decimal) = 20 AND + source_high_watermark_decimal <= '18446744073709551615' + )) + ) + ), + max_observed_alter_id_decimal TEXT CHECK ( + max_observed_alter_id_decimal IS NULL OR + max_observed_alter_id_decimal = '0' OR ( + max_observed_alter_id_decimal NOT GLOB '*[^0-9]*' AND + substr(max_observed_alter_id_decimal, 1, 1) BETWEEN '1' AND '9' AND + (length(max_observed_alter_id_decimal) < 20 OR ( + length(max_observed_alter_id_decimal) = 20 AND + max_observed_alter_id_decimal <= '18446744073709551615' + )) + ) + ), + source_record_count INTEGER NOT NULL CHECK (source_record_count >= 0), + accepted_record_count INTEGER NOT NULL CHECK (accepted_record_count >= 0), + deduplicated_record_count INTEGER NOT NULL CHECK (deduplicated_record_count >= 0), + numeric_alter_id_count INTEGER NOT NULL CHECK (numeric_alter_id_count >= 0), + rejected_record_count INTEGER NOT NULL CHECK (rejected_record_count = 0), + duplicate_identity_count INTEGER NOT NULL CHECK (duplicate_identity_count = 0), + missing_identity_count INTEGER NOT NULL CHECK (missing_identity_count = 0), + out_of_scope_record_count INTEGER NOT NULL CHECK (out_of_scope_record_count = 0), + verifier_contract_version INTEGER NOT NULL CHECK (verifier_contract_version > 0), + receipt_sha256 TEXT NOT NULL UNIQUE CHECK ( + length(receipt_sha256) = 64 AND receipt_sha256 NOT GLOB '*[^0-9a-f]*' + ), + created_at_unix_ms INTEGER NOT NULL CHECK (created_at_unix_ms > 0), + CHECK ( + source_record_count = accepted_record_count AND + source_record_count = deduplicated_record_count AND + source_record_count = numeric_alter_id_count AND + ((source_record_count = 0 AND max_observed_alter_id_decimal IS NULL) OR + (source_record_count > 0 AND max_observed_alter_id_decimal IS NOT NULL)) AND + (max_observed_alter_id_decimal IS NULL OR + length(max_observed_alter_id_decimal) < length(source_high_watermark_decimal) OR + (length(max_observed_alter_id_decimal) = length(source_high_watermark_decimal) AND + max_observed_alter_id_decimal <= source_high_watermark_decimal)) + ), + UNIQUE(scope_sha256, proof_id, source_high_watermark_decimal), + FOREIGN KEY (capability_observation_id) + REFERENCES tally_incremental_capability_observations(id) ON DELETE RESTRICT, + FOREIGN KEY (proof_id) REFERENCES tally_proof_ledger(id) ON DELETE RESTRICT, + FOREIGN KEY (batch_id) REFERENCES tally_observation_batches(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS tally_incremental_checkpoint_heads ( + scope_sha256 TEXT PRIMARY KEY CHECK ( + length(scope_sha256) = 64 AND scope_sha256 NOT GLOB '*[^0-9a-f]*' + ), + scope_json TEXT NOT NULL CHECK (json_valid(scope_json)), + establishment_receipt_id TEXT NOT NULL UNIQUE, + high_watermark_decimal TEXT NOT NULL CHECK ( + high_watermark_decimal = '0' OR ( + high_watermark_decimal NOT GLOB '*[^0-9]*' AND + substr(high_watermark_decimal, 1, 1) BETWEEN '1' AND '9' AND + (length(high_watermark_decimal) < 20 OR ( + length(high_watermark_decimal) = 20 AND + high_watermark_decimal <= '18446744073709551615' + )) + ) + ), + generation INTEGER NOT NULL CHECK (generation = 1), + state TEXT NOT NULL CHECK (state = 'active'), + established_at_unix_ms INTEGER NOT NULL CHECK (established_at_unix_ms > 0), + FOREIGN KEY (establishment_receipt_id) + REFERENCES tally_incremental_establishment_receipts(id) ON DELETE RESTRICT +); + +CREATE TRIGGER IF NOT EXISTS tally_incremental_capabilities_no_update +BEFORE UPDATE ON tally_incremental_capability_observations +BEGIN + SELECT RAISE(ABORT, 'incremental capability observations are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_incremental_capabilities_no_delete +BEFORE DELETE ON tally_incremental_capability_observations +BEGIN + SELECT RAISE(ABORT, 'incremental capability observations are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_incremental_establishment_authority_required +BEFORE INSERT ON tally_incremental_establishment_receipts +WHEN NOT EXISTS ( + SELECT 1 + FROM tally_incremental_capability_observations AS capability + JOIN tally_proof_ledger AS proof ON proof.id = NEW.proof_id + JOIN tally_observation_batches AS batch ON batch.id = NEW.batch_id + JOIN tally_capability_snapshots AS snapshot + ON snapshot.id = proof.capability_snapshot_id + JOIN tally_companies AS company ON company.id = proof.company_id + JOIN tally_snapshot_run_states AS durable ON durable.run_id = proof.run_id + WHERE capability.id = NEW.capability_observation_id + AND capability.scope_sha256 = NEW.scope_sha256 + AND capability.scope_json = NEW.scope_json + AND capability.company_id = proof.company_id + AND capability.capability_snapshot_id = proof.capability_snapshot_id + AND capability.capability_state = 'supported' + AND capability.confidence = 'observed' + AND capability.identifier_semantics = 'monotonic_per_object' + AND capability.inclusive_lower_bound_observed = 1 + AND capability.explicit_source_high_watermark_observed = 1 + AND proof.entry_sha256 = NEW.proof_sha256 + AND proof.batch_id = NEW.batch_id + AND proof.outcome = 'completed' + AND proof.verification_state = 'verified' + AND proof.completed_at_unix_ms IS NOT NULL + AND proof.snapshot_sha256 IS NOT NULL + AND proof.gap_codes_json = '[]' + AND proof.warning_codes_json = '[]' + AND proof.rejected_records = 0 + AND proof.accepted_records = NEW.accepted_record_count + AND batch.run_id = proof.run_id + AND batch.capability_snapshot_id = proof.capability_snapshot_id + AND batch.company_id = proof.company_id + AND batch.pack_id = proof.pack_id + AND batch.pack_id = json_extract(NEW.scope_json, '$.pack') + AND batch.pack_schema_major = json_extract(NEW.scope_json, '$.pack_schema_version.major') + AND batch.pack_schema_minor = json_extract(NEW.scope_json, '$.pack_schema_version.minor') + AND batch.source_transport = json_extract(NEW.scope_json, '$.transport') + AND batch.source_release = json_extract(NEW.scope_json, '$.release') + AND batch.state = 'verified' + AND batch.snapshot_sha256 = proof.snapshot_sha256 + AND batch.accepted_records = NEW.accepted_record_count + AND batch.rejected_records = 0 + AND snapshot.profile_version = json_extract(NEW.scope_json, '$.capability_profile_version') + AND snapshot.product = json_extract(NEW.scope_json, '$.product') + AND snapshot.release = json_extract(NEW.scope_json, '$.release') + AND snapshot.mode = json_extract(NEW.scope_json, '$.mode') + AND snapshot.mode_confidence = 'observed' + AND company.endpoint_id = snapshot.endpoint_id + AND company.company_guid = json_extract(NEW.scope_json, '$.company_guid') COLLATE NOCASE + AND company.identity_confidence = 'observed' + AND durable.row_sha256 IS NOT NULL + AND durable.plan_sha256 = NEW.snapshot_plan_sha256 + AND json_extract(durable.state_json, '$.plan_sha256') = NEW.snapshot_plan_sha256 + AND json_extract(durable.state_json, '$.batch_id') = NEW.batch_id + AND json_extract(durable.state_json, '$.progress.phase') = 'completed' + AND json_extract(durable.state_json, '$.commit_receipt.proof_id') = NEW.proof_id + AND json_extract(durable.state_json, '$.commit_receipt.proof_sha256') = NEW.proof_sha256 + AND EXISTS ( + SELECT 1 FROM json_each(durable.state_json, '$.plan.windows') AS window + WHERE json_extract(window.value, '$.query_profile') = + json_extract(NEW.scope_json, '$.query_profile') + AND json_extract(window.value, '$.filters_sha256') = + json_extract(NEW.scope_json, '$.filters_sha256') + ) +) +BEGIN + SELECT RAISE(ABORT, 'incremental establishment authority is incomplete'); +END; + +-- This unconditional gate remains effective regardless of same-timing trigger order: an +-- earlier relational rejection is already fail-closed, while a relationally valid insert +-- reaches this rejection. Remove it only in a reviewed migration that atomically constructs +-- the receipt from verifier-owned evidence and bracketing source-watermark observations. +CREATE TRIGGER IF NOT EXISTS tally_incremental_establishment_disabled +BEFORE INSERT ON tally_incremental_establishment_receipts +BEGIN + SELECT RAISE(ABORT, 'incremental establishment verifier is not enabled'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_incremental_establishment_no_update +BEFORE UPDATE ON tally_incremental_establishment_receipts +BEGIN + SELECT RAISE(ABORT, 'incremental establishment receipts are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_incremental_establishment_no_delete +BEFORE DELETE ON tally_incremental_establishment_receipts +BEGIN + SELECT RAISE(ABORT, 'incremental establishment receipts are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_incremental_checkpoint_head_authority_required +BEFORE INSERT ON tally_incremental_checkpoint_heads +WHEN NOT EXISTS ( + SELECT 1 FROM tally_incremental_establishment_receipts AS receipt + WHERE receipt.id = NEW.establishment_receipt_id + AND receipt.scope_sha256 = NEW.scope_sha256 + AND receipt.scope_json = NEW.scope_json + AND receipt.source_high_watermark_decimal = NEW.high_watermark_decimal + AND receipt.created_at_unix_ms = NEW.established_at_unix_ms +) +BEGIN + SELECT RAISE(ABORT, 'incremental checkpoint head lacks exact establishment receipt'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_incremental_checkpoint_heads_no_update +BEFORE UPDATE ON tally_incremental_checkpoint_heads +BEGIN + SELECT RAISE(ABORT, 'incremental checkpoint advancement is not enabled'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_incremental_checkpoint_heads_no_delete +BEFORE DELETE ON tally_incremental_checkpoint_heads +BEGIN + SELECT RAISE(ABORT, 'incremental checkpoint heads are immutable evidence'); +END; + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (6, 'scope-bound incremental establishment foundation', 0); diff --git a/src-tauri/src/db/migrations/0007_tally_selected_read_evidence.sql b/src-tauri/src/db/migrations/0007_tally_selected_read_evidence.sql new file mode 100644 index 0000000..e0745db --- /dev/null +++ b/src-tauri/src/db/migrations/0007_tally_selected_read_evidence.sql @@ -0,0 +1,170 @@ +DROP INDEX IF EXISTS uq_tally_companies_guid; + +CREATE UNIQUE INDEX uq_tally_companies_guid + ON tally_companies(endpoint_id, company_guid COLLATE NOCASE) + WHERE company_guid IS NOT NULL; + +CREATE TABLE IF NOT EXISTS tally_selected_read_scopes ( + id TEXT PRIMARY KEY, + capability_snapshot_id TEXT NOT NULL UNIQUE, + company_id TEXT NOT NULL, + scope_contract_version INTEGER NOT NULL CHECK (scope_contract_version = 1), + scope_commitment_sha256 TEXT NOT NULL UNIQUE CHECK ( + length(scope_commitment_sha256) = 64 AND + scope_commitment_sha256 NOT GLOB '*[^0-9a-f]*' + ), + parent_review_sha256 TEXT NOT NULL CHECK ( + length(parent_review_sha256) = 64 AND + parent_review_sha256 NOT GLOB '*[^0-9a-f]*' + ), + ledger_profile_id TEXT NOT NULL CHECK (ledger_profile_id = 'bridge.tally.ledgers/1'), + voucher_profile_id TEXT NOT NULL CHECK (voucher_profile_id = 'bridge.tally.vouchers/3'), + voucher_from_yyyymmdd TEXT NOT NULL CHECK ( + length(voucher_from_yyyymmdd) = 8 AND + voucher_from_yyyymmdd NOT GLOB '*[^0-9]*' + ), + voucher_to_yyyymmdd TEXT NOT NULL CHECK ( + length(voucher_to_yyyymmdd) = 8 AND + voucher_to_yyyymmdd NOT GLOB '*[^0-9]*' AND + voucher_from_yyyymmdd <= voucher_to_yyyymmdd + ), + observed_at_unix_ms INTEGER NOT NULL CHECK (observed_at_unix_ms > 0), + completeness_state TEXT NOT NULL CHECK (completeness_state = 'not_claimed'), + no_writes_attempted INTEGER NOT NULL CHECK (no_writes_attempted = 1), + raw_records_retained INTEGER NOT NULL CHECK (raw_records_retained = 0), + UNIQUE (id, capability_snapshot_id), + FOREIGN KEY (capability_snapshot_id) REFERENCES tally_capability_snapshots(id) ON DELETE RESTRICT, + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT +); + +CREATE TABLE IF NOT EXISTS tally_selected_read_observations ( + scope_id TEXT NOT NULL, + capability_snapshot_id TEXT NOT NULL, + capability_kind TEXT NOT NULL CHECK (capability_kind = 'feature'), + capability_key TEXT NOT NULL CHECK ( + capability_key IN ('selected_ledger_read', 'selected_voucher_window_read') + ), + capability_state TEXT NOT NULL CHECK (capability_state IN ('supported', 'unknown')), + confidence TEXT NOT NULL CHECK (confidence IN ('observed', 'unknown')), + safe_reason_code TEXT NOT NULL, + result_bucket TEXT NOT NULL CHECK ( + result_bucket IN ('empty_observed', 'non_empty_observed', 'rejected', 'skipped') + ), + request_sha256 TEXT CHECK ( + request_sha256 IS NULL OR ( + length(request_sha256) = 64 AND request_sha256 NOT GLOB '*[^0-9a-f]*' + ) + ), + decoded_response_sha256 TEXT CHECK ( + decoded_response_sha256 IS NULL OR ( + length(decoded_response_sha256) = 64 AND decoded_response_sha256 NOT GLOB '*[^0-9a-f]*' + ) + ), + response_encoding TEXT CHECK ( + response_encoding IS NULL OR response_encoding IN ( + 'utf8', 'utf8_bom', 'utf16le_bom', 'utf16be_bom' + ) + ), + company_context_verified INTEGER NOT NULL CHECK (company_context_verified IN (0, 1)), + schema_verified INTEGER NOT NULL CHECK (schema_verified IN (0, 1)), + record_count_verified INTEGER NOT NULL CHECK (record_count_verified IN (0, 1)), + identity_evidence_state TEXT NOT NULL CHECK ( + identity_evidence_state IN ('verified', 'not_applicable_empty', 'unverified') + ), + date_window_verified INTEGER NOT NULL CHECK (date_window_verified IN (0, 1)), + PRIMARY KEY (scope_id, capability_key), + UNIQUE (capability_snapshot_id, capability_key), + CHECK ( + (capability_state = 'supported' AND confidence = 'observed' AND + result_bucket IN ('empty_observed', 'non_empty_observed') AND + request_sha256 IS NOT NULL AND decoded_response_sha256 IS NOT NULL AND + response_encoding IS NOT NULL AND + company_context_verified = 1 AND schema_verified = 1 AND + record_count_verified = 1 AND + ((result_bucket = 'empty_observed' AND identity_evidence_state = 'not_applicable_empty') OR + (result_bucket = 'non_empty_observed' AND identity_evidence_state = 'verified')) AND + ((capability_key = 'selected_ledger_read' AND date_window_verified = 0) OR + (capability_key = 'selected_voucher_window_read' AND date_window_verified = 1))) + OR + (capability_state = 'unknown' AND + ((result_bucket = 'rejected' AND confidence = 'observed') OR + (result_bucket = 'skipped' AND confidence = 'unknown')) AND + request_sha256 IS NULL AND decoded_response_sha256 IS NULL AND + response_encoding IS NULL AND company_context_verified = 0 AND + schema_verified = 0 AND record_count_verified = 0 AND + identity_evidence_state = 'unverified' AND date_window_verified = 0) + ), + FOREIGN KEY (scope_id, capability_snapshot_id) + REFERENCES tally_selected_read_scopes(id, capability_snapshot_id) + ON DELETE RESTRICT, + FOREIGN KEY (capability_snapshot_id, capability_kind, capability_key) + REFERENCES tally_capability_items(snapshot_id, capability_kind, capability_key) + ON DELETE RESTRICT +); + +CREATE TRIGGER IF NOT EXISTS tally_selected_read_scopes_no_update +BEFORE UPDATE ON tally_selected_read_scopes +BEGIN + SELECT RAISE(ABORT, 'selected read scopes are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_selected_read_scope_authority_required +BEFORE INSERT ON tally_selected_read_scopes +WHEN NOT EXISTS ( + SELECT 1 FROM tally_capability_snapshots AS snapshot + JOIN tally_companies AS company ON company.id = NEW.company_id + WHERE snapshot.id = NEW.capability_snapshot_id + AND company.endpoint_id = snapshot.endpoint_id + AND NEW.observed_at_unix_ms >= snapshot.observed_at_unix_ms +) +BEGIN + SELECT RAISE(ABORT, 'selected read scope authority is incomplete'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_selected_read_scopes_no_delete +BEFORE DELETE ON tally_selected_read_scopes +BEGIN + SELECT RAISE(ABORT, 'selected read scopes are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_selected_read_observations_no_update +BEFORE UPDATE ON tally_selected_read_observations +BEGIN + SELECT RAISE(ABORT, 'selected read observations are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_selected_read_observation_authority_required +BEFORE INSERT ON tally_selected_read_observations +WHEN NOT EXISTS ( + SELECT 1 FROM tally_capability_items AS item + WHERE item.snapshot_id = NEW.capability_snapshot_id + AND item.capability_kind = 'feature' + AND item.capability_key = NEW.capability_key + AND item.capability_state = NEW.capability_state + AND item.confidence = NEW.confidence + AND item.safe_reason_code = NEW.safe_reason_code +) +BEGIN + SELECT RAISE(ABORT, 'selected read observation authority is incomplete'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_selected_read_observations_no_delete +BEFORE DELETE ON tally_selected_read_observations +BEGIN + SELECT RAISE(ABORT, 'selected read observations are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_capability_items_no_update +BEFORE UPDATE ON tally_capability_items +BEGIN + SELECT RAISE(ABORT, 'capability items are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_capability_items_no_delete +BEFORE DELETE ON tally_capability_items +BEGIN + SELECT RAISE(ABORT, 'capability items are immutable'); +END; + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (7, 'scoped selected-read evidence and case-insensitive company GUID authority', 0); diff --git a/src-tauri/src/db/migrations/0008_tally_reviewed_setup_consumption.sql b/src-tauri/src/db/migrations/0008_tally_reviewed_setup_consumption.sql new file mode 100644 index 0000000..d22dc3b --- /dev/null +++ b/src-tauri/src/db/migrations/0008_tally_reviewed_setup_consumption.sql @@ -0,0 +1,42 @@ +CREATE TABLE IF NOT EXISTS tally_reviewed_setup_consumptions ( + review_commitment_sha256 TEXT PRIMARY KEY CHECK ( + length(review_commitment_sha256) = 64 AND + review_commitment_sha256 NOT GLOB '*[^0-9a-f]*' + ), + setup_payload_sha256 TEXT NOT NULL CHECK ( + length(setup_payload_sha256) = 64 AND + setup_payload_sha256 NOT GLOB '*[^0-9a-f]*' + ), + capability_snapshot_id TEXT NOT NULL UNIQUE, + company_id TEXT NOT NULL, + consumed_at_unix_ms INTEGER NOT NULL CHECK (consumed_at_unix_ms > 0), + FOREIGN KEY (capability_snapshot_id) REFERENCES tally_capability_snapshots(id) ON DELETE RESTRICT, + FOREIGN KEY (company_id) REFERENCES tally_companies(id) ON DELETE RESTRICT +); + +CREATE TRIGGER IF NOT EXISTS tally_reviewed_setup_consumptions_no_update +BEFORE UPDATE ON tally_reviewed_setup_consumptions +BEGIN + SELECT RAISE(ABORT, 'reviewed setup consumptions are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_reviewed_setup_consumptions_no_delete +BEFORE DELETE ON tally_reviewed_setup_consumptions +BEGIN + SELECT RAISE(ABORT, 'reviewed setup consumptions are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS tally_reviewed_setup_consumption_authority_required +BEFORE INSERT ON tally_reviewed_setup_consumptions +WHEN NOT EXISTS ( + SELECT 1 FROM tally_capability_snapshots AS snapshot + JOIN tally_companies AS company ON company.id = NEW.company_id + WHERE snapshot.id = NEW.capability_snapshot_id + AND snapshot.endpoint_id = company.endpoint_id +) +BEGIN + SELECT RAISE(ABORT, 'reviewed setup consumption authority is incomplete'); +END; + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (8, 'durable idempotent reviewed-setup consumption authority', 0); diff --git a/src-tauri/src/db/migrations/0009_tally_snapshot_window_staging.sql b/src-tauri/src/db/migrations/0009_tally_snapshot_window_staging.sql new file mode 100644 index 0000000..31de6b4 --- /dev/null +++ b/src-tauri/src/db/migrations/0009_tally_snapshot_window_staging.sql @@ -0,0 +1,147 @@ +CREATE TABLE IF NOT EXISTS tally_snapshot_window_attempts ( + id TEXT PRIMARY KEY, + batch_id TEXT NOT NULL, + window_id TEXT NOT NULL, + attempt_ordinal INTEGER NOT NULL CHECK (attempt_ordinal > 0), + state TEXT NOT NULL CHECK (state IN ('open', 'abandoned', 'complete')), + started_at_unix_ms INTEGER NOT NULL CHECK (started_at_unix_ms > 0), + completed_at_unix_ms INTEGER, + receipt_json TEXT CHECK (receipt_json IS NULL OR json_valid(receipt_json)), + receipt_sha256 TEXT CHECK ( + receipt_sha256 IS NULL OR ( + length(receipt_sha256) = 64 AND receipt_sha256 NOT GLOB '*[^0-9a-f]*' + ) + ), + UNIQUE (id, batch_id, window_id), + UNIQUE (batch_id, window_id, attempt_ordinal), + CHECK ( + (state = 'open' AND completed_at_unix_ms IS NULL AND receipt_json IS NULL AND + receipt_sha256 IS NULL) OR + (state = 'abandoned' AND completed_at_unix_ms IS NOT NULL AND receipt_json IS NULL AND + receipt_sha256 IS NULL) OR + (state = 'complete' AND completed_at_unix_ms IS NOT NULL AND receipt_json IS NOT NULL AND + receipt_sha256 IS NOT NULL) + ), + CHECK (completed_at_unix_ms IS NULL OR completed_at_unix_ms >= started_at_unix_ms), + FOREIGN KEY (batch_id) REFERENCES tally_observation_batches(id) ON DELETE RESTRICT +); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_tally_snapshot_window_one_open_attempt + ON tally_snapshot_window_attempts(batch_id, window_id) WHERE state = 'open'; + +CREATE INDEX IF NOT EXISTS idx_tally_snapshot_window_attempt_latest_complete + ON tally_snapshot_window_attempts(batch_id, window_id, attempt_ordinal DESC) + WHERE state = 'complete'; + +CREATE TABLE IF NOT EXISTS tally_snapshot_window_memberships ( + batch_id TEXT NOT NULL, + window_id TEXT NOT NULL, + record_key TEXT NOT NULL, + canonical_sha256 TEXT NOT NULL CHECK ( + length(canonical_sha256) = 64 AND canonical_sha256 NOT GLOB '*[^0-9a-f]*' + ), + canonical_payload_json TEXT NOT NULL CHECK (json_valid(canonical_payload_json)), + exact_decimals_json TEXT NOT NULL CHECK (json_valid(exact_decimals_json)), + provenance_state TEXT NOT NULL CHECK ( + provenance_state IN ('observed', 'unavailable') + ), + source_record_id TEXT, + observation_id TEXT, + safe_reason_code TEXT, + first_seen_attempt_id TEXT NOT NULL, + last_seen_attempt_id TEXT NOT NULL, + PRIMARY KEY (batch_id, window_id, record_key), + CHECK ( + (provenance_state = 'observed' AND source_record_id IS NOT NULL AND + observation_id IS NOT NULL AND safe_reason_code IS NULL) OR + (provenance_state = 'unavailable' AND source_record_id IS NULL AND + observation_id IS NULL AND safe_reason_code IS NOT NULL) + ), + FOREIGN KEY (batch_id) REFERENCES tally_observation_batches(id) ON DELETE RESTRICT, + FOREIGN KEY (source_record_id) REFERENCES tally_source_records(id) ON DELETE RESTRICT, + FOREIGN KEY (observation_id) REFERENCES tally_record_observations(id) ON DELETE RESTRICT, + FOREIGN KEY (first_seen_attempt_id, batch_id, window_id) + REFERENCES tally_snapshot_window_attempts(id, batch_id, window_id) ON DELETE RESTRICT, + FOREIGN KEY (last_seen_attempt_id, batch_id, window_id) + REFERENCES tally_snapshot_window_attempts(id, batch_id, window_id) ON DELETE RESTRICT +); + +CREATE INDEX IF NOT EXISTS idx_tally_snapshot_window_membership_last_seen + ON tally_snapshot_window_memberships(batch_id, window_id, last_seen_attempt_id); + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_window_attempt_terminal_immutable +BEFORE UPDATE ON tally_snapshot_window_attempts +WHEN OLD.state <> 'open' +BEGIN + SELECT RAISE(ABORT, 'terminal snapshot window attempt is immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_window_attempt_identity_immutable +BEFORE UPDATE OF id, batch_id, window_id, attempt_ordinal, started_at_unix_ms +ON tally_snapshot_window_attempts +BEGIN + SELECT RAISE(ABORT, 'snapshot window attempt identity is immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_window_attempt_no_delete +BEFORE DELETE ON tally_snapshot_window_attempts +BEGIN + SELECT RAISE(ABORT, 'snapshot window attempts are append-only'); +END; + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_window_membership_insert_open_attempt +BEFORE INSERT ON tally_snapshot_window_memberships +WHEN NOT EXISTS ( + SELECT 1 FROM tally_snapshot_window_attempts AS attempt + WHERE attempt.id = NEW.first_seen_attempt_id + AND attempt.id = NEW.last_seen_attempt_id + AND attempt.batch_id = NEW.batch_id + AND attempt.window_id = NEW.window_id + AND attempt.state = 'open' +) +BEGIN + SELECT RAISE(ABORT, 'membership requires its owner-bound open attempt'); +END; + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_window_membership_content_immutable +BEFORE UPDATE ON tally_snapshot_window_memberships +WHEN OLD.batch_id IS NOT NEW.batch_id + OR OLD.window_id IS NOT NEW.window_id + OR OLD.record_key IS NOT NEW.record_key + OR OLD.canonical_sha256 IS NOT NEW.canonical_sha256 + OR OLD.canonical_payload_json IS NOT NEW.canonical_payload_json + OR OLD.exact_decimals_json IS NOT NEW.exact_decimals_json + OR OLD.provenance_state IS NOT NEW.provenance_state + OR OLD.source_record_id IS NOT NEW.source_record_id + OR OLD.observation_id IS NOT NEW.observation_id + OR OLD.safe_reason_code IS NOT NEW.safe_reason_code + OR OLD.first_seen_attempt_id IS NOT NEW.first_seen_attempt_id +BEGIN + SELECT RAISE(ABORT, 'snapshot window membership identity and content are immutable'); +END; + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_window_membership_last_seen_advance +BEFORE UPDATE OF last_seen_attempt_id ON tally_snapshot_window_memberships +WHEN NOT EXISTS ( + SELECT 1 + FROM tally_snapshot_window_attempts AS incoming + JOIN tally_snapshot_window_attempts AS previous + ON previous.id = OLD.last_seen_attempt_id + WHERE incoming.id = NEW.last_seen_attempt_id + AND incoming.batch_id = OLD.batch_id + AND incoming.window_id = OLD.window_id + AND incoming.state = 'open' + AND incoming.attempt_ordinal >= previous.attempt_ordinal +) +BEGIN + SELECT RAISE(ABORT, 'last seen may only advance to an owner-bound open attempt'); +END; + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_window_membership_no_delete +BEFORE DELETE ON tally_snapshot_window_memberships +BEGIN + SELECT RAISE(ABORT, 'snapshot window memberships are append-only'); +END; + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (9, 'normalized immutable snapshot window staging', 0); diff --git a/src-tauri/src/db/migrations/0010_tally_provenance_unavailable_counts.sql b/src-tauri/src/db/migrations/0010_tally_provenance_unavailable_counts.sql new file mode 100644 index 0000000..b196c62 --- /dev/null +++ b/src-tauri/src/db/migrations/0010_tally_provenance_unavailable_counts.sql @@ -0,0 +1,10 @@ +ALTER TABLE tally_observation_batches + ADD COLUMN provenance_unavailable_records INTEGER NOT NULL DEFAULT 0 + CHECK (provenance_unavailable_records >= 0); + +ALTER TABLE tally_proof_ledger + ADD COLUMN provenance_unavailable_records INTEGER NOT NULL DEFAULT 0 + CHECK (provenance_unavailable_records >= 0); + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (10, 'receipt-bound provenance-unavailable record counts', 0); diff --git a/src-tauri/src/db/migrations/0011_tally_proof_record_counts_digest.sql b/src-tauri/src/db/migrations/0011_tally_proof_record_counts_digest.sql new file mode 100644 index 0000000..d61ad0e --- /dev/null +++ b/src-tauri/src/db/migrations/0011_tally_proof_record_counts_digest.sql @@ -0,0 +1,10 @@ +ALTER TABLE tally_proof_ledger + ADD COLUMN record_counts_sha256 TEXT + CHECK ( + record_counts_sha256 IS NULL OR + (LENGTH(record_counts_sha256) = 64 AND + record_counts_sha256 NOT GLOB '*[^0-9a-f]*') + ); + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (11, 'proof-bound canonical record count digest', 0); diff --git a/src-tauri/src/db/migrations/0012_tally_window_terminal_evidence.sql b/src-tauri/src/db/migrations/0012_tally_window_terminal_evidence.sql new file mode 100644 index 0000000..5dadd1f --- /dev/null +++ b/src-tauri/src/db/migrations/0012_tally_window_terminal_evidence.sql @@ -0,0 +1,23 @@ +ALTER TABLE tally_snapshot_window_attempts + ADD COLUMN terminal_safe_reason_code TEXT + CHECK ( + terminal_safe_reason_code IS NULL OR + terminal_safe_reason_code = 'local_clock_moved_backwards' + ); + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_window_attempt_terminal_reason_insert +BEFORE INSERT ON tally_snapshot_window_attempts +WHEN NEW.terminal_safe_reason_code IS NOT NULL +BEGIN + SELECT RAISE(ABORT, 'new snapshot window attempt cannot carry terminal evidence'); +END; + +CREATE TRIGGER IF NOT EXISTS trg_tally_snapshot_window_attempt_terminal_reason_shape +BEFORE UPDATE OF state, terminal_safe_reason_code ON tally_snapshot_window_attempts +WHEN NEW.terminal_safe_reason_code IS NOT NULL AND NEW.state = 'open' +BEGIN + SELECT RAISE(ABORT, 'snapshot window terminal evidence requires terminal state'); +END; + +INSERT OR IGNORE INTO tally_schema_migrations(version, description, applied_at_unix_ms) +VALUES (12, 'durable snapshot window terminal evidence', 0); diff --git a/src-tauri/src/db/mod.rs b/src-tauri/src/db/mod.rs index f5147f9..e942b68 100644 --- a/src-tauri/src/db/mod.rs +++ b/src-tauri/src/db/mod.rs @@ -1,13 +1,10 @@ pub mod audit; +pub mod encrypted; pub mod migrations; pub mod outbox; pub mod schema; +pub mod tally_incremental; +pub mod tally_mirror; +pub mod tally_write_store; -use sqlx::{sqlite::SqlitePoolOptions, SqlitePool}; - -pub async fn connect(database_url: &str) -> anyhow::Result { - Ok(SqlitePoolOptions::new() - .max_connections(5) - .connect(database_url) - .await?) -} +pub use encrypted::{connect_encrypted, resolve_mirror_key, MirrorKeyStore, OsMirrorKeyStore}; diff --git a/src-tauri/src/db/tally_incremental.rs b/src-tauri/src/db/tally_incremental.rs new file mode 100644 index 0000000..6545a71 --- /dev/null +++ b/src-tauri/src/db/tally_incremental.rs @@ -0,0 +1,900 @@ +use bridge_tally_core::{CapabilityPackId, CapabilityState, EvidenceConfidence, TransportId}; +use bridge_tally_incremental::{ + plan_sync, ChangeIdentifierSemantics, IncrementalCapabilityObservation, IncrementalCheckpoint, + IncrementalPolicy, IncrementalScope, SyncPlan, +}; +use sha2::{Digest, Sha256}; +use sqlx::Row; +use uuid::Uuid; + +use crate::db::tally_mirror::{MirrorError, TallyMirrorRepository}; +use crate::sync::snapshot::{SnapshotPhase, SqliteSnapshotStateStore}; +use crate::tally::company_source_identity; + +/// Sealed authority receipt. Only the future protocol verifier in this module may construct one; +/// callers cannot turn a generic pack passport into observed incremental evidence. +#[derive(Debug, Clone)] +pub struct VerifiedIncrementalCanaryReceipt { + company_id: String, + capability_snapshot_id: String, + canary_contract_version: u16, + response_sha256: String, + observation: IncrementalCapabilityObservation, + observed_at_unix_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StoredIncrementalCapability { + pub id: String, + pub observation: IncrementalCapabilityObservation, + pub observed_at_unix_ms: i64, +} + +#[derive(Debug, Clone, serde::Serialize)] +pub struct IncrementalReadiness { + pub scope_sha256: String, + pub plan: SyncPlan, +} + +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct IncrementalFoundationEvidence { + pub execution_enabled: bool, + pub affirmative_exact_capability_receipts: i64, + pub establishment_receipts: i64, + pub active_checkpoint_heads: i64, + pub state: &'static str, + pub fallback_warning_code: &'static str, +} + +#[derive(Debug, serde::Serialize)] +struct EstablishmentReceiptHashInput { + verifier_contract_version: i64, + receipt_id: String, + scope_sha256: String, + capability_observation_id: String, + proof_id: String, + proof_sha256: String, + batch_id: String, + snapshot_plan_sha256: String, + source_response_sha256: String, + coverage_manifest_sha256: String, + source_high_watermark_decimal: String, + max_observed_alter_id_decimal: Option, + source_record_count: i64, + accepted_record_count: i64, + deduplicated_record_count: i64, + numeric_alter_id_count: i64, + rejected_record_count: i64, + duplicate_identity_count: i64, + missing_identity_count: i64, + out_of_scope_record_count: i64, + created_at_unix_ms: i64, +} + +impl TallyMirrorRepository { + /// Read-only, count-only operator evidence. It cannot authorize or start an incremental read. + pub async fn incremental_foundation_evidence( + &self, + company_id: &str, + ) -> Result { + if company_id.trim().is_empty() + || company_id.len() > 128 + || company_id.chars().any(char::is_control) + { + return Err(MirrorError::InvalidInput("company_id")); + } + let row = sqlx::query( + "SELECT \ + (SELECT COUNT(*) FROM tally_incremental_capability_observations AS capability \ + WHERE capability.company_id = ?1 AND capability.capability_state = 'supported' \ + AND capability.confidence = 'observed' \ + AND capability.identifier_semantics = 'monotonic_per_object' \ + AND capability.inclusive_lower_bound_observed = 1 \ + AND capability.explicit_source_high_watermark_observed = 1) \ + AS capability_count, \ + (SELECT COUNT(*) FROM tally_incremental_establishment_receipts AS receipt \ + JOIN tally_incremental_capability_observations AS capability \ + ON capability.id = receipt.capability_observation_id \ + WHERE capability.company_id = ?1) AS receipt_count, \ + (SELECT COUNT(*) FROM tally_incremental_checkpoint_heads AS head \ + JOIN tally_incremental_establishment_receipts AS receipt \ + ON receipt.id = head.establishment_receipt_id \ + JOIN tally_incremental_capability_observations AS capability \ + ON capability.id = receipt.capability_observation_id \ + WHERE capability.company_id = ?1 AND head.generation = 1 \ + AND head.state = 'active') AS head_count", + ) + .bind(company_id) + .fetch_one(&self.pool) + .await?; + let affirmative_exact_capability_receipts: i64 = row.try_get("capability_count")?; + let establishment_receipts: i64 = row.try_get("receipt_count")?; + let active_checkpoint_heads: i64 = row.try_get("head_count")?; + let state = if affirmative_exact_capability_receipts == 0 { + "exact_capability_not_observed" + } else if establishment_receipts == 0 || active_checkpoint_heads == 0 { + "verified_establishment_missing" + } else { + "execution_not_enabled" + }; + Ok(IncrementalFoundationEvidence { + execution_enabled: false, + affirmative_exact_capability_receipts, + establishment_receipts, + active_checkpoint_heads, + state, + fallback_warning_code: "incremental_execution_disabled_full_snapshot_required", + }) + } + + /// Persist only exact-profile evidence. This is intentionally not exposed as a Tauri command: + /// the future object-specific canary owns authority to call it. + pub async fn save_incremental_capability_observation( + &self, + receipt: VerifiedIncrementalCanaryReceipt, + ) -> Result { + validate_scope(&receipt.observation.scope)?; + if receipt.company_id.trim().is_empty() + || receipt.capability_snapshot_id.trim().is_empty() + || receipt.canary_contract_version == 0 + || !is_lower_sha256(&receipt.response_sha256) + || receipt.observed_at_unix_ms <= 0 + { + return Err(MirrorError::InvalidInput("incremental_authority")); + } + let pin = self.snapshot_source_pin(&receipt.company_id).await?; + let expected_lineage = format!("tally_xml_http:{}", pin.canonical_origin); + let expected_identity = company_source_identity(&expected_lineage, &pin.company_guid); + if receipt.observation.scope.source_lineage != expected_identity.bridge_source_lineage + || !receipt + .observation + .scope + .company_guid + .eq_ignore_ascii_case(&expected_identity.company_guid) + || receipt.observation.scope.company_fingerprint + != expected_identity.observed_fingerprint + { + return Err(MirrorError::InvalidInput("incremental_company_scope")); + } + if !self + .capability_snapshot_matches_plan( + &receipt.capability_snapshot_id, + &receipt.company_id, + receipt.observation.scope.capability_profile_version, + &receipt.observation.scope.product, + Some(&receipt.observation.scope.release), + Some(&receipt.observation.scope.mode), + ) + .await? + { + return Err(MirrorError::InvalidInput("incremental_capability_profile")); + } + + let scope_json = serde_json::to_string(&receipt.observation.scope)?; + let scope_sha256 = incremental_scope_sha256(&receipt.observation.scope)?; + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_incremental_capability_observations(\ + id, scope_sha256, scope_json, capability_snapshot_id, company_id, \ + verifier_contract_version, response_sha256, capability_state, confidence, \ + identifier_semantics, inclusive_lower_bound_observed, \ + explicit_source_high_watermark_observed, observed_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + ) + .bind(&id) + .bind(scope_sha256) + .bind(scope_json) + .bind(receipt.capability_snapshot_id) + .bind(receipt.company_id) + .bind(i64::from(receipt.canary_contract_version)) + .bind(receipt.response_sha256) + .bind(capability_state_code(receipt.observation.state)) + .bind(confidence_code(receipt.observation.confidence)) + .bind(identifier_semantics_code( + receipt.observation.identifier_semantics, + )) + .bind(i64::from( + receipt.observation.inclusive_lower_bound_observed, + )) + .bind(i64::from( + receipt.observation.explicit_source_high_watermark_observed, + )) + .bind(receipt.observed_at_unix_ms) + .execute(&self.pool) + .await?; + Ok(StoredIncrementalCapability { + id, + observation: receipt.observation, + observed_at_unix_ms: receipt.observed_at_unix_ms, + }) + } + + pub async fn load_incremental_capability( + &self, + scope: &IncrementalScope, + ) -> Result, MirrorError> { + validate_scope(scope)?; + let row = sqlx::query( + "SELECT id, scope_json, capability_state, confidence, identifier_semantics, \ + inclusive_lower_bound_observed, explicit_source_high_watermark_observed, \ + observed_at_unix_ms \ + FROM tally_incremental_capability_observations WHERE scope_sha256 = ?1 \ + ORDER BY observed_at_unix_ms DESC, id DESC LIMIT 1", + ) + .bind(incremental_scope_sha256(scope)?) + .fetch_optional(&self.pool) + .await?; + let stored = row.map(decode_capability_row).transpose()?; + if stored + .as_ref() + .is_some_and(|stored| stored.observation.scope != *scope) + { + return Err(MirrorError::VerificationInvariant); + } + Ok(stored) + } + + pub async fn load_incremental_checkpoint( + &self, + scope: &IncrementalScope, + ) -> Result, MirrorError> { + validate_scope(scope)?; + let scope_sha256 = incremental_scope_sha256(scope)?; + let head_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_incremental_checkpoint_heads WHERE scope_sha256 = ?1", + ) + .bind(&scope_sha256) + .fetch_one(&self.pool) + .await?; + if head_count == 0 { + return Ok(None); + } + if head_count != 1 { + return Err(MirrorError::VerificationInvariant); + } + let row = sqlx::query( + "SELECT head.scope_sha256, head.scope_json, head.high_watermark_decimal, \ + head.generation, head.state, head.established_at_unix_ms, \ + receipt.id AS receipt_id, receipt.scope_sha256 AS receipt_scope_sha256, \ + receipt.scope_json AS receipt_scope_json, \ + receipt.capability_observation_id, receipt.proof_id, receipt.proof_sha256, \ + receipt.batch_id, receipt.snapshot_plan_sha256, \ + receipt.source_response_sha256, receipt.coverage_manifest_sha256, \ + receipt.source_high_watermark_decimal, receipt.max_observed_alter_id_decimal, \ + receipt.source_record_count, receipt.accepted_record_count, \ + receipt.deduplicated_record_count, receipt.numeric_alter_id_count, \ + receipt.rejected_record_count, receipt.duplicate_identity_count, \ + receipt.missing_identity_count, receipt.out_of_scope_record_count, \ + receipt.verifier_contract_version AS receipt_verifier_contract_version, \ + receipt.receipt_sha256, receipt.created_at_unix_ms, proof.run_id, \ + CASE WHEN \ + head.establishment_receipt_id = receipt.id AND \ + head.scope_sha256 = receipt.scope_sha256 AND \ + head.scope_json = receipt.scope_json AND \ + head.high_watermark_decimal = receipt.source_high_watermark_decimal AND \ + head.generation = 1 AND head.state = 'active' AND \ + head.established_at_unix_ms = receipt.created_at_unix_ms AND \ + capability.id = receipt.capability_observation_id AND \ + capability.scope_sha256 = receipt.scope_sha256 AND \ + capability.scope_json = receipt.scope_json AND \ + capability.company_id = proof.company_id AND \ + capability.capability_snapshot_id = proof.capability_snapshot_id AND \ + capability.verifier_contract_version > 0 AND \ + length(capability.response_sha256) = 64 AND \ + capability.response_sha256 NOT GLOB '*[^0-9a-f]*' AND \ + capability.capability_state = 'supported' AND capability.confidence = 'observed' AND \ + capability.identifier_semantics = 'monotonic_per_object' AND \ + capability.inclusive_lower_bound_observed = 1 AND \ + capability.explicit_source_high_watermark_observed = 1 AND \ + proof.id = receipt.proof_id AND proof.entry_sha256 = receipt.proof_sha256 AND \ + proof.batch_id = receipt.batch_id AND proof.outcome = 'completed' AND \ + proof.verification_state = 'verified' AND proof.completed_at_unix_ms IS NOT NULL AND \ + proof.snapshot_sha256 IS NOT NULL AND proof.gap_codes_json = '[]' AND \ + proof.warning_codes_json = '[]' AND proof.rejected_records = 0 AND \ + proof.accepted_records = receipt.accepted_record_count AND \ + batch.id = receipt.batch_id AND batch.run_id = proof.run_id AND \ + batch.capability_snapshot_id = proof.capability_snapshot_id AND \ + batch.company_id = proof.company_id AND batch.pack_id = proof.pack_id AND \ + batch.pack_id = json_extract(receipt.scope_json, '$.pack') AND \ + batch.pack_schema_major = json_extract(receipt.scope_json, '$.pack_schema_version.major') AND \ + batch.pack_schema_minor = json_extract(receipt.scope_json, '$.pack_schema_version.minor') AND \ + batch.source_transport = json_extract(receipt.scope_json, '$.transport') AND \ + batch.source_release = json_extract(receipt.scope_json, '$.release') AND \ + batch.state = 'verified' AND batch.snapshot_sha256 = proof.snapshot_sha256 AND \ + batch.accepted_records = receipt.accepted_record_count AND batch.rejected_records = 0 AND \ + snapshot.id = proof.capability_snapshot_id AND \ + snapshot.profile_version = json_extract(receipt.scope_json, '$.capability_profile_version') AND \ + snapshot.product = json_extract(receipt.scope_json, '$.product') AND \ + snapshot.release = json_extract(receipt.scope_json, '$.release') AND \ + snapshot.mode = json_extract(receipt.scope_json, '$.mode') AND \ + snapshot.mode_confidence = 'observed' AND company.id = proof.company_id AND \ + company.endpoint_id = snapshot.endpoint_id AND \ + company.company_guid = json_extract(receipt.scope_json, '$.company_guid') COLLATE NOCASE AND \ + company.identity_confidence = 'observed' AND durable.run_id = proof.run_id AND \ + durable.row_sha256 IS NOT NULL AND durable.plan_sha256 = receipt.snapshot_plan_sha256 AND \ + json_extract(durable.state_json, '$.plan_sha256') = receipt.snapshot_plan_sha256 AND \ + json_extract(durable.state_json, '$.batch_id') = receipt.batch_id AND \ + json_extract(durable.state_json, '$.progress.phase') = 'completed' AND \ + json_extract(durable.state_json, '$.commit_receipt.proof_id') = receipt.proof_id AND \ + json_extract(durable.state_json, '$.commit_receipt.proof_sha256') = receipt.proof_sha256 AND \ + EXISTS (SELECT 1 FROM json_each(durable.state_json, '$.plan.windows') AS window \ + WHERE json_extract(window.value, '$.query_profile') = json_extract(receipt.scope_json, '$.query_profile') \ + AND json_extract(window.value, '$.filters_sha256') = json_extract(receipt.scope_json, '$.filters_sha256')) \ + THEN 1 ELSE 0 END AS relationally_valid \ + FROM tally_incremental_checkpoint_heads AS head \ + JOIN tally_incremental_establishment_receipts AS receipt \ + ON receipt.id = head.establishment_receipt_id \ + JOIN tally_incremental_capability_observations AS capability \ + ON capability.id = receipt.capability_observation_id \ + JOIN tally_proof_ledger AS proof ON proof.id = receipt.proof_id \ + JOIN tally_observation_batches AS batch ON batch.id = receipt.batch_id \ + JOIN tally_capability_snapshots AS snapshot ON snapshot.id = proof.capability_snapshot_id \ + JOIN tally_companies AS company ON company.id = proof.company_id \ + JOIN tally_snapshot_run_states AS durable ON durable.run_id = proof.run_id \ + WHERE head.scope_sha256 = ?1", + ) + .bind(&scope_sha256) + .fetch_optional(&self.pool) + .await? + .ok_or(MirrorError::VerificationInvariant)?; + if row.try_get::("relationally_valid")? != 1 { + return Err(MirrorError::VerificationInvariant); + } + let stored_scope: IncrementalScope = + serde_json::from_str(&row.try_get::("scope_json")?)?; + let receipt_scope: IncrementalScope = + serde_json::from_str(&row.try_get::("receipt_scope_json")?)?; + if stored_scope != *scope + || receipt_scope != *scope + || row.try_get::("scope_sha256")? != scope_sha256 + || row.try_get::("receipt_scope_sha256")? != scope_sha256 + { + return Err(MirrorError::VerificationInvariant); + } + let receipt = decode_establishment_receipt_hash_input(&row)?; + if sha256_establishment_receipt(&receipt)? != row.try_get::("receipt_sha256")? { + return Err(MirrorError::VerificationInvariant); + } + let batch_id: String = row.try_get("batch_id")?; + let run_id: String = row.try_get("run_id")?; + let proof_id: String = row.try_get("proof_id")?; + let proof_sha256: String = row.try_get("proof_sha256")?; + let durable = SqliteSnapshotStateStore::new(self.pool_clone()) + .load_by_run_id(&run_id) + .await + .map_err(|_| MirrorError::VerificationInvariant)? + .ok_or(MirrorError::VerificationInvariant)?; + let durable_receipt = durable + .commit_receipt + .as_ref() + .ok_or(MirrorError::VerificationInvariant)?; + if !durable.row_integrity_bound + || durable.progress.phase != SnapshotPhase::Completed + || durable.plan_sha256 != row.try_get::("snapshot_plan_sha256")? + || durable.batch_id.as_deref() != Some(batch_id.as_str()) + || durable_receipt.proof_id.as_deref() != Some(proof_id.as_str()) + || durable_receipt.proof_sha256.as_deref() != Some(proof_sha256.as_str()) + || !durable_receipt.checkpoint_advanced + { + return Err(MirrorError::VerificationInvariant); + } + let generic_proof = self + .historical_commit_receipt_for_batch(&batch_id, &run_id) + .await?; + if generic_proof.proof_id != proof_id || generic_proof.proof_sha256 != proof_sha256 { + return Err(MirrorError::VerificationInvariant); + } + let high_watermark_decimal: String = row.try_get("high_watermark_decimal")?; + let high_watermark = high_watermark_decimal + .parse::() + .map_err(|_| MirrorError::VerificationInvariant)?; + Ok(Some(IncrementalCheckpoint { + scope: stored_scope, + high_watermark, + established_by_verified_full_snapshot: true, + established_by_proof_sha256: proof_sha256.clone(), + last_transition_proof_sha256: proof_sha256, + last_identity_sweep_unix_ms: row.try_get("established_at_unix_ms")?, + invalidated_reason: None, + })) + } + + /// Current runtime entry point: it can prove eligibility, but it never starts a delta read. + /// Missing evidence produces the portable policy's explicit full-snapshot warning. + pub async fn incremental_readiness( + &self, + scope: &IncrementalScope, + policy: IncrementalPolicy, + now_unix_ms: i64, + ) -> Result { + let capability = self.load_incremental_capability(scope).await?; + let checkpoint = self.load_incremental_checkpoint(scope).await?; + Ok(IncrementalReadiness { + scope_sha256: incremental_scope_sha256(scope)?, + plan: plan_sync( + policy, + scope, + capability.as_ref().map(|stored| &stored.observation), + checkpoint.as_ref(), + now_unix_ms, + ), + }) + } +} + +pub fn incremental_scope_sha256(scope: &IncrementalScope) -> Result { + validate_scope(scope)?; + let bytes = serde_json::to_vec(scope)?; + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-incremental-scope-v1\0"); + digest.update(bytes); + Ok(hex_digest(digest.finalize())) +} + +fn decode_establishment_receipt_hash_input( + row: &sqlx::sqlite::SqliteRow, +) -> Result { + Ok(EstablishmentReceiptHashInput { + verifier_contract_version: row.try_get("receipt_verifier_contract_version")?, + receipt_id: row.try_get("receipt_id")?, + scope_sha256: row.try_get("receipt_scope_sha256")?, + capability_observation_id: row.try_get("capability_observation_id")?, + proof_id: row.try_get("proof_id")?, + proof_sha256: row.try_get("proof_sha256")?, + batch_id: row.try_get("batch_id")?, + snapshot_plan_sha256: row.try_get("snapshot_plan_sha256")?, + source_response_sha256: row.try_get("source_response_sha256")?, + coverage_manifest_sha256: row.try_get("coverage_manifest_sha256")?, + source_high_watermark_decimal: row.try_get("source_high_watermark_decimal")?, + max_observed_alter_id_decimal: row.try_get("max_observed_alter_id_decimal")?, + source_record_count: row.try_get("source_record_count")?, + accepted_record_count: row.try_get("accepted_record_count")?, + deduplicated_record_count: row.try_get("deduplicated_record_count")?, + numeric_alter_id_count: row.try_get("numeric_alter_id_count")?, + rejected_record_count: row.try_get("rejected_record_count")?, + duplicate_identity_count: row.try_get("duplicate_identity_count")?, + missing_identity_count: row.try_get("missing_identity_count")?, + out_of_scope_record_count: row.try_get("out_of_scope_record_count")?, + created_at_unix_ms: row.try_get("created_at_unix_ms")?, + }) +} + +fn sha256_establishment_receipt( + receipt: &EstablishmentReceiptHashInput, +) -> Result { + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-incremental-establishment-v1\0"); + digest.update(serde_json::to_vec(receipt)?); + Ok(hex_digest(digest.finalize())) +} + +fn hex_digest(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn validate_scope(scope: &IncrementalScope) -> Result<(), MirrorError> { + if !scope.is_exact() + || scope.pack != CapabilityPackId::CoreAccounting + || scope.transport != TransportId::XmlHttp + { + return Err(MirrorError::InvalidInput("incremental_scope")); + } + Ok(()) +} + +fn is_lower_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn decode_capability_row( + row: sqlx::sqlite::SqliteRow, +) -> Result { + let scope: IncrementalScope = serde_json::from_str(&row.try_get::("scope_json")?)?; + validate_scope(&scope)?; + Ok(StoredIncrementalCapability { + id: row.try_get("id")?, + observation: IncrementalCapabilityObservation { + scope, + state: parse_capability_state(&row.try_get::("capability_state")?)?, + confidence: parse_confidence(&row.try_get::("confidence")?)?, + identifier_semantics: parse_identifier_semantics( + &row.try_get::("identifier_semantics")?, + )?, + inclusive_lower_bound_observed: row + .try_get::("inclusive_lower_bound_observed")? + == 1, + explicit_source_high_watermark_observed: row + .try_get::("explicit_source_high_watermark_observed")? + == 1, + }, + observed_at_unix_ms: row.try_get("observed_at_unix_ms")?, + }) +} + +fn capability_state_code(value: CapabilityState) -> &'static str { + match value { + CapabilityState::Supported => "supported", + CapabilityState::Unsupported => "unsupported", + CapabilityState::Unknown => "unknown", + CapabilityState::NotConfigured => "not_configured", + } +} + +fn parse_capability_state(value: &str) -> Result { + match value { + "supported" => Ok(CapabilityState::Supported), + "unsupported" => Ok(CapabilityState::Unsupported), + "unknown" => Ok(CapabilityState::Unknown), + "not_configured" => Ok(CapabilityState::NotConfigured), + _ => Err(MirrorError::VerificationInvariant), + } +} + +fn confidence_code(value: EvidenceConfidence) -> &'static str { + match value { + EvidenceConfidence::Documented => "documented", + EvidenceConfidence::Observed => "observed", + EvidenceConfidence::Inferred => "inferred", + EvidenceConfidence::Unknown => "unknown", + } +} + +fn parse_confidence(value: &str) -> Result { + match value { + "documented" => Ok(EvidenceConfidence::Documented), + "observed" => Ok(EvidenceConfidence::Observed), + "inferred" => Ok(EvidenceConfidence::Inferred), + "unknown" => Ok(EvidenceConfidence::Unknown), + _ => Err(MirrorError::VerificationInvariant), + } +} + +fn identifier_semantics_code(value: ChangeIdentifierSemantics) -> &'static str { + match value { + ChangeIdentifierSemantics::MonotonicPerObject => "monotonic_per_object", + ChangeIdentifierSemantics::Unknown => "unknown", + } +} + +fn parse_identifier_semantics(value: &str) -> Result { + match value { + "monotonic_per_object" => Ok(ChangeIdentifierSemantics::MonotonicPerObject), + "unknown" => Ok(ChangeIdentifierSemantics::Unknown), + _ => Err(MirrorError::VerificationInvariant), + } +} + +#[cfg(test)] +mod tests { + use bridge_tally_core::{CapabilityPackId, PackSchemaVersion, TransportId}; + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + use crate::db::tally_mirror::{ + BeginBatchInput, CapabilityItemInput, CapabilityKind, CapabilitySnapshotInput, + CapabilityState as MirrorCapabilityState, CompanyInput, Confidence, RunOutcome, + SourceIdentityInput, VerificationState as MirrorVerificationState, + }; + use crate::sync::reconciliation::{CommitBatchInput, CommitBatchParts}; + + async fn setup() -> (TallyMirrorRepository, String, String, IncrementalScope) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect("sqlite::memory:") + .await + .expect("connect synthetic mirror"); + let repository = TallyMirrorRepository::new(pool); + repository + .migrate() + .await + .expect("migrate synthetic mirror"); + let snapshot = repository + .save_capability_snapshot(CapabilitySnapshotInput { + canonical_origin: "http://127.0.0.1:9000".to_string(), + observed_at_unix_ms: 1_000, + profile_version: 1, + product: "TallyPrime".to_string(), + release: Some("7.0".to_string()), + mode: Some("Education".to_string()), + mode_confidence: Confidence::Observed, + items: vec![ + CapabilityItemInput { + kind: CapabilityKind::Transport, + key: "xml_http".to_string(), + state: MirrorCapabilityState::Supported, + confidence: Confidence::Observed, + safe_reason_code: None, + }, + CapabilityItemInput { + kind: CapabilityKind::Pack, + key: "core_accounting".to_string(), + state: MirrorCapabilityState::Supported, + confidence: Confidence::Observed, + safe_reason_code: None, + }, + ], + }) + .await + .expect("persist synthetic capability profile"); + let company_guid = "synthetic-company-guid"; + let company = repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id, + display_name: "Synthetic Company".to_string(), + identity: SourceIdentityInput { + guid: Some(company_guid.to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 1_000, + }) + .await + .expect("persist synthetic company"); + let lineage = "tally_xml_http:http://127.0.0.1:9000"; + let identity = company_source_identity(lineage, company_guid); + let scope = IncrementalScope { + source_lineage: identity.bridge_source_lineage, + company_guid: identity.company_guid, + company_fingerprint: identity.observed_fingerprint, + object_type: "voucher".to_string(), + capability_profile_version: 1, + product: "TallyPrime".to_string(), + release: "7.0".to_string(), + mode: "Education".to_string(), + transport: TransportId::XmlHttp, + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + query_profile: "core_voucher_incremental_v1".to_string(), + filters_sha256: "a".repeat(64), + date_window_policy: "change_id_overlap_v1".to_string(), + }; + (repository, snapshot.id, company.id, scope) + } + + #[tokio::test] + async fn exact_observation_is_immutable_and_still_requires_verified_full_checkpoint() { + let (repository, snapshot_id, company_id, scope) = setup().await; + let generic_only = repository + .incremental_readiness(&scope, IncrementalPolicy::default(), 1_500) + .await + .expect("generic capability passport is inspectable"); + assert!(matches!( + generic_only.plan, + SyncPlan::FullSnapshot { + reason: bridge_tally_incremental::FullSnapshotReason::CapabilityNotObserved, + .. + } + )); + let stored = repository + .save_incremental_capability_observation(VerifiedIncrementalCanaryReceipt { + company_id: company_id.clone(), + capability_snapshot_id: snapshot_id, + canary_contract_version: 1, + response_sha256: "1".repeat(64), + observation: IncrementalCapabilityObservation { + scope: scope.clone(), + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + identifier_semantics: ChangeIdentifierSemantics::MonotonicPerObject, + inclusive_lower_bound_observed: true, + explicit_source_high_watermark_observed: true, + }, + observed_at_unix_ms: 2_000, + }) + .await + .expect("persist exact synthetic incremental evidence"); + let loaded = repository + .load_incremental_capability(&scope) + .await + .expect("load exact capability") + .expect("capability exists"); + assert_eq!(loaded, stored); + let foundation = repository + .incremental_foundation_evidence(&company_id) + .await + .expect("read count-only incremental evidence"); + assert!(!foundation.execution_enabled); + assert_eq!(foundation.affirmative_exact_capability_receipts, 1); + assert_eq!(foundation.establishment_receipts, 0); + assert_eq!(foundation.active_checkpoint_heads, 0); + assert_eq!(foundation.state, "verified_establishment_missing"); + let readiness = repository + .incremental_readiness(&scope, IncrementalPolicy::default(), 3_000) + .await + .expect("plan readiness"); + assert!(matches!( + readiness.plan, + SyncPlan::FullSnapshot { + reason: bridge_tally_incremental::FullSnapshotReason::NoVerifiedCheckpoint, + .. + } + )); + let mutation = sqlx::query( + "UPDATE tally_incremental_capability_observations SET confidence = 'unknown' \ + WHERE id = ?1", + ) + .bind(stored.id) + .execute(&repository.pool) + .await; + assert!(mutation.is_err(), "capability evidence must be immutable"); + } + + #[tokio::test] + async fn company_or_filter_scope_drift_cannot_reuse_incremental_authority() { + let (repository, snapshot_id, company_id, scope) = setup().await; + let observation = |scope| IncrementalCapabilityObservation { + scope, + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + identifier_semantics: ChangeIdentifierSemantics::MonotonicPerObject, + inclusive_lower_bound_observed: true, + explicit_source_high_watermark_observed: true, + }; + let original = repository + .save_incremental_capability_observation(VerifiedIncrementalCanaryReceipt { + company_id: company_id.clone(), + capability_snapshot_id: snapshot_id.clone(), + canary_contract_version: 1, + response_sha256: "1".repeat(64), + observation: observation(scope.clone()), + observed_at_unix_ms: 2_000, + }) + .await + .expect("persist original exact scope"); + let mut changed_filter = scope.clone(); + changed_filter.filters_sha256 = "b".repeat(64); + let changed = repository + .save_incremental_capability_observation(VerifiedIncrementalCanaryReceipt { + company_id: company_id.clone(), + capability_snapshot_id: snapshot_id.clone(), + canary_contract_version: 1, + response_sha256: "2".repeat(64), + observation: observation(changed_filter), + observed_at_unix_ms: 2_001, + }) + .await + .expect("a new exact filter scope gets separate evidence"); + assert_ne!( + incremental_scope_sha256(&original.observation.scope).unwrap(), + incremental_scope_sha256(&changed.observation.scope).unwrap() + ); + + let mut wrong_company = scope; + wrong_company.company_guid = "different-guid".to_string(); + assert!(matches!( + repository + .save_incremental_capability_observation(VerifiedIncrementalCanaryReceipt { + company_id, + capability_snapshot_id: snapshot_id, + canary_contract_version: 1, + response_sha256: "3".repeat(64), + observation: observation(wrong_company), + observed_at_unix_ms: 2_001, + }) + .await, + Err(MirrorError::InvalidInput("incremental_company_scope")) + )); + } + + #[tokio::test] + async fn generic_pack_proof_cannot_self_attest_incremental_establishment() { + let (repository, snapshot_id, company_id, scope) = setup().await; + let capability = repository + .save_incremental_capability_observation(VerifiedIncrementalCanaryReceipt { + company_id: company_id.clone(), + capability_snapshot_id: snapshot_id.clone(), + canary_contract_version: 1, + response_sha256: "1".repeat(64), + observation: IncrementalCapabilityObservation { + scope: scope.clone(), + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + identifier_semantics: ChangeIdentifierSemantics::MonotonicPerObject, + inclusive_lower_bound_observed: true, + explicit_source_high_watermark_observed: true, + }, + observed_at_unix_ms: 2_000, + }) + .await + .expect("persist sealed canary evidence"); + let batch_id = repository + .begin_batch(BeginBatchInput { + run_id: "incremental-foundation-proof".to_string(), + capability_snapshot_id: snapshot_id, + company_id: company_id.clone(), + pack_id: "core_accounting".to_string(), + pack_schema_major: 1, + pack_schema_minor: 0, + source_transport: "xml_http".to_string(), + source_release: Some("7.0".to_string()), + requested_from_yyyymmdd: None, + requested_to_yyyymmdd: None, + started_at_unix_ms: 2_100, + }) + .await + .expect("begin synthetic verified full snapshot"); + let proof = repository + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: batch_id.clone(), + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: MirrorVerificationState::Verified, + completed_at_unix_ms: 3_000, + record_counts_sha256: None, + snapshot_sha256: Some("2".repeat(64)), + expected_checkpoint_before: None, + checkpoint_after: Some("42".to_string()), + freshness_target_seconds: 60, + gap_codes: vec![], + warning_codes: vec![], + })) + .await + .expect("commit proof-bound full snapshot"); + let scope_sha256 = incremental_scope_sha256(&scope).unwrap(); + let scope_json = serde_json::to_string(&scope).unwrap(); + + for watermark in ["01", "18446744073709551616", "42"] { + let insert = sqlx::query( + "INSERT INTO tally_incremental_establishment_receipts(\ + id, scope_sha256, scope_json, capability_observation_id, proof_id, \ + proof_sha256, batch_id, snapshot_plan_sha256, source_response_sha256, \ + coverage_manifest_sha256, source_high_watermark_decimal, \ + max_observed_alter_id_decimal, source_record_count, accepted_record_count, \ + deduplicated_record_count, numeric_alter_id_count, rejected_record_count, \ + duplicate_identity_count, missing_identity_count, out_of_scope_record_count, \ + verifier_contract_version, receipt_sha256, created_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, NULL, \ + 0, 0, 0, 0, 0, 0, 0, 0, 1, ?12, 3000)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(&scope_sha256) + .bind(&scope_json) + .bind(&capability.id) + .bind(&proof.proof_id) + .bind(&proof.proof_sha256) + .bind(&batch_id) + .bind("3".repeat(64)) + .bind("4".repeat(64)) + .bind("5".repeat(64)) + .bind(watermark) + .bind("6".repeat(64)) + .execute(&repository.pool) + .await; + assert!( + insert.is_err(), + "generic proof and caller hashes must never establish authority" + ); + } + assert!(repository + .load_incremental_checkpoint(&scope) + .await + .expect("missing authority falls back safely") + .is_none()); + assert!(sqlx::query( + "INSERT INTO tally_incremental_checkpoint_heads(\ + scope_sha256, scope_json, establishment_receipt_id, high_watermark_decimal, \ + generation, state, established_at_unix_ms\ + ) VALUES (?1, ?2, 'missing-receipt', '42', 1, 'active', 3000)", + ) + .bind(scope_sha256) + .bind(scope_json) + .execute(&repository.pool) + .await + .is_err()); + } +} diff --git a/src-tauri/src/db/tally_mirror.rs b/src-tauri/src/db/tally_mirror.rs new file mode 100644 index 0000000..587bbb3 --- /dev/null +++ b/src-tauri/src/db/tally_mirror.rs @@ -0,0 +1,6412 @@ +use std::collections::BTreeMap; + +use bridge_tally_core::{ExactDecimal, PackSchemaVersion, TallyDate}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use sqlx::{Row, Sqlite, SqlitePool, Transaction}; +use uuid::Uuid; + +use crate::sync::reconciliation::CommitBatchInput; +use crate::tally::core_snapshot_start_authorized_codes; + +const MIRROR_MIGRATION_V2: &str = include_str!("migrations/0002_tally_mirror.sql"); +const MIRROR_MIGRATION_V3: &str = include_str!("migrations/0003_tally_safe_writes.sql"); +const MIRROR_MIGRATION_V4: &str = include_str!("migrations/0004_tally_snapshot_state.sql"); +const MIRROR_MIGRATION_V5: &str = include_str!("migrations/0005_tally_snapshot_recovery.sql"); +const MIRROR_MIGRATION_V6: &str = include_str!("migrations/0006_tally_incremental_foundation.sql"); +const MIRROR_MIGRATION_V7: &str = include_str!("migrations/0007_tally_selected_read_evidence.sql"); +const MIRROR_MIGRATION_V8: &str = + include_str!("migrations/0008_tally_reviewed_setup_consumption.sql"); +const MIRROR_MIGRATION_V9: &str = include_str!("migrations/0009_tally_snapshot_window_staging.sql"); +const MIRROR_MIGRATION_V10: &str = + include_str!("migrations/0010_tally_provenance_unavailable_counts.sql"); +const MIRROR_MIGRATION_V11: &str = + include_str!("migrations/0011_tally_proof_record_counts_digest.sql"); +const MIRROR_MIGRATION_V12: &str = + include_str!("migrations/0012_tally_window_terminal_evidence.sql"); + +const MAX_WINDOW_STAGE_CHUNK: usize = 256; +const MAX_WINDOW_EVIDENCE_JSON_BYTES: usize = 16 * 1024; +const WINDOW_MEMBERSHIP_DIGEST_PAGE_SIZE: i64 = 512; + +pub(crate) const REVIEWED_TALLY_TERMINAL_CODES: &[&str] = &[ + "adaptive_window_limit_reached", + "application_response_rejected", + "canary_cache_unavailable", + "capability_cache_unavailable", + "capability_probe_required", + "company_export_invalid", + "company_identity_mismatch", + "connector_context_invalid", + "endpoint_circuit_open", + "endpoint_invalid", + "endpoint_queue_deadline_exceeded", + "fresh_capability_probe_not_supported", + "group_export_invalid", + "http_client_initialization_failed", + "http_status_failure", + "ledger_export_invalid", + "local_clock_moved_backwards", + "minimum_window_response_too_large", + "period_report_identity_missing", + "period_report_invalid", + "period_report_scope_mismatch", + "query_profile_not_supported", + "reconciliation_record_budget_exceeded", + "request_size_limit_exceeded", + "response_content_encoding_unsupported", + "response_encoding_invalid", + "response_read_failed", + "response_size_limit_exceeded", + "response_truncated", + "runtime_capacity_reached", + "snapshot_checkpoint_changed", + "transport_policy_invalid", + "unclassified_tally_error", + "voucher_export_invalid", + "voucher_response_size_limit_exceeded", + "voucher_type_export_invalid", + "window_membership_replay_conflict", +]; + +#[derive(Debug, thiserror::Error)] +pub enum MirrorError { + #[error("mirror database operation failed")] + Database(#[from] sqlx::Error), + #[error("invalid mirror input ({0})")] + InvalidInput(&'static str), + #[error("the requested mirror entity was not found")] + NotFound, + #[error("the observation batch is no longer open")] + BatchClosed, + #[error("multiple source identities resolve to different records")] + IdentityCollision, + #[error("a fallback identity cannot be upgraded without an explicit audit event")] + IdentityUpgradeRequiresAudit, + #[error("the record has already been observed in this batch")] + DuplicateObservation, + #[error("the replayed observation conflicts with the record already stored in this batch")] + ObservationConflict, + #[error("the snapshot window attempt is no longer open")] + WindowAttemptClosed, + #[error("the replayed snapshot window membership conflicts with immutable stored content")] + WindowMembershipConflict, + #[error("a previously staged snapshot window membership disappeared")] + WindowMembershipDisappeared, + #[error("only a complete, gap-free batch can be verified")] + VerificationInvariant, + #[error("the mirror checkpoint changed concurrently")] + ConcurrentCheckpoint, + #[error("canonical payload serialization failed")] + Serialization(#[from] serde_json::Error), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Confidence { + Documented, + Observed, + Inferred, + Unknown, +} + +impl Confidence { + fn as_str(self) -> &'static str { + match self { + Self::Documented => "documented", + Self::Observed => "observed", + Self::Inferred => "inferred", + Self::Unknown => "unknown", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityKind { + Transport, + Pack, + Feature, +} + +impl CapabilityKind { + fn as_str(self) -> &'static str { + match self { + Self::Transport => "transport", + Self::Pack => "pack", + Self::Feature => "feature", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CapabilityState { + Supported, + Unsupported, + Unknown, + NotConfigured, +} + +impl CapabilityState { + fn as_str(self) -> &'static str { + match self { + Self::Supported => "supported", + Self::Unsupported => "unsupported", + Self::Unknown => "unknown", + Self::NotConfigured => "not_configured", + } + } +} + +#[derive(Debug, Clone)] +pub struct CapabilityItemInput { + pub kind: CapabilityKind, + pub key: String, + pub state: CapabilityState, + pub confidence: Confidence, + pub safe_reason_code: Option, +} + +#[derive(Debug, Clone)] +pub struct CapabilitySnapshotInput { + pub canonical_origin: String, + pub observed_at_unix_ms: i64, + pub profile_version: u16, + pub product: String, + pub release: Option, + pub mode: Option, + pub mode_confidence: Confidence, + pub items: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CapabilitySnapshotRef { + pub id: String, + pub endpoint_id: String, +} + +#[derive(Debug, Clone, Default)] +pub struct SourceIdentityInput { + pub guid: Option, + pub remote_id: Option, + pub master_id: Option, + pub fallback_fingerprint: Option, + pub confidence: Option, +} + +#[derive(Debug, Clone)] +pub struct CompanyInput { + pub endpoint_id: String, + pub display_name: String, + pub identity: SourceIdentityInput, + pub observed_at_unix_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CompanyRef { + pub id: String, + pub display_name: String, +} + +#[derive(Debug, Clone)] +pub struct ReviewedSetupInput { + pub review_commitment_sha256: String, + pub capability: CapabilitySnapshotInput, + pub company_display_name: String, + pub company_identity: SourceIdentityInput, + pub selected_read_scope: Option, +} + +#[derive(Debug, Clone)] +pub struct SelectedReadScopeInput { + pub scope_commitment_sha256: String, + pub parent_review_sha256: String, + pub ledger_profile_id: String, + pub voucher_profile_id: String, + pub voucher_from_yyyymmdd: String, + pub voucher_to_yyyymmdd: String, + pub observed_at_unix_ms: i64, + pub observations: Vec, +} + +#[derive(Debug, Clone)] +pub struct SelectedReadObservationInput { + pub capability_key: String, + pub state: CapabilityState, + pub confidence: Confidence, + pub safe_reason_code: String, + pub result_bucket: String, + pub request_sha256: Option, + pub decoded_response_sha256: Option, + pub response_encoding: Option, + pub company_context_verified: bool, + pub schema_verified: bool, + pub record_count_verified: bool, + pub identity_evidence_state: String, + pub date_window_verified: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct SelectedReadObservationCommitmentMaterial { + pub capability_key: String, + pub state: String, + pub confidence: String, + pub safe_reason_code: String, + pub result_bucket: String, + pub request_sha256: Option, + pub decoded_response_sha256: Option, + pub response_encoding: Option, + pub company_context_verified: bool, + pub schema_verified: bool, + pub record_count_verified: bool, + pub identity_evidence_state: String, + pub date_window_verified: bool, +} + +#[derive(Debug, Clone, Serialize)] +pub(crate) struct SelectedReadScopeCommitmentMaterial { + pub parent_review_commitment_sha256: String, + pub canonical_origin: String, + pub company_guid_ascii_casefolded: String, + pub company_name: String, + pub ledger_profile_id: String, + pub voucher_profile_id: String, + pub voucher_from_yyyymmdd: String, + pub voucher_to_yyyymmdd: String, + pub observed_at_unix_ms: i64, + pub observations: Vec, +} + +#[derive(Serialize)] +struct SelectedReadScopeCommitmentEnvelope<'a> { + schema: &'static str, + #[serde(flatten)] + material: &'a SelectedReadScopeCommitmentMaterial, + no_writes_attempted: bool, + raw_records_retained: bool, + completeness_claimed: bool, +} + +pub(crate) fn selected_read_scope_commitment_sha256( + material: &SelectedReadScopeCommitmentMaterial, +) -> Result { + sha256_json(&SelectedReadScopeCommitmentEnvelope { + schema: "bridge.tally.selected-read-scope/1", + material, + no_writes_attempted: true, + raw_records_retained: false, + completeness_claimed: false, + }) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ReviewedSetupRef { + pub snapshot: CapabilitySnapshotRef, + pub company: CompanyRef, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotSourcePin { + pub company_id: String, + pub endpoint_id: String, + pub canonical_origin: String, + pub display_name: String, + pub company_guid: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PersistedCompanyProfile { + pub name: String, + pub guid_observed: bool, + pub mirror_company_id: String, + pub correlation_key: String, + pub identity_confidence: String, + pub canonical_endpoint: String, + pub last_observed_at_unix_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct PersistedCompanyProfilePage { + pub profiles: Vec, + pub total_profiles: u64, + pub limit: u32, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MirrorExplorerRecord { + pub local_alias: String, + pub object_type: String, + pub identity_confidence: String, + pub last_batch_state: String, + pub tombstoned: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct MirrorExplorerPage { + pub pack_id: String, + pub offset: u32, + pub limit: u32, + pub total_records: u64, + pub records: Vec, +} + +#[derive(Debug, Clone)] +pub struct BeginBatchInput { + pub run_id: String, + pub capability_snapshot_id: String, + pub company_id: String, + pub pack_id: String, + pub pack_schema_major: u16, + pub pack_schema_minor: u16, + pub source_transport: String, + pub source_release: Option, + pub requested_from_yyyymmdd: Option, + pub requested_to_yyyymmdd: Option, + pub started_at_unix_ms: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ObservationStatus { + Accepted, + Rejected, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ObserveRecordOutcome { + Inserted { observation_id: String }, + AlreadyPresentIdentical { observation_id: String }, +} + +impl ObservationStatus { + fn as_str(self) -> &'static str { + match self { + Self::Accepted => "accepted", + Self::Rejected => "rejected", + } + } +} + +#[derive(Debug, Clone)] +pub struct ObservedRecordInput { + pub batch_id: String, + pub object_type: String, + pub display_name: Option, + pub identity: SourceIdentityInput, + pub observed_at_unix_ms: i64, + pub raw_source_sha256: String, + pub canonical_sha256: Option, + pub canonical_payload: Option, + pub exact_decimals: BTreeMap, + pub observed_alter_id: Option, + pub status: ObservationStatus, + pub safe_rejection_code: Option, +} + +struct PreparedObservedRecord { + canonical_payload_json: Option, + exact_decimals_json: String, +} + +struct ObservedTransactionResult { + outcome: ObserveRecordOutcome, + source_record_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotWindowAttemptRef { + pub attempt_id: String, + pub batch_id: String, + pub window_id: String, + pub attempt_ordinal: u32, +} + +#[derive(Debug, Clone)] +pub struct BeginSnapshotWindowAttemptInput { + pub batch_id: String, + pub window_id: String, + pub started_at_unix_ms: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BeginSnapshotWindowAttemptResult { + pub attempt: SnapshotWindowAttemptRef, + pub prior_abandonment: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AbandonSnapshotWindowAttemptResult { + pub completed_at_unix_ms: i64, + pub local_clock_moved_backwards: bool, +} + +#[derive(Debug, Clone)] +pub enum SnapshotWindowMembershipInput { + Observed { + record_key: String, + observation: Box, + }, + ProvenanceUnavailable { + record_key: String, + canonical_sha256: String, + canonical_payload: Value, + exact_decimals: BTreeMap, + safe_reason_code: String, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct StageSnapshotWindowMembershipsResult { + pub inserted_memberships: u32, + pub replayed_memberships: u32, + pub inserted_observations: u32, + pub replayed_observations: u32, + pub provenance_unavailable_memberships: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SnapshotWindowReceipt { + pub schema: String, + pub attempt_id: String, + pub batch_id: String, + pub window_id: String, + pub attempt_ordinal: u32, + pub member_count: u32, + pub membership_sha256: String, + pub evidence: Value, + pub completed_at_unix_ms: i64, + pub receipt_sha256: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SnapshotWindowCompletionResult { + pub receipt: SnapshotWindowReceipt, + pub local_clock_moved_backwards: bool, +} + +impl std::ops::Deref for SnapshotWindowCompletionResult { + type Target = SnapshotWindowReceipt; + + fn deref(&self) -> &Self::Target { + &self.receipt + } +} + +#[derive(Serialize)] +struct SnapshotWindowReceiptMaterial<'a> { + schema: &'static str, + attempt_id: &'a str, + batch_id: &'a str, + window_id: &'a str, + attempt_ordinal: u32, + member_count: u32, + membership_sha256: &'a str, + evidence: &'a Value, + completed_at_unix_ms: i64, +} + +#[derive(Serialize)] +struct SnapshotWindowMembershipDigestEntry<'a> { + record_key: &'a str, + canonical_sha256: &'a str, + provenance_state: &'a str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum RunOutcome { + Completed, + Failed, + Cancelled, + OutcomeUnknown, +} + +impl RunOutcome { + fn as_str(self) -> &'static str { + match self { + Self::Completed => "completed", + Self::Failed => "failed", + Self::Cancelled => "cancelled", + Self::OutcomeUnknown => "outcome_unknown", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum VerificationState { + Verified, + Partial, + Unverified, +} + +impl VerificationState { + fn as_str(self) -> &'static str { + match self { + Self::Verified => "verified", + Self::Partial => "partial", + Self::Unverified => "unverified", + } + } + + fn batch_state(self) -> &'static str { + match self { + Self::Verified => "verified", + Self::Partial => "partial", + Self::Unverified => "failed", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommitResult { + pub proof_id: String, + pub proof_sha256: String, + pub checkpoint_advanced: bool, + pub facts: CommitReceiptFacts, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct CommitReceiptFacts { + pub proof_contract_version: u16, + pub run_id: String, + pub batch_id: String, + pub capability_snapshot_id: String, + pub company_id: String, + pub pack_id: String, + pub outcome: RunOutcome, + pub verification: VerificationState, + pub started_at_unix_ms: i64, + pub completed_at_unix_ms: i64, + pub accepted_records: i64, + pub rejected_records: i64, + pub provenance_unavailable_records: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub record_counts_sha256: Option, + pub snapshot_sha256: Option, + pub checkpoint_before: Option, + pub checkpoint_after: Option, + pub gap_codes: Vec, + pub warning_codes: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ObservationCounts { + pub accepted_records: i64, + pub rejected_records: i64, + pub provenance_unavailable_records: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FreshnessState { + Fresh, + Stale, + NeverVerified, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FreshnessStatus { + pub state: FreshnessState, + pub verified_at_unix_ms: Option, + pub age_seconds: Option, + pub checkpoint_token: Option, + pub proof_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProofSummary { + pub integrity_state: &'static str, + pub run_id: String, + pub selection_token: String, + pub proof_sha256: String, + pub pack_id: String, + pub outcome: String, + pub verification_state: String, + pub started_at_unix_ms: i64, + pub completed_at_unix_ms: Option, + pub accepted_records: i64, + pub rejected_records: i64, + pub provenance_unavailable_records: i64, + pub gap_codes: Vec, + pub warning_codes: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct RedactedProofExport { + pub json: String, + pub payload_sha256: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct LocalReconciliationMismatch { + pub reason_code: String, + pub record_aliases: Vec, +} + +#[derive(Serialize)] +struct RedactedProofPayload { + schema: &'static str, + schema_version: u16, + exported_at_unix_ms: i64, + redaction_profile: &'static str, + subject: RedactedSubject, + proofs: Vec, + current_status: RedactedCurrentStatus, +} + +#[derive(Serialize)] +struct RedactedSubject { + reference: &'static str, + identity_disclosed: bool, +} + +#[derive(Serialize)] +struct RedactedProofEntry { + entry_index: u16, + proof_contract_version: u16, + pack_id: String, + pack_schema_version: PackSchemaVersion, + outcome: String, + verification_state: String, + started_at_unix_ms: i64, + completed_at_unix_ms: i64, + counts: RedactedCounts, + gaps: Vec, + warnings: Vec, + local_ledger: RedactedLedgerEvidence, +} + +#[derive(Serialize)] +struct RedactedCounts { + provenance_backed_accepted_records: i64, + provenance_unavailable_records: i64, + rejected_records: i64, +} + +#[derive(Serialize)] +struct RedactedLedgerEvidence { + chain_validation: &'static str, +} + +#[derive(Serialize)] +struct RedactedCurrentStatus { + freshness_state: &'static str, + verified_at_unix_ms: Option, + checkpoint_present: bool, +} + +#[derive(Serialize)] +struct RedactedProofDocument { + #[serde(flatten)] + payload: RedactedProofPayload, + integrity: RedactedIntegrity, +} + +#[derive(Serialize)] +struct RedactedIntegrity { + canonicalization: &'static str, + hash_algorithm: &'static str, + domain: &'static str, + payload_sha256: String, + signature: Option, + integrity_claim: &'static str, + authenticity_claim: &'static str, +} + +#[derive(Clone)] +pub struct TallyMirrorRepository { + pub(super) pool: SqlitePool, +} + +impl TallyMirrorRepository { + pub fn new(pool: SqlitePool) -> Self { + Self { pool } + } + + pub(crate) fn pool_clone(&self) -> SqlitePool { + self.pool.clone() + } + + pub async fn persisted_company_profiles( + &self, + ) -> Result { + const LIMIT: u32 = 500; + let mut transaction = self.pool.begin().await?; + let total_profiles = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_companies AS c \ + WHERE c.identity_confidence = 'observed' \ + AND c.company_guid IS NOT NULL AND TRIM(c.company_guid) <> ''", + ) + .fetch_one(&mut *transaction) + .await?; + let rows = sqlx::query( + "SELECT c.id, c.display_name, c.company_guid, c.identity_confidence, \ + c.last_observed_at_unix_ms, e.canonical_origin \ + FROM tally_companies AS c \ + JOIN tally_endpoints AS e ON e.id = c.endpoint_id \ + WHERE c.identity_confidence = 'observed' \ + AND c.company_guid IS NOT NULL AND TRIM(c.company_guid) <> '' \ + ORDER BY c.last_observed_at_unix_ms DESC, c.id ASC LIMIT ?1", + ) + .bind(i64::from(LIMIT)) + .fetch_all(&mut *transaction) + .await?; + transaction.commit().await?; + let profiles = rows + .into_iter() + .map(|row| { + let canonical_endpoint: String = row.try_get("canonical_origin")?; + let company_guid: String = row.try_get("company_guid")?; + Ok(PersistedCompanyProfile { + name: row.try_get("display_name")?, + guid_observed: true, + mirror_company_id: row.try_get("id")?, + correlation_key: company_profile_correlation_key( + &canonical_endpoint, + &company_guid, + ), + identity_confidence: row.try_get("identity_confidence")?, + canonical_endpoint, + last_observed_at_unix_ms: row.try_get("last_observed_at_unix_ms")?, + }) + }) + .collect::, sqlx::Error>>()?; + let total_profiles = u64::try_from(total_profiles) + .map_err(|_| MirrorError::InvalidInput("persisted_company_profile_count"))?; + Ok(PersistedCompanyProfilePage { + truncated: total_profiles > u64::from(LIMIT), + profiles, + total_profiles, + limit: LIMIT, + }) + } + + pub async fn mirror_explorer_page( + &self, + company_id: &str, + pack_id: &str, + offset: u32, + limit: u32, + ) -> Result { + validate_nonempty(company_id, 128, "mirror_explorer_company")?; + validate_nonempty(pack_id, 64, "mirror_explorer_pack")?; + if limit == 0 || limit > 100 || offset > 1_000_000 { + return Err(MirrorError::InvalidInput("mirror_explorer_page")); + } + let mut transaction = self.pool.begin().await?; + let total_records = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_source_records AS record \ + JOIN tally_observation_batches AS batch ON batch.id = record.last_seen_batch_id \ + WHERE record.company_id = ?1 AND batch.company_id = record.company_id \ + AND batch.pack_id = ?2", + ) + .bind(company_id) + .bind(pack_id) + .fetch_one(&mut *transaction) + .await?; + let rows = sqlx::query( + "SELECT record.object_type, record.identity_confidence, \ + record.tombstoned_at_unix_ms, batch.state \ + FROM tally_source_records AS record \ + JOIN tally_observation_batches AS batch ON batch.id = record.last_seen_batch_id \ + WHERE record.company_id = ?1 AND batch.company_id = record.company_id \ + AND batch.pack_id = ?2 \ + ORDER BY record.object_type ASC, record.id ASC LIMIT ?3 OFFSET ?4", + ) + .bind(company_id) + .bind(pack_id) + .bind(i64::from(limit)) + .bind(i64::from(offset)) + .fetch_all(&mut *transaction) + .await?; + transaction.commit().await?; + let records = rows + .into_iter() + .enumerate() + .map(|(index, row)| { + Ok(MirrorExplorerRecord { + local_alias: format!("local-record-{}", u64::from(offset) + index as u64 + 1), + object_type: row.try_get("object_type")?, + identity_confidence: row.try_get("identity_confidence")?, + last_batch_state: row.try_get("state")?, + tombstoned: row + .try_get::, _>("tombstoned_at_unix_ms")? + .is_some(), + }) + }) + .collect::, sqlx::Error>>()?; + Ok(MirrorExplorerPage { + pack_id: pack_id.to_string(), + offset, + limit, + total_records: u64::try_from(total_records) + .map_err(|_| MirrorError::InvalidInput("mirror_explorer_count"))?, + records, + }) + } + + pub async fn snapshot_source_pin( + &self, + company_id: &str, + ) -> Result { + if company_id.trim().is_empty() { + return Err(MirrorError::InvalidInput("company_pin")); + } + let row = sqlx::query( + "SELECT c.id AS company_id, c.endpoint_id, e.canonical_origin, c.display_name, \ + c.company_guid, c.identity_confidence FROM tally_companies c \ + JOIN tally_endpoints e ON e.id = c.endpoint_id WHERE c.id = ?1", + ) + .bind(company_id) + .fetch_optional(&self.pool) + .await? + .ok_or(MirrorError::NotFound)?; + let identity_confidence: String = row.try_get("identity_confidence")?; + if identity_confidence != "observed" { + return Err(MirrorError::InvalidInput("company_identity_not_observed")); + } + let company_guid: Option = row.try_get("company_guid")?; + Ok(SnapshotSourcePin { + company_id: row.try_get("company_id")?, + endpoint_id: row.try_get("endpoint_id")?, + canonical_origin: row.try_get("canonical_origin")?, + display_name: row.try_get("display_name")?, + company_guid: company_guid + .filter(|guid| !guid.trim().is_empty()) + .ok_or(MirrorError::InvalidInput("company_guid_unobserved"))?, + }) + } + + /// Validates the encrypted capability receipt used by Core Accounting restart recovery. + /// + /// This is deliberately Core-specific. Other packs keep their own `Supported + Observed` + /// authorization semantics, while Core resumes only from the exact sealed-profile execution + /// receipt accepted by a fresh start. + pub async fn core_snapshot_resume_evidence_matches_plan( + &self, + snapshot_id: &str, + company_id: &str, + profile_version: u16, + product: &str, + release: Option<&str>, + mode: Option<&str>, + ) -> Result { + validate_nonempty(snapshot_id, 128, "capability_snapshot_id")?; + validate_nonempty(company_id, 128, "company_id")?; + if profile_version == 0 { + return Err(MirrorError::InvalidInput("profile_version")); + } + validate_nonempty(product, 128, "product")?; + validate_optional_text(release, 128, "release")?; + validate_optional_text(mode, 64, "mode")?; + let evidence = sqlx::query_as::<_, (String, String, Option)>( + "SELECT pack.capability_state, pack.confidence, pack.safe_reason_code \ + FROM tally_capability_snapshots AS snapshot \ + JOIN tally_companies AS company ON company.endpoint_id = snapshot.endpoint_id \ + JOIN tally_capability_items AS pack ON pack.snapshot_id = snapshot.id \ + WHERE snapshot.id = ?1 AND company.id = ?2 \ + AND snapshot.profile_version = ?3 AND snapshot.product = ?4 \ + AND snapshot.release IS ?5 AND snapshot.mode IS ?6 \ + AND EXISTS (SELECT 1 FROM tally_capability_items AS transport \ + WHERE transport.snapshot_id = snapshot.id \ + AND transport.capability_kind = 'transport' \ + AND transport.capability_key = 'xml_http' \ + AND transport.capability_state = 'supported' \ + AND transport.confidence = 'observed') \ + AND pack.capability_kind = 'pack' \ + AND pack.capability_key = 'core_accounting'", + ) + .bind(snapshot_id) + .bind(company_id) + .bind(i64::from(profile_version)) + .bind(product) + .bind(release) + .bind(mode) + .fetch_optional(&self.pool) + .await?; + Ok( + evidence.is_some_and(|(state, confidence, safe_reason_code)| { + core_snapshot_start_authorized_codes( + &state, + &confidence, + safe_reason_code.as_deref(), + ) + }), + ) + } + + /// Retains the ordinary observed-support contract used by non-snapshot capability flows. + pub async fn capability_snapshot_matches_plan( + &self, + snapshot_id: &str, + company_id: &str, + profile_version: u16, + product: &str, + release: Option<&str>, + mode: Option<&str>, + ) -> Result { + validate_nonempty(snapshot_id, 128, "capability_snapshot_id")?; + validate_nonempty(company_id, 128, "company_id")?; + if profile_version == 0 { + return Err(MirrorError::InvalidInput("profile_version")); + } + validate_nonempty(product, 128, "product")?; + validate_optional_text(release, 128, "release")?; + validate_optional_text(mode, 64, "mode")?; + let matches = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_capability_snapshots AS snapshot \ + JOIN tally_companies AS company ON company.endpoint_id = snapshot.endpoint_id \ + WHERE snapshot.id = ?1 AND company.id = ?2 \ + AND snapshot.profile_version = ?3 AND snapshot.product = ?4 \ + AND snapshot.release IS ?5 AND snapshot.mode IS ?6 \ + AND EXISTS (SELECT 1 FROM tally_capability_items AS transport \ + WHERE transport.snapshot_id = snapshot.id \ + AND transport.capability_kind = 'transport' \ + AND transport.capability_key = 'xml_http' \ + AND transport.capability_state = 'supported' \ + AND transport.confidence = 'observed') \ + AND EXISTS (SELECT 1 FROM tally_capability_items AS pack \ + WHERE pack.snapshot_id = snapshot.id \ + AND pack.capability_kind = 'pack' \ + AND pack.capability_key = 'core_accounting' \ + AND pack.capability_state = 'supported' \ + AND pack.confidence = 'observed')", + ) + .bind(snapshot_id) + .bind(company_id) + .bind(i64::from(profile_version)) + .bind(product) + .bind(release) + .bind(mode) + .fetch_one(&self.pool) + .await?; + Ok(matches == 1) + } + + pub async fn migrate(&self) -> Result<(), MirrorError> { + let mut transaction = self.pool.begin().await?; + sqlx::raw_sql(MIRROR_MIGRATION_V2) + .execute(&mut *transaction) + .await?; + sqlx::raw_sql(MIRROR_MIGRATION_V3) + .execute(&mut *transaction) + .await?; + sqlx::raw_sql(MIRROR_MIGRATION_V4) + .execute(&mut *transaction) + .await?; + let recovery_installed = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 5", + ) + .fetch_one(&mut *transaction) + .await?; + if recovery_installed == 0 { + let duplicate_snapshot_runs = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM (SELECT run_id FROM tally_snapshot_run_states \ + GROUP BY run_id HAVING COUNT(*) > 1)", + ) + .fetch_one(&mut *transaction) + .await?; + if duplicate_snapshot_runs != 0 { + return Err(MirrorError::InvalidInput("snapshot_state_duplicate_run_id")); + } + sqlx::raw_sql(MIRROR_MIGRATION_V5) + .execute(&mut *transaction) + .await?; + } + let incremental_installed = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 6", + ) + .fetch_one(&mut *transaction) + .await?; + if incremental_installed == 0 { + sqlx::raw_sql(MIRROR_MIGRATION_V6) + .execute(&mut *transaction) + .await?; + } + let selected_read_evidence_installed = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 7", + ) + .fetch_one(&mut *transaction) + .await?; + if selected_read_evidence_installed == 0 { + let casefold_collisions = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM (\ + SELECT endpoint_id, company_guid COLLATE NOCASE \ + FROM tally_companies WHERE company_guid IS NOT NULL \ + GROUP BY endpoint_id, company_guid COLLATE NOCASE HAVING COUNT(*) > 1\ + )", + ) + .fetch_one(&mut *transaction) + .await?; + if casefold_collisions != 0 { + return Err(MirrorError::InvalidInput("company_guid_casefold_collision")); + } + sqlx::raw_sql(MIRROR_MIGRATION_V7) + .execute(&mut *transaction) + .await?; + } + let reviewed_setup_consumption_installed = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 8", + ) + .fetch_one(&mut *transaction) + .await?; + if reviewed_setup_consumption_installed == 0 { + sqlx::raw_sql(MIRROR_MIGRATION_V8) + .execute(&mut *transaction) + .await?; + } + let window_staging_installed = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 9", + ) + .fetch_one(&mut *transaction) + .await?; + if window_staging_installed == 0 { + sqlx::raw_sql(MIRROR_MIGRATION_V9) + .execute(&mut *transaction) + .await?; + } + let provenance_counts_installed = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 10", + ) + .fetch_one(&mut *transaction) + .await?; + if provenance_counts_installed == 0 { + sqlx::raw_sql(MIRROR_MIGRATION_V10) + .execute(&mut *transaction) + .await?; + } + let proof_record_counts_installed = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 11", + ) + .fetch_one(&mut *transaction) + .await?; + if proof_record_counts_installed == 0 { + sqlx::raw_sql(MIRROR_MIGRATION_V11) + .execute(&mut *transaction) + .await?; + } + let window_abandonment_evidence_installed = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 12", + ) + .fetch_one(&mut *transaction) + .await?; + if window_abandonment_evidence_installed == 0 { + sqlx::raw_sql(MIRROR_MIGRATION_V12) + .execute(&mut *transaction) + .await?; + } + sqlx::query( + "UPDATE tally_schema_migrations SET applied_at_unix_ms = ?1 \ + WHERE version IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) AND applied_at_unix_ms = 0", + ) + .bind(Utc::now().timestamp_millis()) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(()) + } + + pub async fn save_capability_snapshot( + &self, + input: CapabilitySnapshotInput, + ) -> Result { + validate_capability_snapshot(&input)?; + let mut transaction = self.pool.begin().await?; + let snapshot = Self::insert_capability_snapshot(&mut transaction, input).await?; + transaction.commit().await?; + Ok(snapshot) + } + + async fn insert_capability_snapshot( + transaction: &mut Transaction<'_, Sqlite>, + input: CapabilitySnapshotInput, + ) -> Result { + let endpoint_id = match sqlx::query_scalar::<_, String>( + "SELECT id FROM tally_endpoints WHERE canonical_origin = ?1", + ) + .bind(&input.canonical_origin) + .fetch_optional(&mut **transaction) + .await? + { + Some(id) => { + sqlx::query( + "UPDATE tally_endpoints SET last_observed_at_unix_ms = ?1 WHERE id = ?2", + ) + .bind(input.observed_at_unix_ms) + .bind(&id) + .execute(&mut **transaction) + .await?; + id + } + None => { + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_endpoints(\ + id, canonical_origin, created_at_unix_ms, last_observed_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?3)", + ) + .bind(&id) + .bind(&input.canonical_origin) + .bind(input.observed_at_unix_ms) + .execute(&mut **transaction) + .await?; + id + } + }; + + let snapshot_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_capability_snapshots(\ + id, endpoint_id, observed_at_unix_ms, profile_version, product, release, mode, \ + mode_confidence\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + ) + .bind(&snapshot_id) + .bind(&endpoint_id) + .bind(input.observed_at_unix_ms) + .bind(i64::from(input.profile_version)) + .bind(input.product) + .bind(input.release) + .bind(input.mode) + .bind(input.mode_confidence.as_str()) + .execute(&mut **transaction) + .await?; + + for item in input.items { + sqlx::query( + "INSERT INTO tally_capability_items(\ + snapshot_id, capability_kind, capability_key, capability_state, confidence, \ + safe_reason_code\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + ) + .bind(&snapshot_id) + .bind(item.kind.as_str()) + .bind(item.key) + .bind(item.state.as_str()) + .bind(item.confidence.as_str()) + .bind(item.safe_reason_code) + .execute(&mut **transaction) + .await?; + } + Ok(CapabilitySnapshotRef { + id: snapshot_id, + endpoint_id, + }) + } + + pub async fn upsert_company(&self, input: CompanyInput) -> Result { + validate_company_input(&input)?; + let mut transaction = self.pool.begin().await?; + let company = Self::upsert_company_in_transaction(&mut transaction, input).await?; + transaction.commit().await?; + Ok(company) + } + + async fn upsert_company_in_transaction( + transaction: &mut Transaction<'_, Sqlite>, + input: CompanyInput, + ) -> Result { + let matches = find_identity_matches( + transaction, + "tally_companies", + "endpoint_id", + &input.endpoint_id, + None, + &input.identity, + ) + .await?; + + let id = match unique_match(matches)? { + Some(existing) => { + ensure_no_silent_identity_change(&existing, &input.identity)?; + let incoming_confidence = identity_confidence(&input.identity).as_str(); + sqlx::query( + "UPDATE tally_companies SET display_name = ?1, last_observed_at_unix_ms = ?2, \ + identity_confidence = CASE \ + WHEN ?3 = 'documented' THEN 'documented' \ + WHEN ?3 = 'observed' AND identity_confidence IN ('inferred', 'unknown') \ + THEN 'observed' \ + WHEN ?3 = 'inferred' AND identity_confidence = 'unknown' THEN 'inferred' \ + ELSE identity_confidence END WHERE id = ?4", + ) + .bind(&input.display_name) + .bind(input.observed_at_unix_ms) + .bind(incoming_confidence) + .bind(&existing.id) + .execute(&mut **transaction) + .await?; + existing.id + } + None => { + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_companies(\ + id, endpoint_id, display_name, company_guid, remote_id, master_id, \ + fallback_fingerprint, identity_confidence, first_observed_at_unix_ms, \ + last_observed_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)", + ) + .bind(&id) + .bind(&input.endpoint_id) + .bind(&input.display_name) + .bind(&input.identity.guid) + .bind(&input.identity.remote_id) + .bind(&input.identity.master_id) + .bind(&input.identity.fallback_fingerprint) + .bind(identity_confidence(&input.identity).as_str()) + .bind(input.observed_at_unix_ms) + .execute(&mut **transaction) + .await?; + id + } + }; + Ok(CompanyRef { + id, + display_name: input.display_name, + }) + } + + pub async fn save_reviewed_setup( + &self, + input: ReviewedSetupInput, + ) -> Result { + validate_sha256(&input.review_commitment_sha256)?; + validate_capability_snapshot(&input.capability)?; + validate_nonempty(&input.company_display_name, 512, "display_name")?; + validate_identity(&input.company_identity)?; + if let Some(guid) = input.company_identity.guid.as_deref() { + validate_company_guid(guid)?; + } + validate_selected_read_scope( + input.selected_read_scope.as_ref(), + &input.capability, + &input.company_display_name, + input.company_identity.guid.as_deref(), + )?; + let setup_payload_sha256 = reviewed_setup_payload_sha256(&input)?; + + let observed_at_unix_ms = input.capability.observed_at_unix_ms; + let mut transaction = self.pool.begin().await?; + if let Some(existing) = sqlx::query( + "SELECT consumption.setup_payload_sha256, snapshot.id AS snapshot_id, \ + snapshot.endpoint_id, company.id AS company_id, company.display_name \ + FROM tally_reviewed_setup_consumptions AS consumption \ + JOIN tally_capability_snapshots AS snapshot \ + ON snapshot.id = consumption.capability_snapshot_id \ + JOIN tally_companies AS company ON company.id = consumption.company_id \ + WHERE consumption.review_commitment_sha256 = ?1", + ) + .bind(&input.review_commitment_sha256) + .fetch_optional(&mut *transaction) + .await? + { + if existing.get::("setup_payload_sha256") != setup_payload_sha256 { + transaction.rollback().await?; + return Err(MirrorError::InvalidInput("review_commitment_reused")); + } + let reviewed = ReviewedSetupRef { + snapshot: CapabilitySnapshotRef { + id: existing.get("snapshot_id"), + endpoint_id: existing.get("endpoint_id"), + }, + company: CompanyRef { + id: existing.get("company_id"), + display_name: existing.get("display_name"), + }, + }; + transaction.rollback().await?; + return Ok(reviewed); + } + let snapshot = Self::insert_capability_snapshot(&mut transaction, input.capability).await?; + let company = Self::upsert_company_in_transaction( + &mut transaction, + CompanyInput { + endpoint_id: snapshot.endpoint_id.clone(), + display_name: input.company_display_name, + identity: input.company_identity, + observed_at_unix_ms, + }, + ) + .await?; + if let Some(scope) = input.selected_read_scope { + Self::insert_selected_read_scope(&mut transaction, &snapshot.id, &company.id, scope) + .await?; + } + sqlx::query( + "INSERT INTO tally_reviewed_setup_consumptions(\ + review_commitment_sha256, setup_payload_sha256, capability_snapshot_id, \ + company_id, consumed_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5)", + ) + .bind(input.review_commitment_sha256) + .bind(setup_payload_sha256) + .bind(&snapshot.id) + .bind(&company.id) + .bind(Utc::now().timestamp_millis()) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(ReviewedSetupRef { snapshot, company }) + } + + async fn insert_selected_read_scope( + transaction: &mut Transaction<'_, Sqlite>, + snapshot_id: &str, + company_id: &str, + input: SelectedReadScopeInput, + ) -> Result<(), MirrorError> { + let scope_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_selected_read_scopes(\ + id, capability_snapshot_id, company_id, scope_contract_version, \ + scope_commitment_sha256, parent_review_sha256, ledger_profile_id, \ + voucher_profile_id, voucher_from_yyyymmdd, voucher_to_yyyymmdd, \ + observed_at_unix_ms, completeness_state, no_writes_attempted, \ + raw_records_retained\ + ) VALUES (?1, ?2, ?3, 1, ?4, ?5, ?6, ?7, ?8, ?9, ?10, \ + 'not_claimed', 1, 0)", + ) + .bind(&scope_id) + .bind(snapshot_id) + .bind(company_id) + .bind(input.scope_commitment_sha256) + .bind(input.parent_review_sha256) + .bind(input.ledger_profile_id) + .bind(input.voucher_profile_id) + .bind(input.voucher_from_yyyymmdd) + .bind(input.voucher_to_yyyymmdd) + .bind(input.observed_at_unix_ms) + .execute(&mut **transaction) + .await?; + + for observation in input.observations { + sqlx::query( + "INSERT INTO tally_selected_read_observations(\ + scope_id, capability_snapshot_id, capability_kind, capability_key, \ + capability_state, confidence, safe_reason_code, result_bucket, \ + request_sha256, decoded_response_sha256, response_encoding, company_context_verified, schema_verified, \ + record_count_verified, identity_evidence_state, date_window_verified\ + ) VALUES (?1, ?2, 'feature', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, \ + ?12, ?13, ?14, ?15)", + ) + .bind(&scope_id) + .bind(snapshot_id) + .bind(observation.capability_key) + .bind(observation.state.as_str()) + .bind(observation.confidence.as_str()) + .bind(observation.safe_reason_code) + .bind(observation.result_bucket) + .bind(observation.request_sha256) + .bind(observation.decoded_response_sha256) + .bind(observation.response_encoding) + .bind(i64::from(observation.company_context_verified)) + .bind(i64::from(observation.schema_verified)) + .bind(i64::from(observation.record_count_verified)) + .bind(observation.identity_evidence_state) + .bind(i64::from(observation.date_window_verified)) + .execute(&mut **transaction) + .await?; + } + Ok(()) + } + + pub async fn begin_batch(&self, input: BeginBatchInput) -> Result { + validate_nonempty(&input.run_id, 128, "run_id")?; + validate_safe_code(&input.pack_id)?; + validate_safe_code(&input.source_transport)?; + validate_optional_text(input.source_release.as_deref(), 128, "source_release")?; + validate_date_range( + input.requested_from_yyyymmdd.as_deref(), + input.requested_to_yyyymmdd.as_deref(), + )?; + + let mut transaction = self.pool.begin().await?; + // Serialize the read-before-insert idempotency check. The v2 schema deliberately permits + // one batch per (run, pack), so v5 must not strengthen this to global run uniqueness. + sqlx::query("UPDATE tally_schema_migrations SET version = version WHERE version = 4") + .execute(&mut *transaction) + .await?; + let same_endpoint = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_capability_snapshots AS s \ + JOIN tally_companies AS c ON c.endpoint_id = s.endpoint_id \ + WHERE s.id = ?1 AND c.id = ?2", + ) + .bind(&input.capability_snapshot_id) + .bind(&input.company_id) + .fetch_one(&mut *transaction) + .await?; + if same_endpoint != 1 { + return Err(MirrorError::InvalidInput("snapshot_company_endpoint")); + } + + let existing = sqlx::query( + "SELECT id, capability_snapshot_id, company_id, pack_id, pack_schema_major, \ + pack_schema_minor, source_transport, source_release, requested_from_yyyymmdd, \ + requested_to_yyyymmdd, started_at_unix_ms, state \ + FROM tally_observation_batches WHERE run_id = ?1 AND pack_id = ?2", + ) + .bind(&input.run_id) + .bind(&input.pack_id) + .fetch_optional(&mut *transaction) + .await?; + if let Some(existing) = existing { + let matches = existing.try_get::("capability_snapshot_id")? + == input.capability_snapshot_id + && existing.try_get::("company_id")? == input.company_id + && existing.try_get::("pack_id")? == input.pack_id + && existing.try_get::("pack_schema_major")? + == i64::from(input.pack_schema_major) + && existing.try_get::("pack_schema_minor")? + == i64::from(input.pack_schema_minor) + && existing.try_get::("source_transport")? == input.source_transport + && existing.try_get::, _>("source_release")? == input.source_release + && existing.try_get::, _>("requested_from_yyyymmdd")? + == input.requested_from_yyyymmdd + && existing.try_get::, _>("requested_to_yyyymmdd")? + == input.requested_to_yyyymmdd + && existing.try_get::("started_at_unix_ms")? == input.started_at_unix_ms + && existing.try_get::("state")? == "staging"; + if !matches { + return Err(MirrorError::InvalidInput("run_batch_mismatch")); + } + let id = existing.try_get("id")?; + transaction.commit().await?; + return Ok(id); + } + + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_observation_batches(\ + id, run_id, capability_snapshot_id, company_id, pack_id, pack_schema_major, \ + pack_schema_minor, source_transport, source_release, requested_from_yyyymmdd, \ + requested_to_yyyymmdd, started_at_unix_ms, state\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 'staging')", + ) + .bind(&id) + .bind(&input.run_id) + .bind(&input.capability_snapshot_id) + .bind(&input.company_id) + .bind(&input.pack_id) + .bind(i64::from(input.pack_schema_major)) + .bind(i64::from(input.pack_schema_minor)) + .bind(&input.source_transport) + .bind(&input.source_release) + .bind(&input.requested_from_yyyymmdd) + .bind(&input.requested_to_yyyymmdd) + .bind(input.started_at_unix_ms) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(id) + } + + pub async fn observe_record(&self, input: ObservedRecordInput) -> Result { + match self.observe_record_idempotent(input).await { + Ok(ObserveRecordOutcome::Inserted { observation_id }) => Ok(observation_id), + Ok(ObserveRecordOutcome::AlreadyPresentIdentical { .. }) + | Err(MirrorError::ObservationConflict) => Err(MirrorError::DuplicateObservation), + Err(error) => Err(error), + } + } + + pub async fn observe_record_idempotent( + &self, + input: ObservedRecordInput, + ) -> Result { + let prepared = prepare_observed_record(&input)?; + + let mut transaction = self.pool.begin().await?; + // Acquire SQLite's write lock before the replay check. This keeps the + // read-before-insert decision exact when multiple workers lose the + // acknowledgement for the same observation concurrently. + sqlx::query("UPDATE tally_schema_migrations SET version = version WHERE version = 4") + .execute(&mut *transaction) + .await?; + let result = + Self::observe_record_in_transaction(&mut transaction, &input, &prepared).await?; + transaction.commit().await?; + Ok(result.outcome) + } + + async fn observe_record_in_transaction( + transaction: &mut Transaction<'_, Sqlite>, + input: &ObservedRecordInput, + prepared: &PreparedObservedRecord, + ) -> Result { + let batch = + sqlx::query("SELECT company_id, state FROM tally_observation_batches WHERE id = ?1") + .bind(&input.batch_id) + .fetch_optional(&mut **transaction) + .await? + .ok_or(MirrorError::NotFound)?; + let company_id: String = batch.try_get("company_id")?; + let state: String = batch.try_get("state")?; + if state != "staging" { + return Err(MirrorError::BatchClosed); + } + + let matches = find_identity_matches( + transaction, + "tally_source_records", + "company_id", + &company_id, + Some(&input.object_type), + &input.identity, + ) + .await?; + + let source_record_id = match unique_match(matches)? { + Some(existing) => { + ensure_no_silent_identity_change(&existing, &input.identity)?; + let stored = sqlx::query( + "SELECT id, raw_source_sha256, canonical_sha256, canonical_payload_json, \ + exact_decimals_json, observed_alter_id, validation_status, \ + safe_rejection_code \ + FROM tally_record_observations \ + WHERE batch_id = ?1 AND source_record_id = ?2", + ) + .bind(&input.batch_id) + .bind(&existing.id) + .fetch_optional(&mut **transaction) + .await?; + if let Some(stored) = stored { + let observation_id: String = stored.try_get("id")?; + let identical = stored.try_get::("raw_source_sha256")?.as_str() + == input.raw_source_sha256.as_str() + && stored + .try_get::, _>("canonical_sha256")? + .as_deref() + == input.canonical_sha256.as_deref() + && stored + .try_get::, _>("canonical_payload_json")? + .as_deref() + == prepared.canonical_payload_json.as_deref() + && stored.try_get::("exact_decimals_json")?.as_str() + == prepared.exact_decimals_json.as_str() + && stored + .try_get::, _>("observed_alter_id")? + .as_deref() + == input.observed_alter_id.as_deref() + && stored.try_get::("validation_status")?.as_str() + == input.status.as_str() + && stored + .try_get::, _>("safe_rejection_code")? + .as_deref() + == input.safe_rejection_code.as_deref(); + if !identical { + return Err(MirrorError::ObservationConflict); + } + return Ok(ObservedTransactionResult { + outcome: ObserveRecordOutcome::AlreadyPresentIdentical { observation_id }, + source_record_id: existing.id, + }); + } + sqlx::query( + "UPDATE tally_source_records SET display_name = COALESCE(?1, display_name), \ + last_seen_batch_id = ?2, tombstoned_at_unix_ms = NULL WHERE id = ?3", + ) + .bind(&input.display_name) + .bind(&input.batch_id) + .bind(&existing.id) + .execute(&mut **transaction) + .await?; + existing.id + } + None => { + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_source_records(\ + id, company_id, object_type, display_name, source_guid, remote_id, master_id, \ + fallback_fingerprint, identity_confidence, first_seen_batch_id, \ + last_seen_batch_id\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)", + ) + .bind(&id) + .bind(&company_id) + .bind(&input.object_type) + .bind(&input.display_name) + .bind(&input.identity.guid) + .bind(&input.identity.remote_id) + .bind(&input.identity.master_id) + .bind(&input.identity.fallback_fingerprint) + .bind(identity_confidence(&input.identity).as_str()) + .bind(&input.batch_id) + .execute(&mut **transaction) + .await?; + id + } + }; + + let observation_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_record_observations(\ + id, batch_id, source_record_id, observed_at_unix_ms, raw_source_sha256, \ + canonical_sha256, canonical_payload_json, exact_decimals_json, observed_alter_id, \ + validation_status, safe_rejection_code\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)", + ) + .bind(&observation_id) + .bind(&input.batch_id) + .bind(&source_record_id) + .bind(input.observed_at_unix_ms) + .bind(&input.raw_source_sha256) + .bind(&input.canonical_sha256) + .bind(&prepared.canonical_payload_json) + .bind(&prepared.exact_decimals_json) + .bind(&input.observed_alter_id) + .bind(input.status.as_str()) + .bind(&input.safe_rejection_code) + .execute(&mut **transaction) + .await?; + Ok(ObservedTransactionResult { + outcome: ObserveRecordOutcome::Inserted { + observation_id: observation_id.clone(), + }, + source_record_id, + }) + } + + pub async fn begin_snapshot_window_attempt( + &self, + input: BeginSnapshotWindowAttemptInput, + ) -> Result { + validate_nonempty(&input.batch_id, 128, "window_attempt_batch_id")?; + validate_nonempty(&input.window_id, 128, "window_attempt_window_id")?; + if input.started_at_unix_ms <= 0 { + return Err(MirrorError::InvalidInput("window_attempt_started_at")); + } + let mut transaction = self.pool.begin().await?; + acquire_mirror_write_lock(&mut transaction).await?; + let batch_state = sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_observation_batches WHERE id = ?1", + ) + .bind(&input.batch_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(MirrorError::NotFound)?; + if batch_state != "staging" { + return Err(MirrorError::BatchClosed); + } + sqlx::query( + "UPDATE tally_snapshot_window_attempts \ + SET state = 'abandoned', completed_at_unix_ms = \ + CASE WHEN started_at_unix_ms > ?1 THEN started_at_unix_ms ELSE ?1 END, \ + terminal_safe_reason_code = CASE WHEN started_at_unix_ms > ?1 \ + THEN 'local_clock_moved_backwards' ELSE NULL END \ + WHERE batch_id = ?2 AND window_id = ?3 AND state = 'open'", + ) + .bind(input.started_at_unix_ms) + .bind(&input.batch_id) + .bind(&input.window_id) + .execute(&mut *transaction) + .await?; + // Query cumulatively rather than relying on the row just changed. If the process loses + // the begin acknowledgement before saving its new attempt ref, a later begin can still + // recover rollback evidence from an earlier implicitly abandoned attempt. + let prior_abandonment = sqlx::query( + "SELECT completed_at_unix_ms FROM tally_snapshot_window_attempts \ + WHERE batch_id = ?1 AND window_id = ?2 AND state = 'abandoned' \ + AND terminal_safe_reason_code = 'local_clock_moved_backwards' \ + ORDER BY attempt_ordinal DESC LIMIT 1", + ) + .bind(&input.batch_id) + .bind(&input.window_id) + .fetch_optional(&mut *transaction) + .await? + .map(|row| -> Result<_, MirrorError> { + Ok(AbandonSnapshotWindowAttemptResult { + completed_at_unix_ms: row.try_get("completed_at_unix_ms")?, + local_clock_moved_backwards: true, + }) + }) + .transpose()?; + let next_ordinal = sqlx::query_scalar::<_, i64>( + "SELECT COALESCE(MAX(attempt_ordinal), 0) + 1 \ + FROM tally_snapshot_window_attempts WHERE batch_id = ?1 AND window_id = ?2", + ) + .bind(&input.batch_id) + .bind(&input.window_id) + .fetch_one(&mut *transaction) + .await?; + let attempt_ordinal = u32::try_from(next_ordinal) + .map_err(|_| MirrorError::InvalidInput("window_attempt_ordinal"))?; + let attempt_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_snapshot_window_attempts(\ + id, batch_id, window_id, attempt_ordinal, state, started_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, 'open', ?5)", + ) + .bind(&attempt_id) + .bind(&input.batch_id) + .bind(&input.window_id) + .bind(next_ordinal) + .bind(input.started_at_unix_ms) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(BeginSnapshotWindowAttemptResult { + attempt: SnapshotWindowAttemptRef { + attempt_id, + batch_id: input.batch_id, + window_id: input.window_id, + attempt_ordinal, + }, + prior_abandonment, + }) + } + + pub async fn stage_snapshot_window_membership( + &self, + attempt: &SnapshotWindowAttemptRef, + membership: SnapshotWindowMembershipInput, + ) -> Result { + self.stage_snapshot_window_memberships(attempt, vec![membership]) + .await + } + + pub async fn abandon_snapshot_window_attempt( + &self, + attempt: &SnapshotWindowAttemptRef, + observed_completed_at_unix_ms: i64, + ) -> Result { + validate_snapshot_window_attempt_ref(attempt)?; + if observed_completed_at_unix_ms <= 0 { + return Err(MirrorError::InvalidInput("window_attempt_completed_at")); + } + let mut transaction = self.pool.begin().await?; + acquire_mirror_write_lock(&mut transaction).await?; + let stored = sqlx::query( + "SELECT state, started_at_unix_ms, completed_at_unix_ms, \ + terminal_safe_reason_code \ + FROM tally_snapshot_window_attempts \ + WHERE id = ?1 AND batch_id = ?2 AND window_id = ?3 AND attempt_ordinal = ?4", + ) + .bind(&attempt.attempt_id) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(i64::from(attempt.attempt_ordinal)) + .fetch_optional(&mut *transaction) + .await? + .ok_or(MirrorError::NotFound)?; + let state: String = stored.try_get("state")?; + let started_at_unix_ms: i64 = stored.try_get("started_at_unix_ms")?; + if state == "abandoned" { + let completed_at_unix_ms = stored + .try_get::, _>("completed_at_unix_ms")? + .ok_or(MirrorError::InvalidInput("window_attempt_completed_at"))?; + let safe_reason_code = + stored.try_get::, _>("terminal_safe_reason_code")?; + transaction.commit().await?; + return Ok(AbandonSnapshotWindowAttemptResult { + completed_at_unix_ms, + local_clock_moved_backwards: safe_reason_code.as_deref() + == Some("local_clock_moved_backwards"), + }); + } + if state != "open" { + return Err(MirrorError::WindowAttemptClosed); + } + // A process can restart after the local wall clock has moved behind the timestamp that + // was durably recorded when the attempt opened. Abandonment is cleanup, so waiting for + // wall time to catch up would strand the run in `Staging`. Preserve the database's + // monotonic timestamp invariant while reporting the rollback to the proof layer. + let local_clock_moved_backwards = observed_completed_at_unix_ms < started_at_unix_ms; + let completed_at_unix_ms = observed_completed_at_unix_ms.max(started_at_unix_ms); + let updated = sqlx::query( + "UPDATE tally_snapshot_window_attempts \ + SET state = 'abandoned', completed_at_unix_ms = ?1, \ + terminal_safe_reason_code = ?2 \ + WHERE id = ?3 AND batch_id = ?4 AND window_id = ?5 \ + AND attempt_ordinal = ?6 AND state = 'open'", + ) + .bind(completed_at_unix_ms) + .bind(local_clock_moved_backwards.then_some("local_clock_moved_backwards")) + .bind(&attempt.attempt_id) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(i64::from(attempt.attempt_ordinal)) + .execute(&mut *transaction) + .await?; + if updated.rows_affected() != 1 { + return Err(MirrorError::WindowAttemptClosed); + } + transaction.commit().await?; + Ok(AbandonSnapshotWindowAttemptResult { + completed_at_unix_ms, + local_clock_moved_backwards, + }) + } + + pub async fn stage_snapshot_window_memberships( + &self, + attempt: &SnapshotWindowAttemptRef, + memberships: Vec, + ) -> Result { + validate_snapshot_window_attempt_ref(attempt)?; + if memberships.is_empty() || memberships.len() > MAX_WINDOW_STAGE_CHUNK { + return Err(MirrorError::InvalidInput("window_membership_chunk_size")); + } + for membership in &memberships { + validate_snapshot_window_membership_input(membership, &attempt.batch_id)?; + } + + let mut transaction = self.pool.begin().await?; + acquire_mirror_write_lock(&mut transaction).await?; + ensure_open_snapshot_window_attempt(&mut transaction, attempt).await?; + let mut result = StageSnapshotWindowMembershipsResult::default(); + for membership in memberships { + let ( + record_key, + canonical_sha256, + canonical_payload_json, + exact_decimals_json, + provenance_state, + source_record_id, + observation_id, + safe_reason_code, + ) = match membership { + SnapshotWindowMembershipInput::Observed { + record_key, + observation, + } => { + let prepared = prepare_observed_record(&observation)?; + let observed = Self::observe_record_in_transaction( + &mut transaction, + &observation, + &prepared, + ) + .await?; + let observation_id = match observed.outcome { + ObserveRecordOutcome::Inserted { observation_id } => { + result.inserted_observations += 1; + observation_id + } + ObserveRecordOutcome::AlreadyPresentIdentical { observation_id } => { + result.replayed_observations += 1; + observation_id + } + }; + ( + record_key, + observation + .canonical_sha256 + .expect("validated accepted observation"), + prepared + .canonical_payload_json + .expect("validated accepted observation"), + prepared.exact_decimals_json, + "observed", + Some(observed.source_record_id), + Some(observation_id), + None, + ) + } + SnapshotWindowMembershipInput::ProvenanceUnavailable { + record_key, + canonical_sha256, + canonical_payload, + exact_decimals, + safe_reason_code, + } => { + result.provenance_unavailable_memberships += 1; + ( + record_key, + canonical_sha256, + canonical_json(&canonical_payload)?, + validate_and_serialize_decimals(&exact_decimals)?, + "unavailable", + None, + None, + Some(safe_reason_code), + ) + } + }; + + let existing = sqlx::query( + "SELECT canonical_sha256, canonical_payload_json, exact_decimals_json, \ + provenance_state, source_record_id, observation_id, safe_reason_code \ + FROM tally_snapshot_window_memberships \ + WHERE batch_id = ?1 AND window_id = ?2 AND record_key = ?3", + ) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(&record_key) + .fetch_optional(&mut *transaction) + .await?; + if let Some(existing) = existing { + let identical = existing.try_get::("canonical_sha256")? + == canonical_sha256 + && existing.try_get::("canonical_payload_json")? + == canonical_payload_json + && existing.try_get::("exact_decimals_json")? == exact_decimals_json + && existing.try_get::("provenance_state")? == provenance_state + && existing.try_get::, _>("source_record_id")? + == source_record_id + && existing.try_get::, _>("observation_id")? == observation_id + && existing.try_get::, _>("safe_reason_code")? + == safe_reason_code; + if !identical { + return Err(MirrorError::WindowMembershipConflict); + } + sqlx::query( + "UPDATE tally_snapshot_window_memberships SET last_seen_attempt_id = ?1 \ + WHERE batch_id = ?2 AND window_id = ?3 AND record_key = ?4", + ) + .bind(&attempt.attempt_id) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(&record_key) + .execute(&mut *transaction) + .await?; + result.replayed_memberships += 1; + } else { + sqlx::query( + "INSERT INTO tally_snapshot_window_memberships(\ + batch_id, window_id, record_key, canonical_sha256, canonical_payload_json, \ + exact_decimals_json, provenance_state, source_record_id, observation_id, \ + safe_reason_code, first_seen_attempt_id, last_seen_attempt_id\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?11)", + ) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(&record_key) + .bind(&canonical_sha256) + .bind(&canonical_payload_json) + .bind(&exact_decimals_json) + .bind(provenance_state) + .bind(&source_record_id) + .bind(&observation_id) + .bind(&safe_reason_code) + .bind(&attempt.attempt_id) + .execute(&mut *transaction) + .await?; + result.inserted_memberships += 1; + } + } + transaction.commit().await?; + Ok(result) + } + + pub async fn complete_snapshot_window_attempt( + &self, + attempt: &SnapshotWindowAttemptRef, + observed_completed_at_unix_ms: i64, + evidence: Value, + ) -> Result { + validate_snapshot_window_attempt_ref(attempt)?; + if observed_completed_at_unix_ms <= 0 { + return Err(MirrorError::InvalidInput("window_attempt_completed_at")); + } + let evidence_json = canonical_json(&evidence)?; + if evidence_json.len() > MAX_WINDOW_EVIDENCE_JSON_BYTES { + return Err(MirrorError::InvalidInput("window_attempt_evidence_size")); + } + let mut transaction = self.pool.begin().await?; + acquire_mirror_write_lock(&mut transaction).await?; + ensure_open_snapshot_window_attempt(&mut transaction, attempt).await?; + let started_at_unix_ms = sqlx::query_scalar::<_, i64>( + "SELECT started_at_unix_ms FROM tally_snapshot_window_attempts \ + WHERE id = ?1 AND batch_id = ?2 AND window_id = ?3 AND attempt_ordinal = ?4", + ) + .bind(&attempt.attempt_id) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(i64::from(attempt.attempt_ordinal)) + .fetch_one(&mut *transaction) + .await?; + let local_clock_moved_backwards = observed_completed_at_unix_ms < started_at_unix_ms; + let completed_at_unix_ms = observed_completed_at_unix_ms.max(started_at_unix_ms); + let disappeared = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_snapshot_window_memberships \ + WHERE batch_id = ?1 AND window_id = ?2 AND last_seen_attempt_id <> ?3", + ) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(&attempt.attempt_id) + .fetch_one(&mut *transaction) + .await?; + if disappeared != 0 { + return Err(MirrorError::WindowMembershipDisappeared); + } + let (member_count, membership_sha256) = Self::snapshot_window_membership_digest( + &mut transaction, + &attempt.batch_id, + &attempt.window_id, + Some(&attempt.attempt_id), + None, + ) + .await?; + let material = SnapshotWindowReceiptMaterial { + schema: "bridge.tally.snapshot-window-receipt/1", + attempt_id: &attempt.attempt_id, + batch_id: &attempt.batch_id, + window_id: &attempt.window_id, + attempt_ordinal: attempt.attempt_ordinal, + member_count, + membership_sha256: &membership_sha256, + evidence: &evidence, + completed_at_unix_ms, + }; + let receipt_sha256 = sha256_json(&material)?; + let receipt = SnapshotWindowReceipt { + schema: material.schema.to_string(), + attempt_id: attempt.attempt_id.clone(), + batch_id: attempt.batch_id.clone(), + window_id: attempt.window_id.clone(), + attempt_ordinal: attempt.attempt_ordinal, + member_count, + membership_sha256, + evidence, + completed_at_unix_ms, + receipt_sha256: receipt_sha256.clone(), + }; + let receipt_json = canonical_json(&serde_json::to_value(&receipt)?)?; + sqlx::query( + "UPDATE tally_snapshot_window_attempts \ + SET state = 'complete', completed_at_unix_ms = ?1, receipt_json = ?2, \ + receipt_sha256 = ?3, terminal_safe_reason_code = ?4 \ + WHERE id = ?5 AND batch_id = ?6 AND window_id = ?7", + ) + .bind(completed_at_unix_ms) + .bind(receipt_json) + .bind(receipt_sha256) + .bind(local_clock_moved_backwards.then_some("local_clock_moved_backwards")) + .bind(&attempt.attempt_id) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(SnapshotWindowCompletionResult { + receipt, + local_clock_moved_backwards, + }) + } + + pub async fn load_latest_completed_window_receipt( + &self, + batch_id: &str, + window_id: &str, + ) -> Result, MirrorError> { + validate_nonempty(batch_id, 128, "window_attempt_batch_id")?; + validate_nonempty(window_id, 128, "window_attempt_window_id")?; + let mut transaction = self.pool.begin().await?; + let row = sqlx::query( + "SELECT id, attempt_ordinal, receipt_json, receipt_sha256, terminal_safe_reason_code \ + FROM tally_snapshot_window_attempts \ + WHERE batch_id = ?1 AND window_id = ?2 AND state = 'complete' \ + ORDER BY attempt_ordinal DESC LIMIT 1", + ) + .bind(batch_id) + .bind(window_id) + .fetch_optional(&mut *transaction) + .await?; + let Some(row) = row else { + transaction.commit().await?; + return Ok(None); + }; + let attempt_id: String = row.try_get("id")?; + let attempt_ordinal = u32::try_from(row.try_get::("attempt_ordinal")?) + .map_err(|_| MirrorError::VerificationInvariant)?; + let receipt_json: String = row.try_get("receipt_json")?; + let stored_receipt_sha256: String = row.try_get("receipt_sha256")?; + let local_clock_moved_backwards = row + .try_get::, _>("terminal_safe_reason_code")? + .as_deref() + == Some("local_clock_moved_backwards"); + if receipt_json.len() > MAX_WINDOW_EVIDENCE_JSON_BYTES + 4_096 { + return Err(MirrorError::VerificationInvariant); + } + let receipt: SnapshotWindowReceipt = serde_json::from_str(&receipt_json)?; + if receipt.schema != "bridge.tally.snapshot-window-receipt/1" + || receipt.attempt_id != attempt_id + || receipt.batch_id != batch_id + || receipt.window_id != window_id + || receipt.attempt_ordinal != attempt_ordinal + || receipt.completed_at_unix_ms <= 0 + || receipt.membership_sha256.len() != 64 + || receipt.receipt_sha256.len() != 64 + || canonical_json(&receipt.evidence)?.len() > MAX_WINDOW_EVIDENCE_JSON_BYTES + { + return Err(MirrorError::VerificationInvariant); + } + validate_sha256(&receipt.membership_sha256) + .map_err(|_| MirrorError::VerificationInvariant)?; + validate_sha256(&receipt.receipt_sha256).map_err(|_| MirrorError::VerificationInvariant)?; + let computed = sha256_json(&SnapshotWindowReceiptMaterial { + schema: "bridge.tally.snapshot-window-receipt/1", + attempt_id: &receipt.attempt_id, + batch_id: &receipt.batch_id, + window_id: &receipt.window_id, + attempt_ordinal: receipt.attempt_ordinal, + member_count: receipt.member_count, + membership_sha256: &receipt.membership_sha256, + evidence: &receipt.evidence, + completed_at_unix_ms: receipt.completed_at_unix_ms, + })?; + if computed != receipt.receipt_sha256 || computed != stored_receipt_sha256 { + return Err(MirrorError::VerificationInvariant); + } + let (member_count, membership_sha256) = Self::snapshot_window_membership_digest( + &mut transaction, + batch_id, + window_id, + None, + Some(attempt_ordinal), + ) + .await?; + if member_count != receipt.member_count || membership_sha256 != receipt.membership_sha256 { + return Err(MirrorError::VerificationInvariant); + } + transaction.commit().await?; + Ok(Some(SnapshotWindowCompletionResult { + receipt, + local_clock_moved_backwards, + })) + } + + async fn snapshot_window_membership_digest( + transaction: &mut Transaction<'_, Sqlite>, + batch_id: &str, + window_id: &str, + last_seen_attempt_id: Option<&str>, + maximum_first_seen_ordinal: Option, + ) -> Result<(u32, String), MirrorError> { + if last_seen_attempt_id.is_some() == maximum_first_seen_ordinal.is_some() { + return Err(MirrorError::InvalidInput("window_membership_digest_scope")); + } + let mut digest = Sha256::new(); + digest.update(b"["); + let mut first = true; + let mut count = 0_u32; + let mut after_record_key = String::new(); + loop { + let rows = sqlx::query( + "SELECT membership.record_key, membership.canonical_sha256, \ + membership.provenance_state \ + FROM tally_snapshot_window_memberships AS membership \ + JOIN tally_snapshot_window_attempts AS first_attempt \ + ON first_attempt.id = membership.first_seen_attempt_id \ + AND first_attempt.batch_id = membership.batch_id \ + AND first_attempt.window_id = membership.window_id \ + WHERE membership.batch_id = ?1 AND membership.window_id = ?2 \ + AND ((?3 IS NOT NULL AND membership.last_seen_attempt_id = ?3) OR \ + (?4 IS NOT NULL AND first_attempt.attempt_ordinal <= ?4)) \ + AND membership.record_key > ?5 \ + ORDER BY membership.record_key LIMIT ?6", + ) + .bind(batch_id) + .bind(window_id) + .bind(last_seen_attempt_id) + .bind(maximum_first_seen_ordinal.map(i64::from)) + .bind(&after_record_key) + .bind(WINDOW_MEMBERSHIP_DIGEST_PAGE_SIZE) + .fetch_all(&mut **transaction) + .await?; + if rows.is_empty() { + break; + } + for row in rows { + let record_key: String = row.try_get("record_key")?; + let canonical_sha256: String = row.try_get("canonical_sha256")?; + let provenance_state: String = row.try_get("provenance_state")?; + if !first { + digest.update(b","); + } + first = false; + digest.update(serde_json::to_vec(&SnapshotWindowMembershipDigestEntry { + record_key: &record_key, + canonical_sha256: &canonical_sha256, + provenance_state: &provenance_state, + })?); + count = count + .checked_add(1) + .ok_or(MirrorError::InvalidInput("window_membership_count"))?; + after_record_key = record_key; + } + } + digest.update(b"]"); + Ok((count, hex_digest(digest.finalize()))) + } + + pub async fn load_completed_window_canonical_record_map( + &self, + attempt: &SnapshotWindowAttemptRef, + ) -> Result, MirrorError> { + validate_snapshot_window_attempt_ref(attempt)?; + let complete = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_snapshot_window_attempts \ + WHERE id = ?1 AND batch_id = ?2 AND window_id = ?3 \ + AND attempt_ordinal = ?4 AND state = 'complete'", + ) + .bind(&attempt.attempt_id) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(i64::from(attempt.attempt_ordinal)) + .fetch_one(&self.pool) + .await?; + if complete != 1 { + return Err(MirrorError::NotFound); + } + let rows = sqlx::query( + "SELECT membership.record_key, membership.canonical_sha256 \ + FROM tally_snapshot_window_memberships AS membership \ + JOIN tally_snapshot_window_attempts AS first_attempt \ + ON first_attempt.id = membership.first_seen_attempt_id \ + AND first_attempt.batch_id = membership.batch_id \ + AND first_attempt.window_id = membership.window_id \ + WHERE membership.batch_id = ?1 AND membership.window_id = ?2 \ + AND first_attempt.attempt_ordinal <= ?3 ORDER BY membership.record_key", + ) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(i64::from(attempt.attempt_ordinal)) + .fetch_all(&self.pool) + .await?; + rows.into_iter() + .map(|row| { + let key: String = row.try_get("record_key")?; + let canonical_sha256: String = row.try_get("canonical_sha256")?; + validate_sha256(&canonical_sha256) + .map_err(|_| MirrorError::VerificationInvariant)?; + Ok((key, canonical_sha256)) + }) + .collect() + } + + pub async fn commit_batch(&self, input: CommitBatchInput) -> Result { + let mut input = input.into_parts(); + input.gap_codes.sort(); + input.gap_codes.dedup(); + input.warning_codes.sort(); + input.warning_codes.dedup(); + if input.gap_codes.len() > 32 || input.warning_codes.len() > 32 { + return Err(MirrorError::InvalidInput("proof_code_count")); + } + if input.proof_contract_version == 0 || input.freshness_target_seconds <= 0 { + return Err(MirrorError::InvalidInput("proof_or_freshness_version")); + } + validate_optional_sha256(input.snapshot_sha256.as_deref())?; + validate_optional_sha256(input.record_counts_sha256.as_deref())?; + if (input.proof_contract_version >= 3) != input.record_counts_sha256.is_some() { + return Err(MirrorError::InvalidInput("proof_record_counts_digest")); + } + validate_optional_token(input.checkpoint_after.as_deref())?; + for code in input.gap_codes.iter().chain(&input.warning_codes) { + validate_safe_code(code)?; + } + + let mut transaction = self.pool.begin().await?; + // Acquire SQLite's write lock before reading the proof-chain head. + sqlx::query("UPDATE tally_schema_migrations SET version = version WHERE version = 4") + .execute(&mut *transaction) + .await?; + + let batch = sqlx::query( + "SELECT run_id, capability_snapshot_id, company_id, pack_id, started_at_unix_ms, state \ + FROM tally_observation_batches WHERE id = ?1", + ) + .bind(&input.batch_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(MirrorError::NotFound)?; + let state: String = batch.try_get("state")?; + if state != "staging" { + return Err(MirrorError::BatchClosed); + } + let started_at_unix_ms: i64 = batch.try_get("started_at_unix_ms")?; + if input.completed_at_unix_ms < started_at_unix_ms { + return Err(MirrorError::InvalidInput("batch_completed_at")); + } + + let counts = sqlx::query( + "SELECT \ + COALESCE(SUM(CASE WHEN validation_status = 'accepted' THEN 1 ELSE 0 END), 0) \ + AS accepted_records, \ + COALESCE(SUM(CASE WHEN validation_status = 'rejected' THEN 1 ELSE 0 END), 0) \ + AS rejected_records \ + FROM tally_record_observations WHERE batch_id = ?1", + ) + .bind(&input.batch_id) + .fetch_one(&mut *transaction) + .await?; + let accepted_records: i64 = counts.try_get("accepted_records")?; + let rejected_records: i64 = counts.try_get("rejected_records")?; + let provenance_unavailable_records = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_snapshot_window_memberships \ + WHERE batch_id = ?1 AND provenance_state = 'unavailable'", + ) + .bind(&input.batch_id) + .fetch_one(&mut *transaction) + .await?; + + if input.verification == VerificationState::Verified + && (input.outcome != RunOutcome::Completed + || rejected_records != 0 + || !input.gap_codes.is_empty() + || input.snapshot_sha256.is_none() + || input.checkpoint_after.is_none()) + { + return Err(MirrorError::VerificationInvariant); + } + if input.verification != VerificationState::Verified && input.checkpoint_after.is_some() { + return Err(MirrorError::VerificationInvariant); + } + + // Close every owner-bound attempt in the same transaction as the batch. This also + // catches the lost-ack window after attempt creation but before its durable state ref save. + sqlx::query( + "UPDATE tally_snapshot_window_attempts SET state = 'abandoned', \ + completed_at_unix_ms = MAX(started_at_unix_ms, ?1), \ + terminal_safe_reason_code = CASE WHEN started_at_unix_ms > ?1 \ + THEN 'local_clock_moved_backwards' ELSE NULL END \ + WHERE batch_id = ?2 AND state = 'open'", + ) + .bind(input.completed_at_unix_ms) + .bind(&input.batch_id) + .execute(&mut *transaction) + .await?; + + let run_id: String = batch.try_get("run_id")?; + let capability_snapshot_id: String = batch.try_get("capability_snapshot_id")?; + let company_id: String = batch.try_get("company_id")?; + let pack_id: String = batch.try_get("pack_id")?; + let current_checkpoint = sqlx::query_scalar::<_, String>( + "SELECT checkpoint_token FROM tally_checkpoints WHERE company_id = ?1 AND pack_id = ?2", + ) + .bind(&company_id) + .bind(&pack_id) + .fetch_optional(&mut *transaction) + .await?; + // Only a verified commit advances checkpoint authority and therefore needs a compare-and- + // swap against the current head. Non-advancing proofs retain the checkpoint observed at + // the start of their run, even if another run has advanced the live head meanwhile. This + // lets a losing verified run close its staging batch with a truthful terminal proof. + if input.verification == VerificationState::Verified + && current_checkpoint != input.expected_checkpoint_before + { + return Err(MirrorError::ConcurrentCheckpoint); + } + let checkpoint_before = input.expected_checkpoint_before.clone(); + + sqlx::query( + "UPDATE tally_observation_batches SET state = ?1, completed_at_unix_ms = ?2, \ + snapshot_sha256 = ?3, accepted_records = ?4, rejected_records = ?5, \ + provenance_unavailable_records = ?6 WHERE id = ?7", + ) + .bind(input.verification.batch_state()) + .bind(input.completed_at_unix_ms) + .bind(&input.snapshot_sha256) + .bind(accepted_records) + .bind(rejected_records) + .bind(provenance_unavailable_records) + .bind(&input.batch_id) + .execute(&mut *transaction) + .await?; + + let previous_entry_sha256 = sqlx::query_scalar::<_, String>( + "SELECT entry_sha256 FROM tally_proof_ledger ORDER BY sequence DESC LIMIT 1", + ) + .fetch_optional(&mut *transaction) + .await?; + let proof_id = Uuid::new_v4().to_string(); + // Proof creation is a local persistence event and cannot truthfully precede the run + // completion it seals, even when the wall clock moved backwards during the run. + let created_at_unix_ms = Utc::now() + .timestamp_millis() + .max(input.completed_at_unix_ms); + let hash_input = ProofHashInput { + proof_contract_version: input.proof_contract_version, + previous_entry_sha256: previous_entry_sha256.as_deref(), + proof_id: &proof_id, + run_id: &run_id, + batch_id: &input.batch_id, + capability_snapshot_id: &capability_snapshot_id, + company_id: &company_id, + pack_id: &pack_id, + outcome: input.outcome, + verification: input.verification, + started_at_unix_ms, + completed_at_unix_ms: input.completed_at_unix_ms, + accepted_records, + rejected_records, + provenance_unavailable_records: (input.proof_contract_version >= 2) + .then_some(provenance_unavailable_records), + record_counts_sha256: input.record_counts_sha256.as_deref(), + snapshot_sha256: input.snapshot_sha256.as_deref(), + checkpoint_before: checkpoint_before.as_deref(), + checkpoint_after: input.checkpoint_after.as_deref(), + gap_codes: &input.gap_codes, + warning_codes: &input.warning_codes, + created_at_unix_ms, + }; + let proof_sha256 = sha256_json(&hash_input)?; + let gap_codes_json = serde_json::to_string(&input.gap_codes)?; + let warning_codes_json = serde_json::to_string(&input.warning_codes)?; + + sqlx::query( + "INSERT INTO tally_proof_ledger(\ + id, proof_contract_version, previous_entry_sha256, entry_sha256, run_id, batch_id, \ + capability_snapshot_id, company_id, pack_id, outcome, verification_state, \ + started_at_unix_ms, completed_at_unix_ms, accepted_records, rejected_records, \ + provenance_unavailable_records, record_counts_sha256, snapshot_sha256, checkpoint_before, \ + checkpoint_after, gap_codes_json, warning_codes_json, created_at_unix_ms\ + ) VALUES (\ + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, \ + ?17, ?18, ?19, ?20, ?21, ?22, ?23\ + )", + ) + .bind(&proof_id) + .bind(i64::from(input.proof_contract_version)) + .bind(&previous_entry_sha256) + .bind(&proof_sha256) + .bind(&run_id) + .bind(&input.batch_id) + .bind(&capability_snapshot_id) + .bind(&company_id) + .bind(&pack_id) + .bind(input.outcome.as_str()) + .bind(input.verification.as_str()) + .bind(started_at_unix_ms) + .bind(input.completed_at_unix_ms) + .bind(accepted_records) + .bind(rejected_records) + .bind(provenance_unavailable_records) + .bind(&input.record_counts_sha256) + .bind(&input.snapshot_sha256) + .bind(&checkpoint_before) + .bind(&input.checkpoint_after) + .bind(gap_codes_json) + .bind(warning_codes_json) + .bind(created_at_unix_ms) + .execute(&mut *transaction) + .await?; + + let checkpoint_advanced = input.verification == VerificationState::Verified; + if checkpoint_advanced { + sqlx::query( + "INSERT INTO tally_checkpoints(\ + company_id, pack_id, checkpoint_token, run_id, proof_id, snapshot_sha256, \ + verified_at_unix_ms, freshness_target_seconds, generation\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1) \ + ON CONFLICT(company_id, pack_id) DO UPDATE SET \ + checkpoint_token = excluded.checkpoint_token, run_id = excluded.run_id, \ + proof_id = excluded.proof_id, snapshot_sha256 = excluded.snapshot_sha256, \ + verified_at_unix_ms = excluded.verified_at_unix_ms, \ + freshness_target_seconds = excluded.freshness_target_seconds, \ + generation = tally_checkpoints.generation + 1", + ) + .bind(&company_id) + .bind(&pack_id) + .bind( + input + .checkpoint_after + .as_deref() + .expect("verified checkpoint"), + ) + .bind(&run_id) + .bind(&proof_id) + .bind( + input + .snapshot_sha256 + .as_deref() + .expect("verified snapshot hash"), + ) + .bind(input.completed_at_unix_ms) + .bind(input.freshness_target_seconds) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + Ok(CommitResult { + proof_id, + proof_sha256, + checkpoint_advanced, + facts: CommitReceiptFacts { + proof_contract_version: input.proof_contract_version, + run_id, + batch_id: input.batch_id, + capability_snapshot_id, + company_id, + pack_id, + outcome: input.outcome, + verification: input.verification, + started_at_unix_ms, + completed_at_unix_ms: input.completed_at_unix_ms, + accepted_records, + rejected_records, + provenance_unavailable_records, + record_counts_sha256: input.record_counts_sha256, + snapshot_sha256: input.snapshot_sha256, + checkpoint_before, + checkpoint_after: input.checkpoint_after, + gap_codes: input.gap_codes, + warning_codes: input.warning_codes, + }, + }) + } + + pub async fn batch_observation_counts( + &self, + batch_id: &str, + run_id: &str, + ) -> Result { + validate_nonempty(batch_id, 128, "batch_id")?; + validate_nonempty(run_id, 128, "run_id")?; + let row = sqlx::query( + "SELECT \ + COUNT(DISTINCT batch.id) AS batch_count, \ + COALESCE(SUM(CASE WHEN observation.validation_status = 'accepted' THEN 1 ELSE 0 END), 0) \ + AS accepted_records, \ + COALESCE(SUM(CASE WHEN observation.validation_status = 'rejected' THEN 1 ELSE 0 END), 0) \ + AS rejected_records \ + FROM tally_observation_batches AS batch \ + LEFT JOIN tally_record_observations AS observation ON observation.batch_id = batch.id \ + WHERE batch.id = ?1 AND batch.run_id = ?2", + ) + .bind(batch_id) + .bind(run_id) + .fetch_one(&self.pool) + .await?; + if row.try_get::("batch_count")? != 1 { + return Err(MirrorError::NotFound); + } + Ok(ObservationCounts { + accepted_records: row.try_get("accepted_records")?, + rejected_records: row.try_get("rejected_records")?, + provenance_unavailable_records: sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_snapshot_window_memberships \ + WHERE batch_id = ?1 AND provenance_state = 'unavailable'", + ) + .bind(batch_id) + .fetch_one(&self.pool) + .await?, + }) + } + + pub async fn freshness( + &self, + company_id: &str, + pack_id: &str, + now_unix_ms: i64, + ) -> Result { + let checkpoint = sqlx::query( + "SELECT checkpoint_token, proof_id, verified_at_unix_ms, freshness_target_seconds \ + FROM tally_checkpoints WHERE company_id = ?1 AND pack_id = ?2", + ) + .bind(company_id) + .bind(pack_id) + .fetch_optional(&self.pool) + .await?; + + let Some(checkpoint) = checkpoint else { + return Ok(FreshnessStatus { + state: FreshnessState::NeverVerified, + verified_at_unix_ms: None, + age_seconds: None, + checkpoint_token: None, + proof_id: None, + }); + }; + let verified_at_unix_ms: i64 = checkpoint.try_get("verified_at_unix_ms")?; + let target_seconds: i64 = checkpoint.try_get("freshness_target_seconds")?; + let clock_moved_backwards = now_unix_ms < verified_at_unix_ms; + let age_seconds = now_unix_ms.saturating_sub(verified_at_unix_ms).max(0) / 1_000; + Ok(FreshnessStatus { + state: if !clock_moved_backwards && age_seconds <= target_seconds { + FreshnessState::Fresh + } else { + FreshnessState::Stale + }, + verified_at_unix_ms: Some(verified_at_unix_ms), + age_seconds: Some(age_seconds), + checkpoint_token: Some(checkpoint.try_get("checkpoint_token")?), + proof_id: Some(checkpoint.try_get("proof_id")?), + }) + } + + pub async fn commit_receipt_for_batch( + &self, + batch_id: &str, + run_id: &str, + ) -> Result { + self.proof_receipt_for_batch(batch_id, run_id, true).await + } + + pub(crate) async fn historical_commit_receipt_for_batch( + &self, + batch_id: &str, + run_id: &str, + ) -> Result { + self.proof_receipt_for_batch(batch_id, run_id, false).await + } + + async fn proof_receipt_for_batch( + &self, + batch_id: &str, + run_id: &str, + require_current_checkpoint: bool, + ) -> Result { + validate_nonempty(batch_id, 128, "batch_id")?; + validate_nonempty(run_id, 128, "run_id")?; + let row = sqlx::query( + "SELECT sequence, id, proof_contract_version, previous_entry_sha256, entry_sha256, \ + run_id, batch_id, capability_snapshot_id, company_id, pack_id, outcome, \ + verification_state, started_at_unix_ms, completed_at_unix_ms, accepted_records, \ + rejected_records, provenance_unavailable_records, record_counts_sha256, snapshot_sha256, \ + checkpoint_before, checkpoint_after, gap_codes_json, warning_codes_json, \ + created_at_unix_ms \ + FROM tally_proof_ledger WHERE batch_id = ?1 AND run_id = ?2", + ) + .bind(batch_id) + .bind(run_id) + .fetch_optional(&self.pool) + .await? + .ok_or(MirrorError::NotFound)?; + + let sequence: i64 = row.try_get("sequence")?; + let proof_id: String = row.try_get("id")?; + let proof_contract_version: i64 = row.try_get("proof_contract_version")?; + let proof_contract_version = u16::try_from(proof_contract_version) + .map_err(|_| MirrorError::InvalidInput("proof_contract_version"))?; + let previous_entry_sha256: Option = row.try_get("previous_entry_sha256")?; + let entry_sha256: String = row.try_get("entry_sha256")?; + let stored_run_id: String = row.try_get("run_id")?; + let stored_batch_id: String = row.try_get("batch_id")?; + let capability_snapshot_id: String = row.try_get("capability_snapshot_id")?; + let company_id: String = row.try_get("company_id")?; + let pack_id: String = row.try_get("pack_id")?; + let outcome_text: String = row.try_get("outcome")?; + let verification_text: String = row.try_get("verification_state")?; + let started_at_unix_ms: i64 = row.try_get("started_at_unix_ms")?; + let completed_at_unix_ms: i64 = row.try_get("completed_at_unix_ms")?; + let accepted_records: i64 = row.try_get("accepted_records")?; + let rejected_records: i64 = row.try_get("rejected_records")?; + let provenance_unavailable_records: i64 = row.try_get("provenance_unavailable_records")?; + let record_counts_sha256: Option = row.try_get("record_counts_sha256")?; + let snapshot_sha256: Option = row.try_get("snapshot_sha256")?; + let checkpoint_before: Option = row.try_get("checkpoint_before")?; + let checkpoint_after: Option = row.try_get("checkpoint_after")?; + let gap_codes_json: String = row.try_get("gap_codes_json")?; + let warning_codes_json: String = row.try_get("warning_codes_json")?; + let created_at_unix_ms: i64 = row.try_get("created_at_unix_ms")?; + let gap_codes: Vec = serde_json::from_str(&gap_codes_json)?; + let warning_codes: Vec = serde_json::from_str(&warning_codes_json)?; + if gap_codes.len() > 32 || warning_codes.len() > 32 { + return Err(MirrorError::VerificationInvariant); + } + for code in gap_codes.iter().chain(&warning_codes) { + validate_safe_code(code).map_err(|_| MirrorError::VerificationInvariant)?; + } + let outcome = parse_run_outcome(&outcome_text)?; + let verification = parse_verification_state(&verification_text)?; + if (proof_contract_version >= 3) != record_counts_sha256.is_some() + || record_counts_sha256 + .as_deref() + .is_some_and(|digest| validate_sha256(digest).is_err()) + { + return Err(MirrorError::VerificationInvariant); + } + + let expected_previous = sqlx::query_scalar::<_, String>( + "SELECT entry_sha256 FROM tally_proof_ledger WHERE sequence < ?1 \ + ORDER BY sequence DESC LIMIT 1", + ) + .bind(sequence) + .fetch_optional(&self.pool) + .await?; + if expected_previous != previous_entry_sha256 { + return Err(MirrorError::VerificationInvariant); + } + let expected_hash = sha256_json(&ProofHashInput { + proof_contract_version, + previous_entry_sha256: previous_entry_sha256.as_deref(), + proof_id: &proof_id, + run_id: &stored_run_id, + batch_id: &stored_batch_id, + capability_snapshot_id: &capability_snapshot_id, + company_id: &company_id, + pack_id: &pack_id, + outcome, + verification, + started_at_unix_ms, + completed_at_unix_ms, + accepted_records, + rejected_records, + provenance_unavailable_records: (proof_contract_version >= 2) + .then_some(provenance_unavailable_records), + record_counts_sha256: record_counts_sha256.as_deref(), + snapshot_sha256: snapshot_sha256.as_deref(), + checkpoint_before: checkpoint_before.as_deref(), + checkpoint_after: checkpoint_after.as_deref(), + gap_codes: &gap_codes, + warning_codes: &warning_codes, + created_at_unix_ms, + })?; + if stored_run_id != run_id + || stored_batch_id != batch_id + || validate_sha256(&entry_sha256).is_err() + || expected_hash != entry_sha256 + { + return Err(MirrorError::VerificationInvariant); + } + + let checkpoint_advanced = if verification == VerificationState::Verified { + let checkpoint_after = checkpoint_after + .as_deref() + .ok_or(MirrorError::VerificationInvariant)?; + if require_current_checkpoint { + let matches = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_checkpoints WHERE proof_id = ?1 AND run_id = ?2 \ + AND checkpoint_token = ?3", + ) + .bind(&proof_id) + .bind(run_id) + .bind(checkpoint_after) + .fetch_one(&self.pool) + .await?; + if matches != 1 { + return Err(MirrorError::VerificationInvariant); + } + } + true + } else { + if checkpoint_after.is_some() { + return Err(MirrorError::VerificationInvariant); + } + false + }; + Ok(CommitResult { + proof_id, + proof_sha256: entry_sha256, + checkpoint_advanced, + facts: CommitReceiptFacts { + proof_contract_version, + run_id: stored_run_id, + batch_id: stored_batch_id, + capability_snapshot_id, + company_id, + pack_id, + outcome, + verification, + started_at_unix_ms, + completed_at_unix_ms, + accepted_records, + rejected_records, + provenance_unavailable_records, + record_counts_sha256, + snapshot_sha256, + checkpoint_before, + checkpoint_after, + gap_codes, + warning_codes, + }, + }) + } + + /// Returns immutable, hash-addressed proof summaries newest first. The payload deliberately + /// excludes source data and display names so operator diagnostics cannot become a data leak. + /// The run ID remains available as a safe local support correlation token. + pub async fn latest_proofs( + &self, + company_id: &str, + limit: u32, + ) -> Result, MirrorError> { + if company_id.trim().is_empty() || limit == 0 || limit > 100 { + return Err(MirrorError::InvalidInput("proof_query")); + } + let rows = sqlx::query( + "SELECT run_id, batch_id FROM tally_proof_ledger \ + WHERE company_id = ?1 ORDER BY sequence DESC LIMIT ?2", + ) + .bind(company_id) + .bind(i64::from(limit)) + .fetch_all(&self.pool) + .await?; + + let mut summaries = Vec::with_capacity(rows.len()); + for row in rows { + let run_id: String = row.try_get("run_id")?; + let batch_id: String = row.try_get("batch_id")?; + let receipt = self + .historical_commit_receipt_for_batch(&batch_id, &run_id) + .await?; + if receipt.facts.company_id != company_id { + return Err(MirrorError::VerificationInvariant); + } + let facts = receipt.facts; + summaries.push(ProofSummary { + integrity_state: "entry_hash_valid", + run_id: facts.run_id.clone(), + selection_token: receipt.proof_id, + proof_sha256: receipt.proof_sha256, + pack_id: facts.pack_id, + outcome: facts.outcome.as_str().to_string(), + verification_state: facts.verification.as_str().to_string(), + started_at_unix_ms: facts.started_at_unix_ms, + completed_at_unix_ms: Some(facts.completed_at_unix_ms), + accepted_records: facts.accepted_records, + rejected_records: facts.rejected_records, + provenance_unavailable_records: facts.provenance_unavailable_records, + gap_codes: facts.gap_codes, + warning_codes: facts.warning_codes, + }); + } + Ok(summaries) + } + + /// Builds an allow-list-only support artifact after revalidating the local + /// proof chain and restart-safe snapshot receipt. It deliberately omits + /// names, source identities, internal IDs, endpoints, checkpoints, source + /// records, amounts, payloads, and drill-down hashes. + pub async fn redacted_proof_export( + &self, + company_id: &str, + proof_id: &str, + exported_at_unix_ms: i64, + ) -> Result { + validate_nonempty(company_id, 128, "company_id")?; + validate_nonempty(proof_id, 128, "proof_id")?; + let selected = sqlx::query( + "SELECT sequence, run_id, batch_id FROM tally_proof_ledger \ + WHERE id = ?1 AND company_id = ?2", + ) + .bind(proof_id) + .bind(company_id) + .fetch_optional(&self.pool) + .await? + .ok_or(MirrorError::NotFound)?; + let selected_sequence: i64 = selected.try_get("sequence")?; + let run_id: String = selected.try_get("run_id")?; + let batch_id: String = selected.try_get("batch_id")?; + + let mut last_sequence = 0_i64; + while last_sequence < selected_sequence { + let chain_rows = sqlx::query( + "SELECT sequence, run_id, batch_id FROM tally_proof_ledger \ + WHERE sequence > ?1 AND sequence <= ?2 ORDER BY sequence ASC LIMIT 250", + ) + .bind(last_sequence) + .bind(selected_sequence) + .fetch_all(&self.pool) + .await?; + if chain_rows.is_empty() { + return Err(MirrorError::VerificationInvariant); + } + for row in chain_rows { + let sequence: i64 = row.try_get("sequence")?; + let chain_run_id: String = row.try_get("run_id")?; + let chain_batch_id: String = row.try_get("batch_id")?; + self.historical_commit_receipt_for_batch(&chain_batch_id, &chain_run_id) + .await?; + last_sequence = sequence; + } + } + + let receipt = self + .historical_commit_receipt_for_batch(&batch_id, &run_id) + .await?; + if receipt.proof_id != proof_id || receipt.facts.company_id != company_id { + return Err(MirrorError::VerificationInvariant); + } + let counts = self.batch_observation_counts(&batch_id, &run_id).await?; + if counts.accepted_records != receipt.facts.accepted_records + || counts.rejected_records != receipt.facts.rejected_records + || counts.provenance_unavailable_records != receipt.facts.provenance_unavailable_records + { + return Err(MirrorError::VerificationInvariant); + } + + let state = crate::sync::snapshot::SqliteSnapshotStateStore::new(self.pool.clone()) + .load_by_run_id(&run_id) + .await + .map_err(|_| MirrorError::VerificationInvariant)? + .ok_or(MirrorError::VerificationInvariant)?; + let stored_receipt = state + .commit_receipt + .as_ref() + .ok_or(MirrorError::VerificationInvariant)?; + let proof = state + .proof + .as_ref() + .ok_or(MirrorError::VerificationInvariant)?; + let plan = state + .plan + .as_ref() + .ok_or(MirrorError::VerificationInvariant)?; + let mut proof_gap_codes = proof + .gaps + .iter() + .map(|gap| gap.safe_reason_code.clone()) + .collect::>(); + proof_gap_codes.sort(); + proof_gap_codes.dedup(); + let mut receipt_gap_codes = receipt.facts.gap_codes.clone(); + receipt_gap_codes.sort(); + receipt_gap_codes.dedup(); + if state.batch_id.as_deref() != Some(batch_id.as_str()) + || stored_receipt.proof_id.as_deref() != Some(proof_id) + || stored_receipt.proof_sha256.as_deref() != Some(receipt.proof_sha256.as_str()) + || stored_receipt.checkpoint_advanced != receipt.checkpoint_advanced + || proof.run_id != run_id + || proof.proof_contract_version != receipt.facts.proof_contract_version + || proof.started_at_unix_ms != receipt.facts.started_at_unix_ms + || proof.completed_at_unix_ms != Some(receipt.facts.completed_at_unix_ms) + || plan.pack != proof.pack + || plan.pack_schema_version != proof.pack_schema_version + || proof.snapshot_sha256 != receipt.facts.snapshot_sha256 + || !proof_outcome_matches(proof.outcome, receipt.facts.outcome) + || !proof_verification_matches(proof.verification, receipt.facts.verification) + || proof_gap_codes != receipt_gap_codes + || crate::sync::snapshot::pack_code(proof.pack) != receipt.facts.pack_id + { + return Err(MirrorError::VerificationInvariant); + } + + let freshness = self + .freshness(company_id, &receipt.facts.pack_id, exported_at_unix_ms) + .await?; + for code in receipt + .facts + .gap_codes + .iter() + .chain(&receipt.facts.warning_codes) + { + validate_export_code(code)?; + } + let freshness_state = match freshness.state { + FreshnessState::Fresh => "fresh", + FreshnessState::Stale => "stale", + FreshnessState::NeverVerified => "never_verified", + }; + let payload = RedactedProofPayload { + schema: "bridge.tally.redacted-proof-of-sync", + schema_version: 1, + exported_at_unix_ms, + redaction_profile: "public_support_v1", + subject: RedactedSubject { + reference: "company-1", + identity_disclosed: false, + }, + proofs: vec![RedactedProofEntry { + entry_index: 1, + proof_contract_version: receipt.facts.proof_contract_version, + pack_id: receipt.facts.pack_id.clone(), + pack_schema_version: plan.pack_schema_version, + outcome: receipt.facts.outcome.as_str().to_string(), + verification_state: receipt.facts.verification.as_str().to_string(), + started_at_unix_ms: receipt.facts.started_at_unix_ms, + completed_at_unix_ms: receipt.facts.completed_at_unix_ms, + counts: RedactedCounts { + provenance_backed_accepted_records: receipt.facts.accepted_records, + provenance_unavailable_records: receipt.facts.provenance_unavailable_records, + rejected_records: receipt.facts.rejected_records, + }, + gaps: receipt.facts.gap_codes.clone(), + warnings: receipt.facts.warning_codes.clone(), + local_ledger: RedactedLedgerEvidence { + chain_validation: "valid_at_export", + }, + }], + current_status: RedactedCurrentStatus { + freshness_state, + verified_at_unix_ms: freshness.verified_at_unix_ms, + checkpoint_present: freshness.checkpoint_token.is_some(), + }, + }; + finish_redacted_export(payload) + } + + /// Returns local-session aliases for bounded mismatch drill-down. Stored + /// source-derived tokens never cross this API and are not included in the + /// public support export. + pub async fn local_reconciliation_mismatches( + &self, + company_id: &str, + proof_id: &str, + now_unix_ms: i64, + ) -> Result, MirrorError> { + self.redacted_proof_export(company_id, proof_id, now_unix_ms) + .await?; + let run_id = sqlx::query_scalar::<_, String>( + "SELECT run_id FROM tally_proof_ledger WHERE id = ?1 AND company_id = ?2", + ) + .bind(proof_id) + .bind(company_id) + .fetch_optional(&self.pool) + .await? + .ok_or(MirrorError::NotFound)?; + let state = crate::sync::snapshot::SqliteSnapshotStateStore::new(self.pool.clone()) + .load_by_run_id(&run_id) + .await + .map_err(|_| MirrorError::VerificationInvariant)? + .ok_or(MirrorError::VerificationInvariant)?; + let mut internal = state + .windows + .values() + .filter_map(|window| window.evidence.as_ref()) + .flat_map(|evidence| evidence.mismatches.iter()) + .map(|mismatch| { + ( + mismatch.safe_reason_code.clone(), + mismatch.safe_record_ids.clone(), + ) + }) + .collect::>(); + internal.sort(); + internal.dedup(); + internal.truncate(32); + let mut aliases = BTreeMap::::new(); + let mut result = Vec::with_capacity(internal.len()); + for (reason_code, tokens) in internal { + validate_safe_code(&reason_code)?; + let mut record_aliases = Vec::new(); + for token in tokens.into_iter().take(20) { + let next = aliases.len() + 1; + let alias = aliases + .entry(token) + .or_insert_with(|| format!("local-record-{next:04}")) + .clone(); + record_aliases.push(alias); + } + record_aliases.sort(); + record_aliases.dedup(); + result.push(LocalReconciliationMismatch { + reason_code, + record_aliases, + }); + } + Ok(result) + } +} + +/// Returns an opaque, stable join key for the same observed company at the same endpoint. +/// The key deliberately does not expose the locally persisted Tally GUID. +pub(crate) fn company_profile_correlation_key( + canonical_origin: &str, + company_guid: &str, +) -> String { + let mut digest = Sha256::new(); + digest.update(b"bridge.tally.company-profile-correlation/1\0"); + digest.update(canonical_origin.as_bytes()); + digest.update(b"\0"); + digest.update(company_guid.to_ascii_lowercase().as_bytes()); + hex_digest(digest.finalize()) +} + +fn finish_redacted_export( + payload: RedactedProofPayload, +) -> Result { + let payload_bytes = serde_json::to_vec(&payload)?; + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-redacted-proof-v1\0"); + digest.update((payload_bytes.len() as u64).to_be_bytes()); + digest.update(&payload_bytes); + let payload_sha256 = hex_digest(digest.finalize()); + let document = RedactedProofDocument { + payload, + integrity: RedactedIntegrity { + canonicalization: "serde-struct-order-v1", + hash_algorithm: "sha-256", + domain: "bridge-tally-redacted-proof-v1", + payload_sha256: payload_sha256.clone(), + signature: None, + integrity_claim: "checksum_only", + authenticity_claim: "none", + }, + }; + let json = serde_json::to_string_pretty(&document)?; + if json.len() > 256 * 1024 { + return Err(MirrorError::InvalidInput("proof_export_too_large")); + } + Ok(RedactedProofExport { + json, + payload_sha256, + }) +} + +fn proof_outcome_matches(proof: bridge_tally_core::RunOutcome, receipt: RunOutcome) -> bool { + matches!( + (proof, receipt), + ( + bridge_tally_core::RunOutcome::Completed, + RunOutcome::Completed + ) | (bridge_tally_core::RunOutcome::Failed, RunOutcome::Failed) + | ( + bridge_tally_core::RunOutcome::Cancelled, + RunOutcome::Cancelled + ) + | ( + bridge_tally_core::RunOutcome::OutcomeUnknown, + RunOutcome::OutcomeUnknown + ) + ) +} + +fn proof_verification_matches( + proof: bridge_tally_core::VerificationState, + receipt: VerificationState, +) -> bool { + matches!( + (proof, receipt), + ( + bridge_tally_core::VerificationState::Verified, + VerificationState::Verified + ) | ( + bridge_tally_core::VerificationState::Partial, + VerificationState::Partial + ) | ( + bridge_tally_core::VerificationState::Unverified, + VerificationState::Unverified + ) + ) +} + +fn validate_export_code(value: &str) -> Result<(), MirrorError> { + let allowed = REVIEWED_TALLY_TERMINAL_CODES.contains(&value) + || matches!( + value, + "accept_dedupe_count_mismatch" + | "accounting_reconciliation_unavailable" + | "capability_not_supported" + | "capability_not_verified" + | "capability_profile_changed" + | "capability_profile_drift_check_unavailable" + | "capability_profile_changed_during_run" + | "company_identity_ambiguous" + | "company_identity_not_found" + | "complete_source_count_disagreement" + | "duplicate_record_across_windows" + | "duplicate_source_identity" + | "missing_snapshot_window" + | "missing_source_identity" + | "parse_accept_count_mismatch" + | "record_evidence_mismatch" + | "record_provenance_unavailable" + | "reconciliation_mismatch" + | "rejected_snapshot_records" + | "report_tie_out_unavailable" + | "report_tie_out_mismatch" + | "report_tie_out_evidence_invalid" + | "period_report_profile_unobserved" + | "response_date_outside_window" + | "response_pack_mismatch" + | "response_parse_failed" + | "response_validation_failed" + | "run_cancelled" + | "source_accepted_count_mismatch" + | "source_changed_during_resume" + | "source_changed_during_snapshot" + | "source_count_evidence_invalid" + | "source_count_scope_mismatch" + | "source_count_unavailable" + | "source_count_window_only" + | "source_cut_consistency_unavailable" + | "source_cut_atomicity_unavailable" + | "source_changed_during_run" + | "source_outcome_unknown" + | "tally_protocol_failed" + | "tally_unreachable" + | "typed_pack_validation_failed" + | "voucher_entry_applicability_unavailable" + | "voucher_entry_polarity_unavailable" + | "voucher_header_entry_total_unavailable" + | "window_source_accepted_count_mismatch" + ); + if allowed { + Ok(()) + } else { + Err(MirrorError::VerificationInvariant) + } +} + +#[derive(Debug)] +struct IdentityRow { + id: String, + guid: Option, + remote_id: Option, + master_id: Option, + fallback_fingerprint: Option, +} + +async fn find_identity_matches( + transaction: &mut Transaction<'_, Sqlite>, + table: &'static str, + owner_column: &'static str, + owner_id: &str, + object_type: Option<&str>, + identity: &SourceIdentityInput, +) -> Result, MirrorError> { + let rows = match (table, owner_column) { + ("tally_companies", "endpoint_id") => { + sqlx::query( + "SELECT id, company_guid AS guid, remote_id, master_id, fallback_fingerprint \ + FROM tally_companies WHERE endpoint_id = ?1 AND (\ + (?2 IS NOT NULL AND company_guid = ?2 COLLATE NOCASE) OR \ + (?3 IS NOT NULL AND remote_id = ?3) OR \ + (?4 IS NOT NULL AND master_id = ?4) OR \ + (?5 IS NOT NULL AND fallback_fingerprint = ?5)\ + )", + ) + .bind(owner_id) + .bind(identity.guid.as_deref()) + .bind(identity.remote_id.as_deref()) + .bind(identity.master_id.as_deref()) + .bind(identity.fallback_fingerprint.as_deref()) + .fetch_all(&mut **transaction) + .await? + } + ("tally_source_records", "company_id") => { + sqlx::query( + "SELECT id, source_guid AS guid, remote_id, master_id, fallback_fingerprint \ + FROM tally_source_records WHERE company_id = ?1 AND object_type = ?2 AND (\ + (?3 IS NOT NULL AND source_guid = ?3) OR \ + (?4 IS NOT NULL AND remote_id = ?4) OR \ + (?5 IS NOT NULL AND master_id = ?5) OR \ + (?6 IS NOT NULL AND fallback_fingerprint = ?6)\ + )", + ) + .bind(owner_id) + .bind(object_type.ok_or(MirrorError::InvalidInput("object_type"))?) + .bind(identity.guid.as_deref()) + .bind(identity.remote_id.as_deref()) + .bind(identity.master_id.as_deref()) + .bind(identity.fallback_fingerprint.as_deref()) + .fetch_all(&mut **transaction) + .await? + } + _ => return Err(MirrorError::InvalidInput("identity_query_scope")), + }; + + rows.into_iter() + .map(|row| { + Ok(IdentityRow { + id: row.try_get("id")?, + guid: row.try_get("guid")?, + remote_id: row.try_get("remote_id")?, + master_id: row.try_get("master_id")?, + fallback_fingerprint: row.try_get("fallback_fingerprint")?, + }) + }) + .collect() +} + +fn unique_match(mut matches: Vec) -> Result, MirrorError> { + matches.sort_by(|left, right| left.id.cmp(&right.id)); + matches.dedup_by(|left, right| left.id == right.id); + if matches.len() > 1 { + return Err(MirrorError::IdentityCollision); + } + Ok(matches.pop()) +} + +fn ensure_no_silent_identity_change( + existing: &IdentityRow, + incoming: &SourceIdentityInput, +) -> Result<(), MirrorError> { + match (existing.guid.as_deref(), incoming.guid.as_deref()) { + (Some(left), Some(right)) if !left.eq_ignore_ascii_case(right) => { + return Err(MirrorError::IdentityCollision); + } + (None, Some(_)) => return Err(MirrorError::IdentityUpgradeRequiresAudit), + _ => {} + } + for (stored, observed) in [ + (&existing.remote_id, &incoming.remote_id), + (&existing.master_id, &incoming.master_id), + ( + &existing.fallback_fingerprint, + &incoming.fallback_fingerprint, + ), + ] { + match (stored.as_deref(), observed.as_deref()) { + (Some(left), Some(right)) if left != right => { + return Err(MirrorError::IdentityCollision); + } + (None, Some(_)) => return Err(MirrorError::IdentityUpgradeRequiresAudit), + _ => {} + } + } + Ok(()) +} + +fn identity_confidence(identity: &SourceIdentityInput) -> Confidence { + identity.confidence.unwrap_or( + if identity.fallback_fingerprint.is_some() + && identity.guid.is_none() + && identity.remote_id.is_none() + && identity.master_id.is_none() + { + Confidence::Inferred + } else { + Confidence::Observed + }, + ) +} + +fn validate_capability_snapshot(input: &CapabilitySnapshotInput) -> Result<(), MirrorError> { + validate_nonempty(&input.canonical_origin, 512, "canonical_origin")?; + validate_nonempty(&input.product, 128, "product")?; + if input.profile_version == 0 { + return Err(MirrorError::InvalidInput("profile_version")); + } + validate_optional_text(input.release.as_deref(), 128, "release")?; + validate_optional_text(input.mode.as_deref(), 64, "mode")?; + for item in &input.items { + validate_safe_code(&item.key)?; + validate_optional_safe_code(item.safe_reason_code.as_deref())?; + } + Ok(()) +} + +fn reviewed_setup_payload_sha256(input: &ReviewedSetupInput) -> Result { + let mut items = input + .capability + .items + .iter() + .map(|item| { + serde_json::json!({ + "kind": item.kind.as_str(), + "key": item.key, + "state": item.state.as_str(), + "confidence": item.confidence.as_str(), + "safe_reason_code": item.safe_reason_code, + }) + }) + .collect::>(); + items.sort_by(|left, right| { + left["kind"] + .as_str() + .unwrap_or_default() + .cmp(right["kind"].as_str().unwrap_or_default()) + .then_with(|| { + left["key"] + .as_str() + .unwrap_or_default() + .cmp(right["key"].as_str().unwrap_or_default()) + }) + }); + let selected_read_scope = input.selected_read_scope.as_ref().map(|scope| { + let mut observations = scope + .observations + .iter() + .map(|observation| { + serde_json::json!({ + "capability_key": observation.capability_key, + "state": observation.state.as_str(), + "confidence": observation.confidence.as_str(), + "safe_reason_code": observation.safe_reason_code, + "result_bucket": observation.result_bucket, + "request_sha256": observation.request_sha256, + "decoded_response_sha256": observation.decoded_response_sha256, + "response_encoding": observation.response_encoding, + "company_context_verified": observation.company_context_verified, + "schema_verified": observation.schema_verified, + "record_count_verified": observation.record_count_verified, + "identity_evidence_state": observation.identity_evidence_state, + "date_window_verified": observation.date_window_verified, + }) + }) + .collect::>(); + observations.sort_by(|left, right| { + left["capability_key"] + .as_str() + .unwrap_or_default() + .cmp(right["capability_key"].as_str().unwrap_or_default()) + }); + serde_json::json!({ + "scope_commitment_sha256": scope.scope_commitment_sha256, + "parent_review_sha256": scope.parent_review_sha256, + "ledger_profile_id": scope.ledger_profile_id, + "voucher_profile_id": scope.voucher_profile_id, + "voucher_from_yyyymmdd": scope.voucher_from_yyyymmdd, + "voucher_to_yyyymmdd": scope.voucher_to_yyyymmdd, + "observed_at_unix_ms": scope.observed_at_unix_ms, + "observations": observations, + }) + }); + let payload = serde_json::json!({ + "schema": "bridge.tally.reviewed-setup-payload/1", + "capability": { + "canonical_origin": input.capability.canonical_origin, + "observed_at_unix_ms": input.capability.observed_at_unix_ms, + "profile_version": input.capability.profile_version, + "product": input.capability.product, + "release": input.capability.release, + "mode": input.capability.mode, + "mode_confidence": input.capability.mode_confidence.as_str(), + "items": items, + }, + "company": { + "display_name": input.company_display_name, + "guid": input.company_identity.guid, + "remote_id": input.company_identity.remote_id, + "master_id": input.company_identity.master_id, + "fallback_fingerprint": input.company_identity.fallback_fingerprint, + "confidence": input.company_identity.confidence.map(Confidence::as_str), + }, + "selected_read_scope": selected_read_scope, + }); + let canonical = canonical_json(&payload)?; + Ok(hex_digest(Sha256::digest(canonical.as_bytes()))) +} + +fn validate_selected_read_scope( + scope: Option<&SelectedReadScopeInput>, + capability: &CapabilitySnapshotInput, + company_display_name: &str, + company_guid: Option<&str>, +) -> Result<(), MirrorError> { + let selected_items = capability + .items + .iter() + .filter(|item| { + item.kind == CapabilityKind::Feature + && matches!( + item.key.as_str(), + "selected_ledger_read" | "selected_voucher_window_read" + ) + }) + .collect::>(); + let broad_reads_unknown = capability.items.iter().all(|item| { + item.kind != CapabilityKind::Feature + || !matches!(item.key.as_str(), "ledger_read" | "voucher_read") + || item.state == CapabilityState::Unknown + }); + if !broad_reads_unknown { + return Err(MirrorError::InvalidInput("broad_read_claim_not_allowed")); + } + let Some(scope) = scope else { + if selected_items.iter().any(|item| { + item.state == CapabilityState::Supported || item.confidence == Confidence::Observed + }) { + return Err(MirrorError::InvalidInput("selected_read_scope_missing")); + } + return Ok(()); + }; + if capability.profile_version < 3 || scope.observations.len() != 2 { + return Err(MirrorError::InvalidInput("selected_read_scope_shape")); + } + validate_sha256(&scope.scope_commitment_sha256)?; + validate_sha256(&scope.parent_review_sha256)?; + if scope.ledger_profile_id != "bridge.tally.ledgers/1" + || scope.voucher_profile_id != "bridge.tally.vouchers/3" + { + return Err(MirrorError::InvalidInput("selected_read_profile")); + } + let from = TallyDate::parse(scope.voucher_from_yyyymmdd.clone()) + .map_err(|_| MirrorError::InvalidInput("selected_read_date_range"))?; + let to = TallyDate::parse(scope.voucher_to_yyyymmdd.clone()) + .map_err(|_| MirrorError::InvalidInput("selected_read_date_range"))?; + if from.as_str() > to.as_str() || scope.observed_at_unix_ms != capability.observed_at_unix_ms { + return Err(MirrorError::InvalidInput("selected_read_date_range")); + } + let mut observed_keys = std::collections::BTreeSet::new(); + for observation in &scope.observations { + if !matches!( + observation.capability_key.as_str(), + "selected_ledger_read" | "selected_voucher_window_read" + ) || !observed_keys.insert(observation.capability_key.as_str()) + { + return Err(MirrorError::InvalidInput("selected_read_observation_key")); + } + validate_safe_code(&observation.safe_reason_code)?; + validate_safe_code(&observation.result_bucket)?; + validate_optional_sha256(observation.request_sha256.as_deref())?; + validate_optional_sha256(observation.decoded_response_sha256.as_deref())?; + if let Some(encoding) = observation.response_encoding.as_deref() { + if !matches!( + encoding, + "utf8" | "utf8_bom" | "utf16le_bom" | "utf16be_bom" + ) { + return Err(MirrorError::InvalidInput("selected_read_response_encoding")); + } + } + let matching_item = selected_items + .iter() + .find(|item| item.key == observation.capability_key) + .ok_or(MirrorError::InvalidInput("selected_read_feature_missing"))?; + if matching_item.state != observation.state + || matching_item.confidence != observation.confidence + || matching_item.safe_reason_code.as_deref() + != Some(observation.safe_reason_code.as_str()) + { + return Err(MirrorError::InvalidInput("selected_read_feature_mismatch")); + } + match observation.state { + CapabilityState::Supported + if observation.confidence == Confidence::Observed + && observation.request_sha256.is_some() + && observation.decoded_response_sha256.is_some() + && observation.response_encoding.is_some() + && observation.company_context_verified + && observation.schema_verified + && observation.record_count_verified + && ((observation.result_bucket == "empty_observed" + && observation.identity_evidence_state == "not_applicable_empty") + || (observation.result_bucket == "non_empty_observed" + && observation.identity_evidence_state == "verified")) + && ((observation.capability_key == "selected_ledger_read" + && !observation.date_window_verified) + || (observation.capability_key == "selected_voucher_window_read" + && observation.date_window_verified)) => {} + CapabilityState::Unknown + if matches!( + observation.confidence, + Confidence::Observed | Confidence::Unknown + ) && observation.request_sha256.is_none() + && observation.decoded_response_sha256.is_none() + && observation.response_encoding.is_none() + && !observation.company_context_verified + && !observation.schema_verified + && !observation.record_count_verified + && observation.identity_evidence_state == "unverified" + && !observation.date_window_verified + && ((observation.result_bucket == "rejected" + && observation.confidence == Confidence::Observed) + || (observation.result_bucket == "skipped" + && observation.confidence == Confidence::Unknown)) => {} + _ => return Err(MirrorError::InvalidInput("selected_read_observation_shape")), + } + } + if observed_keys.len() != 2 || selected_items.len() != 2 { + return Err(MirrorError::InvalidInput( + "selected_read_observation_cardinality", + )); + } + if scope.observations[0].capability_key != "selected_ledger_read" + || scope.observations[1].capability_key != "selected_voucher_window_read" + { + return Err(MirrorError::InvalidInput("selected_read_observation_order")); + } + let company_guid = company_guid.ok_or(MirrorError::InvalidInput( + "selected_read_company_guid_missing", + ))?; + validate_company_guid(company_guid)?; + let material = SelectedReadScopeCommitmentMaterial { + parent_review_commitment_sha256: scope.parent_review_sha256.clone(), + canonical_origin: capability.canonical_origin.clone(), + company_guid_ascii_casefolded: company_guid.to_ascii_lowercase(), + company_name: company_display_name.to_string(), + ledger_profile_id: scope.ledger_profile_id.clone(), + voucher_profile_id: scope.voucher_profile_id.clone(), + voucher_from_yyyymmdd: scope.voucher_from_yyyymmdd.clone(), + voucher_to_yyyymmdd: scope.voucher_to_yyyymmdd.clone(), + observed_at_unix_ms: scope.observed_at_unix_ms, + observations: scope + .observations + .iter() + .map(|observation| SelectedReadObservationCommitmentMaterial { + capability_key: observation.capability_key.clone(), + state: observation.state.as_str().to_string(), + confidence: observation.confidence.as_str().to_string(), + safe_reason_code: observation.safe_reason_code.clone(), + result_bucket: observation.result_bucket.clone(), + request_sha256: observation.request_sha256.clone(), + decoded_response_sha256: observation.decoded_response_sha256.clone(), + response_encoding: observation.response_encoding.clone(), + company_context_verified: observation.company_context_verified, + schema_verified: observation.schema_verified, + record_count_verified: observation.record_count_verified, + identity_evidence_state: observation.identity_evidence_state.clone(), + date_window_verified: observation.date_window_verified, + }) + .collect(), + }; + if selected_read_scope_commitment_sha256(&material)? != scope.scope_commitment_sha256 { + return Err(MirrorError::InvalidInput( + "selected_read_commitment_mismatch", + )); + } + Ok(()) +} + +fn validate_company_guid(value: &str) -> Result<(), MirrorError> { + if value.is_empty() + || value.len() > 256 + || value.trim() != value + || !value.is_ascii() + || value.bytes().any(|byte| byte.is_ascii_control()) + { + return Err(MirrorError::InvalidInput("company_guid")); + } + Ok(()) +} + +fn validate_company_input(input: &CompanyInput) -> Result<(), MirrorError> { + validate_nonempty(&input.endpoint_id, 128, "endpoint_id")?; + validate_nonempty(&input.display_name, 512, "display_name")?; + validate_identity(&input.identity)?; + if let Some(guid) = input.identity.guid.as_deref() { + validate_company_guid(guid)?; + } + Ok(()) +} + +fn validate_identity(identity: &SourceIdentityInput) -> Result<(), MirrorError> { + for value in [ + identity.guid.as_deref(), + identity.remote_id.as_deref(), + identity.master_id.as_deref(), + identity.fallback_fingerprint.as_deref(), + ] { + validate_optional_text(value, 256, "source_identity")?; + } + if identity.guid.is_none() + && identity.remote_id.is_none() + && identity.master_id.is_none() + && identity.fallback_fingerprint.is_none() + { + return Err(MirrorError::InvalidInput("source_identity")); + } + Ok(()) +} + +fn validate_observation_shape(input: &ObservedRecordInput) -> Result<(), MirrorError> { + match input.status { + ObservationStatus::Accepted + if input.canonical_sha256.is_some() + && input.canonical_payload.is_some() + && input.safe_rejection_code.is_none() => + { + Ok(()) + } + ObservationStatus::Rejected + if input.canonical_payload.is_none() && input.safe_rejection_code.is_some() => + { + Ok(()) + } + _ => Err(MirrorError::InvalidInput("observation_status_shape")), + } +} + +async fn acquire_mirror_write_lock( + transaction: &mut Transaction<'_, Sqlite>, +) -> Result<(), MirrorError> { + sqlx::query("UPDATE tally_schema_migrations SET version = version WHERE version = 4") + .execute(&mut **transaction) + .await?; + Ok(()) +} + +async fn ensure_open_snapshot_window_attempt( + transaction: &mut Transaction<'_, Sqlite>, + attempt: &SnapshotWindowAttemptRef, +) -> Result<(), MirrorError> { + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_snapshot_window_attempts \ + WHERE id = ?1 AND batch_id = ?2 AND window_id = ?3 AND attempt_ordinal = ?4", + ) + .bind(&attempt.attempt_id) + .bind(&attempt.batch_id) + .bind(&attempt.window_id) + .bind(i64::from(attempt.attempt_ordinal)) + .fetch_optional(&mut **transaction) + .await? + .ok_or(MirrorError::NotFound)?; + if state != "open" { + return Err(MirrorError::WindowAttemptClosed); + } + Ok(()) +} + +fn validate_snapshot_window_attempt_ref( + attempt: &SnapshotWindowAttemptRef, +) -> Result<(), MirrorError> { + validate_nonempty(&attempt.attempt_id, 128, "window_attempt_id")?; + validate_nonempty(&attempt.batch_id, 128, "window_attempt_batch_id")?; + validate_nonempty(&attempt.window_id, 128, "window_attempt_window_id")?; + if attempt.attempt_ordinal == 0 { + return Err(MirrorError::InvalidInput("window_attempt_ordinal")); + } + Ok(()) +} + +fn validate_snapshot_window_record_key(record_key: &str) -> Result<(&str, &str), MirrorError> { + if record_key.len() > 385 { + return Err(MirrorError::InvalidInput("window_membership_record_key")); + } + let mut parts = record_key.split('\0'); + let object_type = parts.next().unwrap_or_default(); + let source_id = parts.next().unwrap_or_default(); + if parts.next().is_some() { + return Err(MirrorError::InvalidInput("window_membership_record_key")); + } + validate_safe_code(object_type)?; + validate_nonempty(source_id, 256, "window_membership_source_id")?; + Ok((object_type, source_id)) +} + +fn validate_snapshot_window_membership_input( + input: &SnapshotWindowMembershipInput, + batch_id: &str, +) -> Result<(), MirrorError> { + match input { + SnapshotWindowMembershipInput::Observed { + record_key, + observation, + } => { + let (object_type, source_id) = validate_snapshot_window_record_key(record_key)?; + if observation.batch_id != batch_id + || observation.object_type != object_type + || observation.status != ObservationStatus::Accepted + || ![ + observation.identity.guid.as_deref(), + observation.identity.remote_id.as_deref(), + observation.identity.master_id.as_deref(), + observation.identity.fallback_fingerprint.as_deref(), + ] + .contains(&Some(source_id)) + { + return Err(MirrorError::InvalidInput( + "window_membership_observation_owner_or_identity", + )); + } + prepare_observed_record(observation)?; + } + SnapshotWindowMembershipInput::ProvenanceUnavailable { + record_key, + canonical_sha256, + canonical_payload, + exact_decimals, + safe_reason_code, + } => { + validate_snapshot_window_record_key(record_key)?; + validate_sha256(canonical_sha256)?; + canonical_json(canonical_payload)?; + validate_and_serialize_decimals(exact_decimals)?; + validate_safe_code(safe_reason_code)?; + } + } + Ok(()) +} + +fn prepare_observed_record( + input: &ObservedRecordInput, +) -> Result { + validate_safe_code(&input.object_type)?; + validate_identity(&input.identity)?; + validate_sha256(&input.raw_source_sha256)?; + validate_optional_sha256(input.canonical_sha256.as_deref())?; + validate_optional_text(input.display_name.as_deref(), 512, "display_name")?; + validate_optional_token(input.observed_alter_id.as_deref())?; + validate_optional_safe_code(input.safe_rejection_code.as_deref())?; + validate_observation_shape(input)?; + Ok(PreparedObservedRecord { + canonical_payload_json: input + .canonical_payload + .as_ref() + .map(canonical_json) + .transpose()?, + exact_decimals_json: validate_and_serialize_decimals(&input.exact_decimals)?, + }) +} + +fn validate_and_serialize_decimals( + decimals: &BTreeMap, +) -> Result { + for (field, value) in decimals { + validate_safe_code(field)?; + ExactDecimal::parse(value.clone()) + .map_err(|_| MirrorError::InvalidInput("exact_decimal"))?; + } + Ok(serde_json::to_string(decimals)?) +} + +fn canonical_json(value: &Value) -> Result { + fn canonicalize(value: &Value) -> Result { + match value { + Value::Object(map) => { + let mut ordered = serde_json::Map::new(); + let mut entries = map.iter().collect::>(); + entries.sort_by_key(|(key, _)| *key); + for (key, value) in entries { + ordered.insert(key.clone(), canonicalize(value)?); + } + Ok(Value::Object(ordered)) + } + Value::Array(values) => values + .iter() + .map(canonicalize) + .collect::, _>>() + .map(Value::Array), + Value::Number(number) if number.is_f64() => { + Err(MirrorError::InvalidInput("floating_point_payload_number")) + } + other => Ok(other.clone()), + } + } + + Ok(serde_json::to_string(&canonicalize(value)?)?) +} + +fn validate_nonempty(value: &str, max: usize, field: &'static str) -> Result<(), MirrorError> { + if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) { + return Err(MirrorError::InvalidInput(field)); + } + Ok(()) +} + +fn validate_optional_text( + value: Option<&str>, + max: usize, + field: &'static str, +) -> Result<(), MirrorError> { + if let Some(value) = value { + validate_nonempty(value, max, field)?; + } + Ok(()) +} + +fn validate_safe_code(value: &str) -> Result<(), MirrorError> { + if value.is_empty() + || value.len() > 128 + || !value.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'-' | b'.' | b':') + }) + { + return Err(MirrorError::InvalidInput("safe_code")); + } + Ok(()) +} + +fn validate_optional_safe_code(value: Option<&str>) -> Result<(), MirrorError> { + if let Some(value) = value { + validate_safe_code(value)?; + } + Ok(()) +} + +fn validate_sha256(value: &str) -> Result<(), MirrorError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(MirrorError::InvalidInput("sha256")); + } + Ok(()) +} + +fn validate_optional_sha256(value: Option<&str>) -> Result<(), MirrorError> { + if let Some(value) = value { + validate_sha256(value)?; + } + Ok(()) +} + +fn validate_optional_token(value: Option<&str>) -> Result<(), MirrorError> { + if let Some(value) = value { + if value.is_empty() + || value.len() > 128 + || !value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b'.' | b':') + }) + { + return Err(MirrorError::InvalidInput("checkpoint_or_alter_token")); + } + } + Ok(()) +} + +fn validate_date_range(from: Option<&str>, to: Option<&str>) -> Result<(), MirrorError> { + match (from, to) { + (None, None) => Ok(()), + (Some(from), Some(to)) if valid_yyyymmdd(from) && valid_yyyymmdd(to) && from <= to => { + Ok(()) + } + _ => Err(MirrorError::InvalidInput("date_range")), + } +} + +fn valid_yyyymmdd(value: &str) -> bool { + value.len() == 8 && value.bytes().all(|byte| byte.is_ascii_digit()) +} + +fn parse_run_outcome(value: &str) -> Result { + match value { + "completed" => Ok(RunOutcome::Completed), + "failed" => Ok(RunOutcome::Failed), + "cancelled" => Ok(RunOutcome::Cancelled), + "outcome_unknown" => Ok(RunOutcome::OutcomeUnknown), + _ => Err(MirrorError::VerificationInvariant), + } +} + +fn parse_verification_state(value: &str) -> Result { + match value { + "verified" => Ok(VerificationState::Verified), + "partial" => Ok(VerificationState::Partial), + "unverified" => Ok(VerificationState::Unverified), + _ => Err(MirrorError::VerificationInvariant), + } +} + +#[derive(Serialize)] +struct ProofHashInput<'a> { + proof_contract_version: u16, + previous_entry_sha256: Option<&'a str>, + proof_id: &'a str, + run_id: &'a str, + batch_id: &'a str, + capability_snapshot_id: &'a str, + company_id: &'a str, + pack_id: &'a str, + outcome: RunOutcome, + verification: VerificationState, + started_at_unix_ms: i64, + completed_at_unix_ms: i64, + accepted_records: i64, + rejected_records: i64, + #[serde(skip_serializing_if = "Option::is_none")] + provenance_unavailable_records: Option, + #[serde(skip_serializing_if = "Option::is_none")] + record_counts_sha256: Option<&'a str>, + snapshot_sha256: Option<&'a str>, + checkpoint_before: Option<&'a str>, + checkpoint_after: Option<&'a str>, + gap_codes: &'a [String], + warning_codes: &'a [String], + created_at_unix_ms: i64, +} + +fn sha256_json(value: &impl Serialize) -> Result { + let bytes = serde_json::to_vec(value)?; + let digest = Sha256::digest(bytes); + Ok(digest.iter().map(|byte| format!("{byte:02x}")).collect()) +} + +fn hex_digest(bytes: impl AsRef<[u8]>) -> String { + bytes + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + use crate::sync::reconciliation::{proof_record_counts_sha256, CommitBatchParts}; + + const HASH_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HASH_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + async fn repository() -> TallyMirrorRepository { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect("sqlite::memory:") + .await + .expect("connect to in-memory SQLite"); + let repository = TallyMirrorRepository::new(pool); + repository.migrate().await.expect("run mirror migration"); + repository + } + + async fn repository_through_v9() -> TallyMirrorRepository { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect("sqlite::memory:") + .await + .expect("connect to v9 in-memory SQLite"); + let mut transaction = pool.begin().await.expect("begin v9 migration"); + for migration in [ + MIRROR_MIGRATION_V2, + MIRROR_MIGRATION_V3, + MIRROR_MIGRATION_V4, + MIRROR_MIGRATION_V5, + MIRROR_MIGRATION_V6, + MIRROR_MIGRATION_V7, + MIRROR_MIGRATION_V8, + MIRROR_MIGRATION_V9, + ] { + sqlx::raw_sql(migration) + .execute(&mut *transaction) + .await + .expect("apply migration through v9"); + } + transaction.commit().await.expect("commit v9 schema"); + TallyMirrorRepository::new(pool) + } + + async fn seed_repository( + repository: TallyMirrorRepository, + ) -> (TallyMirrorRepository, CapabilitySnapshotRef, CompanyRef) { + seed_repository_with_core_evidence( + repository, + CapabilityState::Supported, + Confidence::Observed, + None, + ) + .await + } + + async fn seed_repository_with_core_evidence( + repository: TallyMirrorRepository, + core_state: CapabilityState, + core_confidence: Confidence, + core_reason: Option<&str>, + ) -> (TallyMirrorRepository, CapabilitySnapshotRef, CompanyRef) { + let snapshot = repository + .save_capability_snapshot(CapabilitySnapshotInput { + canonical_origin: "http://127.0.0.1:9000".to_string(), + observed_at_unix_ms: 1_000, + profile_version: 1, + product: "TallyPrime".to_string(), + release: None, + mode: Some("Education".to_string()), + mode_confidence: Confidence::Observed, + items: vec![ + CapabilityItemInput { + kind: CapabilityKind::Transport, + key: "xml_http".to_string(), + state: CapabilityState::Supported, + confidence: Confidence::Observed, + safe_reason_code: None, + }, + CapabilityItemInput { + kind: CapabilityKind::Pack, + key: "core_accounting".to_string(), + state: core_state, + confidence: core_confidence, + safe_reason_code: core_reason.map(str::to_string), + }, + ], + }) + .await + .expect("save capability snapshot"); + let company = repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id.clone(), + display_name: "Synthetic Bridge Test".to_string(), + identity: SourceIdentityInput { + guid: Some("company-guid-1".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 1_000, + }) + .await + .expect("save company"); + (repository, snapshot, company) + } + + async fn seeded_repository() -> (TallyMirrorRepository, CapabilitySnapshotRef, CompanyRef) { + seed_repository(repository().await).await + } + + async fn seeded_repository_with_core_evidence( + state: CapabilityState, + confidence: Confidence, + reason: Option<&str>, + ) -> (TallyMirrorRepository, CapabilitySnapshotRef, CompanyRef) { + seed_repository_with_core_evidence(repository().await, state, confidence, reason).await + } + + fn reviewed_setup_input(review_commitment_sha256: &str) -> ReviewedSetupInput { + ReviewedSetupInput { + review_commitment_sha256: review_commitment_sha256.to_string(), + capability: CapabilitySnapshotInput { + canonical_origin: "http://127.0.0.1:9000".to_string(), + observed_at_unix_ms: 2_000, + profile_version: 2, + product: "TallyPrime".to_string(), + release: Some("synthetic".to_string()), + mode: Some("Education".to_string()), + mode_confidence: Confidence::Observed, + items: vec![CapabilityItemInput { + kind: CapabilityKind::Feature, + key: "write".to_string(), + state: CapabilityState::Unknown, + confidence: Confidence::Unknown, + safe_reason_code: Some("write_probe_not_run".to_string()), + }], + }, + company_display_name: "Synthetic Reviewed Company".to_string(), + company_identity: SourceIdentityInput { + guid: Some("reviewed-company-guid".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + selected_read_scope: None, + } + } + + #[tokio::test] + async fn reviewed_setup_rolls_back_snapshot_items_and_company_on_identity_collision() { + let (repository, snapshot, _) = seeded_repository().await; + repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id, + display_name: "Synthetic Collision Peer".to_string(), + identity: SourceIdentityInput { + remote_id: Some("remote-peer-2".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 1_000, + }) + .await + .expect("seed second identity"); + + let counts_before = setup_row_counts(&repository).await; + let error = repository + .save_reviewed_setup(ReviewedSetupInput { + review_commitment_sha256: HASH_A.to_string(), + capability: CapabilitySnapshotInput { + canonical_origin: "http://127.0.0.1:9000".to_string(), + observed_at_unix_ms: 2_000, + profile_version: 2, + product: "Unknown".to_string(), + release: None, + mode: None, + mode_confidence: Confidence::Unknown, + items: vec![CapabilityItemInput { + kind: CapabilityKind::Feature, + key: "write".to_string(), + state: CapabilityState::Unknown, + confidence: Confidence::Unknown, + safe_reason_code: Some("write_probe_not_run".to_string()), + }], + }, + company_display_name: "Synthetic Ambiguous".to_string(), + company_identity: SourceIdentityInput { + guid: Some("company-guid-1".to_string()), + remote_id: Some("remote-peer-2".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + selected_read_scope: None, + }) + .await + .expect_err("cross-record identity match must roll back the setup save"); + assert!(matches!(error, MirrorError::IdentityCollision)); + assert_eq!(setup_row_counts(&repository).await, counts_before); + } + + #[tokio::test] + async fn reviewed_setup_replay_after_lost_acknowledgement_is_idempotent() { + let repository = repository().await; + let input = reviewed_setup_input(HASH_A); + let first = repository + .save_reviewed_setup(input.clone()) + .await + .expect("commit reviewed setup before acknowledgement is lost"); + let counts_after_commit = setup_row_counts(&repository).await; + + let replay = repository + .save_reviewed_setup(input) + .await + .expect("replay the exact reviewed setup"); + + assert_eq!(replay.snapshot.id, first.snapshot.id); + assert_eq!(replay.snapshot.endpoint_id, first.snapshot.endpoint_id); + assert_eq!(replay.company.id, first.company.id); + assert_eq!(setup_row_counts(&repository).await, counts_after_commit); + let consumptions = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_reviewed_setup_consumptions \ + WHERE review_commitment_sha256 = ?1", + ) + .bind(HASH_A) + .fetch_one(&repository.pool) + .await + .expect("count durable review consumption"); + assert_eq!(consumptions, 1); + } + + #[tokio::test] + async fn reviewed_setup_commitment_cannot_be_reused_for_changed_payload() { + let repository = repository().await; + let input = reviewed_setup_input(HASH_A); + repository + .save_reviewed_setup(input.clone()) + .await + .expect("commit first reviewed setup"); + let counts_after_commit = setup_row_counts(&repository).await; + let mut changed = input; + changed.company_display_name = "Synthetic Changed Company".to_string(); + + assert!(matches!( + repository.save_reviewed_setup(changed).await, + Err(MirrorError::InvalidInput("review_commitment_reused")) + )); + assert_eq!(setup_row_counts(&repository).await, counts_after_commit); + } + + #[tokio::test] + async fn reviewed_setup_consumption_authority_is_immutable() { + let repository = repository().await; + repository + .save_reviewed_setup(reviewed_setup_input(HASH_A)) + .await + .expect("commit reviewed setup"); + + assert!(sqlx::query( + "UPDATE tally_reviewed_setup_consumptions SET consumed_at_unix_ms = 3 \ + WHERE review_commitment_sha256 = ?1", + ) + .bind(HASH_A) + .execute(&repository.pool) + .await + .is_err()); + assert!(sqlx::query( + "DELETE FROM tally_reviewed_setup_consumptions WHERE review_commitment_sha256 = ?1", + ) + .bind(HASH_A) + .execute(&repository.pool) + .await + .is_err()); + } + + #[tokio::test] + async fn reviewed_setup_atomically_persists_scoped_selected_read_evidence() { + let repository = repository().await; + let feature = |key: &str, state, confidence, reason: &str| CapabilityItemInput { + kind: CapabilityKind::Feature, + key: key.to_string(), + state, + confidence, + safe_reason_code: Some(reason.to_string()), + }; + let observation = |key: &str, date_window_verified| SelectedReadObservationInput { + capability_key: key.to_string(), + state: CapabilityState::Supported, + confidence: Confidence::Observed, + safe_reason_code: if key == "selected_ledger_read" { + "selected_ledger_read_non_empty_observed".to_string() + } else { + "selected_voucher_window_non_empty_observed".to_string() + }, + result_bucket: "non_empty_observed".to_string(), + request_sha256: Some(HASH_A.to_string()), + decoded_response_sha256: Some(HASH_B.to_string()), + response_encoding: Some("utf8".to_string()), + company_context_verified: true, + schema_verified: true, + record_count_verified: true, + identity_evidence_state: "verified".to_string(), + date_window_verified, + }; + let observations = vec![ + observation("selected_ledger_read", false), + observation("selected_voucher_window_read", true), + ]; + let commitment_observations = observations + .iter() + .map(|observation| SelectedReadObservationCommitmentMaterial { + capability_key: observation.capability_key.clone(), + state: observation.state.as_str().to_string(), + confidence: observation.confidence.as_str().to_string(), + safe_reason_code: observation.safe_reason_code.clone(), + result_bucket: observation.result_bucket.clone(), + request_sha256: observation.request_sha256.clone(), + decoded_response_sha256: observation.decoded_response_sha256.clone(), + response_encoding: observation.response_encoding.clone(), + company_context_verified: observation.company_context_verified, + schema_verified: observation.schema_verified, + record_count_verified: observation.record_count_verified, + identity_evidence_state: observation.identity_evidence_state.clone(), + date_window_verified: observation.date_window_verified, + }) + .collect(); + let scope_commitment_sha256 = + selected_read_scope_commitment_sha256(&SelectedReadScopeCommitmentMaterial { + parent_review_commitment_sha256: HASH_B.to_string(), + canonical_origin: "http://127.0.0.1:9000".to_string(), + company_guid_ascii_casefolded: "qualified-company-guid".to_string(), + company_name: "Synthetic Qualified Company".to_string(), + ledger_profile_id: "bridge.tally.ledgers/1".to_string(), + voucher_profile_id: "bridge.tally.vouchers/3".to_string(), + voucher_from_yyyymmdd: "20260701".to_string(), + voucher_to_yyyymmdd: "20260731".to_string(), + observed_at_unix_ms: 2_000, + observations: commitment_observations, + }) + .expect("compute selected-read commitment"); + let saved = repository + .save_reviewed_setup(ReviewedSetupInput { + review_commitment_sha256: HASH_A.to_string(), + capability: CapabilitySnapshotInput { + canonical_origin: "http://127.0.0.1:9000".to_string(), + observed_at_unix_ms: 2_000, + profile_version: 3, + product: "Unknown".to_string(), + release: None, + mode: None, + mode_confidence: Confidence::Unknown, + items: vec![ + feature( + "ledger_read", + CapabilityState::Unknown, + Confidence::Unknown, + "selected_read_probe_not_run", + ), + feature( + "voucher_read", + CapabilityState::Unknown, + Confidence::Unknown, + "selected_read_probe_not_run", + ), + feature( + "selected_ledger_read", + CapabilityState::Supported, + Confidence::Observed, + "selected_ledger_read_non_empty_observed", + ), + feature( + "selected_voucher_window_read", + CapabilityState::Supported, + Confidence::Observed, + "selected_voucher_window_non_empty_observed", + ), + ], + }, + company_display_name: "Synthetic Qualified Company".to_string(), + company_identity: SourceIdentityInput { + guid: Some("qualified-company-guid".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + selected_read_scope: Some(SelectedReadScopeInput { + scope_commitment_sha256, + parent_review_sha256: HASH_B.to_string(), + ledger_profile_id: "bridge.tally.ledgers/1".to_string(), + voucher_profile_id: "bridge.tally.vouchers/3".to_string(), + voucher_from_yyyymmdd: "20260701".to_string(), + voucher_to_yyyymmdd: "20260731".to_string(), + observed_at_unix_ms: 2_000, + observations, + }), + }) + .await + .expect("atomically save selected-read evidence"); + + let scope_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_selected_read_scopes \ + WHERE capability_snapshot_id = ?1 AND company_id = ?2", + ) + .bind(&saved.snapshot.id) + .bind(&saved.company.id) + .fetch_one(&repository.pool) + .await + .expect("count saved selected-read scope"); + let observation_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_selected_read_observations \ + WHERE capability_snapshot_id = ?1", + ) + .bind(&saved.snapshot.id) + .fetch_one(&repository.pool) + .await + .expect("count saved selected-read observations"); + assert_eq!((scope_count, observation_count), (1, 2)); + assert!(sqlx::query( + "UPDATE tally_selected_read_scopes SET completeness_state = 'not_claimed' WHERE capability_snapshot_id = ?1", + ) + .bind(&saved.snapshot.id) + .execute(&repository.pool) + .await + .is_err()); + } + + #[tokio::test] + async fn selected_read_observation_cannot_cross_wire_scope_and_snapshot() { + let repository = repository().await; + sqlx::raw_sql( + "INSERT INTO tally_endpoints VALUES ('ep', 'http://127.0.0.1:9000', 1, 2);\ + INSERT INTO tally_capability_snapshots VALUES ('snap-a', 'ep', 1, 3, 'Unknown', NULL, NULL, 'unknown');\ + INSERT INTO tally_capability_snapshots VALUES ('snap-b', 'ep', 2, 3, 'Unknown', NULL, NULL, 'unknown');\ + INSERT INTO tally_capability_items VALUES ('snap-b', 'feature', 'selected_ledger_read', 'unknown', 'unknown', 'qualification_prerequisite_failed');\ + INSERT INTO tally_companies VALUES ('company', 'ep', 'Synthetic', 'company-guid', NULL, NULL, NULL, 'observed', 1, 2);\ + INSERT INTO tally_selected_read_scopes VALUES (\ + 'scope-a', 'snap-a', 'company', 1,\ + 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',\ + 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',\ + 'bridge.tally.ledgers/1', 'bridge.tally.vouchers/3',\ + '20260701', '20260731', 2, 'not_claimed', 1, 0\ + );", + ) + .execute(&repository.pool) + .await + .expect("seed two independent snapshot authorities"); + + let error = sqlx::query( + "INSERT INTO tally_selected_read_observations(\ + scope_id, capability_snapshot_id, capability_kind, capability_key,\ + capability_state, confidence, safe_reason_code, result_bucket,\ + request_sha256, decoded_response_sha256, response_encoding,\ + company_context_verified, schema_verified, record_count_verified,\ + identity_evidence_state, date_window_verified\ + ) VALUES (\ + 'scope-a', 'snap-b', 'feature', 'selected_ledger_read',\ + 'unknown', 'unknown', 'qualification_prerequisite_failed', 'skipped',\ + NULL, NULL, NULL, 0, 0, 0, 'unverified', 0\ + )", + ) + .execute(&repository.pool) + .await + .expect_err("scope and observation snapshot must be the same authority"); + assert!(error + .to_string() + .to_ascii_lowercase() + .contains("foreign key")); + } + + async fn setup_row_counts(repository: &TallyMirrorRepository) -> (i64, i64, i64, i64) { + let endpoints = sqlx::query_scalar("SELECT COUNT(*) FROM tally_endpoints") + .fetch_one(&repository.pool) + .await + .unwrap(); + let snapshots = sqlx::query_scalar("SELECT COUNT(*) FROM tally_capability_snapshots") + .fetch_one(&repository.pool) + .await + .unwrap(); + let items = sqlx::query_scalar("SELECT COUNT(*) FROM tally_capability_items") + .fetch_one(&repository.pool) + .await + .unwrap(); + let companies = sqlx::query_scalar("SELECT COUNT(*) FROM tally_companies") + .fetch_one(&repository.pool) + .await + .unwrap(); + (endpoints, snapshots, items, companies) + } + + async fn begin_batch( + repository: &TallyMirrorRepository, + snapshot: &CapabilitySnapshotRef, + company: &CompanyRef, + run_id: &str, + ) -> String { + repository + .begin_batch(BeginBatchInput { + run_id: run_id.to_string(), + capability_snapshot_id: snapshot.id.clone(), + company_id: company.id.clone(), + pack_id: "core_accounting".to_string(), + pack_schema_major: 1, + pack_schema_minor: 0, + source_transport: "xml_http".to_string(), + source_release: None, + requested_from_yyyymmdd: Some("20260401".to_string()), + requested_to_yyyymmdd: Some("20260401".to_string()), + started_at_unix_ms: 2_000, + }) + .await + .expect("begin batch") + } + + fn replayable_observation(batch_id: &str) -> ObservedRecordInput { + ObservedRecordInput { + batch_id: batch_id.to_string(), + object_type: "ledger".to_string(), + display_name: Some("Synthetic Replay Ledger".to_string()), + identity: SourceIdentityInput { + guid: Some("ledger-guid-replay".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 2_100, + raw_source_sha256: HASH_A.to_string(), + canonical_sha256: Some(HASH_B.to_string()), + canonical_payload: Some( + json!({"amount": "1180.00", "name": "Synthetic Replay Ledger"}), + ), + exact_decimals: BTreeMap::from([( + "opening_balance".to_string(), + "1180.00".to_string(), + )]), + observed_alter_id: Some("42".to_string()), + status: ObservationStatus::Accepted, + safe_rejection_code: None, + } + } + + #[tokio::test] + async fn identical_lost_ack_replay_returns_existing_observation_without_duplication() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "replay-run").await; + let input = replayable_observation(&batch_id); + let inserted = repository + .observe_record_idempotent(input.clone()) + .await + .expect("insert first observation"); + let ObserveRecordOutcome::Inserted { observation_id } = inserted else { + panic!("first observation must be inserted"); + }; + + let mut replay = input.clone(); + replay.observed_at_unix_ms = 9_999; + assert_eq!( + repository + .observe_record_idempotent(replay.clone()) + .await + .expect("accept exact lost-ack replay"), + ObserveRecordOutcome::AlreadyPresentIdentical { + observation_id: observation_id.clone(), + } + ); + let observation_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_record_observations WHERE batch_id = ?1", + ) + .bind(&batch_id) + .fetch_one(&repository.pool) + .await + .expect("count replay observations"); + assert_eq!(observation_count, 1); + + assert!(matches!( + repository.observe_record(replay).await, + Err(MirrorError::DuplicateObservation) + )); + } + + #[tokio::test] + async fn changed_replay_is_a_conflict_and_mutates_neither_record_nor_observation() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "conflict-run").await; + let input = replayable_observation(&batch_id); + repository + .observe_record_idempotent(input.clone()) + .await + .expect("insert first observation"); + + let source_before = sqlx::query_as::<_, (Option, String, Option)>( + "SELECT display_name, last_seen_batch_id, tombstoned_at_unix_ms \ + FROM tally_source_records \ + WHERE company_id = ?1 AND object_type = 'ledger' AND source_guid = ?2", + ) + .bind(&company.id) + .bind(input.identity.guid.as_deref()) + .fetch_one(&repository.pool) + .await + .expect("read source record before conflict"); + let observation_before = sqlx::query_as::< + _, + ( + i64, + String, + Option, + Option, + String, + Option, + String, + Option, + ), + >( + "SELECT observed_at_unix_ms, raw_source_sha256, canonical_sha256, \ + canonical_payload_json, exact_decimals_json, observed_alter_id, \ + validation_status, safe_rejection_code \ + FROM tally_record_observations WHERE batch_id = ?1", + ) + .bind(&batch_id) + .fetch_one(&repository.pool) + .await + .expect("read observation before conflict"); + + let mut changed = input; + changed.display_name = Some("Changed Replay Name".to_string()); + changed.observed_at_unix_ms = 8_888; + changed.raw_source_sha256 = HASH_B.to_string(); + changed.canonical_sha256 = Some(HASH_A.to_string()); + changed.canonical_payload = Some(json!({"amount": "999.00", "name": "Changed"})); + changed.exact_decimals = + BTreeMap::from([("opening_balance".to_string(), "999.00".to_string())]); + assert!(matches!( + repository.observe_record_idempotent(changed).await, + Err(MirrorError::ObservationConflict) + )); + let mut legacy_changed = replayable_observation(&batch_id); + legacy_changed.raw_source_sha256 = HASH_B.to_string(); + assert!(matches!( + repository.observe_record(legacy_changed).await, + Err(MirrorError::DuplicateObservation) + )); + + let source_after = sqlx::query_as::<_, (Option, String, Option)>( + "SELECT display_name, last_seen_batch_id, tombstoned_at_unix_ms \ + FROM tally_source_records \ + WHERE company_id = ?1 AND object_type = 'ledger' AND source_guid = ?2", + ) + .bind(&company.id) + .bind("ledger-guid-replay") + .fetch_one(&repository.pool) + .await + .expect("read source record after conflict"); + let observation_after = sqlx::query_as::< + _, + ( + i64, + String, + Option, + Option, + String, + Option, + String, + Option, + ), + >( + "SELECT observed_at_unix_ms, raw_source_sha256, canonical_sha256, \ + canonical_payload_json, exact_decimals_json, observed_alter_id, \ + validation_status, safe_rejection_code \ + FROM tally_record_observations WHERE batch_id = ?1", + ) + .bind(&batch_id) + .fetch_one(&repository.pool) + .await + .expect("read observation after conflict"); + assert_eq!(source_after, source_before); + assert_eq!(observation_after, observation_before); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_record_observations WHERE batch_id = ?1", + ) + .bind(&batch_id) + .fetch_one(&repository.pool) + .await + .expect("count observations after conflict"), + 1 + ); + } + + fn unavailable_membership( + record_key: &str, + canonical_sha256: &str, + name: &str, + ) -> SnapshotWindowMembershipInput { + SnapshotWindowMembershipInput::ProvenanceUnavailable { + record_key: record_key.to_string(), + canonical_sha256: canonical_sha256.to_string(), + canonical_payload: json!({"name": name}), + exact_decimals: BTreeMap::new(), + safe_reason_code: "record_provenance_unavailable".to_string(), + } + } + + async fn begin_window_attempt( + repository: &TallyMirrorRepository, + batch_id: &str, + started_at_unix_ms: i64, + ) -> SnapshotWindowAttemptRef { + repository + .begin_snapshot_window_attempt(BeginSnapshotWindowAttemptInput { + batch_id: batch_id.to_string(), + window_id: "voucher:20260401:20260401".to_string(), + started_at_unix_ms, + }) + .await + .expect("begin window attempt") + .attempt + } + + #[tokio::test] + async fn normalized_window_staging_is_idempotent_and_loads_bounded_receipt_and_map() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "window-stage-run").await; + let attempt = begin_window_attempt(&repository, &batch_id, 3_000).await; + let observed = SnapshotWindowMembershipInput::Observed { + record_key: "ledger\0ledger-guid-replay".to_string(), + observation: Box::new(replayable_observation(&batch_id)), + }; + let unavailable = unavailable_membership( + "voucher\0voucher-guid-unavailable", + HASH_A, + "Synthetic Unavailable Voucher", + ); + let staged = repository + .stage_snapshot_window_memberships( + &attempt, + vec![observed.clone(), unavailable.clone()], + ) + .await + .expect("atomically stage mixed provenance chunk"); + assert_eq!( + staged, + StageSnapshotWindowMembershipsResult { + inserted_memberships: 2, + inserted_observations: 1, + provenance_unavailable_memberships: 1, + ..Default::default() + } + ); + let replayed = repository + .stage_snapshot_window_memberships(&attempt, vec![observed, unavailable]) + .await + .expect("replay exact mixed provenance chunk"); + assert_eq!(replayed.replayed_memberships, 2); + assert_eq!(replayed.replayed_observations, 1); + assert_eq!(replayed.provenance_unavailable_memberships, 1); + + let receipt = repository + .complete_snapshot_window_attempt(&attempt, 4_000, json!({"response_bytes": 321})) + .await + .expect("complete immutable attempt"); + assert_eq!(receipt.member_count, 2); + assert_eq!( + repository + .load_latest_completed_window_receipt(&batch_id, &attempt.window_id) + .await + .expect("load latest receipt"), + Some(receipt.clone()) + ); + let map = repository + .load_completed_window_canonical_record_map(&attempt) + .await + .expect("load ordered canonical map"); + assert_eq!( + map.keys().cloned().collect::>(), + vec![ + "ledger\0ledger-guid-replay".to_string(), + "voucher\0voucher-guid-unavailable".to_string() + ] + ); + assert!(matches!( + repository + .stage_snapshot_window_membership( + &attempt, + unavailable_membership("voucher\0later", HASH_B, "Later") + ) + .await, + Err(MirrorError::WindowAttemptClosed) + )); + assert!(sqlx::query( + "UPDATE tally_snapshot_window_attempts SET receipt_sha256 = ?1 WHERE id = ?2", + ) + .bind(HASH_B) + .bind(&attempt.attempt_id) + .execute(&repository.pool) + .await + .is_err()); + assert!(sqlx::query( + "UPDATE tally_snapshot_window_memberships SET canonical_sha256 = ?1 \ + WHERE batch_id = ?2 AND window_id = ?3 AND record_key = ?4", + ) + .bind(HASH_B) + .bind(&batch_id) + .bind(&attempt.window_id) + .bind("voucher\0voucher-guid-unavailable") + .execute(&repository.pool) + .await + .is_err()); + assert!(sqlx::query( + "DELETE FROM tally_snapshot_window_memberships \ + WHERE batch_id = ?1 AND window_id = ?2", + ) + .bind(&batch_id) + .bind(&attempt.window_id) + .execute(&repository.pool) + .await + .is_err()); + } + + #[tokio::test] + async fn membership_content_conflict_rolls_back_the_entire_chunk() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "window-conflict-run").await; + let first = begin_window_attempt(&repository, &batch_id, 3_000).await; + repository + .stage_snapshot_window_membership( + &first, + unavailable_membership("voucher\0stable", HASH_A, "Stable"), + ) + .await + .expect("seed immutable membership"); + let second = begin_window_attempt(&repository, &batch_id, 4_000).await; + assert!(matches!( + repository + .stage_snapshot_window_memberships( + &second, + vec![ + unavailable_membership("voucher\0new", HASH_B, "New"), + unavailable_membership("voucher\0stable", HASH_B, "Changed"), + ], + ) + .await, + Err(MirrorError::WindowMembershipConflict) + )); + let new_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_snapshot_window_memberships WHERE record_key = ?1", + ) + .bind("voucher\0new") + .fetch_one(&repository.pool) + .await + .expect("count rolled-back addition"); + assert_eq!(new_count, 0); + let last_seen = sqlx::query_scalar::<_, String>( + "SELECT last_seen_attempt_id FROM tally_snapshot_window_memberships \ + WHERE batch_id = ?1 AND window_id = ?2 AND record_key = ?3", + ) + .bind(&batch_id) + .bind(&first.window_id) + .bind("voucher\0stable") + .fetch_one(&repository.pool) + .await + .expect("read unchanged last seen attempt"); + assert_eq!(last_seen, first.attempt_id); + } + + #[tokio::test] + async fn completion_detects_membership_from_abandoned_partial_attempt_and_allows_additions() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "window-disappear-run").await; + let crashed = begin_window_attempt(&repository, &batch_id, 3_000).await; + let old = unavailable_membership("voucher\0old", HASH_A, "Old"); + repository + .stage_snapshot_window_membership(&crashed, old.clone()) + .await + .expect("stage before synthetic crash"); + let resumed = begin_window_attempt(&repository, &batch_id, 4_000).await; + let addition = unavailable_membership("voucher\0addition", HASH_B, "Addition"); + repository + .stage_snapshot_window_membership(&resumed, addition) + .await + .expect("stage addition on resumed attempt"); + assert!(matches!( + repository + .complete_snapshot_window_attempt(&resumed, 5_000, json!({})) + .await, + Err(MirrorError::WindowMembershipDisappeared) + )); + repository + .stage_snapshot_window_membership(&resumed, old) + .await + .expect("re-observe pre-crash membership"); + let receipt = repository + .complete_snapshot_window_attempt(&resumed, 5_001, json!({})) + .await + .expect("complete after exact full membership replay"); + assert_eq!(receipt.member_count, 2, "additions remain permitted"); + let crashed_state = sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_snapshot_window_attempts WHERE id = ?1", + ) + .bind(crashed.attempt_id) + .fetch_one(&repository.pool) + .await + .expect("read crashed attempt state"); + assert_eq!(crashed_state, "abandoned"); + } + + #[tokio::test] + async fn chunk_limit_and_owner_binding_fail_closed_without_mutation() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "window-bounds-run").await; + let attempt = begin_window_attempt(&repository, &batch_id, 3_000).await; + let oversized = (0..=MAX_WINDOW_STAGE_CHUNK) + .map(|index| { + unavailable_membership(&format!("voucher\0item-{index}"), HASH_A, "Synthetic") + }) + .collect(); + assert!(matches!( + repository + .stage_snapshot_window_memberships(&attempt, oversized) + .await, + Err(MirrorError::InvalidInput("window_membership_chunk_size")) + )); + let forged = SnapshotWindowAttemptRef { + window_id: "voucher:other".to_string(), + ..attempt + }; + assert!(matches!( + repository + .stage_snapshot_window_membership( + &forged, + unavailable_membership("voucher\0forged", HASH_A, "Forged") + ) + .await, + Err(MirrorError::NotFound) + )); + let membership_count = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tally_snapshot_window_memberships") + .fetch_one(&repository.pool) + .await + .expect("count bounded memberships"); + assert_eq!(membership_count, 0); + } + + #[tokio::test] + async fn explicit_window_abandon_clamps_clock_rollback_and_is_terminal_immutable() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "window-abandon-run").await; + let attempt = begin_window_attempt(&repository, &batch_id, 3_000).await; + repository + .stage_snapshot_window_membership( + &attempt, + unavailable_membership("voucher\0partial", HASH_A, "Partial"), + ) + .await + .expect("stage partial membership"); + assert!(matches!( + repository + .abandon_snapshot_window_attempt(&attempt, 0) + .await, + Err(MirrorError::InvalidInput("window_attempt_completed_at")) + )); + let forged = SnapshotWindowAttemptRef { + window_id: "voucher:foreign".to_string(), + ..attempt.clone() + }; + assert!(matches!( + repository + .abandon_snapshot_window_attempt(&forged, 4_000) + .await, + Err(MirrorError::NotFound) + )); + let abandonment = repository + .abandon_snapshot_window_attempt(&attempt, 2_999) + .await + .expect("clock rollback is clamped for terminal cleanup"); + assert_eq!( + abandonment, + AbandonSnapshotWindowAttemptResult { + completed_at_unix_ms: 3_000, + local_clock_moved_backwards: true, + } + ); + let stored = sqlx::query_as::< + _, + ( + String, + Option, + Option, + Option, + Option, + ), + >( + "SELECT state, completed_at_unix_ms, receipt_json, receipt_sha256, \ + terminal_safe_reason_code \ + FROM tally_snapshot_window_attempts WHERE id = ?1", + ) + .bind(&attempt.attempt_id) + .fetch_one(&repository.pool) + .await + .expect("read abandoned attempt"); + assert_eq!( + stored, + ( + "abandoned".to_string(), + Some(3_000), + None, + None, + Some("local_clock_moved_backwards".to_string()), + ) + ); + let replayed = repository + .abandon_snapshot_window_attempt(&attempt, 4_001) + .await + .expect("lost acknowledgement replays persisted abandonment evidence"); + assert_eq!(replayed, abandonment); + assert!(matches!( + repository + .complete_snapshot_window_attempt(&attempt, 4_001, json!({})) + .await, + Err(MirrorError::WindowAttemptClosed) + )); + assert!(sqlx::query( + "UPDATE tally_snapshot_window_attempts SET completed_at_unix_ms = 5000 WHERE id = ?1", + ) + .bind(&attempt.attempt_id) + .execute(&repository.pool) + .await + .is_err()); + } + + #[tokio::test] + async fn implicit_begin_abandonment_replays_cumulative_clock_evidence() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = + begin_batch(&repository, &snapshot, &company, "window-begin-clock-run").await; + let first = repository + .begin_snapshot_window_attempt(BeginSnapshotWindowAttemptInput { + batch_id: batch_id.clone(), + window_id: "voucher:clock-window".to_string(), + started_at_unix_ms: 5_000, + }) + .await + .unwrap(); + assert!(first.prior_abandonment.is_none()); + let second = repository + .begin_snapshot_window_attempt(BeginSnapshotWindowAttemptInput { + batch_id: batch_id.clone(), + window_id: "voucher:clock-window".to_string(), + started_at_unix_ms: 4_000, + }) + .await + .unwrap(); + assert_eq!( + second.prior_abandonment, + Some(AbandonSnapshotWindowAttemptResult { + completed_at_unix_ms: 5_000, + local_clock_moved_backwards: true, + }) + ); + let third = repository + .begin_snapshot_window_attempt(BeginSnapshotWindowAttemptInput { + batch_id, + window_id: "voucher:clock-window".to_string(), + started_at_unix_ms: 6_000, + }) + .await + .unwrap(); + assert_eq!(third.prior_abandonment, second.prior_abandonment); + assert_eq!(third.attempt.attempt_ordinal, 3); + } + + #[tokio::test] + async fn window_completion_clamps_and_reloads_clock_rollback_evidence() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch( + &repository, + &snapshot, + &company, + "window-complete-clock-run", + ) + .await; + let attempt = begin_window_attempt(&repository, &batch_id, 5_000).await; + repository + .stage_snapshot_window_membership( + &attempt, + unavailable_membership("voucher\0clock", HASH_A, "Clock"), + ) + .await + .unwrap(); + let completion = repository + .complete_snapshot_window_attempt(&attempt, 4_999, json!({})) + .await + .expect("completion clamps rollback instead of stranding the run"); + assert!(completion.local_clock_moved_backwards); + assert_eq!(completion.completed_at_unix_ms, 5_000); + assert_eq!( + repository + .load_latest_completed_window_receipt(&batch_id, &attempt.window_id) + .await + .unwrap(), + Some(completion) + ); + } + + #[tokio::test] + async fn completed_window_attempt_cannot_be_abandoned() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "window-complete-run").await; + let attempt = begin_window_attempt(&repository, &batch_id, 3_000).await; + repository + .stage_snapshot_window_membership( + &attempt, + unavailable_membership("voucher\0complete", HASH_A, "Complete"), + ) + .await + .expect("stage complete membership"); + repository + .complete_snapshot_window_attempt(&attempt, 4_000, json!({})) + .await + .expect("complete attempt"); + assert!(matches!( + repository + .abandon_snapshot_window_attempt(&attempt, 4_001) + .await, + Err(MirrorError::WindowAttemptClosed) + )); + let state = sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_snapshot_window_attempts WHERE id = ?1", + ) + .bind(attempt.attempt_id) + .fetch_one(&repository.pool) + .await + .expect("read complete state"); + assert_eq!(state, "complete"); + } + + #[tokio::test] + async fn terminal_commit_closes_orphan_attempt_and_binds_unavailable_count() { + let (repository, snapshot, company) = seeded_repository().await; + let run_id = "orphan-window-terminal-run"; + let batch_id = begin_batch(&repository, &snapshot, &company, run_id).await; + let attempt = begin_window_attempt(&repository, &batch_id, 3_000).await; + repository + .stage_snapshot_window_membership( + &attempt, + unavailable_membership("voucher\0orphan", HASH_A, "Orphan"), + ) + .await + .expect("stage unavailable orphan membership"); + + let receipt = repository + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: batch_id.clone(), + proof_contract_version: 2, + outcome: RunOutcome::Failed, + verification: VerificationState::Unverified, + completed_at_unix_ms: 4_000, + record_counts_sha256: None, + snapshot_sha256: None, + expected_checkpoint_before: None, + checkpoint_after: None, + freshness_target_seconds: 60, + gap_codes: vec!["record_provenance_unavailable".to_string()], + warning_codes: Vec::new(), + })) + .await + .expect("terminal commit closes every open attempt"); + assert_eq!(receipt.facts.provenance_unavailable_records, 1); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_snapshot_window_attempts WHERE id = ?1", + ) + .bind(&attempt.attempt_id) + .fetch_one(&repository.pool) + .await + .unwrap(), + "abandoned" + ); + assert!(matches!( + repository + .stage_snapshot_window_membership( + &attempt, + unavailable_membership("voucher\0late", HASH_B, "Late"), + ) + .await, + Err(MirrorError::WindowAttemptClosed) + )); + let recovered = repository + .historical_commit_receipt_for_batch(&batch_id, run_id) + .await + .expect("v2 unavailable count is ledger-bound"); + assert_eq!(recovered.facts.provenance_unavailable_records, 1); + } + + #[tokio::test] + async fn window_completion_clamps_pre_start_time_and_begin_handles_clock_rollback() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "window-clock-run").await; + let first = begin_window_attempt(&repository, &batch_id, 5_000).await; + let completion = repository + .complete_snapshot_window_attempt(&first, 4_999, json!({})) + .await + .expect("pre-start completion is clamped with durable rollback evidence"); + assert_eq!(completion.receipt.completed_at_unix_ms, 5_000); + assert!(completion.local_clock_moved_backwards); + let second = begin_window_attempt(&repository, &batch_id, 4_000).await; + let first_terminal = sqlx::query_as::<_, (String, i64)>( + "SELECT state, completed_at_unix_ms FROM tally_snapshot_window_attempts WHERE id = ?1", + ) + .bind(&first.attempt_id) + .fetch_one(&repository.pool) + .await + .expect("read clock-rollback abandonment"); + assert_eq!(first_terminal, ("complete".to_string(), 5_000)); + assert!(sqlx::query( + "UPDATE tally_snapshot_window_attempts \ + SET state = 'abandoned', completed_at_unix_ms = 3999 WHERE id = ?1", + ) + .bind(&second.attempt_id) + .execute(&repository.pool) + .await + .is_err()); + } + + #[tokio::test] + async fn window_membership_receipt_digest_pages_without_changing_commitment() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "window-paged-digest").await; + let attempt = begin_window_attempt(&repository, &batch_id, 3_000).await; + let memberships = (0..513) + .map(|index| { + unavailable_membership( + &format!("voucher\0paged-{index:04}"), + HASH_A, + &format!("Paged {index:04}"), + ) + }) + .collect::>(); + for chunk in memberships.chunks(MAX_WINDOW_STAGE_CHUNK) { + repository + .stage_snapshot_window_memberships(&attempt, chunk.to_vec()) + .await + .expect("stage bounded digest page fixture"); + } + let expected_values = (0..513) + .map(|index| { + ( + format!("voucher\0paged-{index:04}"), + HASH_A.to_string(), + "unavailable".to_string(), + ) + }) + .collect::>(); + let expected_entries = expected_values + .iter() + .map(|(record_key, canonical_sha256, provenance_state)| { + SnapshotWindowMembershipDigestEntry { + record_key, + canonical_sha256, + provenance_state, + } + }) + .collect::>(); + let expected_sha256 = sha256_json(&expected_entries).expect("hash legacy vector form"); + let receipt = repository + .complete_snapshot_window_attempt(&attempt, 4_000, json!({})) + .await + .expect("complete paged digest attempt"); + assert_eq!(receipt.member_count, 513); + assert_eq!(receipt.membership_sha256, expected_sha256); + assert_eq!( + repository + .load_latest_completed_window_receipt(&batch_id, &attempt.window_id) + .await + .expect("revalidate paged receipt"), + Some(receipt) + ); + } + + #[tokio::test] + async fn earlier_v9_schema_upgrades_additively_and_preserves_v1_proof_hashes() { + let repository = repository_through_v9().await; + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM pragma_table_info('tally_proof_ledger') \ + WHERE name = 'provenance_unavailable_records'", + ) + .fetch_one(&repository.pool) + .await + .unwrap(), + 0 + ); + let (repository, snapshot, company) = seed_repository(repository).await; + let run_id = "legacy-v1-proof-run"; + let batch_id = begin_batch(&repository, &snapshot, &company, run_id).await; + let proof_id = Uuid::new_v4().to_string(); + let completed_at_unix_ms = 3_000; + let created_at_unix_ms = 3_001; + let empty_codes = Vec::::new(); + let proof_sha256 = sha256_json(&ProofHashInput { + proof_contract_version: 1, + previous_entry_sha256: None, + proof_id: &proof_id, + run_id, + batch_id: &batch_id, + capability_snapshot_id: &snapshot.id, + company_id: &company.id, + pack_id: "core_accounting", + outcome: RunOutcome::Failed, + verification: VerificationState::Unverified, + started_at_unix_ms: 2_000, + completed_at_unix_ms, + accepted_records: 0, + rejected_records: 0, + provenance_unavailable_records: None, + record_counts_sha256: None, + snapshot_sha256: None, + checkpoint_before: None, + checkpoint_after: None, + gap_codes: &empty_codes, + warning_codes: &empty_codes, + created_at_unix_ms, + }) + .unwrap(); + sqlx::query( + "UPDATE tally_observation_batches SET state = 'failed', \ + completed_at_unix_ms = ?1 WHERE id = ?2", + ) + .bind(completed_at_unix_ms) + .bind(&batch_id) + .execute(&repository.pool) + .await + .unwrap(); + sqlx::query( + "INSERT INTO tally_proof_ledger(\ + id, proof_contract_version, previous_entry_sha256, entry_sha256, run_id, batch_id, \ + capability_snapshot_id, company_id, pack_id, outcome, verification_state, \ + started_at_unix_ms, completed_at_unix_ms, accepted_records, rejected_records, \ + snapshot_sha256, checkpoint_before, checkpoint_after, gap_codes_json, \ + warning_codes_json, created_at_unix_ms\ + ) VALUES (?1, 1, NULL, ?2, ?3, ?4, ?5, ?6, 'core_accounting', 'failed', \ + 'unverified', 2000, ?7, 0, 0, NULL, NULL, NULL, '[]', '[]', ?8)", + ) + .bind(&proof_id) + .bind(&proof_sha256) + .bind(run_id) + .bind(&batch_id) + .bind(&snapshot.id) + .bind(&company.id) + .bind(completed_at_unix_ms) + .bind(created_at_unix_ms) + .execute(&repository.pool) + .await + .unwrap(); + + repository.migrate().await.expect("upgrade v9 through v12"); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 10", + ) + .fetch_one(&repository.pool) + .await + .unwrap(), + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 11", + ) + .fetch_one(&repository.pool) + .await + .unwrap(), + 1 + ); + assert_eq!( + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 12", + ) + .fetch_one(&repository.pool) + .await + .unwrap(), + 1 + ); + let receipt = repository + .historical_commit_receipt_for_batch(&batch_id, run_id) + .await + .expect("v1 receipt remains hash-valid after v10"); + assert_eq!(receipt.proof_sha256, proof_sha256); + assert_eq!(receipt.facts.proof_contract_version, 1); + assert_eq!(receipt.facts.provenance_unavailable_records, 0); + assert_eq!(receipt.facts.record_counts_sha256, None); + } + + #[tokio::test] + async fn migration_is_versioned_and_idempotent() { + let repository = repository().await; + repository.migrate().await.expect("reapply migration"); + let table_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN (\ + 'tally_capability_snapshots', 'tally_companies', 'tally_observation_batches', \ + 'tally_record_observations', 'tally_proof_ledger', 'tally_checkpoints')", + ) + .fetch_one(&repository.pool) + .await + .expect("count mirror tables"); + let migration_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)", + ) + .fetch_one(&repository.pool) + .await + .expect("count migration marker"); + assert_eq!(table_count, 6); + assert_eq!(migration_count, 11); + let snapshot_state_table = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master \ + WHERE type = 'table' AND name = 'tally_snapshot_run_states'", + ) + .fetch_one(&repository.pool) + .await + .expect("count snapshot state table"); + assert_eq!(snapshot_state_table, 1); + let incremental_table_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN (\ + 'tally_incremental_capability_observations', \ + 'tally_incremental_establishment_receipts', \ + 'tally_incremental_checkpoint_heads')", + ) + .fetch_one(&repository.pool) + .await + .expect("count incremental foundation tables"); + let incremental_trigger_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name LIKE \ + 'tally_incremental_%'", + ) + .fetch_one(&repository.pool) + .await + .expect("count incremental immutability triggers"); + assert_eq!(incremental_table_count, 3); + assert_eq!(incremental_trigger_count, 9); + let selected_read_tables = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN (\ + 'tally_selected_read_scopes', 'tally_selected_read_observations')", + ) + .fetch_one(&repository.pool) + .await + .expect("count selected-read evidence tables"); + assert_eq!(selected_read_tables, 2); + let reviewed_setup_consumption_tables = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' \ + AND name = 'tally_reviewed_setup_consumptions'", + ) + .fetch_one(&repository.pool) + .await + .expect("count reviewed-setup consumption table"); + assert_eq!(reviewed_setup_consumption_tables, 1); + let normalized_staging_tables = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN (\ + 'tally_snapshot_window_attempts', 'tally_snapshot_window_memberships')", + ) + .fetch_one(&repository.pool) + .await + .expect("count normalized staging tables"); + assert_eq!(normalized_staging_tables, 2); + let guid_index_sql = sqlx::query_scalar::<_, String>( + "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'uq_tally_companies_guid'", + ) + .fetch_one(&repository.pool) + .await + .expect("read company GUID index"); + assert!(guid_index_sql.contains("company_guid COLLATE NOCASE")); + let applied_at = sqlx::query_scalar::<_, i64>( + "SELECT MIN(applied_at_unix_ms) FROM tally_schema_migrations \ + WHERE version IN (2, 3, 4, 5, 6, 7, 8, 9)", + ) + .fetch_one(&repository.pool) + .await + .expect("read migration timestamp"); + assert!(applied_at > 0, "migration marker must contain real time"); + } + + #[tokio::test] + async fn v3_receipt_binds_canonical_record_counts_and_rejects_pre_start_completion() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "v3-count-binding").await; + let record_counts = BTreeMap::from([ + ("group.accepted_unique".to_string(), 3), + ("locally_staged.accepted".to_string(), 3), + ("locally_staged.rejected".to_string(), 0), + ]); + let record_counts_sha256 = proof_record_counts_sha256(&record_counts); + let receipt = repository + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: batch_id.clone(), + proof_contract_version: 3, + outcome: RunOutcome::Failed, + verification: VerificationState::Unverified, + completed_at_unix_ms: 2_500, + record_counts_sha256: Some(record_counts_sha256.clone()), + snapshot_sha256: None, + expected_checkpoint_before: None, + checkpoint_after: None, + freshness_target_seconds: 60, + gap_codes: vec!["source_outcome_unknown".to_string()], + warning_codes: Vec::new(), + })) + .await + .expect("commit v3 count-bound proof"); + assert_eq!( + receipt.facts.record_counts_sha256.as_deref(), + Some(record_counts_sha256.as_str()) + ); + let recovered = repository + .historical_commit_receipt_for_batch(&batch_id, "v3-count-binding") + .await + .expect("revalidate count-bound historical receipt"); + assert_eq!(recovered, receipt); + + let clock_batch = begin_batch(&repository, &snapshot, &company, "clock-regression").await; + let error = repository + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: clock_batch.clone(), + proof_contract_version: 3, + outcome: RunOutcome::Failed, + verification: VerificationState::Unverified, + completed_at_unix_ms: 1_999, + record_counts_sha256: Some(proof_record_counts_sha256(&BTreeMap::new())), + snapshot_sha256: None, + expected_checkpoint_before: None, + checkpoint_after: None, + freshness_target_seconds: 60, + gap_codes: vec!["source_outcome_unknown".to_string()], + warning_codes: Vec::new(), + })) + .await + .expect_err("proof completion before batch start must fail closed"); + assert!(matches!( + error, + MirrorError::InvalidInput("batch_completed_at") + )); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_observation_batches WHERE id = ?1", + ) + .bind(clock_batch) + .fetch_one(&repository.pool) + .await + .unwrap(), + "staging" + ); + } + + #[tokio::test] + async fn v7_fails_closed_on_legacy_casefold_company_guid_collision() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("connect to legacy in-memory SQLite"); + let mut transaction = pool.begin().await.expect("begin legacy migration"); + for migration in [ + MIRROR_MIGRATION_V2, + MIRROR_MIGRATION_V3, + MIRROR_MIGRATION_V4, + MIRROR_MIGRATION_V5, + MIRROR_MIGRATION_V6, + ] { + sqlx::raw_sql(migration) + .execute(&mut *transaction) + .await + .expect("install legacy migration"); + } + sqlx::query( + "INSERT INTO tally_endpoints(id, canonical_origin, created_at_unix_ms, last_observed_at_unix_ms) \ + VALUES ('endpoint-1', 'http://127.0.0.1:9000', 1, 1)", + ) + .execute(&mut *transaction) + .await + .expect("seed endpoint"); + for (id, guid) in [("company-a", "CASE-GUID"), ("company-b", "case-guid")] { + sqlx::query( + "INSERT INTO tally_companies(\ + id, endpoint_id, display_name, company_guid, identity_confidence, \ + first_observed_at_unix_ms, last_observed_at_unix_ms\ + ) VALUES (?1, 'endpoint-1', ?1, ?2, 'observed', 1, 1)", + ) + .bind(id) + .bind(guid) + .execute(&mut *transaction) + .await + .expect("seed case-variant company"); + } + transaction.commit().await.expect("commit legacy database"); + + let repository = TallyMirrorRepository::new(pool); + assert!(matches!( + repository.migrate().await, + Err(MirrorError::InvalidInput("company_guid_casefold_collision")) + )); + let v7_markers = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 7", + ) + .fetch_one(&repository.pool) + .await + .expect("inspect v7 marker"); + assert_eq!(v7_markers, 0); + let index_sql = sqlx::query_scalar::<_, String>( + "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'uq_tally_companies_guid'", + ) + .fetch_one(&repository.pool) + .await + .expect("inspect rolled-back index"); + assert!(!index_sql.contains("COLLATE NOCASE")); + } + + #[tokio::test] + async fn commit_pending_restart_accepts_only_exact_sealed_core_evidence() { + let (ordinary, ordinary_snapshot, ordinary_company) = seeded_repository().await; + assert!(ordinary + .capability_snapshot_matches_plan( + &ordinary_snapshot.id, + &ordinary_company.id, + 1, + "TallyPrime", + None, + Some("Education"), + ) + .await + .expect("ordinary observed-support contract remains available to other flows")); + + let (repository, snapshot, company) = seeded_repository_with_core_evidence( + CapabilityState::Unknown, + Confidence::Observed, + Some("sealed_profile_executed"), + ) + .await; + assert!(repository + .core_snapshot_resume_evidence_matches_plan( + &snapshot.id, + &company.id, + 1, + "TallyPrime", + None, + Some("Education"), + ) + .await + .expect("CommitPending restart accepts exact sealed Core evidence")); + + for (state, confidence, reason) in [ + ( + CapabilityState::Supported, + Confidence::Observed, + "sealed_profile_executed", + ), + ( + CapabilityState::Unknown, + Confidence::Inferred, + "sealed_profile_executed", + ), + ( + CapabilityState::Unknown, + Confidence::Observed, + "some_other_observation", + ), + ] { + let (altered, altered_snapshot, altered_company) = + seeded_repository_with_core_evidence(state, confidence, Some(reason)).await; + assert!( + !altered + .core_snapshot_resume_evidence_matches_plan( + &altered_snapshot.id, + &altered_company.id, + 1, + "TallyPrime", + None, + Some("Education"), + ) + .await + .expect("reject altered persisted Core evidence"), + "resume must reject state={state:?}, confidence={confidence:?}, reason={reason}" + ); + } + + assert!(!repository + .core_snapshot_resume_evidence_matches_plan( + &snapshot.id, + &company.id, + 1, + "TallyPrime", + Some("different-release"), + Some("Education"), + ) + .await + .expect("reject changed capability profile")); + } + + #[tokio::test] + async fn batch_identity_remains_composite_across_capability_packs() { + let (repository, snapshot, company) = seeded_repository().await; + let core = begin_batch(&repository, &snapshot, &company, "multi-pack-run").await; + let tax = repository + .begin_batch(BeginBatchInput { + run_id: "multi-pack-run".to_string(), + capability_snapshot_id: snapshot.id, + company_id: company.id, + pack_id: "india_tax".to_string(), + pack_schema_major: 1, + pack_schema_minor: 0, + source_transport: "xml_http".to_string(), + source_release: None, + requested_from_yyyymmdd: Some("20260401".to_string()), + requested_to_yyyymmdd: Some("20260401".to_string()), + started_at_unix_ms: 2_000, + }) + .await + .expect("same run may carry a distinct pack batch"); + assert_ne!(core, tax); + } + + #[tokio::test] + async fn raw_multi_statement_migration_rolls_back_ddl_on_failure() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("connect to in-memory SQLite"); + let mut transaction = pool.begin().await.expect("begin migration transaction"); + let result = sqlx::raw_sql( + "CREATE TABLE rollback_probe(id INTEGER PRIMARY KEY); \ + INSERT INTO rollback_probe(id) VALUES (1); \ + INSERT INTO table_that_does_not_exist(id) VALUES (1);", + ) + .execute(&mut *transaction) + .await; + assert!(result.is_err(), "synthetic migration must fail"); + transaction + .rollback() + .await + .expect("rollback failed migration"); + + let table_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'rollback_probe'", + ) + .fetch_one(&pool) + .await + .expect("inspect schema after rollback"); + assert_eq!(table_count, 0, "DDL must not survive migration rollback"); + } + + #[tokio::test] + async fn recovery_migration_fails_closed_on_duplicate_legacy_run_ids() { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .expect("connect to legacy in-memory SQLite"); + let mut transaction = pool.begin().await.expect("begin legacy migration"); + sqlx::raw_sql(MIRROR_MIGRATION_V2) + .execute(&mut *transaction) + .await + .expect("install v2"); + sqlx::raw_sql(MIRROR_MIGRATION_V3) + .execute(&mut *transaction) + .await + .expect("install v3"); + sqlx::raw_sql(MIRROR_MIGRATION_V4) + .execute(&mut *transaction) + .await + .expect("install v4"); + transaction.commit().await.expect("commit legacy schema"); + for resume_key in ["legacy:a", "legacy:b"] { + sqlx::query( + "INSERT INTO tally_snapshot_run_states(\ + resume_key, run_id, generation, state_sha256, state_json, updated_at_unix_ms\ + ) VALUES (?1, 'duplicated-run', 1, ?2, '{}', 1)", + ) + .bind(resume_key) + .bind(HASH_A) + .execute(&pool) + .await + .expect("seed ambiguous legacy recovery row"); + } + let repository = TallyMirrorRepository::new(pool); + assert!(matches!( + repository.migrate().await, + Err(MirrorError::InvalidInput("snapshot_state_duplicate_run_id")) + )); + } + + #[tokio::test] + async fn stable_company_identity_survives_rename() { + let (repository, snapshot, original) = seeded_repository().await; + let renamed = repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id, + display_name: "Synthetic Bridge Test Renamed".to_string(), + identity: SourceIdentityInput { + guid: Some("company-guid-1".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 2_000, + }) + .await + .expect("rename company by stable identity"); + assert_eq!(original.id, renamed.id); + assert_eq!(renamed.display_name, "Synthetic Bridge Test Renamed"); + } + + #[tokio::test] + async fn company_guid_casing_resolves_to_one_stable_pin() { + let (repository, snapshot, _) = seeded_repository().await; + let uppercase = repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id.clone(), + display_name: "Synthetic Case Company".to_string(), + identity: SourceIdentityInput { + guid: Some("CASE-GUID-2".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 2_000, + }) + .await + .expect("save uppercase GUID"); + let lowercase = repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id.clone(), + display_name: "Synthetic Case Company Renamed".to_string(), + identity: SourceIdentityInput { + guid: Some("case-guid-2".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 3_000, + }) + .await + .expect("save lowercase GUID"); + + assert_eq!(uppercase.id, lowercase.id); + let count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_companies \ + WHERE endpoint_id = ?1 AND company_guid = ?2 COLLATE NOCASE", + ) + .bind(&snapshot.endpoint_id) + .bind("case-guid-2") + .fetch_one(&repository.pool) + .await + .expect("count case-folded company pins"); + assert_eq!(count, 1); + } + + #[test] + fn company_profile_correlation_is_casefolded_scoped_and_opaque() { + let first = + company_profile_correlation_key("http://127.0.0.1:9000", "SENSITIVE-COMPANY-GUID"); + let same = + company_profile_correlation_key("http://127.0.0.1:9000", "sensitive-company-guid"); + let other_endpoint = + company_profile_correlation_key("http://127.0.0.1:9001", "sensitive-company-guid"); + let other_company = + company_profile_correlation_key("http://127.0.0.1:9000", "other-company-guid"); + + assert_eq!(first, same); + assert_ne!(first, other_endpoint); + assert_ne!(first, other_company); + assert_eq!(first.len(), 64); + assert!(!first.contains("sensitive")); + } + + #[tokio::test] + async fn persisted_profiles_only_return_observed_stable_company_pins() { + let (repository, snapshot, observed) = seeded_repository().await; + repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id, + display_name: "Synthetic Inferred Company".to_string(), + identity: SourceIdentityInput { + fallback_fingerprint: Some("inferred-company-fingerprint".to_string()), + confidence: Some(Confidence::Inferred), + ..Default::default() + }, + observed_at_unix_ms: 2_000, + }) + .await + .expect("save inferred company"); + + let page = repository + .persisted_company_profiles() + .await + .expect("load persisted profiles"); + let profiles = page.profiles; + assert_eq!(page.total_profiles, 1); + assert!(!page.truncated); + assert_eq!(profiles.len(), 1); + assert_eq!(profiles[0].mirror_company_id, observed.id); + assert!(profiles[0].guid_observed); + assert_eq!( + profiles[0].correlation_key, + company_profile_correlation_key("http://127.0.0.1:9000", "company-guid-1") + ); + assert_eq!(profiles[0].identity_confidence, "observed"); + assert_eq!(profiles[0].canonical_endpoint, "http://127.0.0.1:9000"); + } + + #[tokio::test] + async fn mirror_explorer_is_paged_and_omits_source_content() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "explorer-run").await; + for (guid, name, hash) in [ + ("ledger-guid-sensitive", "Private Sales Ledger", HASH_A), + ("voucher-guid-sensitive", "Private Receipt Voucher", HASH_B), + ] { + repository + .observe_record(ObservedRecordInput { + batch_id: batch_id.clone(), + object_type: if guid.starts_with("ledger") { + "ledger".to_string() + } else { + "voucher".to_string() + }, + display_name: Some(name.to_string()), + identity: SourceIdentityInput { + guid: Some(guid.to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 2_100, + raw_source_sha256: hash.to_string(), + canonical_sha256: Some(hash.to_string()), + canonical_payload: Some(json!({"private_amount": "1180.00"})), + exact_decimals: BTreeMap::from([( + "private_amount".to_string(), + "1180.00".to_string(), + )]), + observed_alter_id: None, + status: ObservationStatus::Accepted, + safe_rejection_code: None, + }) + .await + .expect("store explorer record"); + } + + let first = repository + .mirror_explorer_page(&company.id, "core_accounting", 0, 1) + .await + .expect("load first explorer page"); + let second = repository + .mirror_explorer_page(&company.id, "core_accounting", 1, 1) + .await + .expect("load second explorer page"); + assert_eq!(first.total_records, 2); + assert_eq!(first.records.len(), 1); + assert_eq!(first.records[0].local_alias, "local-record-1"); + assert_eq!(second.records[0].local_alias, "local-record-2"); + + let serialized = serde_json::to_string(&(first, second)).expect("serialize explorer pages"); + for sensitive in [ + "Private Sales Ledger", + "Private Receipt Voucher", + "ledger-guid-sensitive", + "voucher-guid-sensitive", + "1180.00", + HASH_A, + HASH_B, + ] { + assert!(!serialized.contains(sensitive)); + } + } + + #[tokio::test] + async fn observed_probe_upgrades_company_pin_confidence() { + let (repository, snapshot, company) = seeded_repository().await; + sqlx::query("UPDATE tally_companies SET identity_confidence = 'inferred' WHERE id = ?1") + .bind(&company.id) + .execute(&repository.pool) + .await + .expect("weaken synthetic confidence"); + assert!(matches!( + repository.snapshot_source_pin(&company.id).await, + Err(MirrorError::InvalidInput("company_identity_not_observed")) + )); + + repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id, + display_name: company.display_name, + identity: SourceIdentityInput { + guid: Some("company-guid-1".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 2_000, + }) + .await + .expect("upgrade pin with direct observation"); + let pin = repository + .snapshot_source_pin(&company.id) + .await + .expect("observed pin is snapshot eligible"); + assert_eq!(pin.company_guid, "company-guid-1"); + } + + #[tokio::test] + async fn fallback_identity_is_not_silently_upgraded() { + let repository = repository().await; + let snapshot = repository + .save_capability_snapshot(CapabilitySnapshotInput { + canonical_origin: "http://127.0.0.1:9000".to_string(), + observed_at_unix_ms: 1, + profile_version: 1, + product: "TallyPrime".to_string(), + release: None, + mode: None, + mode_confidence: Confidence::Unknown, + items: vec![], + }) + .await + .expect("save snapshot"); + repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id.clone(), + display_name: "Synthetic".to_string(), + identity: SourceIdentityInput { + fallback_fingerprint: Some("fallback-1".to_string()), + ..Default::default() + }, + observed_at_unix_ms: 1, + }) + .await + .expect("save fallback identity"); + let error = repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id, + display_name: "Synthetic".to_string(), + identity: SourceIdentityInput { + guid: Some("new-guid".to_string()), + fallback_fingerprint: Some("fallback-1".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 2, + }) + .await + .expect_err("identity upgrade must require an audit event"); + assert!(matches!(error, MirrorError::IdentityUpgradeRequiresAudit)); + } + + #[tokio::test] + async fn verified_commit_atomically_advances_checkpoint_and_proof_is_immutable() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "run-1").await; + let resumed_batch_id = begin_batch(&repository, &snapshot, &company, "run-1").await; + assert_eq!(resumed_batch_id, batch_id); + repository + .observe_record(ObservedRecordInput { + batch_id: batch_id.clone(), + object_type: "ledger".to_string(), + display_name: Some("Synthetic Sales".to_string()), + identity: SourceIdentityInput { + guid: Some("ledger-guid-1".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 2_100, + raw_source_sha256: HASH_A.to_string(), + canonical_sha256: Some(HASH_B.to_string()), + canonical_payload: Some(json!({"amount": "1180.00", "name": "Synthetic Sales"})), + exact_decimals: BTreeMap::from([( + "opening_balance".to_string(), + "1180.00".to_string(), + )]), + observed_alter_id: Some("42".to_string()), + status: ObservationStatus::Accepted, + safe_rejection_code: None, + }) + .await + .expect("store observed record"); + + let commit = repository + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: batch_id.clone(), + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: 3_000, + record_counts_sha256: None, + snapshot_sha256: Some(HASH_B.to_string()), + expected_checkpoint_before: None, + checkpoint_after: Some("alter_id:42".to_string()), + freshness_target_seconds: 60, + gap_codes: vec![], + warning_codes: vec!["report_tie_out_unavailable".to_string()], + })) + .await + .expect("commit verified batch"); + assert!(commit.checkpoint_advanced); + assert_eq!(commit.proof_sha256.len(), 64); + let recovered = repository + .commit_receipt_for_batch(&batch_id, "run-1") + .await + .expect("recover exact proof receipt"); + assert_eq!(recovered.proof_id, commit.proof_id); + assert_eq!(recovered.proof_sha256, commit.proof_sha256); + assert!(recovered.checkpoint_advanced); + + let fresh = repository + .freshness(&company.id, "core_accounting", 30_000) + .await + .expect("read freshness"); + assert_eq!(fresh.state, FreshnessState::Fresh); + assert_eq!(fresh.checkpoint_token.as_deref(), Some("alter_id:42")); + let clock_skew = repository + .freshness(&company.id, "core_accounting", 2_999) + .await + .expect("clock skew is fail-closed"); + assert_eq!(clock_skew.state, FreshnessState::Stale); + assert_eq!(clock_skew.age_seconds, Some(0)); + + let proofs = repository + .latest_proofs(&company.id, 10) + .await + .expect("read immutable proof summary"); + assert_eq!(proofs.len(), 1); + assert_eq!(proofs[0].selection_token, commit.proof_id); + assert_eq!(proofs[0].proof_sha256, commit.proof_sha256); + assert_eq!(proofs[0].verification_state, "verified"); + assert_eq!(proofs[0].accepted_records, 1); + assert_eq!( + proofs[0].warning_codes, + vec!["report_tie_out_unavailable".to_string()] + ); + + let next_batch_id = begin_batch(&repository, &snapshot, &company, "run-2").await; + repository + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: next_batch_id, + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: 4_000, + record_counts_sha256: None, + snapshot_sha256: Some(HASH_A.to_string()), + expected_checkpoint_before: Some("alter_id:42".to_string()), + checkpoint_after: Some("alter_id:43".to_string()), + freshness_target_seconds: 60, + gap_codes: vec![], + warning_codes: vec![], + })) + .await + .expect("advance generic checkpoint with a later verified proof"); + assert!(matches!( + repository + .commit_receipt_for_batch(&batch_id, "run-1") + .await, + Err(MirrorError::VerificationInvariant) + )); + let historical = repository + .historical_commit_receipt_for_batch(&batch_id, "run-1") + .await + .expect("immutable historical proof remains authentic after a later checkpoint"); + assert_eq!(historical.proof_id, commit.proof_id); + assert_eq!(historical.proof_sha256, commit.proof_sha256); + + let mutation = sqlx::query("UPDATE tally_proof_ledger SET entry_sha256 = ?1 WHERE id = ?2") + .bind(HASH_A) + .bind(&commit.proof_id) + .execute(&repository.pool) + .await; + assert!(mutation.is_err(), "proof ledger must be append-only"); + } + + #[tokio::test] + async fn partial_batch_cannot_advance_checkpoint_and_float_payloads_are_rejected() { + let (repository, snapshot, company) = seeded_repository().await; + let batch_id = begin_batch(&repository, &snapshot, &company, "run-2").await; + let float_error = repository + .observe_record(ObservedRecordInput { + batch_id: batch_id.clone(), + object_type: "ledger".to_string(), + display_name: None, + identity: SourceIdentityInput { + guid: Some("ledger-guid-2".to_string()), + ..Default::default() + }, + observed_at_unix_ms: 2_100, + raw_source_sha256: HASH_A.to_string(), + canonical_sha256: Some(HASH_B.to_string()), + canonical_payload: Some(json!({"amount": 12.5})), + exact_decimals: BTreeMap::new(), + observed_alter_id: None, + status: ObservationStatus::Accepted, + safe_rejection_code: None, + }) + .await + .expect_err("floating accounting representation must not enter the mirror"); + assert!(matches!( + float_error, + MirrorError::InvalidInput("floating_point_payload_number") + )); + + repository + .observe_record(ObservedRecordInput { + batch_id: batch_id.clone(), + object_type: "ledger".to_string(), + display_name: None, + identity: SourceIdentityInput { + guid: Some("ledger-guid-2".to_string()), + ..Default::default() + }, + observed_at_unix_ms: 2_100, + raw_source_sha256: HASH_A.to_string(), + canonical_sha256: None, + canonical_payload: None, + exact_decimals: BTreeMap::new(), + observed_alter_id: None, + status: ObservationStatus::Rejected, + safe_rejection_code: Some("invalid_exact_decimal".to_string()), + }) + .await + .expect("store safe rejection evidence"); + + repository + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id, + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Partial, + completed_at_unix_ms: 3_000, + record_counts_sha256: None, + snapshot_sha256: Some(HASH_B.to_string()), + expected_checkpoint_before: None, + checkpoint_after: None, + freshness_target_seconds: 60, + gap_codes: vec!["rejected_records_present".to_string()], + warning_codes: vec![], + })) + .await + .expect("commit partial proof without checkpoint"); + let freshness = repository + .freshness(&company.id, "core_accounting", 4_000) + .await + .expect("read freshness"); + assert_eq!(freshness.state, FreshnessState::NeverVerified); + } + + #[tokio::test] + async fn verified_checkpoint_commit_is_compare_and_swap_protected() { + let (repository, snapshot, company) = seeded_repository().await; + let first_batch = begin_batch(&repository, &snapshot, &company, "cas-run-1").await; + let second_batch = begin_batch(&repository, &snapshot, &company, "cas-run-2").await; + repository + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: first_batch, + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: 3_000, + record_counts_sha256: None, + snapshot_sha256: Some(HASH_A.to_string()), + expected_checkpoint_before: None, + checkpoint_after: Some("full:first".to_string()), + freshness_target_seconds: 60, + gap_codes: vec![], + warning_codes: vec![], + })) + .await + .expect("first verified run advances an empty checkpoint"); + + let conflict = repository + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: second_batch, + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: 3_001, + record_counts_sha256: None, + snapshot_sha256: Some(HASH_B.to_string()), + expected_checkpoint_before: None, + checkpoint_after: Some("full:second".to_string()), + freshness_target_seconds: 60, + gap_codes: vec![], + warning_codes: vec![], + })) + .await + .expect_err("stale checkpoint authority must fail closed"); + assert!(matches!(conflict, MirrorError::ConcurrentCheckpoint)); + let freshness = repository + .freshness(&company.id, "core_accounting", 3_001) + .await + .expect("read winning checkpoint"); + assert_eq!(freshness.checkpoint_token.as_deref(), Some("full:first")); + } + + #[test] + fn public_support_export_is_minimized_uncorrelatable_and_checksum_stable() { + let build = || RedactedProofPayload { + schema: "bridge.tally.redacted-proof-of-sync", + schema_version: 1, + exported_at_unix_ms: 123, + redaction_profile: "public_support_v1", + subject: RedactedSubject { + reference: "company-1", + identity_disclosed: false, + }, + proofs: vec![RedactedProofEntry { + entry_index: 1, + proof_contract_version: 1, + pack_id: "core_accounting".to_string(), + pack_schema_version: PackSchemaVersion { major: 2, minor: 0 }, + outcome: "completed".to_string(), + verification_state: "partial".to_string(), + started_at_unix_ms: 100, + completed_at_unix_ms: 120, + counts: RedactedCounts { + provenance_backed_accepted_records: 2, + provenance_unavailable_records: 0, + rejected_records: 0, + }, + gaps: vec!["report_tie_out_unavailable".to_string()], + warnings: Vec::new(), + local_ledger: RedactedLedgerEvidence { + chain_validation: "valid_at_export", + }, + }], + current_status: RedactedCurrentStatus { + freshness_state: "never_verified", + verified_at_unix_ms: None, + checkpoint_present: false, + }, + }; + let first = finish_redacted_export(build()).expect("build public support artifact"); + let second = finish_redacted_export(build()).expect("repeat deterministic artifact"); + assert_eq!(first.payload_sha256, second.payload_sha256); + assert_eq!(first.json, second.json); + for forbidden in [ + "Synthetic Company", + "company-guid", + "run-id", + "batch-id", + "proof-id", + "checkpoint-token", + "1180.00", + "snapshot_commitment_sha256", + "entry_sha256", + "rid:", + ] { + assert!(!first.json.contains(forbidden), "leaked {forbidden}"); + } + assert!(first + .json + .contains("\"integrity_claim\": \"checksum_only\"")); + assert!(first.json.contains("\"authenticity_claim\": \"none\"")); + assert!(validate_export_code("report_tie_out_unavailable").is_ok()); + assert!(validate_export_code("period_report_profile_unobserved").is_ok()); + assert!(validate_export_code("future_unreviewed_code").is_err()); + } + + #[test] + fn redacted_proof_export_accepts_every_reviewed_precise_tally_terminal_code() { + for code in REVIEWED_TALLY_TERMINAL_CODES { + validate_export_code(code).expect("reviewed terminal code must be exportable"); + } + assert!(REVIEWED_TALLY_TERMINAL_CODES.contains(&"window_membership_replay_conflict")); + assert!(REVIEWED_TALLY_TERMINAL_CODES.contains(&"adaptive_window_limit_reached")); + assert!(REVIEWED_TALLY_TERMINAL_CODES.contains(&"minimum_window_response_too_large")); + let payload = RedactedProofPayload { + schema: "bridge.tally.redacted-proof-of-sync", + schema_version: 1, + exported_at_unix_ms: 1, + redaction_profile: "public_support_v1", + subject: RedactedSubject { + reference: "selected_company", + identity_disclosed: false, + }, + proofs: vec![RedactedProofEntry { + entry_index: 0, + proof_contract_version: 1, + pack_id: "core_accounting".to_string(), + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + outcome: "failed".to_string(), + verification_state: "unverified".to_string(), + started_at_unix_ms: 1, + completed_at_unix_ms: 2, + counts: RedactedCounts { + provenance_backed_accepted_records: 0, + provenance_unavailable_records: 0, + rejected_records: 0, + }, + gaps: REVIEWED_TALLY_TERMINAL_CODES + .iter() + .map(|code| (*code).to_string()) + .collect(), + warnings: Vec::new(), + local_ledger: RedactedLedgerEvidence { + chain_validation: "valid_at_export", + }, + }], + current_status: RedactedCurrentStatus { + freshness_state: "never_verified", + verified_at_unix_ms: None, + checkpoint_present: false, + }, + }; + let export = finish_redacted_export(payload).expect("export every reviewed terminal code"); + for code in REVIEWED_TALLY_TERMINAL_CODES { + assert!(export.json.contains(code), "export omitted {code}"); + } + } +} diff --git a/src-tauri/src/db/tally_write_store.rs b/src-tauri/src/db/tally_write_store.rs new file mode 100644 index 0000000..c07bc91 --- /dev/null +++ b/src-tauri/src/db/tally_write_store.rs @@ -0,0 +1,1793 @@ +use serde::Serialize; +use sha2::{Digest, Sha256}; +use sqlx::{Row, Sqlite, Transaction}; +use std::collections::BTreeMap; +use uuid::Uuid; + +use super::tally_mirror::TallyMirrorRepository; + +const MAX_IDENTIFIER_BYTES: usize = 200; + +#[derive(Debug, thiserror::Error)] +pub enum SafeWriteStoreError { + #[error("safe-write database operation failed")] + Database(#[from] sqlx::Error), + #[error("invalid safe-write input ({0})")] + InvalidInput(&'static str), + #[error("safe-write record was not found")] + NotFound, + #[error("safe-write state transition is not allowed")] + InvalidTransition, + #[error("open conflicts must be resolved before approval")] + OpenConflicts, + #[error("mapping version does not match the requested scope")] + MappingScopeMismatch, + #[error("legacy caller-attested verification evidence cannot promote a write outcome")] + LegacyVerificationEvidence, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteOperation { + Create, + Alter, + Delete, +} + +impl WriteOperation { + fn as_str(self) -> &'static str { + match self { + Self::Create => "create", + Self::Alter => "alter", + Self::Delete => "delete", + } + } + + fn parse(value: &str) -> Result { + match value { + "create" => Ok(Self::Create), + "alter" => Ok(Self::Alter), + "delete" => Ok(Self::Delete), + _ => Err(SafeWriteStoreError::InvalidInput("stored_write_operation")), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteJobState { + Prepared, + Approved, + ReadyToSend, + SendStarted, + ConfirmedSuccess, + ConfirmedFailure, + OutcomeUnknown, + RecoveredSuccess, + RecoveredNotApplied, + /// A pre-PR11A terminal row whose evidence authority was caller-attested. + LegacyUntrusted, + FailedPreSend, + Cancelled, +} + +impl WriteJobState { + pub fn as_str(self) -> &'static str { + match self { + Self::Prepared => "prepared", + Self::Approved => "approved", + Self::ReadyToSend => "ready_to_send", + Self::SendStarted => "send_started", + Self::ConfirmedSuccess => "confirmed_success", + Self::ConfirmedFailure => "confirmed_failure", + Self::OutcomeUnknown => "outcome_unknown", + Self::RecoveredSuccess => "recovered_success", + Self::RecoveredNotApplied => "recovered_not_applied", + Self::LegacyUntrusted => "legacy_untrusted", + Self::FailedPreSend => "failed_pre_send", + Self::Cancelled => "cancelled", + } + } + + fn parse(value: &str) -> Result { + match value { + "prepared" => Ok(Self::Prepared), + "approved" => Ok(Self::Approved), + "ready_to_send" => Ok(Self::ReadyToSend), + "send_started" => Ok(Self::SendStarted), + "confirmed_success" => Ok(Self::LegacyUntrusted), + "confirmed_failure" => Ok(Self::ConfirmedFailure), + "outcome_unknown" => Ok(Self::OutcomeUnknown), + "recovered_success" | "recovered_not_applied" => Ok(Self::LegacyUntrusted), + "failed_pre_send" => Ok(Self::FailedPreSend), + "cancelled" => Ok(Self::Cancelled), + _ => Err(SafeWriteStoreError::InvalidInput("stored_job_state")), + } + } +} + +#[derive(Debug, Clone)] +pub struct CreateMappingVersionInput { + pub company_id: String, + pub object_type: String, + pub mapping_key: String, + pub version: u32, + pub mapping_sha256: String, + pub supersedes_id: Option, + pub created_at_unix_ms: i64, +} + +#[derive(Debug, Clone)] +pub struct PrepareImportItemInput { + pub object_type: String, + pub operation: WriteOperation, + pub source_identity_sha256: String, + pub payload_sha256: String, + pub diff_sha256: String, + pub expected_before_sha256: Option, +} + +#[derive(Debug, Clone)] +pub struct PrepareImportJobInput { + pub company_id: String, + pub mapping_version_id: String, + pub request_id: String, + pub payload_sha256: String, + pub diff_sha256: String, + pub idempotency_key_sha256: String, + pub preparation_evidence_sha256: String, + pub created_at_unix_ms: i64, + pub items: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConflictResolution { + Resolved, + Rejected, +} + +impl ConflictResolution { + fn as_str(self) -> &'static str { + match self { + Self::Resolved => "resolved", + Self::Rejected => "rejected", + } + } +} + +#[derive(Debug, Clone)] +pub struct ImportCounters { + pub created: u64, + pub altered: u64, + pub deleted: u64, + pub ignored: u64, + pub errors: u64, + pub cancelled: u64, + pub exceptions: u64, + pub line_errors: u64, +} + +impl ImportCounters { + fn as_database_values(&self) -> Result<[i64; 8], SafeWriteStoreError> { + let convert = |value| { + i64::try_from(value).map_err(|_| SafeWriteStoreError::InvalidInput("import_counter")) + }; + Ok([ + convert(self.created)?, + convert(self.altered)?, + convert(self.deleted)?, + convert(self.ignored)?, + convert(self.errors)?, + convert(self.cancelled)?, + convert(self.exceptions)?, + convert(self.line_errors)?, + ]) + } +} + +#[derive(Debug, Clone)] +pub struct ImportResultEvidenceInput { + pub job_id: String, + pub verification_id: String, + pub result_sha256: String, + pub safe_result_code: String, + pub counters: Option, + pub observed_at_unix_ms: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InitialImportOutcome { + ConfirmedSuccess, + ConfirmedFailure, + OutcomeUnknown, +} + +impl InitialImportOutcome { + fn state(self) -> WriteJobState { + match self { + Self::ConfirmedSuccess => WriteJobState::ConfirmedSuccess, + Self::ConfirmedFailure => WriteJobState::ConfirmedFailure, + Self::OutcomeUnknown => WriteJobState::OutcomeUnknown, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RecoveryOutcome { + RecoveredSuccess, + RecoveredNotApplied, + Inconclusive, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecoveryObservedState { + Present { payload_sha256: String }, + Absent, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryIdentityReadback { + pub source_identity_sha256: String, + pub observed: RecoveryObservedState, +} + +/// Read-back evidence for an ambiguous post-send result. The repository, not +/// the caller, derives the recovery outcome from this shape and the immutable +/// outbox items. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecoveryReadbackEvidence { + pub intended_payload_sha256: String, + pub observed_payload_sha256: String, + /// Opaque digest of the observed Tally release/version evidence. + pub observed_version_digest: String, + pub identities: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WriteJobSnapshot { + pub id: String, + pub request_id: String, + pub state: WriteJobState, + pub dispatch_attempts: u8, + pub approval_digest: Option, + pub payload_sha256: String, +} + +#[derive(Debug)] +struct RecoveryBinding { + intended_payload_sha256: String, + observed_payload_sha256: String, + identity_coverage_sha256: String, + observed_version_digest: String, +} + +#[derive(Debug)] +struct StoredRecoveryItem { + operation: WriteOperation, + payload_sha256: String, + expected_before_sha256: Option, +} + +#[derive(Serialize)] +struct RecoveryCoveragePreimage<'a> { + contract: &'static str, + identities: Vec>, +} + +#[derive(Serialize)] +struct RecoveryCoverageIdentity<'a> { + source_identity_sha256: &'a str, + state: &'static str, + payload_sha256: Option<&'a str>, +} + +impl TallyMirrorRepository { + pub async fn create_write_mapping_version( + &self, + input: CreateMappingVersionInput, + ) -> Result { + validate_id(&input.company_id, "company_id")?; + validate_safe_code(&input.object_type, "object_type")?; + validate_safe_code(&input.mapping_key, "mapping_key")?; + validate_hash(&input.mapping_sha256, "mapping_sha256")?; + validate_timestamp(input.created_at_unix_ms)?; + if input.version == 0 { + return Err(SafeWriteStoreError::InvalidInput("mapping_version")); + } + if let Some(id) = input.supersedes_id.as_deref() { + validate_id(id, "supersedes_id")?; + } + + let mut transaction = self.pool.begin().await?; + let existing_count = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_write_mapping_versions \ + WHERE company_id = ?1 AND object_type = ?2 AND mapping_key = ?3", + ) + .bind(&input.company_id) + .bind(&input.object_type) + .bind(&input.mapping_key) + .fetch_one(&mut *transaction) + .await?; + + match input.supersedes_id.as_deref() { + None if existing_count != 0 || input.version != 1 => { + return Err(SafeWriteStoreError::MappingScopeMismatch); + } + Some(supersedes_id) => { + let previous = sqlx::query( + "SELECT company_id, object_type, mapping_key, version \ + FROM tally_write_mapping_versions WHERE id = ?1", + ) + .bind(supersedes_id) + .fetch_optional(&mut *transaction) + .await? + .ok_or(SafeWriteStoreError::NotFound)?; + let previous_version: i64 = previous.try_get("version")?; + if previous.try_get::("company_id")? != input.company_id + || previous.try_get::("object_type")? != input.object_type + || previous.try_get::("mapping_key")? != input.mapping_key + || i64::from(input.version) != previous_version + 1 + { + return Err(SafeWriteStoreError::MappingScopeMismatch); + } + } + _ => {} + } + + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_write_mapping_versions(\ + id, company_id, object_type, mapping_key, version, mapping_sha256, \ + supersedes_id, created_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + ) + .bind(&id) + .bind(input.company_id) + .bind(input.object_type) + .bind(input.mapping_key) + .bind(i64::from(input.version)) + .bind(input.mapping_sha256) + .bind(input.supersedes_id) + .bind(input.created_at_unix_ms) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + Ok(id) + } + + pub async fn activate_write_mapping_version( + &self, + mapping_version_id: &str, + activated_at_unix_ms: i64, + ) -> Result<(), SafeWriteStoreError> { + validate_id(mapping_version_id, "mapping_version_id")?; + validate_timestamp(activated_at_unix_ms)?; + let mapping = sqlx::query( + "SELECT company_id, object_type, mapping_key \ + FROM tally_write_mapping_versions WHERE id = ?1", + ) + .bind(mapping_version_id) + .fetch_optional(&self.pool) + .await? + .ok_or(SafeWriteStoreError::NotFound)?; + sqlx::query( + "INSERT INTO tally_write_mapping_heads(\ + company_id, object_type, mapping_key, mapping_version_id, activated_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5) \ + ON CONFLICT(company_id, object_type, mapping_key) DO UPDATE SET \ + mapping_version_id = excluded.mapping_version_id, \ + activated_at_unix_ms = excluded.activated_at_unix_ms", + ) + .bind(mapping.try_get::("company_id")?) + .bind(mapping.try_get::("object_type")?) + .bind(mapping.try_get::("mapping_key")?) + .bind(mapping_version_id) + .bind(activated_at_unix_ms) + .execute(&self.pool) + .await?; + Ok(()) + } + + pub async fn prepare_import_job( + &self, + input: PrepareImportJobInput, + ) -> Result { + validate_id(&input.company_id, "company_id")?; + validate_id(&input.mapping_version_id, "mapping_version_id")?; + validate_id(&input.request_id, "request_id")?; + validate_hash(&input.payload_sha256, "payload_sha256")?; + validate_hash(&input.diff_sha256, "diff_sha256")?; + validate_hash(&input.idempotency_key_sha256, "idempotency_key_sha256")?; + validate_hash( + &input.preparation_evidence_sha256, + "preparation_evidence_sha256", + )?; + validate_timestamp(input.created_at_unix_ms)?; + if input.items.is_empty() { + return Err(SafeWriteStoreError::InvalidInput("items")); + } + for item in &input.items { + validate_import_item(item)?; + } + + let mut transaction = self.pool.begin().await?; + let mapping_company = sqlx::query_scalar::<_, String>( + "SELECT version.company_id \ + FROM tally_write_mapping_versions AS version \ + INNER JOIN tally_write_mapping_heads AS head ON \ + head.mapping_version_id = version.id AND \ + head.company_id = version.company_id AND \ + head.object_type = version.object_type AND \ + head.mapping_key = version.mapping_key \ + WHERE version.id = ?1", + ) + .bind(&input.mapping_version_id) + .fetch_optional(&mut *transaction) + .await?; + let mapping_company = match mapping_company { + Some(company_id) => company_id, + None => { + let exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_write_mapping_versions WHERE id = ?1", + ) + .bind(&input.mapping_version_id) + .fetch_one(&mut *transaction) + .await?; + return Err(if exists == 0 { + SafeWriteStoreError::NotFound + } else { + SafeWriteStoreError::MappingScopeMismatch + }); + } + }; + if mapping_company != input.company_id { + return Err(SafeWriteStoreError::MappingScopeMismatch); + } + + let job_id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_import_outbox_jobs(\ + id, company_id, mapping_version_id, request_id, payload_sha256, diff_sha256, \ + state, dispatch_attempts, created_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'prepared', 0, ?7)", + ) + .bind(&job_id) + .bind(input.company_id) + .bind(input.mapping_version_id) + .bind(&input.request_id) + .bind(input.payload_sha256) + .bind(input.diff_sha256) + .bind(input.created_at_unix_ms) + .execute(&mut *transaction) + .await?; + + sqlx::query( + "INSERT INTO tally_import_idempotency_state(\ + idempotency_key_sha256, job_id, state, reserved_at_unix_ms\ + ) VALUES (?1, ?2, 'reserved', ?3)", + ) + .bind(input.idempotency_key_sha256) + .bind(&job_id) + .bind(input.created_at_unix_ms) + .execute(&mut *transaction) + .await?; + + for (ordinal, item) in input.items.into_iter().enumerate() { + let ordinal = i64::try_from(ordinal) + .map_err(|_| SafeWriteStoreError::InvalidInput("item_ordinal"))?; + sqlx::query( + "INSERT INTO tally_import_outbox_items(\ + id, job_id, ordinal, object_type, operation, source_identity_sha256, \ + payload_sha256, diff_sha256, expected_before_sha256\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(&job_id) + .bind(ordinal) + .bind(item.object_type) + .bind(item.operation.as_str()) + .bind(item.source_identity_sha256) + .bind(item.payload_sha256) + .bind(item.diff_sha256) + .bind(item.expected_before_sha256) + .execute(&mut *transaction) + .await?; + } + + insert_event( + &mut transaction, + &job_id, + None, + WriteJobState::Prepared, + &input.request_id, + None, + Some("write_job_prepared"), + &input.preparation_evidence_sha256, + input.created_at_unix_ms, + ) + .await?; + transaction.commit().await?; + Ok(job_id) + } + + pub async fn record_import_conflict( + &self, + job_id: &str, + source_identity_sha256: &str, + diff_sha256: &str, + conflict_code: &str, + created_at_unix_ms: i64, + ) -> Result { + validate_id(job_id, "job_id")?; + validate_hash(source_identity_sha256, "source_identity_sha256")?; + validate_hash(diff_sha256, "diff_sha256")?; + validate_safe_code(conflict_code, "conflict_code")?; + validate_timestamp(created_at_unix_ms)?; + let state = self.import_job(job_id).await?.state; + if state != WriteJobState::Prepared { + return Err(SafeWriteStoreError::InvalidTransition); + } + let id = Uuid::new_v4().to_string(); + sqlx::query( + "INSERT INTO tally_import_conflicts(\ + id, job_id, source_identity_sha256, diff_sha256, conflict_code, state, \ + created_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5, 'open', ?6)", + ) + .bind(&id) + .bind(job_id) + .bind(source_identity_sha256) + .bind(diff_sha256) + .bind(conflict_code) + .bind(created_at_unix_ms) + .execute(&self.pool) + .await?; + Ok(id) + } + + pub async fn resolve_import_conflict( + &self, + conflict_id: &str, + resolution: ConflictResolution, + resolution_code: &str, + resolution_digest: &str, + resolved_at_unix_ms: i64, + ) -> Result<(), SafeWriteStoreError> { + validate_id(conflict_id, "conflict_id")?; + validate_safe_code(resolution_code, "resolution_code")?; + validate_hash(resolution_digest, "resolution_digest")?; + validate_timestamp(resolved_at_unix_ms)?; + let result = sqlx::query( + "UPDATE tally_import_conflicts SET state = ?1, resolution_code = ?2, \ + resolution_digest = ?3, resolved_at_unix_ms = ?4 \ + WHERE id = ?5 AND state = 'open'", + ) + .bind(resolution.as_str()) + .bind(resolution_code) + .bind(resolution_digest) + .bind(resolved_at_unix_ms) + .bind(conflict_id) + .execute(&self.pool) + .await?; + require_changed(result.rows_affected()) + } + + pub async fn approve_import_job( + &self, + job_id: &str, + approval_digest: &str, + evidence_sha256: &str, + approved_at_unix_ms: i64, + ) -> Result<(), SafeWriteStoreError> { + validate_hash(approval_digest, "approval_digest")?; + self.transition_with_conflict_gate( + job_id, + WriteJobState::Prepared, + WriteJobState::Approved, + evidence_sha256, + approved_at_unix_ms, + Some(approval_digest), + ) + .await + } + + pub async fn mark_import_job_ready( + &self, + job_id: &str, + evidence_sha256: &str, + observed_at_unix_ms: i64, + ) -> Result<(), SafeWriteStoreError> { + self.transition_with_conflict_gate( + job_id, + WriteJobState::Approved, + WriteJobState::ReadyToSend, + evidence_sha256, + observed_at_unix_ms, + None, + ) + .await + } + + pub async fn mark_import_send_started( + &self, + job_id: &str, + evidence_sha256: &str, + send_started_at_unix_ms: i64, + ) -> Result<(), SafeWriteStoreError> { + validate_transition_input(job_id, evidence_sha256, send_started_at_unix_ms)?; + let mut transaction = self.pool.begin().await?; + let job = load_job(&mut transaction, job_id).await?; + if job.state != WriteJobState::ReadyToSend || job.dispatch_attempts != 0 { + return Err(SafeWriteStoreError::InvalidTransition); + } + let changed = sqlx::query( + "UPDATE tally_import_outbox_jobs SET state = 'send_started', dispatch_attempts = 1, \ + send_started_at_unix_ms = ?1 \ + WHERE id = ?2 AND state = 'ready_to_send' AND dispatch_attempts = 0", + ) + .bind(send_started_at_unix_ms) + .bind(job_id) + .execute(&mut *transaction) + .await?; + require_changed(changed.rows_affected())?; + let changed = sqlx::query( + "UPDATE tally_import_idempotency_state SET state = 'send_started', \ + send_started_at_unix_ms = ?1 \ + WHERE job_id = ?2 AND state = 'reserved'", + ) + .bind(send_started_at_unix_ms) + .bind(job_id) + .execute(&mut *transaction) + .await?; + require_changed(changed.rows_affected())?; + insert_event( + &mut transaction, + job_id, + Some(WriteJobState::ReadyToSend), + WriteJobState::SendStarted, + &job.request_id, + None, + Some("write_send_started"), + evidence_sha256, + send_started_at_unix_ms, + ) + .await?; + transaction.commit().await?; + Ok(()) + } + + pub async fn record_initial_import_result( + &self, + input: ImportResultEvidenceInput, + outcome: InitialImportOutcome, + ) -> Result<(), SafeWriteStoreError> { + validate_result_input(&input)?; + // This legacy input shape accepts caller-provided hashes and counters. + // It may retain a failure or ambiguous result, but it must never promote + // a job to success. A future migration must accept the opaque portable + // derived-verdict contract and persist its distinct commitments. + if outcome == InitialImportOutcome::ConfirmedSuccess { + return Err(SafeWriteStoreError::LegacyVerificationEvidence); + } + let target = outcome.state(); + let mut transaction = self.pool.begin().await?; + let job = load_job(&mut transaction, &input.job_id).await?; + if job.state != WriteJobState::SendStarted { + return Err(SafeWriteStoreError::InvalidTransition); + } + insert_result( + &mut transaction, + &input, + "initial", + target.as_str(), + &input.safe_result_code, + None, + ) + .await?; + let completed_at = + (target != WriteJobState::OutcomeUnknown).then_some(input.observed_at_unix_ms); + let changed = sqlx::query( + "UPDATE tally_import_outbox_jobs SET state = ?1, completed_at_unix_ms = ?2 \ + WHERE id = ?3 AND state = 'send_started'", + ) + .bind(target.as_str()) + .bind(completed_at) + .bind(&input.job_id) + .execute(&mut *transaction) + .await?; + require_changed(changed.rows_affected())?; + let idempotency_state = if target == WriteJobState::OutcomeUnknown { + "outcome_unknown" + } else { + "terminal" + }; + let changed = sqlx::query( + "UPDATE tally_import_idempotency_state SET state = ?1, terminal_at_unix_ms = ?2 \ + WHERE job_id = ?3 AND state = 'send_started'", + ) + .bind(idempotency_state) + .bind(completed_at) + .bind(&input.job_id) + .execute(&mut *transaction) + .await?; + require_changed(changed.rows_affected())?; + insert_event( + &mut transaction, + &input.job_id, + Some(WriteJobState::SendStarted), + target, + &job.request_id, + Some(&input.verification_id), + Some(&input.safe_result_code), + &input.result_sha256, + input.observed_at_unix_ms, + ) + .await?; + transaction.commit().await?; + Ok(()) + } + + pub async fn record_outcome_unknown_recovery( + &self, + _input: ImportResultEvidenceInput, + _evidence: RecoveryReadbackEvidence, + ) -> Result { + // RecoveryReadbackEvidence predates parser-derived, company-bound + // readback commitments. Keep existing rows readable, but never resume + // or promote them under this caller-attested contract. + Err(SafeWriteStoreError::LegacyVerificationEvidence) + } + + pub async fn cancel_import_job_before_send( + &self, + job_id: &str, + safe_reason_code: &str, + evidence_sha256: &str, + cancelled_at_unix_ms: i64, + ) -> Result<(), SafeWriteStoreError> { + self.terminate_before_send( + job_id, + WriteJobState::Cancelled, + safe_reason_code, + evidence_sha256, + cancelled_at_unix_ms, + ) + .await + } + + pub async fn fail_import_job_before_send( + &self, + job_id: &str, + safe_reason_code: &str, + evidence_sha256: &str, + failed_at_unix_ms: i64, + ) -> Result<(), SafeWriteStoreError> { + self.terminate_before_send( + job_id, + WriteJobState::FailedPreSend, + safe_reason_code, + evidence_sha256, + failed_at_unix_ms, + ) + .await + } + + pub async fn import_job(&self, job_id: &str) -> Result { + validate_id(job_id, "job_id")?; + let row = sqlx::query( + "SELECT id, request_id, state, dispatch_attempts, approval_digest, payload_sha256 \ + FROM tally_import_outbox_jobs WHERE id = ?1", + ) + .bind(job_id) + .fetch_optional(&self.pool) + .await? + .ok_or(SafeWriteStoreError::NotFound)?; + job_snapshot(row) + } + + async fn transition_with_conflict_gate( + &self, + job_id: &str, + expected: WriteJobState, + target: WriteJobState, + evidence_sha256: &str, + observed_at_unix_ms: i64, + approval_digest: Option<&str>, + ) -> Result<(), SafeWriteStoreError> { + validate_transition_input(job_id, evidence_sha256, observed_at_unix_ms)?; + let mut transaction = self.pool.begin().await?; + let job = load_job(&mut transaction, job_id).await?; + if job.state != expected { + return Err(SafeWriteStoreError::InvalidTransition); + } + let open_conflicts = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_import_conflicts WHERE job_id = ?1 AND state = 'open'", + ) + .bind(job_id) + .fetch_one(&mut *transaction) + .await?; + if open_conflicts != 0 { + return Err(SafeWriteStoreError::OpenConflicts); + } + let changed = if target == WriteJobState::Approved { + sqlx::query( + "UPDATE tally_import_outbox_jobs SET state = 'approved', approval_digest = ?1, \ + approved_at_unix_ms = ?2 WHERE id = ?3 AND state = 'prepared'", + ) + .bind(approval_digest) + .bind(observed_at_unix_ms) + .bind(job_id) + .execute(&mut *transaction) + .await? + } else { + sqlx::query( + "UPDATE tally_import_outbox_jobs SET state = ?1 WHERE id = ?2 AND state = ?3", + ) + .bind(target.as_str()) + .bind(job_id) + .bind(expected.as_str()) + .execute(&mut *transaction) + .await? + }; + require_changed(changed.rows_affected())?; + insert_event( + &mut transaction, + job_id, + Some(expected), + target, + &job.request_id, + None, + Some(if target == WriteJobState::Approved { + "write_job_approved" + } else { + "write_job_ready" + }), + evidence_sha256, + observed_at_unix_ms, + ) + .await?; + transaction.commit().await?; + Ok(()) + } + + async fn terminate_before_send( + &self, + job_id: &str, + target: WriteJobState, + safe_reason_code: &str, + evidence_sha256: &str, + observed_at_unix_ms: i64, + ) -> Result<(), SafeWriteStoreError> { + validate_transition_input(job_id, evidence_sha256, observed_at_unix_ms)?; + validate_safe_code(safe_reason_code, "safe_reason_code")?; + let mut transaction = self.pool.begin().await?; + let job = load_job(&mut transaction, job_id).await?; + let allowed = match target { + WriteJobState::Cancelled => matches!( + job.state, + WriteJobState::Prepared | WriteJobState::Approved | WriteJobState::ReadyToSend + ), + WriteJobState::FailedPreSend => job.state == WriteJobState::ReadyToSend, + _ => false, + }; + if !allowed { + return Err(SafeWriteStoreError::InvalidTransition); + } + let changed = sqlx::query( + "UPDATE tally_import_outbox_jobs SET state = ?1, completed_at_unix_ms = ?2 \ + WHERE id = ?3 AND state = ?4 AND dispatch_attempts = 0", + ) + .bind(target.as_str()) + .bind(observed_at_unix_ms) + .bind(job_id) + .bind(job.state.as_str()) + .execute(&mut *transaction) + .await?; + require_changed(changed.rows_affected())?; + let changed = sqlx::query( + "UPDATE tally_import_idempotency_state SET state = 'abandoned_before_send', \ + terminal_at_unix_ms = ?1 \ + WHERE job_id = ?2 AND state = 'reserved' AND send_started_at_unix_ms IS NULL", + ) + .bind(observed_at_unix_ms) + .bind(job_id) + .execute(&mut *transaction) + .await?; + require_changed(changed.rows_affected())?; + insert_event( + &mut transaction, + job_id, + Some(job.state), + target, + &job.request_id, + None, + Some(safe_reason_code), + evidence_sha256, + observed_at_unix_ms, + ) + .await?; + transaction.commit().await?; + Ok(()) + } +} + +async fn load_job( + transaction: &mut Transaction<'_, Sqlite>, + job_id: &str, +) -> Result { + let row = sqlx::query( + "SELECT id, request_id, state, dispatch_attempts, approval_digest, payload_sha256 \ + FROM tally_import_outbox_jobs WHERE id = ?1", + ) + .bind(job_id) + .fetch_optional(&mut **transaction) + .await? + .ok_or(SafeWriteStoreError::NotFound)?; + job_snapshot(row) +} + +fn job_snapshot(row: sqlx::sqlite::SqliteRow) -> Result { + let dispatch_attempts = row.try_get::("dispatch_attempts")?; + Ok(WriteJobSnapshot { + id: row.try_get("id")?, + request_id: row.try_get("request_id")?, + state: WriteJobState::parse(&row.try_get::("state")?)?, + dispatch_attempts: u8::try_from(dispatch_attempts) + .map_err(|_| SafeWriteStoreError::InvalidInput("dispatch_attempts"))?, + approval_digest: row.try_get("approval_digest")?, + payload_sha256: row.try_get("payload_sha256")?, + }) +} + +#[allow(dead_code)] // retained only to decode/audit legacy rows; never promotes new evidence +async fn derive_recovery_outcome( + transaction: &mut Transaction<'_, Sqlite>, + job: &WriteJobSnapshot, + evidence: &RecoveryReadbackEvidence, +) -> Result<(RecoveryOutcome, RecoveryBinding), SafeWriteStoreError> { + validate_hash( + &evidence.intended_payload_sha256, + "recovery_intended_payload_sha256", + )?; + validate_hash( + &evidence.observed_payload_sha256, + "recovery_observed_payload_sha256", + )?; + validate_hash( + &evidence.observed_version_digest, + "recovery_observed_version_digest", + )?; + if evidence.intended_payload_sha256 != job.payload_sha256 { + return Err(SafeWriteStoreError::InvalidInput( + "recovery_intended_payload_mismatch", + )); + } + + let mut observed = BTreeMap::new(); + for identity in &evidence.identities { + validate_hash( + &identity.source_identity_sha256, + "recovery_source_identity_sha256", + )?; + if let RecoveryObservedState::Present { payload_sha256 } = &identity.observed { + validate_hash(payload_sha256, "recovery_identity_payload_sha256")?; + } + if observed + .insert( + identity.source_identity_sha256.clone(), + identity.observed.clone(), + ) + .is_some() + { + return Err(SafeWriteStoreError::InvalidInput( + "duplicate_recovery_identity", + )); + } + } + let identity_coverage_sha256 = recovery_coverage_sha256(&observed)?; + + let rows = sqlx::query( + "SELECT source_identity_sha256, operation, payload_sha256, expected_before_sha256 \ + FROM tally_import_outbox_items WHERE job_id = ?1 ORDER BY source_identity_sha256", + ) + .bind(&job.id) + .fetch_all(&mut **transaction) + .await?; + let mut intended = BTreeMap::new(); + for row in rows { + intended.insert( + row.try_get::("source_identity_sha256")?, + StoredRecoveryItem { + operation: WriteOperation::parse(&row.try_get::("operation")?)?, + payload_sha256: row.try_get("payload_sha256")?, + expected_before_sha256: row.try_get("expected_before_sha256")?, + }, + ); + } + + let exact_coverage = observed.len() == intended.len() && observed.keys().eq(intended.keys()); + let success_items = exact_coverage + && intended.iter().all(|(identity, item)| { + let actual = observed.get(identity); + match item.operation { + WriteOperation::Create | WriteOperation::Alter => matches!( + actual, + Some(RecoveryObservedState::Present { payload_sha256 }) + if payload_sha256 == &item.payload_sha256 + ), + WriteOperation::Delete => matches!(actual, Some(RecoveryObservedState::Absent)), + } + }); + + let expected_before = intended + .iter() + .map(|(identity, item)| { + let state = match item.operation { + WriteOperation::Create => RecoveryObservedState::Absent, + WriteOperation::Alter | WriteOperation::Delete => RecoveryObservedState::Present { + payload_sha256: item + .expected_before_sha256 + .clone() + .expect("alter/delete preparation requires a before hash"), + }, + }; + (identity.clone(), state) + }) + .collect::>(); + let expected_before_sha256 = recovery_coverage_sha256(&expected_before)?; + let not_applied = exact_coverage + && observed == expected_before + && evidence.observed_payload_sha256 == expected_before_sha256; + let success = + success_items && evidence.observed_payload_sha256 == evidence.intended_payload_sha256; + let outcome = if success { + RecoveryOutcome::RecoveredSuccess + } else if not_applied { + RecoveryOutcome::RecoveredNotApplied + } else { + RecoveryOutcome::Inconclusive + }; + Ok(( + outcome, + RecoveryBinding { + intended_payload_sha256: evidence.intended_payload_sha256.clone(), + observed_payload_sha256: evidence.observed_payload_sha256.clone(), + identity_coverage_sha256, + observed_version_digest: evidence.observed_version_digest.clone(), + }, + )) +} + +fn recovery_coverage_sha256( + identities: &BTreeMap, +) -> Result { + let identities = identities + .iter() + .map(|(identity, state)| RecoveryCoverageIdentity { + source_identity_sha256: identity, + state: match state { + RecoveryObservedState::Present { .. } => "present", + RecoveryObservedState::Absent => "absent", + }, + payload_sha256: match state { + RecoveryObservedState::Present { payload_sha256 } => Some(payload_sha256), + RecoveryObservedState::Absent => None, + }, + }) + .collect(); + let canonical = serde_json::to_vec(&RecoveryCoveragePreimage { + contract: "bridge_tally_recovery_identity_coverage_v1", + identities, + }) + .map_err(|_| SafeWriteStoreError::InvalidInput("recovery_coverage_serialization"))?; + Ok(Sha256::digest(canonical) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect()) +} + +#[allow(clippy::too_many_arguments)] +async fn insert_event( + transaction: &mut Transaction<'_, Sqlite>, + job_id: &str, + from_state: Option, + to_state: WriteJobState, + request_id: &str, + verification_id: Option<&str>, + safe_reason_code: Option<&str>, + evidence_sha256: &str, + observed_at_unix_ms: i64, +) -> Result<(), SafeWriteStoreError> { + sqlx::query( + "INSERT INTO tally_import_job_events(\ + id, job_id, from_state, to_state, request_id, verification_id, safe_reason_code, \ + evidence_sha256, observed_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(job_id) + .bind(from_state.map(WriteJobState::as_str)) + .bind(to_state.as_str()) + .bind(request_id) + .bind(verification_id) + .bind(safe_reason_code) + .bind(evidence_sha256) + .bind(observed_at_unix_ms) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +async fn insert_result( + transaction: &mut Transaction<'_, Sqlite>, + input: &ImportResultEvidenceInput, + phase: &str, + outcome: &str, + safe_result_code: &str, + recovery: Option<&RecoveryBinding>, +) -> Result<(), SafeWriteStoreError> { + let counts = input + .counters + .as_ref() + .map(ImportCounters::as_database_values) + .transpose()?; + let observed = counts.is_some(); + let values = counts.unwrap_or([0; 8]); + let value = |index: usize| observed.then_some(values[index]); + sqlx::query( + "INSERT INTO tally_import_results(\ + id, job_id, phase, verification_id, outcome, result_sha256, safe_result_code, \ + intended_payload_sha256, observed_payload_sha256, identity_coverage_sha256, \ + observed_version_digest, \ + counters_observed, created_count, altered_count, deleted_count, ignored_count, \ + error_count, cancelled_count, exception_count, line_error_count, observed_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, \ + ?15, ?16, ?17, ?18, ?19, ?20, ?21)", + ) + .bind(Uuid::new_v4().to_string()) + .bind(&input.job_id) + .bind(phase) + .bind(&input.verification_id) + .bind(outcome) + .bind(&input.result_sha256) + .bind(safe_result_code) + .bind(recovery.map(|value| &value.intended_payload_sha256)) + .bind(recovery.map(|value| &value.observed_payload_sha256)) + .bind(recovery.map(|value| &value.identity_coverage_sha256)) + .bind(recovery.map(|value| &value.observed_version_digest)) + .bind(i64::from(observed)) + .bind(value(0)) + .bind(value(1)) + .bind(value(2)) + .bind(value(3)) + .bind(value(4)) + .bind(value(5)) + .bind(value(6)) + .bind(value(7)) + .bind(input.observed_at_unix_ms) + .execute(&mut **transaction) + .await?; + Ok(()) +} + +fn validate_import_item(item: &PrepareImportItemInput) -> Result<(), SafeWriteStoreError> { + validate_safe_code(&item.object_type, "item_object_type")?; + validate_hash(&item.source_identity_sha256, "source_identity_sha256")?; + validate_hash(&item.payload_sha256, "item_payload_sha256")?; + validate_hash(&item.diff_sha256, "item_diff_sha256")?; + if let Some(hash) = item.expected_before_sha256.as_deref() { + validate_hash(hash, "expected_before_sha256")?; + } + if item.operation == WriteOperation::Alter + && item.expected_before_sha256.as_deref() == Some(item.payload_sha256.as_str()) + { + return Err(SafeWriteStoreError::InvalidInput("alter_payload_unchanged")); + } + match (item.operation, item.expected_before_sha256.is_some()) { + (WriteOperation::Create, false) + | (WriteOperation::Alter | WriteOperation::Delete, true) => Ok(()), + _ => Err(SafeWriteStoreError::InvalidInput("expected_before_sha256")), + } +} + +fn validate_result_input(input: &ImportResultEvidenceInput) -> Result<(), SafeWriteStoreError> { + validate_id(&input.job_id, "job_id")?; + validate_id(&input.verification_id, "verification_id")?; + validate_hash(&input.result_sha256, "result_sha256")?; + validate_safe_code(&input.safe_result_code, "safe_result_code")?; + validate_timestamp(input.observed_at_unix_ms)?; + if let Some(counters) = &input.counters { + counters.as_database_values()?; + } + Ok(()) +} + +fn validate_transition_input( + job_id: &str, + evidence_sha256: &str, + observed_at_unix_ms: i64, +) -> Result<(), SafeWriteStoreError> { + validate_id(job_id, "job_id")?; + validate_hash(evidence_sha256, "evidence_sha256")?; + validate_timestamp(observed_at_unix_ms) +} + +fn validate_hash(value: &str, field: &'static str) -> Result<(), SafeWriteStoreError> { + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(SafeWriteStoreError::InvalidInput(field)); + } + Ok(()) +} + +fn validate_id(value: &str, field: &'static str) -> Result<(), SafeWriteStoreError> { + if value.is_empty() + || value.len() > MAX_IDENTIFIER_BYTES + || value.chars().any(|character| { + character.is_control() + || !(character.is_ascii_alphanumeric() + || matches!(character, '-' | '_' | ':' | '.')) + }) + { + return Err(SafeWriteStoreError::InvalidInput(field)); + } + Ok(()) +} + +fn validate_safe_code(value: &str, field: &'static str) -> Result<(), SafeWriteStoreError> { + validate_id(value, field) +} + +fn validate_timestamp(value: i64) -> Result<(), SafeWriteStoreError> { + if value < 0 { + return Err(SafeWriteStoreError::InvalidInput("timestamp")); + } + Ok(()) +} + +fn require_changed(rows: u64) -> Result<(), SafeWriteStoreError> { + if rows == 1 { + Ok(()) + } else { + Err(SafeWriteStoreError::InvalidTransition) + } +} + +#[cfg(test)] +mod tests { + use sqlx::sqlite::SqlitePoolOptions; + + use super::*; + + #[test] + fn legacy_success_states_are_never_exposed_as_authoritative() { + assert_eq!( + WriteJobState::parse("confirmed_success").unwrap(), + WriteJobState::LegacyUntrusted + ); + assert_eq!( + WriteJobState::parse("recovered_success").unwrap(), + WriteJobState::LegacyUntrusted + ); + assert_eq!( + WriteJobState::parse("recovered_not_applied").unwrap(), + WriteJobState::LegacyUntrusted + ); + } + use crate::db::tally_mirror::{ + CapabilitySnapshotInput, CompanyInput, Confidence, SourceIdentityInput, + }; + + const HASH_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HASH_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const HASH_C: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + const HASH_D: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + async fn repository_and_company() -> (TallyMirrorRepository, String) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect("sqlite::memory:") + .await + .expect("connect in-memory SQLite"); + let repository = TallyMirrorRepository::new(pool); + repository.migrate().await.expect("migrate mirror"); + let snapshot = repository + .save_capability_snapshot(CapabilitySnapshotInput { + canonical_origin: "http://127.0.0.1:9000".to_string(), + observed_at_unix_ms: 1, + profile_version: 1, + product: "TallyPrime".to_string(), + release: Some("synthetic".to_string()), + mode: Some("Education".to_string()), + mode_confidence: Confidence::Observed, + items: Vec::new(), + }) + .await + .expect("save capability snapshot"); + let company = repository + .upsert_company(CompanyInput { + endpoint_id: snapshot.endpoint_id, + display_name: "Synthetic Company".to_string(), + identity: SourceIdentityInput { + guid: Some("synthetic-company-guid".to_string()), + confidence: Some(Confidence::Observed), + ..Default::default() + }, + observed_at_unix_ms: 2, + }) + .await + .expect("save company"); + (repository, company.id) + } + + async fn mapping(repository: &TallyMirrorRepository, company_id: &str) -> String { + let id = repository + .create_write_mapping_version(CreateMappingVersionInput { + company_id: company_id.to_string(), + object_type: "voucher".to_string(), + mapping_key: "voucher_import".to_string(), + version: 1, + mapping_sha256: HASH_A.to_string(), + supersedes_id: None, + created_at_unix_ms: 3, + }) + .await + .expect("create mapping version"); + repository + .activate_write_mapping_version(&id, 4) + .await + .expect("activate mapping version"); + id + } + + fn job_input( + company_id: &str, + mapping_version_id: &str, + request_id: &str, + idempotency_hash: &str, + ) -> PrepareImportJobInput { + PrepareImportJobInput { + company_id: company_id.to_string(), + mapping_version_id: mapping_version_id.to_string(), + request_id: request_id.to_string(), + payload_sha256: HASH_B.to_string(), + diff_sha256: HASH_C.to_string(), + idempotency_key_sha256: idempotency_hash.to_string(), + preparation_evidence_sha256: HASH_D.to_string(), + created_at_unix_ms: 5, + items: vec![PrepareImportItemInput { + object_type: "voucher".to_string(), + operation: WriteOperation::Alter, + source_identity_sha256: HASH_A.to_string(), + payload_sha256: HASH_B.to_string(), + diff_sha256: HASH_C.to_string(), + expected_before_sha256: Some(HASH_D.to_string()), + }], + } + } + + async fn send_started_job( + repository: &TallyMirrorRepository, + company_id: &str, + mapping_id: &str, + request_id: &str, + ) -> String { + let job = repository + .prepare_import_job(job_input(company_id, mapping_id, request_id, HASH_A)) + .await + .expect("prepare job"); + repository + .approve_import_job(&job, HASH_B, HASH_C, 6) + .await + .expect("approve job"); + repository + .mark_import_job_ready(&job, HASH_D, 7) + .await + .expect("ready job"); + repository + .mark_import_send_started(&job, HASH_A, 8) + .await + .expect("persist send started"); + job + } + + #[tokio::test] + async fn migration_is_versioned_and_schema_has_no_raw_payload_or_error_columns() { + let (repository, company_id) = repository_and_company().await; + repository.migrate().await.expect("migration is idempotent"); + let marker = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version = 3 AND \ + applied_at_unix_ms > 0", + ) + .fetch_one(&repository.pool) + .await + .expect("read migration marker"); + assert_eq!(marker, 1); + let mapping_id = mapping(&repository, &company_id).await; + let job = repository + .prepare_import_job(job_input(&company_id, &mapping_id, "request:1", HASH_A)) + .await + .expect("prepare hash-only job"); + assert_eq!( + repository.import_job(&job).await.unwrap().state, + WriteJobState::Prepared + ); + + for columns_sql in [ + "PRAGMA table_info(tally_import_outbox_jobs)", + "PRAGMA table_info(tally_import_outbox_items)", + "PRAGMA table_info(tally_import_results)", + "PRAGMA table_info(tally_import_conflicts)", + ] { + let columns = sqlx::query(columns_sql) + .fetch_all(&repository.pool) + .await + .expect("inspect safe-write table"); + for column in columns { + let name: String = column.try_get("name").unwrap(); + assert!(!name.contains("payload_json")); + assert!(!name.contains("raw")); + assert!(!name.contains("error_message")); + } + } + } + + #[tokio::test] + async fn conflicts_gate_approval_and_outcome_unknown_never_reenters_send() { + let (repository, company_id) = repository_and_company().await; + let mapping_id = mapping(&repository, &company_id).await; + let job = repository + .prepare_import_job(job_input(&company_id, &mapping_id, "request:2", HASH_A)) + .await + .unwrap(); + let conflict = repository + .record_import_conflict(&job, HASH_B, HASH_C, "mapping_conflict", 6) + .await + .unwrap(); + assert!(matches!( + repository.approve_import_job(&job, HASH_B, HASH_C, 7).await, + Err(SafeWriteStoreError::OpenConflicts) + )); + repository + .resolve_import_conflict( + &conflict, + ConflictResolution::Resolved, + "approved_resolution", + HASH_D, + 8, + ) + .await + .unwrap(); + repository + .approve_import_job(&job, HASH_B, HASH_C, 9) + .await + .unwrap(); + repository + .mark_import_job_ready(&job, HASH_D, 10) + .await + .unwrap(); + repository + .mark_import_send_started(&job, HASH_A, 11) + .await + .unwrap(); + repository + .record_initial_import_result( + ImportResultEvidenceInput { + job_id: job.clone(), + verification_id: "verification:unknown".to_string(), + result_sha256: HASH_B.to_string(), + safe_result_code: "response_not_observed".to_string(), + counters: None, + observed_at_unix_ms: 12, + }, + InitialImportOutcome::OutcomeUnknown, + ) + .await + .unwrap(); + assert_eq!( + repository.import_job(&job).await.unwrap(), + WriteJobSnapshot { + id: job.clone(), + request_id: "request:2".to_string(), + state: WriteJobState::OutcomeUnknown, + dispatch_attempts: 1, + approval_digest: Some(HASH_B.to_string()), + payload_sha256: HASH_B.to_string(), + } + ); + assert!(matches!( + repository.mark_import_send_started(&job, HASH_C, 13).await, + Err(SafeWriteStoreError::InvalidTransition) + )); + + let forged_success = repository + .record_outcome_unknown_recovery( + ImportResultEvidenceInput { + job_id: job.clone(), + verification_id: "verification:inconclusive".to_string(), + result_sha256: HASH_C.to_string(), + safe_result_code: "readback_inconclusive".to_string(), + counters: None, + observed_at_unix_ms: 14, + }, + RecoveryReadbackEvidence { + intended_payload_sha256: HASH_B.to_string(), + observed_payload_sha256: HASH_B.to_string(), + observed_version_digest: HASH_A.to_string(), + identities: vec![RecoveryIdentityReadback { + source_identity_sha256: HASH_A.to_string(), + observed: RecoveryObservedState::Present { + payload_sha256: HASH_C.to_string(), + }, + }], + }, + ) + .await; + assert!(matches!( + forged_success, + Err(SafeWriteStoreError::LegacyVerificationEvidence) + )); + assert_eq!( + repository.import_job(&job).await.unwrap().state, + WriteJobState::OutcomeUnknown + ); + + let recovered = repository + .record_outcome_unknown_recovery( + ImportResultEvidenceInput { + job_id: job.clone(), + verification_id: "verification:not-applied".to_string(), + result_sha256: HASH_D.to_string(), + safe_result_code: "readback_proves_not_applied".to_string(), + counters: None, + observed_at_unix_ms: 15, + }, + RecoveryReadbackEvidence { + intended_payload_sha256: HASH_B.to_string(), + observed_payload_sha256: HASH_D.to_string(), + observed_version_digest: HASH_A.to_string(), + identities: vec![RecoveryIdentityReadback { + source_identity_sha256: HASH_A.to_string(), + observed: RecoveryObservedState::Present { + payload_sha256: HASH_D.to_string(), + }, + }], + }, + ) + .await; + assert!(matches!( + recovered, + Err(SafeWriteStoreError::LegacyVerificationEvidence) + )); + assert_eq!( + repository.import_job(&job).await.unwrap().state, + WriteJobState::OutcomeUnknown + ); + let idempotency_state = sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_import_idempotency_state WHERE job_id = ?1", + ) + .bind(&job) + .fetch_one(&repository.pool) + .await + .unwrap(); + assert_eq!(idempotency_state, "outcome_unknown"); + } + + #[tokio::test] + async fn results_are_immutable_and_duplicate_idempotency_is_transactionally_rejected() { + let (repository, company_id) = repository_and_company().await; + let mapping_id = mapping(&repository, &company_id).await; + let job = send_started_job(&repository, &company_id, &mapping_id, "request:3").await; + repository + .record_initial_import_result( + ImportResultEvidenceInput { + job_id: job.clone(), + verification_id: "verification:success".to_string(), + result_sha256: HASH_B.to_string(), + safe_result_code: "synthetic_failure".to_string(), + counters: Some(ImportCounters { + created: 0, + altered: 1, + deleted: 0, + ignored: 0, + errors: 1, + cancelled: 0, + exceptions: 0, + line_errors: 0, + }), + observed_at_unix_ms: 9, + }, + InitialImportOutcome::ConfirmedFailure, + ) + .await + .unwrap(); + assert_eq!( + repository.import_job(&job).await.unwrap().state, + WriteJobState::ConfirmedFailure + ); + assert!( + sqlx::query("UPDATE tally_import_results SET safe_result_code = 'changed'") + .execute(&repository.pool) + .await + .is_err() + ); + assert!(sqlx::query( + "UPDATE tally_import_outbox_jobs SET state = 'send_started', \ + completed_at_unix_ms = NULL WHERE id = ?1" + ) + .bind(&job) + .execute(&repository.pool) + .await + .is_err()); + + let before = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tally_import_outbox_jobs") + .fetch_one(&repository.pool) + .await + .unwrap(); + let duplicate = repository + .prepare_import_job(job_input( + &company_id, + &mapping_id, + "request:duplicate", + HASH_A, + )) + .await; + assert!(matches!(duplicate, Err(SafeWriteStoreError::Database(_)))); + let after = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tally_import_outbox_jobs") + .fetch_one(&repository.pool) + .await + .unwrap(); + assert_eq!(after, before, "failed reservation must roll back the job"); + } + + #[tokio::test] + async fn cancellation_and_pre_send_failure_are_terminal_without_dispatch() { + let (repository, company_id) = repository_and_company().await; + let mapping_id = mapping(&repository, &company_id).await; + let cancelled = repository + .prepare_import_job(job_input( + &company_id, + &mapping_id, + "request:cancelled", + HASH_A, + )) + .await + .unwrap(); + repository + .cancel_import_job_before_send(&cancelled, "operator_cancelled_before_send", HASH_B, 6) + .await + .unwrap(); + let cancelled = repository.import_job(&cancelled).await.unwrap(); + assert_eq!(cancelled.state, WriteJobState::Cancelled); + assert_eq!(cancelled.dispatch_attempts, 0); + let cancelled_idempotency = sqlx::query( + "SELECT state, send_started_at_unix_ms, terminal_at_unix_ms \ + FROM tally_import_idempotency_state WHERE job_id = ?1", + ) + .bind(&cancelled.id) + .fetch_one(&repository.pool) + .await + .unwrap(); + assert_eq!( + cancelled_idempotency.get::("state"), + "abandoned_before_send" + ); + assert_eq!( + cancelled_idempotency.get::, _>("send_started_at_unix_ms"), + None + ); + assert_eq!( + cancelled_idempotency.get::, _>("terminal_at_unix_ms"), + Some(6) + ); + + let failed = repository + .prepare_import_job(job_input( + &company_id, + &mapping_id, + "request:failed", + HASH_C, + )) + .await + .unwrap(); + repository + .approve_import_job(&failed, HASH_B, HASH_C, 7) + .await + .unwrap(); + repository + .mark_import_job_ready(&failed, HASH_D, 8) + .await + .unwrap(); + repository + .fail_import_job_before_send(&failed, "pre_send_validation_failed", HASH_A, 9) + .await + .unwrap(); + let failed = repository.import_job(&failed).await.unwrap(); + assert_eq!(failed.state, WriteJobState::FailedPreSend); + assert_eq!(failed.dispatch_attempts, 0); + let failed_idempotency = sqlx::query( + "SELECT state, send_started_at_unix_ms, terminal_at_unix_ms \ + FROM tally_import_idempotency_state WHERE job_id = ?1", + ) + .bind(&failed.id) + .fetch_one(&repository.pool) + .await + .unwrap(); + assert_eq!( + failed_idempotency.get::("state"), + "abandoned_before_send" + ); + assert_eq!( + failed_idempotency.get::, _>("send_started_at_unix_ms"), + None + ); + assert_eq!( + failed_idempotency.get::, _>("terminal_at_unix_ms"), + Some(9) + ); + } + + #[tokio::test] + async fn superseded_inactive_mapping_cannot_prepare_a_job() { + let (repository, company_id) = repository_and_company().await; + let version_one = mapping(&repository, &company_id).await; + let version_two = repository + .create_write_mapping_version(CreateMappingVersionInput { + company_id: company_id.clone(), + object_type: "voucher".to_string(), + mapping_key: "voucher_import".to_string(), + version: 2, + mapping_sha256: HASH_B.to_string(), + supersedes_id: Some(version_one.clone()), + created_at_unix_ms: 5, + }) + .await + .expect("create superseding mapping"); + repository + .activate_write_mapping_version(&version_two, 6) + .await + .expect("activate superseding mapping"); + + let stale = repository + .prepare_import_job(job_input( + &company_id, + &version_one, + "request:stale-mapping", + HASH_C, + )) + .await; + assert!(matches!( + stale, + Err(SafeWriteStoreError::MappingScopeMismatch) + )); + + let active = repository + .prepare_import_job(job_input( + &company_id, + &version_two, + "request:active-mapping", + HASH_D, + )) + .await + .expect("active mapping can prepare"); + assert_eq!( + repository.import_job(&active).await.unwrap().state, + WriteJobState::Prepared + ); + let job_count = + sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM tally_import_outbox_jobs") + .fetch_one(&repository.pool) + .await + .unwrap(); + assert_eq!(job_count, 1, "stale preparation must not persist a job"); + } +} diff --git a/src-tauri/src/dsc.rs b/src-tauri/src/dsc.rs index 408a29f..e60a2be 100644 --- a/src-tauri/src/dsc.rs +++ b/src-tauri/src/dsc.rs @@ -152,8 +152,8 @@ pub fn run_probe_isolated( explicit_pins: Option>, force_load: bool, ) -> Result> { - let mut pins = explicit_pins.unwrap_or_default(); - let report = (|| { + let mut pins = Zeroizing::new(explicit_pins.unwrap_or_default()); + (|| { let library_root = runtime_library_root()?; let configs = token_configs(&library_root, explicit_library); let deadline = Instant::now() + PROBE_OPERATION_TIMEOUT; @@ -183,7 +183,7 @@ pub fn run_probe_isolated( let detection = run_child_attempt(config, true, deadline); if detection.loaded && detection.initialized && detection.slot_count > 0 { let mut selected = config.clone(); - selected.pins = std::mem::take(&mut pins); + selected.pins = std::mem::take(&mut *pins); attempts.push(run_child_attempt(&selected, false, deadline)); break; } @@ -200,9 +200,7 @@ pub fn run_probe_isolated( detect_only, attempts, }) - })(); - pins.zeroize(); - report + })() } pub fn run_single_attempt( @@ -1020,7 +1018,8 @@ fn display_library_path(path: &Path) -> String { #[cfg(test)] mod tests { use super::{ - spawn_with_output_files, token_configs, wait_with_limited_output, CHILD_OUTPUT_LIMIT, + run_single_attempt, spawn_with_output_files, token_configs, wait_with_limited_output, + CHILD_OUTPUT_LIMIT, }; use std::path::Path; use std::process::{Command, Stdio}; @@ -1042,6 +1041,19 @@ mod tests { assert!(configs.iter().all(|config| config.pins.is_empty())); } + #[test] + fn probe_debug_and_serialized_output_never_contain_the_pin() { + let sentinel = "BRIDGE_SYNTHETIC_PIN_SENTINEL"; + let attempt = run_single_attempt( + "synthetic".to_string(), + "definitely-missing-pkcs11-module".to_string(), + vec![sentinel.to_string()], + false, + ); + assert!(!format!("{attempt:?}").contains(sentinel)); + assert!(!serde_json::to_string(&attempt).unwrap().contains(sentinel)); + } + #[test] fn child_process_deadline_kills_and_reaps() { let mut command = if cfg!(windows) { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12dd16e..b23981c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -7,12 +7,56 @@ pub mod gst; pub mod sync; pub mod tally; +use tauri::Manager; + +fn initialize_tally_mirror(app: &tauri::App) -> anyhow::Result<()> { + let app_data_directory = app.path().app_data_dir()?; + std::fs::create_dir_all(&app_data_directory)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&app_data_directory, std::fs::Permissions::from_mode(0o700))?; + } + + let database_path = app_data_directory.join("tally-mirror-v1.db"); + let _initialization_lock = db::encrypted::lock_mirror_initialization(&database_path)?; + let key_store = db::OsMirrorKeyStore::for_database(&database_path); + let resolved_key = db::resolve_mirror_key(&database_path, &key_store)?; + let pool = + tauri::async_runtime::block_on(db::connect_encrypted(&database_path, resolved_key.key))?; + let repository = db::tally_mirror::TallyMirrorRepository::new(pool); + tauri::async_runtime::block_on(repository.migrate())?; + app.manage(repository); + Ok(()) +} + pub fn run() { tracing_subscriber::fmt::init(); tauri::Builder::default() + .manage(tally::TallyRuntime::default()) + .manage(sync::coordinator::SnapshotCoordinator::default()) + .setup(|app| { + initialize_tally_mirror(app)?; + Ok(()) + }) .invoke_handler(tauri::generate_handler![ commands::check_tally_connection, + commands::probe_tally, + commands::qualify_selected_tally_reads, + commands::save_tally_setup, + commands::tally_persisted_company_profiles, + commands::tally_mirror_explorer_page, + commands::tally_sync_evidence, + commands::preview_tally_redacted_proof, + commands::start_tally_core_snapshot, + commands::resume_tally_core_snapshot, + commands::tally_recent_snapshot_runs, + commands::tally_snapshot_status, + commands::cancel_tally_snapshot, + commands::cancel_tally_request, + commands::tally_runtime_snapshots, + commands::tally_telemetry_preview, commands::fetch_tally_companies, commands::fetch_tally_ledgers, commands::fetch_tally_vouchers, diff --git a/src-tauri/src/sync/coordinator.rs b/src-tauri/src/sync/coordinator.rs new file mode 100644 index 0000000..defab88 --- /dev/null +++ b/src-tauri/src/sync/coordinator.rs @@ -0,0 +1,367 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use bridge_tally_core::{CapabilityPackId, VerificationState}; +use serde::Serialize; + +use crate::db::tally_mirror::TallyMirrorRepository; +use crate::sync::snapshot::{ + AtomicCancellation, DurableSnapshotState, FullSnapshotEngine, SnapshotError, SnapshotPhase, + SnapshotPlan, SnapshotStateStore, SqliteSnapshotStateStore, +}; +use crate::tally::RuntimeTallyConnector; + +const MAX_TRACKED_RUNS: usize = 100; + +#[derive(Debug, Clone, Serialize)] +pub struct SnapshotJobStatus { + pub run_id: String, + pub mirror_company_id: Option, + pub pack_id: Option, + pub requested_from_yyyymmdd: Option, + pub requested_to_yyyymmdd: Option, + pub phase: SnapshotPhase, + pub active_window_id: Option, + pub completed_windows: u32, + pub total_windows: u32, + pub verification: Option, + pub proof_id: Option, + pub proof_sha256: Option, + pub gap_codes: Vec, + pub warning_codes: Vec, + pub failure_code: Option, + /// True only when durable non-terminal state exists but no worker is attached in this process. + pub requires_resume: bool, + /// False for legacy/corrupt detached states that are inspectable but not safely resumable. + pub resume_available: bool, +} + +struct SnapshotJob { + plan: SnapshotPlan, + mirror: TallyMirrorRepository, + connector: RuntimeTallyConnector, + cancellation: Arc, + terminal: Arc>>, +} + +#[derive(Clone, Default)] +pub struct SnapshotCoordinator { + jobs: Arc>>, +} + +impl SnapshotCoordinator { + pub async fn start( + &self, + plan: SnapshotPlan, + connector: RuntimeTallyConnector, + mirror: TallyMirrorRepository, + ) -> Result { + let cancellation = Arc::new(AtomicCancellation::default()); + let terminal = Arc::new(Mutex::new(None)); + let lease_owner = uuid::Uuid::new_v4().to_string(); + let store = SqliteSnapshotStateStore::for_worker(mirror.pool_clone(), lease_owner) + .map_err(|_| "snapshot_lease_invalid")?; + store + .migrate() + .await + .map_err(|_| "snapshot_state_migration_missing")?; + store + .claim(&plan.resume_key) + .await + .map_err(|error| match error { + SnapshotError::LeaseUnavailable => "snapshot_run_owned_elsewhere", + _ => "snapshot_state_unavailable", + })?; + let registration = (|| { + let mut jobs = self + .jobs + .lock() + .map_err(|_| "snapshot_registry_unavailable")?; + if jobs.contains_key(&plan.run_id) { + let replaceable = jobs.get(&plan.run_id).is_some_and(|job| { + job.terminal.lock().is_ok_and(|status| { + status.as_ref().is_some_and(|status| status.requires_resume) + }) + }); + if replaceable { + jobs.remove(&plan.run_id); + } else { + return Err("snapshot_run_already_exists"); + } + } + if jobs.len() >= MAX_TRACKED_RUNS { + if let Some(completed) = jobs + .iter() + .find(|(_, job)| job.terminal.lock().is_ok_and(|state| state.is_some())) + .map(|(run_id, _)| run_id.clone()) + { + jobs.remove(&completed); + } else { + return Err("snapshot_run_limit_reached"); + } + } + jobs.insert( + plan.run_id.clone(), + SnapshotJob { + plan: plan.clone(), + mirror: mirror.clone(), + connector: connector.clone(), + cancellation: Arc::clone(&cancellation), + terminal: Arc::clone(&terminal), + }, + ); + Ok::<(), &'static str>(()) + })(); + if let Err(error) = registration { + let _ = store.release(&plan.resume_key).await; + return Err(error); + } + + let initial = status_from_plan(&plan); + tauri::async_runtime::spawn(async move { + let engine = FullSnapshotEngine::new(&mirror, &store, &connector); + let final_status = match engine.run(&plan, cancellation.as_ref()).await { + Ok(result) => SnapshotJobStatus { + run_id: plan.run_id.clone(), + mirror_company_id: Some(plan.mirror_company_id.clone()), + pack_id: Some(plan.pack), + requested_from_yyyymmdd: requested_bounds(&plan).0, + requested_to_yyyymmdd: requested_bounds(&plan).1, + phase: result.state.progress.phase, + active_window_id: result.state.progress.active_window_id, + completed_windows: result.state.progress.completed_windows, + total_windows: result.state.progress.total_windows, + verification: Some(result.proof.verification), + proof_id: result.receipt.proof_id, + proof_sha256: result.receipt.proof_sha256, + gap_codes: result.state.gap_codes.into_iter().collect(), + warning_codes: result.state.warning_codes.into_iter().collect(), + failure_code: None, + requires_resume: false, + resume_available: false, + }, + Err(error) => { + let _ = store.release(&plan.resume_key).await; + match store.load(&plan.resume_key).await { + Ok(Some(state)) => { + let requires_resume = !state.progress.phase.is_terminal(); + let mut status = status_from_state(state, requires_resume); + status.failure_code = Some(snapshot_error_code(&error).to_string()); + status + } + _ => SnapshotJobStatus { + phase: SnapshotPhase::Failed, + failure_code: Some(snapshot_error_code(&error).to_string()), + requires_resume: false, + resume_available: false, + ..status_from_plan(&plan) + }, + } + } + }; + if let Ok(mut state) = terminal.lock() { + *state = Some(final_status); + } + }); + Ok(initial) + } + + pub async fn status( + &self, + run_id: &str, + fallback_mirror: &TallyMirrorRepository, + ) -> Result { + let tracked = { + let jobs = self + .jobs + .lock() + .map_err(|_| "snapshot_registry_unavailable")?; + jobs.get(run_id).map(|job| { + ( + job.plan.clone(), + job.mirror.clone(), + Arc::clone(&job.terminal), + ) + }) + }; + let Some((plan, mirror, terminal)) = tracked else { + let store = SqliteSnapshotStateStore::new(fallback_mirror.pool_clone()); + let state = store + .load_by_run_id(run_id) + .await + .map_err(|_| "snapshot_state_unavailable")? + .ok_or("snapshot_run_not_found")?; + let requires_resume = !state.progress.phase.is_terminal(); + return Ok(status_from_state(state, requires_resume)); + }; + if let Some(status) = terminal + .lock() + .map_err(|_| "snapshot_status_unavailable")? + .clone() + { + return Ok(status); + } + let store = SqliteSnapshotStateStore::new(mirror.pool_clone()); + let Some(state) = store + .load(&plan.resume_key) + .await + .map_err(|_| "snapshot_state_unavailable")? + else { + return Ok(status_from_plan(&plan)); + }; + Ok(status_from_state(state, false)) + } + + pub async fn recent( + &self, + mirror: &TallyMirrorRepository, + limit: u32, + ) -> Result, &'static str> { + let tracked = self + .jobs + .lock() + .map_err(|_| "snapshot_registry_unavailable")? + .iter() + .map(|(run_id, job)| { + let terminal = job.terminal.lock().ok().and_then(|status| status.clone()); + (run_id.clone(), terminal) + }) + .collect::>(); + let store = SqliteSnapshotStateStore::new(mirror.pool_clone()); + let states = store + .load_recent(limit) + .await + .map_err(|_| "snapshot_state_unavailable")?; + Ok(states + .into_iter() + .map(|state| { + if let Some(Some(status)) = tracked.get(&state.run_id) { + return status.clone(); + } + let requires_resume = + !state.progress.phase.is_terminal() && !tracked.contains_key(&state.run_id); + status_from_state(state, requires_resume) + }) + .collect()) + } + + pub fn cancel(&self, run_id: &str) -> Result { + let jobs = self + .jobs + .lock() + .map_err(|_| "snapshot_registry_unavailable")?; + let job = jobs.get(run_id).ok_or("snapshot_run_not_found")?; + if job + .terminal + .lock() + .map_err(|_| "snapshot_status_unavailable")? + .is_some() + { + return Ok(false); + } + job.cancellation.cancel(); + job.connector.cancel(); + Ok(true) + } +} + +fn snapshot_error_code(error: &SnapshotError) -> &'static str { + match error { + SnapshotError::InvalidPlan(_) => "snapshot_plan_invalid", + SnapshotError::ResumePlanMismatch => "snapshot_resume_plan_mismatch", + SnapshotError::ResumePlanUnavailable => "snapshot_resume_plan_unavailable", + SnapshotError::CorruptState => "snapshot_state_corrupt", + SnapshotError::StateMigrationMissing => "snapshot_state_migration_missing", + SnapshotError::LeaseUnavailable => "snapshot_run_owned_elsewhere", + SnapshotError::LeaseIo(_) => "snapshot_process_lock_failed", + SnapshotError::StateConflict => "snapshot_state_generation_changed", + SnapshotError::StateStore(_) => "snapshot_state_store_failed", + SnapshotError::Mirror(_) => "snapshot_mirror_failed", + SnapshotError::Reconciliation(_) => "snapshot_reconciliation_failed", + SnapshotError::ConcurrentCheckpoint => "snapshot_checkpoint_changed", + SnapshotError::StateInvariant(_) => "snapshot_state_invariant_failed", + SnapshotError::Serialization => "snapshot_serialization_failed", + } +} + +fn status_from_plan(plan: &SnapshotPlan) -> SnapshotJobStatus { + let (requested_from_yyyymmdd, requested_to_yyyymmdd) = requested_bounds(plan); + SnapshotJobStatus { + run_id: plan.run_id.clone(), + mirror_company_id: Some(plan.mirror_company_id.clone()), + pack_id: Some(plan.pack), + requested_from_yyyymmdd, + requested_to_yyyymmdd, + phase: SnapshotPhase::Prepare, + active_window_id: None, + completed_windows: 0, + total_windows: plan.windows.len() as u32, + verification: None, + proof_id: None, + proof_sha256: None, + gap_codes: Vec::new(), + warning_codes: Vec::new(), + failure_code: None, + requires_resume: false, + resume_available: false, + } +} + +fn status_from_state(state: DurableSnapshotState, requires_resume: bool) -> SnapshotJobStatus { + let resume_available = requires_resume && state.recoverable_plan().is_ok(); + let mirror_company_id = state + .plan + .as_ref() + .map(|plan| plan.mirror_company_id.clone()); + let pack_id = state.plan.as_ref().map(|plan| plan.pack); + let (requested_from_yyyymmdd, requested_to_yyyymmdd) = + state.plan.as_ref().map_or((None, None), requested_bounds); + SnapshotJobStatus { + run_id: state.run_id, + mirror_company_id, + pack_id, + requested_from_yyyymmdd, + requested_to_yyyymmdd, + phase: state.progress.phase, + active_window_id: state.progress.active_window_id, + completed_windows: state.progress.completed_windows, + total_windows: state.progress.total_windows, + verification: state.proof.as_ref().map(|proof| proof.verification), + proof_id: state + .commit_receipt + .as_ref() + .and_then(|receipt| receipt.proof_id.clone()), + proof_sha256: state + .commit_receipt + .as_ref() + .and_then(|receipt| receipt.proof_sha256.clone()), + gap_codes: state.gap_codes.into_iter().collect(), + warning_codes: state.warning_codes.into_iter().collect(), + failure_code: None, + requires_resume, + resume_available, + } +} + +fn requested_bounds(plan: &SnapshotPlan) -> (Option, Option) { + ( + plan.windows + .iter() + .map(|window| window.range.from_yyyymmdd.as_str()) + .min() + .map(str::to_string), + plan.windows + .iter() + .map(|window| window.range.to_yyyymmdd.as_str()) + .max() + .map(str::to_string), + ) +} + +#[cfg(test)] +pub(crate) fn status_from_state_for_test( + state: DurableSnapshotState, + requires_resume: bool, +) -> SnapshotJobStatus { + status_from_state(state, requires_resume) +} diff --git a/src-tauri/src/sync/incremental.rs b/src-tauri/src/sync/incremental.rs deleted file mode 100644 index a53dae5..0000000 --- a/src-tauri/src/sync/incremental.rs +++ /dev/null @@ -1,25 +0,0 @@ -use chrono::Utc; -use sqlx::SqlitePool; - -pub async fn update_altmastid( - pool: &SqlitePool, - company: &str, - obj_type: &str, - last_id: i64, -) -> anyhow::Result<()> { - sqlx::query( - "INSERT INTO altmastid_cache (company, obj_type, last_id, synced_at) - VALUES (?1, ?2, ?3, ?4) - ON CONFLICT(company, obj_type) DO UPDATE SET - last_id = excluded.last_id, - synced_at = excluded.synced_at", - ) - .bind(company) - .bind(obj_type) - .bind(last_id) - .bind(Utc::now().timestamp()) - .execute(pool) - .await?; - - Ok(()) -} diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index 5a1ef96..9675d27 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -1,4 +1,6 @@ pub mod conflict; +pub mod coordinator; pub mod engine; -pub mod incremental; +pub mod reconciliation; pub mod retry; +pub mod snapshot; diff --git a/src-tauri/src/sync/reconciliation.rs b/src-tauri/src/sync/reconciliation.rs new file mode 100644 index 0000000..c2dd490 --- /dev/null +++ b/src-tauri/src/sync/reconciliation.rs @@ -0,0 +1,2566 @@ +use std::collections::{BTreeMap, BTreeSet}; + +use bridge_tally_core::report_tie_out::TieOutState; +use bridge_tally_core::{ + CanonicalPackWindow, CanonicalText, CapabilityPackId, CapabilityState, CoreAccountingBatch, + Freshness, Gap, PackBatch, PackSchemaVersion, ProofManifest, ReadWindow, + RunOutcome as CoreRunOutcome, SourceCountScope, SourceCountScopeDescriptor, SourceIdentity, + SourceIdentityKind, SourceRecordEvidence, TallyDate, + VerificationState as CoreVerificationState, PROOF_CONTRACT_VERSION, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; + +use crate::db::tally_mirror::{ + Confidence, ObservationStatus, ObservedRecordInput, RunOutcome, SourceIdentityInput, + VerificationState, +}; + +const MAX_SAFE_DRILL_DOWN_IDS: usize = 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ComparisonScope { + Complete, + Window, + Unavailable, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum EndProfileCheck { + Passed, + Mismatch, + #[default] + Unavailable, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SourceStabilityCheck { + Passed, + Mismatch, + #[default] + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ObjectCountEvidence { + pub scope: ComparisonScope, + pub source_reported_count: Option, + pub parsed_count: u64, + pub accepted_count: u64, + pub deduped_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "scope", rename_all = "snake_case")] +pub enum ExternalReferenceCatalog { + Unavailable, + Complete { + company_ids: BTreeSet, + voucher_ids: BTreeSet, + ledger_ids: BTreeSet, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ReconciliationMismatch { + pub safe_reason_code: String, + pub safe_record_ids: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ReportTieOutEvidence { + pub source_identity: SourceIdentity, + pub pack: CapabilityPackId, + pub pack_schema_version: PackSchemaVersion, + pub query_profile: CanonicalText, + pub filters_sha256: CanonicalText, + pub from_yyyymmdd: String, + pub to_yyyymmdd: String, + pub report_sha256: String, + pub state: TieOutState, + pub compared_ledger_count: u64, + pub source_reported_count: u64, + pub core_ledger_count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct WindowEvidence { + pub window_id: String, + pub from_yyyymmdd: String, + pub to_yyyymmdd: String, + pub canonical_sha256: String, + #[serde(default)] + pub query_profile: String, + #[serde(default)] + pub filters_sha256: String, + /// Complete only when every canonical record has connector-supplied raw + /// provenance bound one-to-one. Unavailable is an explicit proof gap. + #[serde(default = "unavailable_comparison_scope")] + pub record_provenance_scope: ComparisonScope, + pub source_count_scope: ComparisonScope, + pub source_count: u64, + pub parsed_count: u64, + pub accepted_count: u64, + pub deduped_count: u64, + pub rejected_count: u64, + pub duplicate_identity_count: u64, + pub missing_identity_count: u64, + pub out_of_range_count: u64, + pub record_counts: BTreeMap, + pub accepted_record_counts: BTreeMap, + pub object_counts: BTreeMap, + /// Commitment to the complete ordered membership stored in the encrypted + /// mirror. The full identity map is hydrated only for reconciliation and + /// is never embedded in durable snapshot-state JSON. + #[serde(default)] + pub record_set_sha256: Option, + #[serde(default, skip_serializing)] + pub canonical_records: BTreeMap, + pub accounting_scope: ComparisonScope, + /// Fine-grained accounting checks that could not be evaluated for this + /// window. These codes are proof gaps, never warnings. + #[serde(default)] + pub accounting_gap_codes: BTreeSet, + pub report_tie_out_scope: ComparisonScope, + #[serde(default)] + pub report_tie_out: Option, + pub mismatches: Vec, +} + +fn unavailable_comparison_scope() -> ComparisonScope { + ComparisonScope::Unavailable +} + +#[derive(Debug, Clone)] +pub struct CanonicalObservation { + pub object_type: String, + pub source_id: String, + pub display_name: Option, + pub canonical_payload: Value, + pub exact_decimals: BTreeMap, + pub canonical_sha256: String, + pub provenance: Option, +} + +impl CanonicalObservation { + pub fn mirror_input( + &self, + batch_id: &str, + observed_at_unix_ms: i64, + ) -> Result { + let provenance = self + .provenance + .as_ref() + .ok_or(ReconciliationError::RecordProvenanceUnavailable)?; + let identity = SourceIdentityInput { + guid: provenance + .observed_identities + .guid + .as_ref() + .map(|value| value.as_str().to_string()), + remote_id: provenance + .observed_identities + .remote_id + .as_ref() + .map(|value| value.as_str().to_string()), + master_id: provenance + .observed_identities + .master_id + .as_ref() + .map(|value| value.as_str().to_string()), + fallback_fingerprint: (provenance.identity_kind == SourceIdentityKind::Fallback) + .then(|| self.source_id.clone()), + confidence: Some( + if provenance.identity_kind == SourceIdentityKind::Fallback { + Confidence::Inferred + } else { + Confidence::Observed + }, + ), + }; + Ok(ObservedRecordInput { + batch_id: batch_id.to_string(), + object_type: self.object_type.clone(), + display_name: self.display_name.clone(), + identity, + observed_at_unix_ms, + raw_source_sha256: provenance.raw_source_sha256.as_str().to_string(), + canonical_sha256: Some(self.canonical_sha256.clone()), + canonical_payload: Some(self.canonical_payload.clone()), + exact_decimals: self.exact_decimals.clone(), + observed_alter_id: provenance + .alter_id + .as_ref() + .map(|alter_id| alter_id.as_str().to_string()), + status: ObservationStatus::Accepted, + safe_rejection_code: None, + }) + } + + pub fn durable_key(&self) -> String { + format!( + "{}:{}:{}", + self.object_type, self.source_id, self.canonical_sha256 + ) + } +} + +#[derive(Debug, Clone)] +pub struct CanonicalWindow { + pub observations: Vec, + pub evidence: WindowEvidence, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ReconciliationError { + #[error("the returned pack does not match the requested pack")] + PackMismatch, + #[error("canonical serialization failed")] + Serialization, + #[error("canonical typed pack validation failed")] + InvalidTypedPack, + #[error("source count evidence is invalid")] + InvalidSourceCountEvidence, + #[error("source count evidence does not match the immutable request scope")] + SourceCountScopeMismatch, + #[error("record provenance evidence does not bind to the canonical batch")] + RecordEvidenceMismatch, + #[error("record provenance is unavailable")] + RecordProvenanceUnavailable, + #[error("invalid snapshot proof input ({0})")] + InvalidInput(&'static str), +} + +#[derive(Debug, Clone)] +pub struct ReconciliationInput { + pub batch_id: String, + pub run_id: String, + pub source_identity: SourceIdentity, + pub pack: CapabilityPackId, + pub pack_schema_version: PackSchemaVersion, + pub started_at_unix_ms: i64, + pub completed_at_unix_ms: i64, + pub freshness_before: Freshness, + pub freshness_target_seconds: i64, + pub planned_window_ids: BTreeSet, + pub completed_windows: BTreeMap, + pub end_profile_check: EndProfileCheck, + pub source_stability_check: SourceStabilityCheck, + pub explicit_gap_codes: BTreeSet, + pub warning_codes: BTreeSet, +} + +#[derive(Debug, Clone)] +pub struct ReconciliationDecision { + pub proof: ProofManifest, + pub mirror_commit: CommitBatchInput, + pub safe_mismatches: Vec, +} + +/// Sealed commit authority. Production code can only obtain this value from +/// the fail-closed reconciliation builders in this module. +#[derive(Debug, Clone)] +pub struct CommitBatchInput { + parts: CommitBatchParts, +} + +#[derive(Debug, Clone)] +pub(crate) struct CommitBatchParts { + pub batch_id: String, + pub proof_contract_version: u16, + pub outcome: RunOutcome, + pub verification: VerificationState, + pub completed_at_unix_ms: i64, + pub record_counts_sha256: Option, + pub snapshot_sha256: Option, + pub expected_checkpoint_before: Option, + pub checkpoint_after: Option, + pub freshness_target_seconds: i64, + pub gap_codes: Vec, + pub warning_codes: Vec, +} + +impl CommitBatchInput { + pub(in crate::sync) fn reconciled(parts: CommitBatchParts) -> Self { + Self { parts } + } + + pub(in crate::sync) fn parts(&self) -> &CommitBatchParts { + &self.parts + } + + pub(in crate::sync) fn bind_expected_checkpoint(&mut self, checkpoint: Option) { + self.parts.expected_checkpoint_before = checkpoint; + } + + pub(crate) fn into_parts(self) -> CommitBatchParts { + self.parts + } + + #[cfg(test)] + pub(crate) fn test_only(parts: CommitBatchParts) -> Self { + Self { parts } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TerminalKind { + Failed, + Cancelled, +} + +pub struct CanonicalWindowContext<'a> { + pub requested_pack: CapabilityPackId, + pub schema_version: PackSchemaVersion, + pub source_identity: &'a SourceIdentity, + pub query_profile: &'a CanonicalText, + pub filters_sha256: &'a CanonicalText, + pub external_references: &'a ExternalReferenceCatalog, + pub window_id: &'a str, + pub requested_window: &'a ReadWindow, +} + +pub fn canonicalize_window( + context: &CanonicalWindowContext<'_>, + window: &CanonicalPackWindow, +) -> Result { + window + .validate_source_count_evidence() + .map_err(|_| ReconciliationError::InvalidSourceCountEvidence)?; + window + .validate_record_evidence_binding() + .map_err(|_| ReconciliationError::RecordEvidenceMismatch)?; + if pack_id(&window.batch) != context.requested_pack { + return Err(ReconciliationError::PackMismatch); + } + + let mut canonical = match &window.batch { + PackBatch::CoreAccounting(core) => { + canonicalize_core_window(context.window_id, context.requested_window, core) + } + PackBatch::IndiaTax(batch) => canonicalize_india_tax_window( + context.window_id, + context.requested_window, + batch, + context.external_references, + ), + PackBatch::BillsAndPayments(batch) => canonicalize_bills_window( + context.window_id, + context.requested_window, + batch, + context.external_references, + ), + PackBatch::Inventory(batch) => canonicalize_inventory_window( + context.window_id, + context.requested_window, + batch, + context.external_references, + ), + }?; + canonical.evidence.query_profile = context.query_profile.as_str().to_string(); + canonical.evidence.filters_sha256 = context.filters_sha256.as_str().to_string(); + bind_record_provenance(&mut canonical, window)?; + bind_source_count_evidence( + &mut canonical.evidence, + window, + context.source_identity, + context.requested_pack, + context.schema_version, + context.query_profile, + context.filters_sha256, + context.requested_window, + )?; + Ok(canonical) +} + +fn bind_record_provenance( + canonical: &mut CanonicalWindow, + window: &CanonicalPackWindow, +) -> Result<(), ReconciliationError> { + let Some(record_evidence) = &window.record_evidence else { + canonical.evidence.record_provenance_scope = ComparisonScope::Unavailable; + return Ok(()); + }; + let mut evidence_by_record = record_evidence + .iter() + .cloned() + .map(|evidence| { + ( + ( + evidence.object_type.as_str().to_string(), + evidence.source_id.as_str().to_string(), + ), + evidence, + ) + }) + .collect::>(); + let mut inferred_ids = Vec::new(); + for observation in &mut canonical.observations { + observation.provenance = evidence_by_record.remove(&( + observation.object_type.clone(), + observation.source_id.clone(), + )); + if observation.provenance.is_none() { + return Err(ReconciliationError::RecordEvidenceMismatch); + } + if observation + .provenance + .as_ref() + .is_some_and(|evidence| evidence.identity_kind == SourceIdentityKind::Fallback) + { + inferred_ids.push(observation.source_id.clone()); + } + } + if !evidence_by_record.is_empty() { + return Err(ReconciliationError::RecordEvidenceMismatch); + } + canonical.evidence.record_provenance_scope = ComparisonScope::Complete; + if !inferred_ids.is_empty() { + canonical + .evidence + .mismatches + .push(safe_mismatch("inferred_record_identity", inferred_ids)); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn bind_source_count_evidence( + evidence: &mut WindowEvidence, + window: &CanonicalPackWindow, + source_identity: &SourceIdentity, + pack: CapabilityPackId, + schema_version: PackSchemaVersion, + query_profile: &CanonicalText, + filters_sha256: &CanonicalText, + requested_window: &ReadWindow, +) -> Result<(), ReconciliationError> { + let expected_object_types = pack_object_types(pack); + let supplied = window.source_counts.as_deref().unwrap_or_default(); + if supplied + .iter() + .any(|count| !expected_object_types.contains(&count.object_type.as_str())) + { + return Err(ReconciliationError::SourceCountScopeMismatch); + } + + let mut object_counts = BTreeMap::new(); + for &object_type in expected_object_types { + let parsed_count = evidence + .record_counts + .get(object_type) + .copied() + .unwrap_or(0); + let accepted_count = evidence + .accepted_record_counts + .get(object_type) + .copied() + .unwrap_or(0); + let deduped_count = evidence + .canonical_records + .keys() + .filter(|identity| { + identity + .split_once('\0') + .is_some_and(|(kind, _)| kind == object_type) + }) + .count() as u64; + let mut complete = None; + let mut window_only = None; + for count in supplied + .iter() + .filter(|count| count.object_type.as_str() == object_type) + { + let descriptor = SourceCountScopeDescriptor { + source_identity: source_identity.clone(), + pack, + pack_schema_version: schema_version, + object_type: count.object_type.clone(), + query_profile: query_profile.clone(), + filters_sha256: filters_sha256.clone(), + window: match count.source_count_scope { + SourceCountScope::Complete => None, + SourceCountScope::Window => Some(requested_window.clone()), + }, + }; + let matches = count + .matches_scope_descriptor(&descriptor) + .map_err(|_| ReconciliationError::InvalidSourceCountEvidence)?; + if !matches { + return Err(ReconciliationError::SourceCountScopeMismatch); + } + match count.source_count_scope { + SourceCountScope::Complete => complete = Some(count.source_reported_count), + SourceCountScope::Window => window_only = Some(count.source_reported_count), + } + } + let (scope, source_reported_count) = if let Some(count) = complete { + (ComparisonScope::Complete, Some(count)) + } else if let Some(count) = window_only { + (ComparisonScope::Window, Some(count)) + } else { + (ComparisonScope::Unavailable, None) + }; + object_counts.insert( + object_type.to_string(), + ObjectCountEvidence { + scope, + source_reported_count, + parsed_count, + accepted_count, + deduped_count, + }, + ); + } + evidence.source_count_scope = if object_counts + .values() + .all(|count| count.scope == ComparisonScope::Complete) + { + ComparisonScope::Complete + } else if object_counts + .values() + .any(|count| count.scope == ComparisonScope::Unavailable) + { + ComparisonScope::Unavailable + } else { + ComparisonScope::Window + }; + evidence.source_count = object_counts + .values() + .filter_map(|count| count.source_reported_count) + .sum(); + evidence.object_counts = object_counts; + Ok(()) +} + +pub fn build_reconciliation( + input: ReconciliationInput, +) -> Result { + validate_reconciliation_input(&input)?; + + let mut gaps = input.explicit_gap_codes; + // The current XML transport reads multiple reports sequentially and does + // not yet bracket them with an independently observed source watermark. + // That remains a proof gap even when every internal invariant below and a + // fresh end-of-run capability-profile comparison pass. + match input.source_stability_check { + SourceStabilityCheck::Passed => { + // Full semantic reread equality is strong drift evidence, but + // Tally documents no cross-request snapshot-isolation contract. + gaps.insert("source_cut_atomicity_unavailable".to_string()); + } + SourceStabilityCheck::Mismatch => { + gaps.insert("source_changed_during_run".to_string()); + } + SourceStabilityCheck::Unavailable => { + gaps.insert("source_cut_consistency_unavailable".to_string()); + } + } + match input.end_profile_check { + EndProfileCheck::Passed => {} + EndProfileCheck::Mismatch => { + gaps.insert("capability_profile_changed_during_run".to_string()); + } + EndProfileCheck::Unavailable => { + gaps.insert("capability_profile_drift_check_unavailable".to_string()); + } + } + let warnings = input.warning_codes; + let mut mismatches = Vec::new(); + let completed_ids = input.completed_windows.keys().cloned().collect(); + if input.planned_window_ids != completed_ids { + gaps.insert("missing_snapshot_window".to_string()); + } + + let mut record_counts = BTreeMap::new(); + let mut records_across_windows: BTreeMap = BTreeMap::new(); + let mut complete_source_counts = BTreeMap::new(); + let mut unique_identities_by_object: BTreeMap> = BTreeMap::new(); + let mut expected_count_objects = BTreeSet::new(); + let mut window_count_objects = BTreeSet::new(); + for evidence in input.completed_windows.values() { + if evidence.record_provenance_scope == ComparisonScope::Unavailable { + gaps.insert("record_provenance_unavailable".to_string()); + } + if evidence.parsed_count != evidence.accepted_count + evidence.rejected_count { + gaps.insert("parse_accept_count_mismatch".to_string()); + } + if evidence.accepted_count < evidence.deduped_count + || evidence + .accepted_count + .saturating_sub(evidence.deduped_count) + != evidence.duplicate_identity_count + { + gaps.insert("accept_dedupe_count_mismatch".to_string()); + } + if evidence.rejected_count > 0 { + gaps.insert("rejected_snapshot_records".to_string()); + } + if evidence.duplicate_identity_count > 0 { + gaps.insert("duplicate_source_identity".to_string()); + } + if evidence.missing_identity_count > 0 { + gaps.insert("missing_source_identity".to_string()); + } + if evidence.out_of_range_count > 0 { + gaps.insert("response_date_outside_window".to_string()); + } + if evidence.accounting_scope == ComparisonScope::Unavailable { + gaps.insert("accounting_reconciliation_unavailable".to_string()); + } + gaps.extend(evidence.accounting_gap_codes.iter().cloned()); + match &evidence.report_tie_out { + Some(report) + if report.source_identity == input.source_identity + && report.pack == input.pack + && report.pack_schema_version == input.pack_schema_version + && report.from_yyyymmdd == evidence.from_yyyymmdd + && report.to_yyyymmdd == evidence.to_yyyymmdd + && report.query_profile.as_str() == evidence.query_profile + && report.filters_sha256.as_str() == evidence.filters_sha256 + && is_lower_sha256(&report.report_sha256) + && report.state == TieOutState::Passed + && report.compared_ledger_count == report.source_reported_count + && report.compared_ledger_count == report.core_ledger_count => {} + Some(report) if report.state == TieOutState::Mismatch => { + gaps.insert("report_tie_out_mismatch".to_string()); + } + Some(report) if report.state == TieOutState::Unavailable => { + gaps.insert("period_report_profile_unobserved".to_string()); + } + Some(_) => { + gaps.insert("report_tie_out_evidence_invalid".to_string()); + } + None => { + gaps.insert("report_tie_out_unavailable".to_string()); + } + } + if !evidence.mismatches.is_empty() { + gaps.insert("reconciliation_mismatch".to_string()); + mismatches.extend(evidence.mismatches.clone()); + } + for (record_type, count) in &evidence.record_counts { + *record_counts.entry(record_type.clone()).or_insert(0) += count; + } + for (object_type, count) in &evidence.object_counts { + expected_count_objects.insert(object_type.clone()); + *record_counts + .entry(format!("{object_type}.parsed")) + .or_insert(0) += count.parsed_count; + *record_counts + .entry(format!("{object_type}.accepted")) + .or_insert(0) += count.accepted_count; + *record_counts + .entry(format!("{object_type}.deduped")) + .or_insert(0) += count.deduped_count; + match count.scope { + ComparisonScope::Unavailable => {} + ComparisonScope::Window => { + window_count_objects.insert(object_type.clone()); + if count.source_reported_count != Some(count.deduped_count) { + gaps.insert("window_source_accepted_count_mismatch".to_string()); + } + } + ComparisonScope::Complete => { + let reported = count + .source_reported_count + .expect("complete evidence has a source count"); + match complete_source_counts.insert(object_type.clone(), reported) { + Some(previous) if previous != reported => { + gaps.insert("complete_source_count_disagreement".to_string()); + } + _ => {} + } + } + } + } + for (identity, content_sha256) in &evidence.canonical_records { + let identity_parts = identity.split_once('\0'); + let object_scope = identity_parts + .and_then(|(object_type, _)| evidence.object_counts.get(object_type)) + .map_or(ComparisonScope::Unavailable, |count| count.scope); + if let Some((object_type, source_id)) = identity_parts { + unique_identities_by_object + .entry(object_type.to_string()) + .or_default() + .insert(source_id.to_string()); + } + match records_across_windows + .insert(identity.clone(), (content_sha256.clone(), object_scope)) + { + Some((previous, previous_scope)) if previous == *content_sha256 => { + if previous_scope != ComparisonScope::Complete + || object_scope != ComparisonScope::Complete + || !identity_parts + .is_some_and(|(object_type, _)| repeatable_complete_master(object_type)) + { + gaps.insert("duplicate_record_across_windows".to_string()); + if let Some((_, source_id)) = identity_parts { + mismatches.push(safe_mismatch( + "duplicate_record_across_windows", + vec![source_id.to_string()], + )); + } + } + } + Some(_) => { + gaps.insert("source_changed_during_snapshot".to_string()); + if let Some((_, source_id)) = identity_parts { + mismatches.push(safe_mismatch( + "source_changed_during_snapshot", + vec![source_id.to_string()], + )); + } + } + None => {} + } + } + } + for (object_type, count) in complete_source_counts { + let accepted_unique = unique_identities_by_object + .get(&object_type) + .map_or(0, |identities| identities.len() as u64); + if count != accepted_unique { + gaps.insert("source_accepted_count_mismatch".to_string()); + } + record_counts.insert(format!("{object_type}.source_reported_complete"), count); + record_counts.insert(format!("{object_type}.accepted_unique"), accepted_unique); + expected_count_objects.remove(&object_type); + window_count_objects.remove(&object_type); + } + for object_type in expected_count_objects { + if window_count_objects.contains(&object_type) { + gaps.insert("source_count_window_only".to_string()); + } else { + gaps.insert("source_count_unavailable".to_string()); + } + } + + mismatches.sort_by(|left, right| { + left.safe_reason_code + .cmp(&right.safe_reason_code) + .then(left.safe_record_ids.cmp(&right.safe_record_ids)) + }); + mismatches.dedup(); + + let verification = if gaps.is_empty() { + CoreVerificationState::Verified + } else { + CoreVerificationState::Partial + }; + let snapshot_sha256 = snapshot_sha256(&input.completed_windows)?; + let checkpoint_after = (verification == CoreVerificationState::Verified) + .then(|| format!("full:{snapshot_sha256}")); + + let proof_gaps = gaps + .iter() + .map(|code| Gap { + pack: input.pack, + field_or_invariant: code.clone(), + state: CapabilityState::Unknown, + safe_reason_code: code.clone(), + }) + .collect(); + let record_counts_sha256 = proof_record_counts_sha256(&record_counts); + let proof = ProofManifest { + proof_contract_version: PROOF_CONTRACT_VERSION, + run_id: input.run_id, + source_identity: input.source_identity, + pack: input.pack, + pack_schema_version: input.pack_schema_version, + outcome: CoreRunOutcome::Completed, + verification, + freshness: if verification == CoreVerificationState::Verified { + Freshness::Fresh + } else { + input.freshness_before + }, + started_at_unix_ms: input.started_at_unix_ms, + completed_at_unix_ms: Some(input.completed_at_unix_ms), + record_counts, + snapshot_sha256: Some(snapshot_sha256.clone()), + gaps: proof_gaps, + }; + let mirror_commit = CommitBatchInput::reconciled(CommitBatchParts { + batch_id: input.batch_id, + proof_contract_version: PROOF_CONTRACT_VERSION, + outcome: RunOutcome::Completed, + verification: if verification == CoreVerificationState::Verified { + VerificationState::Verified + } else { + VerificationState::Partial + }, + completed_at_unix_ms: input.completed_at_unix_ms, + record_counts_sha256: Some(record_counts_sha256), + snapshot_sha256: Some(snapshot_sha256), + expected_checkpoint_before: None, + checkpoint_after, + freshness_target_seconds: input.freshness_target_seconds, + gap_codes: gaps.into_iter().collect(), + warning_codes: warnings.into_iter().collect(), + }); + + Ok(ReconciliationDecision { + proof, + mirror_commit, + safe_mismatches: mismatches, + }) +} + +fn repeatable_complete_master(object_type: &str) -> bool { + matches!(object_type, "group" | "ledger" | "voucher_type") +} + +#[allow(clippy::too_many_arguments)] +pub fn build_terminal_proof( + batch_id: String, + run_id: String, + source_identity: SourceIdentity, + pack: CapabilityPackId, + pack_schema_version: PackSchemaVersion, + started_at_unix_ms: i64, + completed_at_unix_ms: i64, + freshness_before: Freshness, + freshness_target_seconds: i64, + kind: TerminalKind, + safe_reason_code: String, + mut gap_codes: BTreeSet, + warning_codes: BTreeSet, + record_counts: BTreeMap, +) -> ReconciliationDecision { + let clock_moved_backwards = completed_at_unix_ms < started_at_unix_ms; + let completed_at_unix_ms = completed_at_unix_ms.max(started_at_unix_ms); + if clock_moved_backwards { + gap_codes.insert("local_clock_moved_backwards".to_string()); + } + let (core_outcome, mirror_outcome) = match kind { + TerminalKind::Failed => (CoreRunOutcome::Failed, RunOutcome::Failed), + TerminalKind::Cancelled => (CoreRunOutcome::Cancelled, RunOutcome::Cancelled), + }; + gap_codes.insert(safe_reason_code); + let gaps = gap_codes + .iter() + .map(|code| Gap { + pack, + field_or_invariant: code.clone(), + state: CapabilityState::Unknown, + safe_reason_code: code.clone(), + }) + .collect(); + let record_counts_sha256 = proof_record_counts_sha256(&record_counts); + ReconciliationDecision { + proof: ProofManifest { + proof_contract_version: PROOF_CONTRACT_VERSION, + run_id, + source_identity, + pack, + pack_schema_version, + outcome: core_outcome, + verification: CoreVerificationState::Unverified, + freshness: freshness_before, + started_at_unix_ms, + completed_at_unix_ms: Some(completed_at_unix_ms), + record_counts, + snapshot_sha256: None, + gaps, + }, + mirror_commit: CommitBatchInput::reconciled(CommitBatchParts { + batch_id, + proof_contract_version: PROOF_CONTRACT_VERSION, + outcome: mirror_outcome, + verification: VerificationState::Unverified, + completed_at_unix_ms, + record_counts_sha256: Some(record_counts_sha256), + snapshot_sha256: None, + expected_checkpoint_before: None, + checkpoint_after: None, + freshness_target_seconds, + gap_codes: gap_codes.into_iter().collect(), + warning_codes: warning_codes.into_iter().collect(), + }), + safe_mismatches: Vec::new(), + } +} + +pub(crate) fn proof_record_counts_sha256(record_counts: &BTreeMap) -> String { + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-proof-record-counts-v1\0"); + digest.update((record_counts.len() as u64).to_be_bytes()); + for (key, count) in record_counts { + hash_framed(&mut digest, key.as_bytes()); + digest.update(count.to_be_bytes()); + } + hex_digest(digest.finalize()) +} + +fn canonicalize_core_window( + window_id: &str, + requested_window: &ReadWindow, + core: &CoreAccountingBatch, +) -> Result { + if core + .vouchers + .iter() + .any(|voucher| TallyDate::parse(voucher.date_yyyymmdd.clone()).is_err()) + { + return Err(ReconciliationError::InvalidTypedPack); + } + let mut observations = Vec::new(); + let mut duplicate_identity_count = 0_u64; + let mut missing_identity_count = 0_u64; + let mut record_counts = BTreeMap::new(); + let mut accepted_record_counts = BTreeMap::new(); + let mut seen = BTreeSet::new(); + let mut source_count = 0_u64; + + macro_rules! push_records { + ($records:expr, $object_type:literal, $name:expr, $decimals:expr) => {{ + let mut records = $records.iter().collect::>(); + records.sort_by(|left, right| { + left.source_id.cmp(&right.source_id).then_with(|| { + canonical_sha256(*left) + .unwrap_or_default() + .cmp(&canonical_sha256(*right).unwrap_or_default()) + }) + }); + for record in records { + source_count += 1; + *record_counts.entry($object_type.to_string()).or_insert(0) += 1; + if !mirror_safe_source_id(&record.source_id) { + missing_identity_count += 1; + continue; + } + *accepted_record_counts + .entry($object_type.to_string()) + .or_insert(0) += 1; + if !seen.insert(($object_type, record.source_id.as_str())) { + duplicate_identity_count += 1; + continue; + } + let canonical_payload = canonical_value( + serde_json::to_value(record).map_err(|_| ReconciliationError::Serialization)?, + ); + observations.push(CanonicalObservation { + object_type: $object_type.to_string(), + source_id: record.source_id.clone(), + display_name: $name(record), + canonical_sha256: canonical_sha256(&canonical_payload)?, + canonical_payload, + exact_decimals: $decimals(record), + provenance: None, + }); + } + }}; + } + + push_records!( + &core.groups, + "group", + |record: &bridge_tally_core::GroupRecord| Some(record.name.clone()), + |_record: &bridge_tally_core::GroupRecord| BTreeMap::new() + ); + push_records!( + &core.ledgers, + "ledger", + |record: &bridge_tally_core::LedgerRecord| Some(record.name.clone()), + |record: &bridge_tally_core::LedgerRecord| { + let mut values = BTreeMap::new(); + if let Some(value) = &record.opening_balance { + values.insert("opening_balance".to_string(), value.as_str().to_string()); + } + values + } + ); + push_records!( + &core.voucher_types, + "voucher_type", + |record: &bridge_tally_core::VoucherTypeRecord| Some(record.name.clone()), + |_record: &bridge_tally_core::VoucherTypeRecord| BTreeMap::new() + ); + push_records!( + &core.vouchers, + "voucher", + |_record: &bridge_tally_core::VoucherRecord| None, + |_record: &bridge_tally_core::VoucherRecord| BTreeMap::new() + ); + push_records!( + &core.ledger_entries, + "ledger_entry", + |_record: &bridge_tally_core::LedgerEntryRecord| None, + |record: &bridge_tally_core::LedgerEntryRecord| { + BTreeMap::from([("amount".to_string(), record.amount.as_str().to_string())]) + } + ); + + observations.sort_by(|left, right| { + left.object_type + .cmp(&right.object_type) + .then(left.source_id.cmp(&right.source_id)) + .then(left.canonical_sha256.cmp(&right.canonical_sha256)) + }); + let out_of_range_ids = core + .vouchers + .iter() + .filter(|voucher| { + voucher.date_yyyymmdd < requested_window.from_yyyymmdd + || voucher.date_yyyymmdd > requested_window.to_yyyymmdd + }) + .map(|voucher| voucher.source_id.clone()) + .collect::>(); + let out_of_range_count = out_of_range_ids.len() as u64; + let accounting = reconcile_core_accounting(core); + let mut mismatches = accounting.mismatches; + if !out_of_range_ids.is_empty() { + mismatches.push(safe_mismatch( + "response_date_outside_window", + out_of_range_ids, + )); + } + let canonical_sha256 = hash_observations(&observations); + let canonical_records = observations + .iter() + .map(|observation| { + ( + format!("{}\0{}", observation.object_type, observation.source_id), + observation.canonical_sha256.clone(), + ) + }) + .collect(); + let accepted_count = observations.len() as u64; + let validated_count = accepted_record_counts.values().sum(); + let rejected_count = source_count.saturating_sub(validated_count); + + Ok(CanonicalWindow { + observations, + evidence: WindowEvidence { + window_id: window_id.to_string(), + from_yyyymmdd: requested_window.from_yyyymmdd.clone(), + to_yyyymmdd: requested_window.to_yyyymmdd.clone(), + canonical_sha256, + query_profile: String::new(), + filters_sha256: String::new(), + record_provenance_scope: ComparisonScope::Unavailable, + source_count_scope: ComparisonScope::Unavailable, + source_count, + parsed_count: source_count, + accepted_count: validated_count, + deduped_count: accepted_count, + rejected_count, + duplicate_identity_count, + missing_identity_count, + out_of_range_count, + record_counts, + accepted_record_counts, + object_counts: BTreeMap::new(), + record_set_sha256: None, + canonical_records, + accounting_scope: ComparisonScope::Window, + accounting_gap_codes: accounting.gap_codes, + report_tie_out_scope: ComparisonScope::Unavailable, + report_tie_out: None, + mismatches, + }, + }) +} + +fn canonicalize_india_tax_window( + window_id: &str, + requested_window: &ReadWindow, + batch: &bridge_tally_core::IndiaTaxBatch, + external_references: &ExternalReferenceCatalog, +) -> Result { + batch + .validate() + .map_err(|_| ReconciliationError::InvalidTypedPack)?; + let mut observations = Vec::new(); + for record in &batch.tax_registrations { + observations.push(typed_observation( + "tax_registration", + record.source_id.as_str(), + None, + record, + BTreeMap::new(), + )?); + } + for record in &batch.voucher_taxes { + observations.push(typed_observation( + "voucher_tax", + record.source_id.as_str(), + None, + record, + BTreeMap::from([ + ( + "assessable_value".to_string(), + record.assessable_value.as_str().to_string(), + ), + ( + "tax_rate".to_string(), + record.tax_rate.as_exact_decimal().as_str().to_string(), + ), + ( + "tax_amount".to_string(), + record.tax_amount.as_str().to_string(), + ), + ]), + )?); + } + let mut mismatches = Vec::new(); + reconcile_india_tax_references(batch, external_references, &mut mismatches); + finish_typed_window( + window_id, + requested_window, + observations, + BTreeMap::from([ + ( + "tax_registration".to_string(), + batch.tax_registrations.len() as u64, + ), + ("voucher_tax".to_string(), batch.voucher_taxes.len() as u64), + ]), + mismatches, + ComparisonScope::Unavailable, + ) +} + +fn canonicalize_bills_window( + window_id: &str, + requested_window: &ReadWindow, + batch: &bridge_tally_core::BillsAndPaymentsBatch, + external_references: &ExternalReferenceCatalog, +) -> Result { + batch + .validate() + .map_err(|_| ReconciliationError::InvalidTypedPack)?; + let mut observations = Vec::new(); + let mut allocation_count = 0_u64; + let mut outstanding_count = 0_u64; + for party in &batch.parties { + observations.push(typed_observation( + "party_outstanding", + party.party_ledger_source_id.as_str(), + None, + party, + BTreeMap::new(), + )?); + for record in &party.allocations { + observations.push(typed_observation( + "bill_allocation", + record.source_id.as_str(), + record + .reference + .name + .as_ref() + .map(|name| name.as_str().to_string()), + record, + BTreeMap::from([("amount".to_string(), record.amount.as_str().to_string())]), + )?); + allocation_count = allocation_count.saturating_add(1); + } + for record in &party.outstanding { + let mut exact_decimals = BTreeMap::from([( + "pending_amount".to_string(), + record.pending_amount.as_str().to_string(), + )]); + if let Some(opening) = &record.opening_amount { + exact_decimals.insert("opening_amount".to_string(), opening.as_str().to_string()); + } + observations.push(typed_observation( + "bill_outstanding", + record.source_id.as_str(), + record + .reference + .name + .as_ref() + .map(|name| name.as_str().to_string()), + record, + exact_decimals, + )?); + outstanding_count = outstanding_count.saturating_add(1); + } + } + let mut mismatches = Vec::new(); + reconcile_bills_references(batch, external_references, &mut mismatches); + finish_typed_window( + window_id, + requested_window, + observations, + BTreeMap::from([ + ("party_outstanding".to_string(), batch.parties.len() as u64), + ("bill_allocation".to_string(), allocation_count), + ("bill_outstanding".to_string(), outstanding_count), + ]), + mismatches, + ComparisonScope::Unavailable, + ) +} + +fn canonicalize_inventory_window( + window_id: &str, + requested_window: &ReadWindow, + batch: &bridge_tally_core::InventoryBatch, + external_references: &ExternalReferenceCatalog, +) -> Result { + batch + .validate() + .map_err(|_| ReconciliationError::InvalidTypedPack)?; + let mut observations = Vec::new(); + for record in &batch.stock_items { + observations.push(typed_observation( + "stock_item", + record.source_id.as_str(), + Some(record.name.as_str().to_string()), + record, + BTreeMap::new(), + )?); + } + for record in &batch.godowns { + observations.push(typed_observation( + "godown", + record.source_id.as_str(), + Some(record.name.as_str().to_string()), + record, + BTreeMap::new(), + )?); + } + for record in &batch.inventory_entries { + observations.push(typed_observation( + "inventory_entry", + record.source_id.as_str(), + None, + record, + BTreeMap::from([ + ("quantity".to_string(), record.quantity.as_str().to_string()), + ("rate".to_string(), record.rate.as_str().to_string()), + ("amount".to_string(), record.amount.as_str().to_string()), + ]), + )?); + } + + let item_ids = batch + .stock_items + .iter() + .map(|record| record.source_id.as_str()) + .collect::>(); + let godown_ids = batch + .godowns + .iter() + .map(|record| record.source_id.as_str()) + .collect::>(); + let mut mismatches = Vec::new(); + let missing_items = batch + .inventory_entries + .iter() + .filter(|record| !item_ids.contains(record.stock_item_source_id.as_str())) + .map(|record| record.source_id.as_str().to_string()) + .collect::>(); + if !missing_items.is_empty() { + mismatches.push(safe_mismatch("stock_item_reference_missing", missing_items)); + } + let missing_godowns = batch + .inventory_entries + .iter() + .filter(|record| !godown_ids.contains(record.godown_source_id.as_str())) + .map(|record| record.source_id.as_str().to_string()) + .collect::>(); + if !missing_godowns.is_empty() { + mismatches.push(safe_mismatch("godown_reference_missing", missing_godowns)); + } + reconcile_inventory_voucher_references(batch, external_references, &mut mismatches); + finish_typed_window( + window_id, + requested_window, + observations, + BTreeMap::from([ + ("stock_item".to_string(), batch.stock_items.len() as u64), + ("godown".to_string(), batch.godowns.len() as u64), + ( + "inventory_entry".to_string(), + batch.inventory_entries.len() as u64, + ), + ]), + mismatches, + ComparisonScope::Unavailable, + ) +} + +fn typed_observation( + object_type: &str, + source_id: &str, + display_name: Option, + record: &impl Serialize, + exact_decimals: BTreeMap, +) -> Result { + if !mirror_safe_source_id(source_id) { + return Err(ReconciliationError::InvalidTypedPack); + } + let canonical_payload = canonical_value( + serde_json::to_value(record).map_err(|_| ReconciliationError::Serialization)?, + ); + Ok(CanonicalObservation { + object_type: object_type.to_string(), + source_id: source_id.to_string(), + display_name, + canonical_sha256: canonical_sha256(&canonical_payload)?, + canonical_payload, + exact_decimals, + provenance: None, + }) +} + +fn mirror_safe_source_id(value: &str) -> bool { + !value.is_empty() + && value.len() <= 256 + && value.trim() == value + && !value.chars().any(char::is_control) +} + +fn finish_typed_window( + window_id: &str, + requested_window: &ReadWindow, + mut observations: Vec, + record_counts: BTreeMap, + mismatches: Vec, + accounting_scope: ComparisonScope, +) -> Result { + observations.sort_by(|left, right| { + left.object_type + .cmp(&right.object_type) + .then(left.source_id.cmp(&right.source_id)) + .then(left.canonical_sha256.cmp(&right.canonical_sha256)) + }); + let canonical_sha256 = hash_observations(&observations); + let canonical_records = observations + .iter() + .map(|observation| { + ( + format!("{}\0{}", observation.object_type, observation.source_id), + observation.canonical_sha256.clone(), + ) + }) + .collect(); + let count = observations.len() as u64; + let accepted_record_counts = record_counts.clone(); + Ok(CanonicalWindow { + observations, + evidence: WindowEvidence { + window_id: window_id.to_string(), + from_yyyymmdd: requested_window.from_yyyymmdd.clone(), + to_yyyymmdd: requested_window.to_yyyymmdd.clone(), + canonical_sha256, + query_profile: String::new(), + filters_sha256: String::new(), + record_provenance_scope: ComparisonScope::Unavailable, + source_count_scope: ComparisonScope::Unavailable, + source_count: count, + parsed_count: count, + accepted_count: count, + deduped_count: count, + rejected_count: 0, + duplicate_identity_count: 0, + missing_identity_count: 0, + out_of_range_count: 0, + record_counts, + accepted_record_counts, + object_counts: BTreeMap::new(), + record_set_sha256: None, + canonical_records, + accounting_scope, + accounting_gap_codes: BTreeSet::new(), + report_tie_out_scope: ComparisonScope::Unavailable, + report_tie_out: None, + mismatches, + }, + }) +} + +fn reconcile_india_tax_references( + batch: &bridge_tally_core::IndiaTaxBatch, + references: &ExternalReferenceCatalog, + mismatches: &mut Vec, +) { + let ExternalReferenceCatalog::Complete { + company_ids, + voucher_ids, + ledger_ids, + } = references + else { + if !batch.tax_registrations.is_empty() || !batch.voucher_taxes.is_empty() { + mismatches.push(safe_mismatch( + "external_reference_scope_unavailable", + Vec::new(), + )); + } + return; + }; + let missing_owners = batch + .tax_registrations + .iter() + .filter(|record| match record.owner_kind { + bridge_tally_core::TaxRegistrationOwnerKind::Company => { + !company_ids.contains(record.owner_source_id.as_str()) + } + bridge_tally_core::TaxRegistrationOwnerKind::Ledger => { + !ledger_ids.contains(record.owner_source_id.as_str()) + } + }) + .map(|record| record.source_id.as_str().to_string()) + .collect::>(); + if !missing_owners.is_empty() { + mismatches.push(safe_mismatch( + "tax_registration_owner_reference_missing", + missing_owners, + )); + } + let missing_vouchers = batch + .voucher_taxes + .iter() + .filter(|record| !voucher_ids.contains(record.voucher_source_id.as_str())) + .map(|record| record.source_id.as_str().to_string()) + .collect::>(); + if !missing_vouchers.is_empty() { + mismatches.push(safe_mismatch( + "voucher_tax_reference_missing", + missing_vouchers, + )); + } +} + +fn reconcile_bills_references( + batch: &bridge_tally_core::BillsAndPaymentsBatch, + references: &ExternalReferenceCatalog, + mismatches: &mut Vec, +) { + let ExternalReferenceCatalog::Complete { + voucher_ids, + ledger_ids, + .. + } = references + else { + if !batch.parties.is_empty() { + mismatches.push(safe_mismatch( + "external_reference_scope_unavailable", + Vec::new(), + )); + } + return; + }; + let missing_party_ledgers = batch + .parties + .iter() + .filter(|party| !ledger_ids.contains(party.party_ledger_source_id.as_str())) + .map(|party| party.party_ledger_source_id.as_str().to_string()) + .collect::>(); + if !missing_party_ledgers.is_empty() { + mismatches.push(safe_mismatch( + "bill_party_ledger_reference_missing", + missing_party_ledgers, + )); + } + let missing_allocation_vouchers = batch + .parties + .iter() + .flat_map(|party| party.allocations.iter()) + .filter(|record| match &record.origin { + bridge_tally_core::BillAllocationOrigin::Voucher { + voucher_source_id, .. + } => !voucher_ids.contains(voucher_source_id.as_str()), + bridge_tally_core::BillAllocationOrigin::LedgerOpening => false, + }) + .map(|record| record.source_id.as_str().to_string()) + .collect::>(); + if !missing_allocation_vouchers.is_empty() { + mismatches.push(safe_mismatch( + "bill_allocation_voucher_reference_missing", + missing_allocation_vouchers, + )); + } + let missing_outstanding_vouchers = batch + .parties + .iter() + .flat_map(|party| party.outstanding.iter()) + .filter(|record| match &record.origin { + bridge_tally_core::OutstandingOrigin::Voucher { + voucher_source_id: Some(voucher_source_id), + } => !voucher_ids.contains(voucher_source_id.as_str()), + bridge_tally_core::OutstandingOrigin::Voucher { + voucher_source_id: None, + } + | bridge_tally_core::OutstandingOrigin::LedgerOpening + | bridge_tally_core::OutstandingOrigin::Unavailable => false, + }) + .map(|record| record.source_id.as_str().to_string()) + .collect::>(); + if !missing_outstanding_vouchers.is_empty() { + mismatches.push(safe_mismatch( + "bill_outstanding_voucher_reference_missing", + missing_outstanding_vouchers, + )); + } +} + +fn reconcile_inventory_voucher_references( + batch: &bridge_tally_core::InventoryBatch, + references: &ExternalReferenceCatalog, + mismatches: &mut Vec, +) { + let ExternalReferenceCatalog::Complete { voucher_ids, .. } = references else { + if !batch.inventory_entries.is_empty() { + mismatches.push(safe_mismatch( + "external_reference_scope_unavailable", + Vec::new(), + )); + } + return; + }; + let missing_vouchers = batch + .inventory_entries + .iter() + .filter(|record| !voucher_ids.contains(record.voucher_source_id.as_str())) + .map(|record| record.source_id.as_str().to_string()) + .collect::>(); + if !missing_vouchers.is_empty() { + mismatches.push(safe_mismatch( + "inventory_voucher_reference_missing", + missing_vouchers, + )); + } +} + +struct CoreAccountingReconciliation { + mismatches: Vec, + gap_codes: BTreeSet, +} + +fn reconcile_core_accounting(core: &CoreAccountingBatch) -> CoreAccountingReconciliation { + let assessment = bridge_tally_core::reconciliation::assess_core_accounting(core); + let mismatches = assessment + .issues + .into_iter() + .map(|issue| safe_mismatch(issue.safe_reason_code, issue.source_ids)) + .collect(); + let mut gap_codes = BTreeSet::new(); + if assessment.checks.voucher_entry_applicability + == bridge_tally_core::reconciliation::CheckState::Unavailable + { + gap_codes.insert("voucher_entry_applicability_unavailable".to_string()); + } + if assessment.checks.voucher_header_entry_total + == bridge_tally_core::reconciliation::CheckState::Unavailable + { + gap_codes.insert("voucher_header_entry_total_unavailable".to_string()); + } + if assessment.checks.voucher_entry_polarity + == bridge_tally_core::reconciliation::CheckState::Unavailable + { + gap_codes.insert("voucher_entry_polarity_unavailable".to_string()); + } + CoreAccountingReconciliation { + mismatches, + gap_codes, + } +} + +fn safe_mismatch(code: &str, ids: Vec) -> ReconciliationMismatch { + let mut safe_record_ids = ids + .into_iter() + .map(|source_id| safe_record_token(&source_id)) + .collect::>(); + safe_record_ids.sort(); + safe_record_ids.dedup(); + safe_record_ids.truncate(MAX_SAFE_DRILL_DOWN_IDS); + ReconciliationMismatch { + safe_reason_code: code.to_string(), + safe_record_ids, + } +} + +fn safe_record_token(source_id: &str) -> String { + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-safe-record-id-v1\0"); + hash_framed(&mut digest, source_id.as_bytes()); + format!("rid:{}", hex_digest(digest.finalize())) +} + +fn validate_reconciliation_input(input: &ReconciliationInput) -> Result<(), ReconciliationError> { + if input.batch_id.is_empty() + || input.run_id.is_empty() + || input.planned_window_ids.is_empty() + || input.freshness_target_seconds <= 0 + || input.completed_at_unix_ms < input.started_at_unix_ms + { + return Err(ReconciliationError::InvalidInput("run_metadata")); + } + for code in input + .explicit_gap_codes + .iter() + .chain(input.warning_codes.iter()) + { + if !is_safe_code(code) { + return Err(ReconciliationError::InvalidInput("safe_code")); + } + } + Ok(()) +} + +fn snapshot_sha256( + windows: &BTreeMap, +) -> Result { + #[derive(Serialize)] + struct SnapshotHashInput<'a> { + windows: &'a BTreeMap, + } + canonical_sha256(&SnapshotHashInput { windows }) +} + +fn hash_observations(observations: &[CanonicalObservation]) -> String { + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-window-v1\0"); + for observation in observations { + hash_framed(&mut digest, observation.object_type.as_bytes()); + hash_framed(&mut digest, observation.source_id.as_bytes()); + hash_framed(&mut digest, observation.canonical_sha256.as_bytes()); + } + hex_digest(digest.finalize()) +} + +fn hash_framed(digest: &mut Sha256, value: &[u8]) { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); +} + +fn canonical_sha256(value: &impl Serialize) -> Result { + let value = serde_json::to_value(value).map_err(|_| ReconciliationError::Serialization)?; + let bytes = serde_json::to_vec(&canonical_value(value)) + .map_err(|_| ReconciliationError::Serialization)?; + Ok(hex_digest(Sha256::digest(bytes))) +} + +fn canonical_value(value: Value) -> Value { + match value { + Value::Array(values) => Value::Array(values.into_iter().map(canonical_value).collect()), + Value::Object(values) => { + let ordered = values + .into_iter() + .map(|(key, value)| (key, canonical_value(value))) + .collect::>(); + serde_json::to_value(ordered).expect("BTreeMap JSON serialization cannot fail") + } + other => other, + } +} + +fn hex_digest(digest: impl AsRef<[u8]>) -> String { + digest + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn is_lower_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn is_safe_code(value: &str) -> bool { + !value.is_empty() + && value.len() <= 128 + && value.bytes().all(|byte| { + byte.is_ascii_lowercase() + || byte.is_ascii_digit() + || matches!(byte, b'_' | b'-' | b'.' | b':') + }) +} + +fn pack_id(batch: &PackBatch) -> CapabilityPackId { + match batch { + PackBatch::CoreAccounting(_) => CapabilityPackId::CoreAccounting, + PackBatch::IndiaTax(_) => CapabilityPackId::IndiaTax, + PackBatch::BillsAndPayments(_) => CapabilityPackId::BillsAndPayments, + PackBatch::Inventory(_) => CapabilityPackId::Inventory, + } +} + +fn pack_object_types(pack: CapabilityPackId) -> &'static [&'static str] { + match pack { + CapabilityPackId::CoreAccounting => { + &["group", "ledger", "voucher_type", "voucher", "ledger_entry"] + } + CapabilityPackId::IndiaTax => &["tax_registration", "voucher_tax"], + CapabilityPackId::BillsAndPayments => { + &["party_outstanding", "bill_allocation", "bill_outstanding"] + } + CapabilityPackId::Inventory => &["stock_item", "godown", "inventory_entry"], + } +} + +#[cfg(test)] +mod tests { + use bridge_tally_core::{ + source_count_scope_fingerprint, ExactDecimal, LedgerEntryPolarity, LedgerEntryRecord, + LedgerRecord, ObservedSourceIdentities, RawSourceSha256, SourceAlterId, SourceRecordId, + SourceReportedCountEvidence, VoucherRecord, VoucherTypeRecord, + }; + + use super::*; + use serde_json::json; + + fn source_identity() -> SourceIdentity { + SourceIdentity { + bridge_source_lineage: "lineage-1".to_string(), + company_guid: "company-guid".to_string(), + observed_fingerprint: "fingerprint".to_string(), + } + } + + fn balanced_batch(reverse: bool) -> PackBatch { + let mut batch = CoreAccountingBatch { + ledgers: vec![ + LedgerRecord { + source_id: "ledger-b".to_string(), + name: "B".to_string(), + parent_source_id: None, + opening_balance: None, + }, + LedgerRecord { + source_id: "ledger-a".to_string(), + name: "A".to_string(), + parent_source_id: None, + opening_balance: None, + }, + ], + voucher_types: vec![VoucherTypeRecord { + source_id: "sales".to_string(), + name: "Sales".to_string(), + }], + vouchers: vec![VoucherRecord { + source_id: "voucher-1".to_string(), + date_yyyymmdd: "20260701".to_string(), + voucher_type_source_id: "sales".to_string(), + voucher_number: Some("1".to_string()), + cancelled: false, + optional: false, + }], + ledger_entries: vec![ + LedgerEntryRecord { + source_id: "entry-credit".to_string(), + voucher_source_id: "voucher-1".to_string(), + ledger_source_id: "ledger-b".to_string(), + amount: ExactDecimal::parse("-100.00").unwrap(), + polarity: LedgerEntryPolarity::Debit, + }, + LedgerEntryRecord { + source_id: "entry-debit".to_string(), + voucher_source_id: "voucher-1".to_string(), + ledger_source_id: "ledger-a".to_string(), + amount: ExactDecimal::parse("100").unwrap(), + polarity: LedgerEntryPolarity::Credit, + }, + ], + ..CoreAccountingBatch::default() + }; + if reverse { + batch.ledgers.reverse(); + batch.ledger_entries.reverse(); + } + PackBatch::CoreAccounting(batch) + } + + fn window() -> ReadWindow { + ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260731".to_string(), + } + } + + fn query_profile() -> CanonicalText { + CanonicalText::parse("core-accounting-v1").unwrap() + } + + fn filters_sha256() -> CanonicalText { + CanonicalText::parse("a".repeat(64)).unwrap() + } + + fn canonicalize_test( + batch: PackBatch, + source_counts: Option>, + ) -> CanonicalWindow { + canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-1", + requested_window: &window(), + }, + &CanonicalPackWindow { + batch, + source_counts, + record_evidence: None, + }, + ) + .unwrap() + } + + fn complete_core_counts() -> Vec { + [ + ("group", 0), + ("ledger", 2), + ("voucher_type", 1), + ("voucher", 1), + ("ledger_entry", 2), + ] + .into_iter() + .map(|(object_type, source_reported_count)| { + let object_type = CanonicalText::parse(object_type).unwrap(); + let descriptor = SourceCountScopeDescriptor { + source_identity: source_identity(), + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + object_type: object_type.clone(), + query_profile: query_profile(), + filters_sha256: filters_sha256(), + window: None, + }; + SourceReportedCountEvidence { + object_type, + query_profile: query_profile(), + source_scope_fingerprint: source_count_scope_fingerprint( + &descriptor, + SourceCountScope::Complete, + ) + .unwrap(), + source_count_scope: SourceCountScope::Complete, + source_reported_count, + } + }) + .collect() + } + + fn complete_core_record_evidence() -> Vec { + [ + ("ledger", "ledger-a", SourceIdentityKind::Guid, '1'), + ("ledger", "ledger-b", SourceIdentityKind::RemoteId, '2'), + ("voucher_type", "sales", SourceIdentityKind::Fallback, '3'), + ("voucher", "voucher-1", SourceIdentityKind::MasterId, '4'), + ( + "ledger_entry", + "entry-credit", + SourceIdentityKind::RemoteId, + '5', + ), + ("ledger_entry", "entry-debit", SourceIdentityKind::Guid, '6'), + ] + .into_iter() + .map(|(object_type, source_id, identity_kind, hash_char)| { + let source_id = SourceRecordId::parse(source_id).unwrap(); + let mut observed_identities = ObservedSourceIdentities::default(); + match identity_kind { + SourceIdentityKind::Guid => observed_identities.guid = Some(source_id.clone()), + SourceIdentityKind::RemoteId => { + observed_identities.remote_id = Some(source_id.clone()) + } + SourceIdentityKind::MasterId => { + observed_identities.master_id = Some(source_id.clone()) + } + SourceIdentityKind::Fallback => {} + } + SourceRecordEvidence { + object_type: CanonicalText::parse(object_type).unwrap(), + source_id: source_id.clone(), + identity_kind, + observed_identities, + raw_source_sha256: RawSourceSha256::parse(hash_char.to_string().repeat(64)) + .unwrap(), + alter_id: (source_id.as_str() == "voucher-1") + .then(|| SourceAlterId::parse("alter:77").unwrap()), + } + }) + .collect() + } + + fn canonicalize_typed( + pack: CapabilityPackId, + batch: PackBatch, + external_references: ExternalReferenceCatalog, + ) -> CanonicalWindow { + canonicalize_window( + &CanonicalWindowContext { + requested_pack: pack, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &CanonicalText::parse("typed-pack-v1").unwrap(), + filters_sha256: &filters_sha256(), + external_references: &external_references, + window_id: "window-1", + requested_window: &window(), + }, + &CanonicalPackWindow::without_source_count_evidence(batch), + ) + .unwrap() + } + + fn input(evidence: WindowEvidence) -> ReconciliationInput { + ReconciliationInput { + batch_id: "batch-1".to_string(), + run_id: "run-1".to_string(), + source_identity: source_identity(), + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + started_at_unix_ms: 1_000, + completed_at_unix_ms: 2_000, + freshness_before: Freshness::NeverVerified, + freshness_target_seconds: 300, + planned_window_ids: BTreeSet::from(["window-1".to_string()]), + completed_windows: BTreeMap::from([("window-1".to_string(), evidence)]), + end_profile_check: EndProfileCheck::Unavailable, + source_stability_check: SourceStabilityCheck::Unavailable, + explicit_gap_codes: BTreeSet::new(), + warning_codes: BTreeSet::new(), + } + } + + #[test] + fn canonical_hash_is_stable_when_source_order_changes() { + let first = canonicalize_test(balanced_batch(false), None); + let second = canonicalize_test(balanced_batch(true), None); + assert_eq!( + first.evidence.canonical_sha256, + second.evidence.canonical_sha256 + ); + assert_eq!(first.observations.len(), 6); + assert!(first.evidence.mismatches.is_empty()); + } + + #[test] + fn mirror_input_preserves_identity_kind_raw_hash_and_alter_id() { + let source_window = CanonicalPackWindow { + batch: balanced_batch(false), + source_counts: None, + record_evidence: Some(complete_core_record_evidence()), + }; + let canonical = canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-1", + requested_window: &window(), + }, + &source_window, + ) + .unwrap(); + assert_eq!( + canonical.evidence.record_provenance_scope, + ComparisonScope::Complete + ); + let voucher = canonical + .observations + .iter() + .find(|record| record.object_type == "voucher") + .unwrap(); + let mirror = voucher.mirror_input("batch-1", 1_000).unwrap(); + assert_eq!(mirror.identity.master_id.as_deref(), Some("voucher-1")); + assert!(mirror.identity.guid.is_none()); + assert!(mirror.identity.remote_id.is_none()); + assert!(mirror.identity.fallback_fingerprint.is_none()); + assert_eq!(mirror.raw_source_sha256, "4".repeat(64)); + assert_ne!(mirror.raw_source_sha256, voucher.canonical_sha256); + assert_eq!(mirror.observed_alter_id.as_deref(), Some("alter:77")); + } + + #[test] + fn missing_or_mismatched_record_provenance_never_reaches_staging_as_fabricated_data() { + let missing = canonicalize_test(balanced_batch(false), None); + assert_eq!( + missing.evidence.record_provenance_scope, + ComparisonScope::Unavailable + ); + assert!(matches!( + missing.observations[0].mirror_input("batch-1", 1_000), + Err(ReconciliationError::RecordProvenanceUnavailable) + )); + let decision = build_reconciliation(input(missing.evidence)).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"record_provenance_unavailable".to_string())); + + let mut evidence = complete_core_record_evidence(); + evidence[0].object_type = CanonicalText::parse("group").unwrap(); + let result = canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-1", + requested_window: &window(), + }, + &CanonicalPackWindow { + batch: balanced_batch(false), + source_counts: None, + record_evidence: Some(evidence), + }, + ); + assert!(matches!( + result, + Err(ReconciliationError::RecordEvidenceMismatch) + )); + } + + #[test] + fn exact_decimal_reconciliation_detects_imbalance_without_float_math() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + core.ledger_entries[0].amount = ExactDecimal::parse("-99.999").unwrap(); + let canonical = canonicalize_test(batch, None); + assert!(canonical + .evidence + .mismatches + .iter() + .any(|mismatch| mismatch.safe_reason_code == "voucher_entries_unbalanced")); + let decision = build_reconciliation(input(canonical.evidence)).unwrap(); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"reconciliation_mismatch".to_string())); + } + + #[test] + fn tally_polarity_is_independently_reconciled_from_signed_amounts() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + core.ledger_entries[0].polarity = LedgerEntryPolarity::Credit; + let canonical = canonicalize_test(batch, None); + assert!(canonical + .evidence + .mismatches + .iter() + .any(|mismatch| { mismatch.safe_reason_code == "voucher_entry_polarity_mismatch" })); + let decision = build_reconciliation(input(canonical.evidence)).unwrap(); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"reconciliation_mismatch".to_string())); + } + + #[test] + fn cancelled_empty_voucher_is_not_a_false_missing_entry_failure() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + core.vouchers[0].cancelled = true; + core.ledger_entries.clear(); + let canonical = canonicalize_test(batch, None); + assert!(!canonical + .evidence + .accounting_gap_codes + .contains("voucher_entry_applicability_unavailable")); + assert!(!canonical + .evidence + .mismatches + .iter() + .any(|mismatch| mismatch.safe_reason_code == "voucher_entries_missing")); + } + + #[test] + fn unknown_non_cancelled_empty_voucher_applicability_is_a_proof_gap() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + core.ledger_entries.clear(); + let canonical = canonicalize_test(batch, None); + let decision = build_reconciliation(input(canonical.evidence)).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"voucher_entry_applicability_unavailable".to_string())); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); + } + + #[test] + fn zero_amount_polarity_unavailability_prevents_checkpoint() { + let mut batch = balanced_batch(false); + let PackBatch::CoreAccounting(core) = &mut batch else { + unreachable!() + }; + core.ledger_entries[0].amount = ExactDecimal::parse("-0.00").unwrap(); + core.ledger_entries[1].amount = ExactDecimal::parse("0").unwrap(); + let canonical = canonicalize_test(batch, None); + let decision = build_reconciliation(input(canonical.evidence)).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"voucher_entry_polarity_unavailable".to_string())); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); + } + + #[test] + fn missing_window_and_mismatch_can_never_create_a_checkpoint() { + let canonical = canonicalize_test(balanced_batch(false), None); + let mut input = input(canonical.evidence); + input.planned_window_ids.insert("window-2".to_string()); + let decision = build_reconciliation(input).unwrap(); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert_eq!(decision.mirror_commit.parts().checkpoint_after, None); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"missing_snapshot_window".to_string())); + } + + #[test] + fn complete_counts_cannot_claim_verified_without_required_report_tie_out() { + let canonical = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); + let first = build_reconciliation(input(canonical.evidence.clone())).unwrap(); + let second = build_reconciliation(input(canonical.evidence)).unwrap(); + assert_eq!(first.proof.verification, CoreVerificationState::Partial); + assert_eq!( + first.proof.snapshot_sha256, second.proof.snapshot_sha256, + "unchanged canonical state must hash identically" + ); + assert!(first.mirror_commit.parts().checkpoint_after.is_none()); + assert!(first + .mirror_commit + .parts() + .gap_codes + .contains(&"report_tie_out_unavailable".to_string())); + assert!(first + .mirror_commit + .parts() + .gap_codes + .contains(&"source_cut_consistency_unavailable".to_string())); + assert!(first + .mirror_commit + .parts() + .gap_codes + .contains(&"capability_profile_drift_check_unavailable".to_string())); + } + + #[test] + fn fresh_profile_and_full_reread_evidence_are_distinguished_from_atomicity() { + let canonical = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); + let mut evidence = input(canonical.evidence); + evidence.end_profile_check = EndProfileCheck::Passed; + evidence.source_stability_check = SourceStabilityCheck::Passed; + let decision = build_reconciliation(evidence).unwrap(); + let gaps = &decision.mirror_commit.parts().gap_codes; + assert!(!gaps.contains(&"capability_profile_drift_check_unavailable".to_string())); + assert!(!gaps.contains(&"source_cut_consistency_unavailable".to_string())); + assert!(gaps.contains(&"source_cut_atomicity_unavailable".to_string())); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); + } + + #[test] + fn end_profile_drift_is_a_proof_mismatch_not_cached_as_passed() { + let canonical = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); + let mut evidence = input(canonical.evidence); + evidence.end_profile_check = EndProfileCheck::Mismatch; + let decision = build_reconciliation(evidence).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"capability_profile_changed_during_run".to_string())); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); + } + + #[test] + fn missing_source_count_and_changed_identity_across_windows_fail_closed() { + let first = canonicalize_test(balanced_batch(false), None); + let unavailable = build_reconciliation(input(first.evidence.clone())).unwrap(); + assert_eq!( + unavailable.proof.verification, + CoreVerificationState::Partial + ); + assert!(unavailable + .mirror_commit + .parts() + .gap_codes + .contains(&"source_count_unavailable".to_string())); + assert!(unavailable.mirror_commit.parts().checkpoint_after.is_none()); + + let mut second_evidence = first.evidence.clone(); + second_evidence.window_id = "window-2".to_string(); + second_evidence.from_yyyymmdd = "20260801".to_string(); + second_evidence.to_yyyymmdd = "20260831".to_string(); + let identity = second_evidence + .canonical_records + .keys() + .next() + .expect("at least one record") + .clone(); + second_evidence + .canonical_records + .insert(identity, "f".repeat(64)); + let mut changed = input(first.evidence); + changed.planned_window_ids.insert("window-2".to_string()); + changed + .completed_windows + .insert("window-2".to_string(), second_evidence); + let decision = build_reconciliation(changed).unwrap(); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"source_changed_during_snapshot".to_string())); + assert!(decision.mirror_commit.parts().checkpoint_after.is_none()); + } + + #[test] + fn complete_count_once_covers_global_scope_without_per_window_repetition() { + let first = canonicalize_test(balanced_batch(false), Some(complete_core_counts())); + let second_window = ReadWindow { + from_yyyymmdd: "20260801".to_string(), + to_yyyymmdd: "20260831".to_string(), + }; + let second = canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-2", + requested_window: &second_window, + }, + &CanonicalPackWindow::without_source_count_evidence(PackBatch::CoreAccounting( + CoreAccountingBatch::default(), + )), + ) + .unwrap(); + let mut reconciliation = input(first.evidence); + reconciliation + .planned_window_ids + .insert("window-2".to_string()); + reconciliation + .completed_windows + .insert("window-2".to_string(), second.evidence); + let decision = build_reconciliation(reconciliation).unwrap(); + assert_eq!(decision.proof.verification, CoreVerificationState::Partial); + assert!(decision + .mirror_commit + .parts() + .gap_codes + .contains(&"report_tie_out_unavailable".to_string())); + assert!(!decision + .mirror_commit + .parts() + .gap_codes + .iter() + .any(|code| code.starts_with("source_count_"))); + } + + #[test] + fn mismatch_drill_down_never_retains_raw_printable_source_ids() { + let raw_source_id = "CUSTOMER-LEDGER-PRINTABLE-123"; + let mismatch = safe_mismatch( + "synthetic_reference_missing", + vec![raw_source_id.to_string()], + ); + let encoded = serde_json::to_string(&mismatch).unwrap(); + assert!(!encoded.contains(raw_source_id)); + assert_eq!(mismatch.safe_record_ids.len(), 1); + assert!(mismatch.safe_record_ids[0].starts_with("rid:")); + assert_eq!(mismatch.safe_record_ids[0].len(), 68); + } + + #[test] + fn terminal_proofs_never_advance_a_checkpoint() { + for kind in [TerminalKind::Failed, TerminalKind::Cancelled] { + let decision = build_terminal_proof( + "batch-1".to_string(), + "run-1".to_string(), + source_identity(), + CapabilityPackId::CoreAccounting, + PackSchemaVersion { major: 1, minor: 0 }, + 1_000, + 2_000, + Freshness::Fresh, + 300, + kind, + "window_extract_failed".to_string(), + BTreeSet::from(["earlier_gap".to_string()]), + BTreeSet::from(["earlier_warning".to_string()]), + BTreeMap::from([ + ("locally_staged.accepted".to_string(), 2), + ("locally_staged.rejected".to_string(), 1), + ]), + ); + assert_eq!(decision.mirror_commit.parts().checkpoint_after, None); + assert_eq!( + decision.proof.verification, + CoreVerificationState::Unverified + ); + assert_eq!( + decision.mirror_commit.parts().gap_codes, + vec!["earlier_gap", "window_extract_failed"] + ); + assert_eq!( + decision.mirror_commit.parts().warning_codes, + vec!["earlier_warning"] + ); + assert_eq!( + decision + .proof + .gaps + .iter() + .map(|gap| gap.safe_reason_code.as_str()) + .collect::>(), + vec!["earlier_gap", "window_extract_failed"] + ); + assert_eq!(decision.proof.record_counts["locally_staged.accepted"], 2); + assert_eq!(decision.proof.record_counts["locally_staged.rejected"], 1); + assert_eq!( + decision.mirror_commit.parts().record_counts_sha256, + Some(proof_record_counts_sha256(&decision.proof.record_counts)) + ); + } + } + + #[test] + fn terminal_proof_clamps_backward_clock_and_records_the_gap() { + let decision = build_terminal_proof( + "batch-clock".to_string(), + "run-clock".to_string(), + source_identity(), + CapabilityPackId::CoreAccounting, + PackSchemaVersion { major: 1, minor: 0 }, + 2_000, + 1_999, + Freshness::NeverVerified, + 300, + TerminalKind::Failed, + "source_outcome_unknown".to_string(), + BTreeSet::new(), + BTreeSet::new(), + BTreeMap::new(), + ); + assert_eq!(decision.proof.completed_at_unix_ms, Some(2_000)); + assert_eq!(decision.mirror_commit.parts().completed_at_unix_ms, 2_000); + assert_eq!( + decision.mirror_commit.parts().gap_codes, + vec!["local_clock_moved_backwards", "source_outcome_unknown"] + ); + } + + #[test] + fn source_count_scope_fingerprint_mismatch_is_rejected_before_staging() { + let mut counts = complete_core_counts(); + counts[0].source_scope_fingerprint = CanonicalText::parse("b".repeat(64)).unwrap(); + let result = canonicalize_window( + &CanonicalWindowContext { + requested_pack: CapabilityPackId::CoreAccounting, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + source_identity: &source_identity(), + query_profile: &query_profile(), + filters_sha256: &filters_sha256(), + external_references: &ExternalReferenceCatalog::Unavailable, + window_id: "window-1", + requested_window: &window(), + }, + &CanonicalPackWindow { + batch: balanced_batch(false), + source_counts: Some(counts), + record_evidence: None, + }, + ); + assert!(matches!( + result, + Err(ReconciliationError::SourceCountScopeMismatch) + )); + } + + #[test] + fn typed_packs_preserve_exact_values_and_enforce_reference_integrity() { + let references = ExternalReferenceCatalog::Complete { + company_ids: BTreeSet::from(["company-guid".to_string()]), + voucher_ids: BTreeSet::from(["voucher:1".to_string(), "voucher:2".to_string()]), + ledger_ids: BTreeSet::from(["ledger:customer".to_string()]), + }; + let tax = bridge_tally_core::IndiaTaxBatch { + tax_registrations: vec![serde_json::from_value(json!({ + "source_id": "tax-registration:1", + "owner_kind": "ledger", + "owner_source_id": "ledger:customer", + "registration_type": "regular", + "gstin": "27ABCDE1234F1Z5" + })) + .unwrap()], + voucher_taxes: vec![serde_json::from_value(json!({ + "source_id": "voucher-tax:1", + "voucher_source_id": "voucher:1", + "place_of_supply": "27", + "assessable_value": "1000.00", + "tax_component": "igst", + "tax_rate": "18.00", + // Deliberately not assessable*rate/100: no rounding/formula profile exists. + "tax_amount": "179.99" + })) + .unwrap()], + }; + let tax_window = canonicalize_typed( + CapabilityPackId::IndiaTax, + PackBatch::IndiaTax(tax), + references.clone(), + ); + assert_eq!(tax_window.observations.len(), 2); + assert!(tax_window.evidence.mismatches.is_empty()); + assert_eq!( + tax_window.observations[1] + .exact_decimals + .get("tax_amount") + .map(String::as_str), + Some("179.99") + ); + + let bills: bridge_tally_core::BillsAndPaymentsBatch = serde_json::from_value(json!({ + "parties": [{ + "source_identity": { + "bridge_source_lineage": "bridge-source:test", + "company_guid": "company-guid:test", + "observed_fingerprint": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + }, + "party_ledger_source_id": "ledger:customer", + "report_as_of_yyyymmdd": "20260731", + "direction": "receivable", + "bill_wise_state": "enabled_observed", + "allocation_coverage": "observed_complete_scope", + "outstanding_coverage": "observed_complete_scope", + "fetch_bracket": "stable_observed", + "query_profile": "bills-confidence-v1", + "source_scope_fingerprint": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "source_reported_allocation_count": 1, + "source_reported_outstanding_count": 1, + "allocations": [{ + "source_id": "bill:1", + "identity_basis": "parent_ordinal", + "origin": { + "origin": "voucher", + "voucher_source_id": "voucher:1", + "party_entry_source_id": "entry:party-1" + }, + "reference": { + "kind": "new_reference", + "name": "INV-1", + "raw_kind": null + }, + "bill_date_yyyymmdd": "20260701", + "effective_date_yyyymmdd": null, + "due_date_yyyymmdd": "20260731", + "due_date_evidence": "explicit", + "amount": "-1180.00", + "observed_polarity": "debit", + "currency_basis": { + "basis": "company_base", + "currency": "company-base" + } + }], + "outstanding": [{ + "source_id": "outstanding:1", + "identity_basis": "parent_ordinal", + "origin": { + "origin": "voucher", + "voucher_source_id": "voucher:1" + }, + "reference": { + "kind": "new_reference", + "name": "INV-1", + "raw_kind": null + }, + "bill_date_yyyymmdd": "20260701", + "effective_date_yyyymmdd": null, + "due_date_yyyymmdd": "20260731", + "due_date_evidence": "explicit", + "opening_amount": "-1180.00", + "pending_amount": "-1180.00", + "observed_polarity": "debit", + "source_reported_overdue_days": 0, + "currency_basis": { + "basis": "company_base", + "currency": "company-base" + } + }] + }] + })) + .unwrap(); + let bills_window = canonicalize_typed( + CapabilityPackId::BillsAndPayments, + PackBatch::BillsAndPayments(bills), + references.clone(), + ); + assert_eq!(bills_window.observations.len(), 3); + assert!(bills_window.evidence.mismatches.is_empty()); + + let inventory: bridge_tally_core::InventoryBatch = serde_json::from_value(json!({ + "stock_items": [{ + "source_id": "item:1", + "name": "Synthetic Item", + "base_unit": "nos" + }], + "godowns": [{ + "source_id": "godown:1", + "name": "Synthetic Location" + }], + "inventory_entries": [{ + "source_id": "inventory:1", + "voucher_source_id": "voucher:1", + "stock_item_source_id": "missing-item", + "godown_source_id": "godown:1", + "quantity": "2.000", + "rate": "500.00", + "amount": "999.99" + }] + })) + .unwrap(); + let inventory_window = canonicalize_typed( + CapabilityPackId::Inventory, + PackBatch::Inventory(inventory), + references, + ); + assert!(inventory_window + .evidence + .mismatches + .iter() + .any(|mismatch| mismatch.safe_reason_code == "stock_item_reference_missing")); + assert!(!inventory_window + .evidence + .mismatches + .iter() + .any(|mismatch| mismatch.safe_reason_code.contains("amount"))); + } +} diff --git a/src-tauri/src/sync/snapshot.rs b/src-tauri/src/sync/snapshot.rs new file mode 100644 index 0000000..e9f7b08 --- /dev/null +++ b/src-tauri/src/sync/snapshot.rs @@ -0,0 +1,6876 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs::{File, OpenOptions}; +use std::future::Future; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use bridge_tally_core::report_tie_out::{ + assess_core_period_report, scoped_mismatch_record_alias, TieOutState, +}; +use bridge_tally_core::{ + CanonicalText, CapabilityPackId, CapabilityProfile, CapabilityState, CompanyRef, + EvidenceConfidence, Freshness, PackBatch, PackSchemaVersion, ProofManifest, ReadResponseScope, + ReadWindow, RequestContext, TallyConnector, TallyError, TransportId, +}; +use chrono::{Duration as ChronoDuration, NaiveDate, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use sqlx::{Row, SqlitePool}; + +use crate::db::tally_mirror::{ + AbandonSnapshotWindowAttemptResult, BeginBatchInput, BeginSnapshotWindowAttemptInput, + CommitReceiptFacts, CommitResult, FreshnessState, MirrorError, ObservationCounts, + SnapshotWindowAttemptRef, SnapshotWindowMembershipInput, SnapshotWindowReceipt, + TallyMirrorRepository, +}; +use crate::sync::reconciliation::{ + build_reconciliation, build_terminal_proof, canonicalize_window, proof_record_counts_sha256, + CanonicalWindowContext, CommitBatchInput, CommitBatchParts, ComparisonScope, EndProfileCheck, + ExternalReferenceCatalog, ReconciliationDecision, ReconciliationError, ReconciliationInput, + ReconciliationMismatch, ReportTieOutEvidence, SourceStabilityCheck, TerminalKind, + WindowEvidence, +}; +use crate::tally::core_snapshot_start_authorized; + +const SNAPSHOT_STATE_VERSION: u16 = 5; +const LEGACY_SNAPSHOT_STATE_VERSION_V3: u16 = 3; +const LEGACY_SNAPSHOT_STATE_VERSION_V4: u16 = 4; +const MAX_DURABLE_STATE_BYTES: usize = 16 * 1024 * 1024; +const MAX_SNAPSHOT_PLAN_BYTES: usize = 4 * 1024 * 1024; +const MAX_SNAPSHOT_WINDOWS: usize = 1024; +const MAX_STAGED_KEYS_PER_WINDOW: usize = 1_000_000; +const MAX_WINDOW_STAGE_CHUNK: usize = 256; +// Reconciliation currently materializes one bounded canonical identity/hash map. Record keys are +// validated at 385 bytes and hashes at 64 bytes before staging, so this also places a conservative +// deterministic ceiling on transient reconciliation bytes without trusting allocator behavior. +const MAX_RECONCILIATION_RECORDS: u64 = 100_000; +const RECONCILIATION_RECORD_BUDGET_CODE: &str = "reconciliation_record_budget_exceeded"; +const WORKER_LEASE_TTL_MS: i64 = 5 * 60 * 1000; +#[cfg(not(test))] +const WORKER_LEASE_HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30); +#[cfg(test)] +const WORKER_LEASE_HEARTBEAT_INTERVAL: Duration = Duration::from_millis(25); +const CANCELLATION_POLL_INTERVAL: Duration = Duration::from_millis(100); + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct PlannedWindow { + pub id: String, + pub range: ReadWindow, + pub query_profile: CanonicalText, + pub filters_sha256: CanonicalText, +} + +impl PlannedWindow { + pub fn deterministic(pack: CapabilityPackId, range: ReadWindow) -> Self { + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-planned-window-v1\0"); + digest.update(pack_code(pack).as_bytes()); + digest.update(b"\0"); + digest.update(range.from_yyyymmdd.as_bytes()); + digest.update(b"\0"); + digest.update(range.to_yyyymmdd.as_bytes()); + let hash = hex_digest(digest.finalize()); + Self { + id: format!("window:{}", &hash[..24]), + range, + query_profile: CanonicalText::parse(match pack { + CapabilityPackId::CoreAccounting => "core_accounting_v2".to_string(), + _ => format!("{}_v1", pack_code(pack)), + }) + .expect("built-in query profile is canonical"), + filters_sha256: CanonicalText::parse(sha256_bytes( + format!("bridge-default-filter-v1:{}", pack_code(pack)).as_bytes(), + )) + .expect("SHA-256 is canonical text"), + } + } + + fn adaptive_child(parent: &Self, range: ReadWindow) -> Self { + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-adaptive-window-child-v1\0"); + digest.update(parent.id.as_bytes()); + digest.update(b"\0"); + digest.update(parent.query_profile.as_str().as_bytes()); + digest.update(b"\0"); + digest.update(parent.filters_sha256.as_str().as_bytes()); + digest.update(b"\0"); + digest.update(range.from_yyyymmdd.as_bytes()); + digest.update(b"\0"); + digest.update(range.to_yyyymmdd.as_bytes()); + let hash = hex_digest(digest.finalize()); + Self { + id: format!("window:{}", &hash[..24]), + range, + query_profile: parent.query_profile.clone(), + filters_sha256: parent.filters_sha256.clone(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitTrigger { + VoucherResponseSizeLimit, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitAlgorithm { + CalendarMidpointV1, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct AdaptiveWindowPolicy { + pub policy_version: u16, + pub split_trigger: SplitTrigger, + pub split_algorithm: SplitAlgorithm, + pub minimum_days: u16, + pub maximum_leaf_windows: u16, +} + +impl AdaptiveWindowPolicy { + pub fn bounded_default() -> Self { + Self { + policy_version: 1, + split_trigger: SplitTrigger::VoucherResponseSizeLimit, + split_algorithm: SplitAlgorithm::CalendarMidpointV1, + minimum_days: 1, + maximum_leaf_windows: MAX_SNAPSHOT_WINDOWS as u16, + } + } + + fn validate(&self) -> Result<(), SnapshotError> { + if self.policy_version != 1 + || self.split_trigger != SplitTrigger::VoucherResponseSizeLimit + || self.split_algorithm != SplitAlgorithm::CalendarMidpointV1 + || self.minimum_days != 1 + || self.maximum_leaf_windows == 0 + || usize::from(self.maximum_leaf_windows) > MAX_SNAPSHOT_WINDOWS + { + return Err(SnapshotError::InvalidPlan("adaptive_window_policy")); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct SnapshotPlan { + pub resume_key: String, + pub run_id: String, + pub capability_snapshot_id: String, + pub mirror_company_id: String, + pub company: CompanyRef, + pub pack: CapabilityPackId, + pub pack_schema_version: PackSchemaVersion, + #[serde(default)] + pub capability_profile_version: u16, + #[serde(default)] + pub capability_profile_sha256: String, + #[serde(default)] + pub source_product: String, + pub source_transport: String, + pub source_release: Option, + #[serde(default)] + pub source_mode: Option, + pub external_references: ExternalReferenceCatalog, + pub windows: Vec, + #[serde(default)] + pub adaptive_window_policy: Option, + #[serde(default)] + pub capability_canary_window: Option, + pub started_at_unix_ms: i64, + pub freshness_target_seconds: i64, +} + +impl SnapshotPlan { + pub fn fingerprint(&self) -> Result { + #[derive(Serialize)] + struct PlanFingerprint<'a> { + capability_snapshot_id: &'a str, + mirror_company_id: &'a str, + company: &'a CompanyRef, + pack: CapabilityPackId, + pack_schema_version: PackSchemaVersion, + capability_profile_version: u16, + capability_profile_sha256: &'a str, + source_product: &'a str, + source_transport: &'a str, + source_release: &'a Option, + source_mode: &'a Option, + external_references: &'a ExternalReferenceCatalog, + windows: &'a [PlannedWindow], + adaptive_window_policy: &'a Option, + capability_canary_window: &'a Option, + started_at_unix_ms: i64, + freshness_target_seconds: i64, + } + if self.adaptive_window_policy.is_none() && self.capability_canary_window.is_none() { + return self.legacy_fingerprint_v3(); + } + sha256_json(&PlanFingerprint { + capability_snapshot_id: &self.capability_snapshot_id, + mirror_company_id: &self.mirror_company_id, + company: &self.company, + pack: self.pack, + pack_schema_version: self.pack_schema_version, + capability_profile_version: self.capability_profile_version, + capability_profile_sha256: &self.capability_profile_sha256, + source_product: &self.source_product, + source_transport: &self.source_transport, + source_release: &self.source_release, + source_mode: &self.source_mode, + external_references: &self.external_references, + windows: &self.windows, + adaptive_window_policy: &self.adaptive_window_policy, + capability_canary_window: &self.capability_canary_window, + started_at_unix_ms: self.started_at_unix_ms, + freshness_target_seconds: self.freshness_target_seconds, + }) + } + + fn legacy_fingerprint_v3(&self) -> Result { + #[derive(Serialize)] + struct LegacyPlanFingerprint<'a> { + capability_snapshot_id: &'a str, + mirror_company_id: &'a str, + company: &'a CompanyRef, + pack: CapabilityPackId, + pack_schema_version: PackSchemaVersion, + capability_profile_version: u16, + capability_profile_sha256: &'a str, + source_product: &'a str, + source_transport: &'a str, + source_release: &'a Option, + source_mode: &'a Option, + external_references: &'a ExternalReferenceCatalog, + windows: &'a [PlannedWindow], + started_at_unix_ms: i64, + freshness_target_seconds: i64, + } + sha256_json(&LegacyPlanFingerprint { + capability_snapshot_id: &self.capability_snapshot_id, + mirror_company_id: &self.mirror_company_id, + company: &self.company, + pack: self.pack, + pack_schema_version: self.pack_schema_version, + capability_profile_version: self.capability_profile_version, + capability_profile_sha256: &self.capability_profile_sha256, + source_product: &self.source_product, + source_transport: &self.source_transport, + source_release: &self.source_release, + source_mode: &self.source_mode, + external_references: &self.external_references, + windows: &self.windows, + started_at_unix_ms: self.started_at_unix_ms, + freshness_target_seconds: self.freshness_target_seconds, + }) + } + + fn validate(&self) -> Result<(), SnapshotError> { + let policy = self + .adaptive_window_policy + .as_ref() + .ok_or(SnapshotError::InvalidPlan("adaptive_window_policy"))?; + policy.validate()?; + let canary = self + .capability_canary_window + .as_ref() + .ok_or(SnapshotError::InvalidPlan("capability_canary_window"))?; + if self.resume_key.is_empty() + || self.run_id.is_empty() + || self.capability_snapshot_id.is_empty() + || self.mirror_company_id.is_empty() + || self.windows.is_empty() + || self.windows.len() > MAX_SNAPSHOT_WINDOWS + || self.capability_profile_version == 0 + || !is_lower_sha256(&self.capability_profile_sha256) + || self.source_product.is_empty() + || self.source_product.len() > 128 + || self.source_product.chars().any(char::is_control) + || self.source_transport.is_empty() + || self.source_transport.len() > 64 + || self.source_transport.chars().any(char::is_control) + || self.source_release.as_ref().is_some_and(|value| { + value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) + }) + || self.source_mode.as_ref().is_some_and(|value| { + value.is_empty() || value.len() > 64 || value.chars().any(char::is_control) + }) + || self.freshness_target_seconds <= 0 + { + return Err(SnapshotError::InvalidPlan("run_metadata")); + } + let mut ids = BTreeSet::new(); + let mut ranges = BTreeSet::new(); + for window in &self.windows { + if window.id.is_empty() + || !valid_yyyymmdd(&window.range.from_yyyymmdd) + || !valid_yyyymmdd(&window.range.to_yyyymmdd) + || window.range.from_yyyymmdd > window.range.to_yyyymmdd + || !is_lower_sha256(window.filters_sha256.as_str()) + || !ids.insert(window.id.clone()) + || !ranges.insert(( + window.range.from_yyyymmdd.clone(), + window.range.to_yyyymmdd.clone(), + )) + { + return Err(SnapshotError::InvalidPlan("windows")); + } + } + let mut sorted_ranges = ranges.into_iter().collect::>(); + sorted_ranges.sort(); + for pair in sorted_ranges.windows(2) { + if pair[1].0 <= pair[0].1 { + return Err(SnapshotError::InvalidPlan("overlapping_windows")); + } + } + let canary_from = parse_yyyymmdd(&canary.range.from_yyyymmdd) + .ok_or(SnapshotError::InvalidPlan("capability_canary_window"))?; + let canary_to = parse_yyyymmdd(&canary.range.to_yyyymmdd) + .ok_or(SnapshotError::InvalidPlan("capability_canary_window"))?; + if canary_from != canary_to + || *canary + != PlannedWindow::deterministic( + self.pack, + ReadWindow { + from_yyyymmdd: canary.range.from_yyyymmdd.clone(), + to_yyyymmdd: canary.range.to_yyyymmdd.clone(), + }, + ) + || !self.windows.iter().any(|root| { + root.query_profile == canary.query_profile + && root.filters_sha256 == canary.filters_sha256 + && root.range.from_yyyymmdd <= canary.range.from_yyyymmdd + && root.range.to_yyyymmdd >= canary.range.to_yyyymmdd + }) + { + return Err(SnapshotError::InvalidPlan("capability_canary_window")); + } + if serde_json::to_vec(self) + .map_err(|_| SnapshotError::Serialization)? + .len() + > MAX_SNAPSHOT_PLAN_BYTES + { + return Err(SnapshotError::InvalidPlan("plan_size")); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum SnapshotPhase { + Prepare, + CapabilityCheck, + CompanyIdentityCheck, + PlanWindows, + Extract, + Normalize, + Validate, + Stage, + Reconcile, + CommitPending, + EmitProof, + Completed, + Partial, + Failed, + Cancelled, +} + +impl SnapshotPhase { + pub fn is_terminal(self) -> bool { + matches!( + self, + Self::Completed | Self::Partial | Self::Failed | Self::Cancelled + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum WindowPhase { + Pending, + Extracting, + Normalizing, + Validating, + Staging, + Complete, + Split, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct WindowSplit { + pub policy_version: u16, + pub trigger: SplitTrigger, + pub algorithm: SplitAlgorithm, + pub left_window_id: String, + pub right_window_id: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct WindowStageAttempt { + pub attempt_id: String, + pub batch_id: String, + pub window_id: String, + pub attempt_ordinal: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct WindowStageReceipt { + pub attempt: WindowStageAttempt, + pub member_count: u32, + pub membership_sha256: String, + pub receipt_sha256: String, +} + +impl From<&SnapshotWindowAttemptRef> for WindowStageAttempt { + fn from(value: &SnapshotWindowAttemptRef) -> Self { + Self { + attempt_id: value.attempt_id.clone(), + batch_id: value.batch_id.clone(), + window_id: value.window_id.clone(), + attempt_ordinal: value.attempt_ordinal, + } + } +} + +impl WindowStageAttempt { + fn repository_ref(&self) -> SnapshotWindowAttemptRef { + SnapshotWindowAttemptRef { + attempt_id: self.attempt_id.clone(), + batch_id: self.batch_id.clone(), + window_id: self.window_id.clone(), + attempt_ordinal: self.attempt_ordinal, + } + } +} + +impl From<&SnapshotWindowReceipt> for WindowStageReceipt { + fn from(value: &SnapshotWindowReceipt) -> Self { + Self { + attempt: WindowStageAttempt { + attempt_id: value.attempt_id.clone(), + batch_id: value.batch_id.clone(), + window_id: value.window_id.clone(), + attempt_ordinal: value.attempt_ordinal, + }, + member_count: value.member_count, + membership_sha256: value.membership_sha256.clone(), + receipt_sha256: value.receipt_sha256.clone(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct WindowProgress { + pub planned: PlannedWindow, + pub phase: WindowPhase, + #[serde(default)] + pub parent_window_id: Option, + #[serde(default)] + pub split: Option, + /// Legacy v4 identity set. New v5 states keep this empty and use the + /// normalized encrypted membership table instead. + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + pub staged_record_keys: BTreeSet, + #[serde(default)] + pub stage_attempt: Option, + #[serde(default)] + pub stage_receipt: Option, + pub evidence: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct PhaseProgress { + pub phase: SnapshotPhase, + pub active_window_id: Option, + pub completed_windows: u32, + pub total_windows: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] +enum PendingDecisionKind { + Reconciled, + Failed, + Cancelled, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +struct PendingCommit { + kind: PendingDecisionKind, + completed_at_unix_ms: i64, + safe_reason_code: Option, + intended_checkpoint: Option, + #[serde(default)] + expected_receipt_facts_sha256: Option, + /// Compact, hash-bound authority for exact local recovery. Canonical membership stays in + /// normalized SQLite and is deliberately not embedded here. + #[serde(default, skip_serializing_if = "Option::is_none")] + reconciled_proof: Option>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct StoredCommitReceipt { + pub proof_id: Option, + pub proof_sha256: Option, + pub checkpoint_advanced: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct DurableSnapshotState { + pub state_version: u16, + #[serde(default)] + pub generation: u64, + #[serde(skip)] + pub row_integrity_bound: bool, + pub resume_key: String, + pub run_id: String, + pub plan_sha256: String, + /// Existing v2 states without this field remain inspectable, but cannot be reconstructed and + /// resumed after restart without inventing missing immutable-plan authority. + #[serde(default)] + pub plan: Option, + pub batch_id: Option, + pub checkpoint_before: Option, + pub freshness_before: Freshness, + pub progress: PhaseProgress, + pub windows: BTreeMap, + pub gap_codes: BTreeSet, + pub warning_codes: BTreeSet, + #[serde(default)] + pub end_profile_check: EndProfileCheck, + #[serde(default)] + pub source_stability_check: SourceStabilityCheck, + pending_commit: Option, + pub proof: Option, + pub commit_receipt: Option, +} + +impl DurableSnapshotState { + pub fn new(plan: &SnapshotPlan, freshness_before: Freshness) -> Result { + plan.validate()?; + let windows = plan + .windows + .iter() + .cloned() + .map(|planned| { + ( + planned.id.clone(), + WindowProgress { + planned, + phase: WindowPhase::Pending, + parent_window_id: None, + split: None, + staged_record_keys: BTreeSet::new(), + stage_attempt: None, + stage_receipt: None, + evidence: None, + }, + ) + }) + .collect(); + Ok(Self { + state_version: SNAPSHOT_STATE_VERSION, + generation: 0, + row_integrity_bound: false, + resume_key: plan.resume_key.clone(), + run_id: plan.run_id.clone(), + plan_sha256: plan.fingerprint()?, + plan: Some(plan.clone()), + batch_id: None, + checkpoint_before: None, + freshness_before, + progress: PhaseProgress { + phase: SnapshotPhase::Prepare, + active_window_id: None, + completed_windows: 0, + total_windows: plan.windows.len() as u32, + }, + windows, + gap_codes: BTreeSet::new(), + warning_codes: BTreeSet::new(), + end_profile_check: EndProfileCheck::Unavailable, + source_stability_check: SourceStabilityCheck::Unavailable, + pending_commit: None, + proof: None, + commit_receipt: None, + }) + } + + pub fn assert_resumable_with(&self, plan: &SnapshotPlan) -> Result<(), SnapshotError> { + if self.state_version != SNAPSHOT_STATE_VERSION { + return Err(SnapshotError::ResumePlanUnavailable); + } + plan.validate()?; + if self.resume_key != plan.resume_key + || self.run_id != plan.run_id + || self.plan_sha256 != plan.fingerprint()? + { + return Err(SnapshotError::ResumePlanMismatch); + } + Ok(()) + } + + pub fn recoverable_plan(&self) -> Result { + if self.state_version != SNAPSHOT_STATE_VERSION + || !self.row_integrity_bound + || self.generation == 0 + { + return Err(SnapshotError::ResumePlanUnavailable); + } + let plan = self + .plan + .clone() + .ok_or(SnapshotError::ResumePlanUnavailable)?; + self.assert_resumable_with(&plan)?; + Ok(plan) + } + + fn validate_invariants(&self) -> Result<(), SnapshotError> { + if !matches!( + self.state_version, + LEGACY_SNAPSHOT_STATE_VERSION_V3 + | LEGACY_SNAPSHOT_STATE_VERSION_V4 + | SNAPSHOT_STATE_VERSION + ) || self.resume_key.is_empty() + || self.run_id.is_empty() + || !is_lower_sha256(&self.plan_sha256) + || self.windows.is_empty() + || self.windows.len() > MAX_SNAPSHOT_WINDOWS + || self.progress.total_windows as usize != self.executable_leaves().len() + || self.progress.completed_windows as usize + != self + .windows + .values() + .filter(|window| window.phase == WindowPhase::Complete) + .count() + || self.windows.values().any(|window| { + window.staged_record_keys.len() > MAX_STAGED_KEYS_PER_WINDOW + || (self.state_version == SNAPSHOT_STATE_VERSION + && !window.staged_record_keys.is_empty()) + }) + { + return Err(SnapshotError::CorruptState); + } + if let Some(active) = &self.progress.active_window_id { + if self + .windows + .get(active) + .is_none_or(|window| window.phase == WindowPhase::Split) + || self.progress.phase.is_terminal() + { + return Err(SnapshotError::CorruptState); + } + } + if let Some(plan) = &self.plan { + if self.state_version == SNAPSHOT_STATE_VERSION { + self.assert_resumable_with(plan)?; + validate_window_graph(self, plan)?; + for window in self.windows.values() { + let stage_shape_valid = match window.phase { + WindowPhase::Complete => { + window.stage_attempt.is_none() + && window.stage_receipt.is_some() + && window.evidence.is_some() + } + WindowPhase::Staging => { + window.stage_attempt.is_some() && window.stage_receipt.is_none() + } + WindowPhase::Split => { + window.stage_attempt.is_none() + && window.stage_receipt.is_none() + && window.evidence.is_none() + } + _ => window.stage_attempt.is_none() && window.stage_receipt.is_none(), + }; + if !stage_shape_valid + || window.stage_attempt.as_ref().is_some_and(|attempt| { + attempt.window_id != window.planned.id + || attempt.batch_id != self.batch_id.clone().unwrap_or_default() + || attempt.attempt_id.is_empty() + || attempt.attempt_ordinal == 0 + }) + || window.stage_receipt.as_ref().is_some_and(|receipt| { + receipt.attempt.window_id != window.planned.id + || receipt.attempt.batch_id + != self.batch_id.clone().unwrap_or_default() + || receipt.member_count as u64 + != window + .evidence + .as_ref() + .map_or(0, |evidence| evidence.deduped_count) + || window.evidence.as_ref().is_none_or(|evidence| { + evidence.record_set_sha256.as_deref() + != Some(receipt.membership_sha256.as_str()) + }) + || !is_lower_sha256(&receipt.membership_sha256) + || !is_lower_sha256(&receipt.receipt_sha256) + }) + { + return Err(SnapshotError::CorruptState); + } + } + } else if self.state_version == LEGACY_SNAPSHOT_STATE_VERSION_V4 { + if self.plan_sha256 != plan.fingerprint()? { + return Err(SnapshotError::CorruptState); + } + validate_window_graph(self, plan)?; + } else if self.plan_sha256 != plan.legacy_fingerprint_v3()? + || plan.windows.len() != self.windows.len() + || plan.windows.iter().any(|planned| { + self.windows.get(&planned.id).is_none_or(|window| { + window.planned != *planned + || window.parent_window_id.is_some() + || window.split.is_some() + }) + }) + { + return Err(SnapshotError::CorruptState); + } + } + let terminal = self.progress.phase.is_terminal(); + let terminal_evidence_valid = if terminal { + self.proof.is_some() && self.commit_receipt.is_some() + } else { + self.proof.is_none() && self.commit_receipt.is_none() + }; + if !terminal_evidence_valid + || (self.progress.phase == SnapshotPhase::CommitPending) + != self.pending_commit.is_some() + { + return Err(SnapshotError::CorruptState); + } + if self.pending_commit.as_ref().is_some_and(|pending| { + pending + .expected_receipt_facts_sha256 + .as_deref() + .is_none_or(|hash| !is_lower_sha256(hash)) + }) { + return Err(SnapshotError::CorruptState); + } + if let Some(receipt) = &self.commit_receipt { + if receipt.proof_id.as_ref().is_none_or(String::is_empty) + || receipt + .proof_sha256 + .as_deref() + .is_none_or(|hash| !is_lower_sha256(hash)) + { + return Err(SnapshotError::CorruptState); + } + } + if let Some(proof) = &self.proof { + if proof.run_id != self.run_id { + return Err(SnapshotError::CorruptState); + } + if let Some(plan) = &self.plan { + if proof.source_identity != plan.company.identity + || proof.pack != plan.pack + || proof.pack_schema_version != plan.pack_schema_version + { + return Err(SnapshotError::CorruptState); + } + } + } + Ok(()) + } + + fn set_phase(&mut self, phase: SnapshotPhase, active_window_id: Option) { + self.progress.phase = phase; + self.progress.active_window_id = active_window_id; + self.progress.completed_windows = self + .windows + .values() + .filter(|window| window.phase == WindowPhase::Complete) + .count() as u32; + self.progress.total_windows = self.executable_leaves().len() as u32; + } + + fn executable_leaves(&self) -> Vec { + let mut leaves = self + .windows + .values() + .filter(|window| window.phase != WindowPhase::Split) + .map(|window| window.planned.clone()) + .collect::>(); + leaves.sort_by(|left, right| { + left.range + .from_yyyymmdd + .cmp(&right.range.from_yyyymmdd) + .then_with(|| left.range.to_yyyymmdd.cmp(&right.range.to_yyyymmdd)) + .then_with(|| left.id.cmp(&right.id)) + }); + leaves + } +} + +enum SplitLeafResult { + Created, + MinimumReached, + LeafLimitReached, +} + +fn split_leaf( + state: &mut DurableSnapshotState, + plan: &SnapshotPlan, + window_id: &str, +) -> Result { + let policy = plan + .adaptive_window_policy + .as_ref() + .ok_or(SnapshotError::StateInvariant("adaptive_window_policy"))?; + let parent = state + .windows + .get(window_id) + .ok_or(SnapshotError::StateInvariant("window"))?; + if parent.phase != WindowPhase::Extracting + || parent.split.is_some() + || !parent.staged_record_keys.is_empty() + || parent.stage_attempt.is_some() + || parent.stage_receipt.is_some() + || parent.evidence.is_some() + { + return Err(SnapshotError::StateInvariant("split_window_transition")); + } + let Some((left_range, right_range)) = midpoint_split(&parent.planned.range) else { + return Ok(SplitLeafResult::MinimumReached); + }; + if state + .windows + .len() + .checked_add(2) + .is_none_or(|nodes| nodes > MAX_SNAPSHOT_WINDOWS) + || state.executable_leaves().len().saturating_add(1) + > usize::from(policy.maximum_leaf_windows) + { + return Ok(SplitLeafResult::LeafLimitReached); + } + let parent_planned = parent.planned.clone(); + let left = PlannedWindow::adaptive_child(&parent_planned, left_range); + let right = PlannedWindow::adaptive_child(&parent_planned, right_range); + if left.id == right.id + || state.windows.contains_key(&left.id) + || state.windows.contains_key(&right.id) + { + return Err(SnapshotError::StateInvariant("adaptive_window_identity")); + } + let split = WindowSplit { + policy_version: policy.policy_version, + trigger: policy.split_trigger, + algorithm: policy.split_algorithm, + left_window_id: left.id.clone(), + right_window_id: right.id.clone(), + }; + let progress = state + .windows + .get_mut(window_id) + .ok_or(SnapshotError::StateInvariant("window"))?; + progress.phase = WindowPhase::Split; + progress.split = Some(split); + for child in [left, right] { + state.windows.insert( + child.id.clone(), + WindowProgress { + planned: child, + phase: WindowPhase::Pending, + parent_window_id: Some(window_id.to_string()), + split: None, + staged_record_keys: BTreeSet::new(), + stage_attempt: None, + stage_receipt: None, + evidence: None, + }, + ); + } + state + .warning_codes + .insert("adaptive_window_split".to_string()); + state.set_phase(SnapshotPhase::PlanWindows, None); + Ok(SplitLeafResult::Created) +} + +fn validate_window_graph( + state: &DurableSnapshotState, + plan: &SnapshotPlan, +) -> Result<(), SnapshotError> { + let policy = plan + .adaptive_window_policy + .as_ref() + .ok_or(SnapshotError::CorruptState)?; + let root_ids = plan + .windows + .iter() + .map(|window| window.id.clone()) + .collect::>(); + if root_ids.len() != plan.windows.len() + || plan.windows.iter().any(|planned| { + state.windows.get(&planned.id).is_none_or(|window| { + window.planned != *planned || window.parent_window_id.is_some() + }) + }) + { + return Err(SnapshotError::CorruptState); + } + + let mut visited = BTreeSet::new(); + let mut stack = root_ids.iter().cloned().collect::>(); + while let Some(window_id) = stack.pop() { + if !visited.insert(window_id.clone()) { + return Err(SnapshotError::CorruptState); + } + let window = state + .windows + .get(&window_id) + .ok_or(SnapshotError::CorruptState)?; + match (&window.phase, &window.split) { + (WindowPhase::Split, Some(split)) => { + if split.policy_version != policy.policy_version + || split.trigger != policy.split_trigger + || split.algorithm != policy.split_algorithm + || !window.staged_record_keys.is_empty() + || window.stage_attempt.is_some() + || window.stage_receipt.is_some() + || window.evidence.is_some() + { + return Err(SnapshotError::CorruptState); + } + let (left_range, right_range) = + midpoint_split(&window.planned.range).ok_or(SnapshotError::CorruptState)?; + let expected_left = PlannedWindow::adaptive_child(&window.planned, left_range); + let expected_right = PlannedWindow::adaptive_child(&window.planned, right_range); + for (child_id, expected) in [ + (&split.left_window_id, expected_left), + (&split.right_window_id, expected_right), + ] { + let child = state + .windows + .get(child_id) + .ok_or(SnapshotError::CorruptState)?; + if child.parent_window_id.as_deref() != Some(window_id.as_str()) + || child.planned != expected + { + return Err(SnapshotError::CorruptState); + } + stack.push(child_id.clone()); + } + } + (WindowPhase::Split, None) | (_, Some(_)) => { + return Err(SnapshotError::CorruptState); + } + (_, None) => {} + } + } + if visited.len() != state.windows.len() { + return Err(SnapshotError::CorruptState); + } + let leaves = state.executable_leaves(); + if leaves.is_empty() || leaves.len() > usize::from(policy.maximum_leaf_windows) { + return Err(SnapshotError::CorruptState); + } + for pair in leaves.windows(2) { + if pair[1].range.from_yyyymmdd <= pair[0].range.to_yyyymmdd { + return Err(SnapshotError::CorruptState); + } + } + Ok(()) +} + +fn midpoint_split(range: &ReadWindow) -> Option<(ReadWindow, ReadWindow)> { + let from = parse_yyyymmdd(&range.from_yyyymmdd)?; + let to = parse_yyyymmdd(&range.to_yyyymmdd)?; + if from >= to { + return None; + } + let midpoint = from + ChronoDuration::days((to - from).num_days() / 2); + let right_from = midpoint + ChronoDuration::days(1); + Some(( + ReadWindow { + from_yyyymmdd: from.format("%Y%m%d").to_string(), + to_yyyymmdd: midpoint.format("%Y%m%d").to_string(), + }, + ReadWindow { + from_yyyymmdd: right_from.format("%Y%m%d").to_string(), + to_yyyymmdd: to.format("%Y%m%d").to_string(), + }, + )) +} + +fn parse_yyyymmdd(value: &str) -> Option { + (value.len() == 8 && value.bytes().all(|byte| byte.is_ascii_digit())) + .then(|| NaiveDate::parse_from_str(value, "%Y%m%d").ok()) + .flatten() +} + +#[derive(Debug, thiserror::Error)] +pub enum SnapshotError { + #[error("invalid snapshot plan ({0})")] + InvalidPlan(&'static str), + #[error("the durable run belongs to a different immutable plan")] + ResumePlanMismatch, + #[error("the durable run predates restart-safe plan persistence")] + ResumePlanUnavailable, + #[error("durable snapshot state is corrupt")] + CorruptState, + #[error("durable snapshot state migration is not installed")] + StateMigrationMissing, + #[error("another worker owns the durable snapshot lease")] + LeaseUnavailable, + #[error("durable snapshot process lock failed")] + LeaseIo(#[source] std::io::Error), + #[error("the durable snapshot generation changed concurrently")] + StateConflict, + #[error("snapshot state operation failed")] + StateStore(#[source] sqlx::Error), + #[error("mirror operation failed")] + Mirror(#[from] MirrorError), + #[error("snapshot reconciliation failed")] + Reconciliation(#[from] ReconciliationError), + #[error("snapshot checkpoint changed concurrently")] + ConcurrentCheckpoint, + #[error("snapshot state invariant failed ({0})")] + StateInvariant(&'static str), + #[error("canonical state serialization failed")] + Serialization, +} + +#[async_trait] +pub trait SnapshotStateStore: Send + Sync { + async fn load(&self, resume_key: &str) -> Result, SnapshotError>; + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError>; + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError>; +} + +#[derive(Clone)] +pub struct SqliteSnapshotStateStore { + pool: SqlitePool, + lease_owner: Option, + process_leases: Arc>>, +} + +impl SqliteSnapshotStateStore { + pub fn new(pool: SqlitePool) -> Self { + Self { + pool, + lease_owner: None, + process_leases: Arc::new(Mutex::new(BTreeMap::new())), + } + } + + pub fn for_worker(pool: SqlitePool, lease_owner: String) -> Result { + if lease_owner.is_empty() + || lease_owner.len() > 128 + || lease_owner.chars().any(char::is_control) + { + return Err(SnapshotError::InvalidPlan("lease_owner")); + } + Ok(Self { + pool, + lease_owner: Some(lease_owner), + process_leases: Arc::new(Mutex::new(BTreeMap::new())), + }) + } + + async fn process_lease_path(&self, resume_key: &str) -> Result, SnapshotError> { + let database_path = sqlx::query_scalar::<_, String>( + "SELECT file FROM pragma_database_list WHERE name = 'main'", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + if database_path.is_empty() { + // SQLite in-memory databases have no cross-process identity. Their test-only lease + // behavior therefore retains the bounded UTC fallback below. + return Ok(None); + } + let database_path = PathBuf::from(database_path); + let file_name = database_path.file_name().ok_or_else(|| { + SnapshotError::LeaseIo(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "snapshot database path has no file name", + )) + })?; + let mut lock_name = file_name.to_os_string(); + lock_name.push(format!( + ".snapshot-{}.lock", + sha256_bytes(resume_key.as_bytes()) + )); + Ok(Some(database_path.with_file_name(lock_name))) + } + + async fn acquire_process_lease(&self, resume_key: &str) -> Result { + let Some(lock_path) = self.process_lease_path(resume_key).await? else { + return Ok(false); + }; + let mut leases = self + .process_leases + .lock() + .map_err(|_| SnapshotError::LeaseUnavailable)?; + if leases.contains_key(resume_key) { + return Ok(true); + } + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(lock_path) + .map_err(SnapshotError::LeaseIo)?; + match file.try_lock() { + Ok(()) => { + leases.insert(resume_key.to_string(), file); + Ok(true) + } + Err(error) => { + let error: std::io::Error = error.into(); + if error.kind() == std::io::ErrorKind::WouldBlock { + Err(SnapshotError::LeaseUnavailable) + } else { + Err(SnapshotError::LeaseIo(error)) + } + } + } + } + + fn holds_process_lease(&self, resume_key: &str) -> Result { + self.process_leases + .lock() + .map(|leases| leases.contains_key(resume_key)) + .map_err(|_| SnapshotError::LeaseUnavailable) + } + + async fn required_process_lease(&self, resume_key: &str) -> Result { + let file_backed = self.process_lease_path(resume_key).await?.is_some(); + let held = self.holds_process_lease(resume_key)?; + if file_backed && !held { + return Err(SnapshotError::LeaseUnavailable); + } + Ok(held) + } + + fn drop_process_lease(&self, resume_key: &str) -> Result<(), SnapshotError> { + self.process_leases + .lock() + .map_err(|_| SnapshotError::LeaseUnavailable)? + .remove(resume_key); + Ok(()) + } + + pub async fn migrate(&self) -> Result<(), SnapshotError> { + let installed = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_schema_migrations WHERE version IN (4, 5, 9, 10, 12)", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let table_exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master \ + WHERE type = 'table' AND name = 'tally_snapshot_run_states'", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let recovery_columns = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM pragma_table_info('tally_snapshot_run_states') \ + WHERE name IN ('row_sha256', 'lease_owner', 'lease_expires_at_unix_ms')", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let unique_run_index = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' \ + AND name = 'idx_tally_snapshot_run_states_unique_run'", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let composite_batch_identity = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM pragma_index_list('tally_observation_batches') AS list \ + WHERE list.[unique] = 1 \ + AND (SELECT COUNT(*) FROM pragma_index_info(list.name)) = 2 \ + AND EXISTS (SELECT 1 FROM pragma_index_info(list.name) \ + WHERE seqno = 0 AND name = 'run_id') \ + AND EXISTS (SELECT 1 FROM pragma_index_info(list.name) \ + WHERE seqno = 1 AND name = 'pack_id')", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let recovery_triggers = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN (\ + 'trg_tally_snapshot_run_identity_immutable', \ + 'trg_tally_snapshot_terminal_immutable', \ + 'trg_tally_snapshot_state_no_delete')", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let window_staging_tables = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN (\ + 'tally_snapshot_window_attempts', 'tally_snapshot_window_memberships')", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let window_staging_triggers = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name IN (\ + 'trg_tally_snapshot_window_attempt_terminal_immutable', \ + 'trg_tally_snapshot_window_attempt_identity_immutable', \ + 'trg_tally_snapshot_window_attempt_no_delete', \ + 'trg_tally_snapshot_window_membership_insert_open_attempt', \ + 'trg_tally_snapshot_window_membership_content_immutable', \ + 'trg_tally_snapshot_window_membership_last_seen_advance', \ + 'trg_tally_snapshot_window_membership_no_delete', \ + 'trg_tally_snapshot_window_attempt_terminal_reason_insert', \ + 'trg_tally_snapshot_window_attempt_terminal_reason_shape')", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let terminal_evidence_column = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM pragma_table_info('tally_snapshot_window_attempts') \ + WHERE name = 'terminal_safe_reason_code'", + ) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let json_available = sqlx::query_scalar::<_, i64>("SELECT json_valid('{}')") + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + if installed != 5 + || table_exists != 1 + || recovery_columns != 3 + || unique_run_index != 1 + || composite_batch_identity != 1 + || recovery_triggers != 3 + || window_staging_tables != 2 + || window_staging_triggers != 9 + || terminal_evidence_column != 1 + || json_available != 1 + { + return Err(SnapshotError::StateMigrationMissing); + } + Ok(()) + } + + pub async fn claim(&self, resume_key: &str) -> Result { + let owner = self + .lease_owner + .as_deref() + .ok_or(SnapshotError::LeaseUnavailable)?; + let now = Utc::now().timestamp_millis(); + let process_locked = self.acquire_process_lease(resume_key).await?; + let result = if process_locked { + // The kernel releases this lock when a worker crashes. Once acquired, an old UTC + // expiry cannot strand a restart after a wall-clock rollback, and a live owner + // cannot be stolen regardless of timestamp movement. + sqlx::query( + "UPDATE tally_snapshot_run_states SET lease_owner = ?1, \ + lease_expires_at_unix_ms = ?2 WHERE resume_key = ?3", + ) + .bind(owner) + .bind(now.saturating_add(WORKER_LEASE_TTL_MS)) + .bind(resume_key) + .execute(&self.pool) + .await + } else { + sqlx::query( + "UPDATE tally_snapshot_run_states SET lease_owner = ?1, \ + lease_expires_at_unix_ms = ?2 \ + WHERE resume_key = ?3 AND (lease_owner IS NULL OR lease_owner = ?1 OR \ + lease_expires_at_unix_ms <= ?4)", + ) + .bind(owner) + .bind(now.saturating_add(WORKER_LEASE_TTL_MS)) + .bind(resume_key) + .bind(now) + .execute(&self.pool) + .await + } + .map_err(|error| { + if process_locked { + let _ = self.drop_process_lease(resume_key); + } + SnapshotError::StateStore(error) + })?; + if result.rows_affected() == 1 { + return Ok(true); + } + let exists = sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM tally_snapshot_run_states WHERE resume_key = ?1", + ) + .bind(resume_key) + .fetch_one(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + if exists == 0 { + Ok(false) + } else { + if process_locked { + self.drop_process_lease(resume_key)?; + } + Err(SnapshotError::LeaseUnavailable) + } + } + + pub async fn release(&self, resume_key: &str) -> Result<(), SnapshotError> { + let owner = self + .lease_owner + .as_deref() + .ok_or(SnapshotError::LeaseUnavailable)?; + sqlx::query( + "UPDATE tally_snapshot_run_states SET lease_owner = NULL, \ + lease_expires_at_unix_ms = NULL \ + WHERE resume_key = ?1 AND lease_owner = ?2", + ) + .bind(resume_key) + .bind(owner) + .execute(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + self.drop_process_lease(resume_key)?; + Ok(()) + } + + pub async fn load_by_run_id( + &self, + run_id: &str, + ) -> Result, SnapshotError> { + if run_id.is_empty() || run_id.len() > 256 || run_id.chars().any(char::is_control) { + return Err(SnapshotError::InvalidPlan("run_id")); + } + let rows = sqlx::query( + "SELECT resume_key, run_id, generation, state_json, state_sha256, row_sha256 \ + FROM tally_snapshot_run_states \ + WHERE run_id = ?1 ORDER BY updated_at_unix_ms DESC LIMIT 2", + ) + .bind(run_id) + .fetch_all(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + match rows.len() { + 0 => Ok(None), + 1 => decode_state_row(&rows[0]).map(Some), + _ => Err(SnapshotError::CorruptState), + } + } + + pub async fn load_recent( + &self, + limit: u32, + ) -> Result, SnapshotError> { + if !(1..=100).contains(&limit) { + return Err(SnapshotError::InvalidPlan("recent_limit")); + } + let rows = sqlx::query( + "SELECT resume_key, run_id, generation, state_json, state_sha256, row_sha256 \ + FROM tally_snapshot_run_states \ + ORDER BY updated_at_unix_ms DESC LIMIT ?1", + ) + .bind(i64::from(limit)) + .fetch_all(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + rows.iter().map(decode_state_row).collect() + } +} + +#[async_trait] +impl SnapshotStateStore for SqliteSnapshotStateStore { + async fn load(&self, resume_key: &str) -> Result, SnapshotError> { + let row = sqlx::query( + "SELECT resume_key, run_id, generation, state_json, state_sha256, row_sha256 \ + FROM tally_snapshot_run_states WHERE resume_key = ?1", + ) + .bind(resume_key) + .fetch_optional(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + let Some(row) = row else { + return Ok(None); + }; + decode_state_row(&row).map(Some) + } + + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError> { + let owner = self + .lease_owner + .as_deref() + .ok_or(SnapshotError::LeaseUnavailable)?; + state.validate_invariants()?; + // File-backed stores must prove kernel-level ownership even for the first insert. This + // keeps direct callers from bypassing claim() and manufacturing a live-looking DB lease. + let process_locked = self.required_process_lease(&state.resume_key).await?; + let expected_generation = state.generation; + let next_generation = expected_generation + .checked_add(1) + .ok_or(SnapshotError::StateConflict)?; + state.generation = next_generation; + let state_json = match serde_json::to_string(state) { + Ok(json) if json.len() <= MAX_DURABLE_STATE_BYTES => json, + Ok(_) => { + state.generation = expected_generation; + return Err(SnapshotError::StateInvariant("state_size")); + } + Err(_) => { + state.generation = expected_generation; + return Err(SnapshotError::Serialization); + } + }; + let state_sha256 = sha256_bytes(state_json.as_bytes()); + let row_sha256 = snapshot_state_row_sha256( + &state.resume_key, + &state.run_id, + next_generation, + &state_sha256, + ); + let now = Utc::now().timestamp_millis(); + let terminal = state.progress.phase.is_terminal(); + let result = if expected_generation == 0 { + sqlx::query( + "INSERT OR IGNORE INTO tally_snapshot_run_states(\ + resume_key, run_id, generation, state_sha256, state_json, row_sha256, \ + lease_owner, lease_expires_at_unix_ms, updated_at_unix_ms\ + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + ) + .bind(&state.resume_key) + .bind(&state.run_id) + .bind(i64::try_from(next_generation).map_err(|_| SnapshotError::StateConflict)?) + .bind(&state_sha256) + .bind(&state_json) + .bind(&row_sha256) + .bind((!terminal).then_some(owner)) + .bind((!terminal).then_some(now.saturating_add(WORKER_LEASE_TTL_MS))) + .bind(now) + .execute(&self.pool) + .await + } else if process_locked { + sqlx::query( + "UPDATE tally_snapshot_run_states SET generation = ?1, state_sha256 = ?2, \ + state_json = ?3, row_sha256 = ?4, lease_owner = ?5, \ + lease_expires_at_unix_ms = ?6, updated_at_unix_ms = ?7 \ + WHERE resume_key = ?8 AND run_id = ?9 AND generation = ?10 \ + AND lease_owner = ?11", + ) + .bind(i64::try_from(next_generation).map_err(|_| SnapshotError::StateConflict)?) + .bind(&state_sha256) + .bind(&state_json) + .bind(&row_sha256) + .bind((!terminal).then_some(owner)) + .bind((!terminal).then_some(now.saturating_add(WORKER_LEASE_TTL_MS))) + .bind(now) + .bind(&state.resume_key) + .bind(&state.run_id) + .bind(i64::try_from(expected_generation).map_err(|_| SnapshotError::StateConflict)?) + .bind(owner) + .execute(&self.pool) + .await + } else { + sqlx::query( + "UPDATE tally_snapshot_run_states SET generation = ?1, state_sha256 = ?2, \ + state_json = ?3, row_sha256 = ?4, lease_owner = ?5, \ + lease_expires_at_unix_ms = ?6, updated_at_unix_ms = ?7 \ + WHERE resume_key = ?8 AND run_id = ?9 AND generation = ?10 \ + AND lease_owner = ?11 AND lease_expires_at_unix_ms > ?12", + ) + .bind(i64::try_from(next_generation).map_err(|_| SnapshotError::StateConflict)?) + .bind(&state_sha256) + .bind(&state_json) + .bind(&row_sha256) + .bind((!terminal).then_some(owner)) + .bind((!terminal).then_some(now.saturating_add(WORKER_LEASE_TTL_MS))) + .bind(now) + .bind(&state.resume_key) + .bind(&state.run_id) + .bind(i64::try_from(expected_generation).map_err(|_| SnapshotError::StateConflict)?) + .bind(owner) + .bind(now) + .execute(&self.pool) + .await + }; + let result = match result { + Ok(result) => result, + Err(error) => { + state.generation = expected_generation; + return Err(SnapshotError::StateStore(error)); + } + }; + if result.rows_affected() != 1 { + state.generation = expected_generation; + return Err(SnapshotError::StateConflict); + } + state.row_integrity_bound = true; + if terminal && process_locked { + // Terminal state clears the DB owner in the same write; release the matching kernel + // lock promptly rather than relying on the coordinator task to drop its store. + self.drop_process_lease(&state.resume_key)?; + } + Ok(()) + } + + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError> { + let owner = self + .lease_owner + .as_deref() + .ok_or(SnapshotError::LeaseUnavailable)?; + if state.progress.phase.is_terminal() || state.generation == 0 { + return Err(SnapshotError::StateInvariant("heartbeat_state")); + } + let now = Utc::now().timestamp_millis(); + let process_locked = self.required_process_lease(&state.resume_key).await?; + let mut query = sqlx::query( + "UPDATE tally_snapshot_run_states SET lease_expires_at_unix_ms = ?1 \ + WHERE resume_key = ?2 AND run_id = ?3 AND generation = ?4 \ + AND lease_owner = ?5 AND (?6 OR lease_expires_at_unix_ms > ?7)", + ) + .bind(now.saturating_add(WORKER_LEASE_TTL_MS)) + .bind(&state.resume_key) + .bind(&state.run_id) + .bind(i64::try_from(state.generation).map_err(|_| SnapshotError::StateConflict)?) + .bind(owner) + .bind(process_locked); + query = query.bind(now); + let result = query + .execute(&self.pool) + .await + .map_err(SnapshotError::StateStore)?; + if result.rows_affected() != 1 { + return Err(SnapshotError::LeaseUnavailable); + } + Ok(()) + } +} + +fn decode_state_row(row: &sqlx::sqlite::SqliteRow) -> Result { + let row_resume_key: String = row + .try_get("resume_key") + .map_err(SnapshotError::StateStore)?; + let row_run_id: String = row.try_get("run_id").map_err(SnapshotError::StateStore)?; + let row_generation: i64 = row + .try_get("generation") + .map_err(SnapshotError::StateStore)?; + let row_generation = u64::try_from(row_generation).map_err(|_| SnapshotError::CorruptState)?; + let state_json: String = row + .try_get("state_json") + .map_err(SnapshotError::StateStore)?; + if state_json.len() > MAX_DURABLE_STATE_BYTES { + return Err(SnapshotError::CorruptState); + } + let state_sha256: String = row + .try_get("state_sha256") + .map_err(SnapshotError::StateStore)?; + if sha256_bytes(state_json.as_bytes()) != state_sha256 { + return Err(SnapshotError::CorruptState); + } + let row_sha256: Option = row + .try_get("row_sha256") + .map_err(SnapshotError::StateStore)?; + let mut state: DurableSnapshotState = + serde_json::from_str(&state_json).map_err(|_| SnapshotError::CorruptState)?; + if state.generation == 0 && row_sha256.is_none() { + // Legacy v4 state: readable for evidence, deliberately not row-bound or restart-resumable. + state.generation = row_generation; + } + if state.resume_key != row_resume_key + || state.run_id != row_run_id + || state.generation != row_generation + { + return Err(SnapshotError::CorruptState); + } + if let Some(row_sha256) = row_sha256 { + if !is_lower_sha256(&row_sha256) + || row_sha256 + != snapshot_state_row_sha256( + &row_resume_key, + &row_run_id, + row_generation, + &state_sha256, + ) + { + return Err(SnapshotError::CorruptState); + } + state.row_integrity_bound = true; + } + state.validate_invariants()?; + Ok(state) +} + +fn snapshot_state_row_sha256( + resume_key: &str, + run_id: &str, + generation: u64, + state_sha256: &str, +) -> String { + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-snapshot-state-row-v1\0"); + for value in [ + resume_key.as_bytes(), + run_id.as_bytes(), + state_sha256.as_bytes(), + ] { + digest.update((value.len() as u64).to_be_bytes()); + digest.update(value); + } + digest.update(generation.to_be_bytes()); + hex_digest(digest.finalize()) +} + +pub trait CancellationSignal: Send + Sync { + fn is_cancelled(&self) -> bool; +} + +#[derive(Debug, Default)] +pub struct AtomicCancellation { + cancelled: AtomicBool, +} + +impl AtomicCancellation { + pub fn cancel(&self) { + self.cancelled.store(true, Ordering::Release); + } +} + +impl CancellationSignal for AtomicCancellation { + fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::Acquire) + } +} + +#[derive(Debug, Clone)] +pub struct SnapshotRunResult { + pub state: DurableSnapshotState, + pub proof: ProofManifest, + pub receipt: StoredCommitReceipt, +} + +pub struct FullSnapshotEngine<'a, S, C> { + mirror: &'a TallyMirrorRepository, + state_store: &'a S, + connector: &'a C, +} + +enum ConnectorAwait { + Completed(Result), + Cancelled, +} + +impl<'a, S, C> FullSnapshotEngine<'a, S, C> +where + S: SnapshotStateStore, + C: TallyConnector, +{ + fn record_attempt_abandonment( + state: &mut DurableSnapshotState, + result: AbandonSnapshotWindowAttemptResult, + ) { + Self::record_local_clock_rollback(state, result.local_clock_moved_backwards); + } + + fn record_local_clock_rollback(state: &mut DurableSnapshotState, moved_backwards: bool) { + if moved_backwards { + state + .gap_codes + .insert("local_clock_moved_backwards".to_string()); + } + } + + fn clamp_run_completion( + state: &mut DurableSnapshotState, + started_at_unix_ms: i64, + observed_completed_at_unix_ms: i64, + ) -> i64 { + Self::record_local_clock_rollback( + state, + observed_completed_at_unix_ms < started_at_unix_ms, + ); + observed_completed_at_unix_ms.max(started_at_unix_ms) + } + + pub fn new(mirror: &'a TallyMirrorRepository, state_store: &'a S, connector: &'a C) -> Self { + Self { + mirror, + state_store, + connector, + } + } + + async fn await_connector( + &self, + state: &DurableSnapshotState, + cancellation: &dyn CancellationSignal, + future: F, + ) -> Result, SnapshotError> + where + F: Future>, + { + self.state_store.heartbeat(state).await?; + if cancellation.is_cancelled() { + return Ok(ConnectorAwait::Cancelled); + } + tokio::pin!(future); + let mut heartbeat = tokio::time::interval_at( + tokio::time::Instant::now() + WORKER_LEASE_HEARTBEAT_INTERVAL, + WORKER_LEASE_HEARTBEAT_INTERVAL, + ); + heartbeat.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + let mut cancellation_poll = tokio::time::interval(CANCELLATION_POLL_INTERVAL); + cancellation_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + cancellation_poll.tick().await; + loop { + tokio::select! { + result = &mut future => { + self.state_store.heartbeat(state).await?; + return Ok(if cancellation.is_cancelled() + || matches!(result, Err(TallyError::Cancelled)) + { + ConnectorAwait::Cancelled + } else { + ConnectorAwait::Completed(result) + }); + }, + _ = heartbeat.tick() => self.state_store.heartbeat(state).await?, + _ = cancellation_poll.tick() => { + if cancellation.is_cancelled() { + return Ok(ConnectorAwait::Cancelled); + } + } + } + } + } + + async fn recover_window_attempts( + &self, + state: &mut DurableSnapshotState, + ) -> Result<(), SnapshotError> { + let staging_window_ids = state + .windows + .iter() + .filter(|(_, progress)| progress.phase == WindowPhase::Staging) + .map(|(window_id, _)| window_id.clone()) + .collect::>(); + for window_id in staging_window_ids { + let (planned, durable_attempt) = state + .windows + .get(&window_id) + .and_then(|progress| { + progress + .stage_attempt + .clone() + .map(|attempt| (progress.planned.clone(), attempt)) + }) + .ok_or(SnapshotError::CorruptState)?; + let repository_attempt = durable_attempt.repository_ref(); + let latest = self + .mirror + .load_latest_completed_window_receipt( + &repository_attempt.batch_id, + &repository_attempt.window_id, + ) + .await?; + if let Some(completion) = latest.filter(|completion| { + completion.receipt.attempt_id == repository_attempt.attempt_id + && completion.receipt.attempt_ordinal == repository_attempt.attempt_ordinal + }) { + Self::record_local_clock_rollback(state, completion.local_clock_moved_backwards); + let receipt = completion.receipt; + let mut evidence = receipt_window_evidence(&planned, &receipt)?; + evidence.record_set_sha256 = Some(receipt.membership_sha256.clone()); + if evidence.record_provenance_scope == ComparisonScope::Unavailable { + state + .gap_codes + .insert("record_provenance_unavailable".to_string()); + } + let progress = state + .windows + .get_mut(&window_id) + .ok_or(SnapshotError::CorruptState)?; + progress.stage_attempt = None; + progress.stage_receipt = Some(WindowStageReceipt::from(&receipt)); + progress.evidence = Some(evidence); + progress.phase = WindowPhase::Complete; + } else { + match self + .mirror + .abandon_snapshot_window_attempt( + &repository_attempt, + Utc::now().timestamp_millis(), + ) + .await + { + Ok(result) => Self::record_attempt_abandonment(state, result), + Err(MirrorError::NotFound | MirrorError::WindowAttemptClosed) => {} + Err(error) => return Err(error.into()), + } + let progress = state + .windows + .get_mut(&window_id) + .ok_or(SnapshotError::CorruptState)?; + progress.stage_attempt = None; + progress.stage_receipt = None; + progress.evidence = None; + progress.phase = WindowPhase::Pending; + } + state.set_phase(SnapshotPhase::PlanWindows, Some(window_id)); + self.state_store.save(state).await?; + } + Ok(()) + } + + async fn hydrate_completed_window_records( + &self, + state: &mut DurableSnapshotState, + ) -> Result<(), SnapshotError> { + if reconciliation_record_budget_exceeded(state)? { + // Every caller must terminalize this condition. Keep a defensive invariant here so a + // future call site cannot accidentally restore the unbounded allocation path. + return Err(SnapshotError::StateInvariant( + "reconciliation_record_budget", + )); + } + let mut local_clock_moved_backwards = false; + for progress in state + .windows + .values_mut() + .filter(|progress| progress.phase == WindowPhase::Complete) + { + let durable_receipt = progress + .stage_receipt + .as_ref() + .ok_or(SnapshotError::CorruptState)?; + let stored_completion = self + .mirror + .load_latest_completed_window_receipt( + &durable_receipt.attempt.batch_id, + &durable_receipt.attempt.window_id, + ) + .await? + .ok_or(SnapshotError::CorruptState)?; + local_clock_moved_backwards |= stored_completion.local_clock_moved_backwards; + let stored_receipt = stored_completion.receipt; + if stored_receipt.attempt_id != durable_receipt.attempt.attempt_id + || stored_receipt.attempt_ordinal != durable_receipt.attempt.attempt_ordinal + || stored_receipt.member_count != durable_receipt.member_count + || stored_receipt.membership_sha256 != durable_receipt.membership_sha256 + || stored_receipt.receipt_sha256 != durable_receipt.receipt_sha256 + { + return Err(SnapshotError::CorruptState); + } + let mut stored_evidence = receipt_window_evidence(&progress.planned, &stored_receipt)?; + stored_evidence.record_set_sha256 = Some(stored_receipt.membership_sha256.clone()); + let evidence = progress + .evidence + .as_mut() + .ok_or(SnapshotError::CorruptState)?; + let mut durable_evidence = evidence.clone(); + durable_evidence.canonical_records.clear(); + if durable_evidence != stored_evidence { + return Err(SnapshotError::CorruptState); + } + let records = self + .mirror + .load_completed_window_canonical_record_map( + &durable_receipt.attempt.repository_ref(), + ) + .await?; + if u32::try_from(records.len()).ok() != Some(durable_receipt.member_count) { + return Err(SnapshotError::CorruptState); + } + if evidence.record_set_sha256.as_deref() + != Some(durable_receipt.membership_sha256.as_str()) + { + return Err(SnapshotError::CorruptState); + } + evidence.canonical_records = records; + } + Self::record_local_clock_rollback(state, local_clock_moved_backwards); + Ok(()) + } + + async fn abandon_open_window_attempts( + &self, + state: &mut DurableSnapshotState, + ) -> Result<(), SnapshotError> { + let attempts = state + .windows + .values() + .filter_map(|progress| progress.stage_attempt.clone()) + .collect::>(); + for attempt in attempts { + match self + .mirror + .abandon_snapshot_window_attempt( + &attempt.repository_ref(), + Utc::now().timestamp_millis(), + ) + .await + { + Ok(result) => Self::record_attempt_abandonment(state, result), + Err( + MirrorError::NotFound + | MirrorError::WindowAttemptClosed + | MirrorError::BatchClosed, + ) => {} + Err(error) => return Err(error.into()), + } + let progress = state + .windows + .get_mut(&attempt.window_id) + .ok_or(SnapshotError::CorruptState)?; + progress.stage_attempt = None; + progress.stage_receipt = None; + progress.evidence = None; + progress.phase = WindowPhase::Pending; + } + Ok(()) + } + + pub async fn run( + &self, + plan: &SnapshotPlan, + cancellation: &dyn CancellationSignal, + ) -> Result { + plan.validate()?; + let freshness = self + .mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await?; + let freshness_before = core_freshness(freshness.state); + let mut state = match self.state_store.load(&plan.resume_key).await? { + Some(state) => { + state.assert_resumable_with(plan)?; + state + } + None => { + let mut state = DurableSnapshotState::new(plan, freshness_before)?; + state.checkpoint_before = freshness.checkpoint_token; + self.state_store.save(&mut state).await?; + state + } + }; + + if state.progress.phase.is_terminal() { + return completed_result(state); + } + if state.progress.phase == SnapshotPhase::CommitPending { + return self.resume_pending_commit(plan, state).await; + } + + if state.batch_id.is_none() { + let requested_from = plan + .windows + .iter() + .map(|window| window.range.from_yyyymmdd.as_str()) + .min() + .map(str::to_string); + let requested_to = plan + .windows + .iter() + .map(|window| window.range.to_yyyymmdd.as_str()) + .max() + .map(str::to_string); + state.batch_id = Some( + self.mirror + .begin_batch(BeginBatchInput { + run_id: plan.run_id.clone(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: plan.source_release.clone(), + requested_from_yyyymmdd: requested_from, + requested_to_yyyymmdd: requested_to, + started_at_unix_ms: plan.started_at_unix_ms, + }) + .await?, + ); + self.state_store.save(&mut state).await?; + } + + self.recover_window_attempts(&mut state).await?; + + if reconciliation_record_budget_exceeded(&state)? { + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + RECONCILIATION_RECORD_BUDGET_CODE, + ) + .await; + } + + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + + state.set_phase(SnapshotPhase::CapabilityCheck, None); + self.state_store.save(&mut state).await?; + let probe = match self + .await_connector(&state, cancellation, self.connector.probe()) + .await? + { + ConnectorAwait::Completed(result) => result, + ConnectorAwait::Cancelled => { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + }; + match probe { + Ok(probe) => { + let pack_supported = probe.reachable + && probe.profile.packs.get(&plan.pack).is_some_and(|evidence| { + snapshot_pack_start_authorized(plan.pack, evidence) + }) + && probe + .profile + .transports + .get(&TransportId::XmlHttp) + .is_some_and(|evidence| { + evidence.state == CapabilityState::Supported + && evidence.confidence == EvidenceConfidence::Observed + }); + if !pack_supported { + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + "capability_not_verified", + ) + .await; + } + if probe.profile.profile_version != plan.capability_profile_version + || capability_profile_sha256(&probe.profile)? != plan.capability_profile_sha256 + || probe.profile.product != plan.source_product + || probe.profile.release != plan.source_release + || probe.profile.mode != plan.source_mode + { + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + "capability_profile_changed", + ) + .await; + } + } + Err(error) => { + let code = tally_error_code(&error); + return self + .finish_terminal(plan, state, terminal_kind(&error), code) + .await; + } + } + + state.set_phase(SnapshotPhase::CompanyIdentityCheck, None); + self.state_store.save(&mut state).await?; + let companies = match self + .await_connector(&state, cancellation, self.connector.discover_companies()) + .await? + { + ConnectorAwait::Completed(result) => result, + ConnectorAwait::Cancelled => { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + }; + match companies { + Ok(companies) => { + // Setup rejects ambiguous case-insensitive GUID matches. Preserve that invariant + // at execution time: after setup, Tally may load another company that resolves to + // the same canonical identity, and selecting either by display name would no + // longer establish which company supplied the proof-bound records. + let matching_identities = companies + .iter() + .filter(|company| company.identity == plan.company.identity) + .take(2) + .count(); + if matching_identities == 1 { + // Exactly one live company remains bound to the reviewed identity. + } else { + let reason = if matching_identities == 0 { + "company_identity_not_found" + } else { + "company_identity_ambiguous" + }; + return self + .finish_terminal(plan, state, TerminalKind::Failed, reason) + .await; + } + } + Err(error) => { + let code = tally_error_code(&error); + return self + .finish_terminal(plan, state, terminal_kind(&error), code) + .await; + } + } + + state.set_phase(SnapshotPhase::PlanWindows, None); + self.state_store.save(&mut state).await?; + let mut attempted_leaf_ids = BTreeSet::new(); + while let Some(planned_owned) = state.executable_leaves().into_iter().find(|planned| { + !attempted_leaf_ids.contains(&planned.id) + && !state.windows.get(&planned.id).is_some_and(|window| { + window.phase == WindowPhase::Complete + && (plan.pack != CapabilityPackId::CoreAccounting + || window + .evidence + .as_ref() + .and_then(|evidence| evidence.report_tie_out.as_ref()) + .is_some()) + }) + }) { + attempted_leaf_ids.insert(planned_owned.id.clone()); + let planned = &planned_owned; + let completed_with_required_evidence = + state.windows.get(&planned.id).is_some_and(|window| { + window.phase == WindowPhase::Complete + && (plan.pack != CapabilityPackId::CoreAccounting + || window + .evidence + .as_ref() + .and_then(|evidence| evidence.report_tie_out.as_ref()) + .is_some()) + }); + if completed_with_required_evidence { + continue; + } + if let Some(progress) = state.windows.get_mut(&planned.id) { + if progress.phase == WindowPhase::Complete { + if plan.pack != CapabilityPackId::CoreAccounting + || progress + .evidence + .as_ref() + .and_then(|evidence| evidence.report_tie_out.as_ref()) + .is_some() + { + return Err(SnapshotError::StateInvariant( + "completed_window_report_retry", + )); + } + // Reopen the evidence-gathering path. Normalized mirror + // membership remains immutable and the next completed + // attempt must observe every prior identity again. + progress.phase = WindowPhase::Pending; + progress.stage_receipt = None; + progress.evidence = None; + state.set_phase(SnapshotPhase::PlanWindows, Some(planned.id.clone())); + self.state_store.save(&mut state).await?; + } + } + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + + set_window_phase( + &mut state, + planned, + WindowPhase::Extracting, + SnapshotPhase::Extract, + )?; + self.state_store.save(&mut state).await?; + let context = RequestContext { + run_id: plan.run_id.clone(), + company: plan.company.clone(), + pack: plan.pack, + schema_version: plan.pack_schema_version, + window: planned.range.clone(), + query_profile: planned.query_profile.clone(), + filters_sha256: planned.filters_sha256.clone(), + }; + let source_result = match self + .await_connector( + &state, + cancellation, + self.connector.read_pack_window(&context), + ) + .await? + { + ConnectorAwait::Completed(result) => result, + ConnectorAwait::Cancelled => { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + }; + let source_window = match source_result { + Ok(source_window) => source_window, + Err(TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow, + }) => match split_leaf(&mut state, plan, &planned.id)? { + SplitLeafResult::Created => { + // The exact child graph is generation-CAS persisted + // before any child request may be dispatched. + self.state_store.save(&mut state).await?; + if cancellation.is_cancelled() { + return self + .finish_terminal( + plan, + state, + TerminalKind::Cancelled, + "run_cancelled", + ) + .await; + } + continue; + } + SplitLeafResult::MinimumReached => { + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + "minimum_window_response_too_large", + ) + .await; + } + SplitLeafResult::LeafLimitReached => { + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + "adaptive_window_limit_reached", + ) + .await; + } + }, + Err(error) => { + let code = tally_error_code(&error); + return self + .finish_terminal(plan, state, terminal_kind(&error), code) + .await; + } + }; + + set_window_phase( + &mut state, + planned, + WindowPhase::Normalizing, + SnapshotPhase::Normalize, + )?; + self.state_store.save(&mut state).await?; + let mut canonical = match canonicalize_window( + &CanonicalWindowContext { + requested_pack: plan.pack, + schema_version: plan.pack_schema_version, + source_identity: &plan.company.identity, + query_profile: &planned.query_profile, + filters_sha256: &planned.filters_sha256, + external_references: &plan.external_references, + window_id: &planned.id, + requested_window: &planned.range, + }, + &source_window, + ) { + Ok(canonical) => canonical, + Err(error) => { + let code = match error { + ReconciliationError::PackMismatch => "response_pack_mismatch", + ReconciliationError::Serialization => "response_parse_failed", + ReconciliationError::InvalidTypedPack => "typed_pack_validation_failed", + ReconciliationError::InvalidSourceCountEvidence => { + "source_count_evidence_invalid" + } + ReconciliationError::SourceCountScopeMismatch => { + "source_count_scope_mismatch" + } + ReconciliationError::RecordEvidenceMismatch => "record_evidence_mismatch", + ReconciliationError::RecordProvenanceUnavailable => { + "record_provenance_unavailable" + } + ReconciliationError::InvalidInput(_) => "response_validation_failed", + }; + return self + .finish_terminal(plan, state, TerminalKind::Failed, code) + .await; + } + }; + if let PackBatch::CoreAccounting(core) = &source_window.batch { + let report_result = match self + .await_connector( + &state, + cancellation, + self.connector.read_core_period_balance_report(&context), + ) + .await? + { + ConnectorAwait::Completed(result) => result, + ConnectorAwait::Cancelled => { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + }; + match report_result { + Ok(report) => { + state.gap_codes.remove("report_tie_out_unavailable"); + state.gap_codes.remove("report_tie_out_evidence_invalid"); + let report_sha256 = sha256_json(&report)?; + match assess_core_period_report( + core, + &plan.company.identity, + &planned.range, + &report, + ) { + Ok(assessment) => { + canonical.evidence.report_tie_out_scope = + if assessment.state == TieOutState::Passed { + crate::sync::reconciliation::ComparisonScope::Window + } else { + crate::sync::reconciliation::ComparisonScope::Unavailable + }; + canonical.evidence.report_tie_out = Some(ReportTieOutEvidence { + source_identity: plan.company.identity.clone(), + pack: plan.pack, + pack_schema_version: plan.pack_schema_version, + query_profile: planned.query_profile.clone(), + filters_sha256: planned.filters_sha256.clone(), + from_yyyymmdd: planned.range.from_yyyymmdd.clone(), + to_yyyymmdd: planned.range.to_yyyymmdd.clone(), + report_sha256, + state: assessment.state, + compared_ledger_count: assessment.compared_ledger_count, + source_reported_count: report.source_reported_count, + core_ledger_count: core.ledgers.len() as u64, + }); + match assessment.state { + TieOutState::Passed => {} + TieOutState::Unavailable => { + state + .gap_codes + .insert("period_report_profile_unobserved".to_string()); + } + TieOutState::Mismatch => { + let mut source_ids = assessment + .mismatched_ledger_source_ids + .iter() + .map(|source_id| { + scoped_mismatch_record_alias( + &plan.company.identity.observed_fingerprint, + &plan.run_id, + &planned.id, + source_id, + ) + }) + .collect::>(); + source_ids.sort(); + source_ids.dedup(); + source_ids.truncate(20); + for code in assessment.safe_reason_codes { + canonical.evidence.mismatches.push( + ReconciliationMismatch { + safe_reason_code: code.to_string(), + safe_record_ids: source_ids.clone(), + }, + ); + } + } + } + } + Err(_) => { + state + .gap_codes + .insert("report_tie_out_evidence_invalid".to_string()); + } + } + } + Err(_) => { + // Leave evidence absent so a resumed run retries this + // corroborating read before commit. The durable gap + // keeps a one-shot failure truthful if the run proceeds. + state + .gap_codes + .insert("report_tie_out_unavailable".to_string()); + } + } + } + set_window_phase( + &mut state, + planned, + WindowPhase::Validating, + SnapshotPhase::Validate, + )?; + self.state_store.save(&mut state).await?; + let batch_id = state + .batch_id + .clone() + .ok_or(SnapshotError::StateInvariant("batch_id"))?; + let begin = self + .mirror + .begin_snapshot_window_attempt(BeginSnapshotWindowAttemptInput { + batch_id: batch_id.clone(), + window_id: planned.id.clone(), + started_at_unix_ms: Utc::now().timestamp_millis(), + }) + .await?; + if let Some(abandonment) = begin.prior_abandonment { + Self::record_attempt_abandonment(&mut state, abandonment); + } + let attempt = begin.attempt; + set_window_phase( + &mut state, + planned, + WindowPhase::Staging, + SnapshotPhase::Stage, + )?; + state + .windows + .get_mut(&planned.id) + .ok_or(SnapshotError::StateInvariant("window"))? + .stage_attempt = Some(WindowStageAttempt::from(&attempt)); + self.state_store.save(&mut state).await?; + + let observed_at_unix_ms = Utc::now().timestamp_millis(); + let mut memberships = Vec::with_capacity(MAX_WINDOW_STAGE_CHUNK); + for observation in canonical.observations { + let record_key = format!("{}\0{}", observation.object_type, observation.source_id); + let membership = match observation.mirror_input(&batch_id, observed_at_unix_ms) { + Ok(input) => SnapshotWindowMembershipInput::Observed { + record_key, + observation: Box::new(input), + }, + Err(ReconciliationError::RecordProvenanceUnavailable) => { + // Preserve canonical truth without inventing raw provenance. + state + .gap_codes + .insert("record_provenance_unavailable".to_string()); + SnapshotWindowMembershipInput::ProvenanceUnavailable { + record_key, + canonical_sha256: observation.canonical_sha256, + canonical_payload: observation.canonical_payload, + exact_decimals: observation.exact_decimals, + safe_reason_code: "record_provenance_unavailable".to_string(), + } + } + Err(error) => return Err(error.into()), + }; + memberships.push(membership); + if memberships.len() == MAX_WINDOW_STAGE_CHUNK { + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + self.state_store.heartbeat(&state).await?; + let chunk = std::mem::replace( + &mut memberships, + Vec::with_capacity(MAX_WINDOW_STAGE_CHUNK), + ); + match self + .mirror + .stage_snapshot_window_memberships(&attempt, chunk) + .await + { + Ok(_) => {} + Err( + MirrorError::ObservationConflict + | MirrorError::WindowMembershipConflict, + ) => { + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + "window_membership_replay_conflict", + ) + .await; + } + Err(error) => return Err(error.into()), + } + } + } + if !memberships.is_empty() { + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + self.state_store.heartbeat(&state).await?; + match self + .mirror + .stage_snapshot_window_memberships(&attempt, memberships) + .await + { + Ok(_) => {} + Err( + MirrorError::ObservationConflict | MirrorError::WindowMembershipConflict, + ) => { + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + "window_membership_replay_conflict", + ) + .await; + } + Err(error) => return Err(error.into()), + } + } + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + let completion = match self + .mirror + .complete_snapshot_window_attempt( + &attempt, + Utc::now().timestamp_millis(), + serde_json::to_value(&canonical.evidence) + .map_err(|_| SnapshotError::Serialization)?, + ) + .await + { + Ok(completion) => completion, + Err(MirrorError::WindowMembershipDisappeared) => { + state + .gap_codes + .insert("source_changed_during_resume".to_string()); + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + "source_changed_during_resume", + ) + .await; + } + Err(error) => return Err(error.into()), + }; + Self::record_local_clock_rollback(&mut state, completion.local_clock_moved_backwards); + let receipt = completion.receipt; + canonical.evidence.record_set_sha256 = Some(receipt.membership_sha256.clone()); + let progress = state + .windows + .get_mut(&planned.id) + .ok_or(SnapshotError::StateInvariant("window"))?; + progress.stage_attempt = None; + progress.stage_receipt = Some(WindowStageReceipt::from(&receipt)); + progress.evidence = Some(canonical.evidence); + progress.phase = WindowPhase::Complete; + state.set_phase(SnapshotPhase::Stage, Some(planned.id.clone())); + self.state_store.save(&mut state).await?; + if reconciliation_record_budget_exceeded(&state)? { + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + RECONCILIATION_RECORD_BUDGET_CODE, + ) + .await; + } + } + + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + state.source_stability_check = SourceStabilityCheck::Passed; + let stability_windows = state.executable_leaves(); + for planned in &stability_windows { + let context = RequestContext { + run_id: plan.run_id.clone(), + company: plan.company.clone(), + pack: plan.pack, + schema_version: plan.pack_schema_version, + window: planned.range.clone(), + query_profile: planned.query_profile.clone(), + filters_sha256: planned.filters_sha256.clone(), + }; + let reread_result = match self + .await_connector( + &state, + cancellation, + self.connector.read_pack_window(&context), + ) + .await? + { + ConnectorAwait::Completed(result) => result, + ConnectorAwait::Cancelled => { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + }; + let reread = match reread_result { + Ok(window) => canonicalize_window( + &CanonicalWindowContext { + requested_pack: plan.pack, + schema_version: plan.pack_schema_version, + source_identity: &plan.company.identity, + query_profile: &planned.query_profile, + filters_sha256: &planned.filters_sha256, + external_references: &plan.external_references, + window_id: &planned.id, + requested_window: &planned.range, + }, + &window, + ) + .ok(), + Err(_) => None, + }; + let initial = state + .windows + .get(&planned.id) + .and_then(|window| window.evidence.as_ref()); + match (initial, reread) { + (Some(initial), Some(reread)) + if same_source_semantics(initial, &reread.evidence) => {} + (Some(_), Some(_)) => { + state.source_stability_check = SourceStabilityCheck::Mismatch; + break; + } + _ => { + state.source_stability_check = SourceStabilityCheck::Unavailable; + break; + } + } + } + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + self.state_store.save(&mut state).await?; + + let end_probe = match self + .await_connector(&state, cancellation, self.connector.probe_fresh()) + .await? + { + ConnectorAwait::Completed(result) => result, + ConnectorAwait::Cancelled => { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + }; + state.end_profile_check = match end_probe { + Ok(probe) + if probe.reachable + && probe.profile.packs.get(&plan.pack).is_some_and(|evidence| { + snapshot_pack_start_authorized(plan.pack, evidence) + }) + && probe + .profile + .transports + .get(&TransportId::XmlHttp) + .is_some_and(|evidence| { + evidence.state == CapabilityState::Supported + && evidence.confidence == EvidenceConfidence::Observed + }) + && capability_profile_sha256(&probe.profile)? + == plan.capability_profile_sha256 => + { + EndProfileCheck::Passed + } + Ok(_) => EndProfileCheck::Mismatch, + Err(_) => EndProfileCheck::Unavailable, + }; + self.state_store.save(&mut state).await?; + + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + state.set_phase(SnapshotPhase::Reconcile, None); + self.state_store.save(&mut state).await?; + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + self.state_store.heartbeat(&state).await?; + self.hydrate_completed_window_records(&mut state).await?; + let completed_at_unix_ms = Self::clamp_run_completion( + &mut state, + plan.started_at_unix_ms, + Utc::now().timestamp_millis(), + ); + let decision = reconciliation_decision(plan, &mut state, completed_at_unix_ms)?; + if cancellation.is_cancelled() { + return self + .finish_terminal(plan, state, TerminalKind::Cancelled, "run_cancelled") + .await; + } + self.state_store.heartbeat(&state).await?; + self.commit_decision(plan, state, decision, PendingDecisionKind::Reconciled, None) + .await + } + + async fn finish_terminal( + &self, + plan: &SnapshotPlan, + state: DurableSnapshotState, + kind: TerminalKind, + safe_reason_code: &str, + ) -> Result { + let (state, decision, pending_kind) = self + .prepare_terminal_decision(plan, state, kind, safe_reason_code) + .await?; + self.commit_decision( + plan, + state, + decision, + pending_kind, + Some(safe_reason_code.to_string()), + ) + .await + } + + async fn prepare_terminal_decision( + &self, + plan: &SnapshotPlan, + mut state: DurableSnapshotState, + kind: TerminalKind, + safe_reason_code: &str, + ) -> Result< + ( + DurableSnapshotState, + ReconciliationDecision, + PendingDecisionKind, + ), + SnapshotError, + > { + self.abandon_open_window_attempts(&mut state).await?; + state.gap_codes.insert(safe_reason_code.to_string()); + let batch_id = state + .batch_id + .clone() + .ok_or(SnapshotError::StateInvariant("batch_id"))?; + let completed_at = Self::clamp_run_completion( + &mut state, + plan.started_at_unix_ms, + Utc::now().timestamp_millis(), + ); + let record_counts = terminal_record_counts( + self.mirror + .batch_observation_counts(&batch_id, &plan.run_id) + .await?, + )?; + let decision = build_terminal_proof( + batch_id, + plan.run_id.clone(), + plan.company.identity.clone(), + plan.pack, + plan.pack_schema_version, + plan.started_at_unix_ms, + completed_at, + state.freshness_before, + plan.freshness_target_seconds, + kind, + safe_reason_code.to_string(), + state.gap_codes.clone(), + state.warning_codes.clone(), + record_counts, + ); + let pending_kind = match kind { + TerminalKind::Failed => PendingDecisionKind::Failed, + TerminalKind::Cancelled => PendingDecisionKind::Cancelled, + }; + Ok((state, decision, pending_kind)) + } + + async fn commit_decision( + &self, + plan: &SnapshotPlan, + state: DurableSnapshotState, + decision: ReconciliationDecision, + kind: PendingDecisionKind, + safe_reason_code: Option, + ) -> Result { + let (state, decision) = self + .stage_commit_decision(plan, state, decision, kind, safe_reason_code) + .await?; + match self + .mirror + .commit_batch(decision.mirror_commit.clone()) + .await + { + Ok(receipt) => { + verify_commit_receipt(&state, &decision.proof, &receipt)?; + self.finish_committed_state(state, decision.proof, receipt) + .await + } + Err(MirrorError::BatchClosed) => { + self.resolve_closed_batch(plan, state, decision.proof).await + } + Err(MirrorError::ConcurrentCheckpoint) => { + self.finish_checkpoint_conflict(plan, state).await + } + Err(error) => Err(error.into()), + } + } + + async fn stage_commit_decision( + &self, + plan: &SnapshotPlan, + mut state: DurableSnapshotState, + mut decision: ReconciliationDecision, + kind: PendingDecisionKind, + safe_reason_code: Option, + ) -> Result<(DurableSnapshotState, ReconciliationDecision), SnapshotError> { + decision + .mirror_commit + .bind_expected_checkpoint(state.checkpoint_before.clone()); + let commit = decision.mirror_commit.parts(); + let batch_id = state + .batch_id + .as_deref() + .ok_or(SnapshotError::StateInvariant("batch_id"))?; + let counts = self + .mirror + .batch_observation_counts(batch_id, &plan.run_id) + .await?; + let expected_facts = CommitReceiptFacts { + proof_contract_version: commit.proof_contract_version, + run_id: plan.run_id.clone(), + batch_id: batch_id.to_string(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + outcome: commit.outcome, + verification: commit.verification, + started_at_unix_ms: plan.started_at_unix_ms, + completed_at_unix_ms: commit.completed_at_unix_ms, + accepted_records: counts.accepted_records, + rejected_records: counts.rejected_records, + provenance_unavailable_records: counts.provenance_unavailable_records, + record_counts_sha256: commit.record_counts_sha256.clone(), + snapshot_sha256: commit.snapshot_sha256.clone(), + checkpoint_before: state.checkpoint_before.clone(), + checkpoint_after: commit.checkpoint_after.clone(), + gap_codes: commit.gap_codes.clone(), + warning_codes: commit.warning_codes.clone(), + }; + let expected_receipt_facts_sha256 = sha256_json(&expected_facts)?; + if state + .pending_commit + .as_ref() + .and_then(|pending| pending.expected_receipt_facts_sha256.as_deref()) + .is_some_and(|stored| stored != expected_receipt_facts_sha256) + { + return Err(SnapshotError::StateInvariant("pending_commit_changed")); + } + state.gap_codes = commit.gap_codes.iter().cloned().collect(); + state.warning_codes = commit.warning_codes.iter().cloned().collect(); + state.set_phase(SnapshotPhase::CommitPending, None); + state.pending_commit = Some(PendingCommit { + kind, + completed_at_unix_ms: commit.completed_at_unix_ms, + safe_reason_code, + intended_checkpoint: commit.checkpoint_after.clone(), + expected_receipt_facts_sha256: Some(expected_receipt_facts_sha256), + reconciled_proof: (kind == PendingDecisionKind::Reconciled) + .then(|| Box::new(decision.proof.clone())), + }); + self.state_store.save(&mut state).await?; + Ok((state, decision)) + } + + async fn finish_checkpoint_conflict( + &self, + plan: &SnapshotPlan, + mut state: DurableSnapshotState, + ) -> Result { + // The reconciled decision stored in CommitPending is no longer authoritative once its + // checkpoint CAS loses. Replace it with a non-advancing terminal decision so the durable + // state and staging batch cannot continue advertising a resumable run. + state.pending_commit = None; + let (state, decision, pending_kind) = self + .prepare_terminal_decision( + plan, + state, + TerminalKind::Failed, + "snapshot_checkpoint_changed", + ) + .await?; + let (state, decision) = self + .stage_commit_decision( + plan, + state, + decision, + pending_kind, + Some("snapshot_checkpoint_changed".to_string()), + ) + .await?; + match self + .mirror + .commit_batch(decision.mirror_commit.clone()) + .await + { + Ok(receipt) => { + verify_commit_receipt(&state, &decision.proof, &receipt)?; + self.finish_committed_state(state, decision.proof, receipt) + .await + } + Err(MirrorError::BatchClosed) => { + self.resolve_closed_batch(plan, state, decision.proof).await + } + // A non-advancing terminal proof never participates in checkpoint CAS. Reaching this + // branch would mean the repository contract regressed. + Err(MirrorError::ConcurrentCheckpoint) => Err(SnapshotError::StateInvariant( + "terminal_checkpoint_conflict", + )), + Err(error) => Err(error.into()), + } + } + + async fn resume_pending_commit( + &self, + plan: &SnapshotPlan, + mut state: DurableSnapshotState, + ) -> Result { + let pending = state + .pending_commit + .clone() + .ok_or(SnapshotError::StateInvariant("pending_commit"))?; + let budget_exceeded = pending.kind == PendingDecisionKind::Reconciled + && reconciliation_record_budget_exceeded(&state)?; + let persisted_decision = persisted_reconciled_decision(plan, &state, &pending)?; + let decision = if let Some(decision) = persisted_decision { + Some(decision) + } else if budget_exceeded { + None + } else { + Some(match pending.kind { + PendingDecisionKind::Reconciled => { + self.hydrate_completed_window_records(&mut state).await?; + reconciliation_decision(plan, &mut state, pending.completed_at_unix_ms)? + } + PendingDecisionKind::Failed | PendingDecisionKind::Cancelled => { + let batch_id = state + .batch_id + .clone() + .ok_or(SnapshotError::StateInvariant("batch_id"))?; + let record_counts = terminal_record_counts( + self.mirror + .batch_observation_counts(&batch_id, &plan.run_id) + .await?, + )?; + build_terminal_proof( + batch_id, + plan.run_id.clone(), + plan.company.identity.clone(), + plan.pack, + plan.pack_schema_version, + plan.started_at_unix_ms, + pending.completed_at_unix_ms, + state.freshness_before, + plan.freshness_target_seconds, + if pending.kind == PendingDecisionKind::Cancelled { + TerminalKind::Cancelled + } else { + TerminalKind::Failed + }, + pending + .safe_reason_code + .clone() + .ok_or(SnapshotError::StateInvariant("terminal_reason"))?, + state.gap_codes.clone(), + state.warning_codes.clone(), + record_counts, + ) + } + }) + }; + + let batch_id = state + .batch_id + .as_deref() + .ok_or(SnapshotError::StateInvariant("batch_id"))?; + match self + .mirror + .historical_commit_receipt_for_batch(batch_id, &plan.run_id) + .await + { + Ok(receipt) => { + let decision = decision.ok_or(SnapshotError::StateInvariant( + "legacy_committed_reconciliation_record_budget", + ))?; + verify_commit_receipt(&state, &decision.proof, &receipt)?; + return self + .finish_committed_state(state, decision.proof, receipt) + .await; + } + Err(MirrorError::NotFound) => {} + Err(error) => return Err(error.into()), + } + if budget_exceeded { + // No immutable receipt exists, so replacing the uncommitted advancing decision cannot + // overwrite authority. Current rows carry the compact proof above; legacy rows do not + // need it to fail closed before hydration. + state.pending_commit = None; + return self + .finish_terminal( + plan, + state, + TerminalKind::Failed, + RECONCILIATION_RECORD_BUDGET_CODE, + ) + .await; + } + let decision = decision.ok_or(SnapshotError::CorruptState)?; + let freshness = self + .mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await?; + if pending.intended_checkpoint.is_some() + && freshness.checkpoint_token != state.checkpoint_before + { + return self.finish_checkpoint_conflict(plan, state).await; + } + self.commit_decision( + plan, + state, + decision, + pending.kind, + pending.safe_reason_code, + ) + .await + } + + /// `CommitPending` recovery is intentionally local-only: source data and capability state no + /// longer influence a decision that was already staged and hash-bound. The exact immutable + /// proof-ledger receipt is required before terminalizing a previously committed batch. + async fn resolve_closed_batch( + &self, + plan: &SnapshotPlan, + state: DurableSnapshotState, + proof: ProofManifest, + ) -> Result { + state + .pending_commit + .as_ref() + .ok_or(SnapshotError::StateInvariant("pending_commit"))?; + let batch_id = state + .batch_id + .as_deref() + .ok_or(SnapshotError::StateInvariant("batch_id"))?; + let receipt = self + .mirror + .historical_commit_receipt_for_batch(batch_id, &plan.run_id) + .await?; + verify_commit_receipt(&state, &proof, &receipt)?; + self.finish_committed_state(state, proof, receipt).await + } + + async fn finish_committed_state( + &self, + mut state: DurableSnapshotState, + proof: ProofManifest, + receipt: CommitResult, + ) -> Result { + if receipt.proof_id.is_empty() || !is_lower_sha256(&receipt.proof_sha256) { + return Err(SnapshotError::StateInvariant("commit_receipt")); + } + state.set_phase(SnapshotPhase::EmitProof, None); + state.proof = Some(proof.clone()); + let receipt = StoredCommitReceipt { + proof_id: Some(receipt.proof_id), + proof_sha256: Some(receipt.proof_sha256), + checkpoint_advanced: receipt.checkpoint_advanced, + }; + state.commit_receipt = Some(receipt.clone()); + state.pending_commit = None; + state.set_phase( + match (proof.outcome, proof.verification) { + (_, bridge_tally_core::VerificationState::Verified) => SnapshotPhase::Completed, + ( + bridge_tally_core::RunOutcome::Completed, + bridge_tally_core::VerificationState::Partial, + ) => SnapshotPhase::Partial, + (bridge_tally_core::RunOutcome::Cancelled, _) => SnapshotPhase::Cancelled, + _ => SnapshotPhase::Failed, + }, + None, + ); + self.state_store.save(&mut state).await?; + Ok(SnapshotRunResult { + state, + proof, + receipt, + }) + } +} + +fn same_source_semantics(initial: &WindowEvidence, reread: &WindowEvidence) -> bool { + initial.window_id == reread.window_id + && initial.from_yyyymmdd == reread.from_yyyymmdd + && initial.to_yyyymmdd == reread.to_yyyymmdd + && initial.canonical_sha256 == reread.canonical_sha256 + && initial.query_profile == reread.query_profile + && initial.filters_sha256 == reread.filters_sha256 + && initial.record_provenance_scope == reread.record_provenance_scope + && initial.source_count_scope == reread.source_count_scope + && initial.source_count == reread.source_count + && initial.parsed_count == reread.parsed_count + && initial.accepted_count == reread.accepted_count + && initial.deduped_count == reread.deduped_count + && initial.rejected_count == reread.rejected_count + && initial.duplicate_identity_count == reread.duplicate_identity_count + && initial.missing_identity_count == reread.missing_identity_count + && initial.out_of_range_count == reread.out_of_range_count + && initial.record_counts == reread.record_counts + && initial.accepted_record_counts == reread.accepted_record_counts + && initial.object_counts == reread.object_counts + && initial.accounting_scope == reread.accounting_scope + && initial.accounting_gap_codes == reread.accounting_gap_codes +} + +fn receipt_window_evidence( + planned: &PlannedWindow, + receipt: &SnapshotWindowReceipt, +) -> Result { + let evidence: WindowEvidence = serde_json::from_value(receipt.evidence.clone()) + .map_err(|_| SnapshotError::CorruptState)?; + if evidence.window_id != planned.id + || evidence.from_yyyymmdd != planned.range.from_yyyymmdd + || evidence.to_yyyymmdd != planned.range.to_yyyymmdd + || evidence.query_profile != planned.query_profile.as_str() + || evidence.filters_sha256 != planned.filters_sha256.as_str() + || evidence.deduped_count != u64::from(receipt.member_count) + || !is_lower_sha256(&evidence.canonical_sha256) + || evidence.record_set_sha256.is_some() + || !evidence.canonical_records.is_empty() + { + return Err(SnapshotError::CorruptState); + } + Ok(evidence) +} + +fn persisted_reconciled_decision( + plan: &SnapshotPlan, + state: &DurableSnapshotState, + pending: &PendingCommit, +) -> Result, SnapshotError> { + if pending.kind != PendingDecisionKind::Reconciled { + if pending.reconciled_proof.is_some() { + return Err(SnapshotError::CorruptState); + } + return Ok(None); + } + let Some(proof) = pending.reconciled_proof.as_deref().cloned() else { + // Legacy v5 pending rows can be recomputed within the hydration bound. They cannot be + // treated as exact proof authority above it. + return Ok(None); + }; + let completed_at = proof + .completed_at_unix_ms + .ok_or(SnapshotError::CorruptState)?; + let verification = match proof.verification { + bridge_tally_core::VerificationState::Verified => { + crate::db::tally_mirror::VerificationState::Verified + } + bridge_tally_core::VerificationState::Partial => { + crate::db::tally_mirror::VerificationState::Partial + } + bridge_tally_core::VerificationState::Unverified => { + return Err(SnapshotError::CorruptState); + } + }; + let mut proof_gap_codes = proof + .gaps + .iter() + .map(|gap| gap.safe_reason_code.clone()) + .collect::>(); + proof_gap_codes.sort(); + proof_gap_codes.dedup(); + let expected_checkpoint = match verification { + crate::db::tally_mirror::VerificationState::Verified => Some(format!( + "full:{}", + proof + .snapshot_sha256 + .as_deref() + .ok_or(SnapshotError::CorruptState)? + )), + crate::db::tally_mirror::VerificationState::Partial => None, + crate::db::tally_mirror::VerificationState::Unverified => unreachable!(), + }; + if proof.run_id != plan.run_id + || proof.source_identity != plan.company.identity + || proof.pack != plan.pack + || proof.pack_schema_version != plan.pack_schema_version + || proof.outcome != bridge_tally_core::RunOutcome::Completed + || proof.started_at_unix_ms != plan.started_at_unix_ms + || completed_at != pending.completed_at_unix_ms + || pending.safe_reason_code.is_some() + || pending.intended_checkpoint != expected_checkpoint + || proof_gap_codes != state.gap_codes.iter().cloned().collect::>() + || proof + .gaps + .iter() + .any(|gap| gap.pack != plan.pack || gap.safe_reason_code != gap.field_or_invariant) + { + return Err(SnapshotError::CorruptState); + } + let batch_id = state + .batch_id + .clone() + .ok_or(SnapshotError::StateInvariant("batch_id"))?; + let mirror_commit = CommitBatchInput::reconciled(CommitBatchParts { + batch_id, + proof_contract_version: proof.proof_contract_version, + outcome: crate::db::tally_mirror::RunOutcome::Completed, + verification, + completed_at_unix_ms: completed_at, + record_counts_sha256: Some(proof_record_counts_sha256(&proof.record_counts)), + snapshot_sha256: proof.snapshot_sha256.clone(), + expected_checkpoint_before: state.checkpoint_before.clone(), + checkpoint_after: pending.intended_checkpoint.clone(), + freshness_target_seconds: plan.freshness_target_seconds, + gap_codes: proof_gap_codes, + warning_codes: state.warning_codes.iter().cloned().collect(), + }); + Ok(Some(ReconciliationDecision { + proof, + mirror_commit, + safe_mismatches: Vec::new(), + })) +} + +fn reconciliation_record_budget_exceeded( + state: &DurableSnapshotState, +) -> Result { + aggregate_record_budget_exceeded( + state + .windows + .values() + .filter(|progress| progress.phase == WindowPhase::Complete) + .map(|progress| { + progress + .stage_receipt + .as_ref() + .map(|receipt| receipt.member_count) + .ok_or(SnapshotError::CorruptState) + }), + ) +} + +fn aggregate_record_budget_exceeded( + counts: impl IntoIterator>, +) -> Result { + let mut total = 0_u64; + for count in counts { + total = total + .checked_add(u64::from(count?)) + .ok_or(SnapshotError::CorruptState)?; + if total > MAX_RECONCILIATION_RECORDS { + return Ok(true); + } + } + Ok(false) +} + +fn reconciliation_decision( + plan: &SnapshotPlan, + state: &mut DurableSnapshotState, + completed_at_unix_ms: i64, +) -> Result { + let completed_windows = state + .windows + .iter_mut() + .filter_map(|(id, progress)| { + progress.evidence.as_mut().map(|stored| { + // Move the hydrated map into reconciliation instead of cloning the largest + // structure. Normalized SQLite membership remains the restart authority. + let canonical_records = std::mem::take(&mut stored.canonical_records); + let mut evidence = stored.clone(); + evidence.canonical_records = canonical_records; + (id.clone(), evidence) + }) + }) + .collect(); + Ok(build_reconciliation(ReconciliationInput { + batch_id: state + .batch_id + .clone() + .ok_or(SnapshotError::StateInvariant("batch_id"))?, + run_id: plan.run_id.clone(), + source_identity: plan.company.identity.clone(), + pack: plan.pack, + pack_schema_version: plan.pack_schema_version, + started_at_unix_ms: plan.started_at_unix_ms, + completed_at_unix_ms, + freshness_before: state.freshness_before, + freshness_target_seconds: plan.freshness_target_seconds, + planned_window_ids: state + .executable_leaves() + .into_iter() + .map(|window| window.id) + .collect(), + completed_windows, + end_profile_check: state.end_profile_check, + source_stability_check: state.source_stability_check, + explicit_gap_codes: state.gap_codes.clone(), + warning_codes: state.warning_codes.clone(), + })?) +} + +fn completed_result(state: DurableSnapshotState) -> Result { + let proof = state + .proof + .clone() + .ok_or(SnapshotError::StateInvariant("proof"))?; + let receipt = state + .commit_receipt + .clone() + .ok_or(SnapshotError::StateInvariant("commit_receipt"))?; + Ok(SnapshotRunResult { + state, + proof, + receipt, + }) +} + +fn set_window_phase( + state: &mut DurableSnapshotState, + planned: &PlannedWindow, + window_phase: WindowPhase, + snapshot_phase: SnapshotPhase, +) -> Result<(), SnapshotError> { + let progress = state + .windows + .get_mut(&planned.id) + .ok_or(SnapshotError::StateInvariant("window"))?; + if matches!(progress.phase, WindowPhase::Complete | WindowPhase::Split) { + return Err(SnapshotError::StateInvariant("completed_window_transition")); + } + progress.phase = window_phase; + state.set_phase(snapshot_phase, Some(planned.id.clone())); + Ok(()) +} + +fn snapshot_pack_start_authorized( + pack: CapabilityPackId, + evidence: &bridge_tally_core::CapabilityEvidence, +) -> bool { + match pack { + CapabilityPackId::CoreAccounting => core_snapshot_start_authorized(evidence), + _ => { + evidence.state == CapabilityState::Supported + && evidence.confidence == EvidenceConfidence::Observed + } + } +} + +fn terminal_kind(error: &TallyError) -> TerminalKind { + if matches!(error, TallyError::Cancelled) { + TerminalKind::Cancelled + } else { + TerminalKind::Failed + } +} + +fn tally_error_code(error: &TallyError) -> &'static str { + match error { + TallyError::Unreachable => "tally_unreachable", + TallyError::Protocol { code } => match code.as_str() { + "application_response_rejected" => "application_response_rejected", + "canary_cache_unavailable" => "canary_cache_unavailable", + "capability_cache_unavailable" => "capability_cache_unavailable", + "capability_probe_required" => "capability_probe_required", + "company_export_invalid" => "company_export_invalid", + "company_identity_ambiguous" => "company_identity_ambiguous", + "company_identity_not_found" => "company_identity_not_found", + "group_export_invalid" => "group_export_invalid", + "http_status_failure" => "http_status_failure", + "ledger_export_invalid" => "ledger_export_invalid", + "period_report_invalid" => "period_report_invalid", + "response_content_encoding_unsupported" => "response_content_encoding_unsupported", + "response_encoding_invalid" => "response_encoding_invalid", + "response_read_failed" => "response_read_failed", + "response_size_limit_exceeded" => "response_size_limit_exceeded", + "response_truncated" => "response_truncated", + "unclassified_tally_error" => "unclassified_tally_error", + "voucher_export_invalid" => "voucher_export_invalid", + "voucher_type_export_invalid" => "voucher_type_export_invalid", + _ => "tally_protocol_failed", + }, + TallyError::InvalidData { code } => match code.as_str() { + "company_identity_mismatch" => "company_identity_mismatch", + "connector_context_invalid" => "connector_context_invalid", + "endpoint_invalid" => "endpoint_invalid", + "period_report_identity_missing" => "period_report_identity_missing", + "period_report_scope_mismatch" => "period_report_scope_mismatch", + "request_size_limit_exceeded" => "request_size_limit_exceeded", + _ => "response_parse_failed", + }, + TallyError::Unsupported { code } => match code.as_str() { + "endpoint_circuit_open" => "endpoint_circuit_open", + "endpoint_queue_deadline_exceeded" => "endpoint_queue_deadline_exceeded", + "fresh_capability_probe_not_supported" => "fresh_capability_probe_not_supported", + "http_client_initialization_failed" => "http_client_initialization_failed", + "query_profile_not_supported" => "query_profile_not_supported", + "runtime_capacity_reached" => "runtime_capacity_reached", + "transport_policy_invalid" => "transport_policy_invalid", + _ => "capability_not_supported", + }, + TallyError::ReadResponseTooLarge { .. } => "voucher_response_size_limit_exceeded", + TallyError::Cancelled => "run_cancelled", + TallyError::OutcomeUnknown => "source_outcome_unknown", + } +} + +fn terminal_record_counts( + counts: ObservationCounts, +) -> Result, SnapshotError> { + Ok(BTreeMap::from([ + ( + "locally_staged.accepted".to_string(), + u64::try_from(counts.accepted_records) + .map_err(|_| SnapshotError::StateInvariant("observation_counts"))?, + ), + ( + "locally_staged.rejected".to_string(), + u64::try_from(counts.rejected_records) + .map_err(|_| SnapshotError::StateInvariant("observation_counts"))?, + ), + ( + "locally_staged.provenance_unavailable".to_string(), + u64::try_from(counts.provenance_unavailable_records) + .map_err(|_| SnapshotError::StateInvariant("observation_counts"))?, + ), + ])) +} + +fn core_freshness(state: FreshnessState) -> Freshness { + match state { + FreshnessState::Fresh => Freshness::Fresh, + FreshnessState::Stale => Freshness::Stale, + FreshnessState::NeverVerified => Freshness::NeverVerified, + } +} + +pub fn pack_code(pack: CapabilityPackId) -> &'static str { + match pack { + CapabilityPackId::CoreAccounting => "core_accounting", + CapabilityPackId::IndiaTax => "india_tax", + CapabilityPackId::BillsAndPayments => "bills_and_payments", + CapabilityPackId::Inventory => "inventory", + } +} + +fn valid_yyyymmdd(value: &str) -> bool { + parse_yyyymmdd(value).is_some() +} + +fn is_lower_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +fn verify_commit_receipt( + state: &DurableSnapshotState, + proof: &ProofManifest, + receipt: &CommitResult, +) -> Result<(), SnapshotError> { + let pending = state + .pending_commit + .as_ref() + .ok_or(SnapshotError::StateInvariant("pending_commit"))?; + let expected = pending + .expected_receipt_facts_sha256 + .as_deref() + .ok_or(SnapshotError::StateInvariant("commit_receipt"))?; + let record_counts_sha256 = proof_record_counts_sha256(&proof.record_counts); + if receipt.checkpoint_advanced != pending.intended_checkpoint.is_some() + || sha256_json(&receipt.facts)? != expected + || (receipt.facts.proof_contract_version >= 3 + && receipt.facts.record_counts_sha256.as_deref() != Some(record_counts_sha256.as_str())) + { + return Err(SnapshotError::StateInvariant("commit_receipt")); + } + Ok(()) +} + +fn sha256_json(value: &impl Serialize) -> Result { + let bytes = serde_json::to_vec(value).map_err(|_| SnapshotError::Serialization)?; + Ok(sha256_bytes(&bytes)) +} + +pub fn capability_profile_sha256(profile: &CapabilityProfile) -> Result { + sha256_json(profile) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + hex_digest(Sha256::digest(bytes)) +} + +fn hex_digest(digest: impl AsRef<[u8]>) -> String { + digest + .as_ref() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::Mutex; + + use bridge_tally_core::{ + CanonicalPackWindow, CapabilityEvidence, CapabilityProfile, CoreAccountingBatch, + EvidenceConfidence, GroupRecord, ObservedSourceIdentities, PackBatch, ProbeResult, + RawSourceSha256, SourceIdentity, SourceIdentityKind, SourceRecordEvidence, SourceRecordId, + TransportId, + }; + use bridge_tally_protocol::{ + BRIDGE_GROUP_EXPORT_SCHEMA, BRIDGE_LEDGER_EXPORT_SCHEMA, BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA, + }; + use bridge_tally_transport::{TransportPolicy, XML_REQUEST_MAX_BYTES}; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use tally_protocol_simulator::{Fixture, ResponseFraming, ScenarioPlan, SequenceSimulator}; + + use crate::db::tally_mirror::{ + CapabilityItemInput, CapabilityKind, CapabilitySnapshotInput, CompanyInput, Confidence, + RunOutcome, SourceIdentityInput, VerificationState, + }; + use crate::tally::{RuntimeTallyConnector, TallyConfig, TallyRuntime}; + + use super::*; + + fn fake_profile() -> CapabilityProfile { + CapabilityProfile { + profile_version: 1, + product: "TallyPrime".to_string(), + release: None, + mode: Some("Education".to_string()), + transports: BTreeMap::from([( + TransportId::XmlHttp, + CapabilityEvidence { + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: None, + }, + )]), + features: BTreeMap::new(), + packs: BTreeMap::from([( + CapabilityPackId::CoreAccounting, + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("sealed_profile_executed".to_string()), + }, + )]), + } + } + + struct FakeConnector { + batch: Mutex>>, + company: CompanyRef, + requests: Mutex>, + } + + struct RuntimeCancelledStabilityConnector { + inner: FakeConnector, + request_count: Mutex, + } + + struct RuntimeCancelledReportConnector { + inner: FakeConnector, + } + + struct RuntimeCancelledEndProbeConnector { + inner: FakeConnector, + } + + struct HeartbeatCountingStore { + inner: SqliteSnapshotStateStore, + heartbeats: Mutex, + } + + struct RuntimeReadOnlyConnector { + inner: RuntimeTallyConnector, + company: CompanyRef, + } + + struct FailAfterFirstSplitStore { + inner: SqliteSnapshotStateStore, + failed: AtomicBool, + } + + struct FailAfterFirstCommitPendingStore { + inner: SqliteSnapshotStateStore, + failed: AtomicBool, + } + + struct FailBeforeFirstCompletedWindowSaveStore { + inner: SqliteSnapshotStateStore, + failed: AtomicBool, + } + + struct FailBeforeSecondStagingHeartbeatStore { + inner: SqliteSnapshotStateStore, + staging_heartbeats: Mutex, + } + + struct FailAfterClockRollbackAbandonmentStore { + inner: SqliteSnapshotStateStore, + failed: AtomicBool, + } + + struct FailAfterClockRollbackBeginStore { + inner: SqliteSnapshotStateStore, + failed: AtomicBool, + } + + struct SaveMetricsStore { + inner: SqliteSnapshotStateStore, + saves: Mutex, + max_state_json_bytes: Mutex, + } + + struct ReportConnector { + inner: FakeConnector, + } + + struct AmbiguousCompanyConnector { + inner: FakeConnector, + } + + #[async_trait] + impl SnapshotStateStore for HeartbeatCountingStore { + async fn load( + &self, + resume_key: &str, + ) -> Result, SnapshotError> { + self.inner.load(resume_key).await + } + + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.save(state).await + } + + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError> { + *self.heartbeats.lock().unwrap() += 1; + self.inner.heartbeat(state).await + } + } + + #[async_trait] + impl SnapshotStateStore for FailAfterFirstSplitStore { + async fn load( + &self, + resume_key: &str, + ) -> Result, SnapshotError> { + self.inner.load(resume_key).await + } + + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.save(state).await?; + if state + .windows + .values() + .any(|window| window.phase == WindowPhase::Split) + && !self.failed.swap(true, Ordering::AcqRel) + { + return Err(SnapshotError::StateInvariant( + "injected_crash_after_split_commit", + )); + } + Ok(()) + } + + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.heartbeat(state).await + } + } + + #[async_trait] + impl SnapshotStateStore for FailAfterFirstCommitPendingStore { + async fn load( + &self, + resume_key: &str, + ) -> Result, SnapshotError> { + self.inner.load(resume_key).await + } + + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.save(state).await?; + if state.progress.phase == SnapshotPhase::CommitPending + && !self.failed.swap(true, Ordering::AcqRel) + { + return Err(SnapshotError::StateInvariant( + "injected_crash_after_commit_pending", + )); + } + Ok(()) + } + + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.heartbeat(state).await + } + } + + #[async_trait] + impl SnapshotStateStore for FailBeforeFirstCompletedWindowSaveStore { + async fn load( + &self, + resume_key: &str, + ) -> Result, SnapshotError> { + self.inner.load(resume_key).await + } + + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError> { + if state.windows.values().any(|window| { + window.phase == WindowPhase::Complete && window.stage_receipt.is_some() + }) && !self.failed.swap(true, Ordering::AcqRel) + { + return Err(SnapshotError::StateInvariant( + "injected_crash_after_window_attempt_completion", + )); + } + self.inner.save(state).await + } + + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.heartbeat(state).await + } + } + + #[async_trait] + impl SnapshotStateStore for FailBeforeSecondStagingHeartbeatStore { + async fn load( + &self, + resume_key: &str, + ) -> Result, SnapshotError> { + self.inner.load(resume_key).await + } + + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.save(state).await + } + + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError> { + if state + .windows + .values() + .any(|window| window.phase == WindowPhase::Staging) + { + let mut heartbeats = self.staging_heartbeats.lock().unwrap(); + *heartbeats += 1; + if *heartbeats == 2 { + return Err(SnapshotError::StateInvariant( + "injected_crash_after_partial_window_staging", + )); + } + } + self.inner.heartbeat(state).await + } + } + + #[async_trait] + impl SnapshotStateStore for FailAfterClockRollbackAbandonmentStore { + async fn load( + &self, + resume_key: &str, + ) -> Result, SnapshotError> { + self.inner.load(resume_key).await + } + + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError> { + if state.gap_codes.contains("local_clock_moved_backwards") + && state + .windows + .values() + .all(|window| window.stage_attempt.is_none()) + && !self.failed.swap(true, Ordering::AcqRel) + { + // The repository abandonment has already committed, but its returned evidence + // has not reached the durable run state. This models a lost acknowledgement. + return Err(SnapshotError::StateInvariant( + "injected_crash_after_window_abandonment", + )); + } + self.inner.save(state).await + } + + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.heartbeat(state).await + } + } + + #[async_trait] + impl SnapshotStateStore for FailAfterClockRollbackBeginStore { + async fn load( + &self, + resume_key: &str, + ) -> Result, SnapshotError> { + self.inner.load(resume_key).await + } + + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError> { + if state.gap_codes.contains("local_clock_moved_backwards") + && state.windows.values().any(|window| { + window.phase == WindowPhase::Staging && window.stage_attempt.is_some() + }) + && !self.failed.swap(true, Ordering::AcqRel) + { + return Err(SnapshotError::StateInvariant( + "injected_crash_after_window_attempt_begin", + )); + } + self.inner.save(state).await + } + + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.heartbeat(state).await + } + } + + #[async_trait] + impl SnapshotStateStore for SaveMetricsStore { + async fn load( + &self, + resume_key: &str, + ) -> Result, SnapshotError> { + self.inner.load(resume_key).await + } + + async fn save(&self, state: &mut DurableSnapshotState) -> Result<(), SnapshotError> { + let state_json = serde_json::to_vec(state).map_err(|_| SnapshotError::Serialization)?; + { + *self.saves.lock().unwrap() += 1; + let mut max_bytes = self.max_state_json_bytes.lock().unwrap(); + *max_bytes = (*max_bytes).max(state_json.len()); + } + self.inner.save(state).await + } + + async fn heartbeat(&self, state: &DurableSnapshotState) -> Result<(), SnapshotError> { + self.inner.heartbeat(state).await + } + } + + #[async_trait] + impl TallyConnector for FakeConnector { + async fn probe(&self) -> Result { + Ok(ProbeResult { + reachable: true, + profile: fake_profile(), + }) + } + + async fn probe_fresh(&self) -> Result { + Ok(ProbeResult { + reachable: true, + profile: fake_profile(), + }) + } + + async fn discover_companies(&self) -> Result, TallyError> { + Ok(vec![self.company.clone()]) + } + + async fn read_pack_window( + &self, + context: &RequestContext, + ) -> Result { + self.requests.lock().unwrap().push(context.window.clone()); + self.batch.lock().unwrap().pop_front().unwrap_or_else(|| { + Ok(CanonicalPackWindow::without_source_count_evidence( + PackBatch::CoreAccounting(CoreAccountingBatch::default()), + )) + }) + } + } + + #[async_trait] + impl TallyConnector for AmbiguousCompanyConnector { + async fn probe(&self) -> Result { + self.inner.probe().await + } + + async fn probe_fresh(&self) -> Result { + self.inner.probe_fresh().await + } + + async fn discover_companies(&self) -> Result, TallyError> { + Ok(vec![self.inner.company.clone(), self.inner.company.clone()]) + } + + async fn read_pack_window( + &self, + context: &RequestContext, + ) -> Result { + self.inner.read_pack_window(context).await + } + } + + #[async_trait] + impl TallyConnector for ReportConnector { + async fn probe(&self) -> Result { + self.inner.probe().await + } + + async fn probe_fresh(&self) -> Result { + self.inner.probe_fresh().await + } + + async fn discover_companies(&self) -> Result, TallyError> { + self.inner.discover_companies().await + } + + async fn read_pack_window( + &self, + context: &RequestContext, + ) -> Result { + self.inner.read_pack_window(context).await + } + + async fn read_core_period_balance_report( + &self, + context: &RequestContext, + ) -> Result + { + Ok( + bridge_tally_core::report_tie_out::LedgerPeriodBalanceReport { + source_identity: context.company.identity.clone(), + window: context.window.clone(), + ordinary_books_scope_observed: true, + source_reported_count: 0, + balances: Vec::new(), + }, + ) + } + } + + fn core_groups(count: usize) -> CanonicalPackWindow { + CanonicalPackWindow::without_source_count_evidence(PackBatch::CoreAccounting( + CoreAccountingBatch { + groups: (0..count) + .map(|index| GroupRecord { + source_id: format!("group-{index:06}"), + name: format!("Synthetic Group {index:06}"), + parent_source_id: None, + }) + .collect(), + ..CoreAccountingBatch::default() + }, + )) + } + + fn core_groups_with_provenance(count: usize) -> CanonicalPackWindow { + let mut window = core_groups(count); + window.record_evidence = Some( + (0..count) + .map(|index| { + let source_id = SourceRecordId::parse(format!("group-{index:06}")) + .expect("synthetic source id"); + SourceRecordEvidence { + object_type: CanonicalText::parse("group").expect("object type"), + source_id: source_id.clone(), + identity_kind: SourceIdentityKind::Guid, + observed_identities: ObservedSourceIdentities { + guid: Some(source_id.clone()), + ..ObservedSourceIdentities::default() + }, + raw_source_sha256: RawSourceSha256::parse(sha256_bytes( + source_id.as_str().as_bytes(), + )) + .expect("synthetic raw hash"), + alter_id: None, + } + }) + .collect(), + ); + window + } + + #[async_trait] + impl TallyConnector for RuntimeCancelledStabilityConnector { + async fn probe(&self) -> Result { + self.inner.probe().await + } + + async fn probe_fresh(&self) -> Result { + self.inner.probe_fresh().await + } + + async fn discover_companies(&self) -> Result, TallyError> { + self.inner.discover_companies().await + } + + async fn read_pack_window( + &self, + context: &RequestContext, + ) -> Result { + let response = self.inner.read_pack_window(context).await; + let mut request_count = self.request_count.lock().unwrap(); + *request_count += 1; + if *request_count == 2 { + return Err(TallyError::Cancelled); + } + response + } + } + + #[async_trait] + impl TallyConnector for RuntimeCancelledReportConnector { + async fn probe(&self) -> Result { + self.inner.probe().await + } + + async fn probe_fresh(&self) -> Result { + self.inner.probe_fresh().await + } + + async fn discover_companies(&self) -> Result, TallyError> { + self.inner.discover_companies().await + } + + async fn read_pack_window( + &self, + context: &RequestContext, + ) -> Result { + self.inner.read_pack_window(context).await + } + + async fn read_core_period_balance_report( + &self, + _context: &RequestContext, + ) -> Result + { + Err(TallyError::Cancelled) + } + } + + #[async_trait] + impl TallyConnector for RuntimeCancelledEndProbeConnector { + async fn probe(&self) -> Result { + self.inner.probe().await + } + + async fn probe_fresh(&self) -> Result { + Err(TallyError::Cancelled) + } + + async fn discover_companies(&self) -> Result, TallyError> { + self.inner.discover_companies().await + } + + async fn read_pack_window( + &self, + context: &RequestContext, + ) -> Result { + self.inner.read_pack_window(context).await + } + } + + #[async_trait] + impl TallyConnector for RuntimeReadOnlyConnector { + async fn probe(&self) -> Result { + Ok(ProbeResult { + reachable: true, + profile: fake_profile(), + }) + } + + async fn probe_fresh(&self) -> Result { + self.probe().await + } + + async fn discover_companies(&self) -> Result, TallyError> { + Ok(vec![self.company.clone()]) + } + + async fn read_pack_window( + &self, + context: &RequestContext, + ) -> Result { + self.inner.read_pack_window(context).await + } + } + + async fn setup() -> ( + SqlitePool, + TallyMirrorRepository, + SqliteSnapshotStateStore, + SnapshotPlan, + ) { + let pool = SqlitePoolOptions::new() + .max_connections(1) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect("sqlite::memory:") + .await + .unwrap(); + let mirror = TallyMirrorRepository::new(pool.clone()); + mirror.migrate().await.unwrap(); + let store = + SqliteSnapshotStateStore::for_worker(pool.clone(), "synthetic-test-worker".to_string()) + .unwrap(); + store.migrate().await.unwrap(); + let capability = mirror + .save_capability_snapshot(CapabilitySnapshotInput { + canonical_origin: "http://127.0.0.1:9000".to_string(), + observed_at_unix_ms: 1_000, + profile_version: 1, + product: "TallyPrime".to_string(), + release: None, + mode: Some("Education".to_string()), + mode_confidence: Confidence::Observed, + items: vec![CapabilityItemInput { + kind: CapabilityKind::Pack, + key: "core_accounting".to_string(), + state: crate::db::tally_mirror::CapabilityState::Supported, + confidence: Confidence::Observed, + safe_reason_code: None, + }], + }) + .await + .unwrap(); + let company_identity = SourceIdentity { + bridge_source_lineage: "lineage".to_string(), + company_guid: "company-guid".to_string(), + observed_fingerprint: "fingerprint".to_string(), + }; + let company = mirror + .upsert_company(CompanyInput { + endpoint_id: capability.endpoint_id.clone(), + display_name: "Synthetic Company".to_string(), + identity: SourceIdentityInput { + guid: Some(company_identity.company_guid.clone()), + ..SourceIdentityInput::default() + }, + observed_at_unix_ms: 1_000, + }) + .await + .unwrap(); + let range = ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260731".to_string(), + }; + let root_window = PlannedWindow::deterministic(CapabilityPackId::CoreAccounting, range); + let capability_canary_window = PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260701".to_string(), + }, + ); + let plan = SnapshotPlan { + resume_key: "resume-1".to_string(), + run_id: "run-1".to_string(), + capability_snapshot_id: capability.id, + mirror_company_id: company.id, + company: CompanyRef { + identity: company_identity, + display_name: "Synthetic Company".to_string(), + }, + pack: CapabilityPackId::CoreAccounting, + pack_schema_version: PackSchemaVersion { major: 1, minor: 0 }, + capability_profile_version: 1, + capability_profile_sha256: capability_profile_sha256(&fake_profile()).unwrap(), + source_product: "TallyPrime".to_string(), + source_transport: "xml_http".to_string(), + source_release: None, + source_mode: Some("Education".to_string()), + external_references: ExternalReferenceCatalog::Unavailable, + windows: vec![root_window], + adaptive_window_policy: Some(AdaptiveWindowPolicy::bounded_default()), + capability_canary_window: Some(capability_canary_window), + started_at_unix_ms: 2_000, + freshness_target_seconds: 300, + }; + (pool, mirror, store, plan) + } + + async fn setup_file_backed_lease_store() -> ( + tempfile::TempDir, + SqlitePool, + SqliteSnapshotStateStore, + SnapshotPlan, + ) { + let (_, _, _, plan) = setup().await; + let directory = tempfile::tempdir().unwrap(); + let options = SqliteConnectOptions::new() + .filename(directory.path().join("snapshot-lease.sqlite3")) + .create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(2) + .after_connect(|connection, _| { + Box::pin(async move { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(connection) + .await?; + Ok(()) + }) + }) + .connect_with(options) + .await + .unwrap(); + let mirror = TallyMirrorRepository::new(pool.clone()); + mirror.migrate().await.unwrap(); + let store = + SqliteSnapshotStateStore::for_worker(pool.clone(), "synthetic-file-worker".to_string()) + .unwrap(); + store.migrate().await.unwrap(); + (directory, pool, store, plan) + } + + #[test] + fn adaptive_midpoint_split_is_calendar_exact_and_deterministic() { + let leap = ReadWindow { + from_yyyymmdd: "20240228".to_string(), + to_yyyymmdd: "20240302".to_string(), + }; + let (left, right) = midpoint_split(&leap).expect("split leap window"); + assert_eq!( + (left.from_yyyymmdd.as_str(), left.to_yyyymmdd.as_str()), + ("20240228", "20240229") + ); + assert_eq!( + (right.from_yyyymmdd.as_str(), right.to_yyyymmdd.as_str()), + ("20240301", "20240302") + ); + + let parent = PlannedWindow::deterministic(CapabilityPackId::CoreAccounting, leap); + assert_eq!( + PlannedWindow::adaptive_child(&parent, left.clone()), + PlannedWindow::adaptive_child(&parent, left) + ); + assert!(midpoint_split(&ReadWindow { + from_yyyymmdd: "20240229".to_string(), + to_yyyymmdd: "20240229".to_string(), + }) + .is_none()); + } + + #[tokio::test] + async fn adaptive_graph_tampering_fails_closed() { + let (_, _, _, plan) = setup().await; + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + let root_id = plan.windows[0].id.clone(); + state.windows.get_mut(&root_id).unwrap().phase = WindowPhase::Extracting; + assert!(matches!( + split_leaf(&mut state, &plan, &root_id).unwrap(), + SplitLeafResult::Created + )); + state.validate_invariants().expect("valid split graph"); + + let split = state.windows[&root_id].split.clone().unwrap(); + let mut missing_child = state.clone(); + missing_child.windows.remove(&split.left_window_id); + assert!(matches!( + missing_child.validate_invariants(), + Err(SnapshotError::CorruptState) + )); + + let mut drifted_child = state; + drifted_child + .windows + .get_mut(&split.right_window_id) + .unwrap() + .planned + .range + .from_yyyymmdd = "20260716".to_string(); + assert!(matches!( + drifted_child.validate_invariants(), + Err(SnapshotError::CorruptState) + )); + } + + #[tokio::test] + async fn oversized_voucher_window_persists_deterministic_children_before_dispatch() { + let (_, mirror, store, plan) = setup().await; + let original_plan_sha256 = plan.fingerprint().unwrap(); + let root = plan.windows[0].clone(); + let connector = FakeConnector { + batch: Mutex::new(VecDeque::from([Err(TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow, + })])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("adaptively split oversized window"); + + assert_eq!(plan.fingerprint().unwrap(), original_plan_sha256); + let root_progress = result.state.windows.get(&root.id).expect("root progress"); + assert_eq!(root_progress.phase, WindowPhase::Split); + let leaves = result.state.executable_leaves(); + assert_eq!(leaves.len(), 2); + assert_eq!(leaves[0].range.from_yyyymmdd, "20260701"); + assert_eq!(leaves[0].range.to_yyyymmdd, "20260716"); + assert_eq!(leaves[1].range.from_yyyymmdd, "20260717"); + assert_eq!(leaves[1].range.to_yyyymmdd, "20260731"); + assert_eq!(result.state.progress.total_windows, 2); + assert!(result.state.warning_codes.contains("adaptive_window_split")); + let requests = connector.requests.lock().unwrap(); + assert_eq!( + requests + .iter() + .filter(|range| **range == root.range) + .count(), + 1 + ); + } + + #[tokio::test] + async fn simulator_transport_limit_splits_only_the_voucher_window_before_child_dispatch() { + const TEST_RESPONSE_LIMIT: usize = 4 * 1024; + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-transport-adaptive-split".to_string(); + plan.run_id = "run-transport-adaptive-split".to_string(); + plan.pack_schema_version = bridge_tally_core::CORE_ACCOUNTING_SCHEMA_VERSION; + let company_guid = &plan.company.identity.company_guid; + let master_xml = |schema: &str, object_type: &str| { + format!( + r#"
1
"# + ) + }; + let simulator = SequenceSimulator::spawn(vec![ + ScenarioPlan::new(Fixture::SyntheticXml(master_xml( + BRIDGE_GROUP_EXPORT_SCHEMA, + "GROUP", + ))), + ScenarioPlan::new(Fixture::SyntheticXml(master_xml( + BRIDGE_LEDGER_EXPORT_SCHEMA, + "LEDGER", + ))), + ScenarioPlan::new(Fixture::SyntheticXml(master_xml( + BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA, + "VOUCHERTYPE", + ))), + ScenarioPlan::new(Fixture::Oversized { + minimum_bytes: TEST_RESPONSE_LIMIT + 1, + }) + .with_framing(ResponseFraming::ConnectionClose), + ]) + .unwrap(); + let config = TallyConfig { + host: "127.0.0.1".to_string(), + port: simulator.address().port(), + }; + let runtime = TallyRuntime::with_transport_policy(TransportPolicy { + request_timeout: std::time::Duration::from_secs(30), + status_response_max_bytes: TEST_RESPONSE_LIMIT, + xml_request_max_bytes: XML_REQUEST_MAX_BYTES, + xml_response_max_bytes: TEST_RESPONSE_LIMIT, + }); + let root = plan.windows[0].clone(); + let canary_context = RequestContext { + run_id: plan.run_id.clone(), + company: plan.company.clone(), + pack: plan.pack, + schema_version: plan.pack_schema_version, + window: root.range.clone(), + query_profile: root.query_profile.clone(), + filters_sha256: root.filters_sha256.clone(), + }; + let connector = RuntimeReadOnlyConnector { + inner: RuntimeTallyConnector::new( + runtime, + config, + plan.company.clone(), + canary_context, + ) + .unwrap(), + company: plan.company.clone(), + }; + let crash_store = FailAfterFirstSplitStore { + inner: store.clone(), + failed: AtomicBool::new(false), + }; + + let result = FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await; + let requests = simulator.finish().unwrap(); + let error = result.expect_err("stop after the split graph is durably persisted"); + assert!(matches!( + error, + SnapshotError::StateInvariant("injected_crash_after_split_commit") + )); + let persisted = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert_eq!(persisted.windows.len(), 3); + assert!(persisted.windows[&root.id].split.is_some()); + assert_eq!( + persisted + .windows + .values() + .filter(|window| window.parent_window_id.as_deref() == Some(root.id.as_str())) + .count(), + 2 + ); + assert_eq!(requests.len(), 4); + assert!(requests.iter().all(|request| request.request_processed)); + } + + #[tokio::test] + async fn adaptive_windowing_recursively_splits_in_deterministic_date_order() { + let (_, mirror, store, plan) = setup().await; + let connector = FakeConnector { + batch: Mutex::new(VecDeque::from([ + Err(TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow, + }), + Err(TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow, + }), + ])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("recursively split oversized left child"); + let ranges = result + .state + .executable_leaves() + .into_iter() + .map(|window| (window.range.from_yyyymmdd, window.range.to_yyyymmdd)) + .collect::>(); + assert_eq!( + ranges, + vec![ + ("20260701".to_string(), "20260708".to_string()), + ("20260709".to_string(), "20260716".to_string()), + ("20260717".to_string(), "20260731".to_string()), + ] + ); + assert_eq!(result.state.progress.total_windows, 3); + } + + #[tokio::test] + async fn adaptive_leaf_limit_fails_closed_before_mutating_the_graph() { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-adaptive-limit".to_string(); + plan.run_id = "run-adaptive-limit".to_string(); + plan.adaptive_window_policy + .as_mut() + .unwrap() + .maximum_leaf_windows = 1; + let root_id = plan.windows[0].id.clone(); + let connector = FakeConnector { + batch: Mutex::new(VecDeque::from([Err(TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow, + })])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("leaf limit is a terminal truthful result"); + assert_eq!(result.state.windows.len(), 1); + assert!(result.state.windows[&root_id].split.is_none()); + assert!(result + .state + .gap_codes + .contains("adaptive_window_limit_reached")); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "adaptive_window_limit_reached")); + } + + #[tokio::test] + async fn adaptive_total_node_ceiling_is_checked_before_graph_mutation() { + let (_, _, _, mut plan) = setup().await; + let broad_range = ReadWindow { + from_yyyymmdd: "00010101".to_string(), + to_yyyymmdd: "99991231".to_string(), + }; + plan.windows = vec![PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + broad_range, + )]; + plan.capability_canary_window = Some(PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + ReadWindow { + from_yyyymmdd: "00010101".to_string(), + to_yyyymmdd: "00010101".to_string(), + }, + )); + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + while state.windows.len() < MAX_SNAPSHOT_WINDOWS - 1 { + let candidate = state + .executable_leaves() + .into_iter() + .max_by_key(|window| { + let from = parse_yyyymmdd(&window.range.from_yyyymmdd).unwrap(); + let to = parse_yyyymmdd(&window.range.to_yyyymmdd).unwrap(); + (to - from).num_days() + }) + .unwrap(); + state.windows.get_mut(&candidate.id).unwrap().phase = WindowPhase::Extracting; + assert!(matches!( + split_leaf(&mut state, &plan, &candidate.id).unwrap(), + SplitLeafResult::Created + )); + } + assert_eq!(state.windows.len(), 1_023); + state + .validate_invariants() + .expect("near-limit graph is valid"); + let candidate = state + .executable_leaves() + .into_iter() + .max_by_key(|window| { + let from = parse_yyyymmdd(&window.range.from_yyyymmdd).unwrap(); + let to = parse_yyyymmdd(&window.range.to_yyyymmdd).unwrap(); + (to - from).num_days() + }) + .unwrap(); + state.windows.get_mut(&candidate.id).unwrap().phase = WindowPhase::Extracting; + assert!(matches!( + split_leaf(&mut state, &plan, &candidate.id).unwrap(), + SplitLeafResult::LeafLimitReached + )); + assert_eq!(state.windows.len(), 1_023); + } + + #[tokio::test] + async fn one_day_overflow_fails_once_and_preserves_previous_checkpoint() { + let (_, mirror, store, mut plan) = setup().await; + let one_day = ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260701".to_string(), + }; + plan.resume_key = "resume-one-day-overflow".to_string(); + plan.run_id = "run-one-day-overflow".to_string(); + plan.windows = vec![PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + one_day.clone(), + )]; + plan.capability_canary_window = Some(PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + one_day, + )); + let previous = seed_verified_checkpoint(&mirror, &plan).await; + let freshness_before = mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await + .unwrap(); + let mut state = + DurableSnapshotState::new(&plan, core_freshness(freshness_before.state)).unwrap(); + state.checkpoint_before = freshness_before.checkpoint_token.clone(); + state.gap_codes.insert("earlier_safe_gap".to_string()); + state + .warning_codes + .insert("earlier_safe_warning".to_string()); + store.save(&mut state).await.unwrap(); + let connector = FakeConnector { + batch: Mutex::new(VecDeque::from([Err(TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow, + })])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("one-day overflow is a truthful terminal result"); + + assert_eq!(result.state.progress.phase, SnapshotPhase::Failed); + assert!(!result.receipt.checkpoint_advanced); + assert!(result + .state + .gap_codes + .contains("minimum_window_response_too_large")); + let status = + crate::sync::coordinator::status_from_state_for_test(result.state.clone(), false); + assert_eq!(status.phase, SnapshotPhase::Failed); + assert_eq!((status.completed_windows, status.total_windows), (0, 1)); + assert!(status + .gap_codes + .contains(&"minimum_window_response_too_large".to_string())); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "minimum_window_response_too_large")); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "earlier_safe_gap")); + let ledger_receipt = mirror + .historical_commit_receipt_for_batch( + result.state.batch_id.as_deref().unwrap(), + &plan.run_id, + ) + .await + .unwrap(); + assert_eq!( + ledger_receipt.facts.gap_codes, + vec!["earlier_safe_gap", "minimum_window_response_too_large"] + ); + assert_eq!( + ledger_receipt.facts.warning_codes, + vec!["earlier_safe_warning"] + ); + assert_eq!(connector.requests.lock().unwrap().len(), 1); + let freshness_after = mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await + .unwrap(); + assert_eq!(freshness_after.checkpoint_token, Some(previous)); + assert_eq!(freshness_after.proof_id, freshness_before.proof_id); + assert_eq!( + freshness_after.verified_at_unix_ms, + freshness_before.verified_at_unix_ms + ); + } + + #[tokio::test] + async fn terminal_gap_and_warning_sets_survive_commit_pending_crash_resume() { + let (_, mirror, store, mut plan) = setup().await; + let one_day = ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260701".to_string(), + }; + plan.resume_key = "resume-terminal-commit-pending".to_string(); + plan.run_id = "run-terminal-commit-pending".to_string(); + plan.windows = vec![PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + one_day.clone(), + )]; + plan.capability_canary_window = Some(PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + one_day, + )); + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + state.gap_codes.insert("earlier_safe_gap".to_string()); + state + .warning_codes + .insert("earlier_safe_warning".to_string()); + store.save(&mut state).await.unwrap(); + + let connector = FakeConnector { + batch: Mutex::new(VecDeque::from([Err(TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow, + })])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let crash_store = FailAfterFirstCommitPendingStore { + inner: store.clone(), + failed: AtomicBool::new(false), + }; + let error = FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect_err("inject crash after durable CommitPending save"); + assert!(matches!( + error, + SnapshotError::StateInvariant("injected_crash_after_commit_pending") + )); + let pending = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert_eq!(pending.progress.phase, SnapshotPhase::CommitPending); + assert!(pending.gap_codes.contains("earlier_safe_gap")); + assert!(pending + .gap_codes + .contains("minimum_window_response_too_large")); + assert!(pending.warning_codes.contains("earlier_safe_warning")); + + let resumed = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("resume reconstructs and commits the identical terminal decision"); + assert_eq!(resumed.state.progress.phase, SnapshotPhase::Failed); + assert_eq!(connector.requests.lock().unwrap().len(), 1); + assert_eq!( + resumed + .proof + .gaps + .iter() + .map(|gap| gap.safe_reason_code.as_str()) + .collect::>(), + vec!["earlier_safe_gap", "minimum_window_response_too_large"] + ); + let receipt = mirror + .historical_commit_receipt_for_batch( + resumed.state.batch_id.as_deref().unwrap(), + &plan.run_id, + ) + .await + .unwrap(); + assert_eq!( + receipt.facts.gap_codes, + vec!["earlier_safe_gap", "minimum_window_response_too_large"] + ); + assert_eq!(receipt.facts.warning_codes, vec!["earlier_safe_warning"]); + } + + #[tokio::test] + async fn resume_after_split_commit_never_refetches_the_parent() { + let (_, mirror, store, plan) = setup().await; + let root = plan.windows[0].clone(); + let connector = FakeConnector { + batch: Mutex::new(VecDeque::from([Err(TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow, + })])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let crash_store = FailAfterFirstSplitStore { + inner: store.clone(), + failed: AtomicBool::new(false), + }; + let error = FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect_err("inject crash after durable split commit"); + assert!(matches!( + error, + SnapshotError::StateInvariant("injected_crash_after_split_commit") + )); + let persisted = store + .load(&plan.resume_key) + .await + .unwrap() + .expect("load split graph after crash"); + assert_eq!( + persisted.windows.get(&root.id).unwrap().phase, + WindowPhase::Split + ); + + let resumed = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("resume persisted children"); + assert!(resumed.state.progress.phase.is_terminal()); + assert_eq!( + connector + .requests + .lock() + .unwrap() + .iter() + .filter(|range| *range == &root.range) + .count(), + 1 + ); + } + + #[tokio::test] + async fn report_mismatch_aliases_never_persist_raw_source_ids() { + let (_, _, _, plan) = setup().await; + let window = &plan.windows[0]; + let raw = "00000000-0000-4000-8000-000000000777"; + let alias = scoped_mismatch_record_alias( + &plan.company.identity.observed_fingerprint, + &plan.run_id, + &window.id, + raw, + ); + assert!(alias.starts_with("rid:")); + assert_eq!(alias.len(), 68); + assert!(!alias.contains(raw)); + assert_eq!( + alias, + scoped_mismatch_record_alias( + &plan.company.identity.observed_fingerprint, + &plan.run_id, + &window.id, + raw, + ) + ); + + let mut another_run = plan.clone(); + another_run.run_id = "run-2".to_string(); + assert_ne!( + alias, + scoped_mismatch_record_alias( + &another_run.company.identity.observed_fingerprint, + &another_run.run_id, + &window.id, + raw, + ) + ); + + let durable = serde_json::to_string(&ReconciliationMismatch { + safe_reason_code: "period_report_movement_mismatch".to_string(), + safe_record_ids: vec![alias], + }) + .unwrap(); + assert!(!durable.contains(raw)); + } + + async fn seed_verified_checkpoint( + mirror: &TallyMirrorRepository, + plan: &SnapshotPlan, + ) -> String { + let batch_id = mirror + .begin_batch(BeginBatchInput { + run_id: "seed-run".to_string(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: None, + requested_from_yyyymmdd: Some("20260601".to_string()), + requested_to_yyyymmdd: Some("20260630".to_string()), + started_at_unix_ms: 1_000, + }) + .await + .unwrap(); + let token = "full:seed".to_string(); + mirror + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id, + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: 1_500, + record_counts_sha256: None, + snapshot_sha256: Some("a".repeat(64)), + expected_checkpoint_before: None, + checkpoint_after: Some(token.clone()), + freshness_target_seconds: 300, + gap_codes: Vec::new(), + warning_codes: Vec::new(), + })) + .await + .unwrap(); + token + } + + #[tokio::test] + async fn stale_commit_pending_checkpoint_terminalizes_and_closes_staging_batch() { + let (pool, mirror, store, plan) = setup().await; + let checkpoint_before = seed_verified_checkpoint(&mirror, &plan).await; + let batch_id = mirror + .begin_batch(BeginBatchInput { + run_id: plan.run_id.clone(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: None, + requested_from_yyyymmdd: Some("20260701".to_string()), + requested_to_yyyymmdd: Some("20260731".to_string()), + started_at_unix_ms: plan.started_at_unix_ms, + }) + .await + .unwrap(); + let lost_ack_attempt = mirror + .begin_snapshot_window_attempt(BeginSnapshotWindowAttemptInput { + batch_id: batch_id.clone(), + window_id: plan.windows[0].id.clone(), + started_at_unix_ms: plan.started_at_unix_ms + 1, + }) + .await + .unwrap() + .attempt; + let freshness_before = mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + plan.started_at_unix_ms, + ) + .await + .unwrap(); + let mut pending = + DurableSnapshotState::new(&plan, core_freshness(freshness_before.state)).unwrap(); + pending.batch_id = Some(batch_id.clone()); + pending.checkpoint_before = Some(checkpoint_before.clone()); + pending.gap_codes.insert("earlier_safe_gap".to_string()); + pending.set_phase(SnapshotPhase::CommitPending, None); + pending.pending_commit = Some(PendingCommit { + kind: PendingDecisionKind::Reconciled, + completed_at_unix_ms: plan.started_at_unix_ms + 100, + safe_reason_code: None, + intended_checkpoint: Some("full:losing-run".to_string()), + // The stale decision is deliberately replaced before receipt verification. + expected_receipt_facts_sha256: Some("a".repeat(64)), + reconciled_proof: None, + }); + store.save(&mut pending).await.unwrap(); + + let winning_batch = mirror + .begin_batch(BeginBatchInput { + run_id: "checkpoint-winning-run".to_string(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: None, + requested_from_yyyymmdd: None, + requested_to_yyyymmdd: None, + started_at_unix_ms: plan.started_at_unix_ms + 200, + }) + .await + .unwrap(); + mirror + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: winning_batch, + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: plan.started_at_unix_ms + 300, + record_counts_sha256: None, + snapshot_sha256: Some("b".repeat(64)), + expected_checkpoint_before: Some(checkpoint_before.clone()), + checkpoint_after: Some("full:winning-run".to_string()), + freshness_target_seconds: 300, + gap_codes: Vec::new(), + warning_codes: Vec::new(), + })) + .await + .unwrap(); + + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("a losing checkpoint CAS becomes a durable terminal proof"); + + assert_eq!(result.state.progress.phase, SnapshotPhase::Failed); + assert!(!result.receipt.checkpoint_advanced); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "snapshot_checkpoint_changed")); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "earlier_safe_gap")); + assert!(connector.requests.lock().unwrap().is_empty()); + let ledger_receipt = mirror + .historical_commit_receipt_for_batch(&batch_id, &plan.run_id) + .await + .unwrap(); + assert_eq!( + ledger_receipt.facts.checkpoint_before, + Some(checkpoint_before) + ); + assert_eq!(ledger_receipt.facts.checkpoint_after, None); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_observation_batches WHERE id = ?1", + ) + .bind(&batch_id) + .fetch_one(&pool) + .await + .unwrap(), + "failed" + ); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_snapshot_window_attempts WHERE id = ?1", + ) + .bind(&lost_ack_attempt.attempt_id) + .fetch_one(&pool) + .await + .unwrap(), + "abandoned" + ); + let persisted = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert_eq!(persisted.progress.phase, SnapshotPhase::Failed); + assert!(persisted.pending_commit.is_none()); + assert_eq!( + mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + plan.started_at_unix_ms + 400, + ) + .await + .unwrap() + .checkpoint_token + .as_deref(), + Some("full:winning-run") + ); + } + + #[tokio::test] + async fn immediate_checkpoint_cas_loss_terminalizes_instead_of_returning_resumable_error() { + let (pool, mirror, store, plan) = setup().await; + let checkpoint_before = seed_verified_checkpoint(&mirror, &plan).await; + let batch_id = mirror + .begin_batch(BeginBatchInput { + run_id: plan.run_id.clone(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: None, + requested_from_yyyymmdd: None, + requested_to_yyyymmdd: None, + started_at_unix_ms: plan.started_at_unix_ms, + }) + .await + .unwrap(); + let lost_ack_attempt = mirror + .begin_snapshot_window_attempt(BeginSnapshotWindowAttemptInput { + batch_id: batch_id.clone(), + window_id: plan.windows[0].id.clone(), + started_at_unix_ms: plan.started_at_unix_ms + 1, + }) + .await + .unwrap() + .attempt; + let mut state = DurableSnapshotState::new(&plan, Freshness::Fresh).unwrap(); + state.batch_id = Some(batch_id.clone()); + state.checkpoint_before = Some(checkpoint_before.clone()); + + let winning_batch = mirror + .begin_batch(BeginBatchInput { + run_id: "immediate-checkpoint-winner".to_string(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: None, + requested_from_yyyymmdd: None, + requested_to_yyyymmdd: None, + started_at_unix_ms: plan.started_at_unix_ms + 100, + }) + .await + .unwrap(); + mirror + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: winning_batch, + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: plan.started_at_unix_ms + 200, + record_counts_sha256: None, + snapshot_sha256: Some("d".repeat(64)), + expected_checkpoint_before: Some(checkpoint_before), + checkpoint_after: Some("full:immediate-winner".to_string()), + freshness_target_seconds: 300, + gap_codes: Vec::new(), + warning_codes: Vec::new(), + })) + .await + .unwrap(); + + let record_counts = BTreeMap::new(); + let losing_decision = ReconciliationDecision { + proof: ProofManifest { + proof_contract_version: 3, + run_id: plan.run_id.clone(), + source_identity: plan.company.identity.clone(), + pack: plan.pack, + pack_schema_version: plan.pack_schema_version, + outcome: bridge_tally_core::RunOutcome::Completed, + verification: bridge_tally_core::VerificationState::Verified, + freshness: Freshness::Fresh, + started_at_unix_ms: plan.started_at_unix_ms, + completed_at_unix_ms: Some(plan.started_at_unix_ms + 250), + record_counts: record_counts.clone(), + snapshot_sha256: Some("e".repeat(64)), + gaps: Vec::new(), + }, + mirror_commit: CommitBatchInput::test_only(CommitBatchParts { + batch_id: batch_id.clone(), + proof_contract_version: 3, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: plan.started_at_unix_ms + 250, + record_counts_sha256: Some(proof_record_counts_sha256(&record_counts)), + snapshot_sha256: Some("e".repeat(64)), + expected_checkpoint_before: None, + checkpoint_after: Some("full:immediate-loser".to_string()), + freshness_target_seconds: 300, + gap_codes: Vec::new(), + warning_codes: Vec::new(), + }), + safe_mismatches: Vec::new(), + }; + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .commit_decision( + &plan, + state, + losing_decision, + PendingDecisionKind::Reconciled, + None, + ) + .await + .expect("an immediate CAS loss is replaced by a terminal local proof"); + + assert_eq!(result.state.progress.phase, SnapshotPhase::Failed); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "snapshot_checkpoint_changed")); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_observation_batches WHERE id = ?1", + ) + .bind(&batch_id) + .fetch_one(&pool) + .await + .unwrap(), + "failed" + ); + assert_eq!( + sqlx::query_scalar::<_, String>( + "SELECT state FROM tally_snapshot_window_attempts WHERE id = ?1", + ) + .bind(&lost_ack_attempt.attempt_id) + .fetch_one(&pool) + .await + .unwrap(), + "abandoned" + ); + assert_eq!( + mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + plan.started_at_unix_ms + 300, + ) + .await + .unwrap() + .checkpoint_token + .as_deref(), + Some("full:immediate-winner") + ); + } + + async fn assert_nonadvancing_pending_outcome_survives_checkpoint_advance( + source_error: TallyError, + expected_phase: SnapshotPhase, + expected_reason: &str, + ) { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = format!("resume-preserve-{expected_reason}"); + plan.run_id = format!("run-preserve-{expected_reason}"); + let checkpoint_before = seed_verified_checkpoint(&mirror, &plan).await; + let connector = FakeConnector { + batch: Mutex::new(VecDeque::from([Err(source_error)])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let crash_store = FailAfterFirstCommitPendingStore { + inner: store.clone(), + failed: AtomicBool::new(false), + }; + FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect_err("inject crash after the original terminal decision is durable"); + let pending = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert_eq!(pending.progress.phase, SnapshotPhase::CommitPending); + assert!(pending.pending_commit.as_ref().is_some_and(|commit| { + commit.intended_checkpoint.is_none() + && commit.safe_reason_code.as_deref() == Some(expected_reason) + })); + + let winning_batch = mirror + .begin_batch(BeginBatchInput { + run_id: format!("winner-after-{expected_reason}"), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: None, + requested_from_yyyymmdd: None, + requested_to_yyyymmdd: None, + started_at_unix_ms: plan.started_at_unix_ms + 200, + }) + .await + .unwrap(); + let winning_checkpoint = format!("full:winner-after-{expected_reason}"); + mirror + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: winning_batch, + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: plan.started_at_unix_ms + 300, + record_counts_sha256: None, + snapshot_sha256: Some("c".repeat(64)), + expected_checkpoint_before: Some(checkpoint_before), + checkpoint_after: Some(winning_checkpoint.clone()), + freshness_target_seconds: 300, + gap_codes: Vec::new(), + warning_codes: Vec::new(), + })) + .await + .unwrap(); + + let resumed = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("a non-advancing terminal decision is independent of the live checkpoint"); + assert_eq!(resumed.state.progress.phase, expected_phase); + assert!(resumed + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == expected_reason)); + assert!(!resumed + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "snapshot_checkpoint_changed")); + assert_eq!( + mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + plan.started_at_unix_ms + 400, + ) + .await + .unwrap() + .checkpoint_token, + Some(winning_checkpoint) + ); + } + + #[tokio::test] + async fn failed_and_cancelled_pending_outcomes_survive_checkpoint_advance() { + assert_nonadvancing_pending_outcome_survives_checkpoint_advance( + TallyError::Unsupported { + code: "endpoint_circuit_open".to_string(), + }, + SnapshotPhase::Failed, + "endpoint_circuit_open", + ) + .await; + assert_nonadvancing_pending_outcome_survives_checkpoint_advance( + TallyError::Cancelled, + SnapshotPhase::Cancelled, + "run_cancelled", + ) + .await; + } + + #[tokio::test] + async fn closed_commit_pending_recovers_historical_receipt_after_checkpoint_advances() { + let (_, mirror, store, plan) = setup().await; + let first_batch = mirror + .begin_batch(BeginBatchInput { + run_id: plan.run_id.clone(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: None, + requested_from_yyyymmdd: Some("20260701".to_string()), + requested_to_yyyymmdd: Some("20260731".to_string()), + started_at_unix_ms: plan.started_at_unix_ms, + }) + .await + .unwrap(); + let record_counts = BTreeMap::new(); + let first_checkpoint = "full:first-historical".to_string(); + let first_receipt = mirror + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: first_batch.clone(), + proof_contract_version: 3, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: 3_000, + record_counts_sha256: Some(proof_record_counts_sha256(&record_counts)), + snapshot_sha256: Some("a".repeat(64)), + expected_checkpoint_before: None, + checkpoint_after: Some(first_checkpoint.clone()), + freshness_target_seconds: 300, + gap_codes: Vec::new(), + warning_codes: Vec::new(), + })) + .await + .unwrap(); + let proof = ProofManifest { + proof_contract_version: 3, + run_id: plan.run_id.clone(), + source_identity: plan.company.identity.clone(), + pack: plan.pack, + pack_schema_version: plan.pack_schema_version, + outcome: bridge_tally_core::RunOutcome::Completed, + verification: bridge_tally_core::VerificationState::Verified, + freshness: Freshness::Fresh, + started_at_unix_ms: plan.started_at_unix_ms, + completed_at_unix_ms: Some(3_000), + record_counts, + snapshot_sha256: Some("a".repeat(64)), + gaps: Vec::new(), + }; + let mut pending_state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + pending_state.batch_id = Some(first_batch.clone()); + pending_state.set_phase(SnapshotPhase::CommitPending, None); + pending_state.pending_commit = Some(PendingCommit { + kind: PendingDecisionKind::Reconciled, + completed_at_unix_ms: 3_000, + safe_reason_code: None, + intended_checkpoint: Some(first_checkpoint.clone()), + expected_receipt_facts_sha256: Some(sha256_json(&first_receipt.facts).unwrap()), + reconciled_proof: Some(Box::new(proof.clone())), + }); + store.save(&mut pending_state).await.unwrap(); + + let next_batch = mirror + .begin_batch(BeginBatchInput { + run_id: "later-run".to_string(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: None, + requested_from_yyyymmdd: None, + requested_to_yyyymmdd: None, + started_at_unix_ms: 3_100, + }) + .await + .unwrap(); + mirror + .commit_batch(CommitBatchInput::test_only(CommitBatchParts { + batch_id: next_batch, + proof_contract_version: 1, + outcome: RunOutcome::Completed, + verification: VerificationState::Verified, + completed_at_unix_ms: 3_500, + record_counts_sha256: None, + snapshot_sha256: Some("b".repeat(64)), + expected_checkpoint_before: Some(first_checkpoint), + checkpoint_after: Some("full:later".to_string()), + freshness_target_seconds: 300, + gap_codes: Vec::new(), + warning_codes: Vec::new(), + })) + .await + .unwrap(); + + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let recovered = FullSnapshotEngine::new(&mirror, &store, &connector) + .resolve_closed_batch(&plan, pending_state, proof) + .await + .expect("historical immutable receipt must recover independently of checkpoint head"); + assert_eq!(recovered.receipt.proof_id, Some(first_receipt.proof_id)); + assert_eq!(recovered.state.progress.phase, SnapshotPhase::Completed); + assert!(connector.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn completed_window_attempt_recovers_before_state_save_without_refetching_extraction() { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-completed-window-attempt".to_string(); + plan.run_id = "run-completed-window-attempt".to_string(); + let source = core_groups(2); + let connector = ReportConnector { + inner: FakeConnector { + batch: Mutex::new(VecDeque::from([Ok(source.clone()), Ok(source)])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }, + }; + let crash_store = FailBeforeFirstCompletedWindowSaveStore { + inner: store.clone(), + failed: AtomicBool::new(false), + }; + + let error = FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect_err("crash after the normalized attempt commits but before state save"); + assert!(matches!( + error, + SnapshotError::StateInvariant("injected_crash_after_window_attempt_completion") + )); + assert_eq!(connector.inner.requests.lock().unwrap().len(), 1); + + let persisted = store.load(&plan.resume_key).await.unwrap().unwrap(); + let progress = persisted.windows.get(&plan.windows[0].id).unwrap(); + assert_eq!(progress.phase, WindowPhase::Staging); + let attempt = progress + .stage_attempt + .as_ref() + .expect("durable attempt ref"); + let receipt = mirror + .load_latest_completed_window_receipt(&attempt.batch_id, &attempt.window_id) + .await + .unwrap() + .expect("the normalized SQLite attempt completed before the crash"); + assert_eq!(receipt.attempt_id, attempt.attempt_id); + + let resumed = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("resume consumes the local receipt and only performs the stability reread"); + assert!(resumed.state.progress.phase.is_terminal()); + assert_eq!(connector.inner.requests.lock().unwrap().len(), 2); + let completed = resumed.state.windows.get(&plan.windows[0].id).unwrap(); + assert_eq!(completed.phase, WindowPhase::Complete); + assert!(completed.stage_attempt.is_none()); + assert_eq!( + completed + .stage_receipt + .as_ref() + .map(|stored| stored.attempt.attempt_id.as_str()), + Some(receipt.attempt_id.as_str()) + ); + } + + #[tokio::test] + async fn cancellation_after_receipt_recovery_preserves_provenance_gap_and_count() { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-recovered-unavailable-provenance".to_string(); + plan.run_id = "run-recovered-unavailable-provenance".to_string(); + let source = core_groups(2); + let connector = ReportConnector { + inner: FakeConnector { + batch: Mutex::new(VecDeque::from([Ok(source)])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }, + }; + let crash_store = FailBeforeFirstCompletedWindowSaveStore { + inner: store.clone(), + failed: AtomicBool::new(false), + }; + + FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect_err("crash after unavailable-provenance receipt completion"); + let cancellation = AtomicCancellation::default(); + cancellation.cancel(); + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &cancellation) + .await + .expect("recovered receipt is terminalized locally"); + + assert_eq!(result.state.progress.phase, SnapshotPhase::Cancelled); + assert!(result + .state + .gap_codes + .contains("record_provenance_unavailable")); + assert_eq!( + result.proof.record_counts["locally_staged.provenance_unavailable"], + 2 + ); + let ledger_receipt = mirror + .historical_commit_receipt_for_batch( + result.state.batch_id.as_deref().expect("terminal batch id"), + &plan.run_id, + ) + .await + .expect("bound terminal receipt"); + assert_eq!(ledger_receipt.facts.provenance_unavailable_records, 2); + assert_eq!(connector.inner.requests.lock().unwrap().len(), 1); + } + + #[tokio::test] + async fn cancelled_resume_replays_future_dated_abandonment_after_lost_ack() { + let (pool, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-future-dated-window-attempt".to_string(); + plan.run_id = "run-future-dated-window-attempt".to_string(); + let batch_id = mirror + .begin_batch(BeginBatchInput { + run_id: plan.run_id.clone(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: plan.source_release.clone(), + requested_from_yyyymmdd: Some(plan.windows[0].range.from_yyyymmdd.clone()), + requested_to_yyyymmdd: Some(plan.windows[0].range.to_yyyymmdd.clone()), + started_at_unix_ms: plan.started_at_unix_ms, + }) + .await + .expect("begin pre-crash batch"); + let future_attempt_start = Utc::now() + .timestamp_millis() + .saturating_add(24 * 60 * 60 * 1_000); + let attempt = mirror + .begin_snapshot_window_attempt(BeginSnapshotWindowAttemptInput { + batch_id: batch_id.clone(), + window_id: plan.windows[0].id.clone(), + started_at_unix_ms: future_attempt_start, + }) + .await + .expect("persist attempt from a clock that was ahead") + .attempt; + let mut crashed = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + crashed.batch_id = Some(batch_id); + let window_id = plan.windows[0].id.clone(); + let progress = crashed.windows.get_mut(&window_id).unwrap(); + progress.phase = WindowPhase::Staging; + progress.stage_attempt = Some(WindowStageAttempt::from(&attempt)); + crashed.set_phase(SnapshotPhase::Stage, Some(window_id)); + store + .save(&mut crashed) + .await + .expect("persist synthetic crash state"); + + let cancellation = AtomicCancellation::default(); + cancellation.cancel(); + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let crash_store = FailAfterClockRollbackAbandonmentStore { + inner: store.clone(), + failed: AtomicBool::new(false), + }; + let error = FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &cancellation) + .await + .expect_err("crash after abandonment commit loses the transient acknowledgement"); + assert!(matches!( + error, + SnapshotError::StateInvariant("injected_crash_after_window_abandonment") + )); + let pre_replay = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert_eq!( + pre_replay.windows[&plan.windows[0].id].phase, + WindowPhase::Staging + ); + assert!(!pre_replay.gap_codes.contains("local_clock_moved_backwards")); + + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &cancellation) + .await + .expect("idempotent replay restores durable abandonment evidence and terminalizes"); + + assert_eq!(result.state.progress.phase, SnapshotPhase::Cancelled); + assert!(result + .state + .gap_codes + .contains("local_clock_moved_backwards")); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "local_clock_moved_backwards")); + assert!(connector.requests.lock().unwrap().is_empty()); + let stored_attempt = sqlx::query_as::<_, (String, i64)>( + "SELECT state, completed_at_unix_ms FROM tally_snapshot_window_attempts WHERE id = ?1", + ) + .bind(&attempt.attempt_id) + .fetch_one(&pool) + .await + .expect("read abandoned future-dated attempt"); + assert_eq!( + stored_attempt, + ("abandoned".to_string(), future_attempt_start) + ); + } + + #[tokio::test] + async fn begin_lost_ack_replays_implicit_abandonment_clock_evidence_into_proof() { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-begin-lost-ack-clock".to_string(); + plan.run_id = "run-begin-lost-ack-clock".to_string(); + let batch_id = mirror + .begin_batch(BeginBatchInput { + run_id: plan.run_id.clone(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: plan.source_release.clone(), + requested_from_yyyymmdd: Some(plan.windows[0].range.from_yyyymmdd.clone()), + requested_to_yyyymmdd: Some(plan.windows[0].range.to_yyyymmdd.clone()), + started_at_unix_ms: plan.started_at_unix_ms, + }) + .await + .unwrap(); + let future_attempt_start = Utc::now() + .timestamp_millis() + .saturating_add(24 * 60 * 60 * 1_000); + mirror + .begin_snapshot_window_attempt(BeginSnapshotWindowAttemptInput { + batch_id: batch_id.clone(), + window_id: plan.windows[0].id.clone(), + started_at_unix_ms: future_attempt_start, + }) + .await + .expect("persist an attempt whose begin acknowledgement was lost"); + let mut durable = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + durable.batch_id = Some(batch_id); + let window_id = plan.windows[0].id.clone(); + durable.windows.get_mut(&window_id).unwrap().phase = WindowPhase::Validating; + durable.set_phase(SnapshotPhase::Validate, Some(window_id)); + store.save(&mut durable).await.unwrap(); + + let source = core_groups(0); + let connector = ReportConnector { + inner: FakeConnector { + batch: Mutex::new(VecDeque::from([ + Ok(source.clone()), + Ok(source.clone()), + Ok(source), + ])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }, + }; + let crash_store = FailAfterClockRollbackBeginStore { + inner: store.clone(), + failed: AtomicBool::new(false), + }; + let error = FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect_err("crash after new attempt creation loses begin result and state save"); + assert!(matches!( + error, + SnapshotError::StateInvariant("injected_crash_after_window_attempt_begin") + )); + let before_replay = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert!(!before_replay + .gap_codes + .contains("local_clock_moved_backwards")); + + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("later begin recovers cumulative durable abandonment evidence"); + assert!(result + .state + .gap_codes + .contains("local_clock_moved_backwards")); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "local_clock_moved_backwards")); + } + + #[tokio::test] + async fn partial_window_attempt_then_disappearance_fails_without_advancing_checkpoint() { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-partial-window-disappearance".to_string(); + plan.run_id = "run-partial-window-disappearance".to_string(); + let previous_checkpoint = seed_verified_checkpoint(&mirror, &plan).await; + let first = core_groups_with_provenance(MAX_WINDOW_STAGE_CHUNK + 1); + let mut second = first.clone(); + let PackBatch::CoreAccounting(second_core) = &mut second.batch else { + panic!("core fixture"); + }; + second_core.groups.remove(0); + second + .record_evidence + .as_mut() + .expect("synthetic provenance") + .remove(0); + let connector = ReportConnector { + inner: FakeConnector { + batch: Mutex::new(VecDeque::from([Ok(first), Ok(second)])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }, + }; + let crash_store = FailBeforeSecondStagingHeartbeatStore { + inner: store.clone(), + staging_heartbeats: Mutex::new(0), + }; + + let error = FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect_err("crash after the first normalized membership chunk"); + assert!(matches!( + error, + SnapshotError::StateInvariant("injected_crash_after_partial_window_staging") + )); + assert_eq!(connector.inner.requests.lock().unwrap().len(), 1); + let persisted = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert_eq!( + persisted.windows[&plan.windows[0].id].phase, + WindowPhase::Staging + ); + assert_eq!( + mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await + .unwrap() + .checkpoint_token, + Some(previous_checkpoint.clone()) + ); + + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("disappearance is committed as a truthful terminal result"); + assert_eq!(result.state.progress.phase, SnapshotPhase::Failed); + assert!(!result.receipt.checkpoint_advanced); + assert!(result + .state + .gap_codes + .contains("source_changed_during_resume")); + assert_eq!( + result.proof.record_counts["locally_staged.accepted"], + (MAX_WINDOW_STAGE_CHUNK + 1) as u64 + ); + assert_eq!(result.proof.record_counts["locally_staged.rejected"], 0); + let batch_id = result.state.batch_id.as_deref().expect("terminal batch id"); + let ledger_receipt = mirror + .historical_commit_receipt_for_batch(batch_id, &plan.run_id) + .await + .expect("terminal ledger receipt"); + assert_eq!( + ledger_receipt.facts.accepted_records, + (MAX_WINDOW_STAGE_CHUNK + 1) as i64 + ); + assert_eq!(ledger_receipt.facts.rejected_records, 0); + assert_eq!(connector.inner.requests.lock().unwrap().len(), 2); + assert_eq!( + mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await + .unwrap() + .checkpoint_token, + Some(previous_checkpoint) + ); + } + + #[tokio::test] + async fn normalized_staging_keeps_large_window_state_and_save_generation_compact() { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-large-normalized-window".to_string(); + plan.run_id = "run-large-normalized-window".to_string(); + let record_count = (MAX_WINDOW_STAGE_CHUNK * 4) + 1; + let source = core_groups(record_count); + let connector = ReportConnector { + inner: FakeConnector { + batch: Mutex::new(VecDeque::from([Ok(source.clone()), Ok(source)])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }, + }; + let metrics_store = SaveMetricsStore { + inner: store, + saves: Mutex::new(0), + max_state_json_bytes: Mutex::new(0), + }; + + let result = FullSnapshotEngine::new(&mirror, &metrics_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("large normalized window remains resumable and bounded"); + let saves = *metrics_store.saves.lock().unwrap(); + let max_state_json_bytes = *metrics_store.max_state_json_bytes.lock().unwrap(); + assert!(result.state.progress.phase.is_terminal()); + assert_eq!(result.state.generation as usize, saves); + assert!( + saves < 32, + "durable generations must follow phases, not {record_count} records: {saves}" + ); + assert!( + max_state_json_bytes < 128 * 1024, + "record identities leaked into compact state: {max_state_json_bytes} bytes" + ); + assert_eq!(connector.inner.requests.lock().unwrap().len(), 2); + } + + #[test] + fn aggregate_reconciliation_record_budget_accepts_the_exact_boundary() { + assert!(!aggregate_record_budget_exceeded([Ok(50_000), Ok(50_000),]) + .expect("the exact aggregate boundary is valid")); + assert!(aggregate_record_budget_exceeded([Ok(50_000), Ok(50_001),]) + .expect("the first record above the boundary is detected")); + } + + #[tokio::test] + async fn committed_over_budget_pending_recovers_exact_proof_without_hydration() { + let (_, mirror, store, plan) = setup().await; + let batch_id = mirror + .begin_batch(BeginBatchInput { + run_id: plan.run_id.clone(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: plan.source_release.clone(), + requested_from_yyyymmdd: Some(plan.windows[0].range.from_yyyymmdd.clone()), + requested_to_yyyymmdd: Some(plan.windows[0].range.to_yyyymmdd.clone()), + started_at_unix_ms: plan.started_at_unix_ms, + }) + .await + .unwrap(); + let planned = &plan.windows[0]; + let mut evidence = canonicalize_window( + &CanonicalWindowContext { + requested_pack: plan.pack, + schema_version: plan.pack_schema_version, + source_identity: &plan.company.identity, + query_profile: &planned.query_profile, + filters_sha256: &planned.filters_sha256, + external_references: &plan.external_references, + window_id: &planned.id, + requested_window: &planned.range, + }, + &core_groups(0), + ) + .unwrap() + .evidence; + let over_budget = u32::try_from(MAX_RECONCILIATION_RECORDS + 1).unwrap(); + evidence.deduped_count = u64::from(over_budget); + evidence.record_set_sha256 = Some("a".repeat(64)); + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + state.batch_id = Some(batch_id.clone()); + state + .gap_codes + .insert("source_count_unavailable".to_string()); + let progress = state.windows.get_mut(&planned.id).unwrap(); + progress.phase = WindowPhase::Complete; + progress.stage_receipt = Some(WindowStageReceipt { + attempt: WindowStageAttempt { + attempt_id: "committed-over-budget-attempt".to_string(), + batch_id: batch_id.clone(), + window_id: planned.id.clone(), + attempt_ordinal: 1, + }, + member_count: over_budget, + membership_sha256: "a".repeat(64), + receipt_sha256: "b".repeat(64), + }); + progress.evidence = Some(evidence); + state.set_phase(SnapshotPhase::Reconcile, None); + + let completed_at = plan.started_at_unix_ms + 100; + let record_counts = BTreeMap::new(); + let snapshot_sha256 = "c".repeat(64); + let proof = ProofManifest { + proof_contract_version: 3, + run_id: plan.run_id.clone(), + source_identity: plan.company.identity.clone(), + pack: plan.pack, + pack_schema_version: plan.pack_schema_version, + outcome: bridge_tally_core::RunOutcome::Completed, + verification: bridge_tally_core::VerificationState::Partial, + freshness: Freshness::NeverVerified, + started_at_unix_ms: plan.started_at_unix_ms, + completed_at_unix_ms: Some(completed_at), + record_counts: record_counts.clone(), + snapshot_sha256: Some(snapshot_sha256.clone()), + gaps: vec![bridge_tally_core::Gap { + pack: plan.pack, + field_or_invariant: "source_count_unavailable".to_string(), + state: CapabilityState::Unknown, + safe_reason_code: "source_count_unavailable".to_string(), + }], + }; + let decision = ReconciliationDecision { + proof: proof.clone(), + mirror_commit: CommitBatchInput::test_only(CommitBatchParts { + batch_id: batch_id.clone(), + proof_contract_version: 3, + outcome: RunOutcome::Completed, + verification: VerificationState::Partial, + completed_at_unix_ms: completed_at, + record_counts_sha256: Some(proof_record_counts_sha256(&record_counts)), + snapshot_sha256: Some(snapshot_sha256), + expected_checkpoint_before: None, + checkpoint_after: None, + freshness_target_seconds: plan.freshness_target_seconds, + gap_codes: vec!["source_count_unavailable".to_string()], + warning_codes: Vec::new(), + }), + safe_mismatches: Vec::new(), + }; + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let engine = FullSnapshotEngine::new(&mirror, &store, &connector); + let (_pending_state, staged) = engine + .stage_commit_decision( + &plan, + state, + decision, + PendingDecisionKind::Reconciled, + None, + ) + .await + .unwrap(); + let immutable_receipt = mirror + .commit_batch(staged.mirror_commit) + .await + .expect("commit before acknowledgement is lost"); + + // The fake normalized attempt does not exist. Exact terminal recovery proves no canonical + // map hydration was attempted after the lost acknowledgement. + let recovered = engine + .run(&plan, &AtomicCancellation::default()) + .await + .expect("persisted compact proof recovers the immutable receipt"); + assert_eq!(recovered.state.progress.phase, SnapshotPhase::Partial); + assert_eq!(recovered.proof, proof); + assert_eq!(recovered.receipt.proof_id, Some(immutable_receipt.proof_id)); + assert!(connector.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn over_budget_max_window_state_terminalizes_before_hydration_and_is_restart_stable() { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-over-budget-many-window".to_string(); + plan.run_id = "run-over-budget-many-window".to_string(); + let first_day = NaiveDate::from_ymd_opt(2026, 7, 1).unwrap(); + plan.windows = (0..MAX_SNAPSHOT_WINDOWS) + .map(|offset| { + let day = first_day + ChronoDuration::days(offset as i64); + let day = day.format("%Y%m%d").to_string(); + PlannedWindow::deterministic( + plan.pack, + ReadWindow { + from_yyyymmdd: day.clone(), + to_yyyymmdd: day, + }, + ) + }) + .collect(); + plan.capability_canary_window = Some(plan.windows[0].clone()); + plan.validate() + .expect("maximum-window plan remains bounded"); + + let batch_id = mirror + .begin_batch(BeginBatchInput { + run_id: plan.run_id.clone(), + capability_snapshot_id: plan.capability_snapshot_id.clone(), + company_id: plan.mirror_company_id.clone(), + pack_id: pack_code(plan.pack).to_string(), + pack_schema_major: plan.pack_schema_version.major, + pack_schema_minor: plan.pack_schema_version.minor, + source_transport: plan.source_transport.clone(), + source_release: plan.source_release.clone(), + requested_from_yyyymmdd: Some(plan.windows[0].range.from_yyyymmdd.clone()), + requested_to_yyyymmdd: Some( + plan.windows[MAX_SNAPSHOT_WINDOWS - 1] + .range + .to_yyyymmdd + .clone(), + ), + started_at_unix_ms: plan.started_at_unix_ms, + }) + .await + .unwrap(); + let first = &plan.windows[0]; + let mut base_evidence = canonicalize_window( + &CanonicalWindowContext { + requested_pack: plan.pack, + schema_version: plan.pack_schema_version, + source_identity: &plan.company.identity, + query_profile: &first.query_profile, + filters_sha256: &first.filters_sha256, + external_references: &plan.external_references, + window_id: &first.id, + requested_window: &first.range, + }, + &core_groups(0), + ) + .unwrap() + .evidence; + let membership_sha256 = "a".repeat(64); + base_evidence.deduped_count = 98; + base_evidence.record_set_sha256 = Some(membership_sha256.clone()); + + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + state.batch_id = Some(batch_id.clone()); + for (index, planned) in plan.windows.iter().enumerate() { + let mut evidence = base_evidence.clone(); + evidence.window_id = planned.id.clone(); + evidence.from_yyyymmdd = planned.range.from_yyyymmdd.clone(); + evidence.to_yyyymmdd = planned.range.to_yyyymmdd.clone(); + let progress = state.windows.get_mut(&planned.id).unwrap(); + progress.phase = WindowPhase::Complete; + progress.stage_receipt = Some(WindowStageReceipt { + attempt: WindowStageAttempt { + attempt_id: format!("budget-attempt-{index}"), + batch_id: batch_id.clone(), + window_id: planned.id.clone(), + attempt_ordinal: 1, + }, + member_count: 98, + membership_sha256: membership_sha256.clone(), + receipt_sha256: "b".repeat(64), + }); + progress.evidence = Some(evidence); + } + state.set_phase(SnapshotPhase::Reconcile, None); + assert!(reconciliation_record_budget_exceeded(&state).unwrap()); + store.save(&mut state).await.unwrap(); + + // None of the fake normalized receipts exist. Any hydration attempt would therefore fail + // as corrupt; durable terminal success proves the count-only preflight ran first. + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let first_result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("over-budget durable membership terminalizes without hydration"); + assert_eq!(first_result.state.progress.phase, SnapshotPhase::Failed); + assert!(!first_result.receipt.checkpoint_advanced); + assert!(first_result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == RECONCILIATION_RECORD_BUDGET_CODE)); + assert!(connector.requests.lock().unwrap().is_empty()); + + let resumed = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("terminal restart reuses the same proof without hydration or retry"); + assert_eq!(resumed.proof, first_result.proof); + assert_eq!(resumed.receipt, first_result.receipt); + assert!(connector.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn v4_nonterminal_state_remains_inspectable_but_cannot_resume() { + let (_, mirror, store, plan) = setup().await; + let mut legacy = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + legacy.state_version = LEGACY_SNAPSHOT_STATE_VERSION_V4; + store.save(&mut legacy).await.unwrap(); + + let loaded = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert_eq!(loaded.state_version, LEGACY_SNAPSHOT_STATE_VERSION_V4); + loaded + .validate_invariants() + .expect("v4 remains readable for operator inspection"); + assert_eq!(store.load_recent(10).await.unwrap().len(), 1); + assert!(matches!( + loaded.recoverable_plan(), + Err(SnapshotError::ResumePlanUnavailable) + )); + + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + assert!(matches!( + FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await, + Err(SnapshotError::ResumePlanUnavailable) + )); + assert!(connector.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn duplicate_live_company_identity_fails_before_any_snapshot_read() { + let (_, mirror, store, plan) = setup().await; + let connector = AmbiguousCompanyConnector { + inner: FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }, + }; + + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("an ambiguous live identity becomes a durable terminal result"); + + assert_eq!(result.state.progress.phase, SnapshotPhase::Failed); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "company_identity_ambiguous")); + assert!(connector.inner.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn sealed_empty_canary_authorizes_truthful_partial_and_is_idempotent() { + let (pool, mirror, store, plan) = setup().await; + let connector = FakeConnector { + batch: Mutex::new(VecDeque::from([Ok( + CanonicalPackWindow::without_source_count_evidence(PackBatch::CoreAccounting( + CoreAccountingBatch::default(), + )), + )])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let cancellation = AtomicCancellation::default(); + let engine = FullSnapshotEngine::new(&mirror, &store, &connector); + let first = engine.run(&plan, &cancellation).await.unwrap(); + assert_eq!(first.state.progress.phase, SnapshotPhase::Partial); + assert!(!first.receipt.checkpoint_advanced); + assert!(first + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "source_count_unavailable")); + assert!(first.state.gap_codes.contains("source_count_unavailable")); + assert_eq!( + first.state.gap_codes, + first + .proof + .gaps + .iter() + .map(|gap| gap.safe_reason_code.clone()) + .collect() + ); + let proof_id = first + .receipt + .proof_id + .as_deref() + .expect("partial snapshot stores a durable proof"); + let export = mirror + .redacted_proof_export(&plan.mirror_company_id, proof_id, 10_000) + .await + .expect("hash-valid durable partial proof is exportable"); + assert!(export.json.contains("\"verification_state\": \"partial\"")); + assert!(export.json.contains("\"authenticity_claim\": \"none\"")); + assert!(!export.json.contains(&plan.company.display_name)); + assert!(!export.json.contains(&plan.company.identity.company_guid)); + assert!(!export.json.contains(&plan.run_id)); + assert!(!export.json.contains(proof_id)); + assert!(mirror + .redacted_proof_export("wrong-company", proof_id, 10_000) + .await + .is_err()); + let second = engine.run(&plan, &cancellation).await.unwrap(); + assert_eq!(first.proof.snapshot_sha256, second.proof.snapshot_sha256); + let freshness = mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await + .unwrap(); + assert_eq!(freshness.checkpoint_token, None); + let terminal_mutation = sqlx::query( + "UPDATE tally_snapshot_run_states SET generation = generation + 1 \ + WHERE resume_key = ?1", + ) + .bind(&plan.resume_key) + .execute(&pool) + .await; + assert!(terminal_mutation.is_err()); + let terminal_delete = + sqlx::query("DELETE FROM tally_snapshot_run_states WHERE resume_key = ?1") + .bind(&plan.resume_key) + .execute(&pool) + .await; + assert!(terminal_delete.is_err()); + } + + #[tokio::test] + async fn parse_failure_and_cancellation_leave_previous_verified_checkpoint_active() { + let (_pool, mirror, store, mut plan) = setup().await; + let previous = seed_verified_checkpoint(&mirror, &plan).await; + let cancellation = AtomicCancellation::default(); + + plan.resume_key = "resume-failed".to_string(); + plan.run_id = "run-failed".to_string(); + let failing = FakeConnector { + batch: Mutex::new(VecDeque::from([Err(TallyError::InvalidData { + code: "synthetic_parse_failure".to_string(), + })])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let failed = FullSnapshotEngine::new(&mirror, &store, &failing) + .run(&plan, &cancellation) + .await + .unwrap(); + assert_eq!(failed.state.progress.phase, SnapshotPhase::Failed); + assert!(!failed.receipt.checkpoint_advanced); + assert_eq!( + mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await + .unwrap() + .checkpoint_token, + Some(previous.clone()) + ); + + plan.resume_key = "resume-cancelled".to_string(); + plan.run_id = "run-cancelled".to_string(); + let cancelled = AtomicCancellation::default(); + cancelled.cancel(); + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &cancelled) + .await + .unwrap(); + assert_eq!(result.state.progress.phase, SnapshotPhase::Cancelled); + assert!(!result.receipt.checkpoint_advanced); + assert_eq!( + mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await + .unwrap() + .checkpoint_token, + Some(previous) + ); + } + + #[tokio::test] + async fn future_dated_run_terminalizes_with_clamped_proof_and_ledger_creation_time() { + let (pool, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-future-run-clock".to_string(); + plan.run_id = "run-future-run-clock".to_string(); + plan.started_at_unix_ms = Utc::now() + .timestamp_millis() + .saturating_add(24 * 60 * 60 * 1_000); + let cancellation = AtomicCancellation::default(); + cancellation.cancel(); + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &cancellation) + .await + .expect("future-dated batch reaches a truthful terminal proof without waiting"); + assert_eq!(result.state.progress.phase, SnapshotPhase::Cancelled); + assert_eq!( + result.proof.completed_at_unix_ms, + Some(plan.started_at_unix_ms) + ); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "local_clock_moved_backwards")); + let ledger_times = sqlx::query_as::<_, (i64, i64)>( + "SELECT completed_at_unix_ms, created_at_unix_ms FROM tally_proof_ledger \ + WHERE run_id = ?1", + ) + .bind(&plan.run_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(ledger_times.0, plan.started_at_unix_ms); + assert!(ledger_times.1 >= ledger_times.0); + } + + #[tokio::test] + async fn runtime_cancellation_during_source_stability_is_terminal() { + let (_, mirror, store, plan) = setup().await; + let cancellation = AtomicCancellation::default(); + let connector = RuntimeCancelledStabilityConnector { + inner: FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }, + request_count: Mutex::new(0), + }; + + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &cancellation) + .await + .expect("cancellation is committed as a truthful terminal result"); + assert_eq!(result.state.progress.phase, SnapshotPhase::Cancelled); + assert!(!cancellation.is_cancelled()); + assert_eq!(connector.inner.requests.lock().unwrap().len(), 2); + assert!(!result.receipt.checkpoint_advanced); + assert!(result + .proof + .gaps + .iter() + .any(|gap| gap.safe_reason_code == "run_cancelled")); + assert_eq!( + mirror + .freshness( + &plan.mirror_company_id, + pack_code(plan.pack), + Utc::now().timestamp_millis(), + ) + .await + .unwrap() + .checkpoint_token, + None + ); + } + + #[tokio::test] + async fn runtime_cancellation_during_period_report_survives_commit_pending_resume() { + let (_, mirror, store, plan) = setup().await; + let cancellation = AtomicCancellation::default(); + let connector = RuntimeCancelledReportConnector { + inner: FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }, + }; + let crash_store = FailAfterFirstCommitPendingStore { + inner: store.clone(), + failed: AtomicBool::new(false), + }; + let error = FullSnapshotEngine::new(&mirror, &crash_store, &connector) + .run(&plan, &cancellation) + .await + .expect_err("inject crash after the cancelled decision is durable"); + assert!(matches!( + error, + SnapshotError::StateInvariant("injected_crash_after_commit_pending") + )); + assert!(!cancellation.is_cancelled()); + let pending = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert_eq!(pending.progress.phase, SnapshotPhase::CommitPending); + assert_eq!( + pending + .pending_commit + .as_ref() + .and_then(|pending| pending.safe_reason_code.as_deref()), + Some("run_cancelled") + ); + + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &cancellation) + .await + .expect("cancelled proof resumes without another source request"); + assert_eq!(result.state.progress.phase, SnapshotPhase::Cancelled); + assert!(!result + .state + .gap_codes + .contains("report_tie_out_unavailable")); + assert!(result.state.gap_codes.contains("run_cancelled")); + assert!(!result.receipt.checkpoint_advanced); + assert_eq!( + result.proof.outcome, + bridge_tally_core::RunOutcome::Cancelled + ); + let receipt = mirror + .historical_commit_receipt_for_batch( + result.state.batch_id.as_deref().unwrap(), + &plan.run_id, + ) + .await + .unwrap(); + assert_eq!(receipt.facts.outcome, RunOutcome::Cancelled); + assert_eq!(receipt.facts.gap_codes, vec!["run_cancelled"]); + } + + #[tokio::test] + async fn runtime_cancellation_during_end_probe_is_terminal() { + let (_, mirror, store, plan) = setup().await; + let cancellation = AtomicCancellation::default(); + let connector = RuntimeCancelledEndProbeConnector { + inner: FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }, + }; + + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &cancellation) + .await + .expect("runtime cancellation is committed as Cancelled"); + assert!(!cancellation.is_cancelled()); + assert_eq!(result.state.progress.phase, SnapshotPhase::Cancelled); + assert_eq!( + result.proof.outcome, + bridge_tally_core::RunOutcome::Cancelled + ); + assert!(!result.receipt.checkpoint_advanced); + assert!(result.state.gap_codes.contains("run_cancelled")); + assert!(!result + .state + .gap_codes + .contains("end_profile_check_unavailable")); + } + + #[tokio::test] + async fn reviewed_tally_error_codes_are_precise_allowlisted_and_persisted() { + let cases = [ + ( + TallyError::Protocol { + code: "response_truncated".to_string(), + }, + "response_truncated", + ), + ( + TallyError::InvalidData { + code: "company_identity_mismatch".to_string(), + }, + "company_identity_mismatch", + ), + ( + TallyError::Protocol { + code: "company_identity_not_found".to_string(), + }, + "company_identity_not_found", + ), + ( + TallyError::Protocol { + code: "company_identity_ambiguous".to_string(), + }, + "company_identity_ambiguous", + ), + ( + TallyError::Unsupported { + code: "endpoint_queue_deadline_exceeded".to_string(), + }, + "endpoint_queue_deadline_exceeded", + ), + ( + TallyError::Protocol { + code: "source_supplied_sensitive_text".to_string(), + }, + "tally_protocol_failed", + ), + ( + TallyError::InvalidData { + code: "source_supplied_sensitive_text".to_string(), + }, + "response_parse_failed", + ), + ( + TallyError::Unsupported { + code: "source_supplied_sensitive_text".to_string(), + }, + "capability_not_supported", + ), + ]; + + for (index, (error, expected)) in cases.into_iter().enumerate() { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = format!("resume-safe-code-{index}"); + plan.run_id = format!("run-safe-code-{index}"); + let connector = FakeConnector { + batch: Mutex::new(VecDeque::from([Err(error)])), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + + let result = FullSnapshotEngine::new(&mirror, &store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("safe connector failure becomes a durable terminal result"); + assert_eq!(result.state.progress.phase, SnapshotPhase::Failed); + assert_eq!( + result.state.gap_codes, + BTreeSet::from([expected.to_string()]) + ); + assert_eq!(result.proof.gaps.len(), 1); + assert_eq!(result.proof.gaps[0].safe_reason_code, expected); + let receipt = mirror + .historical_commit_receipt_for_batch( + result.state.batch_id.as_deref().unwrap(), + &plan.run_id, + ) + .await + .unwrap(); + assert_eq!(receipt.facts.gap_codes, vec![expected]); + assert!(!receipt + .facts + .gap_codes + .iter() + .any(|code| code.contains("sensitive"))); + } + } + + #[tokio::test] + async fn multi_leaf_source_stability_heartbeats_the_owned_lease() { + let (_, mirror, store, mut plan) = setup().await; + plan.resume_key = "resume-multi-leaf-heartbeat".to_string(); + plan.run_id = "run-multi-leaf-heartbeat".to_string(); + plan.windows = ["20260701", "20260702"] + .into_iter() + .map(|date| { + PlannedWindow::deterministic( + CapabilityPackId::CoreAccounting, + ReadWindow { + from_yyyymmdd: date.to_string(), + to_yyyymmdd: date.to_string(), + }, + ) + }) + .collect(); + plan.capability_canary_window = Some(plan.windows[0].clone()); + let heartbeat_store = HeartbeatCountingStore { + inner: store, + heartbeats: Mutex::new(0), + }; + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + + FullSnapshotEngine::new(&mirror, &heartbeat_store, &connector) + .run(&plan, &AtomicCancellation::default()) + .await + .expect("multi-leaf run retains its lease through stability and commit"); + assert_eq!(connector.requests.lock().unwrap().len(), 4); + assert!(*heartbeat_store.heartbeats.lock().unwrap() >= 8); + } + + #[tokio::test] + async fn connector_await_renews_the_lease_while_a_call_is_in_flight() { + let (_, mirror, store, plan) = setup().await; + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + store.save(&mut state).await.unwrap(); + let heartbeat_store = HeartbeatCountingStore { + inner: store, + heartbeats: Mutex::new(0), + }; + let connector = FakeConnector { + batch: Mutex::new(VecDeque::new()), + company: plan.company.clone(), + requests: Mutex::new(Vec::new()), + }; + let engine = FullSnapshotEngine::new(&mirror, &heartbeat_store, &connector); + + let result = engine + .await_connector(&state, &AtomicCancellation::default(), async { + tokio::time::sleep(Duration::from_millis(90)).await; + Ok::<_, TallyError>(()) + }) + .await + .unwrap(); + assert!(matches!(result, ConnectorAwait::Completed(Ok(())))); + assert!(*heartbeat_store.heartbeats.lock().unwrap() >= 3); + } + + #[tokio::test] + async fn expired_lease_cannot_be_revived_by_heartbeat_or_state_save() { + let (pool, _, store, plan) = setup().await; + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + store.save(&mut state).await.unwrap(); + sqlx::query( + "UPDATE tally_snapshot_run_states SET lease_expires_at_unix_ms = ?1 \ + WHERE resume_key = ?2", + ) + .bind(Utc::now().timestamp_millis().saturating_sub(1)) + .bind(&plan.resume_key) + .execute(&pool) + .await + .unwrap(); + + assert!(matches!( + store.heartbeat(&state).await, + Err(SnapshotError::LeaseUnavailable) + )); + state.warning_codes.insert("must_not_persist".to_string()); + assert!(matches!( + store.save(&mut state).await, + Err(SnapshotError::StateConflict) + )); + let contender = + SqliteSnapshotStateStore::for_worker(pool, "synthetic-contending-worker".to_string()) + .unwrap(); + assert!(contender.claim(&plan.resume_key).await.unwrap()); + } + + #[tokio::test] + async fn file_backed_restart_reclaims_future_utc_lease_after_clock_rollback() { + let (_directory, pool, crashed_worker, plan) = setup_file_backed_lease_store().await; + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + assert!(matches!( + crashed_worker.save(&mut state).await, + Err(SnapshotError::LeaseUnavailable) + )); + assert_eq!(state.generation, 0); + assert!(!crashed_worker.claim(&plan.resume_key).await.unwrap()); + crashed_worker.save(&mut state).await.unwrap(); + sqlx::query( + "UPDATE tally_snapshot_run_states SET lease_expires_at_unix_ms = ?1 \ + WHERE resume_key = ?2", + ) + .bind(i64::MAX) + .bind(&plan.resume_key) + .execute(&pool) + .await + .unwrap(); + + // Dropping the worker models a process crash: the kernel releases its advisory lock, + // even though the durable UTC expiry is now arbitrarily far in the future. + drop(crashed_worker); + let restarted_worker = + SqliteSnapshotStateStore::for_worker(pool, "synthetic-restarted-worker".to_string()) + .unwrap(); + assert!(restarted_worker.claim(&plan.resume_key).await.unwrap()); + let mut recovered = restarted_worker + .load(&plan.resume_key) + .await + .unwrap() + .unwrap(); + restarted_worker.heartbeat(&recovered).await.unwrap(); + recovered + .warning_codes + .insert("restart_reclaimed".to_string()); + restarted_worker.save(&mut recovered).await.unwrap(); + } + + #[tokio::test] + async fn file_backed_live_owner_cannot_be_stolen_when_utc_lease_is_expired() { + let (_directory, pool, live_worker, plan) = setup_file_backed_lease_store().await; + assert!(!live_worker.claim(&plan.resume_key).await.unwrap()); + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + live_worker.save(&mut state).await.unwrap(); + sqlx::query( + "UPDATE tally_snapshot_run_states SET lease_expires_at_unix_ms = ?1 \ + WHERE resume_key = ?2", + ) + .bind(i64::MIN) + .bind(&plan.resume_key) + .execute(&pool) + .await + .unwrap(); + + let contender = + SqliteSnapshotStateStore::for_worker(pool, "synthetic-live-contender".to_string()) + .unwrap(); + assert!(matches!( + contender.claim(&plan.resume_key).await, + Err(SnapshotError::LeaseUnavailable) + )); + // Wall-clock jumps also cannot make the actual owner lose save authority. + live_worker.heartbeat(&state).await.unwrap(); + state + .warning_codes + .insert("live_owner_retained".to_string()); + live_worker.save(&mut state).await.unwrap(); + } + + #[tokio::test] + async fn durable_store_rejects_corruption_and_plan_drift() { + let (pool, _mirror, store, mut plan) = setup().await; + let mut state = DurableSnapshotState::new(&plan, Freshness::NeverVerified).unwrap(); + store.save(&mut state).await.unwrap(); + let original = plan.clone(); + plan.source_transport = "json_ex".to_string(); + let loaded = store.load(&plan.resume_key).await.unwrap().unwrap(); + store + .heartbeat(&loaded) + .await + .expect("the owning worker can renew the exact loaded generation"); + assert_eq!(loaded.recoverable_plan().unwrap(), original); + assert_eq!( + store + .load_by_run_id(&loaded.run_id) + .await + .unwrap() + .unwrap() + .resume_key, + loaded.resume_key + ); + assert_eq!(store.load_recent(10).await.unwrap().len(), 1); + let mut legacy = loaded.clone(); + legacy.plan = None; + assert!(matches!( + legacy.recoverable_plan(), + Err(SnapshotError::ResumePlanUnavailable) + )); + let contender = SqliteSnapshotStateStore::for_worker( + pool.clone(), + "synthetic-contending-worker".to_string(), + ) + .unwrap(); + assert!(matches!( + contender.claim(&loaded.resume_key).await, + Err(SnapshotError::LeaseUnavailable) + )); + let mut advanced = loaded.clone(); + let mut stale = loaded.clone(); + advanced.warning_codes.insert("cas_advanced".to_string()); + store.save(&mut advanced).await.unwrap(); + stale.warning_codes.insert("cas_stale".to_string()); + assert!(matches!( + store.save(&mut stale).await, + Err(SnapshotError::StateConflict) + )); + let loaded = store.load(&plan.resume_key).await.unwrap().unwrap(); + assert!(loaded.warning_codes.contains("cas_advanced")); + assert!(!loaded.warning_codes.contains("cas_stale")); + assert!(matches!( + loaded.assert_resumable_with(&plan), + Err(SnapshotError::ResumePlanMismatch) + )); + let mut changed_references = original.clone(); + changed_references.external_references = ExternalReferenceCatalog::Complete { + company_ids: BTreeSet::new(), + voucher_ids: BTreeSet::new(), + ledger_ids: BTreeSet::new(), + }; + assert!(matches!( + loaded.assert_resumable_with(&changed_references), + Err(SnapshotError::ResumePlanMismatch) + )); + let mut changed_start = original.clone(); + changed_start.started_at_unix_ms += 1; + assert!(matches!( + loaded.assert_resumable_with(&changed_start), + Err(SnapshotError::ResumePlanMismatch) + )); + let mut changed_freshness = original.clone(); + changed_freshness.freshness_target_seconds += 1; + assert!(matches!( + loaded.assert_resumable_with(&changed_freshness), + Err(SnapshotError::ResumePlanMismatch) + )); + let mut malformed_filter = original; + malformed_filter.windows[0].filters_sha256 = CanonicalText::parse("not-a-digest").unwrap(); + assert!(matches!( + malformed_filter.validate(), + Err(SnapshotError::InvalidPlan("windows")) + )); + let mut duplicate_plan = loaded.recoverable_plan().unwrap(); + duplicate_plan.resume_key = "duplicate-resume-key".to_string(); + let mut duplicate_state = + DurableSnapshotState::new(&duplicate_plan, Freshness::NeverVerified).unwrap(); + assert!(store.save(&mut duplicate_state).await.is_err()); + assert_eq!( + store + .load_by_run_id(&loaded.run_id) + .await + .unwrap() + .unwrap() + .resume_key, + loaded.resume_key + ); + let identity_mutation = sqlx::query( + "UPDATE tally_snapshot_run_states SET run_id = 'different-run' \ + WHERE resume_key = ?1", + ) + .bind(&plan.resume_key) + .execute(&pool) + .await; + assert!(identity_mutation.is_err()); + sqlx::query("UPDATE tally_snapshot_run_states SET row_sha256 = ?1 WHERE resume_key = ?2") + .bind("0".repeat(64)) + .bind(&plan.resume_key) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + store.load(&plan.resume_key).await, + Err(SnapshotError::CorruptState) + )); + let state_sha256: String = sqlx::query_scalar( + "SELECT state_sha256 FROM tally_snapshot_run_states WHERE resume_key = ?1", + ) + .bind(&plan.resume_key) + .fetch_one(&pool) + .await + .unwrap(); + sqlx::query("UPDATE tally_snapshot_run_states SET row_sha256 = ?1 WHERE resume_key = ?2") + .bind(snapshot_state_row_sha256( + &loaded.resume_key, + &loaded.run_id, + loaded.generation, + &state_sha256, + )) + .bind(&plan.resume_key) + .execute(&pool) + .await + .unwrap(); + sqlx::query( + "UPDATE tally_snapshot_run_states SET state_json = '{\"corrupt\":true}' \ + WHERE resume_key = ?1", + ) + .bind(&plan.resume_key) + .execute(&pool) + .await + .unwrap(); + assert!(matches!( + store.load(&plan.resume_key).await, + Err(SnapshotError::CorruptState) + )); + } +} diff --git a/src-tauri/src/tally/capability_packs.rs b/src-tauri/src/tally/capability_packs.rs new file mode 100644 index 0000000..5a7edcf --- /dev/null +++ b/src-tauri/src/tally/capability_packs.rs @@ -0,0 +1,632 @@ +use bridge_tally_core::{ + CapabilityPackId, CapabilityState, PackSchemaVersion, TransportId, + BILLS_AND_PAYMENTS_SCHEMA_VERSION, CORE_ACCOUNTING_SCHEMA_VERSION, +}; +use std::collections::{BTreeMap, BTreeSet}; + +/// A canonical field that must be observed before Bridge can call a pack ready. +/// +/// These are Bridge contract names rather than raw Tally XML tag names. A query +/// profile owns the mapping from the release-specific Tally representation to +/// this stable contract. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub struct RequiredField { + pub object_type: &'static str, + pub field: &'static str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CapabilityPackDescriptor { + pub id: CapabilityPackId, + pub schema_version: PackSchemaVersion, + pub required_fields: &'static [RequiredField], +} + +const CORE_ACCOUNTING_FIELDS: &[RequiredField] = &[ + RequiredField { + object_type: "group", + field: "source_id", + }, + RequiredField { + object_type: "group", + field: "name", + }, + RequiredField { + object_type: "group", + field: "parent_source_id", + }, + RequiredField { + object_type: "ledger", + field: "source_id", + }, + RequiredField { + object_type: "ledger", + field: "name", + }, + RequiredField { + object_type: "ledger", + field: "parent_source_id", + }, + RequiredField { + object_type: "ledger", + field: "opening_balance", + }, + RequiredField { + object_type: "voucher_type", + field: "source_id", + }, + RequiredField { + object_type: "voucher_type", + field: "name", + }, + RequiredField { + object_type: "voucher", + field: "source_id", + }, + RequiredField { + object_type: "voucher", + field: "date_yyyymmdd", + }, + RequiredField { + object_type: "voucher", + field: "voucher_type_source_id", + }, + RequiredField { + object_type: "voucher", + field: "voucher_number", + }, + RequiredField { + object_type: "voucher", + field: "cancelled", + }, + RequiredField { + object_type: "voucher", + field: "optional", + }, + RequiredField { + object_type: "ledger_entry", + field: "source_id", + }, + RequiredField { + object_type: "ledger_entry", + field: "voucher_source_id", + }, + RequiredField { + object_type: "ledger_entry", + field: "ledger_source_id", + }, + RequiredField { + object_type: "ledger_entry", + field: "amount", + }, + RequiredField { + object_type: "ledger_entry", + field: "polarity", + }, +]; + +const INDIA_TAX_FIELDS: &[RequiredField] = &[ + RequiredField { + object_type: "tax_registration", + field: "source_id", + }, + RequiredField { + object_type: "tax_registration", + field: "owner_kind", + }, + RequiredField { + object_type: "tax_registration", + field: "owner_source_id", + }, + RequiredField { + object_type: "tax_registration", + field: "registration_type", + }, + RequiredField { + object_type: "tax_registration", + field: "gstin", + }, + RequiredField { + object_type: "voucher_tax", + field: "source_id", + }, + RequiredField { + object_type: "voucher_tax", + field: "voucher_source_id", + }, + RequiredField { + object_type: "voucher_tax", + field: "place_of_supply", + }, + RequiredField { + object_type: "voucher_tax", + field: "assessable_value", + }, + RequiredField { + object_type: "voucher_tax", + field: "tax_component", + }, + RequiredField { + object_type: "voucher_tax", + field: "tax_rate", + }, + RequiredField { + object_type: "voucher_tax", + field: "tax_amount", + }, +]; + +const BILLS_AND_PAYMENTS_FIELDS: &[RequiredField] = &[ + RequiredField { + object_type: "party_outstanding", + field: "source_identity", + }, + RequiredField { + object_type: "party_outstanding", + field: "party_ledger_source_id", + }, + RequiredField { + object_type: "party_outstanding", + field: "report_as_of_yyyymmdd", + }, + RequiredField { + object_type: "party_outstanding", + field: "direction", + }, + RequiredField { + object_type: "party_outstanding", + field: "bill_wise_state", + }, + RequiredField { + object_type: "party_outstanding", + field: "allocation_coverage", + }, + RequiredField { + object_type: "party_outstanding", + field: "outstanding_coverage", + }, + RequiredField { + object_type: "party_outstanding", + field: "fetch_bracket", + }, + RequiredField { + object_type: "party_outstanding", + field: "query_profile", + }, + RequiredField { + object_type: "party_outstanding", + field: "source_scope_fingerprint", + }, + RequiredField { + object_type: "party_outstanding", + field: "source_reported_allocation_count", + }, + RequiredField { + object_type: "party_outstanding", + field: "source_reported_outstanding_count", + }, + RequiredField { + object_type: "bill_allocation", + field: "source_id", + }, + RequiredField { + object_type: "bill_allocation", + field: "identity_basis", + }, + RequiredField { + object_type: "bill_allocation", + field: "origin", + }, + RequiredField { + object_type: "bill_allocation", + field: "reference", + }, + RequiredField { + object_type: "bill_allocation", + field: "due_date_yyyymmdd", + }, + RequiredField { + object_type: "bill_allocation", + field: "due_date_evidence", + }, + RequiredField { + object_type: "bill_allocation", + field: "amount", + }, + RequiredField { + object_type: "bill_allocation", + field: "observed_polarity", + }, + RequiredField { + object_type: "bill_allocation", + field: "currency_basis", + }, + RequiredField { + object_type: "bill_outstanding", + field: "source_id", + }, + RequiredField { + object_type: "bill_outstanding", + field: "identity_basis", + }, + RequiredField { + object_type: "bill_outstanding", + field: "origin", + }, + RequiredField { + object_type: "bill_outstanding", + field: "reference", + }, + RequiredField { + object_type: "bill_outstanding", + field: "due_date_yyyymmdd", + }, + RequiredField { + object_type: "bill_outstanding", + field: "due_date_evidence", + }, + RequiredField { + object_type: "bill_outstanding", + field: "pending_amount", + }, + RequiredField { + object_type: "bill_outstanding", + field: "observed_polarity", + }, + RequiredField { + object_type: "bill_outstanding", + field: "currency_basis", + }, +]; + +const INVENTORY_FIELDS: &[RequiredField] = &[ + RequiredField { + object_type: "stock_item", + field: "source_id", + }, + RequiredField { + object_type: "stock_item", + field: "name", + }, + RequiredField { + object_type: "stock_item", + field: "base_unit", + }, + RequiredField { + object_type: "godown", + field: "source_id", + }, + RequiredField { + object_type: "godown", + field: "name", + }, + RequiredField { + object_type: "inventory_entry", + field: "source_id", + }, + RequiredField { + object_type: "inventory_entry", + field: "voucher_source_id", + }, + RequiredField { + object_type: "inventory_entry", + field: "stock_item_source_id", + }, + RequiredField { + object_type: "inventory_entry", + field: "godown_source_id", + }, + RequiredField { + object_type: "inventory_entry", + field: "quantity", + }, + RequiredField { + object_type: "inventory_entry", + field: "rate", + }, + RequiredField { + object_type: "inventory_entry", + field: "amount", + }, +]; + +pub const CAPABILITY_PACKS: [CapabilityPackDescriptor; 4] = [ + CapabilityPackDescriptor { + id: CapabilityPackId::CoreAccounting, + schema_version: CORE_ACCOUNTING_SCHEMA_VERSION, + required_fields: CORE_ACCOUNTING_FIELDS, + }, + CapabilityPackDescriptor { + id: CapabilityPackId::IndiaTax, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + required_fields: INDIA_TAX_FIELDS, + }, + CapabilityPackDescriptor { + id: CapabilityPackId::BillsAndPayments, + schema_version: BILLS_AND_PAYMENTS_SCHEMA_VERSION, + required_fields: BILLS_AND_PAYMENTS_FIELDS, + }, + CapabilityPackDescriptor { + id: CapabilityPackId::Inventory, + schema_version: PackSchemaVersion { major: 1, minor: 0 }, + required_fields: INVENTORY_FIELDS, + }, +]; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct PackProfileScope { + pub product: String, + pub release: String, + pub mode: String, + pub transport: TransportId, + /// Stable identifier for the exact query and normalisation contract. + pub query_profile: String, +} + +impl PackProfileScope { + pub fn is_exact(&self) -> bool { + [ + self.product.as_str(), + self.release.as_str(), + self.mode.as_str(), + self.query_profile.as_str(), + ] + .into_iter() + .all(|value| !value.trim().is_empty()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ObservedPackEvidence { + pub observed_fields: BTreeSet<(String, String)>, + /// The exact release/query profile completed successfully against Tally. + pub query_completed: bool, + /// Pack-specific invariants (for example exact-decimal parsing and stable + /// identities) passed for the observed response. + pub required_invariants_verified: bool, + /// An explicit negative probe may mark a pack unsupported. Absence of a + /// field is not an unsupported verdict; it remains unknown. + pub explicitly_unsupported_reason: Option, +} + +impl ObservedPackEvidence { + pub fn from_fields( + fields: impl IntoIterator, impl Into)>, + ) -> Self { + Self { + observed_fields: fields + .into_iter() + .map(|(object_type, field)| (object_type.into(), field.into())) + .collect(), + query_completed: false, + required_invariants_verified: false, + explicitly_unsupported_reason: None, + } + } + + pub fn verified_fields( + fields: impl IntoIterator, impl Into)>, + ) -> Self { + let mut evidence = Self::from_fields(fields); + evidence.query_completed = true; + evidence.required_invariants_verified = true; + evidence + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackSupportAssessment { + pub state: CapabilityState, + pub safe_reason_code: &'static str, + pub missing_required_fields: Vec, +} + +/// Release/query-profile scoped pack evidence. A fresh registry deliberately +/// contains no optimistic support claims. +#[derive(Debug, Clone, Default)] +pub struct CapabilityPackRegistry { + evidence: BTreeMap<(PackProfileScope, CapabilityPackId), ObservedPackEvidence>, +} + +impl CapabilityPackRegistry { + pub fn descriptor(pack: CapabilityPackId) -> &'static CapabilityPackDescriptor { + CAPABILITY_PACKS + .iter() + .find(|descriptor| descriptor.id == pack) + .expect("every public capability pack has a descriptor") + } + + pub fn record_observation( + &mut self, + scope: PackProfileScope, + pack: CapabilityPackId, + evidence: ObservedPackEvidence, + ) -> Result<(), &'static str> { + if !scope.is_exact() { + return Err("pack_profile_scope_incomplete"); + } + self.evidence.insert((scope, pack), evidence); + Ok(()) + } + + pub fn assess( + &self, + scope: &PackProfileScope, + pack: CapabilityPackId, + ) -> PackSupportAssessment { + if !scope.is_exact() { + return PackSupportAssessment { + state: CapabilityState::Unknown, + safe_reason_code: "pack_profile_scope_incomplete", + missing_required_fields: Vec::new(), + }; + } + + let Some(evidence) = self.evidence.get(&(scope.clone(), pack)) else { + return PackSupportAssessment { + state: CapabilityState::Unknown, + safe_reason_code: "pack_not_observed_for_profile", + missing_required_fields: Vec::new(), + }; + }; + + if evidence.explicitly_unsupported_reason.is_some() { + return PackSupportAssessment { + state: CapabilityState::Unsupported, + safe_reason_code: "pack_explicitly_unsupported", + missing_required_fields: Vec::new(), + }; + } + + let descriptor = Self::descriptor(pack); + let missing_required_fields = descriptor + .required_fields + .iter() + .copied() + .filter(|required| { + !evidence + .observed_fields + .contains(&(required.object_type.to_string(), required.field.to_string())) + }) + .collect::>(); + + if missing_required_fields.is_empty() + && evidence.query_completed + && evidence.required_invariants_verified + { + PackSupportAssessment { + state: CapabilityState::Supported, + safe_reason_code: "all_required_pack_fields_observed", + missing_required_fields, + } + } else { + PackSupportAssessment { + state: CapabilityState::Unknown, + safe_reason_code: "pack_readiness_not_fully_observed", + missing_required_fields, + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scope(release: &str) -> PackProfileScope { + PackProfileScope { + product: "tally_prime".to_string(), + release: release.to_string(), + mode: "education".to_string(), + transport: TransportId::XmlHttp, + query_profile: "core-v1-sha256:synthetic".to_string(), + } + } + + #[test] + fn registry_is_unknown_by_default_for_every_pack() { + let registry = CapabilityPackRegistry::default(); + for descriptor in CAPABILITY_PACKS { + let assessment = registry.assess(&scope("7.0"), descriptor.id); + assert_eq!(assessment.state, CapabilityState::Unknown); + assert_eq!(assessment.safe_reason_code, "pack_not_observed_for_profile"); + assert!(!descriptor.required_fields.is_empty()); + } + } + + #[test] + fn a_pack_is_supported_only_when_every_required_field_was_observed() { + for descriptor in CAPABILITY_PACKS { + let mut registry = CapabilityPackRegistry::default(); + let all_fields = descriptor + .required_fields + .iter() + .map(|field| (field.object_type, field.field)); + registry + .record_observation( + scope("7.0"), + descriptor.id, + ObservedPackEvidence::verified_fields(all_fields), + ) + .expect("exact scope"); + + assert_eq!( + registry.assess(&scope("7.0"), descriptor.id).state, + CapabilityState::Supported + ); + } + } + + #[test] + fn partial_evidence_stays_unknown_and_is_release_scoped() { + let descriptor = CapabilityPackRegistry::descriptor(CapabilityPackId::CoreAccounting); + let mut registry = CapabilityPackRegistry::default(); + let all_but_last = descriptor.required_fields[..descriptor.required_fields.len() - 1] + .iter() + .map(|field| (field.object_type, field.field)); + registry + .record_observation( + scope("7.0"), + descriptor.id, + ObservedPackEvidence::verified_fields(all_but_last), + ) + .expect("exact scope"); + + let partial = registry.assess(&scope("7.0"), descriptor.id); + assert_eq!(partial.state, CapabilityState::Unknown); + assert_eq!(partial.missing_required_fields.len(), 1); + assert_eq!( + registry.assess(&scope("7.1"), descriptor.id).state, + CapabilityState::Unknown + ); + } + + #[test] + fn complete_field_names_without_a_successful_query_and_invariants_stay_unknown() { + let descriptor = CapabilityPackRegistry::descriptor(CapabilityPackId::Inventory); + let mut registry = CapabilityPackRegistry::default(); + registry + .record_observation( + scope("7.0"), + descriptor.id, + ObservedPackEvidence::from_fields( + descriptor + .required_fields + .iter() + .map(|field| (field.object_type, field.field)), + ), + ) + .expect("exact scope"); + + let assessment = registry.assess(&scope("7.0"), descriptor.id); + assert_eq!(assessment.state, CapabilityState::Unknown); + assert!(assessment.missing_required_fields.is_empty()); + assert_eq!( + assessment.safe_reason_code, + "pack_readiness_not_fully_observed" + ); + } + + #[test] + fn incomplete_profiles_cannot_store_or_claim_support() { + let mut incomplete = scope(" "); + let mut registry = CapabilityPackRegistry::default(); + assert_eq!( + registry.record_observation( + incomplete.clone(), + CapabilityPackId::Inventory, + ObservedPackEvidence::from_fields([] as [(&str, &str); 0]), + ), + Err("pack_profile_scope_incomplete") + ); + assert_eq!( + registry + .assess(&incomplete, CapabilityPackId::Inventory) + .state, + CapabilityState::Unknown + ); + + incomplete.release = "7.0".to_string(); + assert!(incomplete.is_exact()); + } +} diff --git a/src-tauri/src/tally/connection.rs b/src-tauri/src/tally/connection.rs index c2a609b..82af814 100644 --- a/src-tauri/src/tally/connection.rs +++ b/src-tauri/src/tally/connection.rs @@ -1,30 +1,32 @@ -use reqwest::header::CONTENT_TYPE; -use reqwest::redirect::Policy; -use serde::{Deserialize, Serialize}; -use std::net::IpAddr; -use std::time::Duration; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::sync::{ + atomic::{AtomicU64, AtomicU8, Ordering}, + Arc, +}; use super::xml_parser::{TallyLedger, TallyVoucher}; use super::{ - serial_queue::SerialTallyQueue, tdl_engine, + validators::{normalize_company_guid, normalize_company_name}, xml_parser::{self, TallyCompany}, }; +use bridge_tally_core::{ + CapabilityEvidence, CapabilityFeatureId, CapabilityPackId, CapabilityProfile, CapabilityState, + EvidenceConfidence, TransportId, +}; +use bridge_tally_protocol::{ + parse_ledger_source_records_with_evidence, parse_selected_voucher_source_records_with_evidence, + verify_selected_voucher_window_context, TallyTextEncoding, BRIDGE_LEDGER_EXPORT_SCHEMA, + BRIDGE_SELECTED_VOUCHER_EXPORT_SCHEMA, +}; +use bridge_tally_transport::{ + canonical_loopback_origin as transport_canonical_origin, TallyEndpointConfig, + TallyHttpTransport, TallyTransportError, +}; -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct TallyConfig { - pub host: String, - pub port: u16, -} - -impl Default for TallyConfig { - fn default() -> Self { - Self { - host: "localhost".to_string(), - port: 9000, - } - } -} +pub type TallyConfig = TallyEndpointConfig; #[derive(Debug, Clone, Serialize)] pub enum TallyProduct { @@ -37,80 +39,384 @@ pub enum TallyProduct { #[derive(Debug, Clone, Serialize)] pub struct ConnectionStatus { pub reachable: bool, + pub compatible: bool, pub server_text: String, pub product: TallyProduct, pub error: Option, } +#[derive(Debug, Clone, Serialize)] +pub struct TallyProbeResult { + pub connection: ConnectionStatus, + pub companies: Vec, + pub profile: CapabilityProfile, + pub selected_read_scope: Option, + pub passport_snapshot_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct SelectedReadScopeEvidence { + pub scope_version: u16, + pub ledger_profile_id: String, + pub voucher_profile_id: String, + pub voucher_from_yyyymmdd: String, + pub voucher_to_yyyymmdd: String, + pub scope_commitment_sha256: String, + #[serde(skip_serializing)] + pub(crate) parent_review_sha256: String, + #[serde(skip_serializing)] + pub(crate) company_guid_ascii_casefolded: String, + #[serde(skip_serializing)] + pub(crate) observations: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SelectedReadCapabilityObservation { + pub capability_key: &'static str, + pub state: CapabilityState, + pub confidence: EvidenceConfidence, + pub safe_reason_code: &'static str, + pub result_bucket: &'static str, + pub request_sha256: Option, + pub decoded_response_sha256: Option, + pub response_encoding: Option<&'static str>, + pub company_context_verified: bool, + pub schema_verified: bool, + pub record_count_verified: bool, + pub identity_evidence_state: &'static str, + pub date_window_verified: bool, +} + +pub const SELECTED_LEDGER_QUERY_PROFILE_ID: &str = BRIDGE_LEDGER_EXPORT_SCHEMA; +pub const SELECTED_VOUCHER_QUERY_PROFILE_ID: &str = BRIDGE_SELECTED_VOUCHER_EXPORT_SCHEMA; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SelectedReadObservation { + pub request_sha256: String, + /// SHA-256 of the decoded XML re-encoded as UTF-8, not of the wire bytes. + pub decoded_response_sha256: String, + pub response_encoding: &'static str, + pub result_bucket: &'static str, +} + #[derive(Clone)] pub struct TallyClient { config: TallyConfig, - http: reqwest::Client, - queue: SerialTallyQueue, + http: TallyHttpTransport, + observed_body_bytes: Arc, + observed_encoding: Arc, } +const BODY_BYTES_UNAVAILABLE: u64 = u64::MAX; +const ENCODING_UNAVAILABLE: u8 = 0; +const ENCODING_UTF8: u8 = 1; +const ENCODING_UTF8_BOM: u8 = 2; +const ENCODING_UTF16_LE_BOM: u8 = 3; +const ENCODING_UTF16_BE_BOM: u8 = 4; + impl TallyClient { - pub fn new(config: TallyConfig) -> Self { - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(20)) - .redirect(Policy::none()) - .build() - .expect("reqwest client should build"); + pub fn new(config: TallyConfig) -> anyhow::Result { + let http = TallyHttpTransport::new(config.clone())?; + Ok(Self { + config, + http, + observed_body_bytes: Arc::new(AtomicU64::new(BODY_BYTES_UNAVAILABLE)), + observed_encoding: Arc::new(AtomicU8::new(ENCODING_UNAVAILABLE)), + }) + } + + pub fn canonical_origin(&self) -> anyhow::Result { + canonical_loopback_origin(&self.config) + } + #[cfg(test)] + fn with_http_builder(config: TallyConfig, builder: reqwest::ClientBuilder) -> Self { + let http = TallyHttpTransport::with_builder( + config.clone(), + bridge_tally_transport::TransportPolicy::default(), + builder, + ) + .expect("build synthetic Tally HTTP transport"); Self { config, http, - queue: SerialTallyQueue::default(), + observed_body_bytes: Arc::new(AtomicU64::new(BODY_BYTES_UNAVAILABLE)), + observed_encoding: Arc::new(AtomicU8::new(ENCODING_UNAVAILABLE)), } } + #[cfg(test)] + pub(crate) fn with_transport_policy( + config: TallyConfig, + policy: bridge_tally_transport::TransportPolicy, + ) -> anyhow::Result { + let http = + TallyHttpTransport::with_builder(config.clone(), policy, reqwest::Client::builder())?; + Ok(Self { + config, + http, + observed_body_bytes: Arc::new(AtomicU64::new(BODY_BYTES_UNAVAILABLE)), + observed_encoding: Arc::new(AtomicU8::new(ENCODING_UNAVAILABLE)), + }) + } + pub async fn check_connection(&self) -> anyhow::Result { - let url = tally_endpoint(&self.config, "/status")?; - let result = self - .queue - .run(|| async { - let response = self - .http - .get(url) - .header(CONTENT_TYPE, "text/xml") - .send() - .await? - .error_for_status()?; - response_text_limited(response, 1024 * 1024).await - }) - .await; - - match result { - Ok(server_text) => Ok(ConnectionStatus { - reachable: is_supported_tally_status(&server_text), - product: detect_product(&server_text), - server_text, - error: None, - }), + match self.check_connection_strict().await { + Ok(status) => Ok(status), Err(error) => Ok(ConnectionStatus { reachable: false, + compatible: false, server_text: String::new(), product: TallyProduct::Unknown, - error: Some(error.to_string()), + error: Some(safe_connection_failure_code(&error).to_string()), }), } } - pub async fn post_xml(&self, xml: String) -> anyhow::Result { - let url = tally_endpoint(&self.config, "/")?; - self.queue - .run(|| async { - let response = self - .http - .post(url) - .header(CONTENT_TYPE, "text/xml; charset=utf-8") - .body(xml) - .send() - .await? - .error_for_status()?; - response_text_limited(response, 32 * 1024 * 1024).await - }) - .await + pub(crate) async fn check_connection_strict(&self) -> anyhow::Result { + let response = self.http.get_status_decoded().await?; + self.record_observed_body_bytes(response.encoded_bytes()); + self.record_observed_encoding(response.encoding()); + let response_text = response.into_text(); + let product = detect_product(&response_text); + let compatible = matches!(product, TallyProduct::TallyPrime | TallyProduct::TallyErp9); + let server_text = match product { + TallyProduct::TallyPrime => "TallyPrime Server is Running", + TallyProduct::TallyErp9 => "Tally ERP 9 Server is Running", + TallyProduct::Unknown => "Endpoint responded with an unrecognized status document", + }; + Ok(ConnectionStatus { + reachable: true, + compatible, + product, + server_text: server_text.to_string(), + error: None, + }) + } + + pub async fn probe(&self) -> anyhow::Result { + // `/status` is useful local diagnostics but is not part of Tally's + // documented third-party XML contract. Never gate the POST probe or + // authoritative product metadata on this unauthenticated heuristic. + let mut connection = self.check_connection().await?; + let mut transports = BTreeMap::new(); + let mut features = BTreeMap::new(); + let mut packs = BTreeMap::new(); + let mut companies = Vec::new(); + + let xml_evidence = match self.post_xml(tdl_engine::company_list_request()).await { + Ok(xml) => match xml_parser::export_status(&xml) { + Ok(xml_parser::TallyExportStatus::Success) => { + connection.reachable = true; + if connection.error.is_some() { + connection.error = Some("status_heuristic_unavailable".to_string()); + } + match xml_parser::parse_companies(&xml) { + Ok(discovered) => match normalize_discovered_companies(discovered) { + Ok(normalized) => { + companies = normalized; + CapabilityEvidence { + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: None, + } + } + Err(()) => CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("company_identity_invalid".to_string()), + }, + }, + Err(_) => CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("xml_export_shape_unrecognized".to_string()), + }, + } + } + Ok(xml_parser::TallyExportStatus::Failure) => CapabilityEvidence { + // A shaped failure is an endpoint claim, not responder + // authenticity or proof that the read profile works. + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some( + xml_parser::export_failure_reason_code(&xml).to_string(), + ), + }, + Err(_) => CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("xml_export_shape_unrecognized".to_string()), + }, + }, + Err(error) => return Err(error), + }; + transports.insert(TransportId::XmlHttp, xml_evidence.clone()); + transports.insert( + TransportId::JsonEx, + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Unknown, + safe_reason_code: Some("release_not_observed".to_string()), + }, + ); + for transport in [TransportId::TdlCompanion, TransportId::Odbc] { + transports.insert( + transport, + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Unknown, + safe_reason_code: Some("configuration_not_observed".to_string()), + }, + ); + } + + features.insert( + CapabilityFeatureId::EndpointReachability, + CapabilityEvidence { + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("xml_endpoint_responded".to_string()), + }, + ); + let empty_company_reason = || { + if xml_evidence.state == CapabilityState::Supported { + "company_not_loaded".to_string() + } else { + xml_evidence + .safe_reason_code + .clone() + .unwrap_or_else(|| "company_list_not_established".to_string()) + } + }; + let company_state = if companies.is_empty() { + CapabilityEvidence { + state: if xml_evidence.state == CapabilityState::Supported { + CapabilityState::NotConfigured + } else { + CapabilityState::Unknown + }, + confidence: xml_evidence.confidence, + safe_reason_code: Some(empty_company_reason()), + } + } else { + CapabilityEvidence { + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("loaded_company_observed".to_string()), + } + }; + features.insert(CapabilityFeatureId::LoadedCompanies, company_state); + let identity_evidence = if companies.is_empty() { + CapabilityEvidence { + state: if xml_evidence.state == CapabilityState::Supported { + CapabilityState::NotConfigured + } else { + CapabilityState::Unknown + }, + confidence: xml_evidence.confidence, + safe_reason_code: Some(empty_company_reason()), + } + } else if unique_company_guids(&companies) { + CapabilityEvidence { + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("stable_company_guid_observed".to_string()), + } + } else if companies.iter().all(|company| company.guid.is_some()) { + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("company_identity_ambiguous".to_string()), + } + } else { + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("stable_company_identity_not_observed".to_string()), + } + }; + features.insert( + CapabilityFeatureId::StableCompanyIdentity, + identity_evidence, + ); + features.insert( + CapabilityFeatureId::EncodingBehaviour, + self.observed_encoding_evidence(), + ); + features.insert( + CapabilityFeatureId::PracticalResponseLimit, + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Unknown, + safe_reason_code: Some("practical_limit_not_measured".to_string()), + }, + ); + features.insert(CapabilityFeatureId::CompanyRead, xml_evidence); + for feature in [ + CapabilityFeatureId::LedgerRead, + CapabilityFeatureId::VoucherRead, + CapabilityFeatureId::SelectedLedgerRead, + CapabilityFeatureId::SelectedVoucherWindowRead, + ] { + features.insert( + feature, + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Unknown, + safe_reason_code: Some("selected_read_probe_not_run".to_string()), + }, + ); + } + features.insert( + CapabilityFeatureId::Write, + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Unknown, + safe_reason_code: Some("write_probe_not_run".to_string()), + }, + ); + + for pack in [ + CapabilityPackId::CoreAccounting, + CapabilityPackId::IndiaTax, + CapabilityPackId::BillsAndPayments, + CapabilityPackId::Inventory, + ] { + packs.insert( + pack, + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Unknown, + safe_reason_code: Some("verified_snapshot_not_run".to_string()), + }, + ); + } + + Ok(TallyProbeResult { + connection, + companies, + profile: CapabilityProfile { + profile_version: 2, + // Product/release/mode require separate evidence authority; + // `/status` text cannot promote them. + product: "Unknown".to_string(), + release: None, + mode: None, + transports, + features, + packs, + }, + selected_read_scope: None, + passport_snapshot_id: None, + }) + } + + pub(super) async fn post_xml(&self, xml: String) -> anyhow::Result { + let response = self.http.post_xml_decoded(xml).await?; + self.record_observed_body_bytes(response.encoded_bytes()); + self.record_observed_encoding(response.encoding()); + Ok(response.into_text()) } pub async fn fetch_companies(&self) -> anyhow::Result> { @@ -118,90 +424,313 @@ impl TallyClient { xml_parser::parse_companies(&xml) } - pub async fn fetch_ledgers(&self, company: &str) -> anyhow::Result> { + pub async fn fetch_ledgers( + &self, + company: &str, + expected_company_guid: &str, + ) -> anyhow::Result> { let xml = self.post_xml(tdl_engine::ledgers_request(company)).await?; - xml_parser::parse_ledgers(&xml) + let parsed = xml_parser::parse_ledgers_with_evidence(&xml)?; + xml_parser::verify_company_context(&parsed.evidence, expected_company_guid)?; + Ok(parsed.records) + } + + pub async fn qualify_selected_ledgers( + &self, + company: &str, + expected_company_guid: &str, + ) -> anyhow::Result { + let request = tdl_engine::ledgers_request(company); + let request_sha256 = sha256_hex(request.as_bytes()); + let xml = self.post_xml(request).await?; + let decoded_response_sha256 = sha256_hex(xml.as_bytes()); + bridge_tally_protocol::validate_exact_selected_export_structure(&xml, "LEDGER")?; + let parsed = parse_ledger_source_records_with_evidence(&xml)?; + xml_parser::verify_company_context(&parsed.evidence, expected_company_guid)?; + verify_selected_company_name(&parsed.evidence, company)?; + validate_selected_read_identity_evidence( + parsed.records.len(), + parsed.evidence.identified_record_count, + parsed.evidence.duplicate_identities.len(), + )?; + validate_selected_ledgers(&parsed.records)?; + Ok(SelectedReadObservation { + request_sha256, + decoded_response_sha256, + response_encoding: self.observed_encoding_label()?, + result_bucket: if parsed.records.is_empty() { + "empty_observed" + } else { + "non_empty_observed" + }, + }) } pub async fn fetch_vouchers( &self, company: &str, + expected_company_guid: &str, from: &str, to: &str, ) -> anyhow::Result> { let xml = self .post_xml(tdl_engine::vouchers_request(company, from, to)) .await?; - xml_parser::parse_vouchers(&xml) + let parsed = xml_parser::parse_vouchers_with_evidence(&xml)?; + xml_parser::verify_company_context(&parsed.evidence, expected_company_guid)?; + Ok(parsed.records) } -} -fn tally_endpoint(config: &TallyConfig, path: &str) -> anyhow::Result { - let host = config.host.trim(); - if host.is_empty() - || host.len() > 253 - || host.chars().any(char::is_control) - || host.contains(['/', '\\', '?', '#', '@']) - { - anyhow::bail!("Tally host must be a hostname or IP address without a URL scheme or path"); - } - if config.port == 0 { - anyhow::bail!("Tally port must be between 1 and 65535"); - } - - let mut url = reqwest::Url::parse("http://localhost")?; - if let Ok(ip_address) = host.parse::() { - if !ip_address.is_loopback() { - anyhow::bail!("Tally connections are restricted to this computer (loopback)"); + pub async fn qualify_selected_vouchers( + &self, + company: &str, + expected_company_guid: &str, + from: &str, + to: &str, + ) -> anyhow::Result { + let request = tdl_engine::selected_vouchers_request(company, from, to); + let request_sha256 = sha256_hex(request.as_bytes()); + let xml = self.post_xml(request).await?; + let decoded_response_sha256 = sha256_hex(xml.as_bytes()); + bridge_tally_protocol::validate_exact_selected_export_structure(&xml, "VOUCHER")?; + let parsed = parse_selected_voucher_source_records_with_evidence(&xml)?; + xml_parser::verify_company_context(&parsed.evidence, expected_company_guid)?; + verify_selected_company_name(&parsed.evidence, company)?; + verify_selected_voucher_window_context(&parsed.evidence, from, to)?; + validate_selected_read_identity_evidence( + parsed.records.len(), + parsed.evidence.identified_record_count, + parsed.evidence.duplicate_identities.len(), + )?; + bridge_tally_canonical::validate_selected_voucher_window(from, to, &parsed) + .map_err(anyhow::Error::new)?; + Ok(SelectedReadObservation { + request_sha256, + decoded_response_sha256, + response_encoding: self.observed_encoding_label()?, + result_bucket: if parsed.records.is_empty() { + "empty_observed" + } else { + "non_empty_observed" + }, + }) + } + + pub(crate) fn reset_observed_body_bytes(&self) { + self.observed_body_bytes + .store(BODY_BYTES_UNAVAILABLE, Ordering::Release); + } + + pub(crate) fn observed_body_bytes(&self) -> Option { + match self.observed_body_bytes.load(Ordering::Acquire) { + BODY_BYTES_UNAVAILABLE => None, + bytes => Some(bytes), } - url.set_ip_host(ip_address) - .map_err(|_| anyhow::anyhow!("Tally host is invalid"))?; - } else { - if !host.eq_ignore_ascii_case("localhost") { - anyhow::bail!("Tally connections are restricted to localhost or a loopback IP"); + } + + fn record_observed_body_bytes(&self, bytes: usize) { + self.observed_body_bytes.store( + u64::try_from(bytes).unwrap_or(u64::MAX - 1), + Ordering::Release, + ); + } + + fn record_observed_encoding(&self, encoding: TallyTextEncoding) { + let value = match encoding { + TallyTextEncoding::Utf8 => ENCODING_UTF8, + TallyTextEncoding::Utf8Bom => ENCODING_UTF8_BOM, + TallyTextEncoding::Utf16LeBom => ENCODING_UTF16_LE_BOM, + TallyTextEncoding::Utf16BeBom => ENCODING_UTF16_BE_BOM, + }; + self.observed_encoding.store(value, Ordering::Release); + } + + fn observed_encoding_evidence(&self) -> CapabilityEvidence { + let reason = match self.observed_encoding.load(Ordering::Acquire) { + ENCODING_UTF8 => "utf8_observed", + ENCODING_UTF8_BOM => "utf8_bom_observed", + ENCODING_UTF16_LE_BOM => "utf16_le_bom_observed", + ENCODING_UTF16_BE_BOM => "utf16_be_bom_observed", + _ => { + return CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Unknown, + safe_reason_code: Some("encoding_not_observed".to_string()), + }; + } + }; + CapabilityEvidence { + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some(reason.to_string()), } - url.set_ip_host("127.0.0.1".parse::().expect("valid loopback IP")) - .map_err(|_| anyhow::anyhow!("Tally host is invalid"))?; } - url.set_port(Some(config.port)) - .map_err(|_| anyhow::anyhow!("Tally port is invalid"))?; - url.set_path(path); - Ok(url) + + fn observed_encoding_label(&self) -> anyhow::Result<&'static str> { + match self.observed_encoding.load(Ordering::Acquire) { + ENCODING_UTF8 => Ok("utf8"), + ENCODING_UTF8_BOM => Ok("utf8_bom"), + ENCODING_UTF16_LE_BOM => Ok("utf16le_bom"), + ENCODING_UTF16_BE_BOM => Ok("utf16be_bom"), + _ => anyhow::bail!("response_encoding_not_observed"), + } + } +} + +fn normalize_discovered_companies(companies: Vec) -> Result, ()> { + companies + .into_iter() + .map(|company| { + let name = normalize_company_name(&company.name).map_err(|_| ())?; + let guid = company + .guid + .as_deref() + .map(normalize_company_guid) + .transpose() + .map_err(|_| ())?; + Ok(TallyCompany { name, guid }) + }) + .collect() +} + +fn unique_company_guids(companies: &[TallyCompany]) -> bool { + let mut seen = BTreeSet::new(); + companies.iter().all(|company| { + company + .guid + .as_deref() + .is_some_and(|guid| seen.insert(guid.to_ascii_lowercase())) + }) +} + +fn validate_selected_read_identity_evidence( + parsed_record_count: usize, + identified_record_count: u64, + duplicate_identity_count: usize, +) -> anyhow::Result<()> { + let parsed_record_count = u64::try_from(parsed_record_count) + .map_err(|_| anyhow::anyhow!("Selected Tally read exceeded the supported record count"))?; + if identified_record_count != parsed_record_count { + anyhow::bail!("Selected Tally read omitted stable record identity"); + } + if duplicate_identity_count != 0 { + anyhow::bail!("Selected Tally read repeated stable record identity"); + } + Ok(()) } -async fn response_text_limited( - mut response: reqwest::Response, - max_bytes: usize, -) -> anyhow::Result { - if response - .content_length() - .is_some_and(|length| length > max_bytes as u64) - { - anyhow::bail!("Tally response exceeded the {max_bytes}-byte limit"); - } - let mut bytes = Vec::new(); - while let Some(chunk) = response.chunk().await? { - if bytes.len().saturating_add(chunk.len()) > max_bytes { - anyhow::bail!("Tally response exceeded the {max_bytes}-byte limit"); +fn validate_selected_ledgers( + records: &[bridge_tally_protocol::ParsedSourceRecord], +) -> anyhow::Result<()> { + let mut names = BTreeSet::new(); + for source in records { + let source_id = source + .source_id + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Selected ledger omitted stable identity"))?; + if source.identity_kind.is_none() { + anyhow::bail!("Selected ledger omitted identity kind"); + } + bridge_tally_core::SourceRecordId::parse(source_id.clone())?; + bridge_tally_core::RawSourceSha256::parse(source.raw_source_sha256.clone())?; + if let Some(alter_id) = &source.alter_id { + bridge_tally_core::SourceAlterId::parse(alter_id.clone())?; + } + let name = bridge_tally_core::CanonicalText::parse(source.record.name.clone())?; + if !names.insert(name.as_str().to_string()) { + anyhow::bail!("Selected ledger response repeated a normalized name"); + } + for value in [ + source.record.parent.as_ref(), + source.record.party_gstin.as_ref(), + ] + .into_iter() + .flatten() + .filter(|value| !value.trim().is_empty()) + { + bridge_tally_core::CanonicalText::parse(value.clone())?; + } + if let Some(opening_balance) = source + .record + .opening_balance + .as_ref() + .filter(|value| !value.trim().is_empty()) + { + bridge_tally_core::ExactDecimal::parse(opening_balance.clone())?; } - bytes.extend_from_slice(&chunk); } - String::from_utf8(bytes) - .map_err(|_| anyhow::anyhow!("Tally returned a response that was not valid UTF-8")) + Ok(()) +} + +fn verify_selected_company_name( + evidence: &bridge_tally_protocol::ExportEvidence, + expected_name: &str, +) -> anyhow::Result<()> { + let actual_name = evidence + .company_context + .as_ref() + .and_then(|context| context.name.as_deref()) + .ok_or_else(|| anyhow::anyhow!("Selected Tally read omitted company name context"))?; + let actual_name = normalize_company_name(actual_name).map_err(anyhow::Error::msg)?; + let expected_name = normalize_company_name(expected_name).map_err(anyhow::Error::msg)?; + if actual_name != expected_name { + anyhow::bail!("Selected Tally read company name context did not match the request"); + } + Ok(()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() +} + +fn safe_connection_failure_code(error: &anyhow::Error) -> &'static str { + if let Some(transport) = error.downcast_ref::() { + return transport.safe_code(); + } + let message = error.to_string().to_ascii_lowercase(); + if message.contains("cancel") { + "request_cancelled" + } else if message.contains("queue deadline") { + "endpoint_queue_deadline_exceeded" + } else if message.contains("circuit") { + "endpoint_circuit_open" + } else if message.contains("response exceeded") { + "response_size_limit_exceeded" + } else if message.contains("decode") || message.contains("utf") { + "response_encoding_invalid" + } else { + "endpoint_unreachable" + } +} + +pub(super) fn canonical_loopback_origin(config: &TallyConfig) -> anyhow::Result { + Ok(transport_canonical_origin(config)?) +} + +#[cfg(test)] +fn tally_endpoint(config: &TallyConfig, path: &str) -> anyhow::Result { + let mut url = reqwest::Url::parse(&canonical_loopback_origin(config)?)?; + url.set_path(path); + Ok(url) } -fn is_supported_tally_status(text: &str) -> bool { - matches!( - detect_product(text), - TallyProduct::TallyPrime | TallyProduct::TallyErp9 - ) +#[cfg(test)] +fn decode_xml_bytes(bytes: Vec) -> anyhow::Result { + bridge_tally_protocol::decode_xml_bytes(bytes) } fn detect_product(text: &str) -> TallyProduct { - let normalized = text.to_ascii_lowercase(); - if normalized.contains("tallyprime server is running") { + let trimmed = text.trim(); + let marker = |expected: &str| { + trimmed.eq_ignore_ascii_case(expected) + || trimmed.eq_ignore_ascii_case(&format!("{expected}")) + }; + if marker("TallyPrime Server is Running") { TallyProduct::TallyPrime - } else if normalized.contains("tally erp 9") || normalized.contains("tally.erp 9") { + } else if marker("Tally ERP 9 Server is Running") || marker("Tally.ERP 9 Server is Running") { TallyProduct::TallyErp9 } else { TallyProduct::Unknown @@ -210,7 +739,17 @@ fn detect_product(text: &str) -> TallyProduct { #[cfg(test)] mod tests { - use super::{detect_product, tally_endpoint, TallyConfig, TallyProduct}; + use super::{ + canonical_loopback_origin, decode_xml_bytes, detect_product, + normalize_discovered_companies, tally_endpoint, unique_company_guids, TallyClient, + TallyConfig, TallyProduct, + }; + use bridge_tally_core::{ + CapabilityFeatureId, CapabilityPackId, CapabilityState, EvidenceConfidence, TransportId, + }; + use std::time::Duration; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; #[test] fn detects_tallyprime_status() { @@ -228,6 +767,51 @@ mod tests { )); } + #[test] + fn product_marker_is_not_accepted_inside_unrelated_content() { + assert!(matches!( + detect_product("TallyPrime Server is Running"), + TallyProduct::Unknown + )); + assert!(matches!( + detect_product("prefix Tally ERP 9 Server is Running suffix"), + TallyProduct::Unknown + )); + } + + #[test] + fn company_identity_normalization_rejects_invalid_and_ambiguous_guids() { + let normalized = normalize_discovered_companies(vec![ + crate::tally::TallyCompany { + name: " Synthetic A ".to_string(), + guid: Some(" GUID-1 ".to_string()), + }, + crate::tally::TallyCompany { + name: "Synthetic B".to_string(), + guid: Some("guid-1".to_string()), + }, + ]) + .expect("normalize company identities"); + assert_eq!(normalized[0].name, "Synthetic A"); + assert_eq!(normalized[0].guid.as_deref(), Some("GUID-1")); + assert!(!unique_company_guids(&normalized)); + + assert!( + normalize_discovered_companies(vec![crate::tally::TallyCompany { + name: "Synthetic\nCompany".to_string(), + guid: Some("guid-2".to_string()), + }]) + .is_err() + ); + assert!( + normalize_discovered_companies(vec![crate::tally::TallyCompany { + name: "Synthetic Company".to_string(), + guid: Some("guid\n2".to_string()), + }]) + .is_err() + ); + } + #[test] fn validates_tally_endpoint_components() { assert_eq!( @@ -246,6 +830,24 @@ mod tests { .as_str(), "http://[::1]:9000/status" ); + for host in ["localhost", "127.0.0.1"] { + assert_eq!( + canonical_loopback_origin(&TallyConfig { + host: host.to_string(), + port: 9000, + }) + .expect("canonical loopback origin"), + "http://127.0.0.1:9000" + ); + } + assert_eq!( + canonical_loopback_origin(&TallyConfig { + host: "::1".to_string(), + port: 9000, + }) + .expect("canonical IPv6 loopback origin"), + "http://[::1]:9000" + ); for host in ["http://localhost", "localhost/path", "user@localhost", ""] { let invalid = TallyConfig { @@ -270,4 +872,280 @@ mod tests { assert!(tally_endpoint(&remote, "/status").is_err()); } } + + #[test] + fn decodes_supported_xml_byte_order_marks_and_rejects_invalid_sequences() { + let utf8 = [b"\xEF\xBB\xBF".as_slice(), b""].concat(); + assert_eq!(decode_xml_bytes(utf8).expect("UTF-8 BOM"), ""); + + let document = "नमस्ते"; + let mut utf16le = vec![0xFF, 0xFE]; + utf16le.extend(document.encode_utf16().flat_map(u16::to_le_bytes)); + assert_eq!(decode_xml_bytes(utf16le).expect("UTF-16LE"), document); + + let mut utf16be = vec![0xFE, 0xFF]; + utf16be.extend(document.encode_utf16().flat_map(u16::to_be_bytes)); + assert_eq!(decode_xml_bytes(utf16be).expect("UTF-16BE"), document); + + assert!(decode_xml_bytes(vec![0xFF, 0xFE, 0x00]).is_err()); + assert!(decode_xml_bytes(vec![0x80]).is_err()); + } + + #[tokio::test] + async fn tally_requests_ignore_configured_proxy() { + let tally_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind synthetic Tally server"); + let tally_address = tally_listener.local_addr().expect("Tally address"); + let proxy_listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind synthetic proxy"); + let proxy_address = proxy_listener.local_addr().expect("proxy address"); + + let tally_server = tokio::spawn(async move { + let accepted = tokio::time::timeout(Duration::from_secs(2), tally_listener.accept()) + .await + .expect("Tally request timed out") + .expect("accept Tally request"); + let (mut socket, _) = accepted; + let mut request = [0_u8; 2048]; + let bytes_read = socket.read(&mut request).await.expect("read Tally request"); + assert!( + String::from_utf8_lossy(&request[..bytes_read]).starts_with("GET /status HTTP/1.1"), + "request should go directly to the Tally endpoint" + ); + let body = "TallyPrime Server is Running"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write Tally response"); + }); + + let proxy_server = tokio::spawn(async move { + match tokio::time::timeout(Duration::from_millis(750), proxy_listener.accept()).await { + Ok(Ok((mut socket, _))) => { + let response = "HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + socket + .write_all(response.as_bytes()) + .await + .expect("write proxy response"); + true + } + Ok(Err(error)) => panic!("accept proxy request: {error}"), + Err(_) => false, + } + }); + + let client = TallyClient::with_http_builder( + TallyConfig { + host: tally_address.ip().to_string(), + port: tally_address.port(), + }, + reqwest::Client::builder().proxy( + reqwest::Proxy::all(format!("http://{proxy_address}")) + .expect("synthetic proxy URL"), + ), + ); + + let status = client + .check_connection() + .await + .expect("check synthetic Tally connection"); + tally_server.await.expect("synthetic Tally server task"); + let proxy_received_request = proxy_server.await.expect("synthetic proxy task"); + + assert!(status.reachable, "direct Tally response should be accepted"); + assert!( + status.compatible, + "synthetic Tally status should be recognized" + ); + assert!( + !proxy_received_request, + "Tally traffic must never be sent through a configured proxy" + ); + } + + #[tokio::test] + async fn http_success_with_tally_status_zero_is_not_an_empty_success() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind synthetic Tally server"); + let address = listener.local_addr().expect("synthetic Tally address"); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accept Tally request"); + let mut request = [0_u8; 8192]; + let bytes_read = socket.read(&mut request).await.expect("read Tally request"); + assert!( + String::from_utf8_lossy(&request[..bytes_read]).starts_with("POST / HTTP/1.1"), + "ledger fetch should use Tally's XML POST endpoint" + ); + let body = "
0
"; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write Tally response"); + }); + + let client = TallyClient::new(TallyConfig { + host: address.ip().to_string(), + port: address.port(), + }) + .expect("build synthetic Tally client"); + let error = client + .fetch_ledgers("Synthetic Company", "synthetic-company-guid") + .await + .expect_err("STATUS 0 must not become an empty ledger result"); + server.await.expect("synthetic Tally server task"); + assert!(error.to_string().contains("export request failed")); + } + + #[tokio::test] + async fn capability_probe_reports_only_observed_xml_support() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind synthetic Tally server"); + let address = listener.local_addr().expect("synthetic Tally address"); + let server = tokio::spawn(async move { + for body in [ + "LOCAL STATUS HEURISTIC UNRECOGNIZED", + "
1
Synthetic Companyguid-1
", + ] { + let (mut socket, _) = listener.accept().await.expect("accept Tally request"); + let mut request = [0_u8; 8192]; + let bytes_read = socket.read(&mut request).await.expect("read Tally request"); + assert!(bytes_read > 0, "synthetic Tally request must not be empty"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write Tally response"); + } + }); + + let probe = TallyClient::new(TallyConfig { + host: address.ip().to_string(), + port: address.port(), + }) + .expect("build synthetic Tally client") + .probe() + .await + .expect("probe synthetic Tally endpoint"); + server.await.expect("synthetic Tally server task"); + + assert!(probe.connection.reachable); + assert!(!probe.connection.compatible); + assert_eq!(probe.companies.len(), 1); + assert_eq!( + probe.profile.transports[&TransportId::XmlHttp].state, + CapabilityState::Supported + ); + assert_eq!( + probe.profile.packs[&CapabilityPackId::CoreAccounting].state, + CapabilityState::Unknown + ); + assert_eq!(probe.profile.product, "Unknown"); + assert!(probe.profile.release.is_none()); + assert!(probe.profile.mode.is_none()); + assert_eq!(probe.profile.profile_version, 2); + for transport in [TransportId::TdlCompanion, TransportId::Odbc] { + let evidence = &probe.profile.transports[&transport]; + assert_eq!(evidence.state, CapabilityState::Unknown); + assert_eq!(evidence.confidence, EvidenceConfidence::Unknown); + assert_eq!( + evidence.safe_reason_code.as_deref(), + Some("configuration_not_observed") + ); + } + assert_eq!( + probe.profile.features[&CapabilityFeatureId::EndpointReachability].state, + CapabilityState::Supported + ); + assert_eq!( + probe.profile.features[&CapabilityFeatureId::LoadedCompanies].state, + CapabilityState::Supported + ); + assert_eq!( + probe.profile.features[&CapabilityFeatureId::StableCompanyIdentity].state, + CapabilityState::Supported + ); + assert_eq!( + probe.profile.features[&CapabilityFeatureId::EncodingBehaviour] + .safe_reason_code + .as_deref(), + Some("utf8_observed") + ); + for feature in [ + CapabilityFeatureId::PracticalResponseLimit, + CapabilityFeatureId::LedgerRead, + CapabilityFeatureId::VoucherRead, + CapabilityFeatureId::Write, + ] { + assert_eq!( + probe.profile.features[&feature].state, + CapabilityState::Unknown + ); + } + } + + #[tokio::test] + async fn capability_probe_does_not_promote_a_shaped_company_failure_to_xml_support() { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("bind synthetic Tally server"); + let address = listener.local_addr().expect("synthetic Tally address"); + let server = tokio::spawn(async move { + for body in [ + "TallyPrime Server is Running", + "
0
Could not find Company ''
", + ] { + let (mut socket, _) = listener.accept().await.expect("accept Tally request"); + let mut request = [0_u8; 8192]; + let bytes_read = socket.read(&mut request).await.expect("read Tally request"); + assert!(bytes_read > 0, "synthetic Tally request must not be empty"); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write Tally response"); + } + }); + + let probe = TallyClient::new(TallyConfig { + host: address.ip().to_string(), + port: address.port(), + }) + .expect("build synthetic Tally client") + .probe() + .await + .expect("probe synthetic Tally endpoint"); + server.await.expect("synthetic Tally server task"); + + assert!(probe.companies.is_empty()); + let xml = &probe.profile.transports[&TransportId::XmlHttp]; + assert_eq!(xml.state, CapabilityState::Unknown); + assert_eq!(xml.confidence, EvidenceConfidence::Observed); + assert_eq!(xml.safe_reason_code.as_deref(), Some("company_not_loaded")); + assert_eq!( + probe.profile.features[&CapabilityFeatureId::LoadedCompanies].state, + CapabilityState::Unknown + ); + assert_eq!( + probe.profile.features[&CapabilityFeatureId::StableCompanyIdentity].state, + CapabilityState::Unknown + ); + } } diff --git a/src-tauri/src/tally/connector.rs b/src-tauri/src/tally/connector.rs new file mode 100644 index 0000000..00d958e --- /dev/null +++ b/src-tauri/src/tally/connector.rs @@ -0,0 +1,1056 @@ +use bridge_tally_canonical::build_core_window; +use bridge_tally_core::report_tie_out::{LedgerPeriodBalance, LedgerPeriodBalanceReport}; +use bridge_tally_core::{ + CanonicalPackWindow, CapabilityEvidence, CapabilityPackId, CapabilityState, CompanyRef, + EvidenceConfidence, ExactDecimal, PackBatch, ProbeResult, ReadResponseScope, ReadWindow, + RequestContext, SourceIdentity, TallyConnector, TallyError, CORE_ACCOUNTING_SCHEMA_VERSION, +}; +use bridge_tally_protocol::{ + parse_companies, parse_group_source_records_with_evidence, parse_ledger_period_balance_report, + parse_ledger_source_records_with_evidence, parse_voucher_source_records_with_evidence, + parse_voucher_type_source_records_with_evidence, verify_company_context, +}; +use bridge_tally_transport::TallyTransportError; +use sha2::{Digest, Sha256}; +use tokio_util::sync::CancellationToken; + +use super::runtime::{TallyRuntimeControlError, TallyRuntimeReadError}; +use super::{tdl_engine, TallyConfig, TallyRuntime}; + +const CORE_QUERY_PROFILE: &str = "core_accounting_v2"; + +pub(super) struct SealedReadRequest(String); + +impl SealedReadRequest { + fn from_connector_profile(xml: String) -> Self { + Self(xml) + } + + pub(super) fn into_xml(self) -> String { + self.0 + } +} + +#[derive(Clone)] +pub struct RuntimeTallyConnector { + runtime: TallyRuntime, + config: TallyConfig, + company: CompanyRef, + canary_context: RequestContext, + cancellation: CancellationToken, +} + +impl RuntimeTallyConnector { + pub fn new( + runtime: TallyRuntime, + config: TallyConfig, + company: CompanyRef, + canary_context: RequestContext, + ) -> Result { + if canary_context.company != company + || canary_context.pack != CapabilityPackId::CoreAccounting + || canary_context.schema_version != CORE_ACCOUNTING_SCHEMA_VERSION + || canary_context.query_profile.as_str() != CORE_QUERY_PROFILE + { + return Err(invalid_data("connector_context_invalid")); + } + Ok(Self { + runtime, + config, + company, + canary_context, + cancellation: CancellationToken::new(), + }) + } + + pub fn cancel(&self) { + self.cancellation.cancel(); + } + + async fn post_xml_validated

( + &self, + request_xml: String, + validate_application_response: P, + ) -> Result + where + P: Fn(&str) -> bool + Send + Sync, + { + self.runtime + .post_xml_cancellable_validated( + self.config.clone(), + SealedReadRequest::from_connector_profile(request_xml), + self.cancellation.clone(), + validate_application_response, + ) + .await + .map_err(map_transport_error) + } + + async fn extract_core_window( + &self, + context: &RequestContext, + ) -> Result { + if context.company.identity != self.company.identity { + return Err(invalid_data("company_identity_mismatch")); + } + if context.pack != CapabilityPackId::CoreAccounting + || context.schema_version != CORE_ACCOUNTING_SCHEMA_VERSION + || context.query_profile.as_str() != CORE_QUERY_PROFILE + { + return Err(TallyError::Unsupported { + code: "query_profile_not_supported".to_string(), + }); + } + + let company_name = self.company.display_name.clone(); + let expected_guid = self.company.identity.company_guid.clone(); + let validation_guid = expected_guid.clone(); + let group_xml = self + .post_xml_validated(tdl_engine::groups_request(&company_name), move |xml| { + parse_group_source_records_with_evidence(xml) + .and_then(|parsed| verify_company_context(&parsed.evidence, &validation_guid)) + .is_ok() + }) + .await?; + let groups = parse_group_source_records_with_evidence(&group_xml) + .map_err(|_| protocol_error("group_export_invalid"))?; + verify_company_context(&groups.evidence, &expected_guid) + .map_err(|_| invalid_data("company_identity_mismatch"))?; + + let validation_guid = expected_guid.clone(); + let ledger_xml = self + .post_xml_validated(tdl_engine::ledgers_request(&company_name), move |xml| { + parse_ledger_source_records_with_evidence(xml) + .and_then(|parsed| verify_company_context(&parsed.evidence, &validation_guid)) + .is_ok() + }) + .await?; + let ledgers = parse_ledger_source_records_with_evidence(&ledger_xml) + .map_err(|_| protocol_error("ledger_export_invalid"))?; + verify_company_context(&ledgers.evidence, &expected_guid) + .map_err(|_| invalid_data("company_identity_mismatch"))?; + + let validation_guid = expected_guid.clone(); + let voucher_type_xml = self + .post_xml_validated( + tdl_engine::voucher_types_request(&company_name), + move |xml| { + parse_voucher_type_source_records_with_evidence(xml) + .and_then(|parsed| { + verify_company_context(&parsed.evidence, &validation_guid) + }) + .is_ok() + }, + ) + .await?; + let voucher_types = parse_voucher_type_source_records_with_evidence(&voucher_type_xml) + .map_err(|_| protocol_error("voucher_type_export_invalid"))?; + verify_company_context(&voucher_types.evidence, &expected_guid) + .map_err(|_| invalid_data("company_identity_mismatch"))?; + + let validation_guid = expected_guid.clone(); + let voucher_xml = self + .post_xml_validated( + tdl_engine::vouchers_request( + &company_name, + &context.window.from_yyyymmdd, + &context.window.to_yyyymmdd, + ), + move |xml| { + parse_voucher_source_records_with_evidence(xml) + .and_then(|parsed| { + verify_company_context(&parsed.evidence, &validation_guid) + }) + .is_ok() + }, + ) + .await + .map_err(classify_voucher_window_error)?; + let vouchers = parse_voucher_source_records_with_evidence(&voucher_xml) + .map_err(|_| protocol_error("voucher_export_invalid"))?; + verify_company_context(&vouchers.evidence, &expected_guid) + .map_err(|_| invalid_data("company_identity_mismatch"))?; + + build_core_window(context, groups, ledgers, voucher_types, vouchers) + } + + async fn snapshot_probe(&self) -> Result { + let (_, mut result) = self + .runtime + .snapshot_probe_with_observation(self.config.clone()) + .await + .map_err(map_transport_error)?; + let matching_companies = result + .companies + .iter() + .filter(|company| { + company.guid.as_deref().is_some_and(|guid| { + company_guids_equal(guid, &self.company.identity.company_guid) + }) + }) + .take(2) + .count(); + if matching_companies != 1 { + return Err(protocol_error(if matching_companies == 0 { + "company_identity_not_found" + } else { + "company_identity_ambiguous" + })); + } + let core_evidence = match self.extract_core_window(&self.canary_context).await { + Ok(window) => core_canary_capability(&window), + Err(error) => CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some(capability_failure_code(&error)), + }, + }; + result + .profile + .packs + .insert(CapabilityPackId::CoreAccounting, core_evidence); + Ok(ProbeResult { + reachable: result.connection.reachable, + profile: result.profile, + }) + } +} + +#[async_trait::async_trait] +impl TallyConnector for RuntimeTallyConnector { + async fn probe(&self) -> Result { + self.snapshot_probe().await + } + + async fn probe_fresh(&self) -> Result { + self.snapshot_probe().await + } + + async fn discover_companies(&self) -> Result, TallyError> { + // A reviewed setup consumes its interactive probe cache before the snapshot starts. + // Runtime discovery is a fresh, validated company-list read and must not depend on or + // recreate that single-use UI authority. + let lineage = source_lineage(&self.config)?; + let companies = parse_companies( + &self + .post_xml_validated(tdl_engine::company_list_request(), |xml| { + parse_companies(xml).is_ok() + }) + .await?, + ) + .map_err(|_| protocol_error("company_export_invalid"))?; + Ok(companies + .into_iter() + .filter_map(|company| { + let guid = company.guid?; + if guid.trim().is_empty() { + return None; + } + Some(CompanyRef { + identity: company_source_identity(&lineage, &guid), + display_name: company.name, + }) + }) + .collect()) + } + + async fn read_pack_window( + &self, + context: &RequestContext, + ) -> Result { + // Capability probes happen before a durable run receives its started_at timestamp. + // Always perform a new source read here, including for the same canary context, so + // pre-run observations can never enter the snapshot as if they were run data. + self.extract_core_window(context).await + } + + async fn read_core_period_balance_report( + &self, + context: &RequestContext, + ) -> Result { + if context.company.identity != self.company.identity + || context.pack != CapabilityPackId::CoreAccounting + || context.schema_version != CORE_ACCOUNTING_SCHEMA_VERSION + || context.query_profile.as_str() != CORE_QUERY_PROFILE + { + return Err(invalid_data("period_report_scope_mismatch")); + } + let expected_company_guid = self.company.identity.company_guid.clone(); + let expected_from = context.window.from_yyyymmdd.clone(); + let expected_to = context.window.to_yyyymmdd.clone(); + let validation_company_guid = expected_company_guid.clone(); + let validation_from = expected_from.clone(); + let validation_to = expected_to.clone(); + let xml = self + .post_xml_validated( + tdl_engine::ledger_period_balances_request( + &self.company.display_name, + &expected_from, + &expected_to, + ), + move |xml| { + parse_ledger_period_balance_report(xml).is_ok_and(|parsed| { + company_guids_equal(&parsed.context.company_guid, &validation_company_guid) + && parsed.context.from_yyyymmdd == validation_from + && parsed.context.to_yyyymmdd == validation_to + && parsed.context.ordinary_books_requested + }) + }, + ) + .await?; + let parsed = parse_ledger_period_balance_report(&xml) + .map_err(|_| protocol_error("period_report_invalid"))?; + if !company_guids_equal( + &parsed.context.company_guid, + &self.company.identity.company_guid, + ) || parsed.context.from_yyyymmdd != context.window.from_yyyymmdd + || parsed.context.to_yyyymmdd != context.window.to_yyyymmdd + || !parsed.context.ordinary_books_requested + { + return Err(invalid_data("period_report_scope_mismatch")); + } + let balances = parsed + .records + .into_iter() + .map(|row| { + Ok(LedgerPeriodBalance { + ledger_source_id: row + .source_id + .ok_or_else(|| invalid_data("period_report_identity_missing"))?, + opening_balance: ExactDecimal::parse(row.record.opening_balance)?, + closing_balance: ExactDecimal::parse(row.record.closing_balance)?, + }) + }) + .collect::, TallyError>>()?; + Ok(LedgerPeriodBalanceReport { + source_identity: self.company.identity.clone(), + window: ReadWindow { + from_yyyymmdd: parsed.context.from_yyyymmdd, + to_yyyymmdd: parsed.context.to_yyyymmdd, + }, + // The report echoes Bridge's requested profile, but Tally does not + // attest that TBalOpening/TBalClosing exclude every scenario, + // optional, post-dated, or tracking-note effect. A live, + // release-specific capability receipt must opt this in later. + ordinary_books_scope_observed: false, + source_reported_count: parsed.context.source_record_count, + balances, + }) + } +} + +fn core_canary_capability(window: &CanonicalPackWindow) -> CapabilityEvidence { + let PackBatch::CoreAccounting(_) = &window.batch else { + return observed_core_capability( + CapabilityState::Unknown, + "sealed_profile_executed_unexpected_pack", + ); + }; + // A successful extraction proves that every sealed export parsed and matched the pinned + // company. Returned rows cannot prove that optional fields work when absent, nor that a field + // observed in this particular date window is supported generally. Keep one stable, truthful + // execution receipt regardless of incidental row population. + observed_core_capability(CapabilityState::Unknown, "sealed_profile_executed") +} + +/// Returns whether a fresh, identity-bound execution of the sealed Core Accounting profile is +/// sufficient to start a snapshot attempt. +/// +/// `Unknown` is deliberately required: a successful sealed execution authorizes a run, but does +/// not claim that fields absent from the returned rows are supported. Reconciliation retains this +/// evidence and can therefore finish partial/unverified. +pub fn core_snapshot_start_authorized(evidence: &CapabilityEvidence) -> bool { + core_snapshot_start_authorized_codes( + capability_state_code(evidence.state), + evidence_confidence_code(evidence.confidence), + evidence.safe_reason_code.as_deref(), + ) +} + +/// Storage-level form of [`core_snapshot_start_authorized`]. Persisted restart evidence must use +/// this predicate too, so a resume cannot accidentally drift back to the broader `Supported + +/// Observed` convention used by other capability packs. +pub(crate) fn core_snapshot_start_authorized_codes( + state: &str, + confidence: &str, + safe_reason_code: Option<&str>, +) -> bool { + state == "unknown" + && confidence == "observed" + && safe_reason_code == Some("sealed_profile_executed") +} + +fn capability_state_code(state: CapabilityState) -> &'static str { + match state { + CapabilityState::Supported => "supported", + CapabilityState::Unsupported => "unsupported", + CapabilityState::Unknown => "unknown", + CapabilityState::NotConfigured => "not_configured", + } +} + +fn evidence_confidence_code(confidence: EvidenceConfidence) -> &'static str { + match confidence { + EvidenceConfidence::Documented => "documented", + EvidenceConfidence::Observed => "observed", + EvidenceConfidence::Inferred => "inferred", + EvidenceConfidence::Unknown => "unknown", + } +} + +fn observed_core_capability(state: CapabilityState, reason: &str) -> CapabilityEvidence { + CapabilityEvidence { + state, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some(reason.to_string()), + } +} + +pub fn source_lineage(config: &TallyConfig) -> Result { + let endpoint = + super::EndpointKey::from_config(config).map_err(|_| invalid_data("endpoint_invalid"))?; + Ok(format!("tally_xml_http:{}", endpoint.as_str())) +} + +pub fn company_source_identity(lineage: &str, company_guid: &str) -> SourceIdentity { + let canonical_guid = company_guid.to_ascii_lowercase(); + let mut digest = Sha256::new(); + digest.update(b"bridge-tally-company-observation-v1\0"); + digest.update(lineage.as_bytes()); + digest.update(b"\0"); + digest.update(canonical_guid.as_bytes()); + SourceIdentity { + bridge_source_lineage: lineage.to_string(), + company_guid: canonical_guid, + observed_fingerprint: hex_lower(&digest.finalize()), + } +} + +fn company_guids_equal(left: &str, right: &str) -> bool { + left.eq_ignore_ascii_case(right) +} + +fn map_transport_error(error: anyhow::Error) -> TallyError { + if let Some(control) = error.downcast_ref::() { + return match control { + TallyRuntimeControlError::Cancelled => TallyError::Cancelled, + TallyRuntimeControlError::QueueDeadline => TallyError::Unsupported { + code: "endpoint_queue_deadline_exceeded".to_string(), + }, + TallyRuntimeControlError::CircuitCooldown + | TallyRuntimeControlError::HalfOpenProbeInFlight => TallyError::Unsupported { + code: "endpoint_circuit_open".to_string(), + }, + TallyRuntimeControlError::EndpointSessionCapacity => TallyError::Unsupported { + code: "runtime_capacity_reached".to_string(), + }, + }; + } + if let Some(transport) = error.downcast_ref::() { + return match transport { + TallyTransportError::EndpointInvalid { .. } => invalid_data("endpoint_invalid"), + TallyTransportError::PolicyInvalid { .. } + | TallyTransportError::ClientInitializationFailed => TallyError::Unsupported { + code: transport.safe_code().to_string(), + }, + TallyTransportError::RequestTooLarge { .. } => { + invalid_data("request_size_limit_exceeded") + } + TallyTransportError::ResponseTooLarge { .. } + | TallyTransportError::ResponseTruncated + | TallyTransportError::ResponseReadFailed + | TallyTransportError::UnsupportedContentEncoding + | TallyTransportError::InvalidEncoding { .. } + | TallyTransportError::HttpStatus { .. } => protocol_error(transport.safe_code()), + TallyTransportError::ConnectionFailed + | TallyTransportError::RequestTimedOut + | TallyTransportError::RequestFailed => TallyError::Unreachable, + }; + } + if let Some(read) = error.downcast_ref::() { + return match read { + TallyRuntimeReadError::ApplicationResponseRejected => { + protocol_error("application_response_rejected") + } + }; + } + protocol_error("unclassified_tally_error") +} + +fn classify_voucher_window_error(error: TallyError) -> TallyError { + match error { + TallyError::Protocol { code } if code == "response_size_limit_exceeded" => { + TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow, + } + } + error => error, + } +} + +fn invalid_data(code: &'static str) -> TallyError { + TallyError::InvalidData { + code: code.to_string(), + } +} + +fn protocol_error(code: &'static str) -> TallyError { + TallyError::Protocol { + code: code.to_string(), + } +} + +fn capability_failure_code(error: &TallyError) -> String { + match error { + TallyError::Protocol { code } + | TallyError::InvalidData { code } + | TallyError::Unsupported { code } => code.clone(), + TallyError::Unreachable => "tally_unreachable".to_string(), + TallyError::ReadResponseTooLarge { .. } => { + "voucher_response_size_limit_exceeded".to_string() + } + TallyError::Cancelled => "canary_cancelled".to_string(), + TallyError::OutcomeUnknown => "canary_outcome_unknown".to_string(), + } +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut output = String::with_capacity(bytes.len() * 2); + for byte in bytes { + output.push(HEX[(byte >> 4) as usize] as char); + output.push(HEX[(byte & 0x0f) as usize] as char); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{net::SocketAddr, sync::OnceLock}; + use tally_protocol_simulator::{Fixture, ScenarioPlan, SequenceSimulator}; + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + task::JoinHandle, + }; + + fn simulator_test_lock() -> &'static tokio::sync::Mutex<()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| tokio::sync::Mutex::new(())) + } + + async fn spawn_method_routed_server( + post_responses: Vec, + ) -> (SocketAddr, JoinHandle>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind routed Tally server"); + let address = listener.local_addr().expect("routed server address"); + let worker = tokio::spawn(async move { + let mut post_responses = post_responses.into_iter(); + let mut posts_remaining = post_responses.len(); + let mut methods = Vec::new(); + while posts_remaining > 0 { + let (mut socket, _) = listener.accept().await.expect("accept routed request"); + let mut request = Vec::new(); + let (header_end, content_length) = loop { + let mut buffer = [0_u8; 8 * 1024]; + let read = socket.read(&mut buffer).await.expect("read routed request"); + assert!(read > 0, "routed request closed before its headers"); + request.extend_from_slice(&buffer[..read]); + assert!( + request.len() <= 256 * 1024, + "routed request exceeded test bound" + ); + let Some(header_end) = request + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) + else { + continue; + }; + let headers = String::from_utf8_lossy(&request[..header_end]); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + .unwrap_or(0); + if request.len() >= header_end.saturating_add(content_length) { + break (header_end, content_length); + } + }; + let request_line = String::from_utf8_lossy(&request[..header_end]) + .lines() + .next() + .unwrap_or_default() + .to_string(); + let method = request_line.split_whitespace().next().unwrap_or_default(); + methods.push(method.to_string()); + let body = if method == "GET" { + "TallyPrime Server is Running".to_string() + } else { + assert_eq!(request.len(), header_end + content_length); + posts_remaining -= 1; + post_responses.next().expect("next routed POST response") + }; + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/xml\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + socket + .write_all(response.as_bytes()) + .await + .expect("write routed response"); + } + methods + }); + (address, worker) + } + + #[test] + fn company_source_identity_is_stable_across_guid_casing() { + let lowercase = company_source_identity( + "tally_xml_http:http://127.0.0.1:9000", + "4c42a771-abcd-4def-8abc-001122aabbcc", + ); + let mixed_case = company_source_identity( + "tally_xml_http:http://127.0.0.1:9000", + "4C42A771-AbCd-4DeF-8AbC-001122AaBbCc", + ); + + assert_eq!(mixed_case, lowercase); + assert_eq!( + mixed_case.company_guid, + "4c42a771-abcd-4def-8abc-001122aabbcc" + ); + } + + #[test] + fn empty_sealed_canary_stays_unknown() { + let window = CanonicalPackWindow::without_source_count_evidence(PackBatch::CoreAccounting( + bridge_tally_core::CoreAccountingBatch::default(), + )); + + let evidence = core_canary_capability(&window); + + assert_eq!(evidence.state, CapabilityState::Unknown); + assert_eq!( + evidence.safe_reason_code.as_deref(), + Some("sealed_profile_executed") + ); + assert_eq!(evidence.confidence, EvidenceConfidence::Observed); + assert!(core_snapshot_start_authorized(&evidence)); + } + + #[test] + fn partial_sealed_canary_does_not_promote_the_whole_pack() { + let window = CanonicalPackWindow::without_source_count_evidence(PackBatch::CoreAccounting( + bridge_tally_core::CoreAccountingBatch { + groups: vec![bridge_tally_core::GroupRecord { + source_id: "group-guid".to_string(), + name: "Assets".to_string(), + parent_source_id: None, + }], + ..bridge_tally_core::CoreAccountingBatch::default() + }, + )); + + let evidence = core_canary_capability(&window); + + assert_eq!(evidence.state, CapabilityState::Unknown); + assert_eq!( + evidence.safe_reason_code.as_deref(), + Some("sealed_profile_executed") + ); + assert!(core_snapshot_start_authorized(&evidence)); + } + + #[test] + fn fully_populated_canary_does_not_overclaim_field_support() { + let entry_source_id = "bridge-derived:ledger-entry:v1:synthetic".to_string(); + let window = CanonicalPackWindow { + batch: PackBatch::CoreAccounting(bridge_tally_core::CoreAccountingBatch { + groups: vec![ + bridge_tally_core::GroupRecord { + source_id: "root-group".to_string(), + name: "Root".to_string(), + parent_source_id: None, + }, + bridge_tally_core::GroupRecord { + source_id: "child-group".to_string(), + name: "Assets".to_string(), + parent_source_id: Some("root-group".to_string()), + }, + ], + ledgers: vec![bridge_tally_core::LedgerRecord { + source_id: "ledger-guid".to_string(), + name: "Cash".to_string(), + parent_source_id: Some("child-group".to_string()), + opening_balance: Some(ExactDecimal::parse("0").unwrap()), + }], + voucher_types: vec![bridge_tally_core::VoucherTypeRecord { + source_id: "voucher-type-guid".to_string(), + name: "Receipt".to_string(), + }], + vouchers: vec![bridge_tally_core::VoucherRecord { + source_id: "voucher-guid".to_string(), + date_yyyymmdd: "20260716".to_string(), + voucher_type_source_id: "voucher-type-guid".to_string(), + voucher_number: Some("SYN-1".to_string()), + cancelled: false, + optional: false, + }], + ledger_entries: vec![bridge_tally_core::LedgerEntryRecord { + source_id: entry_source_id.clone(), + voucher_source_id: "voucher-guid".to_string(), + ledger_source_id: "ledger-guid".to_string(), + amount: ExactDecimal::parse("0").unwrap(), + polarity: bridge_tally_core::LedgerEntryPolarity::Debit, + }], + }), + source_counts: None, + record_evidence: Some(vec![bridge_tally_core::SourceRecordEvidence { + object_type: bridge_tally_core::CanonicalText::parse("ledger_entry").unwrap(), + source_id: bridge_tally_core::SourceRecordId::parse(entry_source_id).unwrap(), + identity_kind: bridge_tally_core::SourceIdentityKind::Fallback, + observed_identities: bridge_tally_core::ObservedSourceIdentities::default(), + raw_source_sha256: bridge_tally_core::RawSourceSha256::parse("0".repeat(64)) + .unwrap(), + alter_id: None, + }]), + }; + + let evidence = core_canary_capability(&window); + + assert_eq!(evidence.state, CapabilityState::Unknown); + assert_eq!( + evidence.safe_reason_code.as_deref(), + Some("sealed_profile_executed") + ); + assert!(core_snapshot_start_authorized(&evidence)); + } + + #[test] + fn failed_or_unobserved_canary_cannot_authorize_snapshot_start() { + for evidence in [ + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("voucher_export_invalid".to_string()), + }, + CapabilityEvidence { + state: CapabilityState::Unknown, + confidence: EvidenceConfidence::Unknown, + safe_reason_code: Some("sealed_profile_executed".to_string()), + }, + CapabilityEvidence { + state: CapabilityState::Supported, + confidence: EvidenceConfidence::Observed, + safe_reason_code: Some("release_claimed_support".to_string()), + }, + ] { + assert!(!core_snapshot_start_authorized(&evidence)); + } + } + + #[test] + fn period_report_company_guid_matching_is_ascii_case_insensitive_only() { + assert!(company_guids_equal( + "4C42A771-AbCd-4DeF-8AbC-001122AaBbCc", + "4c42a771-abcd-4def-8abc-001122aabbcc" + )); + assert!(!company_guids_equal("company-guid-a", "company-guid-b")); + assert!(!company_guids_equal("company-guid", " company-guid ")); + } + + #[tokio::test] + async fn duplicate_company_snapshot_probe_stops_before_core_exports() { + let _simulator_guard = simulator_test_lock().lock().await; + let company_guid = "synthetic-company-guid"; + let duplicate_company_xml = format!( + r#"

1
Synthetic Company A{company_guid}Synthetic Company B{company_guid}
"# + ); + let (address, server) = spawn_method_routed_server(vec![duplicate_company_xml]).await; + let config = TallyConfig { + host: address.ip().to_string(), + port: address.port(), + }; + let company = CompanyRef { + identity: company_source_identity( + &format!("tally_xml_http:http://{address}"), + company_guid, + ), + display_name: "Synthetic Company A".to_string(), + }; + let context = RequestContext { + run_id: "run-ambiguous-company-probe".to_string(), + company: company.clone(), + pack: CapabilityPackId::CoreAccounting, + schema_version: CORE_ACCOUNTING_SCHEMA_VERSION, + window: ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260701".to_string(), + }, + query_profile: bridge_tally_core::CanonicalText::parse(CORE_QUERY_PROFILE).unwrap(), + filters_sha256: bridge_tally_core::CanonicalText::parse("0".repeat(64)).unwrap(), + }; + let connector = + RuntimeTallyConnector::new(TallyRuntime::default(), config, company, context).unwrap(); + + let error = connector + .probe() + .await + .expect_err("ambiguous company identity must stop the canary"); + assert!(matches!( + error, + TallyError::Protocol { code } if code == "company_identity_ambiguous" + )); + let methods = server.await.expect("join routed Tally server"); + assert_eq!(methods, ["GET", "POST"]); + } + + #[tokio::test] + async fn snapshot_start_and_end_probes_preserve_setup_review_and_read_transport_freshly() { + let _simulator_guard = simulator_test_lock().lock().await; + let company_guid = "synthetic-company-guid"; + let company_xml = format!( + r#"
1
Synthetic Company{company_guid}
"# + ); + let empty_export = |schema: &str, object_type: &str| { + format!( + r#"
1
"# + ) + }; + let mut post_responses = vec![company_xml.clone()]; + for _ in 0..2 { + post_responses.extend([ + company_xml.clone(), + empty_export(bridge_tally_protocol::BRIDGE_GROUP_EXPORT_SCHEMA, "GROUP"), + empty_export(bridge_tally_protocol::BRIDGE_LEDGER_EXPORT_SCHEMA, "LEDGER"), + empty_export( + bridge_tally_protocol::BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA, + "VOUCHERTYPE", + ), + empty_export( + bridge_tally_protocol::BRIDGE_VOUCHER_EXPORT_SCHEMA, + "VOUCHER", + ), + ]); + } + post_responses.push(company_xml.clone()); + let (address, server) = spawn_method_routed_server(post_responses).await; + let config = TallyConfig { + host: address.ip().to_string(), + port: address.port(), + }; + let runtime = TallyRuntime::default(); + let (review_id, observed_at_unix_ms, reviewed) = runtime + .probe_with_observation(config.clone()) + .await + .expect("install interactive setup review"); + assert!(reviewed.connection.reachable); + let reviewed_company_count = reviewed.companies.len(); + let reviewed_product = reviewed.profile.product.clone(); + + let company = CompanyRef { + identity: company_source_identity( + &format!("tally_xml_http:http://{address}"), + company_guid, + ), + display_name: "Synthetic Company".to_string(), + }; + let context = RequestContext { + run_id: "run-uncached-probes".to_string(), + company: company.clone(), + pack: CapabilityPackId::CoreAccounting, + schema_version: CORE_ACCOUNTING_SCHEMA_VERSION, + window: ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260701".to_string(), + }, + query_profile: bridge_tally_core::CanonicalText::parse(CORE_QUERY_PROFILE).unwrap(), + filters_sha256: bridge_tally_core::CanonicalText::parse("0".repeat(64)).unwrap(), + }; + let connector = + RuntimeTallyConnector::new(runtime.clone(), config.clone(), company, context).unwrap(); + + let start = connector.probe().await.expect("snapshot start probe"); + let end = connector.probe_fresh().await.expect("snapshot end probe"); + let start_evidence = start + .profile + .packs + .get(&CapabilityPackId::CoreAccounting) + .unwrap(); + assert!( + core_snapshot_start_authorized(start_evidence), + "start evidence was {start_evidence:?}" + ); + let end_evidence = end + .profile + .packs + .get(&CapabilityPackId::CoreAccounting) + .unwrap(); + assert!( + core_snapshot_start_authorized(end_evidence), + "end evidence was {end_evidence:?}" + ); + + let mut preserved = runtime + .reserve_cached_probe_fresh(&config, &review_id, 300_000) + .expect("review cache remains readable") + .expect("snapshot probes preserve interactive review"); + assert_eq!(preserved.review_id(), review_id); + assert_eq!(preserved.observed_at_unix_ms(), observed_at_unix_ms); + assert_eq!(preserved.result().companies.len(), reviewed_company_count); + assert_eq!(preserved.result().profile.product, reviewed_product); + assert!(preserved.release().expect("release preserved review")); + + let mut consumed = runtime + .reserve_cached_probe_fresh(&config, &review_id, 300_000) + .expect("review cache remains reservable") + .expect("preserved review is still present"); + assert!(consumed.consume().expect("consume setup review")); + assert!(runtime.cached_probe(&config).unwrap().is_none()); + let discovered = connector + .discover_companies() + .await + .expect("snapshot discovery is independent of consumed setup review"); + assert_eq!(discovered.len(), 1); + assert_eq!(discovered[0].identity, connector.company.identity); + + let methods = server.await.expect("join routed Tally server"); + assert!(methods + .iter() + .all(|method| method == "GET" || method == "POST")); + assert_eq!( + methods + .iter() + .filter(|method| method.as_str() == "GET") + .count(), + 3 + ); + assert_eq!( + methods + .iter() + .filter(|method| method.as_str() == "POST") + .count(), + 12 + ); + } + + #[tokio::test] + async fn same_context_snapshot_read_does_not_reuse_pre_run_canary_rows() { + let _simulator_guard = simulator_test_lock().lock().await; + let company_guid = "synthetic-company-guid"; + let empty_export = |schema: &str, object_type: &str| { + format!( + r#"
1
"# + ) + }; + let second_group = format!( + r#"
1
"#, + bridge_tally_protocol::BRIDGE_GROUP_EXPORT_SCHEMA + ); + let plans = [ + empty_export(bridge_tally_protocol::BRIDGE_GROUP_EXPORT_SCHEMA, "GROUP"), + empty_export(bridge_tally_protocol::BRIDGE_LEDGER_EXPORT_SCHEMA, "LEDGER"), + empty_export( + bridge_tally_protocol::BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA, + "VOUCHERTYPE", + ), + empty_export( + bridge_tally_protocol::BRIDGE_VOUCHER_EXPORT_SCHEMA, + "VOUCHER", + ), + second_group, + empty_export(bridge_tally_protocol::BRIDGE_LEDGER_EXPORT_SCHEMA, "LEDGER"), + empty_export( + bridge_tally_protocol::BRIDGE_VOUCHER_TYPE_EXPORT_SCHEMA, + "VOUCHERTYPE", + ), + empty_export( + bridge_tally_protocol::BRIDGE_VOUCHER_EXPORT_SCHEMA, + "VOUCHER", + ), + ] + .into_iter() + .map(Fixture::SyntheticXml) + .map(ScenarioPlan::new) + .collect(); + let simulator = SequenceSimulator::spawn(plans).expect("spawn sequence simulator"); + let company = CompanyRef { + identity: company_source_identity( + &format!("tally_xml_http:http://{}", simulator.address()), + company_guid, + ), + display_name: "Synthetic Company".to_string(), + }; + let context = RequestContext { + run_id: "run-canary-lifecycle".to_string(), + company: company.clone(), + pack: CapabilityPackId::CoreAccounting, + schema_version: CORE_ACCOUNTING_SCHEMA_VERSION, + window: ReadWindow { + from_yyyymmdd: "20260701".to_string(), + to_yyyymmdd: "20260701".to_string(), + }, + query_profile: bridge_tally_core::CanonicalText::parse(CORE_QUERY_PROFILE).unwrap(), + filters_sha256: bridge_tally_core::CanonicalText::parse("0".repeat(64)).unwrap(), + }; + let connector = RuntimeTallyConnector::new( + TallyRuntime::default(), + TallyConfig { + host: simulator.address().ip().to_string(), + port: simulator.address().port(), + }, + company, + context.clone(), + ) + .unwrap(); + + let pre_run_canary = connector.extract_core_window(&context).await.unwrap(); + let PackBatch::CoreAccounting(pre_run_batch) = pre_run_canary.batch else { + panic!("expected core canary batch"); + }; + assert!(pre_run_batch.groups.is_empty()); + + let snapshot_window = connector.read_pack_window(&context).await.unwrap(); + let PackBatch::CoreAccounting(snapshot_batch) = snapshot_window.batch else { + panic!("expected core snapshot batch"); + }; + assert_eq!(snapshot_batch.groups.len(), 1); + assert_eq!(snapshot_batch.groups[0].source_id, "post-start-group"); + + let requests = simulator.finish().expect("finish sequence simulator"); + assert_eq!(requests.len(), 8); + assert!(requests.iter().all(|request| request.method == "POST")); + } + + #[test] + fn only_exact_voucher_response_limit_becomes_adaptive_split_authority() { + assert!(matches!( + classify_voucher_window_error(TallyError::Protocol { + code: "response_size_limit_exceeded".to_string(), + }), + TallyError::ReadResponseTooLarge { + scope: ReadResponseScope::VoucherWindow + } + )); + assert!(matches!( + classify_voucher_window_error(TallyError::Protocol { + code: "response_truncated".to_string(), + }), + TallyError::Protocol { code } if code == "response_truncated" + )); + assert!(matches!( + classify_voucher_window_error(TallyError::InvalidData { + code: "voucher_export_invalid".to_string(), + }), + TallyError::InvalidData { code } if code == "voucher_export_invalid" + )); + } +} diff --git a/src-tauri/src/tally/incremental.rs b/src-tauri/src/tally/incremental.rs new file mode 100644 index 0000000..4f7177b --- /dev/null +++ b/src-tauri/src/tally/incremental.rs @@ -0,0 +1,3 @@ +//! Native Bridge uses the same portable incremental policy exercised in the fast CI lane. + +pub use bridge_tally_incremental::*; diff --git a/src-tauri/src/tally/mod.rs b/src-tauri/src/tally/mod.rs index 120d684..733d834 100644 --- a/src-tauri/src/tally/mod.rs +++ b/src-tauri/src/tally/mod.rs @@ -1,9 +1,27 @@ +pub mod capability_packs; pub mod connection; +pub mod connector; +pub mod incremental; +pub mod runtime; pub mod serial_queue; pub mod tdl_engine; pub mod validators; +pub mod write_sandbox; pub mod xml_builder; pub mod xml_parser; -pub use connection::{ConnectionStatus, TallyClient, TallyConfig, TallyProduct}; -pub use xml_parser::{TallyCompany, TallyLedger, TallyVoucher}; +pub use bridge_tally_core as core; +pub use connection::{ + ConnectionStatus, SelectedReadObservation, SelectedReadScopeEvidence, TallyClient, TallyConfig, + TallyProbeResult, TallyProduct, SELECTED_LEDGER_QUERY_PROFILE_ID, + SELECTED_VOUCHER_QUERY_PROFILE_ID, +}; +pub(crate) use connector::core_snapshot_start_authorized_codes; +pub use connector::{ + company_source_identity, core_snapshot_start_authorized, source_lineage, RuntimeTallyConnector, +}; +pub use runtime::{ + CachedProbeReservation, EndpointKey, TallyRuntime, TallySessionSnapshot, + TallyTelemetryPreviewExport, +}; +pub use xml_parser::{TallyCompany, TallyImportResult, TallyLedger, TallyVoucher}; diff --git a/src-tauri/src/tally/runtime.rs b/src-tauri/src/tally/runtime.rs new file mode 100644 index 0000000..911a710 --- /dev/null +++ b/src-tauri/src/tally/runtime.rs @@ -0,0 +1,1676 @@ +use super::{ConnectionStatus, TallyClient, TallyCompany, TallyConfig, TallyLedger}; +use super::{TallyProbeResult, TallyVoucher}; +use crate::tally::connection::{canonical_loopback_origin, SelectedReadObservation}; +use crate::tally::connector::SealedReadRequest; +use bridge_tally_runtime::{ + BodyBytesObservation, EndpointCircuitState, EndpointIdentity, EndpointRuntimeSnapshot, + PortableReadRuntime, ReadAttempt, ReadExecutionError, ReadFailureClass, ReadOperation, + ReadRetryPolicy, TELEMETRY_PREVIEW_SCHEMA, +}; +use bridge_tally_transport::TallyTransportError; +use serde::Serialize; +use std::collections::HashMap; +use std::future::Future; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, RwLock}; +use std::time::Instant; +use tokio_util::sync::CancellationToken; + +const MAX_ENDPOINT_SESSIONS: usize = 32; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct EndpointKey(String); + +impl EndpointKey { + pub fn from_config(config: &TallyConfig) -> anyhow::Result { + Ok(Self(canonical_loopback_origin(config)?)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +#[derive(Debug, Clone, Serialize)] +pub struct TallySessionSnapshot { + pub session_id: String, + pub canonical_endpoint: String, + pub issued_requests: u64, + pub active_requests: usize, + pub active_request_ids: Vec, + pub consecutive_failures: u32, + pub circuit_state: CircuitState, + pub circuit_retry_after_unix_ms: Option, + pub last_success_unix_ms: Option, + pub last_failure_unix_ms: Option, + pub cached_capability_observed_at_unix_ms: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct TallyTelemetryPreviewExport { + pub schema: &'static str, + pub payload_sha256: String, + pub preview_json: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum CircuitState { + Closed, + Open, + HalfOpen, +} + +#[derive(Debug, Default)] +struct SessionHealth { + last_success_unix_ms: Option, + last_failure_unix_ms: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum HealthOutcome { + TransportSuccess, + TransportFailure, + ApplicationRejected, + Cancelled, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum TallyRuntimeControlError { + #[error("read_request_cancelled")] + Cancelled, + #[error("endpoint_queue_deadline_exceeded")] + QueueDeadline, + #[error("endpoint_circuit_cooldown")] + CircuitCooldown, + #[error("endpoint_half_open_probe_in_flight")] + HalfOpenProbeInFlight, + #[error("endpoint_session_capacity_reached")] + EndpointSessionCapacity, +} + +#[derive(Debug, thiserror::Error)] +pub(crate) enum TallyRuntimeReadError { + #[error("application_response_rejected")] + ApplicationResponseRejected, +} + +#[derive(Clone)] +struct CachedProbe { + review_id: String, + observed_at_unix_ms: i64, + freshness_origin_unix_ms: i64, + result: TallyProbeResult, + reserved: bool, +} + +struct TallySession { + session_id: String, + endpoint: EndpointKey, + client: TallyClient, + sequence: AtomicU64, + active_requests: Mutex>, + health: Mutex, + cached_probe: RwLock>, + active_ordinary_reads: AtomicU64, +} + +impl TallySession { + fn new(endpoint: EndpointKey, config: TallyConfig) -> anyhow::Result { + Ok(Self { + session_id: uuid::Uuid::new_v4().to_string(), + endpoint, + client: TallyClient::new(config)?, + sequence: AtomicU64::new(0), + active_requests: Mutex::new(HashMap::new()), + health: Mutex::new(SessionHealth::default()), + cached_probe: RwLock::new(None), + active_ordinary_reads: AtomicU64::new(0), + }) + } + + #[cfg(test)] + fn with_transport_policy( + endpoint: EndpointKey, + config: TallyConfig, + policy: bridge_tally_transport::TransportPolicy, + ) -> anyhow::Result { + Ok(Self { + session_id: uuid::Uuid::new_v4().to_string(), + endpoint, + client: TallyClient::with_transport_policy(config, policy)?, + sequence: AtomicU64::new(0), + active_requests: Mutex::new(HashMap::new()), + health: Mutex::new(SessionHealth::default()), + cached_probe: RwLock::new(None), + active_ordinary_reads: AtomicU64::new(0), + }) + } + + fn begin_request(self: &Arc) -> anyhow::Result { + let sequence = self.sequence.fetch_add(1, Ordering::Relaxed) + 1; + let request_id = format!("{}:{sequence}", self.session_id); + let cancellation = CancellationToken::new(); + self.active_requests + .lock() + .map_err(|_| anyhow::anyhow!("Tally cancellation registry is unavailable"))? + .insert(request_id.clone(), cancellation.clone()); + Ok(RuntimeRequest { + session: Arc::clone(self), + request_id, + cancellation, + }) + } + + fn record_result(&self, outcome: HealthOutcome) { + let Ok(mut health) = self.health.lock() else { + return; + }; + let now = chrono::Utc::now().timestamp_millis(); + match outcome { + HealthOutcome::TransportSuccess => { + health.last_success_unix_ms = Some(now); + } + HealthOutcome::TransportFailure => { + health.last_failure_unix_ms = Some(now); + } + // A rejected/malformed application response proves a responder was + // reached but must not erase earlier transport failures. Operator + // cancellation says nothing about endpoint health. + HealthOutcome::ApplicationRejected | HealthOutcome::Cancelled => {} + } + } + + fn cancel(&self, request_id: &str) -> anyhow::Result { + let requests = self + .active_requests + .lock() + .map_err(|_| anyhow::anyhow!("Tally cancellation registry is unavailable"))?; + let Some(token) = requests.get(request_id) else { + return Ok(false); + }; + token.cancel(); + Ok(true) + } + + fn snapshot( + &self, + control: Option, + ) -> anyhow::Result { + let (last_success_unix_ms, fallback_last_failure_unix_ms) = { + let health = self + .health + .lock() + .map_err(|_| anyhow::anyhow!("Tally session health is unavailable"))?; + (health.last_success_unix_ms, health.last_failure_unix_ms) + }; + let mut active_request_ids = self + .active_requests + .lock() + .map_err(|_| anyhow::anyhow!("Tally cancellation registry is unavailable"))? + .keys() + .cloned() + .collect::>(); + active_request_ids.sort(); + let cached_capability_observed_at_unix_ms = self + .cached_probe + .read() + .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))? + .as_ref() + .map(|probe| probe.observed_at_unix_ms); + let ( + consecutive_failures, + circuit_state, + circuit_retry_after_unix_ms, + last_failure_unix_ms, + ) = match control { + Some(control) => ( + control.consecutive_failures, + match control.circuit_state { + EndpointCircuitState::Closed => CircuitState::Closed, + EndpointCircuitState::Open => CircuitState::Open, + EndpointCircuitState::HalfOpen => CircuitState::HalfOpen, + }, + control.circuit_retry_after_unix_ms, + control.last_failure_unix_ms, + ), + None => (0, CircuitState::Closed, None, fallback_last_failure_unix_ms), + }; + Ok(TallySessionSnapshot { + session_id: self.session_id.clone(), + canonical_endpoint: self.endpoint.as_str().to_string(), + issued_requests: self.sequence.load(Ordering::Relaxed), + active_requests: active_request_ids.len(), + active_request_ids, + consecutive_failures, + circuit_state, + circuit_retry_after_unix_ms, + last_success_unix_ms, + last_failure_unix_ms, + cached_capability_observed_at_unix_ms, + }) + } +} + +struct RuntimeRequest { + session: Arc, + request_id: String, + cancellation: CancellationToken, +} + +struct OrdinaryReadLease { + session: Arc, +} + +impl Drop for OrdinaryReadLease { + fn drop(&mut self) { + self.session + .active_ordinary_reads + .fetch_sub(1, Ordering::AcqRel); + } +} + +impl Drop for RuntimeRequest { + fn drop(&mut self) { + if let Ok(mut requests) = self.session.active_requests.lock() { + requests.remove(&self.request_id); + } + } +} + +struct SessionSlot { + session: Arc, + last_used: Instant, +} + +#[derive(Clone)] +pub struct TallyRuntime { + sessions: Arc>>, + runtime_identity: Arc<()>, + control: PortableReadRuntime, + #[cfg(test)] + transport_policy: Option, +} + +/// Opaque, owner-bound authority over one fresh reviewed probe. +/// +/// The lease keeps the endpoint session alive and releases the reservation on +/// every unwind/abort/early-return path unless it was explicitly consumed or +/// atomically replaced. Drop never touches a different or newer review. +pub struct CachedProbeReservation { + session: Arc, + runtime_identity: Arc<()>, + review_id: String, + observed_at_unix_ms: i64, + result: TallyProbeResult, + armed: bool, +} + +impl CachedProbeReservation { + pub fn observed_at_unix_ms(&self) -> i64 { + self.observed_at_unix_ms + } + + pub fn result(&self) -> &TallyProbeResult { + &self.result + } + + pub fn review_id(&self) -> &str { + &self.review_id + } + + fn authorize(&self, runtime: &TallyRuntime, config: &TallyConfig) -> anyhow::Result<()> { + if !self.armed + || !Arc::ptr_eq(&self.runtime_identity, &runtime.runtime_identity) + || self.session.endpoint != EndpointKey::from_config(config)? + { + anyhow::bail!("Tally reviewed setup operation ownership changed"); + } + if self + .session + .cached_probe + .read() + .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))? + .as_ref() + .is_some_and(|probe| probe.reserved && probe.review_id == self.review_id) + { + Ok(()) + } else { + anyhow::bail!("Tally reviewed setup operation ownership changed") + } + } + + pub fn release(&mut self) -> anyhow::Result { + self.finish(false) + } + + pub fn consume(&mut self) -> anyhow::Result { + self.finish(true) + } + + pub fn replace( + &mut self, + replacement_review_id: String, + observed_at_unix_ms: i64, + result: TallyProbeResult, + ) -> anyhow::Result { + if replacement_review_id.is_empty() + || replacement_review_id.len() > 128 + || replacement_review_id.chars().any(char::is_control) + { + anyhow::bail!("Tally replacement review ID is invalid"); + } + let mut cache = self + .session + .cached_probe + .write() + .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))?; + let Some(current) = cache.as_ref() else { + self.armed = false; + return Ok(false); + }; + if !self.armed || current.review_id != self.review_id || !current.reserved { + self.armed = false; + return Ok(false); + } + let freshness_origin_unix_ms = current.freshness_origin_unix_ms; + *cache = Some(CachedProbe { + review_id: replacement_review_id, + observed_at_unix_ms, + freshness_origin_unix_ms, + result, + reserved: false, + }); + self.armed = false; + Ok(true) + } + + fn finish(&mut self, consume: bool) -> anyhow::Result { + if !self.armed { + return Ok(false); + } + let mut cache = self + .session + .cached_probe + .write() + .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))?; + let is_reserved_match = cache + .as_ref() + .is_some_and(|probe| probe.review_id == self.review_id && probe.reserved); + if !is_reserved_match { + self.armed = false; + return Ok(false); + } + if consume { + cache.take(); + } else if let Some(probe) = cache.as_mut() { + probe.reserved = false; + } + self.armed = false; + Ok(true) + } +} + +impl Drop for CachedProbeReservation { + fn drop(&mut self) { + let _ = self.release(); + } +} + +impl Default for TallyRuntime { + fn default() -> Self { + Self { + sessions: Arc::new(Mutex::new(HashMap::new())), + runtime_identity: Arc::new(()), + control: PortableReadRuntime::default(), + #[cfg(test)] + transport_policy: None, + } + } +} + +impl TallyRuntime { + #[cfg(test)] + pub(crate) fn with_transport_policy(policy: bridge_tally_transport::TransportPolicy) -> Self { + Self { + transport_policy: Some(policy), + ..Self::default() + } + } + + fn session(&self, config: TallyConfig) -> anyhow::Result> { + let endpoint = EndpointKey::from_config(&config)?; + let mut sessions = self + .sessions + .lock() + .map_err(|_| anyhow::anyhow!("Tally runtime session registry is unavailable"))?; + if let Some(slot) = sessions.get_mut(&endpoint) { + slot.last_used = Instant::now(); + return Ok(Arc::clone(&slot.session)); + } + if sessions.len() >= MAX_ENDPOINT_SESSIONS { + let inactive_oldest = + sessions + .iter() + .filter(|(_, slot)| { + Arc::strong_count(&slot.session) == 1 + && slot.session.cached_probe.read().is_ok_and(|cache| { + !cache.as_ref().is_some_and(|probe| probe.reserved) + }) + }) + .min_by_key(|(_, slot)| slot.last_used) + .map(|(key, _)| key.clone()); + if let Some(key) = inactive_oldest { + sessions.remove(&key); + } else { + anyhow::bail!("Tally runtime endpoint-session limit is in use"); + } + } + #[cfg(test)] + let session = Arc::new(match self.transport_policy { + Some(policy) => TallySession::with_transport_policy(endpoint.clone(), config, policy)?, + None => TallySession::new(endpoint.clone(), config)?, + }); + #[cfg(not(test))] + let session = Arc::new(TallySession::new(endpoint.clone(), config)?); + sessions.insert( + endpoint, + SessionSlot { + session: Arc::clone(&session), + last_used: Instant::now(), + }, + ); + Ok(session) + } + + async fn execute( + &self, + config: TallyConfig, + operation_class: ReadOperation, + retry: ReadRetryPolicy, + operation: F, + ) -> anyhow::Result + where + F: FnMut(TallyClient) -> Fut, + Fut: Future>, + { + self.execute_cancellable(config, None, operation_class, retry, operation) + .await + } + + async fn execute_cancellable( + &self, + config: TallyConfig, + external_cancellation: Option, + operation_class: ReadOperation, + retry: ReadRetryPolicy, + mut operation: F, + ) -> anyhow::Result + where + F: FnMut(TallyClient) -> Fut, + Fut: Future>, + { + let session = self.session(config)?; + let request = session.begin_request()?; + let client = session.client.clone(); + let endpoint = EndpointIdentity::new(session.endpoint.as_str().to_string()) + .map_err(anyhow::Error::new)?; + let effective_cancellation = request.cancellation.child_token(); + let external_watcher = external_cancellation.map(|external| { + let effective_cancellation = effective_cancellation.clone(); + tokio::spawn(async move { + external.cancelled().await; + effective_cancellation.cancel(); + }) + }); + let result = self + .control + .execute_read( + endpoint, + operation_class, + retry, + effective_cancellation, + move |_| { + let attempt_client = client.clone(); + attempt_client.reset_observed_body_bytes(); + let future = operation(attempt_client.clone()); + async move { + let observed_body_bytes = || { + if operation_class == ReadOperation::Capability { + BodyBytesObservation::Unavailable + } else { + attempt_client + .observed_body_bytes() + .map(BodyBytesObservation::Observed) + .unwrap_or(BodyBytesObservation::Unavailable) + } + }; + match future.await { + Ok(value) => ReadAttempt::Success { + value, + observed_body_bytes: observed_body_bytes(), + }, + Err(error) => ReadAttempt::Failure { + class: classify_failure(&error), + error, + observed_body_bytes: observed_body_bytes(), + }, + } + } + }, + ) + .await; + if let Some(watcher) = external_watcher { + watcher.abort(); + } + let health_outcome = match &result { + Ok(_) => HealthOutcome::TransportSuccess, + Err(ReadExecutionError::Attempt(error)) => classify_error(error), + Err(ReadExecutionError::Cancelled) => HealthOutcome::Cancelled, + Err( + ReadExecutionError::QueueDeadline + | ReadExecutionError::CircuitRejected { .. } + | ReadExecutionError::EndpointSessionLimit, + ) => HealthOutcome::ApplicationRejected, + }; + session.record_result(health_outcome); + result.map_err(map_execution_error) + } + + pub async fn check_connection(&self, config: TallyConfig) -> anyhow::Result { + let _lease = self.begin_ordinary_read(&config)?; + self.execute( + config, + ReadOperation::Status, + ReadRetryPolicy::transient_default(), + |client| async move { client.check_connection_strict().await }, + ) + .await + } + + pub async fn probe_with_observation( + &self, + config: TallyConfig, + ) -> anyhow::Result<(String, i64, TallyProbeResult)> { + let _lease = self.begin_ordinary_read(&config)?; + let session = self.session(config.clone())?; + let result = self + .execute( + config, + ReadOperation::Capability, + ReadRetryPolicy::SINGLE_ATTEMPT, + |client| async move { client.probe().await }, + ) + .await?; + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + let review_id = uuid::Uuid::new_v4().to_string(); + let mut cache = session + .cached_probe + .write() + .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))?; + if cache.as_ref().is_some_and(|probe| probe.reserved) { + anyhow::bail!("Tally reviewed setup save is in progress"); + } + *cache = Some(CachedProbe { + review_id: review_id.clone(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: result.clone(), + reserved: false, + }); + Ok((review_id, observed_at_unix_ms, result)) + } + + /// Observe the endpoint for snapshot admission without creating or replacing + /// an interactive setup review. Snapshot start and end probes are lifecycle + /// evidence, not user-reviewed setup state, so they must remain uncached. + pub(crate) async fn snapshot_probe_with_observation( + &self, + config: TallyConfig, + ) -> anyhow::Result<(i64, TallyProbeResult)> { + let _lease = self.begin_ordinary_read(&config)?; + let result = self + .execute( + config, + ReadOperation::Capability, + ReadRetryPolicy::SINGLE_ATTEMPT, + |client| async move { client.probe().await }, + ) + .await?; + Ok((chrono::Utc::now().timestamp_millis(), result)) + } + + pub async fn probe(&self, config: TallyConfig) -> anyhow::Result { + self.probe_with_observation(config) + .await + .map(|(_, _, result)| result) + } + + pub async fn fetch_companies(&self, config: TallyConfig) -> anyhow::Result> { + let _lease = self.begin_ordinary_read(&config)?; + self.execute( + config, + ReadOperation::CompanyList, + ReadRetryPolicy::transient_default(), + |client| async move { client.fetch_companies().await }, + ) + .await + } + + pub async fn fetch_ledgers( + &self, + config: TallyConfig, + company: String, + expected_company_guid: String, + ) -> anyhow::Result> { + let _lease = self.begin_ordinary_read(&config)?; + self.execute( + config, + ReadOperation::MasterExport, + ReadRetryPolicy::transient_default(), + move |client| { + let company = company.clone(); + let expected_company_guid = expected_company_guid.clone(); + async move { client.fetch_ledgers(&company, &expected_company_guid).await } + }, + ) + .await + } + + pub async fn qualify_selected_ledgers( + &self, + config: TallyConfig, + reservation: &CachedProbeReservation, + company: String, + expected_company_guid: String, + ) -> anyhow::Result { + reservation.authorize(self, &config)?; + self.execute( + config, + ReadOperation::MasterExport, + ReadRetryPolicy::SINGLE_ATTEMPT, + move |client| { + let company = company.clone(); + let expected_company_guid = expected_company_guid.clone(); + async move { + client + .qualify_selected_ledgers(&company, &expected_company_guid) + .await + } + }, + ) + .await + } + + pub async fn fetch_vouchers( + &self, + config: TallyConfig, + company: String, + expected_company_guid: String, + from: String, + to: String, + ) -> anyhow::Result> { + let _lease = self.begin_ordinary_read(&config)?; + self.execute( + config, + ReadOperation::VoucherExport, + ReadRetryPolicy::transient_default(), + move |client| { + let company = company.clone(); + let expected_company_guid = expected_company_guid.clone(); + let from = from.clone(); + let to = to.clone(); + async move { + client + .fetch_vouchers(&company, &expected_company_guid, &from, &to) + .await + } + }, + ) + .await + } + + pub async fn qualify_selected_vouchers( + &self, + config: TallyConfig, + reservation: &CachedProbeReservation, + company: String, + expected_company_guid: String, + from: String, + to: String, + ) -> anyhow::Result { + reservation.authorize(self, &config)?; + self.execute( + config, + ReadOperation::VoucherExport, + ReadRetryPolicy::SINGLE_ATTEMPT, + move |client| { + let company = company.clone(); + let expected_company_guid = expected_company_guid.clone(); + let from = from.clone(); + let to = to.clone(); + async move { + client + .qualify_selected_vouchers(&company, &expected_company_guid, &from, &to) + .await + } + }, + ) + .await + } + + pub(super) async fn post_xml_cancellable_validated

( + &self, + config: TallyConfig, + request: SealedReadRequest, + cancellation: CancellationToken, + validate_application_response: P, + ) -> anyhow::Result + where + P: Fn(&str) -> bool + Send + Sync, + { + let _lease = self.begin_ordinary_read(&config)?; + let request_xml = request.into_xml(); + let validate_application_response = Arc::new(validate_application_response); + self.execute_cancellable( + config, + Some(cancellation), + ReadOperation::ReportExport, + ReadRetryPolicy::transient_default(), + move |client| { + let request_xml = request_xml.clone(); + let validate_application_response = Arc::clone(&validate_application_response); + async move { + let xml = client.post_xml(request_xml).await?; + if validate_application_response(&xml) { + Ok(xml) + } else { + Err(anyhow::Error::new( + TallyRuntimeReadError::ApplicationResponseRejected, + )) + } + } + }, + ) + .await + } + + pub fn cancel_request(&self, request_id: &str) -> anyhow::Result { + if request_id.is_empty() + || request_id.len() > 256 + || request_id.chars().any(char::is_control) + { + anyhow::bail!("Tally request ID is invalid"); + } + let sessions = self + .sessions + .lock() + .map_err(|_| anyhow::anyhow!("Tally runtime session registry is unavailable"))?; + for slot in sessions.values() { + if slot.session.cancel(request_id)? { + return Ok(true); + } + } + Ok(false) + } + + pub fn snapshots(&self) -> anyhow::Result> { + let sessions = self + .sessions + .lock() + .map_err(|_| anyhow::anyhow!("Tally runtime session registry is unavailable"))?; + let mut snapshots = sessions + .values() + .map(|slot| { + let endpoint = EndpointIdentity::new(slot.session.endpoint.as_str().to_string()) + .map_err(anyhow::Error::new)?; + slot.session + .snapshot(self.control.endpoint_snapshot(&endpoint)) + }) + .collect::>>()?; + snapshots.sort_by(|left, right| left.canonical_endpoint.cmp(&right.canonical_endpoint)); + Ok(snapshots) + } + + pub fn cached_probe(&self, config: &TallyConfig) -> anyhow::Result> { + let endpoint = EndpointKey::from_config(config)?; + let sessions = self + .sessions + .lock() + .map_err(|_| anyhow::anyhow!("Tally runtime session registry is unavailable"))?; + let Some(session) = sessions + .get(&endpoint) + .map(|slot| Arc::clone(&slot.session)) + else { + return Ok(None); + }; + let cached = session + .cached_probe + .read() + .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))? + .as_ref() + .map(|probe| probe.result.clone()); + Ok(cached) + } + + pub fn reserve_cached_probe_fresh( + &self, + config: &TallyConfig, + expected_review_id: &str, + max_age_ms: i64, + ) -> anyhow::Result> { + if !(1..=600_000).contains(&max_age_ms) { + anyhow::bail!("Tally capability cache freshness bound is invalid"); + } + let endpoint = EndpointKey::from_config(config)?; + let sessions = self + .sessions + .lock() + .map_err(|_| anyhow::anyhow!("Tally runtime session registry is unavailable"))?; + let Some(session) = sessions + .get(&endpoint) + .map(|slot| Arc::clone(&slot.session)) + else { + return Ok(None); + }; + let now = chrono::Utc::now().timestamp_millis(); + let mut cache = session + .cached_probe + .write() + .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))?; + if session.active_ordinary_reads.load(Ordering::Acquire) != 0 { + anyhow::bail!("Tally read operation is already in progress"); + } + let Some(probe) = cache.as_mut() else { + return Ok(None); + }; + if probe.review_id != expected_review_id + || probe.reserved + || probe.freshness_origin_unix_ms > now + || now.saturating_sub(probe.freshness_origin_unix_ms) > max_age_ms + { + return Ok(None); + } + probe.reserved = true; + let reservation = CachedProbeReservation { + session: Arc::clone(&session), + runtime_identity: Arc::clone(&self.runtime_identity), + review_id: probe.review_id.clone(), + observed_at_unix_ms: probe.observed_at_unix_ms, + result: probe.result.clone(), + armed: true, + }; + drop(cache); + Ok(Some(reservation)) + } + + pub fn telemetry_preview(&self) -> anyhow::Result { + let export = self.control.collector().privacy_reduced_export_v2()?; + Ok(TallyTelemetryPreviewExport { + schema: TELEMETRY_PREVIEW_SCHEMA, + payload_sha256: export.payload_sha256().to_string(), + preview_json: export.json().to_string(), + }) + } + + fn begin_ordinary_read(&self, config: &TallyConfig) -> anyhow::Result { + let endpoint = EndpointKey::from_config(config)?; + let sessions = self + .sessions + .lock() + .map_err(|_| anyhow::anyhow!("Tally runtime session registry is unavailable"))?; + let Some(session) = sessions + .get(&endpoint) + .map(|slot| Arc::clone(&slot.session)) + else { + drop(sessions); + let session = self.session(config.clone())?; + return self.begin_ordinary_read_for_session(session); + }; + drop(sessions); + self.begin_ordinary_read_for_session(session) + } + + fn begin_ordinary_read_for_session( + &self, + session: Arc, + ) -> anyhow::Result { + let cache = session + .cached_probe + .write() + .map_err(|_| anyhow::anyhow!("Tally capability cache is unavailable"))?; + if cache.as_ref().is_some_and(|probe| probe.reserved) { + anyhow::bail!("Tally reviewed setup operation is in progress"); + } + session + .active_ordinary_reads + .fetch_update(Ordering::AcqRel, Ordering::Acquire, |count| { + count.checked_add(1) + }) + .map_err(|_| anyhow::anyhow!("Tally read admission capacity is unavailable"))?; + drop(cache); + Ok(OrdinaryReadLease { session }) + } +} + +fn classify_error(error: &anyhow::Error) -> HealthOutcome { + match classify_failure(error) { + ReadFailureClass::Connection + | ReadFailureClass::RequestTimeout + | ReadFailureClass::RequestFailed + | ReadFailureClass::HttpServer + | ReadFailureClass::RateLimited => HealthOutcome::TransportFailure, + ReadFailureClass::HttpClient + | ReadFailureClass::SizeLimit + | ReadFailureClass::Decode + | ReadFailureClass::Application + | ReadFailureClass::Parse + | ReadFailureClass::Validation + | ReadFailureClass::CompanyMismatch => HealthOutcome::ApplicationRejected, + } +} + +fn classify_failure(error: &anyhow::Error) -> ReadFailureClass { + if matches!( + error.downcast_ref::(), + Some(TallyRuntimeReadError::ApplicationResponseRejected) + ) { + return ReadFailureClass::Application; + } + match error.downcast_ref::() { + Some(TallyTransportError::ConnectionFailed) => ReadFailureClass::Connection, + Some(TallyTransportError::RequestTimedOut) => ReadFailureClass::RequestTimeout, + Some( + TallyTransportError::RequestFailed + | TallyTransportError::ResponseTruncated + | TallyTransportError::ResponseReadFailed, + ) => ReadFailureClass::RequestFailed, + Some(TallyTransportError::HttpStatus { status: 429 }) => ReadFailureClass::RateLimited, + Some(TallyTransportError::HttpStatus { status }) if *status >= 500 => { + ReadFailureClass::HttpServer + } + Some(TallyTransportError::HttpStatus { .. }) => ReadFailureClass::HttpClient, + Some( + TallyTransportError::RequestTooLarge { .. } + | TallyTransportError::ResponseTooLarge { .. }, + ) => ReadFailureClass::SizeLimit, + Some( + TallyTransportError::UnsupportedContentEncoding + | TallyTransportError::InvalidEncoding { .. }, + ) => ReadFailureClass::Decode, + Some( + TallyTransportError::EndpointInvalid { .. } + | TallyTransportError::PolicyInvalid { .. } + | TallyTransportError::ClientInitializationFailed, + ) => ReadFailureClass::Validation, + None => ReadFailureClass::Validation, + } +} + +fn map_execution_error(error: ReadExecutionError) -> anyhow::Error { + match error { + ReadExecutionError::Attempt(error) => error, + ReadExecutionError::Cancelled => anyhow::Error::new(TallyRuntimeControlError::Cancelled), + ReadExecutionError::QueueDeadline => { + anyhow::Error::new(TallyRuntimeControlError::QueueDeadline) + } + ReadExecutionError::CircuitRejected { + reason: bridge_tally_runtime::CircuitRejectReason::Cooldown, + .. + } => anyhow::Error::new(TallyRuntimeControlError::CircuitCooldown), + ReadExecutionError::CircuitRejected { + reason: bridge_tally_runtime::CircuitRejectReason::HalfOpenProbeInFlight, + .. + } => anyhow::Error::new(TallyRuntimeControlError::HalfOpenProbeInFlight), + ReadExecutionError::EndpointSessionLimit => { + anyhow::Error::new(TallyRuntimeControlError::EndpointSessionCapacity) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tally::TallyProduct; + use bridge_tally_core::CapabilityProfile; + use std::collections::BTreeMap; + + fn synthetic_probe_result() -> TallyProbeResult { + TallyProbeResult { + connection: ConnectionStatus { + reachable: true, + compatible: false, + server_text: "Synthetic status".to_string(), + product: TallyProduct::Unknown, + error: None, + }, + companies: vec![TallyCompany { + name: "Synthetic Company".to_string(), + guid: Some("synthetic-guid".to_string()), + }], + profile: CapabilityProfile { + profile_version: 2, + product: "Unknown".to_string(), + release: None, + mode: None, + transports: BTreeMap::new(), + features: BTreeMap::new(), + packs: BTreeMap::new(), + }, + selected_read_scope: None, + passport_snapshot_id: None, + } + } + + #[test] + fn local_rejections_and_client_http_statuses_do_not_poison_endpoint_health() { + assert_eq!( + classify_failure(&anyhow::Error::new( + TallyRuntimeReadError::ApplicationResponseRejected, + )), + ReadFailureClass::Application + ); + for error in [ + TallyTransportError::RequestTooLarge { limit: 1024 }, + TallyTransportError::PolicyInvalid { code: "test" }, + TallyTransportError::EndpointInvalid { code: "test" }, + TallyTransportError::ClientInitializationFailed, + TallyTransportError::HttpStatus { status: 400 }, + ] { + assert_eq!( + classify_error(&anyhow::Error::new(error)), + HealthOutcome::ApplicationRejected + ); + } + for error in [ + TallyTransportError::ConnectionFailed, + TallyTransportError::RequestTimedOut, + TallyTransportError::HttpStatus { status: 503 }, + ] { + assert_eq!( + classify_error(&anyhow::Error::new(error)), + HealthOutcome::TransportFailure + ); + } + } + + #[test] + fn endpoint_identity_aliases_only_localhost_to_ipv4_loopback() { + let runtime = TallyRuntime::default(); + let first = runtime + .session(TallyConfig { + host: "localhost".to_string(), + port: 9000, + }) + .expect("localhost session"); + let second = runtime + .session(TallyConfig { + host: "127.0.0.1".to_string(), + port: 9000, + }) + .expect("IPv4 loopback session"); + let third = runtime + .session(TallyConfig { + host: "::1".to_string(), + port: 9000, + }) + .expect("IPv6 loopback session"); + let fourth = runtime + .session(TallyConfig { + host: "127.0.0.2".to_string(), + port: 9000, + }) + .expect("alternate IPv4 loopback session"); + assert!(Arc::ptr_eq(&first, &second)); + assert!(!Arc::ptr_eq(&first, &third)); + assert!(!Arc::ptr_eq(&first, &fourth)); + assert!(!Arc::ptr_eq(&third, &fourth)); + let snapshots = runtime.snapshots().expect("runtime snapshots"); + assert_eq!(snapshots.len(), 3); + assert_eq!(snapshots[0].canonical_endpoint, "http://127.0.0.1:9000"); + assert_eq!(snapshots[1].canonical_endpoint, "http://127.0.0.2:9000"); + assert_eq!(snapshots[2].canonical_endpoint, "http://[::1]:9000"); + } + + #[test] + fn reviewed_probe_cache_preserves_observation_time_and_is_single_use() { + let runtime = TallyRuntime::default(); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9000, + }; + let session = runtime.session(config.clone()).expect("runtime session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + let review_id = "review-current"; + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: review_id.to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + + let mut reservation = runtime + .reserve_cached_probe_fresh(&config, review_id, 300_000) + .expect("reserve cache") + .expect("fresh reviewed probe"); + assert_eq!(reservation.observed_at_unix_ms(), observed_at_unix_ms); + assert_eq!(reservation.result().companies[0].name, "Synthetic Company"); + assert!(runtime + .reserve_cached_probe_fresh(&config, review_id, 300_000) + .expect("second reserve") + .is_none()); + assert!(reservation.consume().expect("consume reservation")); + assert!(!reservation + .consume() + .expect("consuming an already consumed lease is inert")); + assert!(runtime + .reserve_cached_probe_fresh(&config, review_id, 300_000) + .expect("reserve consumed cache") + .is_none()); + } + + #[test] + fn stale_review_id_cannot_consume_or_reserve_a_newer_probe() { + let runtime = TallyRuntime::default(); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9002, + }; + let session = runtime.session(config.clone()).expect("runtime session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-b".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-a", 300_000) + .expect("reject stale review") + .is_none()); + let mut reservation = runtime + .reserve_cached_probe_fresh(&config, "review-b", 300_000) + .expect("reserve current review") + .expect("current review exists"); + assert!(reservation.release().expect("release current review")); + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-b", 300_000) + .expect("retry current review") + .is_some()); + } + + #[test] + fn reviewed_probe_cache_rejects_future_expired_and_invalid_freshness() { + let runtime = TallyRuntime::default(); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9001, + }; + let session = runtime.session(config.clone()).expect("runtime session"); + for observed_at_unix_ms in [ + chrono::Utc::now().timestamp_millis() + 1_000, + chrono::Utc::now().timestamp_millis() - 301_000, + ] { + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-expiry".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-expiry", 300_000) + .expect("reserve cache") + .is_none()); + } + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-expiry", 0) + .is_err()); + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-expiry", 600_001) + .is_err()); + } + + #[test] + fn replacing_a_qualified_review_does_not_renew_its_freshness_origin() { + let runtime = TallyRuntime::default(); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9003, + }; + let session = runtime.session(config.clone()).expect("runtime session"); + let freshness_origin_unix_ms = chrono::Utc::now().timestamp_millis() - 299_000; + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-original".to_string(), + observed_at_unix_ms: freshness_origin_unix_ms, + freshness_origin_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + let mut reservation = runtime + .reserve_cached_probe_fresh(&config, "review-original", 300_000) + .expect("reserve original") + .expect("original remains barely fresh"); + assert!(reservation + .replace( + "review-qualified".to_string(), + chrono::Utc::now().timestamp_millis(), + synthetic_probe_result(), + ) + .expect("replace reservation")); + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-qualified", 298_000) + .expect("check inherited freshness") + .is_none()); + } + + #[test] + fn ordinary_read_admission_and_review_reservation_are_mutually_exclusive() { + let runtime = TallyRuntime::default(); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9004, + }; + let session = runtime.session(config.clone()).expect("runtime session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-lease".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + + let read_lease = runtime + .begin_ordinary_read(&config) + .expect("admit ordinary read"); + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-lease", 300_000) + .is_err()); + drop(read_lease); + + let reservation = runtime + .reserve_cached_probe_fresh(&config, "review-lease", 300_000) + .expect("reserve after read") + .expect("fresh review"); + assert!(runtime.begin_ordinary_read(&config).is_err()); + assert!(reservation.authorize(&runtime, &config).is_ok()); + assert!(reservation + .authorize( + &runtime, + &TallyConfig { + host: "127.0.0.2".to_string(), + port: 9004, + }, + ) + .is_err()); + } + + #[tokio::test] + async fn qualification_rejects_a_reservation_from_another_runtime_before_dispatch() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind cross-runtime qualification server"); + let address = listener.local_addr().expect("qualification server address"); + let config = TallyConfig { + host: address.ip().to_string(), + port: address.port(), + }; + let owner_runtime = TallyRuntime::default(); + let executing_runtime = TallyRuntime::default(); + let session = owner_runtime + .session(config.clone()) + .expect("owner runtime session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-cross-runtime".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + drop(session); + let reservation = owner_runtime + .reserve_cached_probe_fresh(&config, "review-cross-runtime", 300_000) + .expect("reserve owner review") + .expect("fresh owner review"); + + let error = executing_runtime + .qualify_selected_ledgers( + config, + &reservation, + "Synthetic Company".to_string(), + "synthetic-guid".to_string(), + ) + .await + .expect_err("another runtime must not borrow the reservation"); + assert!(error + .to_string() + .contains("reviewed setup operation ownership changed")); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), listener.accept(),) + .await + .is_err() + ); + } + + #[test] + fn dropping_a_review_reservation_restores_the_same_fresh_review() { + let runtime = TallyRuntime::default(); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9005, + }; + let session = runtime.session(config.clone()).expect("runtime session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-drop".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + drop( + runtime + .reserve_cached_probe_fresh(&config, "review-drop", 300_000) + .expect("reserve review") + .expect("fresh review"), + ); + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-drop", 300_000) + .expect("reserve after drop") + .is_some()); + } + + #[tokio::test] + async fn aborting_a_task_drops_and_releases_its_review_reservation() { + let runtime = Arc::new(TallyRuntime::default()); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9006, + }; + let session = runtime.session(config.clone()).expect("runtime session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-abort".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + let (held_tx, held_rx) = tokio::sync::oneshot::channel(); + let task_runtime = Arc::clone(&runtime); + let task_config = config.clone(); + let task = tokio::spawn(async move { + let _reservation = task_runtime + .reserve_cached_probe_fresh(&task_config, "review-abort", 300_000) + .expect("reserve review") + .expect("fresh review"); + held_tx.send(()).expect("announce held reservation"); + std::future::pending::<()>().await; + }); + held_rx.await.expect("reservation was held"); + task.abort(); + let _ = task.await; + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-abort", 300_000) + .expect("reserve after abort") + .is_some()); + } + + #[tokio::test] + async fn aborting_pending_qualification_releases_review_and_active_request() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind pending qualification server"); + let address = listener.local_addr().expect("pending server address"); + let (accepted_tx, accepted_rx) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + let (_socket, _) = listener.accept().await.expect("accept qualification"); + accepted_tx.send(()).expect("announce accepted request"); + std::future::pending::<()>().await; + }); + let runtime = Arc::new(TallyRuntime::default()); + let config = TallyConfig { + host: address.ip().to_string(), + port: address.port(), + }; + let session = runtime.session(config.clone()).expect("runtime session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-pending".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + drop(session); + let task_runtime = Arc::clone(&runtime); + let task_config = config.clone(); + let task = tokio::spawn(async move { + let reservation = task_runtime + .reserve_cached_probe_fresh(&task_config, "review-pending", 300_000) + .expect("reserve pending review") + .expect("fresh pending review"); + let _ = task_runtime + .qualify_selected_ledgers( + task_config, + &reservation, + "Synthetic Company".to_string(), + "synthetic-guid".to_string(), + ) + .await; + }); + accepted_rx.await.expect("qualification reached server"); + task.abort(); + let _ = task.await; + server.abort(); + let snapshots = runtime.snapshots().expect("runtime snapshots after abort"); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].active_requests, 0); + assert!(snapshots[0].active_request_ids.is_empty()); + assert!(runtime + .reserve_cached_probe_fresh(&config, "review-pending", 300_000) + .expect("reserve after pending abort") + .is_some()); + } + + #[test] + fn stale_guard_cannot_release_or_consume_a_newer_reserved_review() { + let runtime = TallyRuntime::default(); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9007, + }; + let session = runtime.session(config.clone()).expect("runtime session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-old".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + let mut stale = runtime + .reserve_cached_probe_fresh(&config, "review-old", 300_000) + .expect("reserve old") + .expect("old review"); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-new".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: true, + }); + assert!(!stale.consume().expect("stale consume is inert")); + drop(stale); + let cache = session.cached_probe.read().expect("capability cache"); + let current = cache.as_ref().expect("new review remains"); + assert_eq!(current.review_id, "review-new"); + assert!(current.reserved); + } + + #[test] + fn stale_guard_cannot_replace_a_newer_reserved_review() { + let runtime = TallyRuntime::default(); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9008, + }; + let session = runtime.session(config.clone()).expect("runtime session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-old".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + let mut stale = runtime + .reserve_cached_probe_fresh(&config, "review-old", 300_000) + .expect("reserve old") + .expect("old review"); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-new".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: true, + }); + + assert!(!stale + .replace( + "review-illegal-replacement".to_string(), + observed_at_unix_ms, + synthetic_probe_result(), + ) + .expect("stale replace is inert")); + drop(stale); + let cache = session.cached_probe.read().expect("capability cache"); + let current = cache.as_ref().expect("new review remains"); + assert_eq!(current.review_id, "review-new"); + assert!(current.reserved); + } + + #[test] + fn held_review_reservation_prevents_endpoint_session_eviction() { + let runtime = TallyRuntime::default(); + let reserved_config = TallyConfig { + host: "127.0.0.1".to_string(), + port: 9200, + }; + let reserved_endpoint = EndpointKey::from_config(&reserved_config).unwrap(); + let session = runtime + .session(reserved_config.clone()) + .expect("reserved session"); + let observed_at_unix_ms = chrono::Utc::now().timestamp_millis(); + *session.cached_probe.write().expect("capability cache") = Some(CachedProbe { + review_id: "review-capacity".to_string(), + observed_at_unix_ms, + freshness_origin_unix_ms: observed_at_unix_ms, + result: synthetic_probe_result(), + reserved: false, + }); + drop(session); + let _reservation = runtime + .reserve_cached_probe_fresh(&reserved_config, "review-capacity", 300_000) + .expect("reserve capacity review") + .expect("fresh capacity review"); + for host_suffix in 2..=MAX_ENDPOINT_SESSIONS { + runtime + .session(TallyConfig { + host: format!("127.0.0.{host_suffix}"), + port: 9200, + }) + .expect("fill endpoint capacity"); + } + runtime + .session(TallyConfig { + host: "127.0.0.254".to_string(), + port: 9200, + }) + .expect("evict one unreserved session"); + assert!(runtime + .sessions + .lock() + .expect("session registry") + .contains_key(&reserved_endpoint)); + } + + #[tokio::test] + async fn cancellation_registry_cancels_and_releases_requests() { + let runtime = Arc::new(TallyRuntime::default()); + let config = TallyConfig { + host: "localhost".to_string(), + port: 9100, + }; + let runtime_task = Arc::clone(&runtime); + let task = tokio::spawn(async move { + runtime_task + .execute( + config, + ReadOperation::OtherRead, + ReadRetryPolicy::SINGLE_ATTEMPT, + |_client| async { + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + Ok::<_, anyhow::Error>(()) + }, + ) + .await + }); + tokio::task::yield_now().await; + let snapshot = runtime + .snapshots() + .expect("runtime snapshots") + .pop() + .expect("active session"); + assert_eq!(snapshot.active_requests, 1); + let session = runtime + .sessions + .lock() + .expect("sessions lock") + .values() + .next() + .expect("session") + .session + .clone(); + let request_id = session + .active_requests + .lock() + .expect("request lock") + .keys() + .next() + .expect("request ID") + .clone(); + assert!(runtime.cancel_request(&request_id).expect("cancel request")); + assert!(task.await.expect("request task").is_err()); + assert_eq!( + runtime.snapshots().expect("runtime snapshots")[0].active_requests, + 0 + ); + assert_eq!( + runtime.snapshots().expect("runtime snapshots")[0].consecutive_failures, + 0, + "operator cancellation must not degrade endpoint health" + ); + } + + #[test] + fn telemetry_preview_is_privacy_reduced_and_checksummed() { + let preview = TallyRuntime::default() + .telemetry_preview() + .expect("telemetry preview"); + assert_eq!(preview.schema, "bridge.tally.telemetry-preview/2"); + assert_eq!(preview.payload_sha256.len(), 64); + let preview_value: serde_json::Value = + serde_json::from_str(&preview.preview_json).expect("valid preview JSON"); + assert_eq!( + preview_value["privacy_profile"], + "fixed_dimensions_bucketed_values_v1" + ); + assert_eq!(preview_value["authenticity_claim"], "none"); + } +} diff --git a/src-tauri/src/tally/serial_queue.rs b/src-tauri/src/tally/serial_queue.rs index 8a90b5b..d226435 100644 --- a/src-tauri/src/tally/serial_queue.rs +++ b/src-tauri/src/tally/serial_queue.rs @@ -1,30 +1,258 @@ -use std::{future::Future, sync::Arc, time::Duration}; -use tokio::sync::Mutex; +use std::{ + collections::HashMap, + future::Future, + sync::{Arc, Mutex as StdMutex, OnceLock, Weak}, + time::{Duration, Instant}, +}; +use tokio::sync::{Mutex, MutexGuard}; + +#[derive(Default)] +struct GateState { + next_request_not_before: Option, +} + +type Gate = Mutex; + +struct RequestSpacingGuard<'a> { + state: MutexGuard<'a, GateState>, + spacing: Duration, +} + +impl Drop for RequestSpacingGuard<'_> { + fn drop(&mut self) { + self.state.next_request_not_before = Some(Instant::now() + self.spacing); + } +} + +fn endpoint_gates() -> &'static StdMutex>> { + static GATES: OnceLock>>> = OnceLock::new(); + GATES.get_or_init(|| StdMutex::new(HashMap::new())) +} #[derive(Clone)] pub struct SerialTallyQueue { - gate: Arc>, + gate: Arc, spacing: Duration, + queue_deadline: Duration, } impl Default for SerialTallyQueue { fn default() -> Self { Self { - gate: Arc::new(Mutex::new(())), + gate: Arc::new(Mutex::new(GateState::default())), spacing: Duration::from_millis(500), + queue_deadline: Duration::from_secs(30), } } } impl SerialTallyQueue { + pub fn for_endpoint(endpoint_key: impl Into) -> Self { + let endpoint_key = endpoint_key.into(); + let mut gates = endpoint_gates() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + gates.retain(|_, gate| gate.strong_count() > 0); + let gate = gates + .get(&endpoint_key) + .and_then(Weak::upgrade) + .unwrap_or_else(|| { + let gate = Arc::new(Mutex::new(GateState::default())); + gates.insert(endpoint_key, Arc::downgrade(&gate)); + gate + }); + Self { + gate, + spacing: Duration::from_millis(500), + queue_deadline: Duration::from_secs(30), + } + } + + #[cfg(test)] + fn for_endpoint_with_timing( + endpoint_key: impl Into, + spacing: Duration, + queue_deadline: Duration, + ) -> Self { + let mut queue = Self::for_endpoint(endpoint_key); + queue.spacing = spacing; + queue.queue_deadline = queue_deadline; + queue + } + pub async fn run(&self, request: F) -> anyhow::Result where F: FnOnce() -> Fut, Fut: Future>, { - let _guard = self.gate.lock().await; + let queued_at = Instant::now(); + let state = tokio::time::timeout(self.queue_deadline, self.gate.lock()) + .await + .map_err(|_| anyhow::anyhow!("Tally endpoint queue deadline exceeded"))?; + if let Some(wait) = state + .next_request_not_before + .and_then(|not_before| not_before.checked_duration_since(Instant::now())) + { + let elapsed = queued_at.elapsed(); + let remaining = self + .queue_deadline + .checked_sub(elapsed) + .ok_or_else(|| anyhow::anyhow!("Tally endpoint queue deadline exceeded"))?; + tokio::time::timeout(remaining, tokio::time::sleep(wait)) + .await + .map_err(|_| anyhow::anyhow!("Tally endpoint queue deadline exceeded"))?; + } + let _guard = RequestSpacingGuard { + state, + spacing: self.spacing, + }; let result = request().await; - tokio::time::sleep(self.spacing).await; result } } + +#[cfg(test)] +mod tests { + use super::SerialTallyQueue; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use std::time::{Duration, Instant}; + + #[tokio::test] + async fn independently_created_queues_serialize_the_same_endpoint() { + let first = SerialTallyQueue::for_endpoint("127.0.0.1:19000"); + let second = SerialTallyQueue::for_endpoint("127.0.0.1:19000"); + let in_flight = Arc::new(AtomicUsize::new(0)); + let max_in_flight = Arc::new(AtomicUsize::new(0)); + + let run = |queue: SerialTallyQueue, + in_flight: Arc, + max_in_flight: Arc| async move { + queue + .run(|| async move { + let current = in_flight.fetch_add(1, Ordering::SeqCst) + 1; + max_in_flight.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(25)).await; + in_flight.fetch_sub(1, Ordering::SeqCst); + Ok(()) + }) + .await + }; + + let (left, right) = tokio::join!( + run(first, Arc::clone(&in_flight), Arc::clone(&max_in_flight)), + run(second, in_flight, Arc::clone(&max_in_flight)) + ); + left.expect("first queue run"); + right.expect("second queue run"); + assert_eq!(max_in_flight.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn different_endpoints_do_not_share_a_gate() { + let first = SerialTallyQueue::for_endpoint("127.0.0.1:19001"); + let second = SerialTallyQueue::for_endpoint("127.0.0.1:19002"); + let in_flight = Arc::new(AtomicUsize::new(0)); + let max_in_flight = Arc::new(AtomicUsize::new(0)); + + let run = |queue: SerialTallyQueue, + in_flight: Arc, + max_in_flight: Arc| async move { + queue + .run(|| async move { + let current = in_flight.fetch_add(1, Ordering::SeqCst) + 1; + max_in_flight.fetch_max(current, Ordering::SeqCst); + tokio::time::sleep(Duration::from_millis(25)).await; + in_flight.fetch_sub(1, Ordering::SeqCst); + Ok(()) + }) + .await + }; + + let (left, right) = tokio::join!( + run(first, Arc::clone(&in_flight), Arc::clone(&max_in_flight)), + run(second, in_flight, Arc::clone(&max_in_flight)) + ); + left.expect("first queue run"); + right.expect("second queue run"); + assert_eq!(max_in_flight.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn queue_wait_has_a_bounded_deadline() { + let first = SerialTallyQueue::for_endpoint_with_timing( + "127.0.0.1:19003", + Duration::ZERO, + Duration::from_secs(1), + ); + let second = SerialTallyQueue::for_endpoint_with_timing( + "127.0.0.1:19003", + Duration::ZERO, + Duration::from_millis(20), + ); + let (acquired_tx, acquired_rx) = tokio::sync::oneshot::channel(); + let first_run = tokio::spawn(async move { + first + .run(|| async move { + let _ = acquired_tx.send(()); + tokio::time::sleep(Duration::from_millis(100)).await; + Ok(()) + }) + .await + }); + acquired_rx + .await + .expect("first request acquired queue gate"); + let error = second + .run(|| async { Ok(()) }) + .await + .expect_err("second request should not wait indefinitely"); + assert!(error.to_string().contains("queue deadline")); + first_run + .await + .expect("first queue task") + .expect("first queue run"); + } + + #[tokio::test] + async fn cancelling_an_in_flight_request_preserves_endpoint_spacing() { + let spacing = Duration::from_millis(80); + let first = SerialTallyQueue::for_endpoint_with_timing( + "127.0.0.1:19004", + spacing, + Duration::from_secs(1), + ); + let second = SerialTallyQueue::for_endpoint_with_timing( + "127.0.0.1:19004", + spacing, + Duration::from_secs(1), + ); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let first_run = tokio::spawn(async move { + first + .run(|| async move { + let _ = started_tx.send(()); + std::future::pending::>().await + }) + .await + }); + started_rx.await.expect("first request started"); + first_run.abort(); + assert!(first_run + .await + .expect_err("first request aborted") + .is_cancelled()); + + let cancelled_at = Instant::now(); + second + .run(|| async { Ok(()) }) + .await + .expect("follow-up request"); + assert!( + cancelled_at.elapsed() >= spacing.saturating_sub(Duration::from_millis(10)), + "follow-up must observe the cancellation quarantine" + ); + } +} diff --git a/src-tauri/src/tally/tdl_engine.rs b/src-tauri/src/tally/tdl_engine.rs index 1d9ffbd..aca48f0 100644 --- a/src-tauri/src/tally/tdl_engine.rs +++ b/src-tauri/src/tally/tdl_engine.rs @@ -1,4 +1,9 @@ pub fn company_list_request() -> String { + bridge_tally_protocol::xml_read_profiles::compatibility::company_list_request() +} + +#[cfg(test)] +fn legacy_company_list_request() -> String { r#"

@@ -31,37 +36,22 @@ pub fn company_list_request() -> String { - Company Name Header, State Header, Phone Header, Email Header, - MOBILENUMBERS Header, Pincode Header, GSTNUMBER Header + Company Name Header, Company GUID Header "Company Name" - "State" - "Phone" - "Email" - "MOBILENUMBERS" - "Pincode" - "GST Number" + "Company GUID" - Company Name Field, State Field, Phone Field, Email Field, - MOBILENUMBERS Field, Pincode Field, GSTNUMBER Field + Company Name Field, Company GUID Field "CompanyInfo" $Name - $StateName - $PhoneNumber - $Email - $MOBILENUMBERS - $Pincode - $GSTRegNumber + $GUID Company - - Name, StateName, PhoneNumber, Email, MOBILENUMBERS, - Pincode, GSTRegNumber - + Name, GUID @@ -114,14 +104,19 @@ pub fn sales_vouchers_request(company: &str, from: &str, to: &str) -> String { } pub fn ledgers_request(company: &str) -> String { + bridge_tally_protocol::xml_read_profiles::compatibility::ledgers_request(company) +} + +#[cfg(test)] +fn legacy_ledgers_request(company: &str) -> String { format!( r#"
1 EXPORT - COLLECTION - Ledgers + DATA + BRIDGE Ledger Export V1
@@ -131,9 +126,238 @@ pub fn ledgers_request(company: &str) -> String { - + + BRIDGE Ledger Export Form V1 + Yes + +
+ BRIDGE Ledger Context Part V1, BRIDGE Ledger Rows Part V1 +
+ + BRIDGE Ledger Context Line V1 + + + BRIDGE Ledger Row Line V1 + BRIDGE Ledger Row Line V1 : BRIDGE Ledger Collection V1 + + + BRIDGE Ledger Schema V1, BRIDGE Ledger Object Type V1, BRIDGE Ledger Company Name V1, BRIDGE Ledger Company GUID V1, BRIDGE Ledger Record Count V1 + "COMPANYCONTEXT" + + + "bridge.tally.ledgers/1" + "SCHEMA" + + + "LEDGER" + "OBJECTTYPE" + + + ##SVCurrentCompany + "NAME" + + + $GUID:Company:##SVCurrentCompany + "GUID" + + + $$NumItems:BRIDGE Ledger Collection V1 + "RECORDCOUNT" + + + BRIDGE Ledger Parent V1, BRIDGE Ledger GSTIN V1, BRIDGE Ledger Opening Balance V1 + "LEDGER" + "NAME" : $Name + "GUID" : $GUID + "REMOTEID" : $RemoteID + "MASTERID" : $MasterID + "ALTERID" : $AlterID + + + $Parent + "PARENT" + + + $PartyGSTIN + "PARTYGSTIN" + + + $OpeningBalance + "OPENINGBALANCE" + + Ledger - Name, Parent, PartyGSTIN, OpeningBalance + Name, GUID, RemoteID, MasterID, AlterID, Parent, PartyGSTIN, OpeningBalance + +
+
+
+ +
+"#, + xml_escape(company) + ) + .trim() + .to_string() +} + +pub fn groups_request(company: &str) -> String { + format!( + r#" + +
+ 1 + EXPORT + DATA + BRIDGE Group Export V1 +
+ + + + $$SysName:XML + {} + + + + + BRIDGE Group Export Form V1 + Yes + +
+ BRIDGE Group Context Part V1, BRIDGE Group Rows Part V1 +
+ + BRIDGE Group Context Line V1 + + + BRIDGE Group Row Line V1 + BRIDGE Group Row Line V1 : BRIDGE Group Collection V1 + + + BRIDGE Group Schema V1, BRIDGE Group Object Type V1, BRIDGE Group Company Name V1, BRIDGE Group Company GUID V1, BRIDGE Group Record Count V1 + "COMPANYCONTEXT" + + + "bridge.tally.groups/1" + "SCHEMA" + + + "GROUP" + "OBJECTTYPE" + + + ##SVCurrentCompany + "NAME" + + + $GUID:Company:##SVCurrentCompany + "GUID" + + + $$NumItems:BRIDGE Group Collection V1 + "RECORDCOUNT" + + + BRIDGE Group Parent V1 + "GROUP" + "NAME" : $Name + "GUID" : $GUID + "REMOTEID" : $RemoteID + "MASTERID" : $MasterID + "ALTERID" : $AlterID + + + $Parent + "PARENT" + + + Group + Name, GUID, RemoteID, MasterID, AlterID, Parent + +
+
+
+ +
+"#, + xml_escape(company) + ) + .trim() + .to_string() +} + +pub fn voucher_types_request(company: &str) -> String { + format!( + r#" + +
+ 1 + EXPORT + DATA + BRIDGE Voucher Type Export V1 +
+ + + + $$SysName:XML + {} + + + + + BRIDGE Voucher Type Export Form V1 + Yes + +
+ BRIDGE Voucher Type Context Part V1, BRIDGE Voucher Type Rows Part V1 +
+ + BRIDGE Voucher Type Context Line V1 + + + BRIDGE Voucher Type Row Line V1 + BRIDGE Voucher Type Row Line V1 : BRIDGE Voucher Type Collection V1 + + + BRIDGE Voucher Type Schema V1, BRIDGE Voucher Type Object Type V1, BRIDGE Voucher Type Company Name V1, BRIDGE Voucher Type Company GUID V1, BRIDGE Voucher Type Record Count V1 + "COMPANYCONTEXT" + + + "bridge.tally.voucher-types/1" + "SCHEMA" + + + "VOUCHERTYPE" + "OBJECTTYPE" + + + ##SVCurrentCompany + "NAME" + + + $GUID:Company:##SVCurrentCompany + "GUID" + + + $$NumItems:BRIDGE Voucher Type Collection V1 + "RECORDCOUNT" + + + BRIDGE Voucher Type Parent V1 + "VOUCHERTYPE" + "NAME" : $Name + "GUID" : $GUID + "REMOTEID" : $RemoteID + "MASTERID" : $MasterID + "ALTERID" : $AlterID + + + $Parent + "PARENT" + + + VoucherType + Name, GUID, RemoteID, MasterID, AlterID, Parent
@@ -148,28 +372,139 @@ pub fn ledgers_request(company: &str) -> String { } pub fn vouchers_request(company: &str, from: &str, to: &str) -> String { + bridge_tally_protocol::xml_read_profiles::compatibility::vouchers_request(company, from, to) +} + +pub fn selected_vouchers_request(company: &str, from: &str, to: &str) -> String { + bridge_tally_protocol::xml_read_profiles::compatibility::selected_vouchers_request( + company, from, to, + ) +} + +#[cfg(test)] +fn legacy_vouchers_request(company: &str, from: &str, to: &str) -> String { format!( r#"
1 EXPORT - COLLECTION - Vouchers + DATA + BRIDGE Voucher Export V2
{} - {} - {} + {} + {} $$SysName:XML - + + BRIDGE Voucher Export Form V2 + Yes + +
+ BRIDGE Voucher Context Part V1, BRIDGE Voucher Rows Part V1 +
+ + BRIDGE Voucher Context Line V1 + + + BRIDGE Voucher Row Line V1 + BRIDGE Voucher Row Line V1 : BRIDGE Voucher Collection V1 + + + BRIDGE Voucher Schema V1, BRIDGE Voucher Object Type V1, BRIDGE Voucher Company Name V1, BRIDGE Voucher Company GUID V1, BRIDGE Voucher Record Count V1 + "COMPANYCONTEXT" + + + "bridge.tally.vouchers/2" + "SCHEMA" + + + "VOUCHER" + "OBJECTTYPE" + + + ##SVCurrentCompany + "NAME" + + + $GUID:Company:##SVCurrentCompany + "GUID" + + + $$NumItems:BRIDGE Voucher Collection V1 + "RECORDCOUNT" + + + BRIDGE Voucher Date V1, BRIDGE Voucher Type V1, BRIDGE Voucher Number V1, BRIDGE Voucher Cancelled V1, BRIDGE Voucher Optional V2, BRIDGE Voucher Ledger Entry Count V1 + "VOUCHER" + "REMOTEID" : $RemoteID + "GUID" : $GUID + "MASTERID" : $MasterID + "ALTERID" : $AlterID + BRIDGE Voucher Ledger Entries Part V1 : Yes + + + $Date + "DATE" + + + $VoucherTypeName + "VOUCHERTYPENAME" + + + $VoucherNumber + "VOUCHERNUMBER" + + + $IsCancelled + "ISCANCELLED" + + + $IsOptional + Logical + "ISOPTIONAL" + + + $$NumItems:AllLedgerEntries + "LEDGERENTRYCOUNT" + + + BRIDGE Voucher Ledger Entry Row V1 + BRIDGE Voucher Ledger Entry Row V1 : AllLedgerEntries + "LEDGERENTRIES" + + + BRIDGE Voucher Ledger Entry Index V1, BRIDGE Voucher Ledger Entry Name V1, BRIDGE Voucher Ledger Entry Amount V1, BRIDGE Voucher Ledger Entry Deemed Positive V1 + "LEDGERENTRY" + + + $$Line + "ENTRYINDEX" + + + $LedgerName + "LEDGERNAME" + + + $Amount + Amount + "No Symbol, No Comma" + "AMOUNT" + + + $IsDeemedPositive + Logical + "ISDEEMEDPOSITIVE" + + Voucher - Date, VoucherTypeName, VoucherNumber, PartyLedgerName + RemoteID, GUID, MasterID, AlterID, Date, VoucherTypeName, VoucherNumber, IsCancelled, AllLedgerEntries.*
@@ -185,6 +520,84 @@ pub fn vouchers_request(company: &str, from: &str, to: &str) -> String { .to_string() } +/// Experimental Bridge-defined ledger-balance cross-view. The request emits no +/// ledger names: rows are joined to the canonical mirror by candidate native +/// identifiers. Exact semantics and applicability remain capability-gated. +pub fn ledger_period_balances_request(company: &str, from: &str, to: &str) -> String { + format!( + r#" + +
+ 1 + EXPORT + DATA + BRIDGE Ledger Period Balances V1 +
+ + + + $$SysName:XML + {} + {} + {} + + + + + BRIDGE Ledger Period Balances Form V1 + Yes + +
+ BRIDGE Ledger Period Context Part V1, BRIDGE Ledger Period Rows Part V1 +
+ + BRIDGE Ledger Period Context Line V1 + + + BRIDGE Ledger Period Row Line V1 + BRIDGE Ledger Period Row Line V1 : BRIDGE Ledger Period Collection V1 + + + BRIDGE Ledger Period Schema V1, BRIDGE Ledger Period Object V1, BRIDGE Ledger Period Company GUID V1, BRIDGE Ledger Period From V1, BRIDGE Ledger Period To V1, BRIDGE Ledger Period Ordinary Books Requested V1, BRIDGE Ledger Period Count V1 + "COMPANYCONTEXT" + + "bridge.tally.ledger-period-balances/1""SCHEMA" + "LEDGERPERIODBALANCE""OBJECTTYPE" + $GUID:Company:##SVCurrentCompany"GUID" + "{}""FROMDATE" + "{}""TODATE" + YesLogical"ORDINARYBOOKSREQUESTED" + $$NumItems:BRIDGE Ledger Period Collection V1"RECORDCOUNT" + + BRIDGE Ledger Period Opening V1, BRIDGE Ledger Period Closing V1 + "LEDGERPERIODBALANCE" + "GUID" : $GUID + "REMOTEID" : $RemoteID + "MASTERID" : $MasterID + "ALTERID" : $AlterID + + $TBalOpeningAmount"No Symbol, No Comma""OPENINGBALANCE" + $TBalClosingAmount"No Symbol, No Comma""CLOSINGBALANCE" + + Ledger + GUID, RemoteID, MasterID, AlterID, TBalOpening, TBalClosing + +
+
+
+ +
+"#, + xml_escape(company), + xml_escape(from), + xml_escape(to), + xml_escape(from), + xml_escape(to), + ) + .trim() + .to_string() +} + fn xml_escape(value: &str) -> String { value .replace('&', "&") @@ -196,30 +609,148 @@ fn xml_escape(value: &str) -> String { #[cfg(test)] mod tests { - use super::{company_list_request, ledgers_request, vouchers_request}; + use super::{ + company_list_request, groups_request, ledger_period_balances_request, ledgers_request, + legacy_company_list_request, legacy_ledgers_request, legacy_vouchers_request, + voucher_types_request, vouchers_request, + }; + + #[test] + fn portable_read_profiles_preserve_the_existing_production_bytes() { + assert_eq!(company_list_request(), legacy_company_list_request()); + assert_eq!( + ledgers_request("BRIDGE & \"BOOK\""), + legacy_ledgers_request("BRIDGE & \"BOOK\"") + ); + assert_eq!( + vouchers_request("BRIDGE 'SYNTHETIC'", "2026<0401", "2026&0430"), + legacy_vouchers_request("BRIDGE 'SYNTHETIC'", "2026<0401", "2026&0430") + ); + } #[test] fn requests_only_fields_used_by_the_renderer() { let combined = format!( - "{}{}{}", + "{}{}{}{}{}", company_list_request(), + groups_request("Synthetic Company"), ledgers_request("Synthetic Company"), + voucher_types_request("Synthetic Company"), vouchers_request("Synthetic Company", "20260401", "20260430") ); for prohibited in [ "NATIVEMETHOD>*", - "AllLedgerEntries", "$Address", "$INCOMETAXNUMBER", "$TANREGNO", "$TANUMBER", "$Website", "$Narration", + "$StateName", + "$PhoneNumber", + "$Email", + "$MOBILENUMBERS", + "$Pincode", + "$GSTRegNumber", ] { assert!( !combined.contains(prohibited), "unexpected TDL field: {prohibited}" ); } + assert!(company_list_request().contains("Name, GUID")); + } + + #[test] + fn exact_report_collection_is_shared_by_count_and_rows() { + let ledgers = ledgers_request("BRIDGE SYNTHETIC BOOK"); + assert!(ledgers.contains("DATA")); + assert!(ledgers.contains("BRIDGE Ledger Export V1")); + assert!(ledgers.contains("$$NumItems:BRIDGE Ledger Collection V1")); + assert!(ledgers + .contains("BRIDGE Ledger Row Line V1 : BRIDGE Ledger Collection V1")); + assert!(ledgers.contains("\"COMPANYCONTEXT\"")); + assert!(ledgers.contains("\"GUID\" : $GUID")); + assert!(ledgers.contains("\"REMOTEID\" : $RemoteID")); + assert!(ledgers.contains("\"MASTERID\" : $MasterID")); + assert!(ledgers.contains("\"ALTERID\" : $AlterID")); + assert!(ledgers.contains("Name, GUID, RemoteID, MasterID, AlterID")); + + let groups = groups_request("BRIDGE SYNTHETIC BOOK"); + assert!(groups.contains("DATA")); + assert!(groups.contains("BRIDGE Group Export V1")); + assert!(groups.contains("$$NumItems:BRIDGE Group Collection V1")); + assert!(groups + .contains("BRIDGE Group Row Line V1 : BRIDGE Group Collection V1")); + assert!(groups.contains("Group")); + + let voucher_types = voucher_types_request("BRIDGE SYNTHETIC BOOK"); + assert!(voucher_types.contains("DATA")); + assert!(voucher_types.contains("BRIDGE Voucher Type Export V1")); + assert!(voucher_types.contains("$$NumItems:BRIDGE Voucher Type Collection V1")); + assert!(voucher_types.contains( + "BRIDGE Voucher Type Row Line V1 : BRIDGE Voucher Type Collection V1" + )); + assert!(voucher_types.contains("VoucherType")); + + let vouchers = vouchers_request("BRIDGE SYNTHETIC BOOK", "20260401", "20260430"); + assert!(vouchers.contains("DATA")); + assert!(vouchers.contains("BRIDGE Voucher Export V2")); + assert!(vouchers.contains("$IsOptional")); + assert!(vouchers.contains("\"ISOPTIONAL\"")); + assert!(vouchers.contains("$$NumItems:BRIDGE Voucher Collection V1")); + assert!(vouchers.contains( + "BRIDGE Voucher Row Line V1 : BRIDGE Voucher Collection V1" + )); + assert!(vouchers.contains("\"REMOTEID\" : $RemoteID")); + assert!(vouchers.contains("\"MASTERID\" : $MasterID")); + assert!(vouchers.contains("\"ALTERID\" : $AlterID")); + assert!(vouchers.contains("BRIDGE Voucher Ledger Entries Part V1 : Yes")); + assert!(vouchers + .contains("BRIDGE Voucher Ledger Entry Row V1 : AllLedgerEntries")); + assert!(vouchers.contains("$$NumItems:AllLedgerEntries")); + assert!(vouchers.contains("\"LEDGERENTRYCOUNT\"")); + assert!(vouchers.contains("\"LEDGERNAME\"")); + assert!(vouchers.contains("\"AMOUNT\"")); + assert!(vouchers.contains("\"ISDEEMEDPOSITIVE\"")); + assert!(vouchers.contains("AllLedgerEntries.*")); + assert!(!vouchers.contains("PartyLedgerName")); + assert!(!vouchers.contains("Narration")); + } + + #[test] + fn report_requests_escape_all_user_controlled_static_variables() { + let ledgers = ledgers_request("BRIDGE & \"BOOK\""); + assert!(ledgers.contains("BRIDGE & <SYNTHETIC> "BOOK"")); + assert!(!ledgers.contains("BRIDGE & ")); + + let vouchers = vouchers_request("BRIDGE 'SYNTHETIC'", "2026<0401", "2026&0430"); + assert!(vouchers.contains("BRIDGE 'SYNTHETIC'")); + assert!(vouchers.contains("2026<0401")); + assert!(vouchers.contains("2026&0430")); + + let groups = groups_request("BRIDGE & "); + assert!(groups.contains("BRIDGE & <GROUPS>")); + + let voucher_types = voucher_types_request("BRIDGE 'VOUCHER TYPES'"); + assert!(voucher_types.contains("BRIDGE 'VOUCHER TYPES'")); + + let period = ledger_period_balances_request("BRIDGE & PERIOD", "2026<0401", "2026&0430"); + assert!(period.contains("BRIDGE & PERIOD")); + assert!(period.contains("2026<0401")); + assert!(period.contains("2026&0430")); + } + + #[test] + fn period_balance_request_is_identity_scoped_and_name_free() { + let request = ledger_period_balances_request("Synthetic Company", "20260401", "20260430"); + assert!(request.contains("bridge.tally.ledger-period-balances/1")); + assert!(request.contains("$TBalOpening")); + assert!(request.contains("$TBalClosing")); + assert!(request.contains("\"ORDINARYBOOKSREQUESTED\"")); + assert!(!request.contains("\"ORDINARYBOOKS\"")); + assert!(request.contains("\"GUID\" : $GUID")); + assert!(request.contains("\"FROMDATE\"")); + assert!(!request.contains("\"NAME\"")); } } diff --git a/src-tauri/src/tally/validators.rs b/src-tauri/src/tally/validators.rs index 30d8a5f..6969e3e 100644 --- a/src-tauri/src/tally/validators.rs +++ b/src-tauri/src/tally/validators.rs @@ -1,3 +1,55 @@ +use chrono::NaiveDate; + +pub fn validate_company_name(value: &str) -> Result<(), String> { + normalize_company_name(value).map(|_| ()) +} + +pub fn normalize_company_name(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("Select a Tally company before requesting company data".to_string()); + } + if trimmed.len() > 255 || trimmed.chars().any(char::is_control) { + return Err( + "Tally company name must be at most 255 bytes and contain no control characters" + .to_string(), + ); + } + Ok(trimmed.to_string()) +} + +pub fn normalize_company_guid(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.len() > 256 + || !trimmed.is_ascii() + || trimmed.chars().any(char::is_control) + { + return Err( + "Tally company GUID must be printable ASCII, at most 256 bytes, and contain no control characters" + .to_string(), + ); + } + Ok(trimmed.to_string()) +} + +pub fn validate_date_range(from: &str, to: &str) -> Result<(), String> { + fn parse(label: &str, value: &str) -> Result { + if value.len() != 8 || !value.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(format!("{label} must use the YYYYMMDD format")); + } + NaiveDate::parse_from_str(value, "%Y%m%d") + .map_err(|_| format!("{label} must be a valid calendar date")) + } + + let from_date = parse("From date", from)?; + let to_date = parse("To date", to)?; + if from_date > to_date { + return Err("From date must be on or before To date".to_string()); + } + Ok(()) +} + pub fn is_valid_gstin(value: &str) -> bool { let bytes = value.as_bytes(); value.len() == 15 @@ -24,7 +76,33 @@ pub fn voucher_balances(debits: i64, credits: i64) -> bool { #[cfg(test)] mod tests { - use super::{is_valid_gstin, is_valid_pan, voucher_balances}; + use super::{ + is_valid_gstin, is_valid_pan, normalize_company_guid, normalize_company_name, + validate_company_name, validate_date_range, voucher_balances, + }; + + #[test] + fn validates_company_selection() { + assert!(validate_company_name("Synthetic Company").is_ok()); + assert!(validate_company_name(" ").is_err()); + assert!(validate_company_name("Synthetic\nCompany").is_err()); + assert!(validate_company_name(&"x".repeat(256)).is_err()); + assert_eq!( + normalize_company_name(" Synthetic Company ").unwrap(), + "Synthetic Company" + ); + assert_eq!(normalize_company_guid(" guid-1 ").unwrap(), "guid-1"); + assert!(normalize_company_guid("guid\n1").is_err()); + assert!(normalize_company_guid(&"g".repeat(257)).is_err()); + } + + #[test] + fn validates_tally_date_ranges() { + assert!(validate_date_range("20260101", "20260131").is_ok()); + assert!(validate_date_range("20260229", "20260301").is_err()); + assert!(validate_date_range("20260430", "20260401").is_err()); + assert!(validate_date_range("2026-04-01", "20260430").is_err()); + } #[test] fn validates_gstin_shape() { diff --git a/src-tauri/src/tally/write_sandbox.rs b/src-tauri/src/tally/write_sandbox.rs new file mode 100644 index 0000000..8cb6be1 --- /dev/null +++ b/src-tauri/src/tally/write_sandbox.rs @@ -0,0 +1,7 @@ +//! Network-free controlled-write qualification. +//! +//! The implementation lives in a portable crate so its evidence derivation can +//! be tested without Tauri, SQLCipher, native libraries, or an installed Tally. +//! It intentionally exposes no HTTP dispatch adapter. + +pub use bridge_tally_write::*; diff --git a/src-tauri/src/tally/xml_parser.rs b/src-tauri/src/tally/xml_parser.rs index 6d21945..9ec7b05 100644 --- a/src-tauri/src/tally/xml_parser.rs +++ b/src-tauri/src/tally/xml_parser.rs @@ -1,355 +1,3 @@ -use quick_xml::{events::Event, name::QName, Reader}; -use serde::{Deserialize, Serialize}; +//! Backwards-compatible Bridge application surface for the portable protocol crate. -#[derive(Debug, Deserialize, Serialize)] -pub struct TallyEnvelope { - #[serde(rename = "BODY")] - pub body: Option, -} - -#[derive(Debug, Deserialize, Serialize)] -pub struct TallyCompany { - pub name: String, - pub state: Option, - pub phone: Option, - pub email: Option, - pub mobile: Option, - pub pincode: Option, - pub gst_number: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct TallyLedger { - pub name: String, - pub parent: Option, - pub party_gstin: Option, - pub opening_balance: Option, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct TallyVoucher { - pub id: Option, - pub date: Option, - pub voucher_type: Option, - pub voucher_number: Option, - pub party_ledger_name: Option, -} - -pub fn parse_xml(xml: &str) -> anyhow::Result -where - T: for<'de> Deserialize<'de>, -{ - Ok(quick_xml::de::from_str(xml)?) -} - -pub fn parse_companies(xml: &str) -> anyhow::Result> { - let mut reader = Reader::from_str(xml); - reader.config_mut().trim_text(true); - let mut companies = Vec::new(); - - loop { - match reader.read_event()? { - Event::Start(element) - if element.name().as_ref().eq_ignore_ascii_case(b"COMPANYINFO") => - { - companies.push(parse_company_info(&mut reader)?); - } - Event::Eof => break, - _ => {} - } - } - - Ok(companies) -} - -pub fn parse_ledgers(xml: &str) -> anyhow::Result> { - let mut reader = Reader::from_str(xml); - reader.config_mut().trim_text(true); - let mut ledgers = Vec::new(); - - loop { - match reader.read_event()? { - Event::Start(element) if element.name().as_ref().eq_ignore_ascii_case(b"LEDGER") => { - let name = attr_value(&element, b"NAME"); - ledgers.push(parse_ledger(&mut reader, name)?); - } - Event::Eof => break, - _ => {} - } - } - - Ok(ledgers) -} - -pub fn parse_vouchers(xml: &str) -> anyhow::Result> { - let mut reader = Reader::from_str(xml); - reader.config_mut().trim_text(true); - let mut vouchers = Vec::new(); - - loop { - match reader.read_event()? { - Event::Start(element) if element.name().as_ref().eq_ignore_ascii_case(b"VOUCHER") => { - let id = attr_value(&element, b"REMOTEID") - .or_else(|| attr_value(&element, b"GUID")) - .or_else(|| attr_value(&element, b"MASTERID")); - vouchers.push(parse_voucher(&mut reader, id)?); - } - Event::Eof => break, - _ => {} - } - } - - Ok(vouchers) -} - -fn parse_company_info(reader: &mut Reader<&[u8]>) -> anyhow::Result { - let mut company = TallyCompany { - name: String::new(), - state: None, - phone: None, - email: None, - mobile: None, - pincode: None, - gst_number: None, - }; - - loop { - match reader.read_event()? { - Event::Start(element) => { - let name = element.name().as_ref().to_ascii_uppercase(); - - match name.as_slice() { - b"COMPANYNAMEFIELD" => { - company.name = - read_optional_text(reader, element.name())?.unwrap_or_default() - } - b"STATEFIELD" => company.state = read_optional_text(reader, element.name())?, - b"PHONEFIELD" => company.phone = read_optional_text(reader, element.name())?, - b"EMAILFIELD" => company.email = read_optional_text(reader, element.name())?, - b"MOBILEFIELD" | b"MOBILENUMBERSFIELD" => { - company.mobile = read_optional_text(reader, element.name())? - } - b"PINCODEFIELD" => { - company.pincode = read_optional_text(reader, element.name())? - } - b"GSTNUMBERFIELD" | b"GSTREGNUMBERFIELD" => { - company.gst_number = read_optional_text(reader, element.name())? - } - _ => {} - } - } - Event::End(element) if element.name().as_ref().eq_ignore_ascii_case(b"COMPANYINFO") => { - break - } - Event::Eof => break, - _ => {} - } - } - - Ok(company) -} - -fn parse_ledger(reader: &mut Reader<&[u8]>, name: Option) -> anyhow::Result { - let mut ledger = TallyLedger { - name: name.unwrap_or_default(), - parent: None, - party_gstin: None, - opening_balance: None, - }; - - loop { - match reader.read_event()? { - Event::Start(element) => { - let name = element.name().as_ref().to_ascii_uppercase(); - - match name.as_slice() { - b"PARENT" => ledger.parent = read_optional_text(reader, element.name())?, - b"PARTYGSTIN" => { - ledger.party_gstin = read_optional_text(reader, element.name())? - } - b"OPENINGBALANCE" => { - ledger.opening_balance = read_optional_text(reader, element.name())? - } - _ => {} - } - } - Event::End(element) if element.name().as_ref().eq_ignore_ascii_case(b"LEDGER") => break, - Event::Eof => break, - _ => {} - } - } - - Ok(ledger) -} - -fn parse_voucher(reader: &mut Reader<&[u8]>, id: Option) -> anyhow::Result { - let mut voucher = TallyVoucher { - id, - date: None, - voucher_type: None, - voucher_number: None, - party_ledger_name: None, - }; - - loop { - match reader.read_event()? { - Event::Start(element) => { - let name = element.name().as_ref().to_ascii_uppercase(); - - match name.as_slice() { - b"DATE" => voucher.date = read_optional_text(reader, element.name())?, - b"VOUCHERTYPENAME" => { - voucher.voucher_type = read_optional_text(reader, element.name())? - } - b"VOUCHERNUMBER" => { - voucher.voucher_number = read_optional_text(reader, element.name())? - } - b"PARTYLEDGERNAME" => { - voucher.party_ledger_name = read_optional_text(reader, element.name())? - } - _ => {} - } - } - Event::End(element) if element.name().as_ref().eq_ignore_ascii_case(b"VOUCHER") => { - break - } - Event::Eof => break, - _ => {} - } - } - - Ok(voucher) -} - -fn attr_value(element: &quick_xml::events::BytesStart<'_>, key: &[u8]) -> Option { - element - .attributes() - .flatten() - .find(|attr| attr.key.as_ref().eq_ignore_ascii_case(key)) - .and_then(|attr| String::from_utf8(attr.value.as_ref().to_vec()).ok()) - .filter(|value| !value.trim().is_empty()) -} - -fn read_optional_text( - reader: &mut Reader<&[u8]>, - name: QName<'_>, -) -> anyhow::Result> { - let value = reader.read_text(name)?; - let decoded = value.decode()?; - Ok(empty_to_none(decoded.trim())) -} - -fn empty_to_none(value: &str) -> Option { - if value.is_empty() { - None - } else { - Some(value.to_string()) - } -} - -#[cfg(test)] -mod tests { - use super::{parse_companies, parse_ledgers, parse_vouchers}; - - #[test] - fn parses_company_info_rows() { - let xml = r#" - - - Demo Pvt Ltd - Maharashtra - accounts@example.com - 400001 - - -"#; - - let companies = parse_companies(xml).expect("companies should parse"); - - assert_eq!(companies.len(), 1); - assert_eq!(companies[0].name, "Demo Pvt Ltd"); - assert_eq!(companies[0].state.as_deref(), Some("Maharashtra")); - assert_eq!(companies[0].email.as_deref(), Some("accounts@example.com")); - } - - #[test] - fn parses_ledger_rows() { - let xml = r#" - - - -
Line 1
-
- Sundry Debtors - a@example.com - 27ABCDE1234F1Z5 -
-
-"#; - - let ledgers = parse_ledgers(xml).expect("ledgers should parse"); - - assert_eq!(ledgers.len(), 1); - assert_eq!(ledgers[0].name, "Customer A"); - assert_eq!(ledgers[0].parent.as_deref(), Some("Sundry Debtors")); - } - - #[test] - fn parses_voucher_rows() { - let xml = r#" - - - 20260401 - Sales - 1 - Customer A - - Customer A - Yes - -1180.00 - - - -"#; - - let vouchers = parse_vouchers(xml).expect("vouchers should parse"); - - assert_eq!(vouchers.len(), 1); - assert_eq!(vouchers[0].voucher_type.as_deref(), Some("Sales")); - let serialized = serde_json::to_string(&vouchers[0]).expect("serialize voucher"); - assert!(!serialized.contains("1180")); - } - - #[test] - fn ignores_nested_voucher_sections_while_parsing_known_fields() { - let xml = r#" - - - 20260401 - - Item A - - Main Location - - - - Customer A - -1180.00 - - Bill 1 - - - - -"#; - - let vouchers = - parse_vouchers(xml).expect("nested voucher sections should not break parsing"); - - assert_eq!(vouchers.len(), 1); - assert_eq!(vouchers[0].date.as_deref(), Some("20260401")); - let serialized = serde_json::to_string(&vouchers[0]).expect("serialize voucher"); - assert!(!serialized.contains("Customer A")); - assert!(!serialized.contains("1180")); - } -} +pub use bridge_tally_protocol::*; diff --git a/src/main.tsx b/src/main.tsx index d9b246a..0c9c2dd 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,8 @@ import React from "react"; import ReactDOM from "react-dom/client"; -import { Activity, Building2, Cable, Cloud, Database, FileText, FolderOpen, KeyRound, Play, RefreshCw, ShieldCheck, UploadCloud } from "lucide-react"; +import { Activity, Building2, Cable, CircleHelp, Cloud, Database, FileText, FolderOpen, KeyRound, Play, RefreshCw, ShieldCheck, UploadCloud } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; +import { applyProbeCompanySelectionTransition, clearCompanyScopedState } from "./tally-company-selection"; import "./styles.css"; type TallyConfig = { @@ -11,6 +12,7 @@ type TallyConfig = { type ConnectionStatus = { reachable: boolean; + compatible: boolean; server_text: string; product: "TallyPrime" | "Tally ERP 9" | "Unknown"; error?: string; @@ -18,12 +20,185 @@ type ConnectionStatus = { type TallyCompany = { name: string; - state?: string; - phone?: string; - email?: string; - mobile?: string; - pincode?: string; - gst_number?: string; + guid?: string; + guid_observed?: boolean; + mirror_company_id?: string; + correlation_key?: string; + identity_confidence?: "observed" | "unknown"; + canonical_endpoint?: string; + last_observed_at_unix_ms?: number; +}; + +type TallyCommandErrorEnvelope = { + code: string; + category: string; + message: string; + retry: "safe" | "after_change" | "not_recommended"; + local_state_changed: boolean; + tally_state_may_have_changed: boolean; + remediation: string; +}; + +type OperatorError = string | TallyCommandErrorEnvelope; + +type PersistedCompanyProfilePage = { + profiles: TallyCompany[]; + total_profiles: number; + limit: number; + truncated: boolean; +}; + +type CapabilityEvidence = { + state: "supported" | "unsupported" | "unknown" | "not_configured"; + confidence: "documented" | "observed" | "inferred" | "unknown"; + safe_reason_code?: string; +}; + +type CapabilityProfile = { + profile_version: number; + product: string; + release?: string; + mode?: string; + transports: Record; + features: Record; + packs: Record; +}; + +type TallyProbeResult = { + review_id: string; + canonical_origin: string; + observed_at_unix_ms: number; + connection: ConnectionStatus; + companies: TallyCompany[]; + profile: CapabilityProfile; + selected_read_scope?: SelectedReadScope; + profile_sha256: string; + review_commitment_sha256: string; + passport_snapshot_id?: string; +}; + +type SelectedReadScope = { + scope_version: number; + ledger_profile_id: string; + voucher_profile_id: string; + voucher_from_yyyymmdd: string; + voucher_to_yyyymmdd: string; + scope_commitment_sha256: string; +}; + +type SelectedReadQualificationResult = { + review_id: string; + observed_at_unix_ms: number; + profile: CapabilityProfile; + profile_sha256: string; + review_commitment_sha256: string; + selected_read_scope: SelectedReadScope; + no_writes_attempted: boolean; + raw_records_retained: boolean; + completeness_claimed: boolean; +}; + +type SavedTallySetup = { + passport_snapshot_id: string; + canonical_origin: string; + observed_at_unix_ms: number; + company: TallyCompany; + review_cleanup_warning?: "review_cache_cleanup_failed_after_save"; +}; + +type TallyProofSummary = { + integrity_state: "entry_hash_valid"; + run_id: string; + selection_token: string; + proof_sha256: string; + pack_id: string; + outcome: "completed" | "failed" | "cancelled" | "outcome_unknown"; + verification_state: "verified" | "partial" | "unverified"; + started_at_unix_ms: number; + completed_at_unix_ms?: number; + accepted_records: number; + rejected_records: number; + provenance_unavailable_records: number; + gap_codes: string[]; + warning_codes: string[]; +}; + +type TallySyncEvidence = { + latest_proofs: TallyProofSummary[]; + latest_reconciliation_mismatches: Array<{ + reason_code: string; + record_aliases: string[]; + }>; + incremental: { + execution_enabled: boolean; + affirmative_exact_capability_receipts: number; + establishment_receipts: number; + active_checkpoint_heads: number; + state: "exact_capability_not_observed" | "verified_establishment_missing" | "execution_not_enabled"; + fallback_warning_code: string; + }; + core_accounting_freshness: { + state: "fresh" | "stale" | "never_verified"; + verified_at_unix_ms?: number; + age_seconds?: number; + checkpoint_present: boolean; + proof_present: boolean; + }; +}; + +type RedactedProofPreview = { + json: string; + payload_sha256: string; +}; + +type MirrorExplorerPage = { + pack_id: string; + offset: number; + limit: number; + total_records: number; + records: Array<{ + local_alias: string; + object_type: string; + identity_confidence: string; + last_batch_state: string; + tombstoned: boolean; + }>; +}; + +type SnapshotPhase = "prepare" | "capability_check" | "company_identity_check" | "plan_windows" | "extract" | "normalize" | "validate" | "stage" | "reconcile" | "commit_pending" | "emit_proof" | "completed" | "partial" | "failed" | "cancelled"; + +type SnapshotJobStatus = { + run_id: string; + mirror_company_id: string | null; + pack_id: string | null; + requested_from_yyyymmdd: string | null; + requested_to_yyyymmdd: string | null; + phase: SnapshotPhase; + active_window_id: string | null; + completed_windows: number; + total_windows: number; + verification: "verified" | "partial" | "unverified" | null; + proof_id: string | null; + proof_sha256: string | null; + gap_codes: string[]; + warning_codes: string[]; + failure_code: string | null; + requires_resume: boolean; + resume_available: boolean; +}; + +type TallyRuntimeSnapshot = { + session_id: string; + canonical_endpoint: string; + issued_requests: number; + active_requests: number; + active_request_ids: string[]; + consecutive_failures: number; + circuit_state: "closed" | "open" | "half_open"; + circuit_retry_after_unix_ms?: number; + last_success_unix_ms?: number; + last_failure_unix_ms?: number; + cached_capability_observed_at_unix_ms?: number; }; type TallyLedger = { @@ -173,19 +348,324 @@ type SelectedDocumentPath = { }; type View = "dashboard" | "companies" | "gst" | "mirror" | "dsc" | "documents" | "axal"; +type TallyAction = "probe" | "qualify" | "save" | "ledgers" | "vouchers" | "evidence" | "explorer" | "start" | "resume" | "cancel"; + +const TABLE_PREVIEW_LIMIT = 100; +const MIRROR_PAGE_LIMIT = 25; + +const VIEW_TITLES: Record = { + dashboard: "Tally evidence dashboard", + companies: "Tally setup and company profile", + gst: "GST return readiness", + mirror: "Accounting mirror and proof", + dsc: "DSC token", + documents: "Documents", + axal: "AXAL backend", +}; + +const TRANSPORT_LABELS: Record = { + xml_http: "XML over HTTP", + json_ex: "JSONEX", + tdl_companion: "TDL companion", + odbc: "ODBC", +}; + +const PACK_LABELS: Record = { + core_accounting: "Core accounting", + india_tax: "India tax", + bills_and_payments: "Bills and payments", + inventory: "Inventory", +}; + +const FEATURE_LABELS: Record = { + endpoint_reachability: "Endpoint responder reachability", + loaded_companies: "Loaded companies", + stable_company_identity: "Stable company identity", + encoding_behaviour: "Response encoding", + practical_response_limit: "Practical response limit", + company_read: "Company enumeration", + ledger_read: "Ledger read", + voucher_read: "Voucher read", + selected_ledger_read: "Selected-company ledger profile", + selected_voucher_window_read: "Selected voucher-window profile", + write: "Write capability", +}; + +const CAPABILITY_REASON_LABELS: Record = { + xml_export_probe_failed: "The safe XML export probe did not complete.", + tally_status_not_recognized: "The endpoint response was not recognized as a compatible Tally status.", + release_not_observed: "The Tally release was not observed, so this transport was not tested.", + configuration_not_observed: "Bridge did not inspect this optional transport's configuration.", + company_identity_invalid: "The company result contained an invalid or unsafe identity field.", + company_identity_ambiguous: "Two or more returned companies shared the same normalized GUID.", + practical_limit_not_measured: "No live workload has established a practical response limit for this endpoint.", + selected_read_probe_not_run: "This selected read was not run by the connection probe.", + selected_ledger_read_empty_observed: "The exact selected ledger profile returned a valid empty response; source emptiness is not claimed.", + selected_ledger_read_non_empty_observed: "The exact selected ledger profile returned validated identified rows, which were discarded.", + selected_voucher_window_empty_observed: "The exact request-bound voucher window returned a valid empty response; source completeness is not claimed.", + selected_voucher_window_non_empty_observed: "The exact request-bound voucher window returned validated identified rows, which were discarded.", + qualification_prerequisite_failed: "Voucher qualification was skipped because the ledger prerequisite did not pass.", + selected_voucher_date_outside_window: "A returned voucher fell outside the exact reviewed date window.", + selected_read_identity_unavailable: "The selected response did not prove stable unique row identity.", + selected_read_schema_rejected: "The selected response did not match the exact reviewed schema and structure.", + selected_read_transport_or_validation_failed: "The selected read failed transport, decoding, or strict validation and remains unknown.", + write_probe_not_run: "No write probe was run. Bridge never infers write support from read access.", + verified_snapshot_not_run: "No profile-scoped capability run has established this pack's declared contract.", +}; + +function CapabilityBadge({ evidence }: { evidence?: CapabilityEvidence }) { + if (!evidence) { + return Not observed; + } + + return ( + + {formatCapabilityState(evidence.state)} + + ); +} + +function CapabilityRows({ + capabilities, + labels, +}: { + capabilities?: Record; + labels: Record; +}) { + const keys = Array.from(new Set([...Object.keys(labels), ...Object.keys(capabilities || {})])); + + return ( +
+ {keys.map((key) => { + const evidence = capabilities?.[key]; + return ( +
+
+ {labels[key] || formatIdentifier(key)} + + {evidence + ? `${formatConfidence(evidence.confidence)}. ${formatCapabilityReason(evidence.safe_reason_code)}` + : "This endpoint has not been probed in the current configuration."} + +
+ +
+ ); + })} +
+ ); +} + +type GapGuidance = { + title: string; + action: string; + retry: "after_change" | "not_useful"; +}; + +const GAP_GUIDANCE: Record = { + source_cut_atomicity_unavailable: { + title: "Atomic source cut is unavailable", + action: "No operator action can close this gap in the current Tally profile. The run may still be useful, but it must remain Partial.", + retry: "not_useful", + }, + period_report_profile_unobserved: { + title: "Ledger-balance profile is not validated", + action: "Validate the exact release, mode, report configuration, scenario, optional-voucher behavior, and receipt/delivery-note tracking effects with a synthetic company before enabling this custom cross-view.", + retry: "not_useful", + }, + voucher_header_entry_total_unavailable: { + title: "Voucher header totals are unavailable", + action: "Do not infer header totals from balanced entries. Extend the capability pack and validate the source fields first.", + retry: "not_useful", + }, + voucher_entry_applicability_unavailable: { + title: "Voucher applicability is incomplete", + action: "Classify the voucher type and its book-effect semantics before treating missing entries as an error.", + retry: "not_useful", + }, + record_provenance_unavailable: { + title: "Raw-record provenance is unavailable", + action: "Use a connector path that binds each canonical record to a source-fragment hash, then run a new evidence read.", + retry: "after_change", + }, + report_tie_out_unavailable: { + title: "Ledger-balance cross-view did not complete", + action: "Check that Tally is responsive and the custom read-only report is supported, then run a new evidence read.", + retry: "after_change", + }, + capability_profile_changed_during_run: { + title: "Capability profile changed during the run", + action: "Stabilize the Tally release, mode, loaded company, and endpoint configuration before retrying.", + retry: "after_change", + }, + source_changed_during_run: { + title: "Source data changed during the run", + action: "Run again during a controlled quiet period. A stable reread still does not prove atomic isolation.", + retry: "after_change", + }, + minimum_window_response_too_large: { + title: "One Tally day exceeds the bounded response limit", + action: "Bridge cannot split below one calendar day. Reduce that day's source density or use a future qualified collection filter before starting a new run; retrying unchanged will fail again.", + retry: "after_change", + }, + adaptive_window_limit_reached: { + title: "Adaptive window safety limit reached", + action: "Start a new run for a shorter requested period. Bridge stopped before growing the durable split graph beyond its reviewed bound.", + retry: "after_change", + }, +}; + +function guidanceForGap(code: string): GapGuidance { + return GAP_GUIDANCE[code] ?? { + title: formatIdentifier(code), + action: "Inspect the local Proof of Sync and support artifact. Do not retry unchanged until this gap's cause is understood.", + retry: "not_useful", + }; +} + +function GapMap({ codes, available }: { codes: string[]; available: boolean }) { + const uniqueCodes = Array.from(new Set(codes)).sort(); + return ( +
+ {!available ? ( +
+ No inspected attempt; Gap Map unavailable + Load evidence or inspect a durable run before interpreting gaps. +
+ ) : uniqueCodes.length === 0 ? ( +
+ No declared gaps in this attempt + This does not establish accuracy unless the attempt is explicitly Verified. +
+ ) : uniqueCodes.map((code) => { + const guidance = guidanceForGap(code); + return ( +
+
+ {guidance.title} + {code} +
+

{guidance.action}

+ + {guidance.retry === "after_change" ? "Retry only after the stated change" : "Retrying unchanged is not useful"} + +
+ ); + })} +
+ ); +} + +function classifyTallyError(message: string): { category: string; action: string } { + const value = message.toLowerCase(); + if (value.includes("permission") || value.includes("education") || value.includes("mode")) { + return { category: "Permission or mode", action: "Confirm this operation is supported by the active Tally mode and company permissions." }; + } + if (value.includes("parse") || value.includes("xml") || value.includes("schema") || value.includes("payload")) { + return { category: "Response validation", action: "Keep the run unverified and inspect the redacted diagnostic evidence before retrying." }; + } + if (value.includes("reconcil") || value.includes("mismatch") || value.includes("proof")) { + return { category: "Reconciliation", action: "Review the Gap Map and local drill-down. Do not overwrite or ignore the mismatch." }; + } + if (value.includes("status") || value.includes("company") || value.includes("tally_export")) { + return { category: "Tally application", action: "Confirm the intended company is loaded and Tally accepted the read-only request." }; + } + if (value.includes("host") || value.includes("port") || value.includes("endpoint") || value.includes("connect")) { + return { category: "Endpoint configuration", action: "Check the loopback host, port, and Tally XML server, then probe again." }; + } + return { category: "Operation", action: "Preserve the error and inspect Tally Setup, the Gap Map, and Tally runtime before deciding whether a retry is safe." }; +} + +function TallyErrorNotice({ message }: { message: OperatorError }) { + const guidance = typeof message === "string" + ? classifyTallyError(message) + : { category: message.category, action: message.remediation }; + const displayMessage = typeof message === "string" ? message : message.message; + return ( +
+ {guidance.category} + {displayMessage} + {typeof message !== "string" && ( + + Code {message.code} · Retry {formatIdentifier(message.retry)} · Local state {message.local_state_changed ? "changed" : "unchanged"} · Tally state {message.tally_state_may_have_changed ? "may have changed" : "unchanged by this read-only action"} + + )} + {guidance.action} +
+ ); +} + +function CopyTokenButton({ value, label }: { value: string; label: string }) { + const [copyState, setCopyState] = React.useState<"idle" | "copied" | "failed">("idle"); + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopyState("copied"); + window.setTimeout(() => setCopyState("idle"), 1500); + } catch { + setCopyState("failed"); + } + } + return ( + + + + {copyState === "failed" ? `Copy failed; select the ${label} text manually.` : copyState === "copied" ? `${label} copied.` : ""} + + {copyState === "failed" && ( + event.currentTarget.select()} + /> + )} + + ); +} + +const DSC_METADATA_RETENTION_MS = 5 * 60 * 1000; function App() { const currentFinancialYear = React.useMemo(() => getCurrentFinancialYear(), []); + const currentQualificationWindow = React.useMemo(() => getCurrentQualificationWindow(), []); const [config, setConfig] = React.useState({ host: "localhost", port: 9000 }); const [status, setStatus] = React.useState(null); + const [passport, setPassport] = React.useState(null); + const [profileSha256, setProfileSha256] = React.useState(null); + const [reviewId, setReviewId] = React.useState(null); + const [reviewCommitmentSha256, setReviewCommitmentSha256] = React.useState(null); + const [selectedReadScope, setSelectedReadScope] = React.useState(null); + const [passportSnapshotId, setPassportSnapshotId] = React.useState(null); + const [runtimeSessions, setRuntimeSessions] = React.useState([]); + const [runtimeError, setRuntimeError] = React.useState(null); const [companies, setCompanies] = React.useState([]); const [selectedCompany, setSelectedCompany] = React.useState(""); + const [liveCompanyKeys, setLiveCompanyKeys] = React.useState([]); + const [persistedCompanyProfileTotal, setPersistedCompanyProfileTotal] = React.useState(0); + const [persistedCompanyProfilesLoaded, setPersistedCompanyProfilesLoaded] = React.useState(0); + const [persistedCompanyProfilesTruncated, setPersistedCompanyProfilesTruncated] = React.useState(false); const [ledgers, setLedgers] = React.useState([]); const [vouchers, setVouchers] = React.useState([]); const [voucherFrom, setVoucherFrom] = React.useState(currentFinancialYear.from); const [voucherTo, setVoucherTo] = React.useState(currentFinancialYear.to); - const [companyError, setCompanyError] = React.useState(null); - const [dashboardError, setDashboardError] = React.useState(null); + const [qualificationFrom, setQualificationFrom] = React.useState(currentQualificationWindow.from); + const [qualificationTo, setQualificationTo] = React.useState(currentQualificationWindow.to); + const [companyError, setCompanyError] = React.useState(null); + const [syncEvidence, setSyncEvidence] = React.useState(null); + const [syncEvidenceError, setSyncEvidenceError] = React.useState(null); + const [proofPreview, setProofPreview] = React.useState(null); + const [proofPreviewSelection, setProofPreviewSelection] = React.useState<{ proofId: string; runId: string } | null>(null); + const [mirrorExplorer, setMirrorExplorer] = React.useState(null); + const [mirrorExplorerError, setMirrorExplorerError] = React.useState(null); + const [snapshotJob, setSnapshotJob] = React.useState(null); + const [recentSnapshotRuns, setRecentSnapshotRuns] = React.useState([]); + const [snapshotError, setSnapshotError] = React.useState(null); + const [snapshotStartOutcomeUnknown, setSnapshotStartOutcomeUnknown] = React.useState(false); + const [dashboardError, setDashboardError] = React.useState(null); const [gstCompany, setGstCompany] = React.useState(""); const [gstFinancialYear, setGstFinancialYear] = React.useState(currentFinancialYear.label); const [draft, setDraft] = React.useState(null); @@ -212,18 +692,565 @@ function App() { const [documentAction, setDocumentAction] = React.useState<"scan" | "sync" | null>(null); const [view, setView] = React.useState("dashboard"); const [busy, setBusy] = React.useState(false); + const [tallyAction, setTallyAction] = React.useState(null); + const [diagnosticsRevealed, setDiagnosticsRevealed] = React.useState(false); + const tallyResultsVersion = React.useRef(0); + const proofPreviewRequestVersion = React.useRef(0); + const diagnosticsRequestVersion = React.useRef(0); + const snapshotSelectionVersion = React.useRef(0); + const dscRequestVersion = React.useRef(0); + const mainContentRef = React.useRef(null); + + const clearDscSensitiveState = React.useCallback(() => { + dscRequestVersion.current += 1; + setDscReport(null); + setDscDetectReport(null); + setDscPin(""); + setDscSync(null); + }, []); + + React.useEffect(() => { + if (!dscReport && !dscDetectReport && !dscPin && !dscSync) return; + const expiry = window.setTimeout(clearDscSensitiveState, DSC_METADATA_RETENTION_MS); + return () => window.clearTimeout(expiry); + }, [clearDscSensitiveState, dscDetectReport, dscPin, dscReport, dscSync]); + + React.useEffect(() => { + if (view !== "dsc") clearDscSensitiveState(); + }, [clearDscSensitiveState, view]); + + const refreshRuntime = React.useCallback(async () => { + try { + const snapshots = await invoke("tally_runtime_snapshots"); + setRuntimeSessions(snapshots); + setRuntimeError(null); + } catch (error) { + setRuntimeError(toOperatorError(error)); + } + }, []); + + const refreshRecentSnapshots = React.useCallback(async () => { + try { + const runs = await invoke("tally_recent_snapshot_runs"); + setRecentSnapshotRuns(runs); + setSnapshotJob((current) => current ? runs.find((run) => run.run_id === current.run_id) ?? current : null); + } catch (error) { + setSnapshotError(toOperatorError(error)); + } + }, []); + + const refreshPersistedCompanyProfiles = React.useCallback(async () => { + try { + const page = await invoke("tally_persisted_company_profiles"); + setCompanies((current) => mergeTallyCompanies(page.profiles, current)); + setPersistedCompanyProfileTotal(page.total_profiles); + setPersistedCompanyProfilesLoaded(page.profiles.length); + setPersistedCompanyProfilesTruncated(page.truncated); + } catch (error) { + setCompanyError(toOperatorError(error)); + } + }, []); + + React.useEffect(() => { + void refreshRecentSnapshots(); + void refreshPersistedCompanyProfiles(); + }, [refreshRecentSnapshots, refreshPersistedCompanyProfiles]); + + React.useEffect(() => { + mainContentRef.current?.focus(); + }, [view]); + + React.useEffect(() => { + clearSensitiveDiagnostics(); + }, [view, selectedCompany]); + + const snapshotActive = !!snapshotJob + && !snapshotJob.requires_resume + && !["completed", "partial", "failed", "cancelled"].includes(snapshotJob.phase); + + React.useEffect(() => { + if (!tallyAction && !snapshotActive) { + void refreshRuntime(); + return; + } + let stopped = false; + let timer: number | undefined; + const poll = async () => { + await refreshRuntime(); + if (!stopped) timer = window.setTimeout(() => void poll(), 500); + }; + void poll(); + return () => { + stopped = true; + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [tallyAction, snapshotActive, refreshRuntime]); + + React.useEffect(() => { + if ( + !snapshotJob + || snapshotJob.requires_resume + || ["completed", "partial", "failed", "cancelled"].includes(snapshotJob.phase) + ) { + return; + } + let stopped = false; + let timer: number | undefined; + let delay = 750; + const selectionVersion = snapshotSelectionVersion.current; + const poll = async () => { + try { + const status = await invoke("tally_snapshot_status", { runId: snapshotJob.run_id }); + if (stopped || selectionVersion !== snapshotSelectionVersion.current) return; + setSnapshotJob(status); + if (["completed", "partial", "failed", "cancelled"].includes(status.phase)) { + void refreshSyncEvidence(); + void refreshRecentSnapshots(); + return; + } + delay = 750; + } catch (error) { + if (stopped || selectionVersion !== snapshotSelectionVersion.current) return; + setSnapshotError(toOperatorError(error)); + delay = Math.min(delay * 2, 10_000); + } + if (!stopped) timer = window.setTimeout(() => void poll(), delay); + }; + void poll(); + return () => { + stopped = true; + if (timer !== undefined) window.clearTimeout(timer); + }; + }, [snapshotJob?.run_id, snapshotJob?.phase, snapshotJob?.requires_resume, refreshRecentSnapshots]); + + function invalidateTallyResults() { + tallyResultsVersion.current += 1; + setStatus(null); + setPassport(null); + setProfileSha256(null); + setReviewId(null); + setReviewCommitmentSha256(null); + setSelectedReadScope(null); + setPassportSnapshotId(null); + setLiveCompanyKeys([]); + clearSensitiveDiagnostics(); + setDraft(null); + setCompanyError(null); + setSyncEvidence(null); + setSyncEvidenceError(null); + setProofPreview(null); + setProofPreviewSelection(null); + proofPreviewRequestVersion.current += 1; + setMirrorExplorer(null); + setMirrorExplorerError(null); + setSnapshotError(null); + setDashboardError(null); + } + + function clearSensitiveDiagnostics() { + diagnosticsRequestVersion.current += 1; + setDiagnosticsRevealed(false); + setLedgers([]); + setVouchers([]); + } + + function clearSelectedCompanyScope() { + clearCompanyScopedState({ + clearQualifiedReadReview: () => { + if (selectedReadScope) { + setPassport(null); + setProfileSha256(null); + setReviewId(null); + setReviewCommitmentSha256(null); + setSelectedReadScope(null); + } + }, + clearPassportSnapshot: () => setPassportSnapshotId(null), + clearSensitiveDiagnostics, + clearSyncEvidence: () => { + setSyncEvidence(null); + setSyncEvidenceError(null); + }, + clearProofPreview: () => { + setProofPreview(null); + setProofPreviewSelection(null); + proofPreviewRequestVersion.current += 1; + }, + clearMirrorExplorer: () => { + setMirrorExplorer(null); + setMirrorExplorerError(null); + }, + clearSnapshotState: () => { + snapshotSelectionVersion.current += 1; + setSnapshotJob(null); + setSnapshotError(null); + setSnapshotStartOutcomeUnknown(false); + }, + invalidateTallyResults: () => { + tallyResultsVersion.current += 1; + }, + }); + } + + function updateTallyHost(host: string) { + setConfig((current) => ({ ...current, host })); + invalidateTallyResults(); + } + + function updateTallyPort(port: number) { + setConfig((current) => ({ ...current, port })); + invalidateTallyResults(); + } async function checkTally() { - setBusy(true); + const resultsVersion = tallyResultsVersion.current; + setTallyAction("probe"); setDashboardError(null); try { - const result = await invoke("check_tally_connection", { config }); - setStatus(result); + const result = await invoke("probe_tally", { config }); + if (resultsVersion === tallyResultsVersion.current) { + const liveCompanies = result.companies.map((company) => ({ + ...company, + canonical_endpoint: result.canonical_origin, + last_observed_at_unix_ms: result.observed_at_unix_ms, + })); + const nextLiveCompanyKeys = liveCompanies.map(tallyCompanyKey); + const selection = applyProbeCompanySelectionTransition( + selectedCompany, + nextLiveCompanyKeys, + { + clearDroppedCompanyScope: clearSelectedCompanyScope, + installProbeState: () => { + setStatus(result.connection); + setPassport(result.profile); + setProfileSha256(result.profile_sha256); + setReviewId(result.review_id); + setReviewCommitmentSha256(result.review_commitment_sha256); + setSelectedReadScope(result.selected_read_scope ?? null); + setPassportSnapshotId(result.passport_snapshot_id ?? null); + setCompanies((current) => mergeTallyCompanies(liveCompanies, current)); + setLiveCompanyKeys(nextLiveCompanyKeys); + }, + }, + ); + setSelectedCompany(selection.selectedCompany); + void refreshPersistedCompanyProfiles(); + } } catch (error) { - setStatus(null); - setDashboardError(toErrorMessage(error)); + if (resultsVersion === tallyResultsVersion.current) { + setStatus(null); + setPassport(null); + setProfileSha256(null); + setReviewId(null); + setReviewCommitmentSha256(null); + setSelectedReadScope(null); + setPassportSnapshotId(null); + setLiveCompanyKeys([]); + setDashboardError(toOperatorError(error)); + } } finally { - setBusy(false); + setTallyAction(null); + void refreshRuntime(); + } + } + + async function qualifySelectedTallyReads() { + const company = companies.find((candidate) => tallyCompanyKey(candidate) === selectedCompany); + if (!reviewId || !reviewCommitmentSha256 || !company?.guid || !selectedCompanyLive) { + setCompanyError("Probe again and select one GUID-bearing company from the current result before qualifying reads."); + return; + } + if (!qualificationFrom || !qualificationTo || qualificationFrom > qualificationTo) { + setCompanyError("Choose a valid inclusive qualification window of 31 days or fewer."); + return; + } + const resultsVersion = tallyResultsVersion.current; + const reviewedCompanyKey = tallyCompanyKey(company); + const expectedReviewId = reviewId; + setTallyAction("qualify"); + setCompanyError(null); + try { + const result = await invoke("qualify_selected_tally_reads", { + request: { + config, + expected_review_id: expectedReviewId, + expected_review_commitment_sha256: reviewCommitmentSha256, + selected_company_guid: company.guid, + voucher_from_yyyymmdd: toTallyDate(qualificationFrom), + voucher_to_yyyymmdd: toTallyDate(qualificationTo), + }, + }); + if ( + resultsVersion !== tallyResultsVersion.current + || reviewedCompanyKey !== selectedCompany + || expectedReviewId !== reviewId + ) return; + setPassport(result.profile); + setProfileSha256(result.profile_sha256); + setReviewId(result.review_id); + setReviewCommitmentSha256(result.review_commitment_sha256); + setSelectedReadScope(result.selected_read_scope); + setPassportSnapshotId(null); + } catch (error) { + if (resultsVersion !== tallyResultsVersion.current) return; + const normalized = toOperatorError(error); + setCompanyError(normalized); + if ( + typeof normalized !== "string" + && ["selected_read_company_context_changed", "selected_read_review_state_uncertain"].includes(normalized.code) + ) { + setReviewId(null); + setReviewCommitmentSha256(null); + setSelectedReadScope(null); + } + } finally { + setTallyAction((current) => current === "qualify" ? null : current); + void refreshRuntime(); + } + } + + async function saveReviewedTallySetup() { + const company = companies.find((candidate) => tallyCompanyKey(candidate) === selectedCompany); + if (!reviewId || !reviewCommitmentSha256 || !company?.guid || !liveCompanyKeys.includes(tallyCompanyKey(company))) { + setCompanyError("Probe again and select a GUID-bearing company from the current result before saving."); + return; + } + const resultsVersion = tallyResultsVersion.current; + const reviewedCompanyKey = tallyCompanyKey(company); + setTallyAction("save"); + setCompanyError(null); + try { + const saved = await invoke("save_tally_setup", { + request: { + config, + expected_review_id: reviewId, + expected_review_commitment_sha256: reviewCommitmentSha256, + selected_company_guid: company.guid, + }, + }); + if (resultsVersion !== tallyResultsVersion.current) return; + const persisted = { + ...saved.company, + canonical_endpoint: saved.canonical_origin, + last_observed_at_unix_ms: saved.observed_at_unix_ms, + }; + setPassportSnapshotId(saved.passport_snapshot_id); + setCompanies((current) => mergeTallyCompanies( + [persisted], + current.filter((candidate) => tallyCompanyKey(candidate) !== reviewedCompanyKey), + )); + setSelectedCompany(tallyCompanyKey(persisted)); + setLiveCompanyKeys((current) => Array.from(new Set([ + ...current.filter((key) => key !== reviewedCompanyKey), + tallyCompanyKey(persisted), + ]))); + setReviewId(null); + setReviewCommitmentSha256(null); + if (saved.review_cleanup_warning) { + setCompanyError("The reviewed setup was saved, but its one-time in-memory review token could not be cleaned up. Restart Bridge before probing or saving another scope."); + } + void refreshPersistedCompanyProfiles(); + } catch (error) { + if (resultsVersion === tallyResultsVersion.current) { + setCompanyError(toOperatorError(error)); + } + } finally { + setTallyAction((current) => current === "save" ? null : current); + } + } + + async function cancelTallyRequest(requestId: string) { + try { + const cancelled = await invoke("cancel_tally_request", { requestId }); + if (!cancelled) { + setRuntimeError("The request had already completed or was not found."); + } + } catch (error) { + setRuntimeError(toOperatorError(error)); + } finally { + void refreshRuntime(); + } + } + + async function refreshSyncEvidence(announce = false) { + const company = companies.find((candidate) => tallyCompanyKey(candidate) === selectedCompany); + if (!company?.mirror_company_id) { + setSyncEvidence(null); + setSyncEvidenceError( + selectedCompany + ? "Run Check Tally Endpoint to persist this company GUID before reading mirror evidence." + : null, + ); + return; + } + const resultsVersion = tallyResultsVersion.current; + const mirrorCompanyId = company.mirror_company_id; + proofPreviewRequestVersion.current += 1; + setProofPreview(null); + setProofPreviewSelection(null); + if (announce) setTallyAction("evidence"); + try { + const evidence = await invoke("tally_sync_evidence", { + request: { mirror_company_id: mirrorCompanyId }, + }); + if (resultsVersion === tallyResultsVersion.current) { + setSyncEvidence(evidence); + setProofPreview(null); + setProofPreviewSelection(null); + proofPreviewRequestVersion.current += 1; + setSyncEvidenceError(null); + } + } catch (error) { + if (resultsVersion === tallyResultsVersion.current) { + setSyncEvidence(null); + setSyncEvidenceError(toOperatorError(error)); + } + } finally { + if (announce) setTallyAction(null); + } + } + + async function previewRedactedProof(proof: TallyProofSummary) { + const company = companies.find((candidate) => tallyCompanyKey(candidate) === selectedCompany); + if (!company?.mirror_company_id) { + setSyncEvidenceError("Select a company with an observed stable identity first."); + return; + } + const resultsVersion = tallyResultsVersion.current; + const requestVersion = ++proofPreviewRequestVersion.current; + const mirrorCompanyId = company.mirror_company_id; + setProofPreview(null); + setProofPreviewSelection({ proofId: proof.selection_token, runId: proof.run_id }); + try { + const preview = await invoke("preview_tally_redacted_proof", { + request: { + mirror_company_id: mirrorCompanyId, + proof_id: proof.selection_token, + }, + }); + if (resultsVersion === tallyResultsVersion.current && requestVersion === proofPreviewRequestVersion.current) { + setProofPreview(preview); + setSyncEvidenceError(null); + } + } catch (error) { + if (resultsVersion === tallyResultsVersion.current && requestVersion === proofPreviewRequestVersion.current) { + setProofPreview(null); + setProofPreviewSelection(null); + setSyncEvidenceError(toOperatorError(error)); + } + } + } + + async function loadMirrorExplorerPage(offset: number) { + const company = companies.find((candidate) => tallyCompanyKey(candidate) === selectedCompany); + if (!company?.mirror_company_id) { + setMirrorExplorerError("Select a persisted company identity before browsing the local mirror."); + return; + } + const resultsVersion = tallyResultsVersion.current; + const mirrorCompanyId = company.mirror_company_id; + setTallyAction("explorer"); + try { + const page = await invoke("tally_mirror_explorer_page", { + request: { + mirror_company_id: mirrorCompanyId, + pack_id: "core_accounting", + offset, + limit: MIRROR_PAGE_LIMIT, + }, + }); + if (resultsVersion === tallyResultsVersion.current) { + setMirrorExplorer(page); + setMirrorExplorerError(null); + } + } catch (error) { + if (resultsVersion === tallyResultsVersion.current) { + setMirrorExplorerError(toOperatorError(error)); + } + } finally { + setTallyAction(null); + } + } + + async function startCoreSnapshot() { + const company = companies.find((candidate) => tallyCompanyKey(candidate) === selectedCompany); + if (!company?.mirror_company_id) { + setSnapshotError("Run Check Tally Endpoint and select a company with an observed GUID first."); + return; + } + if (!liveCompanyKeys.includes(tallyCompanyKey(company))) { + setSnapshotError("The persisted company pin is available for offline evidence review, but it has not been matched by the current endpoint probe. Probe and select the matching live company before starting a Core Accounting read."); + return; + } + if (!voucherFrom || !voucherTo || voucherFrom > voucherTo) { + setSnapshotError("Choose a valid requested accounting period."); + return; + } + setTallyAction("start"); + setSnapshotError(null); + const selectionVersion = ++snapshotSelectionVersion.current; + try { + const job = await invoke("start_tally_core_snapshot", { + request: { + config, + mirror_company_id: company.mirror_company_id, + from: toTallyDate(voucherFrom), + to: toTallyDate(voucherTo), + }, + }); + if (selectionVersion === snapshotSelectionVersion.current) { + setSnapshotJob(job); + setRecentSnapshotRuns((current) => [ + job, + ...current.filter((run) => run.run_id !== job.run_id), + ]); + setSnapshotStartOutcomeUnknown(false); + } + void refreshRecentSnapshots(); + } catch (error) { + await refreshRecentSnapshots(); + setSnapshotStartOutcomeUnknown(true); + setSnapshotError(`Start outcome was not confirmed. Recent durable runs were refreshed and a new start is locked until you review them. ${toErrorMessage(error)}`); + } finally { + setTallyAction(null); + } + } + + async function cancelCoreSnapshot() { + if (!snapshotJob) return; + const runId = snapshotJob.run_id; + const selectionVersion = snapshotSelectionVersion.current; + setTallyAction("cancel"); + try { + const accepted = await invoke("cancel_tally_snapshot", { runId }); + const status = await invoke("tally_snapshot_status", { runId }); + if (selectionVersion === snapshotSelectionVersion.current) setSnapshotJob(status); + if (!accepted) { + setSnapshotError("Cancellation was not accepted because the run was already terminal or no longer cancellable. Status was refreshed."); + } + } catch (error) { + await refreshRecentSnapshots(); + setSnapshotError(`Cancellation outcome was not confirmed. Run status was refreshed. ${toErrorMessage(error)}`); + } finally { + setTallyAction(null); + } + } + + async function resumeCoreSnapshot(runId: string) { + const selectionVersion = ++snapshotSelectionVersion.current; + setTallyAction("resume"); + setSnapshotError(null); + try { + const job = await invoke("resume_tally_core_snapshot", { + request: { config, run_id: runId }, + }); + if (selectionVersion === snapshotSelectionVersion.current) setSnapshotJob(job); + void refreshRecentSnapshots(); + } catch (error) { + await refreshRecentSnapshots(); + setSnapshotError(`Resume outcome was not confirmed. Run status was refreshed before another resume is allowed. ${toErrorMessage(error)}`); + } finally { + setTallyAction(null); } } @@ -235,6 +1262,7 @@ function App() { return; } + const resultsVersion = tallyResultsVersion.current; setBusy(true); setDashboardError(null); try { @@ -244,52 +1272,61 @@ function App() { financial_year: financialYear, }, }); - setDraft(result); - } catch (error) { - setDraft(null); - setDashboardError(toErrorMessage(error)); - } finally { - setBusy(false); - } - } - - async function fetchCompanies() { - setBusy(true); - setCompanyError(null); - try { - const result = await invoke("fetch_tally_companies", { config }); - setCompanies(result); - setSelectedCompany((current) => - result.some((company) => company.name === current) ? current : result[0]?.name || "", - ); + if (resultsVersion === tallyResultsVersion.current) { + setDraft(result); + } } catch (error) { - setCompanyError(error instanceof Error ? error.message : String(error)); + if (resultsVersion === tallyResultsVersion.current) { + setDraft(null); + setDashboardError(toOperatorError(error)); + } } finally { setBusy(false); } } async function fetchLedgers() { + if (!diagnosticsRevealed) { + setCompanyError("Reveal sensitive diagnostics before requesting ledger data."); + return; + } if (!selectedCompany) { setCompanyError("Select a company before fetching ledgers."); return; } - setBusy(true); + const selected = companies.find((company) => tallyCompanyKey(company) === selectedCompany); + const expectedCompanyGuid = selected?.guid; + if (!expectedCompanyGuid) { + setCompanyError("This company has no observed stable GUID. Bridge will not accept company-scoped records without identity proof."); + return; + } + + const resultsVersion = tallyResultsVersion.current; + const requestVersion = diagnosticsRequestVersion.current; + setTallyAction("ledgers"); setCompanyError(null); try { const result = await invoke("fetch_tally_ledgers", { - request: { config, company: selectedCompany }, + request: { config, company: selected?.name ?? "", expected_company_guid: expectedCompanyGuid }, }); - setLedgers(result); + if (resultsVersion === tallyResultsVersion.current && requestVersion === diagnosticsRequestVersion.current) { + setLedgers(result); + } } catch (error) { - setCompanyError(error instanceof Error ? error.message : String(error)); + if (resultsVersion === tallyResultsVersion.current && requestVersion === diagnosticsRequestVersion.current) { + setCompanyError(toOperatorError(error)); + } } finally { - setBusy(false); + setTallyAction(null); } } async function fetchVouchers() { + if (!diagnosticsRevealed) { + setCompanyError("Reveal sensitive diagnostics before requesting voucher data."); + return; + } if (!selectedCompany) { setCompanyError("Select a company before fetching vouchers."); return; @@ -298,23 +1335,36 @@ function App() { setCompanyError("Choose a valid voucher date range with the from date on or before the to date."); return; } + const selected = companies.find((company) => tallyCompanyKey(company) === selectedCompany); + const expectedCompanyGuid = selected?.guid; + if (!expectedCompanyGuid) { + setCompanyError("This company has no observed stable GUID. Bridge will not accept company-scoped records without identity proof."); + return; + } - setBusy(true); + const resultsVersion = tallyResultsVersion.current; + const requestVersion = diagnosticsRequestVersion.current; + setTallyAction("vouchers"); setCompanyError(null); try { const result = await invoke("fetch_tally_vouchers", { request: { config, - company: selectedCompany, + company: selected?.name ?? "", + expected_company_guid: expectedCompanyGuid, from: toTallyDate(voucherFrom), to: toTallyDate(voucherTo), }, }); - setVouchers(result); + if (resultsVersion === tallyResultsVersion.current && requestVersion === diagnosticsRequestVersion.current) { + setVouchers(result); + } } catch (error) { - setCompanyError(error instanceof Error ? error.message : String(error)); + if (resultsVersion === tallyResultsVersion.current && requestVersion === diagnosticsRequestVersion.current) { + setCompanyError(toOperatorError(error)); + } } finally { - setBusy(false); + setTallyAction(null); } } @@ -325,9 +1375,13 @@ function App() { return; } + const requestVersion = ++dscRequestVersion.current; setBusy(true); setDscAction(detectOnly ? "detect" : "extract"); setDscError(null); + setDscReport(null); + setDscDetectReport(null); + setDscSync(null); if (!detectOnly) { setDscPin(""); } @@ -335,13 +1389,17 @@ function App() { const result = detectOnly ? await invoke("detect_dsc_token") : await invoke("extract_dsc_certificates", { pins: [pin] }); - if (detectOnly) { - setDscDetectReport(result); - } else { - setDscReport(result); + if (requestVersion === dscRequestVersion.current) { + if (detectOnly) { + setDscDetectReport(result); + } else { + setDscReport(result); + } } } catch (error) { - setDscError(error instanceof Error ? error.message : String(error)); + if (requestVersion === dscRequestVersion.current) { + setDscError(error instanceof Error ? error.message : String(error)); + } } finally { setBusy(false); setDscAction(null); @@ -551,9 +1609,70 @@ function App() { const primaryCertificate = successfulDscAttempt?.certificates.find((certificate) => certificate.common_name) ?? successfulDscAttempt?.certificates[0]; + const gstDraftComplete = draft !== null && draft.missing_fields.length === 0; + const selectedCompanyRecord = companies.find((company) => tallyCompanyKey(company) === selectedCompany); + const selectedCompanyLive = !!selectedCompanyRecord && liveCompanyKeys.includes(tallyCompanyKey(selectedCompanyRecord)); + const selectedRecentSnapshotRuns = selectedCompanyRecord?.mirror_company_id + ? recentSnapshotRuns.filter((run) => run.mirror_company_id === selectedCompanyRecord.mirror_company_id) + : []; + const latestProof = syncEvidence?.latest_proofs[0]; + const mirrorTruthState = latestProof?.verification_state ?? "unknown"; + const inspectedJob = snapshotJob?.mirror_company_id === selectedCompanyRecord?.mirror_company_id ? snapshotJob : null; + const latestDurableJob = inspectedJob + && !inspectedJob.requires_resume + && !["completed", "partial", "failed", "cancelled"].includes(inspectedJob.phase) + ? inspectedJob + : selectedRecentSnapshotRuns[0] ?? null; + const activeGapCodes = inspectedJob ? inspectedJob.gap_codes : latestProof?.gap_codes ?? []; + const activeWarningCodes = inspectedJob ? inspectedJob.warning_codes : latestProof?.warning_codes ?? []; + const latestGapCodes = latestDurableJob ? latestDurableJob.gap_codes : latestProof?.gap_codes ?? []; + const latestWarningCodes = latestDurableJob ? latestDurableJob.warning_codes : latestProof?.warning_codes ?? []; + const inspectingHistoricalRun = !!inspectedJob && !!latestDurableJob && inspectedJob.run_id !== latestDurableJob.run_id; + const verifiedBaseline = syncEvidence?.core_accounting_freshness.verified_at_unix_ms + ? `${formatIdentifier(syncEvidence.core_accounting_freshness.state)} · ${formatRuntimeTime(syncEvidence.core_accounting_freshness.verified_at_unix_ms)}` + : "No verified Core Accounting baseline"; + const latestAttemptSummary = latestDurableJob + ? `${formatIdentifier(latestDurableJob.phase)}${latestDurableJob.verification ? ` · ${formatIdentifier(latestDurableJob.verification)}` : ""}` + : latestProof + ? `${formatIdentifier(latestProof.outcome)} · ${formatIdentifier(latestProof.verification_state)} · ${formatRuntimeTime(latestProof.completed_at_unix_ms)}` + : "No Core Accounting attempt loaded"; + const latestAttemptNeedsReview = latestDurableJob + ? !!latestDurableJob.failure_code || latestDurableJob.requires_resume || ["partial", "failed", "cancelled"].includes(latestDurableJob.phase) + : !!latestProof && (latestProof.outcome !== "completed" || latestProof.verification_state !== "verified"); + const operatorMissing = !selectedCompanyRecord?.mirror_company_id + ? "A selected company with an observed, persisted GUID" + : latestAttemptNeedsReview + ? "Review of the latest non-Verified or interrupted attempt" + : !status + ? "A current endpoint and capability probe; offline evidence remains reviewable" + : latestGapCodes.length || latestWarningCodes.length + ? `${latestGapCodes.length} gap${latestGapCodes.length === 1 ? "" : "s"} and ${latestWarningCodes.length} warning${latestWarningCodes.length === 1 ? "" : "s"} in the latest attempt` + : syncEvidence?.core_accounting_freshness.state === "fresh" + ? "No gaps declared for the loaded Verified scope; unsupported or unrequested scopes are not covered" + : "A fresh Verified baseline for this company"; + const operatorNext = !selectedCompanyRecord?.mirror_company_id + ? "Select a GUID-bearing company in Tally Setup" + : snapshotJob?.resume_available + ? "Resume the interrupted run" + : snapshotActive + ? "Let the active phase finish or cancel explicitly" + : latestAttemptNeedsReview + ? latestDurableJob?.failure_code + ? `Review ${formatIdentifier(latestDurableJob.failure_code)} before relying on the older baseline` + : "Review the latest non-Verified attempt before relying on the older baseline" + : !status + ? "Review offline evidence, then probe before any new live read" + : latestGapCodes.length + ? inspectingHistoricalRun ? "Inspect the latest run, then review its Gap Map before retrying" : "Review the latest Gap Map before retrying" + : latestWarningCodes.length + ? inspectingHistoricalRun ? "Inspect the latest run, then review its warnings" : "Review warnings before relying on the latest attempt" + : syncEvidence?.core_accounting_freshness.state === "fresh" + ? "No immediate action; monitor freshness and new attempts" + : "Run a read-only Core Accounting evidence read"; return ( -
+
+ Skip to active view
- -
+
-

Foundation build

-

Tally and GST sync core

+

Tally Truth Layer

+

{VIEW_TITLES[view]}

-
+
+
+ Selected company + {selectedCompanyRecord?.name ?? "None selected"} +
+
+ Identity confidence + {selectedCompanyRecord?.mirror_company_id ? formatIdentifier(selectedCompanyRecord.identity_confidence ?? "unknown") : "Not established"} +
+
+ Pinned evidence endpoint + {selectedCompanyRecord?.canonical_endpoint ?? "No persisted endpoint"} +
+
+ Configured live endpoint + {config.host}:{config.port} +
+
+ Current probe match + {selectedCompanyLive ? "Matched" : selectedCompanyRecord ? "Offline evidence only" : "Not selected"} +
+ +
+ + {["dashboard", "companies", "mirror"].includes(view) && ( +
+
Verified baseline{verifiedBaseline}
+
Latest attempt{latestAttemptSummary}
+
What is missing?{operatorMissing}
+
What should I do?{operatorNext}
+
+ )} + {view === "dashboard" && ( <>
- -
- {dashboardError &&
{dashboardError}
} + {dashboardError && }

Tally connection

-
Status
{status ? (status.reachable ? "Reachable" : "Not reachable") : "Not checked"}
-
Product
{status?.product ?? "Unknown"}
-
Server
{status?.server_text || status?.error || "Waiting for check"}
+
Transport
{status ? (status.reachable ? "Endpoint reachable" : "Endpoint not reachable") : "Not checked"}
+
Compatibility
{status ? (status.compatible ? "Recognized Tally status; data capabilities not verified" : status.reachable ? "Endpoint responded but Tally compatibility was not recognized" : "Unavailable") : "Not checked"}
+
Status heuristic claim
{status?.product ?? "Unknown"}
+
Responder text
{status?.server_text || formatConnectionError(status?.error) || "Waiting for endpoint check"}

GST preparation

-
Company
{draft?.company ?? "No draft yet"}
-
GSTR-1 B2B
{draft?.gstr1.b2b_invoice_count ?? 0}
-
GSTR-3B taxable
{draft?.gstr3b.outward_taxable_value ?? "0.00"}
+
Status
{gstDraftComplete ? "Calculated" : draft ? "Unavailable in this build" : "Not checked"}
+
Company
{draft?.company ?? "No result"}
+
GSTR-1 B2B
{gstDraftComplete ? draft.gstr1.b2b_invoice_count : "Not available"}
+
GSTR-3B taxable
{gstDraftComplete ? draft.gstr3b.outward_taxable_value : "Not available"}
+ +
+
+
+

Capability Passport

+

+ Evidence from the latest read-only local endpoint probe. This does not establish responder authenticity, record completeness, or write permission. +

+
+ {passport ? `Profile v${passport.profile_version}` : "No current passport"} +
+ +
+
+ Product + {passport?.product || status?.product || "Unknown"} + {passport ? "Reported by this probe" : "Not observed"} +
+
+ Release + {passport?.release || "Unknown"} + No release is inferred from product text +
+
+ Mode + {passport?.mode || "Unknown"} + Education mode is labelled only when observed +
+
+ Companies returned by current probe + {passport ? liveCompanyKeys.length : "Unknown"} + + {passport?.transports.xml_http?.safe_reason_code === "company_not_loaded" + ? "XML is active, but Tally reported that no company is loaded" + : passport + ? "Persisted offline pins are excluded; this is not a source-completeness count" + : "Probe the endpoint first"} + +
+
+ Local capability observation + {passportSnapshotId ? "Stored" : "Unknown"} + + {passportSnapshotId + ? `Observation ID ${passportSnapshotId.slice(0, 8)}…` + : "No local capability observation stored"} + +
+
+ Persisted company pins + {persistedCompanyProfileTotal} + {persistedCompanyProfilesTruncated ? `Newest ${persistedCompanyProfilesLoaded} loaded; local profile list is truncated` : "Available for local evidence review; excluded from current-probe counts"} +
+
+ +
+
+

Transports

+ +
+
+

Capability packs

+

Pack support remains unknown until its declared fields and invariants are observed on this exact profile. Pack support does not establish a Verified accounting state.

+ +
+
+

Observed features

+

Unknown is intentional when this exact endpoint has not supplied enough evidence. The connection probe never writes to Tally.

+ +
+
+
- Serial Tally queue: ready - SQLite mirror: schema only + Serial Tally queue: configured, not compatibility proof + + Accounting mirror evidence: {passportSnapshotId ? "capability observation stored; record-proof status not loaded" : "no capability observation or proof status loaded"} + DSC: token detection and certificate extraction
@@ -666,113 +1891,189 @@ function App() { {view === "companies" && ( <> -
- - - -
+
+
+
+

Setup / Capability Passport

+

Connect, identify, then prove

+

Endpoint reachability and company discovery are setup evidence, not accounting completeness.

+
+ +
+
+ + +
+ {snapshotActive && ( +

Endpoint settings are locked while the active snapshot continues against its reviewed source.

+ )} +
    +
  1. Endpoint{status?.reachable ? "Reachable" : "Probe required"}
  2. +
  3. Capability Passport{passport ? (passportSnapshotId ? "Reviewed and stored" : "Observed; review before save") : "Not observed"}
  4. +
  5. Company identity{selectedCompanyRecord?.mirror_company_id ? "Observed GUID persisted" : "Select a GUID-bearing company"}
  6. +
+
-
- - - - - -
+ {dashboardError && }
-

Companies

- {companies.length} loaded +
+

Company profile

+

Every run is pinned to the selected company's observed identity.

+
+ {liveCompanyKeys.length} current probe · {persistedCompanyProfileTotal} persisted{persistedCompanyProfilesTruncated ? ` (showing newest ${persistedCompanyProfilesLoaded})` : ""}
- {companyError &&
{companyError}
} + {companyError && } {companies.length === 0 && !companyError ? (
- No companies fetched yet - Start Tally, confirm the XML server is enabled, then fetch from localhost:9000. + No companies discovered yet + Start Tally, load the intended company, enable the XML server, then run Probe and discover.
) : ( -
- - - - - - - - - - - - +
+
- - - - - - - + ))} - -
NameStateGSTINEmailPhonePincode
{company.name}{company.state || "-"}{company.gst_number || "-"}{company.email || "-"}{company.phone || company.mobile || "-"}{company.pincode || "-"}
+ + +
+
Identity confidence
{selectedCompanyRecord?.mirror_company_id ? formatIdentifier(selectedCompanyRecord.identity_confidence ?? "unknown") : "Not established"}
+
GUID reported
{selectedCompanyRecord?.guid || selectedCompanyRecord?.guid_observed ? "Yes; value hidden for persisted profiles" : "No"}
+
Pinned evidence endpoint
{selectedCompanyRecord?.canonical_endpoint ?? "Not persisted"}
+
Mirror company pin
{selectedCompanyRecord?.mirror_company_id ? "Persisted" : "Unavailable"}
+
Last observed
{formatRuntimeTime(selectedCompanyRecord?.last_observed_at_unix_ms)}
+
Current endpoint match
{selectedCompanyLive ? "Yes" : "No; evidence review only"}
+
Passport hash
{profileSha256 ? `${profileSha256.slice(0, 12)}...` : "Probe required"}
+
Exact reviewed scope
{reviewCommitmentSha256 ? `${reviewCommitmentSha256.slice(0, 12)}...` : passportSnapshotId ? "Consumed by atomic save" : "Probe required"}
+
+
+

Selected read qualification

+

Runs one ledger profile and, only if it passes, one exact voucher-window profile. Records are discarded. This does not prove source completeness, performance, pack support, or write permission.

+
+ + + +
+ {selectedReadScope && ( +
+
Ledger profile
{selectedReadScope.ledger_profile_id}
+
Ledger outcome
{formatCapabilityEvidence(passport?.features.selected_ledger_read)}
+
Voucher profile
{selectedReadScope.voucher_profile_id}
+
Voucher outcome
{formatCapabilityEvidence(passport?.features.selected_voucher_window_read)}
+
Voucher window
{formatTallyDate(selectedReadScope.voucher_from_yyyymmdd)} to {formatTallyDate(selectedReadScope.voucher_to_yyyymmdd)}
+
Scope commitment
{selectedReadScope.scope_commitment_sha256.slice(0, 12)}...
+
Data handling
Records discarded; no Tally writes attempted
+
Completeness
Not claimed
+
+ )} +
+ +

This explicit save atomically stores the current Passport, the selected company pin, and any exact selected-read scope evidence. Probing and qualification alone do not write local setup state or anything to Tally.

+
)}
-
+
{ + if (!event.currentTarget.open) { + clearSensitiveDiagnostics(); + } + }} + > + Display-capped source diagnostics (not Proof of Sync) +

Shows at most 100 returned rows. The read itself may return more; returned counts are not source-total counts and never establish completeness or accuracy.

+

Revealing can display ledger names, GSTINs, balances, voucher numbers, and party names from the selected books on screen. Use only in a private workspace.

+ +
+ + + + + +
+ +

Ledgers

- {ledgers.length} loaded + {formatPreviewCount(ledgers.length)}
{ledgers.length === 0 ? (
@@ -791,12 +2092,12 @@ function App() { - {ledgers.slice(0, 100).map((ledger) => ( + {ledgers.slice(0, TABLE_PREVIEW_LIMIT).map((ledger) => ( - {ledger.name} - {ledger.parent || "-"} - {ledger.party_gstin || "-"} - {ledger.opening_balance || "-"} + {diagnosticsRevealed ? ledger.name : "Hidden"} + {diagnosticsRevealed ? ledger.parent || "-" : "Hidden"} + {diagnosticsRevealed ? ledger.party_gstin || "-" : "Hidden"} + {diagnosticsRevealed ? ledger.opening_balance || "-" : "Hidden"} ))} @@ -808,7 +2109,7 @@ function App() {

Vouchers

- {vouchers.length} loaded + {formatPreviewCount(vouchers.length)}
{vouchers.length === 0 ? (
@@ -827,12 +2128,12 @@ function App() { - {vouchers.slice(0, 100).map((voucher, index) => ( + {vouchers.slice(0, TABLE_PREVIEW_LIMIT).map((voucher, index) => ( - {formatTallyDate(voucher.date)} - {voucher.voucher_type || "-"} - {voucher.voucher_number || "-"} - {voucher.party_ledger_name || "-"} + {diagnosticsRevealed ? formatTallyDate(voucher.date) : "Hidden"} + {diagnosticsRevealed ? voucher.voucher_type || "-" : "Hidden"} + {diagnosticsRevealed ? voucher.voucher_number || "-" : "Hidden"} + {diagnosticsRevealed ? voucher.party_ledger_name || "-" : "Hidden"} ))} @@ -840,44 +2141,395 @@ function App() {
)}
-
+
+ )} {view === "gst" && ( -
-
-

GSTR-1 draft

-
-
B2B invoices
{draft?.gstr1.b2b_invoice_count ?? 0}
-
B2C invoices
{draft?.gstr1.b2c_invoice_count ?? 0}
-
Credit/debit notes
{draft?.gstr1.credit_debit_note_count ?? 0}
-
HSN summaries
{draft?.gstr1.hsn_summary_count ?? 0}
-
-
-
-

GSTR-3B draft

-
-
Taxable value
{draft?.gstr3b.outward_taxable_value ?? "0.00"}
-
IGST
{draft?.gstr3b.integrated_tax ?? "0.00"}
-
CGST
{draft?.gstr3b.central_tax ?? "0.00"}
-
SGST
{draft?.gstr3b.state_tax ?? "0.00"}
-
+ !draft || !gstDraftComplete ? ( +
+

GST calculation unavailable

+
+ + No verified GST draft + + {draft + ? draft.missing_fields.join(" ") + : "Use GST preparation on the dashboard to check availability. Zero values are not assumed."} + +
-
+ ) : ( +
+
+

GSTR-1 draft

+
+
B2B invoices
{draft.gstr1.b2b_invoice_count}
+
B2C invoices
{draft.gstr1.b2c_invoice_count}
+
Credit/debit notes
{draft.gstr1.credit_debit_note_count}
+
HSN summaries
{draft.gstr1.hsn_summary_count}
+
+
+
+

GSTR-3B draft

+
+
Taxable value
{draft.gstr3b.outward_taxable_value}
+
IGST
{draft.gstr3b.integrated_tax}
+
CGST
{draft.gstr3b.central_tax}
+
SGST
{draft.gstr3b.state_tax}
+
+
+ +
+ ) )} {view === "mirror" && ( -
+ <> +
+
+

Truth state

+

{latestProof ? `${formatIdentifier(latestProof.outcome)} · ${formatIdentifier(latestProof.verification_state)} ${formatIdentifier(latestProof.pack_id)} attempt` : "No durable Core Accounting run receipt yet"}

+

+ {latestProof + ? `Within this run's declared Core Accounting scope, Bridge persisted ${latestProof.accepted_records} provenance-backed accepted canonical rows, ${latestProof.provenance_unavailable_records} canonical rows with an explicit provenance-unavailable gap, and ${latestProof.rejected_records} rejected rows. These are not Tally source-total counts. ${latestProof.gap_codes.length} declared gap(s) and ${latestProof.warning_codes.length} warning(s).` + : "Endpoint reachability and fetched preview rows do not establish a Verified accounting state."} +

+
+
+ + {formatIdentifier(mirrorTruthState)} + +
+ + +
+ {snapshotJob?.requested_from_yyyymmdd && snapshotJob.requested_to_yyyymmdd && ( + + Selected run period: {formatTallyDate(snapshotJob.requested_from_yyyymmdd)} to {formatTallyDate(snapshotJob.requested_to_yyyymmdd)} + + )} + + + {snapshotJob?.resume_available && ( + + )} + {snapshotActive && ( + + )} +
+
+

Reads Bridge's declared Core Accounting v3 scope for this period. It is not a native Trial Balance, a complete-books guarantee, or an atomic Tally snapshot.

+ + {syncEvidenceError && } + {snapshotError && } + {snapshotStartOutcomeUnknown && ( +
+ A previous start outcome is unknown. Inspect the refreshed durable runs before allowing another start. + +
+ )} + + {snapshotJob && ( +
+ Run {snapshotJob.run_id} + Phase: {formatIdentifier(snapshotJob.phase)} + Completed executable windows: {snapshotJob.completed_windows}/{snapshotJob.total_windows} + {snapshotJob.verification ? `Result: ${formatIdentifier(snapshotJob.verification)}` : "No verification claim yet"} + {snapshotJob.failure_code && Failure: {formatIdentifier(snapshotJob.failure_code)}} + {snapshotJob.requires_resume && ( + {snapshotJob.resume_available ? "Worker detached: explicit resume required" : "Detached legacy state: inspect only"} + )} +
+ )} + + {selectedRecentSnapshotRuns.length > 0 && ( +
+
+
+

Recent durable Core Accounting runs

+

Recovery status comes from hash-checked encrypted state, including runs discovered after an app restart.

+
+ +
+
+ + + + + {selectedRecentSnapshotRuns.slice(0, 10).map((run) => ( + + + + + + + + + ))} + +
Showing up to 10 of {selectedRecentSnapshotRuns.length} loaded runs for {selectedCompanyRecord?.name}
RunPackPhaseExecutable windowsWorkerAction
{run.run_id} {formatIdentifier(run.pack_id ?? "unknown")}{formatIdentifier(run.phase)}{run.completed_windows}/{run.total_windows}{run.resume_available ? "Resume available" : run.requires_resume ? "Inspect only" : run.phase === "completed" || run.phase === "partial" || run.phase === "failed" || run.phase === "cancelled" ? "Terminal" : "Active"}
+
+
+ )} + +
+
+ Endpoint evidence + {status ? (status.compatible ? "Compatible status observed" : status.reachable ? "Reachable; compatibility unknown" : "Not reachable") : "Not checked"} + {status ? `${config.host}:${config.port}` : "Run Check Tally Endpoint to collect a current probe."} +
+
+ Company pin + {selectedCompanyRecord?.mirror_company_id ? "Observed GUID persisted" : "Not established"} + {selectedCompanyRecord?.guid || selectedCompanyRecord?.guid_observed ? "GUID value is stored locally and hidden in this view." : "Select and probe a GUID-bearing company."} +
+
+ Last verified + {formatRuntimeTime(syncEvidence?.core_accounting_freshness.verified_at_unix_ms)} + {syncEvidence ? formatIdentifier(syncEvidence.core_accounting_freshness.state) : "Evidence not loaded"} +
+
+ Local verified checkpoint + {syncEvidence?.core_accounting_freshness.checkpoint_present ? "Bridge receipt committed" : "None"} + {syncEvidence?.core_accounting_freshness.proof_present ? "Bridge committed this local receipt atomically; it is not a Tally source watermark or source-isolation guarantee." : "Partial and failed runs never advance freshness."} +
+
+ Incremental execution + {syncEvidence?.incremental.execution_enabled ? "Enabled" : "Incremental disabled; use a new full planned read"} + + {syncEvidence + ? `${formatIdentifier(syncEvidence.incremental.state)} · ${syncEvidence.incremental.establishment_receipts} receipt(s), ${syncEvidence.incremental.active_checkpoint_heads} head(s)` + : "No exact-scope incremental evidence loaded. A full planned read does not imply source completeness or atomicity."} + +
+
+ +
+
+
+

Gap Map

+

Declared limits for the inspected attempt, with remediation and retry guidance. An empty map is not a Verified claim.

+
+ {activeGapCodes.length} gap{activeGapCodes.length === 1 ? "" : "s"} +
+ + {inspectedJob &&

Gap Map scope: inspected run {inspectedJob.run_id}. This does not replace the separate latest-attempt summary.

} + {activeWarningCodes.length > 0 && ( +
+ Warnings +
    {activeWarningCodes.map((code) =>
  • {code} — {formatIdentifier(code)}
  • )}
+
+ )} +
+ +
+
+
+

Local mirror explorer

+

Paged, privacy-preserving metadata for the selected company and Core Accounting pack. Names, amounts, source IDs, and payloads are not returned to this view.

+

Totals describe rows currently held in Bridge's local mirror for the selected pack/run state. They are not Tally source counts and may reflect a Partial attempt. Aliases are page-local and may shift after later runs.

+
+ +
+ {mirrorExplorerError && } + {!mirrorExplorer ? ( +
Mirror page not loadedThis local read does not contact Tally and remains available for persisted company pins.
+ ) : mirrorExplorer.records.length === 0 ? ( +
No local mirror rows in this selected pack scopeThe local query completed for this company and pack. This says nothing about records outside that scope.
+ ) : ( + <> +
+ + + + {mirrorExplorer.records.map((record) => ( + + + + + + + + ))} +
Showing {mirrorExplorer.offset + 1}-{Math.min(mirrorExplorer.offset + mirrorExplorer.records.length, mirrorExplorer.total_records)} of {mirrorExplorer.total_records} local records. Absence on this page is not absence from the mirror.
Local aliasObjectIdentity confidenceLast batchLifecycle
{record.local_alias}{formatIdentifier(record.object_type)}{formatIdentifier(record.identity_confidence)}{formatIdentifier(record.last_batch_state)}{record.tombstoned ? "Tombstoned" : "Present in local mirror"}
+
+
+ + Page {Math.floor(mirrorExplorer.offset / mirrorExplorer.limit) + 1} + +
+ + )} +
+
-

Local mirror

-
- - SQLite mirror is schema-only - Persistence wiring is not enabled in this Tauri agent build yet. +
+
+

Hash-linked local proof ledger

+

Append-only under Bridge's local controls. Hash checks detect inconsistency; this is not a signature, a tamper-proof audit log, or proof that the responder was genuine Tally.

+
+ Latest {syncEvidence?.latest_proofs.length ?? 0} loaded · 20-row API limit
+ {!latestProof ? ( +
+ No proof entries for this company + A production Core Accounting attempt will append its outcome, gaps, returned-row counts, and local proof hash here. +
+ ) : ( +
+ + + + + {syncEvidence?.latest_proofs.map((proof) => ( + + + + + + + + + + + + ))} + +
Loaded Proof of Sync attempt summaries; accepted/rejected values are returned run-scope rows, not source-completeness counts; older history may not be loaded
CompletedRunPackResultAccepted / rejected returned rowsProof hashGapsWarningsSupport export
{formatRuntimeTime(proof.completed_at_unix_ms)}{formatDuration(proof.started_at_unix_ms, proof.completed_at_unix_ms)}{proof.run_id} {formatIdentifier(proof.pack_id)}{formatIdentifier(proof.outcome)} · {formatIdentifier(proof.verification_state)} · Local hash check: {proof.integrity_state === "entry_hash_valid" ? "passed" : formatIdentifier(proof.integrity_state)}{proof.accepted_records} / {proof.rejected_records}{proof.proof_sha256.slice(0, 12)}... {proof.gap_codes.length ? proof.gap_codes.map(formatIdentifier).join(", ") : "None declared"}{proof.warning_codes.length ? proof.warning_codes.map(formatIdentifier).join(", ") : "None declared"}
+
+ )} + {proofPreview && ( +
+
+
+

Exact redacted support artifact for run {proofPreviewSelection?.runId ?? "unknown"}

+

Review these exact local-only bytes before saving. This is a checksum-backed local consistency record, not a signature or proof that the responder was genuine Tally.

+
+ + Save reviewed JSON + +
+ Payload checksum: {proofPreview.payload_sha256} +
{proofPreview.json}
+
+ )} + {!!syncEvidence?.latest_reconciliation_mismatches.length && ( +
+

Local reconciliation drill-down

+

Session-local aliases identify repeated affected records without exposing Tally IDs or book contents. They are deliberately excluded from the public support export.

+
    + {syncEvidence.latest_reconciliation_mismatches.map((mismatch) => ( +
  • + {formatIdentifier(mismatch.reason_code)}: {mismatch.record_aliases.join(", ") || "No record alias available"} +
  • + ))} +
+
+ )}
-
+ +
+
+
+
+

Pack readiness

+

Supported means the declared pack contract was observed for this exact profile; it does not mean complete books or a Verified run.

+
+
+ +
+ +
+

What “Verified” will require

+
    +
  • Every requested scope and window completes.
  • +
  • Tally application status and payload validation pass.
  • +
  • The company identity matches the pinned source.
  • +
  • A product-supported atomic source cut or equally strong isolation mechanism is evidenced.
  • +
  • Declared reconciliation checks pass.
  • +
+

+ Until those results are reported, Bridge will not present previews, counts, or absence of errors as accounting accuracy. +

+ {passport?.mode?.toLowerCase().includes("education") && ( +

The currently observed Education profile does not provide atomic source-cut evidence, so current Core Accounting runs remain Partial.

+ )} +
+
+ +
+
+
+

Tally runtime

+

+ Per-endpoint queue and health evidence. A closed circuit means requests are allowed; it is not proof that a pack is complete. +

+
+ +
+ {runtimeError && } + {runtimeSessions.length === 0 ? ( +
+ No endpoint session yet + Run a Tally endpoint check to create one shared runtime session. +
+ ) : ( +
+ {runtimeSessions.map((session) => ( +
+
+
+ {session.canonical_endpoint} + {formatIdentifier(session.circuit_state)} circuit · {session.active_requests} active · {session.issued_requests} issued +
+ + {formatIdentifier(session.circuit_state)} + +
+
+
Consecutive failures
{session.consecutive_failures}
+
Last success
{formatRuntimeTime(session.last_success_unix_ms)}
+
Last failure
{formatRuntimeTime(session.last_failure_unix_ms)}
+
Capability observed
{formatRuntimeTime(session.cached_capability_observed_at_unix_ms)}
+
+ {session.circuit_retry_after_unix_ms && ( +

Retry after {formatRuntimeTime(session.circuit_retry_after_unix_ms)}.

+ )} + {session.active_request_ids.length > 0 && ( +
+ {session.active_request_ids.map((requestId) => ( + + {requestId} + + + + ))} +
+ )} +
+ ))} +
+ )} +
+ )} {view === "dsc" && ( @@ -943,6 +2595,14 @@ function App() { )} + {(dscReport || dscDetectReport) && ( +
+ + Certificate and token details clear automatically after five minutes. +
+ )}
@@ -1035,7 +2695,7 @@ function App() {

Files

- {documentScan?.files.length ?? 0} ready + {formatPreviewCount(documentScan?.files.length ?? 0, "ready")}
{!documentScan?.files.length ? (
@@ -1055,7 +2715,7 @@ function App() { - {documentScan.files.slice(0, 100).map((file) => ( + {documentScan.files.slice(0, TABLE_PREVIEW_LIMIT).map((file) => ( {file.relativePath} {file.mimeType} @@ -1150,13 +2810,65 @@ function App() { )} - -
+ + ); } ReactDOM.createRoot(document.getElementById("root")!).render(); +function formatCapabilityState(state: CapabilityEvidence["state"]): string { + switch (state) { + case "supported": + return "Supported"; + case "unsupported": + return "Unsupported"; + case "not_configured": + return "Not configured"; + default: + return "Unknown"; + } +} + +function formatConfidence(confidence: CapabilityEvidence["confidence"]): string { + switch (confidence) { + case "documented": + return "Documented evidence"; + case "observed": + return "Observed by this probe"; + case "inferred": + return "Inferred, not directly observed"; + default: + return "Evidence confidence unknown"; + } +} + +function formatCapabilityReason(reason?: string): string { + if (!reason) { + return "No reason code was returned."; + } + + return CAPABILITY_REASON_LABELS[reason] || `Reason: ${formatIdentifier(reason)}.`; +} + +function formatIdentifier(value: string): string { + const words = value.replace(/_/g, " "); + return words.charAt(0).toUpperCase() + words.slice(1); +} + +function formatRuntimeTime(value?: number): string { + if (value === undefined || !Number.isFinite(value)) { + return "Not observed"; + } + return new Date(value).toLocaleString(); +} + +function formatDuration(startedAt: number, completedAt?: number): string { + if (!Number.isFinite(startedAt) || completedAt === undefined || completedAt < startedAt) return "Duration unavailable"; + const seconds = Math.round((completedAt - startedAt) / 1000); + return `Duration ${seconds}s`; +} + function toTallyDate(value: string): string { return value.replace(/-/g, ""); } @@ -1180,6 +2892,45 @@ function formatBytes(bytes: number): string { return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; } +function formatCapabilityEvidence(evidence?: CapabilityEvidence): string { + if (!evidence) return "Unknown; qualification evidence unavailable"; + const reason = evidence.safe_reason_code + ? CAPABILITY_REASON_LABELS[evidence.safe_reason_code] ?? formatIdentifier(evidence.safe_reason_code) + : "No reason supplied"; + return `${formatIdentifier(evidence.state)} / ${formatIdentifier(evidence.confidence)} — ${reason}`; +} + +function formatPreviewCount(total: number, label = "loaded"): string { + return `Showing ${Math.min(total, TABLE_PREVIEW_LIMIT)} of ${total} returned ${label}; source completeness not established`; +} + +function tallyCompanyKey(company: TallyCompany): string { + if (company.correlation_key) return `correlation:${company.correlation_key}`; + if (company.mirror_company_id) return `mirror:${company.mirror_company_id}`; + if (company.guid) return `guid:${company.guid.toLocaleLowerCase()}`; + return `unverified-name:${company.name}`; +} + +function mergeTallyCompanies(preferred: TallyCompany[], existing: TallyCompany[]): TallyCompany[] { + const merged = new Map(); + for (const company of existing) merged.set(tallyCompanyKey(company), company); + for (const company of preferred) { + const key = tallyCompanyKey(company); + const current = merged.get(key); + merged.set(key, { + ...current, + ...company, + guid: company.guid ?? current?.guid, + guid_observed: company.guid_observed ?? current?.guid_observed, + mirror_company_id: company.mirror_company_id ?? current?.mirror_company_id, + correlation_key: company.correlation_key ?? current?.correlation_key, + canonical_endpoint: company.canonical_endpoint ?? current?.canonical_endpoint, + last_observed_at_unix_ms: company.last_observed_at_unix_ms ?? current?.last_observed_at_unix_ms, + }); + } + return Array.from(merged.values()).sort((left, right) => left.name.localeCompare(right.name)); +} + function getCurrentFinancialYear(now = new Date()): { label: string; from: string; to: string } { const year = now.getFullYear(); const startYear = now.getMonth() >= 3 ? year : year - 1; @@ -1192,5 +2943,49 @@ function getCurrentFinancialYear(now = new Date()): { label: string; from: strin } function toErrorMessage(error: unknown): string { + const normalized = toOperatorError(error); + return typeof normalized === "string" + ? normalized + : `${normalized.category}: ${normalized.message} [${normalized.code}]. ${normalized.remediation}`; +} + +function getCurrentQualificationWindow(now = new Date()): { from: string; to: string } { + const to = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const from = new Date(to); + from.setDate(from.getDate() - 30); + const format = (value: Date) => [ + value.getFullYear().toString().padStart(4, "0"), + (value.getMonth() + 1).toString().padStart(2, "0"), + value.getDate().toString().padStart(2, "0"), + ].join("-"); + return { from: format(from), to: format(to) }; +} + +function toOperatorError(error: unknown): OperatorError { + if (isTallyCommandErrorEnvelope(error)) return error; return error instanceof Error ? error.message : String(error); } + +function isTallyCommandErrorEnvelope(error: unknown): error is TallyCommandErrorEnvelope { + if (!error || typeof error !== "object") return false; + const value = error as Record; + return typeof value.code === "string" + && typeof value.category === "string" + && typeof value.message === "string" + && ["safe", "after_change", "not_recommended"].includes(String(value.retry)) + && typeof value.local_state_changed === "boolean" + && typeof value.tally_state_may_have_changed === "boolean" + && typeof value.remediation === "string"; +} + +function formatConnectionError(code?: string): string { + const labels: Record = { + request_cancelled: "The read-only endpoint request was cancelled.", + endpoint_queue_deadline_exceeded: "The local endpoint queue deadline was exceeded.", + endpoint_circuit_open: "The local endpoint circuit is temporarily open.", + response_size_limit_exceeded: "The endpoint response exceeded Bridge's safety limit.", + response_encoding_invalid: "The endpoint response encoding was invalid.", + endpoint_unreachable: "The local Tally endpoint is unreachable.", + }; + return code ? labels[code] ?? "The local Tally endpoint check failed safely." : ""; +} diff --git a/src/styles.css b/src/styles.css index 9d6dc39..7bdad60 100644 --- a/src/styles.css +++ b/src/styles.css @@ -130,6 +130,21 @@ h2 { font-weight: 700; } +.panel-description, +.section-note { + color: #61756a; + line-height: 1.5; + margin: 6px 0 0; +} + +.panel-description { + max-width: 720px; +} + +.section-note { + font-size: 13px; +} + .panel-actions { border-top: 1px solid #edf1ee; margin-top: 18px; @@ -150,6 +165,117 @@ h2 { padding: 0 16px; } +.secondary-action, +.active-requests button { + border: 1px solid #c9d5ce; + border-radius: 8px; + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + background: #f8faf8; + color: #27352e; + cursor: pointer; + padding: 0 13px; +} + +.active-request { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + border: 1px solid #e0e7e2; + border-radius: 8px; + padding: 8px; +} + +.active-request code { + max-width: 420px; + overflow-wrap: anywhere; +} + +.runtime-panel { + margin-top: 18px; +} + +.runtime-list { + display: grid; + gap: 12px; +} + +.runtime-session { + border: 1px solid #dfe6e1; + border-radius: 8px; + padding: 16px; +} + +.runtime-session-heading { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; +} + +.runtime-session-heading > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.runtime-session-heading strong { + overflow-wrap: anywhere; +} + +.runtime-session-heading div span { + color: #61756a; + font-size: 13px; +} + +.runtime-health { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + margin: 16px 0 0; + background: #dfe6e1; + border: 1px solid #dfe6e1; + border-radius: 8px; + overflow: hidden; +} + +.runtime-health div { + display: grid; + gap: 5px; + background: #f8faf8; + border-top: 0; + padding: 12px; +} + +.runtime-health dt { + color: #61756a; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +.runtime-health dd { + margin: 0; + overflow-wrap: anywhere; + text-align: left; +} + +.active-requests { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} + +.active-requests button { + border-color: #e0a59a; + color: #9f2f22; +} + .primary { background: #0f766e; color: white; @@ -239,6 +365,463 @@ h2 { padding: 20px; } +.passport-panel { + margin-top: 0; +} + +.passport-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 1px; + border: 1px solid #dfe6e1; + border-radius: 8px; + background: #dfe6e1; + overflow: hidden; +} + +.passport-summary > div { + display: grid; + gap: 5px; + align-content: start; + min-height: 116px; + padding: 16px; + background: #f8faf8; +} + +.passport-summary span, +.truth-card > span { + color: #61756a; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.passport-summary strong { + font-size: 18px; + overflow-wrap: anywhere; +} + +.passport-summary small, +.truth-card small { + color: #65776d; + line-height: 1.4; +} + +.passport-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 28px; + margin-top: 24px; +} + +.passport-columns h3 { + font-size: 14px; + margin: 0 0 12px; + text-transform: uppercase; +} + +.capability-list { + display: grid; + gap: 8px; +} + +.capability-row { + display: flex; + justify-content: space-between; + align-items: center; + gap: 16px; + border: 1px solid #edf1ee; + border-radius: 8px; + padding: 12px; +} + +.capability-row > div { + display: grid; + gap: 4px; + min-width: 0; +} + +.capability-row strong { + font-size: 14px; +} + +.capability-row div span { + color: #65776d; + font-size: 12px; + line-height: 1.4; +} + +.capability-badge, +.truth-state { + flex: 0 0 auto; + border: 1px solid #d5ddd7; + border-radius: 999px; + padding: 6px 10px; + font-size: 12px; + font-weight: 800; +} + +.state-supported { + border-color: #9dcec4; + background: #eefaf6; + color: #0f665f; +} + +.state-unsupported { + border-color: #f0b8ad; + background: #fff5f2; + color: #9b2c1f; +} + +.state-unknown { + border-color: #e8cd91; + background: #fff9e9; + color: #7b5915; +} + +.state-not_configured, +.state-unobserved { + border-color: #d5ddd7; + background: #f6f8f5; + color: #52645b; +} + +.mirror-hero { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 24px; + border-left: 4px solid #d9a53b; +} + +.mirror-hero h2 { + margin-bottom: 8px; + font-size: 24px; +} + +.mirror-hero p:last-child { + max-width: 760px; + color: #52645b; + line-height: 1.55; + margin: 0; +} + +.company-context-bar { + position: sticky; + top: 0; + z-index: 5; + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)) auto; + align-items: center; + gap: 14px; + border: 1px solid #cddbd3; + border-left: 4px solid #0f766e; + border-radius: 8px; + background: rgb(248 252 249 / 96%); + box-shadow: 0 8px 24px rgb(23 32 38 / 8%); + padding: 12px 14px; + margin-bottom: 16px; + backdrop-filter: blur(8px); +} + +.skip-link { + position: fixed; + top: 8px; + left: 8px; + z-index: 100; + transform: translateY(-150%); + border-radius: 6px; + background: #10241b; + color: #fff; + padding: 10px 14px; +} + +.skip-link:focus { + transform: translateY(0); +} + +.content:focus { + outline: none; +} + +.privacy-warning { + border-left: 3px solid #b36b00; + background: #fff8e8; + color: #6c4700; + padding: 10px 12px; +} + +.copy-control { + display: inline-flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} + +.copy-status { + color: #4f6358; + font-size: 12px; +} + +.copy-failed { + color: #a32929; +} + +.copy-fallback { + flex: 1 1 240px; + min-width: min(100%, 240px); + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + font-size: 12px; +} + +.company-context-bar div, +.operator-question-grid article { + display: grid; + gap: 4px; +} + +.company-context-bar span, +.operator-question-grid span { + color: #61756a; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.company-context-bar strong { + overflow-wrap: anywhere; +} + +.operator-question-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 0 0 18px; +} + +.operator-question-grid article { + min-height: 104px; + align-content: start; + border: 1px solid #dfe6e1; + border-radius: 8px; + background: white; + padding: 14px; +} + +.operator-question-grid strong { + line-height: 1.4; + overflow-wrap: anywhere; +} + +.setup-workflow, +.diagnostic-disclosure, +.gap-panel { + margin-bottom: 18px; +} + +.setup-fields { + padding-bottom: 18px; +} + +.setup-steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 0; + padding: 0; + list-style: none; +} + +.setup-steps li { + display: grid; + gap: 5px; + border: 1px solid #e8cd91; + border-radius: 8px; + background: #fff9e9; + padding: 12px; +} + +.setup-steps li.step-complete { + border-color: #b8dfd2; + background: #eefaf6; +} + +.setup-steps span { + color: #52645b; + font-size: 13px; +} + +.company-profile-grid { + display: grid; + grid-template-columns: minmax(240px, 1fr) minmax(280px, 2fr) auto; + align-items: end; + gap: 18px; +} + +.qualification-panel { + grid-column: 1 / -1; + padding: 1rem; + border: 1px solid #c7d8cf; + border-radius: 0.75rem; + background: #f1f8f4; +} + +.qualification-panel h3 { + margin-top: 0; +} + +.company-profile-grid > label, +.snapshot-scope label { + display: grid; + gap: 6px; + color: #52645b; + font-size: 13px; + font-weight: 700; +} + +.company-profile-grid select, +.snapshot-scope input { + min-height: 40px; + border: 1px solid #d5ddd7; + border-radius: 8px; + background: white; + padding: 0 10px; +} + +.diagnostic-disclosure > summary { + cursor: pointer; + font-weight: 800; +} + +.diagnostic-disclosure[open] > summary { + margin-bottom: 10px; +} + +.snapshot-scope { + display: grid; + grid-template-columns: repeat(2, minmax(130px, 1fr)); + gap: 8px; + margin: 10px 0; +} + +.gap-map { + display: grid; + gap: 10px; +} + +.gap-item { + display: grid; + grid-template-columns: minmax(220px, 0.8fr) minmax(280px, 1.4fr) auto; + align-items: center; + gap: 16px; + border: 1px solid #ead7aa; + border-radius: 8px; + background: #fffaf0; + padding: 14px; +} + +.gap-item > div { + display: grid; + gap: 5px; +} + +.gap-item code, +.run-token code { + overflow-wrap: anywhere; +} + +.gap-item p { + margin: 0; + color: #52645b; + line-height: 1.45; +} + +.retry-guidance { + border-radius: 999px; + padding: 7px 10px; + font-size: 12px; + font-weight: 800; + text-align: center; +} + +.retry-after_change { + background: #eefaf6; + color: #0f665f; +} + +.retry-not_useful { + background: #f4efe4; + color: #705824; +} + +.warning-list { + border-top: 1px solid #e7dfc9; + margin-top: 16px; + padding-top: 14px; +} + +.pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-top: 1px solid #edf1ee; + margin-top: 14px; + padding-top: 14px; +} + +.copy-token { + border: 1px solid #c9d5ce; + border-radius: 6px; + background: white; + color: #27352e; + cursor: pointer; + min-height: 30px; + padding: 3px 8px; +} + +.truth-state { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.truth-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-top: 18px; +} + +.truth-card { + display: grid; + gap: 8px; + align-content: start; + min-height: 132px; + border: 1px solid #dfe6e1; + border-radius: 8px; + background: white; + padding: 16px; +} + +.truth-card strong { + font-size: 17px; + overflow-wrap: anywhere; +} + +.mirror-details { + margin-top: 18px; +} + +.verification-list { + display: grid; + gap: 10px; + margin: 0; + padding-left: 22px; + color: #34453c; + line-height: 1.4; +} + .certificate-panel { min-height: 300px; } @@ -285,10 +868,74 @@ h2 { overflow-wrap: anywhere; } +.error-banner strong, +.error-banner span, +.error-banner small { + display: block; +} + +.error-banner span { + margin-top: 4px; +} + +.error-banner small { + margin-top: 6px; + color: #71433b; +} + +caption { + color: #61756a; + font-size: 13px; + padding: 0 0 10px; + text-align: left; +} + +td > small { + display: block; + color: #61756a; + margin-top: 4px; +} + .table-wrap { overflow-x: auto; } +.proof-export-preview { + border-top: 1px solid #dfe8e2; + margin-top: 20px; + padding-top: 20px; +} + +.proof-export-preview h3 { + margin: 0; +} + +.proof-export-preview .secondary-action { + text-decoration: none; + white-space: nowrap; +} + +.proof-export-preview > small { + display: block; + color: #52645b; + margin-bottom: 10px; + overflow-wrap: anywhere; +} + +.proof-export-preview pre { + background: #101713; + border-radius: 8px; + color: #dce9e1; + font-size: 12px; + line-height: 1.45; + margin: 0; + max-height: 420px; + overflow: auto; + padding: 16px; + white-space: pre-wrap; + word-break: break-word; +} + table { width: 100%; border-collapse: collapse; @@ -364,10 +1011,20 @@ dd { } button:disabled { - cursor: wait; + cursor: not-allowed; opacity: 0.78; } +button:focus-visible, +a:focus-visible, +input:focus-visible, +select:focus-visible, +summary:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid #1d9a90; + outline-offset: 3px; +} + @keyframes spin { to { transform: rotate(360deg); @@ -409,4 +1066,53 @@ button:disabled { .grid { grid-template-columns: 1fr; } + + .passport-summary, + .truth-grid, + .operator-question-grid, + .runtime-health { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .passport-columns { + grid-template-columns: 1fr; + } + + .company-context-bar, + .company-profile-grid, + .gap-item { + grid-template-columns: 1fr; + } +} + +@media (max-width: 520px) { + .content { + padding: 20px; + } + + .passport-summary, + .truth-grid, + .operator-question-grid, + .setup-steps, + .runtime-health { + grid-template-columns: 1fr; + } + + .capability-row, + .mirror-hero, + .runtime-session-heading { + align-items: flex-start; + flex-direction: column; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } diff --git a/src/tally-company-selection.ts b/src/tally-company-selection.ts new file mode 100644 index 0000000..709226f --- /dev/null +++ b/src/tally-company-selection.ts @@ -0,0 +1,55 @@ +// SPDX-License-Identifier: Apache-2.0 + +export type CompanyScopeCleanup = { + clearQualifiedReadReview: () => void; + clearPassportSnapshot: () => void; + clearSensitiveDiagnostics: () => void; + clearSyncEvidence: () => void; + clearProofPreview: () => void; + clearMirrorExplorer: () => void; + clearSnapshotState: () => void; + invalidateTallyResults: () => void; +}; + +export type ProbeSelectionTransition = { + selectedCompany: string; + dropped: boolean; +}; + +export type ProbeSelectionEffects = { + clearDroppedCompanyScope: () => void; + installProbeState: () => void; +}; + +export function reconcileProbeCompanySelection( + selectedCompany: string, + liveCompanyKeys: readonly string[], +): ProbeSelectionTransition { + const dropped = selectedCompany !== "" && !liveCompanyKeys.includes(selectedCompany); + return { + selectedCompany: dropped ? "" : selectedCompany, + dropped, + }; +} + +export function applyProbeCompanySelectionTransition( + selectedCompany: string, + liveCompanyKeys: readonly string[], + effects: ProbeSelectionEffects, +): ProbeSelectionTransition { + const transition = reconcileProbeCompanySelection(selectedCompany, liveCompanyKeys); + if (transition.dropped) effects.clearDroppedCompanyScope(); + effects.installProbeState(); + return transition; +} + +export function clearCompanyScopedState(cleanup: CompanyScopeCleanup) { + cleanup.clearQualifiedReadReview(); + cleanup.clearPassportSnapshot(); + cleanup.clearSensitiveDiagnostics(); + cleanup.clearSyncEvidence(); + cleanup.clearProofPreview(); + cleanup.clearMirrorExplorer(); + cleanup.clearSnapshotState(); + cleanup.invalidateTallyResults(); +}