lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README

commit 577efcb4fe88f7aaff75bdd5a16a09debbcfc034
parent 2c967a9d5de11b1985e16a6a001752e11fd1c3f2
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Jul 2026 03:19:46 +0000

event-store: add current visibility and FoodAvailability projection

- add schema v3 current visibility, transition feed, and FoodAvailability projection
- bind migration, vectors, and source-capacity guards to the successor contract
- split relay outcomes and bound raw fetch resource consumption
- quarantine legacy replica ingest behind a non-default feature

Diffstat:
MCHANGELOG.md | 40+++++++++++++++++++++++++++++-----------
MCargo.lock | 1+
Mbuild/nix/checks.nix | 35+++++++++++++++++++++++++++++++++++
Acontracts/conformance/vectors/event_store/food_availability_projection.v1.json | 2889+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/coverage-profiles.toml | 5+++++
Mcontracts/event_boundary_matrix.md | 19+++++++++++--------
Mcontracts/events/social-events.md | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Mcontracts/manifest.toml | 2++
Mcontracts/releases/1.0.0-alpha.1.toml | 32++++++++++++++++++++++++++++++++
Mcontracts/replica.toml | 6++++++
Mcrates/event_store/Cargo.toml | 5++++-
Mcrates/event_store/README | 92++++++++++++++++++++++++++++++++++++++++++++++---------------------------------
Acrates/event_store/contracts/food_availability_projection_v1.manifest.json | 418+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/contracts/food_availability_projection_v1.manifest.schema.json | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/contracts/food_availability_projection_v1.manifest.sha256 | 1+
Acrates/event_store/migrations/0003_food_availability_projection.down.sql | 26++++++++++++++++++++++++++
Acrates/event_store/migrations/0003_food_availability_projection.up.sql | 638+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/error.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/generated.rs | 1+
Acrates/event_store/src/generated/food_availability_projection_manifest.rs | 35+++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/lib.rs | 33+++++++++++++++++++++++++++------
Mcrates/event_store/src/migrations.rs | 289++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/event_store/src/model.rs | 42+++++++++++++++++++++++++++++++++++++++++-
Acrates/event_store/src/model/addressable_transition_feed_v1.rs | 647+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/src/model/current_visibility_v1.rs | 154+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/src/model/food_availability_projection_v1.rs | 531+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/nip09/reconciliation_v1.rs | 15+++++++++++++--
Mcrates/event_store/src/schema.rs | 173+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/event_store/src/store.rs | 2437+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Acrates/event_store/src/store/addressable_transition_feed_v1.rs | 958+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/src/store/current_visibility_v1.rs | 345+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/src/store/food_availability_projection_v1.rs | 952+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/store/post_core_extension_capabilities.rs | 7+++++++
Mcrates/event_store/src/store/post_core_extension_dispatcher.rs | 1+
Acrates/event_store/src/store/post_core_extensions_v2.rs | 8++++++++
Acrates/event_store/src/store/post_core_storage_v2.rs | 19+++++++++++++++++++
Mcrates/event_store/src/store/protocol_storage_v1.rs | 6+-----
Acrates/event_store/tests/fixtures/food_availability_projection.v1.json | 2889+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/tests/food_availability_projection_v1_result_vector.rs | 1005+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/replica_sync/Cargo.toml | 9++++++---
Mcrates/replica_sync/README | 13+++++++++----
Mcrates/replica_sync/src/lib.rs | 4+++-
Mcrates/transport_nostr/README | 39+++++++++++++++++++++++++++------------
Mcrates/transport_nostr/src/error.rs | 19+++++++++++++++++++
Mcrates/transport_nostr/src/fetch.rs | 657++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
Mcrates/transport_nostr/src/lib.rs | 15+++++++++------
Mcrates/transport_nostr/src/publish.rs | 36+++++++++++++++++++++++++++++++++++-
Mcrates/transport_nostr/tests/transport.rs | 719+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Mtools/xtask/src/contract.rs | 331++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Atools/xtask/src/contract/food_availability_projection.rs | 4367+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract/nip09_reconciliation.rs | 1167++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Mtools/xtask/src/main.rs | 20++++++++++++++++++++
52 files changed, 21988 insertions(+), 663 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -89,10 +89,16 @@ publish policy both pass for the same source revision. observations, or heads. Read-only consumers can inspect the same fail-closed status summary from an initialized pool without duplicating schema-sensitive SQL or running migrations. -- Nostr fetch-ingest receipts now distinguish admitted, unsupported, invalid, - malformed, inserted, duplicate, and ephemeral not-persisted events, carry - stable admission codes when classification occurs, and name valid-stream - eligibility directly. Local event-store failures now abort fetch ingest as +- Nostr fetch-ingest receipts now report exhaustive verification, contract + admission, valid-stream, and current-visibility outcomes independently. + Verification failures no longer share an `invalid` bucket with contract + failures, unsupported admissions retain their stable code without masking + visibility, and persisted events obtain visibility from the event store's + central authority. Fetches enforce a 64,000 raw-event ceiling, a 64 MiB + aggregate raw-JSON prefix budget, and the 256 KiB per-event wire limit before + Radroots parses adapter raw JSON; after upstream SDK frame decoding, the + official SDK stream applies the same retained-prefix bounds before retaining + serialized adapter output. Local event-store failures abort fetch ingest as operational errors instead of being reported as malformed relay input. - Generic outbox APIs now reject every NIP-16 ephemeral event before durable queue persistence. Live-only events, including NIP-42 relay-auth and NIP-98 @@ -102,6 +108,16 @@ publish policy both pass for the same source revision. - Retired trade order-workflow and product-projection source files that were no longer compiled or exported have been removed. Current FoodAvailability projection ownership remains with the event store. +- Event-store schema v3 adds one central current-visibility authority, a + generation-bound addressable transition feed, and an atomic focused + FoodAvailability projection with bounded FTS search. The successor contract + authenticates schema `0003`, registry-v7 admission, exact kind scope `30402`, + executable transition/projection vectors, and the frozen NIP-09 predecessor. + Stored Blossom image digests use the public typed SHA-256 value. +- Bare-envelope replica ingestion is quarantined behind the explicit, + non-default `legacy-ingest` feature. Default replica APIs expose emit and sync + surfaces only; a future product ingest boundary must consume a store-produced + verified, valid-stream-eligible, currently visible admission. - Blossom blob URLs now validate complete raw Unicode text before URL parsing and exact raw ASCII DNS label grammar before returning a typed value. Unicode control/format text, implicit IDNA conversion, empty labels, underscores, @@ -213,13 +229,15 @@ publish policy both pass for the same source revision. Generic signing and client publication reject focused or mixed kind-`30402` profiles before signer access; signed-event relay remains transport-only. Typed signing and publication do not attest BUD-02 upload completion. -- Legacy replica ingestion now verifies kind-`30402` signatures, selects the - raw addressable head before profile decoding, and sends only the Operational - Listing partition to its trade-product projection. Selected focused/generic - exclusions and invalid/ambiguous rejections remove an older projection while - advancing the head, preventing stale projection fallback. The public - head-only helper rejects kind `30402`; callers must use profile-aware - ingestion so the head and projection remain atomic. +- Behind the explicit non-default `legacy-ingest` feature, legacy replica + ingestion verifies kind-`30402` signatures, selects the raw addressable head + before profile decoding, and sends only the Operational Listing partition to + its trade-product projection. Selected focused/generic exclusions and + invalid/ambiguous rejections remove an older projection while advancing the + head, preventing stale projection fallback. The feature-gated public + head-only helper rejects kind `30402`; callers must use profile-aware legacy + ingestion so the head and projection remain atomic. These helpers are not a + Phase 1 product ingest boundary. - Event-contract identification now selects Operational Listing only for its raw marker partition. Focused FoodAvailability is admission-only, while marker-free generic and mixed-marker NIP-99 events cannot be mislabeled as diff --git a/Cargo.lock b/Cargo.lock @@ -4596,6 +4596,7 @@ dependencies = [ "getrandom 0.2.17", "hex", "nostr", + "radroots_blossom", "radroots_event", "radroots_event_codec", "radroots_transport", diff --git a/build/nix/checks.nix b/build/nix/checks.nix @@ -25,11 +25,46 @@ let installPhaseCommand = "mkdir -p $out"; } ); + mkReplicaSyncLane = + { + pname, + command, + }: + common.craneLib.mkCargoDerivation ( + common.commonCraneArgs + // { + inherit (common) cargoArtifacts; + inherit pname; + doCheck = false; + buildPhaseCargoCommand = command; + installPhaseCommand = "mkdir -p $out"; + } + ); + replicaSyncDefaultCheck = mkReplicaSyncLane { + pname = "radroots-replica-sync-default-check"; + command = "cargo check -p radroots_replica_sync --all-targets"; + }; + replicaSyncDefaultTest = mkReplicaSyncLane { + pname = "radroots-replica-sync-default-test"; + command = "cargo test -p radroots_replica_sync"; + }; + replicaSyncLegacyCheck = mkReplicaSyncLane { + pname = "radroots-replica-sync-legacy-ingest-check"; + command = "cargo check -p radroots_replica_sync --all-targets --features legacy-ingest"; + }; + replicaSyncLegacyTest = mkReplicaSyncLane { + pname = "radroots-replica-sync-legacy-ingest-test"; + command = "cargo test -p radroots_replica_sync --features legacy-ingest"; + }; in { cargo-fmt = cargoFmt; cargo-check = cargoCheck; cargo-test = cargoTest; + replica-sync-default-check = replicaSyncDefaultCheck; + replica-sync-default-test = replicaSyncDefaultTest; + replica-sync-legacy-ingest-check = replicaSyncLegacyCheck; + replica-sync-legacy-ingest-test = replicaSyncLegacyTest; guards = common.mkRepoCheck { name = "repo-guards"; diff --git a/contracts/conformance/vectors/event_store/food_availability_projection.v1.json b/contracts/conformance/vectors/event_store/food_availability_projection.v1.json @@ -0,0 +1,2889 @@ +{ + "schema_version": 1, + "contract_id": "radroots_event_store.food_availability_projection_v1", + "feed_version": 1, + "projection_version": 1, + "scope_kinds": [ + 30402 + ], + "cases": [ + { + "id": "visible_food_availability_projects_and_searches", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "b9d0da50b69689fdd71adc0d698b3e2d04e53c94ec31e23496b4d2fdb96bc1719c5d626881f407682f287f2a5d2bc57159f70a8cd3d1aaae96bd1394c5b1ea0a" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "content": "Carrots available this week.", + "title": "Nantes Carrots", + "summary": "Fresh bunches", + "published_at": 1700000000, + "location": "Central Saanich, BC", + "price_amount": "3", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": null, + "quantity_unit": null, + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "Fresh", + "event_ids": [ + "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7" + ] + }, + { + "query": "Nantes Saanich", + "event_ids": [ + "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7" + ] + } + ], + "transition_page": { + "source_high_water": 1, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 1 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_json_sha256": "817789914f92ad1401bb128f2c176f725143cbbc10f0d1b97fa4b31d5d1ab05f", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [] + } + }, + { + "id": "invalid_same_timestamp_winner_retracts_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "b9d0da50b69689fdd71adc0d698b3e2d04e53c94ec31e23496b4d2fdb96bc1719c5d626881f407682f287f2a5d2bc57159f70a8cd3d1aaae96bd1394c5b1ea0a" + } + }, + { + "role": "scoped_food", + "observed_at_ms": 1700000060001, + "expected_ingest": { + "admission_status": "invalid", + "admission_code": "food_status_invalid", + "contract_id": null, + "event_class": "addressable", + "valid_stream_eligible": false, + "raw_head_decision": "applied" + }, + "event": { + "id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "paused" + ] + ], + "content": "Carrots available this week.", + "sig": "5728cd88796dc4beed1d293db77a9e5a9297bb9e3d7d56b9ee108f3c6d3ad2fd5468b359d1fba09a4eeca7539d678d2e373c8bd151969e19feaceb9aebaca917" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "projection": null, + "searches": [ + { + "query": "Fresh", + "event_ids": [] + } + ], + "transition_page": { + "source_high_water": 2, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 2 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_json_sha256": "817789914f92ad1401bb128f2c176f725143cbbc10f0d1b97fa4b31d5d1ab05f", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "event_seq": 2 + }, + "raw_head_created_at": 1700000060, + "admission_status": "invalid", + "admission_code": "food_status_invalid", + "contract_id": null, + "visibility": "not_admitted", + "suppression": null, + "cause_event": { + "event": { + "event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "event_seq": 2 + }, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "invalid", + "admission_code": "food_status_invalid", + "contract_id": null + }, + "canonical_visible_event": null, + "retracted_event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "invalid", + "decision": "not_admitted", + "is_raw_head": true, + "raw_head_event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "suppression": null + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "final_decision": "not_current" + } + ] + } + }, + { + "id": "blossom_digest_and_image_diagnostics_are_preserved", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "image", + "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp" + ], + [ + "image", + "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp", + "0x600", + "extra" + ], + [ + "image", + "not-a-url", + "800x600" + ] + ], + "content": "Carrots available this week.", + "sig": "1d5eb699944bc1eba93c85071ad1d94878f1c767015391e200b45f1a2066558675a382ede737fb15307a944598687843618c067accf6608f53daad377683c2c9" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "content": "Carrots available this week.", + "title": "Nantes Carrots", + "summary": "Fresh bunches", + "published_at": 1700000000, + "location": "Central Saanich, BC", + "price_amount": "3", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": null, + "quantity_unit": null, + "status": "active", + "diagnostics": [ + "food_image_shape_invalid", + "food_image_dimensions_missing", + "food_image_shape_invalid", + "food_image_dimensions_invalid", + "food_image_duplicate_url", + "food_image_duplicate_digest", + "food_image_url_invalid" + ], + "images": [ + { + "url": "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp", + "width": null, + "height": null, + "blossom_sha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "diagnostics": [ + "food_image_shape_invalid", + "food_image_dimensions_missing" + ], + "qualifies": false + }, + { + "url": "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp", + "width": null, + "height": null, + "blossom_sha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "diagnostics": [ + "food_image_shape_invalid", + "food_image_dimensions_invalid", + "food_image_duplicate_url", + "food_image_duplicate_digest" + ], + "qualifies": false + }, + { + "url": null, + "width": 800, + "height": 600, + "blossom_sha256": null, + "diagnostics": [ + "food_image_url_invalid" + ], + "qualifies": false + } + ] + }, + "searches": [ + { + "query": "Carrots", + "event_ids": [ + "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe" + ] + } + ], + "transition_page": { + "source_high_water": 1, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 1 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "event_seq": 1 + }, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "event_seq": 1 + }, + "raw_json_sha256": "090b5663c36c2d7ee1361866ec6314d64e9c3ba386d430e1265aa4bb4c563884", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [] + } + }, + { + "id": "authorized_address_deletion_retracts_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "causal", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1", + "event_class": "regular", + "valid_stream_eligible": true, + "raw_head_decision": "not_head_selected" + }, + "event": { + "id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 5, + "tags": [ + [ + "a", + "30402:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:nantes-carrots" + ] + ], + "content": "Harvest listing withdrawn.", + "sig": "d360cbde755ad9c04bcc8a82c5d144bbb98bb1366304f0162544622ab648267b0e8e4e8d35baffe98c0cac7dc67e0c1a44a7ae172db49f71641291294759da28" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": null, + "searches": [ + { + "query": "Fresh", + "event_ids": [] + } + ], + "transition_page": { + "source_high_water": 2, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 2 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "suppressed", + "suppression": { + "outcome": "suppressed", + "reason": "deletion_address_reference", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + }, + "cause_event": { + "event": { + "event_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "event_seq": 2 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 5, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1" + }, + "canonical_visible_event": null, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "not_head_selected" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "suppressed", + "is_raw_head": true, + "raw_head_event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "suppression": { + "outcome": "suppressed", + "reason": "deletion_address_reference", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + } + }, + { + "event": { + "event_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": null, + "suppression": { + "outcome": "visible", + "reason": "deletion_request_immune", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "suppressed" + } + ] + } + }, + { + "id": "wrong_author_address_deletion_preserves_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "causal", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1", + "event_class": "regular", + "valid_stream_eligible": true, + "raw_head_decision": "not_head_selected" + }, + "event": { + "id": "51fa76379d859f70a30f4e44b97373c35bf86cc1efdce2bf23ed0e8605387535", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + "created_at": 1700000070, + "kind": 5, + "tags": [ + [ + "a", + "30402:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:nantes-carrots" + ] + ], + "content": "Unauthorized withdrawal attempt.", + "sig": "58e88c1dcd442c6336a090085d8945cb935aff84f859e8132fa9fd4d596ae746c366b56e4a73f23490e550b6d0b0ba60489e1aa3130e7018b4397d4dab3039d2" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "content": "Carrots available this week.", + "title": "Nantes Carrots", + "summary": "Fresh bunches", + "published_at": 1700000000, + "location": "Central Saanich, BC", + "price_amount": "3", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": null, + "quantity_unit": null, + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "Fresh", + "event_ids": [ + "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd" + ] + } + ], + "transition_page": { + "source_high_water": 2, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 2 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_request_author_mismatch", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "51fa76379d859f70a30f4e44b97373c35bf86cc1efdce2bf23ed0e8605387535", + "event_seq": 2 + }, + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + "created_at": 1700000070, + "kind": 5, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "not_head_selected" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "suppression": { + "outcome": "visible", + "reason": "deletion_request_author_mismatch", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "51fa76379d859f70a30f4e44b97373c35bf86cc1efdce2bf23ed0e8605387535", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": null, + "suppression": { + "outcome": "visible", + "reason": "deletion_request_immune", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [] + } + }, + { + "id": "post_cutoff_replacement_restores_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "causal", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1", + "event_class": "regular", + "valid_stream_eligible": true, + "raw_head_decision": "not_head_selected" + }, + "event": { + "id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 5, + "tags": [ + [ + "a", + "30402:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:nantes-carrots" + ] + ], + "content": "Harvest listing withdrawn.", + "sig": "d360cbde755ad9c04bcc8a82c5d144bbb98bb1366304f0162544622ab648267b0e8e4e8d35baffe98c0cac7dc67e0c1a44a7ae172db49f71641291294759da28" + } + }, + { + "role": "scoped_food", + "observed_at_ms": 1700000080000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "New harvest bunches" + ], + [ + "published_at", + "1700000080" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3.5", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "radroots:quantity", + "24", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "A new carrot harvest is available.", + "sig": "fcfbab5dee277d70fb26740a8b058a884c82ceedc83819ff6920cc42634adfbbbe149c2cc207f2c176436c0cb738c007d3fc378d0d530d09d45d818ecad5be4c" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "content": "A new carrot harvest is available.", + "title": "Nantes Carrots", + "summary": "New harvest bunches", + "published_at": 1700000080, + "location": "Central Saanich, BC", + "price_amount": "3.5", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": "24", + "quantity_unit": "lb", + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "New harvest", + "event_ids": [ + "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d" + ] + } + ], + "transition_page": { + "source_high_water": 3, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 3 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "suppressed", + "suppression": { + "outcome": "suppressed", + "reason": "deletion_address_reference", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + }, + "cause_event": { + "event": { + "event_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "event_seq": 2 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 5, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1" + }, + "canonical_visible_event": null, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "not_head_selected" + }, + { + "transition_seq": 3, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_head_created_at": 1700000080, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_address_cutoff_precedes_target", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + }, + "cause_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_json_sha256": "465617b3c73ea58efd9351aa162540614ac50c92115d599a582fec4b98b5e195", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "suppressed", + "reason": "deletion_address_reference", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + } + }, + { + "event": { + "event_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": null, + "suppression": { + "outcome": "visible", + "reason": "deletion_request_immune", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_address_cutoff_precedes_target", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "not_current" + } + ] + } + }, + { + "id": "operational_listing_head_retracts_food_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "scoped_non_food", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "p", + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ], + [ + "a", + "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000070" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "inventory", + "24" + ], + [ + "status", + "active" + ], + [ + "delivery", + "pickup" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "fd70b10cd97d71e49a622482edcc6d4a1ab065fa2d614ca848187fd4c00a8ea3c46c414a96d26b75f0244f03a0cfd0b94495d3ef46cedeec05d1cd84e8864bbb" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": null, + "searches": [ + { + "query": "Nantes", + "event_ids": [] + }, + { + "query": "Carrots available", + "event_ids": [] + } + ], + "transition_page": { + "source_high_water": 2, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 2 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_head_created_at": 1700000070, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_json_sha256": "19a34cd6af7a207c60b1c3e0c9b4f58e9cb7af2a8c26f323e7570298992833cb", + "admission_status": "admitted", + "contract_id": "radroots.operational_listing.published.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "not_current" + } + ] + } + }, + { + "id": "food_head_after_operational_listing_restores_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "scoped_non_food", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "p", + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ], + [ + "a", + "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000070" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "inventory", + "24" + ], + [ + "status", + "active" + ], + [ + "delivery", + "pickup" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "fd70b10cd97d71e49a622482edcc6d4a1ab065fa2d614ca848187fd4c00a8ea3c46c414a96d26b75f0244f03a0cfd0b94495d3ef46cedeec05d1cd84e8864bbb" + } + }, + { + "role": "scoped_food", + "observed_at_ms": 1700000080000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "New harvest bunches" + ], + [ + "published_at", + "1700000080" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3.5", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "radroots:quantity", + "24", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "A new carrot harvest is available.", + "sig": "fcfbab5dee277d70fb26740a8b058a884c82ceedc83819ff6920cc42634adfbbbe149c2cc207f2c176436c0cb738c007d3fc378d0d530d09d45d818ecad5be4c" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "content": "A new carrot harvest is available.", + "title": "Nantes Carrots", + "summary": "New harvest bunches", + "published_at": 1700000080, + "location": "Central Saanich, BC", + "price_amount": "3.5", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": "24", + "quantity_unit": "lb", + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "New harvest", + "event_ids": [ + "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d" + ] + } + ], + "transition_page": { + "source_high_water": 3, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 3 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_head_created_at": 1700000070, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_json_sha256": "19a34cd6af7a207c60b1c3e0c9b4f58e9cb7af2a8c26f323e7570298992833cb", + "admission_status": "admitted", + "contract_id": "radroots.operational_listing.published.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "applied" + }, + { + "transition_seq": 3, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_head_created_at": 1700000080, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_json_sha256": "465617b3c73ea58efd9351aa162540614ac50c92115d599a582fec4b98b5e195", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "not_current" + }, + { + "transition_seq": 2, + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "final_decision": "not_current" + } + ] + } + }, + { + "id": "food_feed_cursor_advances_across_unrelated_addressable_traffic", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "unrelated_addressable", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.farm.profile.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "384e9060b4bbc919ca2ea75a47241078a5ca911c18450d62081232e10b66fea4", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30340, + "tags": [ + [ + "d", + "victoria-farm-traffic" + ] + ], + "content": "{}", + "sig": "7987bef7ebd6c23b6d8a1435600ded7804acaf4caa28a054e977da27c10b1493101d822eb0a113872b7bb07fc5632dbec0e5205f805b0e6d240da30b9c08d862" + } + }, + { + "role": "scoped_food", + "observed_at_ms": 1700000080000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "New harvest bunches" + ], + [ + "published_at", + "1700000080" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3.5", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "radroots:quantity", + "24", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "A new carrot harvest is available.", + "sig": "fcfbab5dee277d70fb26740a8b058a884c82ceedc83819ff6920cc42634adfbbbe149c2cc207f2c176436c0cb738c007d3fc378d0d530d09d45d818ecad5be4c" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "content": "A new carrot harvest is available.", + "title": "Nantes Carrots", + "summary": "New harvest bunches", + "published_at": 1700000080, + "location": "Central Saanich, BC", + "price_amount": "3.5", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": "24", + "quantity_unit": "lb", + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "New harvest", + "event_ids": [ + "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d" + ] + } + ], + "transition_page": { + "source_high_water": 3, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 3 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 3, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_head_created_at": 1700000080, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_json_sha256": "465617b3c73ea58efd9351aa162540614ac50c92115d599a582fec4b98b5e195", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "384e9060b4bbc919ca2ea75a47241078a5ca911c18450d62081232e10b66fea4", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "384e9060b4bbc919ca2ea75a47241078a5ca911c18450d62081232e10b66fea4", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "not_current" + } + ] + } + } + ] +} diff --git a/contracts/coverage-profiles.toml b/contracts/coverage-profiles.toml @@ -22,3 +22,8 @@ test_threads = 1 no_default_features = false features = ["blossom"] test_threads = 1 + +[profiles.crates."radroots_replica_sync"] +no_default_features = false +features = ["legacy-ingest"] +test_threads = 1 diff --git a/contracts/event_boundary_matrix.md b/contracts/event_boundary_matrix.md @@ -105,13 +105,16 @@ The `radroots_nostr` `events` feature seals strict FoodAvailability wire parts behind a builder whose timestamp cannot be mutated after validation. It supports local signing and typed client publication; generic authoring rejects focused and mixed kind-`30402` profiles. Signed-event relay remains transport-only. -Legacy replica ingestion verifies NIP-01 first, selects the raw addressable head -before profile decoding, and routes only Operational Listing into its -trade-product projection. Selected focused or generic exclusions and invalid or -ambiguous rejections remove an older operational projection and still advance -the raw head. The head-only replica helper rejects kind `30402`; these events -require profile-aware ingestion so projection cleanup and head movement remain -atomic. +The non-default `legacy-ingest` replica feature verifies kind-`30402` NIP-01 +events first, selects the raw addressable head before profile decoding, and +routes only Operational Listing into its trade-product projection. Selected +focused or generic exclusions and invalid or ambiguous rejections remove an +older operational projection and still advance the raw head. The head-only +replica helper rejects kind `30402`; these events require profile-aware legacy +ingestion so projection cleanup and head movement remain atomic. This +bare-envelope module is absent from default builds and is not a Phase 1 product +ingestion boundary. A future product replacement must accept only a +store-produced verified, valid-stream-eligible, currently visible admission. A validated authored image proves local Blossom descriptor-to-byte agreement only. Successful BUD-02 upload completion and any raster, retrievability, or @@ -301,7 +304,7 @@ not weaken or add effect fields to the request corpus. | document | 30361 | RadrootsDocument | events.document.publish, events.document.list, events.document.get | requires `d` and pubkey tags; optional address tag | | resource_area | 30370 | RadrootsResourceArea | events.resource_area.publish, events.resource_area.list, events.resource_area.get | addressable; GCS location and `g` tag required | | resource_cap | 30371 | RadrootsResourceHarvestCap | events.resource_cap.publish, events.resource_cap.list, events.resource_cap.get | addressable; required address, pubkey, key, start, and end tags | -| food_availability | 30402 | RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection / RadrootsAdmittedFoodAvailabilityEvent / RadrootsNostrFoodAvailabilityEventBuilder | food_availability.build_authored_draft, food_availability.project_verified_event, food_availability.verify_and_admit_event, food_availability.validate_revision | focused `radroots.food.availability.v1` profile; strict deterministic authoring, verified projection/admission, stable-coordinate revision validation, sealed Nostr signing/publication, generic-authoring reservation, and raw-head-first legacy replica partitioning; BUD-02 upload evidence remains a runtime prerequisite | +| food_availability | 30402 | RadrootsFoodAvailabilityDetails / RadrootsInboundFoodAvailabilityProjection / RadrootsAdmittedFoodAvailabilityEvent / RadrootsNostrFoodAvailabilityEventBuilder | food_availability.build_authored_draft, food_availability.project_verified_event, food_availability.verify_and_admit_event, food_availability.validate_revision | focused `radroots.food.availability.v1` profile; strict deterministic authoring, verified projection/admission, stable-coordinate revision validation, sealed Nostr signing/publication, generic-authoring reservation, and raw-head-first partitioning only behind the non-default `legacy-ingest` replica feature; BUD-02 upload evidence remains a runtime prerequisite | | operational_listing | 30402 | RadrootsOperationalListing | events.operational_listing.publish, events.operational_listing.list, events.operational_listing.get | NIP-99 classified-listing kind with the richer Radroots operational profile; canonical Markdown content and tags; farm author required | | dvm_request | 5000-5999 | RadrootsJobRequest | events.dvm_request.publish, events.dvm_request.list, events.dvm_request.get | generic DVM request surface | | dvm_result | 6000-6999 | RadrootsJobResult | events.dvm_result.publish, events.dvm_result.list, events.dvm_result.get | generic DVM result surface | diff --git a/contracts/events/social-events.md b/contracts/events/social-events.md @@ -490,6 +490,122 @@ The raw SQL pool is a fully trusted escape hatch rather than a security boundary; arbitrary DML or reproduction of the internal maintenance protocol is outside the supported mutation contract. +### Durable Visibility, Transition Feed, and Food Projection + +Event-store schema version `3` installs one central current-visibility authority, +`radroots_event_store_current_visibility_v1`, for every persisted event. Ephemeral events never +enter durable storage. Regular events are their own raw heads; replaceable and addressable events +are compared with the current raw head for their coordinate. The authority composes that raw-head +selection with registry-v7 admission and canonical NIP-09 evidence. All-event reads that claim +current visibility, including `current_event_visibility_v1`, `event_visibility`, `visible_event`, +and `visible_event_head`, must delegate to this view and fail closed on incoherent state. A current +addressable product projection may instead join the transactionally maintained +`radroots_event_store_addressable_head_state` materialization, but must bind the active generation, +complete coordinate and head identity, admission, contract, visibility, and NIP-09 outcome. The +FoodAvailability queries use that bounded specialization. + +The decision precedence is exact: a non-admitted event is `not_admitted`; otherwise an admitted +event that is not the raw head is `not_current`; otherwise an admitted raw head with a suppressed +NIP-09 outcome is `suppressed`; every remaining admitted raw head is `visible`. NIP-09 evidence is +computed independently before the current-head decision, so an admitted `not_current` event can +retain visible or suppressed historical evidence without becoming product-visible. Non-admitted +events carry no NIP-09 evidence. Admitted events also retain the visible reasons for no authorized +reference, an author mismatch, or an address cutoff that precedes the target. A persisted kind-`5` +deletion request is immune, carries visible `deletion_request_immune` evidence with no request +identifiers or cutoff, and cannot be suppressed by another kind-`5` request. + +Addressable transition feed version `1` accepts one through 64 input kinds, all in the +`30000..=39999` range, and fingerprints their sorted, deduplicated canonical set. A cursor binds the +active 32-byte source generation, feed version, exact scope fingerprint, and last scanned global +transition sequence. With no cursor, scanning starts at the active generation floor. A cursor from +another generation or scope is a mismatch, one below the floor is expired, one above the sealed +high-water is ahead, and an absent sequence inside the sealed interval is corruption. + +Transition sequence is global rather than scope-local. A page therefore scans unrelated kinds and +advances its cursor over them, checks sequence continuity, and reads at most 1024 transition rows. +It returns at most the requested number of matching transitions, with a maximum request limit of +64, and at most 4 MiB of visible raw-event JSON. If adding the next matching transition would cross +either returned-item or aggregate-payload limit, the page stops before that transition and the next +call retries it; a single matching row that alone exceeds the payload budget is a typed error. The +page reports a fixed +`source_high_water`, sets `has_more` from the last scanned sequence, and emits that sequence in its +next cursor. It never advances past an unreturned matching transition. + +`RadrootsStoreProducedCanonicalEventV1` means the store selected a signature-verified, admitted, +visible event at the instant represented by its containing transition. Historical transition pages +can therefore carry a canonical payload whose event is `not_current` at the page's final +high-water. The payload is replay input, not a detached proof of present visibility: consumers must +apply every transition in sequence, including later retractions, and use the central current- +visibility API when they need present-state authority. It does not mean that `raw_json` has a +canonical JSON serialization. The wrapper exposes only the typed signed event id, author pubkey, +created-at timestamp, kind, and verified opaque `raw_json`; it deliberately does not expose the +backing `RadrootsStoredRawEvent` or duplicate its containing transition's generation/sequence +witness. The remaining signed fields are available from `raw_json` through the verified event +codec. Store `seq`, `inserted_at_ms`, `updated_at_ms`, source generation, and transition sequence +are local database metadata and must not be persisted as cross-store event identity or replay +authority. Consumers may transport `raw_json`, but must not infer cross-store equality from its JSON +spelling instead of the signed event id. + +FoodAvailability projection version `1` has the sole scope kind `30402` and contract +`radroots.food.availability.v1`. The post-core v2 capability applies pending global transitions in +the same write transaction after protocol reconciliation. Its cursor advances over unrelated +global transitions as well as matching ones. A visible admitted focused replacement is reverified +and registry-v7 reprojected before insertion. A deletion, suppression, selected invalid event, or +selected event outside the focused contract retracts the existing row for the author plus `d` +coordinate. It never restores an older event; a later replacement created after an address-deletion +cutoff can become visible and project normally. Projection rows are unique by generation, author, +and `d`; image rows cascade with their parent, and projection insertion or deletion updates FTS5 +through database triggers in the same transaction. + +Point lookup uses author plus typed FoodAvailability identifier. Recent and search queries accept +the `Any`, `Active`, or `Sold` status filter and a limit from 1 through 1000. Both return only the +current visible projection and order by `published_at DESC, event_id ASC`. Search covers title, +summary, content, and location. Its input is at most 256 UTF-8 bytes and 16 nonempty terms split on +Unicode whitespace or control characters; terms are escaped as quoted FTS5 literals joined with +`AND`, so caller input cannot introduce column selectors, operators, prefix matching, or grouping. +Every returned projection row is bounded by the query limit and is individually checked against a +fresh signature verification and registry-v7 reprojection of its immutable raw event, including its +ordered image projection. Food point, recent, and search reads bind each row to the active +generation's persisted addressable-head authority, including its exact head identity, admission, +contract, and visible NIP-09 outcome. That state is maintained by the same transactional +current-visibility predicate; bounded Food reads do not recompute deletion-reference history for +every result row. + +For `RadrootsStoredFoodAvailabilityImageV1`, `qualifies()` means only that tolerant inbound image +projection produced no structural diagnostics. An ordinary valid HTTP(S) URL with valid dimensions +can therefore qualify while `blossom_sha256()` is `None`. A digest extracted from a Blossom-shaped +URL is structural metadata, not evidence that bytes hash to it or that upload, retrieval, decoding, +safety, or availability succeeded. Strict product authoring still requires an approved +byte-verified descriptor, and publication still requires runtime BUD-02 upload-completion evidence. +Blossom-specific client behavior must require the typed digest and the applicable runtime evidence, +not `qualifies()` alone. + +The FoodAvailability cursor seals active source generation, feed version, projection version, exact +scope fingerprint, hook-manifest SHA-256, last transition sequence, and a nonnegative projected-row +count. The addressable feed seal binds the same generation's floor, high-water, and contiguous +count. Each supported projection page compare-and-swaps the prior sequence and row count; it can +advance by at most 1024 global transitions and change the row count by at most 64 and no more than +the number of scoped transitions in that interval. + +Ordinary schema inspection and FoodAvailability point, recent, and search reads use one bounded +fast authority check. It verifies the active generation, feed floor/high-water/count seal, +feed/projection/scope/manifest identity, cursor equality with the source high-water, and the +nonnegative sealed row count without scanning all projection or FTS rows. Explicit migration, +supported rebuild, and conformance integrity paths must call +`audit_food_availability_projection_v1`: the exhaustive audit reverifies and reprojects every +projected signed event, compares all columns and ordered images, binds each row to the active +generation's latest applicable transition for its exact Food coordinate and visible event, requires +the exact admitted and visible head-coordinate witness set and sealed projection and FTS counts, compares +each FTS shadow row, and runs SQLite's FTS5 integrity check. The governed Food read view joins +immutable raw JSON and aggregates ordered image rows so a bounded point/recent/search result does +not issue per-row SQL lookups before those cryptographic checks. + +The fast seal detects drift across supported typed transactions; it is not a claim to detect +arbitrary direct SQL, disk modification, or FTS index corruption on every hot-path read. `pool()` is +a trusted escape hatch outside the supported mutation guarantees. A caller that uses it assumes +authority for any resulting state; only a governed migration, rebuild, or conformance path that +invokes the exhaustive validator can re-establish integrity evidence for that state. + `RadrootsReaction` uses strict NIP-25 semantics. Empty content, `+`, `-`, emoji, and custom reaction content are valid when the target tags are valid. Missing targets remain invalid. @@ -580,16 +696,18 @@ mixed-marker kind-`30402` events before signer access. Marker-free generic NIP-9 operational-only builders remain available for explicit compatibility, while relaying an already signed event remains transport-only and establishes no Radroots authoring claim. -Legacy replica ingestion verifies kind-`30402` identifiers and signatures before acquiring its -write transaction, then selects the raw addressable head before profile decoding. Only the +Behind the explicit non-default `legacy-ingest` feature, legacy replica ingestion verifies +kind-`30402` identifiers and signatures before acquiring its write transaction, then selects the +raw addressable head before profile decoding. Only the Operational Listing partition can reach the legacy trade-product projection. A signature-valid, coordinate-valid, selected focused event or marker-free generic NIP-99 event advances the raw head as excluded; selected invalid focused, mixed-marker, and malformed operational profiles advance it as rejected. Every selected excluded or rejected replacement removes an older operational projection, so stale events cannot resurrect it. A signature failure changes neither the raw head nor the projection; a missing or invalid `d` tag fails before an addressable head can be selected. -The public head-only helper rejects kind `30402`, which must use profile-aware ingestion so head and -projection changes remain atomic. +The feature-gated public head-only helper rejects kind `30402`, which must use profile-aware legacy +ingestion so head and projection changes remain atomic. Neither helper is a Phase 1 product ingest +boundary. The typed Nostr boundary does not prove BUD-02 upload completion. Every media-bearing caller must obtain successful Blossom upload evidence before signing or publishing; byte-verified descriptors diff --git a/contracts/manifest.toml b/contracts/manifest.toml @@ -80,4 +80,6 @@ require_conformance_vectors = true [policy.replica] forbid_legacy_alias_identifiers = true require_transport_agnostic_sync_contract = true +# Emit is required in default builds; ingest is required only under the +# explicit non-default legacy-ingest feature governed by contracts/replica.toml. require_deterministic_emit_ingest = true diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -387,3 +387,35 @@ semver_impacts = [ "change_exported_algorithm_behavior", ] summary = "Seal and revalidate transport identity, remove policy-free relay deserialization, require typed nonempty fetch targets, reject forged adapter provenance, bind fetch observations to request time, bound caller-redacted diagnostics, constrain relay policies to trusted public configuration or exact loopback hosts, and require canonical reset and reseeding of unreleased-alpha state." + +[[changes]] +id = "event-store-current-visibility-and-food-projection" +classification = "breaking" +semver_impacts = [ + "add_exported_type", + "add_exported_function", + "add_exported_constant", + "add_enum_variant", + "add_conformance_vector", + "change_exported_constant_value", + "change_exported_field_type", + "change_exported_algorithm_behavior", +] +summary = "Advance the event store to schema version 3 with central current visibility, a generation-bound addressable transition feed, an atomic registry-v7 FoodAvailability projection and bounded search authority, typed Blossom digests, and an executable successor contract that preserves the frozen NIP-09 predecessor." + +[[changes]] +id = "transport-event-outcomes-and-replica-quarantine" +classification = "breaking" +semver_impacts = [ + "add_exported_type", + "add_exported_function", + "add_exported_constant", + "add_exported_field", + "add_enum_variant", + "remove_exported_field", + "remove_exported_module", + "remove_exported_function", + "change_exported_field_type", + "change_exported_algorithm_behavior", +] +summary = "Represent relay-event verification, contract admission, valid-stream eligibility, and current visibility as independent exhaustive outcomes; enforce hard raw-event, aggregate raw-JSON, and pre-parse per-event fetch bounds; and remove bare-envelope legacy replica ingestion from the default public feature surface." diff --git a/contracts/replica.toml b/contracts/replica.toml @@ -12,6 +12,8 @@ sync = "radroots_replica_sync" [policy] transport_agnostic_sync_core = true +# Emit is part of the default sync surface; ingest determinism applies only to +# the optional legacy feature named by legacy_ingest_feature below. deterministic_emit_and_ingest = true forbid_legacy_alias_identifiers = true profile_event_emission = "excluded" @@ -21,6 +23,10 @@ classified_listing_head_selection = "raw_before_profile" classified_listing_operational_projection = "operational_partition_only" classified_listing_excluded_or_rejected_head = "remove_projection_and_advance" classified_listing_head_only_ingest = "reject_require_profile_aware" +legacy_bare_envelope_ingest = "explicit_non_default_feature_only" +legacy_ingest_feature = "legacy-ingest" +phase_1_ingest_replacement = "none" +future_product_ingest_input = "store_produced_verified_valid_visible_admission" [transfer] version = 2 diff --git a/crates/event_store/Cargo.toml b/crates/event_store/Cargo.toml @@ -17,6 +17,9 @@ sqlite = ["dep:getrandom", "dep:sqlx", "sqlx/sqlite-bundled"] runtime-tokio = ["sqlx/runtime-tokio"] [dependencies] +radroots_blossom = { workspace = true, default-features = false, features = [ + "std", +] } radroots_event = { workspace = true, default-features = false, features = [ "std", "serde", @@ -38,7 +41,7 @@ thiserror = { workspace = true } [dev-dependencies] nostr = { workspace = true } tempfile = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt"] } +tokio = { workspace = true, features = ["macros", "rt", "sync"] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage_nightly)'] } diff --git a/crates/event_store/README b/crates/event_store/README @@ -36,26 +36,37 @@ The public read APIs name their authority explicitly: protocol identifier; a missing `d` tag or missing first value becomes the empty identifier. Product-contract admission may impose stricter `d` rules independently. -- `event_visibility`, `visible_event`, and `visible_event_head` expose current - product visibility. A visible head is the exact raw head only when that event - is admitted; an unsupported or invalid winning raw head yields no older - fallback. Non-head durable revisions report `NotCurrent`. +- `event_visibility`, `event_visibilities`, `visible_event`, and + `visible_event_head` expose current product visibility. The bounded batch API + evaluates distinct canonical event IDs once in a single read transaction and + preserves requested order and duplicate cardinality. A visible head is the + exact raw head only when that event is admitted; an unsupported or invalid + winning raw head yields no older fallback. Non-head durable revisions report + `NotCurrent`. Verified ephemeral events are classified but never written to the raw sequence, tags, observations, or heads. Their ingest receipt carries `RadrootsEventPersistence::NotPersisted`; repeated delivery remains live-only and is never reported as a durable duplicate. -Schema version 2 persists NIP-09 reconciliation as derived authority without -rewriting or deleting raw envelopes. It stores generation-partitioned event -coordinate facts, admitted deletion-request facts, normalized event and -address targets, canonical addressable-head state, and an ordered transition -history. Kind-`5` requests remain durable protocol events and are immune from -suppression. These derived facts do not claim that a relay removed an event. -The existing `event_visibility`, `visible_event`, and `visible_event_head` -queries remain NIP-01 admission/head views in this schema checkpoint; callers -must not treat them as NIP-09-aware until the generation-bound visibility and -transition read contract is published. +Schema version 3 composes NIP-09 reconciliation with a generation-bound current +visibility view, generic addressable transition feed, and focused kind-`30402` +FoodAvailability projection. Version 2 stores generation-partitioned event +coordinate facts, admitted deletion-request facts, normalized event and address +targets, canonical addressable-head state, and ordered transition history. +Version 3 applies the same admission, raw-head, and suppression predicate to +regular, replaceable, addressable, feed, and focused projection reads. Kind-`5` +requests remain durable protocol events and are immune from suppression. These +derived facts do not claim that a relay removed an event. + +`current_event_visibility_v1` returns the complete generation-bound decision +and NIP-09 evidence for a stored event. Food point, recent, and literal FTS5 +search APIs expose only the active, visible FoodAvailability heads and reverify +each returned raw event and projection. Their persisted cursor seals generation, +feed/projection versions, scope, manifest digest, high-water, and row count. +`audit_food_availability_projection_v1` is the explicit exhaustive projection, +image, source-transition, exact visible-head coordinate, FTS, and signed-event +integrity check; ordinary reads use bounded seal validation. The `0002_nip09` migration also installs guards that reject accidental out-of-sequence SQL mutation of raw envelopes, tags, heads, reconciliation @@ -93,12 +104,13 @@ rollback failure. The reconciliation core and its shared raw-head storage are isolated in versioned modules whose complete production AST identities are bound by the -manifest. The authenticated ingest wrapper then creates a private post-core -storage capability whose only production methods write the declared trade and -transport-observation operation/table pairs. Extension orchestration receives -that capability rather than a SQL transaction and has no SQLx authority. The -capability policy rejects transaction escape, dynamic or compound SQL, schema -changes, attached databases, and event-store protocol authority. +manifest. The authenticated ingest wrapper then creates private post-core +storage capabilities. Version 1 can write only its declared trade and +transport-observation operation/table pairs; version 2 can apply only pending +FoodAvailability transitions. Extension orchestration receives those bounded +capabilities rather than a SQL transaction. The capability policy rejects +transaction escape, dynamic or compound SQL, schema changes, attached +databases, and ungoverned protocol authority. The capability borrow ends before an identity-bound validator compares the exact source generation/profile authority, maintained raw counters and indexed @@ -110,9 +122,10 @@ than quadratic per-ingest scans. ## Schema authority Every constructor converges the database through a versioned migration -authority whose current version is `2`. The frozen `0001_event_store` and -`0002_nip09` SQL inputs are pinned by byte length and SHA-256 in the embedded -registry. Fresh databases install them transactionally; an existing +authority whose current version is `3`. The frozen `0001_event_store`, +`0002_nip09`, and additive `0003_food_availability_projection` SQL inputs are +pinned by byte length and SHA-256 in the embedded registry. Fresh databases +install them transactionally; an existing unledgered database is adopted only when its exact 46-object SQLite catalog matches the frozen version-1 baseline fingerprint. Partial schemas, altered tables, attached indexes or triggers, counterfeit ledgers, history gaps, @@ -121,8 +134,9 @@ checksum drift, and schemas newer than this crate fail closed. Checksummed, tamper-evident managed history lives in the strict, without-rowid `radroots_event_store_schema_migrations` ledger and fails closed on drift. Current-schema opens return after read-only inspection and validate the active -reconciliation source authority. Every open validates all hooks in applied -migration order, so a later hookless migration cannot mask an earlier hook. +reconciliation source authority plus the bounded Food projection seal. Every +open validates all hooks in applied migration order, so a later hook cannot +mask an earlier hook. Mutating migration work rechecks under `BEGIN IMMEDIATE`, validates exact catalog deltas, SQLite and FTS5 integrity, event-store-owned foreign keys, and all applied migration-hook results before commit, and never changes @@ -143,21 +157,23 @@ block event-store migration or source rebuild. Migration `0002_nip09` re-verifies the frozen raw JSON and immutable columns, rebuilds derived registry-v7 admission and raw-head facts, then creates one -random 32-byte source generation. Initial reconciliation and every later full -rebuild use the same marker-first state machine: the marker binds the exact +random 32-byte source generation. Its marker-first state machine binds the exact prior and raw authority, an impossible deferred foreign-key barrier prevents a partial rebuild from committing, and guarded closure requires the rebuilt -authority before commit. Each successful rebuild appends a fresh random -generation and preserves prior generations, facts, states, and transition -history as immutable records. All NIP-09 facts, canonical addressable state, -and transitions are partitioned by generation. The reconciliation version, -addressable-feed version, registry version, hook-manifest digest, raw counts, -raw high-water sequence, and transition floor are stored with each generation -and validated on open. - -The one-time reconciliation preflights and then rechecks under the writer lock -at most 25,000 raw events, 250,000 raw tags, 64 MiB of retained event-row text, -and 32 MiB of retained tag-row text. It loads both authorities in checked +authority before commit. Generations, NIP-09 facts, canonical addressable state, +and transitions are immutable and generation-partitioned. The reconciliation +version, addressable-feed version, registry version, hook-manifest digest, raw +counts, raw high-water sequence, and transition floor are stored with each +generation and validated on open. A supported current-schema full rebuild must +also reset and replay the version-3 Food authority before that marker closes; +this checkpoint does not yet expose such a maintenance operation. + +Every pending rebuild-bound migration, including `0002_nip09` and +`0003_food_availability_projection`, preflights and then rechecks under the +writer lock at most 25,000 raw events, 250,000 raw tags, 64 MiB of retained +event-row text, and 32 MiB of retained tag-row text. This same immutable-source +bound caps the rows and payload from which the Food projection, image rows, and +FTS authority are derived. Reconciliation loads both raw authorities in checked 512-row pages. Candidate heads evaluate only the exact event- and address-target request indices, merging shared matches once in canonical request order without cloning request payloads. Exceeding a dimension returns diff --git a/crates/event_store/contracts/food_availability_projection_v1.manifest.json b/crates/event_store/contracts/food_availability_projection_v1.manifest.json @@ -0,0 +1,418 @@ +{ + "schema_version": 1, + "contract_id": "radroots_event_store.food_availability_projection_v1", + "hook_id": "food_availability_projection_v1", + "manifest_schema": { + "path": "crates/event_store/contracts/food_availability_projection_v1.manifest.schema.json", + "byte_length": 7964, + "sha256": "39171f6ef872a8d1483bc3d55049df5e0d110d9131c5adb4450b7c418f546910", + "hash_algorithm": "sha256_bytes_v1" + }, + "predecessor": { + "hook_id": "nip09_reconciliation_v1", + "manifest": { + "path": "crates/event_store/contracts/nip09_reconciliation_v1.manifest.json", + "byte_length": 537538, + "sha256": "74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77", + "hash_algorithm": "sha256_bytes_v1" + } + }, + "migration": { + "version": 3, + "name": "food_availability_projection", + "up": { + "path": "crates/event_store/migrations/0003_food_availability_projection.up.sql", + "byte_length": 23683, + "sha256": "4e7edfb981b25f76055efc7802ec30b4034eeae9b9c0809ea4ea7c574678748a", + "hash_algorithm": "sha256_bytes_v1" + }, + "down": { + "path": "crates/event_store/migrations/0003_food_availability_projection.down.sql", + "byte_length": 1755, + "sha256": "29d663320109d9dd0df6a00b6a53d8d988438d01f7a66960a9d4ba3482ffffb8", + "hash_algorithm": "sha256_bytes_v1" + }, + "schema_sha256": "dd12467e04addcbddb5ea0f386c12a8ac05ef5ebaaf949f24dd2c62745f5aaac", + "catalog": { + "objects": [ + "radroots_event_store_addressable_feed_generation_insert", + "radroots_event_store_addressable_feed_integrity_v1", + "radroots_event_store_addressable_feed_transition_insert", + "radroots_event_store_addressable_transition_coordinate_idx", + "radroots_event_store_current_visibility_head_lookup_idx", + "radroots_event_store_current_visibility_v1", + "radroots_event_store_food_availability_author_idx", + "radroots_event_store_food_availability_cursor", + "radroots_event_store_food_availability_cursor_delete_guard", + "radroots_event_store_food_availability_cursor_insert_guard", + "radroots_event_store_food_availability_cursor_update_guard", + "radroots_event_store_food_availability_image", + "radroots_event_store_food_availability_image_delete_guard", + "radroots_event_store_food_availability_image_insert_guard", + "radroots_event_store_food_availability_image_update_guard", + "radroots_event_store_food_availability_projection", + "radroots_event_store_food_availability_projection_delete_guard", + "radroots_event_store_food_availability_projection_insert_guard", + "radroots_event_store_food_availability_projection_update_guard", + "radroots_event_store_food_availability_read_v1", + "radroots_event_store_food_availability_recent_idx", + "radroots_event_store_food_availability_search_delete", + "radroots_event_store_food_availability_search_fts", + "radroots_event_store_food_availability_search_fts_config", + "radroots_event_store_food_availability_search_fts_content", + "radroots_event_store_food_availability_search_fts_data", + "radroots_event_store_food_availability_search_fts_docsize", + "radroots_event_store_food_availability_search_fts_idx", + "radroots_event_store_food_availability_search_insert", + "radroots_event_store_food_availability_status_idx", + "radroots_event_store_nip09_address_target_visibility_lookup_idx" + ], + "tables": [ + "radroots_event_store_addressable_feed_integrity_v1", + "radroots_event_store_food_availability_cursor", + "radroots_event_store_food_availability_image", + "radroots_event_store_food_availability_projection", + "radroots_event_store_food_availability_search_fts", + "radroots_event_store_food_availability_search_fts_config", + "radroots_event_store_food_availability_search_fts_content", + "radroots_event_store_food_availability_search_fts_data", + "radroots_event_store_food_availability_search_fts_docsize", + "radroots_event_store_food_availability_search_fts_idx" + ], + "fts5_tables": [ + "radroots_event_store_food_availability_search_fts" + ] + } + }, + "profile": { + "event_contract_registry_version": 7, + "addressable_feed_version": 1, + "projection_version": 1, + "scope_kinds": [ + 30402 + ], + "scope_fingerprint_sha256": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "food_contract_id": "radroots.food.availability.v1", + "admission_authority": "event_contract_registry_v7", + "current_visibility_authority": "radroots_event_store_current_visibility_v1", + "post_core_capability": "apply_v2" + }, + "registry_inventory": { + "path": "contracts/event_store/event_contract_registry_v7.inventory.json", + "byte_length": 158021, + "sha256": "91595544310f865bdef064ee760c227c870417a95b87b3e27278f8da74fdddea", + "hash_algorithm": "sha256_bytes_v1" + }, + "food_profile_vector": { + "path": "contracts/conformance/vectors/food_availability/profile.v1.json", + "byte_length": 90099, + "sha256": "dede1eb1f682ecd548e8cd3ccb251d298762e504e2588a73528e5f7a5b9eff21", + "hash_algorithm": "sha256_bytes_v1" + }, + "entry_points": [ + { + "role": "registry_v7_admission", + "rust_path": "radroots_event_codec::admit_verified_event_registry_v7" + }, + { + "role": "migration_registry", + "rust_path": "radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[2]" + }, + { + "role": "migration_apply_hook", + "rust_path": "radroots_event_store::schema::apply_migration_hook" + }, + { + "role": "migration_validation_hook", + "rust_path": "radroots_event_store::schema::validate_migration_hook_state" + }, + { + "role": "post_core_extension", + "rust_path": "radroots_event_store::store::PostCoreExtensionCapabilities::apply_v2" + }, + { + "role": "addressable_transition_feed", + "rust_path": "radroots_event_store::RadrootsEventStore::addressable_transition_page_v1" + }, + { + "role": "current_visibility", + "rust_path": "radroots_event_store::RadrootsEventStore::current_event_visibility_v1" + }, + { + "role": "event_visibility_batch", + "rust_path": "radroots_event_store::RadrootsEventStore::event_visibilities" + }, + { + "role": "projection_lookup", + "rust_path": "radroots_event_store::RadrootsEventStore::food_availability_v1" + }, + { + "role": "projection_recent", + "rust_path": "radroots_event_store::RadrootsEventStore::recent_food_availability_v1" + }, + { + "role": "projection_search", + "rust_path": "radroots_event_store::RadrootsEventStore::search_food_availability_v1" + }, + { + "role": "projection_audit", + "rust_path": "radroots_event_store::RadrootsEventStore::audit_food_availability_projection_v1" + }, + { + "role": "result_vector_executor", + "rust_path": "food_availability_projection_v1_result_vector" + } + ], + "source_files": [ + { + "role": "workspace_dependency_authority", + "path": "Cargo.toml", + "byte_length": 10836, + "sha256": "285532dbb0894204843a832880f136ceac5ee312a3203ff951fb0551fac63ec4", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "event_store_dependency_authority", + "path": "crates/event_store/Cargo.toml", + "byte_length": 1466, + "sha256": "9e82d57b64bb7fcf145ee8b919682dd6d788b5503be743d11326f5b882fb2d7e", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "blossom_public_surface", + "path": "crates/blossom/src/lib.rs", + "byte_length": 1335, + "sha256": "61ebee86f02887c45507bab052686da082f74ae26f23d18ff4019e02c70097bd", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "blossom_sha256_value_object", + "path": "crates/blossom/src/hash.rs", + "byte_length": 11251, + "sha256": "753eff6ef9a810045c88839d39a46b37d62a4ad0a5ea57aa30e2a8219e3cdbda", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "food_event_contract", + "path": "crates/event/src/food_availability.rs", + "byte_length": 45680, + "sha256": "1c8c3fba6514f9b246545b3305e8ac2742258857d1b813fa34c5fbefa6135e12", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "food_admission", + "path": "crates/event_codec/src/food_availability/admission.rs", + "byte_length": 5799, + "sha256": "727ebf8f086c14376aba745a0a02b269115fed01e8ce95f90fdd211b9640f842", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "registry_v7_admission", + "path": "crates/event_codec/src/admission/registry_v7.rs", + "byte_length": 5350, + "sha256": "755e63dd7ad1115c219e5a3ea540bef67d2770fc9f2a5aa6ba97c925c99bdced", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "registry_v7_food_projection", + "path": "crates/event_codec/src/food_availability/inbound/registry_v7.rs", + "byte_length": 26865, + "sha256": "908015346eaec97bee6f44fe2a524410b701529ac19a5dab4505706d40d527da", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "event_store_error_surface", + "path": "crates/event_store/src/error.rs", + "byte_length": 17557, + "sha256": "ac206fafdc67e4c25f4a2f1bcd38eb34ae224b38aca55894b1a9cc67242d8b1a", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "generated_descriptor_registration", + "path": "crates/event_store/src/generated.rs", + "byte_length": 100, + "sha256": "ddd71b2245f307cac0c8ef835311d0dd9eb2fdaf9d2a76a6c9dc98e56f4faaec", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "public_surface", + "path": "crates/event_store/src/lib.rs", + "byte_length": 3419, + "sha256": "d5bc071309fd4ec9e26a3cfe9c0e7c00b5de5b6c248902b888891b39e6780d23", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "migration_registry", + "path": "crates/event_store/src/migrations.rs", + "byte_length": 55541, + "sha256": "ef62808fe75b5c0704567eecccfe23d0b11ae25d332bc5e38451ea2a446c535a", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "model_registration", + "path": "crates/event_store/src/model.rs", + "byte_length": 33617, + "sha256": "79296b8f263aa06d17005795e4515f769f064ea6fd971eeb1296e1151debaf20", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "schema_hooks", + "path": "crates/event_store/src/schema.rs", + "byte_length": 97574, + "sha256": "6d259548559f32e259d3502787ccd4efbd22eebd1406dd7dd68c8f3145411543", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "store_ingest_and_wal_authority", + "path": "crates/event_store/src/store.rs", + "byte_length": 357693, + "sha256": "8bca94e5c7b26cee60a06ea327fcba385e6cbc51550f1774ef4d0488ffa2727b", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "addressable_transition_feed_model", + "path": "crates/event_store/src/model/addressable_transition_feed_v1.rs", + "byte_length": 22314, + "sha256": "b1c6b0a68f34459f7e14bd63857596154c0aa3fd02dc6c1661d543bb681324a7", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "current_visibility_model", + "path": "crates/event_store/src/model/current_visibility_v1.rs", + "byte_length": 5691, + "sha256": "25ec92f45006e2f66f2e1c8b954a021334bb1595b849c4d8529f289d3f7aeb25", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "food_projection_model", + "path": "crates/event_store/src/model/food_availability_projection_v1.rs", + "byte_length": 17493, + "sha256": "1e5ff9c05a81fda223ed1a27ff18a1b08bcdeaec9047a13fdd577390b3e0fdb9", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "addressable_transition_feed_store", + "path": "crates/event_store/src/store/addressable_transition_feed_v1.rs", + "byte_length": 40253, + "sha256": "fe23424aa1e6b39f9aba2dfa4470652b26b4990f91204a2bdfe379c03da9b610", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "current_visibility_store", + "path": "crates/event_store/src/store/current_visibility_v1.rs", + "byte_length": 15860, + "sha256": "8615086e674c30700305debcef11de5b3dbfe5aec735c0f58b4ac11caa518596", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "food_projection_store", + "path": "crates/event_store/src/store/food_availability_projection_v1.rs", + "byte_length": 49029, + "sha256": "0cec060c50c50d9333e0583e338fe311a1c64a1d586c222e391d8cef685cf871", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "predecessor_protocol_storage", + "path": "crates/event_store/src/store/protocol_storage_v1.rs", + "byte_length": 10975, + "sha256": "155c74d27eee5db1d6f0f844f9d319604eefbbf640f4b2371ac5d0e370816e50", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "predecessor_active_state_fast_validation", + "path": "crates/event_store/src/nip09/reconciliation_v1.rs", + "byte_length": 183733, + "sha256": "3bddb664491bd9abe878f5b2441d1230fb188368dae341f3425a2b035211366a", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "post_core_capabilities", + "path": "crates/event_store/src/store/post_core_extension_capabilities.rs", + "byte_length": 1255, + "sha256": "cb434372156cb7ff31dac392d7095c2e5f44b128fae4e705516d6d000e2e2502", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "post_core_dispatcher", + "path": "crates/event_store/src/store/post_core_extension_dispatcher.rs", + "byte_length": 576, + "sha256": "df62ee92e9f165502d5e533997a47f533129fd3cffab2d9b2012e2ed22405f48", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "post_core_v2_extension", + "path": "crates/event_store/src/store/post_core_extensions_v2.rs", + "byte_length": 294, + "sha256": "8dcbc503ed9ea6fb06ed9a2a83b0804d928f9590b5706de25a057ad72c0d38d2", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "post_core_v2_storage", + "path": "crates/event_store/src/store/post_core_storage_v2.rs", + "byte_length": 632, + "sha256": "4b672770f3c34bf887e4cc949c068cb0c87396cb4af8efb6e13d39aa4e0d973a", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "predecessor_post_core_v1_extension", + "path": "crates/event_store/src/store/post_core_extensions_v1.rs", + "byte_length": 6935, + "sha256": "fb165704c64d982cf3be0a880c44985be6b375758451e94b2aaaf30881769f18", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "predecessor_post_core_v1_storage", + "path": "crates/event_store/src/store/post_core_storage_v1.rs", + "byte_length": 16871, + "sha256": "a6dca0884762cec3c32e460d17662ced9d0335f30e79b3d5fdb3461259d3ec19", + "hash_algorithm": "sha256_bytes_v1" + } + ], + "public_api": [ + "RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1", + "RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1", + "RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1", + "RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1", + "RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1", + "RadrootsAddressableTransitionCauseV1", + "RadrootsAddressableTransitionCoordinateV1", + "RadrootsAddressableTransitionCursorV1", + "RadrootsAddressableTransitionEventReferenceV1", + "RadrootsAddressableTransitionOriginV1", + "RadrootsAddressableTransitionPageV1", + "RadrootsAddressableTransitionRawHeadDecisionV1", + "RadrootsAddressableTransitionScopeFingerprintV1", + "RadrootsAddressableTransitionScopeV1", + "RadrootsAddressableTransitionV1", + "RadrootsAddressableTransitionVisibilityV1", + "RadrootsCurrentEventVisibilityV1", + "RadrootsCurrentVisibilityDecisionV1", + "RadrootsFoodAvailabilitySearchQueryV1", + "RadrootsFoodAvailabilityStatusFilterV1", + "RadrootsNip09SuppressionEvidenceV1", + "RadrootsNip09SuppressionOutcome", + "RadrootsNip09SuppressionReason", + "RadrootsStoreProducedCanonicalEventV1", + "RadrootsStoredFoodAvailabilityImageV1", + "RadrootsStoredFoodAvailabilityV1" + ], + "result_vector": { + "canonical_path": "contracts/conformance/vectors/event_store/food_availability_projection.v1.json", + "mirror_path": "crates/event_store/tests/fixtures/food_availability_projection.v1.json", + "byte_length": 103659, + "sha256": "fca2b71b47736ed04ed1e908823b65b3fc3cf0366cb162128369fe328295bb63", + "hash_algorithm": "sha256_bytes_v1", + "executor_id": "radroots_event_store.food_availability_projection_v1.result_vector_executor.v1", + "executor_path": "crates/event_store/tests/food_availability_projection_v1_result_vector.rs", + "executor_test": "food_availability_projection_v1_result_vector", + "executor_byte_length": 34075, + "executor_sha256": "9e8e11abae7bbc7dda30eab6f0a79074ffc3761aa6b955cff58c4c62fa581aa3", + "executor_hash_algorithm": "sha256_bytes_v1" + } +} diff --git a/crates/event_store/contracts/food_availability_projection_v1.manifest.schema.json b/crates/event_store/contracts/food_availability_projection_v1.manifest.schema.json @@ -0,0 +1,327 @@ +{ + "$defs": { + "file": { + "additionalProperties": false, + "properties": { + "byte_length": { + "minimum": 1, + "type": "integer" + }, + "hash_algorithm": { + "const": "sha256_bytes_v1" + }, + "path": { + "pattern": "^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$", + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "path", + "byte_length", + "sha256", + "hash_algorithm" + ], + "type": "object" + }, + "source_file": { + "additionalProperties": false, + "properties": { + "byte_length": { + "minimum": 1, + "type": "integer" + }, + "hash_algorithm": { + "const": "sha256_bytes_v1" + }, + "path": { + "pattern": "^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$", + "type": "string" + }, + "role": { + "minLength": 1, + "type": "string" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "role", + "path", + "byte_length", + "sha256", + "hash_algorithm" + ], + "type": "object" + } + }, + "$id": "https://radroots.org/core/event-store/food-availability-projection-manifest-v1.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "contract_id": { + "const": "radroots_event_store.food_availability_projection_v1" + }, + "entry_points": { + "items": { + "additionalProperties": false, + "properties": { + "role": { + "minLength": 1, + "type": "string" + }, + "rust_path": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "role", + "rust_path" + ], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "food_profile_vector": { + "$ref": "#/$defs/file" + }, + "hook_id": { + "const": "food_availability_projection_v1" + }, + "manifest_schema": { + "$ref": "#/$defs/file" + }, + "migration": { + "additionalProperties": false, + "properties": { + "catalog": { + "additionalProperties": false, + "properties": { + "fts5_tables": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "objects": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "tables": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "objects", + "tables", + "fts5_tables" + ], + "type": "object" + }, + "down": { + "$ref": "#/$defs/file" + }, + "name": { + "const": "food_availability_projection" + }, + "schema_sha256": { + "const": "dd12467e04addcbddb5ea0f386c12a8ac05ef5ebaaf949f24dd2c62745f5aaac" + }, + "up": { + "$ref": "#/$defs/file" + }, + "version": { + "const": 3 + } + }, + "required": [ + "version", + "name", + "up", + "down", + "schema_sha256", + "catalog" + ], + "type": "object" + }, + "predecessor": { + "additionalProperties": false, + "properties": { + "hook_id": { + "const": "nip09_reconciliation_v1" + }, + "manifest": { + "$ref": "#/$defs/file" + } + }, + "required": [ + "hook_id", + "manifest" + ], + "type": "object" + }, + "profile": { + "additionalProperties": false, + "properties": { + "addressable_feed_version": { + "const": 1 + }, + "admission_authority": { + "const": "event_contract_registry_v7" + }, + "current_visibility_authority": { + "const": "radroots_event_store_current_visibility_v1" + }, + "event_contract_registry_version": { + "const": 7 + }, + "food_contract_id": { + "const": "radroots.food.availability.v1" + }, + "post_core_capability": { + "const": "apply_v2" + }, + "projection_version": { + "const": 1 + }, + "scope_fingerprint_sha256": { + "const": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0" + }, + "scope_kinds": { + "maxItems": 1, + "minItems": 1, + "prefixItems": [ + { + "const": 30402 + } + ], + "type": "array" + } + }, + "required": [ + "event_contract_registry_version", + "addressable_feed_version", + "projection_version", + "scope_kinds", + "scope_fingerprint_sha256", + "food_contract_id", + "admission_authority", + "current_visibility_authority", + "post_core_capability" + ], + "type": "object" + }, + "public_api": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + "registry_inventory": { + "$ref": "#/$defs/file" + }, + "result_vector": { + "additionalProperties": false, + "properties": { + "byte_length": { + "minimum": 1, + "type": "integer" + }, + "canonical_path": { + "const": "contracts/conformance/vectors/event_store/food_availability_projection.v1.json" + }, + "executor_byte_length": { + "minimum": 1, + "type": "integer" + }, + "executor_hash_algorithm": { + "const": "sha256_bytes_v1" + }, + "executor_id": { + "const": "radroots_event_store.food_availability_projection_v1.result_vector_executor.v1" + }, + "executor_path": { + "const": "crates/event_store/tests/food_availability_projection_v1_result_vector.rs" + }, + "executor_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "executor_test": { + "const": "food_availability_projection_v1_result_vector" + }, + "hash_algorithm": { + "const": "sha256_bytes_v1" + }, + "mirror_path": { + "const": "crates/event_store/tests/fixtures/food_availability_projection.v1.json" + }, + "sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + } + }, + "required": [ + "canonical_path", + "mirror_path", + "byte_length", + "sha256", + "hash_algorithm", + "executor_id", + "executor_path", + "executor_test", + "executor_byte_length", + "executor_sha256", + "executor_hash_algorithm" + ], + "type": "object" + }, + "schema_version": { + "const": 1 + }, + "source_files": { + "items": { + "$ref": "#/$defs/source_file" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "schema_version", + "contract_id", + "hook_id", + "manifest_schema", + "predecessor", + "migration", + "profile", + "registry_inventory", + "food_profile_vector", + "entry_points", + "source_files", + "public_api", + "result_vector" + ], + "title": "Radroots event-store FoodAvailability projection manifest v1", + "type": "object" +} diff --git a/crates/event_store/contracts/food_availability_projection_v1.manifest.sha256 b/crates/event_store/contracts/food_availability_projection_v1.manifest.sha256 @@ -0,0 +1 @@ +33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23 diff --git a/crates/event_store/migrations/0003_food_availability_projection.down.sql b/crates/event_store/migrations/0003_food_availability_projection.down.sql @@ -0,0 +1,26 @@ +DROP TRIGGER radroots_event_store_food_availability_cursor_delete_guard; +DROP TRIGGER radroots_event_store_food_availability_cursor_update_guard; +DROP TRIGGER radroots_event_store_food_availability_cursor_insert_guard; +DROP TRIGGER radroots_event_store_food_availability_search_delete; +DROP TRIGGER radroots_event_store_food_availability_search_insert; +DROP TRIGGER radroots_event_store_food_availability_image_delete_guard; +DROP TRIGGER radroots_event_store_food_availability_image_update_guard; +DROP TRIGGER radroots_event_store_food_availability_image_insert_guard; +DROP TRIGGER radroots_event_store_food_availability_projection_delete_guard; +DROP TRIGGER radroots_event_store_food_availability_projection_update_guard; +DROP TRIGGER radroots_event_store_food_availability_projection_insert_guard; +DROP TABLE radroots_event_store_food_availability_cursor; +DROP TABLE radroots_event_store_food_availability_search_fts; +DROP VIEW radroots_event_store_food_availability_read_v1; +DROP TABLE radroots_event_store_food_availability_image; +DROP INDEX radroots_event_store_food_availability_author_idx; +DROP INDEX radroots_event_store_food_availability_recent_idx; +DROP INDEX radroots_event_store_food_availability_status_idx; +DROP TABLE radroots_event_store_food_availability_projection; +DROP INDEX radroots_event_store_addressable_transition_coordinate_idx; +DROP TRIGGER radroots_event_store_addressable_feed_transition_insert; +DROP TRIGGER radroots_event_store_addressable_feed_generation_insert; +DROP TABLE radroots_event_store_addressable_feed_integrity_v1; +DROP INDEX radroots_event_store_nip09_address_target_visibility_lookup_idx; +DROP INDEX radroots_event_store_current_visibility_head_lookup_idx; +DROP VIEW radroots_event_store_current_visibility_v1; diff --git a/crates/event_store/migrations/0003_food_availability_projection.up.sql b/crates/event_store/migrations/0003_food_availability_projection.up.sql @@ -0,0 +1,638 @@ +CREATE VIEW radroots_event_store_current_visibility_v1 AS +WITH active AS ( + SELECT active_generation + FROM radroots_event_store_source_state + WHERE singleton = 1 +), event_facts AS ( + SELECT + event.seq AS event_seq, + event.event_id, + event.pubkey, + event.created_at, + event.kind, + event.contract_status AS admission_status, + event.contract_id, + event.event_class, + active.active_generation AS source_generation, + coordinate.raw_d_tag, + coordinate.nip09_matchable, + coordinate.nip09_d_tag, + CASE + WHEN event.event_class = 'regular' THEN 1 + WHEN head.event_id = event.event_id THEN 1 + ELSE 0 + END AS is_raw_head, + head.event_id AS raw_head_event_id + FROM event_envelopes AS event + CROSS JOIN active + LEFT JOIN radroots_event_store_event_coordinate AS coordinate + ON coordinate.source_generation = active.active_generation + AND coordinate.event_id = event.event_id + LEFT JOIN event_envelope_head AS head + ON head.coordinate_type = event.event_class + AND head.kind = event.kind + AND head.pubkey = event.pubkey + AND ( + (event.event_class = 'replaceable' AND head.d_tag IS NULL) + OR (event.event_class = 'addressable' AND head.d_tag = coordinate.raw_d_tag) + ) +), evidence AS ( + SELECT + fact.*, + CASE WHEN fact.kind != 5 THEN ( + SELECT request.request_event_id + FROM radroots_event_store_nip09_event_target AS target + INDEXED BY radroots_event_store_nip09_event_target_lookup_idx + CROSS JOIN radroots_event_store_nip09_request AS request + ON request.source_generation = target.source_generation + AND request.request_event_id = target.request_event_id + WHERE target.source_generation = fact.source_generation + AND target.target_event_id = fact.event_id + AND request.request_pubkey = fact.pubkey + ORDER BY target.request_event_id + LIMIT 1 + ) END AS event_reference_request_id, + CASE WHEN fact.kind != 5 THEN ( + SELECT request.request_event_id + FROM radroots_event_store_nip09_address_target AS target + INDEXED BY radroots_event_store_nip09_address_target_visibility_lookup_idx + CROSS JOIN radroots_event_store_nip09_request AS request + ON request.source_generation = target.source_generation + AND request.request_event_id = target.request_event_id + WHERE target.source_generation = fact.source_generation + AND target.target_kind = fact.kind + AND target.target_pubkey = fact.pubkey + AND fact.nip09_matchable = 1 + AND target.target_d_tag = fact.nip09_d_tag + AND request.request_pubkey = fact.pubkey + ORDER BY target.inclusive_cutoff DESC, target.request_event_id + LIMIT 1 + ) END AS address_reference_request_id, + CASE WHEN fact.kind != 5 THEN ( + SELECT target.inclusive_cutoff + FROM radroots_event_store_nip09_address_target AS target + INDEXED BY radroots_event_store_nip09_address_target_visibility_lookup_idx + CROSS JOIN radroots_event_store_nip09_request AS request + ON request.source_generation = target.source_generation + AND request.request_event_id = target.request_event_id + WHERE target.source_generation = fact.source_generation + AND target.target_kind = fact.kind + AND target.target_pubkey = fact.pubkey + AND fact.nip09_matchable = 1 + AND target.target_d_tag = fact.nip09_d_tag + AND request.request_pubkey = fact.pubkey + ORDER BY target.inclusive_cutoff DESC, target.request_event_id + LIMIT 1 + ) END AS address_reference_cutoff, + CASE WHEN fact.kind = 5 THEN 0 ELSE EXISTS ( + SELECT 1 + FROM radroots_event_store_nip09_event_target AS target + INDEXED BY radroots_event_store_nip09_event_target_lookup_idx + CROSS JOIN radroots_event_store_nip09_request AS request + ON request.source_generation = target.source_generation + AND request.request_event_id = target.request_event_id + WHERE target.source_generation = fact.source_generation + AND target.target_event_id = fact.event_id + AND request.request_pubkey != fact.pubkey + ) OR EXISTS ( + SELECT 1 + FROM radroots_event_store_nip09_address_target AS target + INDEXED BY radroots_event_store_nip09_address_target_visibility_lookup_idx + CROSS JOIN radroots_event_store_nip09_request AS request + ON request.source_generation = target.source_generation + AND request.request_event_id = target.request_event_id + WHERE target.source_generation = fact.source_generation + AND target.target_kind = fact.kind + AND target.target_pubkey = fact.pubkey + AND fact.nip09_matchable = 1 + AND target.target_d_tag = fact.nip09_d_tag + AND request.request_pubkey != fact.pubkey + ) END AS has_unauthorized_reference + FROM event_facts AS fact +), suppression AS ( + SELECT + evidence.*, + CASE + WHEN admission_status != 'admitted' THEN NULL + WHEN kind = 5 THEN 'visible' + WHEN event_reference_request_id IS NOT NULL THEN 'suppressed' + WHEN address_reference_cutoff >= created_at THEN 'suppressed' + ELSE 'visible' + END AS suppression_outcome, + CASE + WHEN admission_status != 'admitted' THEN NULL + WHEN kind = 5 THEN 'deletion_request_immune' + WHEN event_reference_request_id IS NOT NULL + AND address_reference_cutoff >= created_at + THEN 'deletion_event_id_and_address_reference' + WHEN event_reference_request_id IS NOT NULL + THEN 'deletion_event_id_reference' + WHEN address_reference_cutoff >= created_at + THEN 'deletion_address_reference' + WHEN address_reference_request_id IS NOT NULL + THEN 'deletion_address_cutoff_precedes_target' + WHEN has_unauthorized_reference = 1 + THEN 'deletion_request_author_mismatch' + ELSE 'deletion_no_authorized_reference' + END AS suppression_reason + FROM evidence +) +SELECT + event_seq, + event_id, + pubkey, + created_at, + kind, + admission_status, + contract_id, + event_class, + source_generation, + raw_d_tag, + is_raw_head, + raw_head_event_id, + suppression_outcome, + suppression_reason, + CASE WHEN admission_status = 'admitted' + THEN event_reference_request_id ELSE NULL END AS event_reference_request_id, + CASE WHEN admission_status = 'admitted' + THEN address_reference_request_id ELSE NULL END AS address_reference_request_id, + CASE WHEN admission_status = 'admitted' + THEN address_reference_cutoff ELSE NULL END AS address_reference_cutoff, + CASE + WHEN admission_status != 'admitted' THEN 'not_admitted' + WHEN is_raw_head = 0 THEN 'not_current' + WHEN suppression_outcome = 'suppressed' THEN 'suppressed' + ELSE 'visible' + END AS current_visibility +FROM suppression; + +CREATE INDEX radroots_event_store_current_visibility_head_lookup_idx +ON event_envelope_head(coordinate_type, kind, pubkey, d_tag); + +CREATE INDEX radroots_event_store_nip09_address_target_visibility_lookup_idx +ON radroots_event_store_nip09_address_target( + source_generation, + target_kind, + target_pubkey, + target_d_tag, + inclusive_cutoff DESC, + request_event_id ASC +); + +CREATE TABLE radroots_event_store_addressable_feed_integrity_v1 ( + source_generation BLOB PRIMARY KEY NOT NULL + REFERENCES radroots_event_store_source_generation(source_generation) + ON DELETE RESTRICT, + transition_floor_seq INTEGER NOT NULL CHECK (transition_floor_seq >= 0), + last_transition_seq INTEGER NOT NULL CHECK ( + last_transition_seq >= transition_floor_seq + ), + transition_count INTEGER NOT NULL CHECK ( + transition_count >= 0 + AND transition_count = last_transition_seq - transition_floor_seq + ) +) STRICT, WITHOUT ROWID; + +INSERT INTO radroots_event_store_addressable_feed_integrity_v1( + source_generation, + transition_floor_seq, + last_transition_seq, + transition_count +) +SELECT + generation.source_generation, + generation.transition_floor_seq, + COALESCE(MAX(transition.transition_seq), generation.transition_floor_seq), + COUNT(transition.transition_seq) +FROM radroots_event_store_source_generation AS generation +LEFT JOIN radroots_event_store_addressable_head_transition AS transition + ON transition.source_generation = generation.source_generation +GROUP BY generation.source_generation, generation.transition_floor_seq; + +CREATE TRIGGER radroots_event_store_addressable_feed_generation_insert +AFTER INSERT ON radroots_event_store_source_generation +BEGIN + INSERT INTO radroots_event_store_addressable_feed_integrity_v1( + source_generation, + transition_floor_seq, + last_transition_seq, + transition_count + ) VALUES ( + NEW.source_generation, + NEW.transition_floor_seq, + NEW.transition_floor_seq, + 0 + ); +END; + +CREATE TRIGGER radroots_event_store_addressable_feed_transition_insert +AFTER INSERT ON radroots_event_store_addressable_head_transition +BEGIN + UPDATE radroots_event_store_addressable_feed_integrity_v1 + SET + last_transition_seq = NEW.transition_seq, + transition_count = transition_count + 1 + WHERE source_generation = NEW.source_generation + AND NEW.transition_seq = last_transition_seq + 1; + SELECT CASE + WHEN changes() != 1 + THEN RAISE(ABORT, 'event-store addressable feed integrity advancement failed') + END; +END; + +CREATE INDEX radroots_event_store_addressable_transition_coordinate_idx +ON radroots_event_store_addressable_head_transition( + source_generation, + kind, + pubkey, + d_tag, + transition_seq +); + +CREATE TABLE radroots_event_store_food_availability_projection ( + source_generation BLOB NOT NULL + REFERENCES radroots_event_store_source_generation(source_generation) + ON DELETE RESTRICT, + kind INTEGER NOT NULL CHECK (kind = 30402), + pubkey TEXT NOT NULL CHECK ( + length(pubkey) = 64 + AND pubkey = lower(pubkey) + AND pubkey NOT GLOB '*[^0-9a-f]*' + ), + d_tag TEXT NOT NULL CHECK (length(d_tag) > 0), + event_id TEXT NOT NULL CHECK ( + length(event_id) = 64 + AND event_id = lower(event_id) + AND event_id NOT GLOB '*[^0-9a-f]*' + ), + event_seq INTEGER NOT NULL CHECK (event_seq > 0), + created_at INTEGER NOT NULL CHECK (created_at >= 0), + contract_id TEXT NOT NULL CHECK (contract_id = 'radroots.food.availability.v1'), + content TEXT NOT NULL, + title TEXT NOT NULL CHECK (length(title) > 0), + summary TEXT NOT NULL CHECK (length(summary) > 0), + published_at INTEGER NOT NULL CHECK (published_at > 0 AND published_at <= created_at), + location TEXT NOT NULL CHECK (length(location) > 0), + price_amount TEXT NOT NULL CHECK (length(price_amount) > 0), + price_currency TEXT NOT NULL CHECK ( + length(price_currency) = 3 + AND price_currency NOT GLOB '*[^A-Z]*' + ), + price_unit TEXT NOT NULL CHECK ( + price_unit IN ('g', 'kg', 'lb', 'oz', 'each', 'dozen', 'bunch', 'punnet', 'bag', 'basket') + ), + quantity_amount TEXT, + quantity_unit TEXT, + status TEXT NOT NULL CHECK (status IN ('active', 'sold')), + diagnostic_codes_json TEXT NOT NULL CHECK ( + json_valid(diagnostic_codes_json) + AND json_type(diagnostic_codes_json) = 'array' + ), + source_transition_seq INTEGER NOT NULL CHECK (source_transition_seq > 0), + CHECK ( + (quantity_amount IS NULL AND quantity_unit IS NULL) + OR ( + quantity_amount IS NOT NULL + AND length(quantity_amount) > 0 + AND quantity_unit = price_unit + ) + ), + PRIMARY KEY (source_generation, pubkey, d_tag), + UNIQUE (source_generation, event_id), + UNIQUE (source_generation, event_seq), + FOREIGN KEY (event_seq, event_id) + REFERENCES event_envelopes(seq, event_id) ON DELETE RESTRICT, + FOREIGN KEY (source_transition_seq) + REFERENCES radroots_event_store_addressable_head_transition(transition_seq) + ON DELETE RESTRICT +) STRICT, WITHOUT ROWID; + +CREATE INDEX radroots_event_store_food_availability_status_idx +ON radroots_event_store_food_availability_projection( + source_generation, + status, + published_at DESC, + event_id +); + +CREATE INDEX radroots_event_store_food_availability_recent_idx +ON radroots_event_store_food_availability_projection( + source_generation, + published_at DESC, + event_id +); + +CREATE INDEX radroots_event_store_food_availability_author_idx +ON radroots_event_store_food_availability_projection( + source_generation, + pubkey, + published_at DESC, + event_id +); + +CREATE TABLE radroots_event_store_food_availability_image ( + source_generation BLOB NOT NULL, + pubkey TEXT NOT NULL, + d_tag TEXT NOT NULL, + image_index INTEGER NOT NULL CHECK (image_index >= 0 AND image_index < 64), + raw_tag_json TEXT NOT NULL CHECK ( + json_valid(raw_tag_json) + AND json_type(raw_tag_json) = 'array' + ), + url TEXT, + width INTEGER CHECK (width IS NULL OR width > 0), + height INTEGER CHECK (height IS NULL OR height > 0), + blossom_sha256 TEXT CHECK ( + blossom_sha256 IS NULL + OR ( + length(blossom_sha256) = 64 + AND blossom_sha256 = lower(blossom_sha256) + AND blossom_sha256 NOT GLOB '*[^0-9a-f]*' + ) + ), + qualifies INTEGER NOT NULL CHECK (qualifies IN (0, 1)), + diagnostic_codes_json TEXT NOT NULL CHECK ( + json_valid(diagnostic_codes_json) + AND json_type(diagnostic_codes_json) = 'array' + ), + PRIMARY KEY (source_generation, pubkey, d_tag, image_index), + FOREIGN KEY (source_generation, pubkey, d_tag) + REFERENCES radroots_event_store_food_availability_projection( + source_generation, + pubkey, + d_tag + ) ON DELETE CASCADE +) STRICT, WITHOUT ROWID; + +CREATE VIEW radroots_event_store_food_availability_read_v1 AS +SELECT + projection.*, + event.raw_json AS immutable_raw_json, + ( + SELECT json_group_array(json_object( + 'image_index', ordered_image.image_index, + 'raw_tag_json', ordered_image.raw_tag_json, + 'url', ordered_image.url, + 'width', ordered_image.width, + 'height', ordered_image.height, + 'blossom_sha256', ordered_image.blossom_sha256, + 'qualifies', ordered_image.qualifies, + 'diagnostic_codes_json', ordered_image.diagnostic_codes_json + )) + FROM ( + SELECT + image.image_index, + image.raw_tag_json, + image.url, + image.width, + image.height, + image.blossom_sha256, + image.qualifies, + image.diagnostic_codes_json + FROM radroots_event_store_food_availability_image AS image + WHERE image.source_generation = projection.source_generation + AND image.pubkey = projection.pubkey + AND image.d_tag = projection.d_tag + ORDER BY image.image_index + ) AS ordered_image + ) AS stored_images_json +FROM radroots_event_store_food_availability_projection AS projection +JOIN event_envelopes AS event + ON event.seq = projection.event_seq + AND event.event_id = projection.event_id; + +CREATE VIRTUAL TABLE radroots_event_store_food_availability_search_fts USING fts5( + event_id UNINDEXED, + pubkey UNINDEXED, + d_tag UNINDEXED, + title, + summary, + content, + location, + tokenize = 'unicode61' +); + +CREATE TABLE radroots_event_store_food_availability_cursor ( + singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), + source_generation BLOB NOT NULL + REFERENCES radroots_event_store_source_generation(source_generation) + ON DELETE RESTRICT, + feed_version INTEGER NOT NULL CHECK (feed_version = 1), + projection_version INTEGER NOT NULL CHECK (projection_version = 1), + scope_fingerprint BLOB NOT NULL CHECK ( + length(scope_fingerprint) = 32 + AND hex(scope_fingerprint) = '8B63C5DDC48A2CC7DB69295238B96D5F814DBA50427C80B4D0079F061E6D3DE0' + ), + hook_manifest_sha256 TEXT NOT NULL CHECK ( + length(hook_manifest_sha256) = 64 + AND hook_manifest_sha256 NOT GLOB '*[^0-9a-f]*' + ), + last_transition_seq INTEGER NOT NULL CHECK (last_transition_seq >= 0), + projected_row_count INTEGER NOT NULL CHECK (projected_row_count >= 0) +) STRICT; + +CREATE TRIGGER radroots_event_store_food_availability_projection_insert_guard +BEFORE INSERT ON radroots_event_store_food_availability_projection +WHEN NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_food_availability_cursor AS cursor + JOIN radroots_event_store_source_state AS source + ON source.active_generation = cursor.source_generation + JOIN radroots_event_store_addressable_head_transition AS transition + ON transition.source_generation = source.active_generation + AND transition.transition_seq = NEW.source_transition_seq + AND transition.transition_seq > cursor.last_transition_seq + AND transition.kind = NEW.kind + AND transition.pubkey = NEW.pubkey + AND transition.d_tag = NEW.d_tag + AND transition.visible_event_id = NEW.event_id + AND transition.visible_event_seq = NEW.event_seq + AND transition.contract_id = NEW.contract_id + AND transition.visibility = 'visible' + WHERE source.singleton = 1 + AND source.active_generation = NEW.source_generation +) +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability projection insert is not backed by a visible transition'); +END; + +CREATE TRIGGER radroots_event_store_food_availability_projection_update_guard +BEFORE UPDATE ON radroots_event_store_food_availability_projection +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability projection rows are replaced by delete and insert'); +END; + +CREATE TRIGGER radroots_event_store_food_availability_projection_delete_guard +BEFORE DELETE ON radroots_event_store_food_availability_projection +WHEN NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_food_availability_cursor AS cursor + JOIN radroots_event_store_source_state AS source ON source.singleton = 1 + WHERE cursor.singleton = 1 + AND cursor.source_generation != source.active_generation +) +AND NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_food_availability_cursor AS cursor + JOIN radroots_event_store_addressable_head_transition AS transition + ON transition.source_generation = cursor.source_generation + AND transition.transition_seq > cursor.last_transition_seq + AND transition.kind = OLD.kind + AND transition.pubkey = OLD.pubkey + AND transition.d_tag = OLD.d_tag + AND transition.retracted_event_id = OLD.event_id + WHERE cursor.singleton = 1 +) +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability projection delete is not backed by a pending retraction'); +END; + +CREATE TRIGGER radroots_event_store_food_availability_image_insert_guard +BEFORE INSERT ON radroots_event_store_food_availability_image +WHEN NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_food_availability_projection AS projection + JOIN radroots_event_store_food_availability_cursor AS cursor + ON cursor.source_generation = projection.source_generation + WHERE projection.source_generation = NEW.source_generation + AND projection.pubkey = NEW.pubkey + AND projection.d_tag = NEW.d_tag + AND projection.source_transition_seq > cursor.last_transition_seq +) +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability image insert is not backed by a pending projection'); +END; + +CREATE TRIGGER radroots_event_store_food_availability_image_update_guard +BEFORE UPDATE ON radroots_event_store_food_availability_image +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability image rows are immutable'); +END; + +CREATE TRIGGER radroots_event_store_food_availability_image_delete_guard +BEFORE DELETE ON radroots_event_store_food_availability_image +WHEN NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_food_availability_cursor AS cursor + JOIN radroots_event_store_source_state AS source ON source.singleton = 1 + WHERE cursor.singleton = 1 + AND cursor.source_generation != source.active_generation +) +AND NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_food_availability_cursor AS cursor + JOIN radroots_event_store_addressable_head_transition AS transition + ON transition.source_generation = cursor.source_generation + AND transition.transition_seq > cursor.last_transition_seq + AND transition.kind = 30402 + AND transition.pubkey = OLD.pubkey + AND transition.d_tag = OLD.d_tag + AND transition.retracted_event_id IS NOT NULL + WHERE cursor.singleton = 1 +) +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability image delete is not backed by a pending retraction'); +END; + +CREATE TRIGGER radroots_event_store_food_availability_search_insert +AFTER INSERT ON radroots_event_store_food_availability_projection +BEGIN + INSERT INTO radroots_event_store_food_availability_search_fts( + rowid, + event_id, + pubkey, + d_tag, + title, + summary, + content, + location + ) VALUES ( + NEW.event_seq, + NEW.event_id, + NEW.pubkey, + NEW.d_tag, + NEW.title, + NEW.summary, + NEW.content, + NEW.location + ); +END; + +CREATE TRIGGER radroots_event_store_food_availability_search_delete +AFTER DELETE ON radroots_event_store_food_availability_projection +BEGIN + DELETE FROM radroots_event_store_food_availability_search_fts + WHERE rowid = OLD.event_seq; +END; + +CREATE TRIGGER radroots_event_store_food_availability_cursor_insert_guard +BEFORE INSERT ON radroots_event_store_food_availability_cursor +WHEN EXISTS ( + SELECT 1 FROM radroots_event_store_food_availability_cursor +) +OR EXISTS ( + SELECT 1 FROM radroots_event_store_food_availability_projection +) +OR NEW.feed_version != 1 +OR NEW.projection_version != 1 +OR NEW.projected_row_count != 0 +OR hex(NEW.scope_fingerprint) != '8B63C5DDC48A2CC7DB69295238B96D5F814DBA50427C80B4D0079F061E6D3DE0' +OR NEW.hook_manifest_sha256 != lower(NEW.hook_manifest_sha256) +OR NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_state AS source + JOIN radroots_event_store_source_generation AS generation + ON generation.source_generation = source.active_generation + JOIN radroots_event_store_addressable_feed_integrity_v1 AS integrity + ON integrity.source_generation = source.active_generation + WHERE source.singleton = 1 + AND source.active_generation = NEW.source_generation + AND generation.addressable_feed_version = NEW.feed_version + AND NEW.last_transition_seq = generation.transition_floor_seq + AND integrity.transition_floor_seq = generation.transition_floor_seq + AND integrity.last_transition_seq = source.last_transition_seq +) +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability cursor identity is invalid'); +END; + +CREATE TRIGGER radroots_event_store_food_availability_cursor_update_guard +BEFORE UPDATE ON radroots_event_store_food_availability_cursor +WHEN NEW.singleton != OLD.singleton +OR NEW.source_generation != OLD.source_generation +OR NEW.feed_version != OLD.feed_version +OR NEW.projection_version != OLD.projection_version +OR NEW.scope_fingerprint != OLD.scope_fingerprint +OR NEW.hook_manifest_sha256 != OLD.hook_manifest_sha256 +OR NEW.last_transition_seq <= OLD.last_transition_seq +OR NEW.last_transition_seq - OLD.last_transition_seq > 1024 +OR abs(NEW.projected_row_count - OLD.projected_row_count) > 64 +OR abs(NEW.projected_row_count - OLD.projected_row_count) > ( + SELECT COUNT(*) + FROM radroots_event_store_addressable_head_transition AS transition + WHERE transition.source_generation = NEW.source_generation + AND transition.transition_seq > OLD.last_transition_seq + AND transition.transition_seq <= NEW.last_transition_seq + AND transition.kind = 30402 +) +OR NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_state AS source + WHERE source.singleton = 1 + AND source.active_generation = NEW.source_generation + AND NEW.last_transition_seq <= source.last_transition_seq +) +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability cursor update is invalid'); +END; + +CREATE TRIGGER radroots_event_store_food_availability_cursor_delete_guard +BEFORE DELETE ON radroots_event_store_food_availability_cursor +WHEN OLD.source_generation = ( + SELECT active_generation + FROM radroots_event_store_source_state + WHERE singleton = 1 +) +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability cursor is immutable for the active generation'); +END; diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs @@ -60,8 +60,50 @@ pub enum RadrootsEventStoreError { EmptyContractList, #[error("event-store contract list length {actual} exceeds {max}")] ContractListTooLarge { max: usize, actual: usize }, + #[error("addressable transition scope cannot be empty")] + AddressableTransitionScopeEmpty, + #[error("addressable transition scope length {actual} exceeds {max}")] + AddressableTransitionScopeTooLarge { max: usize, actual: usize }, + #[error("addressable transition scope kind {kind} is outside 30000..=39999")] + AddressableTransitionScopeKindInvalid { kind: u32 }, + #[error("addressable transition cursor sequence cannot be negative: {value}")] + AddressableTransitionCursorNegative { value: i64 }, + #[error("addressable transition cursor JSON is {actual} bytes; maximum is {max}")] + AddressableTransitionCursorTooLarge { max: usize, actual: usize }, + #[error("addressable transition cursor field `{field}` is not canonical lowercase 32-byte hex")] + AddressableTransitionCursorEncoding { field: &'static str }, + #[error( + "addressable transition cursor feed version mismatch: expected {expected}, found {actual}" + )] + AddressableTransitionFeedVersionMismatch { expected: u32, actual: u32 }, + #[error("addressable transition cursor scope fingerprint does not match the requested scope")] + AddressableTransitionScopeMismatch, + #[error("addressable transition cursor source generation is no longer active")] + AddressableTransitionSourceGenerationMismatch, + #[error("addressable transition cursor {cursor} precedes the active generation floor {floor}")] + AddressableTransitionCursorExpired { cursor: i64, floor: i64 }, + #[error("addressable transition cursor {cursor} is ahead of source high-water {high_water}")] + AddressableTransitionCursorAhead { cursor: i64, high_water: i64 }, + #[error("addressable transition feed sequence interval has a gap: {reason}")] + AddressableTransitionSequenceGap { reason: String }, + #[error("addressable transition feed contains corrupt authority: {reason}")] + AddressableTransitionCorruption { reason: String }, + #[error("addressable transition canonical payload is {actual} bytes; page maximum is {max}")] + AddressableTransitionPagePayloadTooLarge { max: usize, actual: usize }, + #[error("event-store current-visibility authority is inconsistent: {reason}")] + CurrentVisibilityDrift { reason: String }, + #[error("FoodAvailability projection authority is inconsistent: {reason}")] + FoodAvailabilityProjectionDrift { reason: String }, + #[error("FoodAvailability search query must not be empty")] + FoodAvailabilitySearchEmpty, + #[error("FoodAvailability search query is {actual} bytes; maximum is {max}")] + FoodAvailabilitySearchTooLarge { max: usize, actual: usize }, + #[error("FoodAvailability search query has {actual} terms; maximum is {max}")] + FoodAvailabilitySearchTooManyTerms { max: usize, actual: usize }, #[error("event-store query limit {actual} is outside {min}..={max}")] QueryLimitOutOfRange { min: u32, max: u32, actual: u32 }, + #[error("event visibility batch contains more than {max} event ids")] + EventVisibilityBatchTooLarge { max: usize }, #[error( "an in-memory event-store pool must have exactly one connection, configured maximum was {actual}" )] @@ -73,6 +115,10 @@ pub enum RadrootsEventStoreError { #[error("event-store SQLite connection has no main database")] SqliteMainDatabaseUnavailable, #[error( + "event-store SQLite file connection did not enter WAL journal mode; reported `{actual}`" + )] + SqliteFileJournalModeNotWal { actual: String }, + #[error( "temporary schema object `{name}` ({object_type}, table `{table_name}`) collides with event-store authority" )] TemporarySchemaCollision { diff --git a/crates/event_store/src/generated.rs b/crates/event_store/src/generated.rs @@ -1 +1,2 @@ +pub(crate) mod food_availability_projection_manifest; pub(crate) mod nip09_reconciliation_manifest; diff --git a/crates/event_store/src/generated/food_availability_projection_manifest.rs b/crates/event_store/src/generated/food_availability_projection_manifest.rs @@ -0,0 +1,35 @@ +// @generated by `cargo xtask contract food-availability-projection-manifest --write`; do not edit. +#![allow(dead_code)] + +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.food_availability_projection_v1\",\n \"hook_id\": \"food_availability_projection_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/food_availability_projection_v1.manifest.schema.json\",\n \"byte_length\": 7964,\n \"sha256\": \"39171f6ef872a8d1483bc3d55049df5e0d110d9131c5adb4450b7c418f546910\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"hook_id\": \"nip09_reconciliation_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/nip09_reconciliation_v1.manifest.json\",\n \"byte_length\": 537538,\n \"sha256\": \"74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration\": {\n \"version\": 3,\n \"name\": \"food_availability_projection\",\n \"up\": {\n \"path\": \"crates/event_store/migrations/0003_food_availability_projection.up.sql\",\n \"byte_length\": 23683,\n \"sha256\": \"4e7edfb981b25f76055efc7802ec30b4034eeae9b9c0809ea4ea7c574678748a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"down\": {\n \"path\": \"crates/event_store/migrations/0003_food_availability_projection.down.sql\",\n \"byte_length\": 1755,\n \"sha256\": \"29d663320109d9dd0df6a00b6a53d8d988438d01f7a66960a9d4ba3482ffffb8\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"schema_sha256\": \"dd12467e04addcbddb5ea0f386c12a8ac05ef5ebaaf949f24dd2c62745f5aaac\",\n \"catalog\": {\n \"objects\": [\n \"radroots_event_store_addressable_feed_generation_insert\",\n \"radroots_event_store_addressable_feed_integrity_v1\",\n \"radroots_event_store_addressable_feed_transition_insert\",\n \"radroots_event_store_addressable_transition_coordinate_idx\",\n \"radroots_event_store_current_visibility_head_lookup_idx\",\n \"radroots_event_store_current_visibility_v1\",\n \"radroots_event_store_food_availability_author_idx\",\n \"radroots_event_store_food_availability_cursor\",\n \"radroots_event_store_food_availability_cursor_delete_guard\",\n \"radroots_event_store_food_availability_cursor_insert_guard\",\n \"radroots_event_store_food_availability_cursor_update_guard\",\n \"radroots_event_store_food_availability_image\",\n \"radroots_event_store_food_availability_image_delete_guard\",\n \"radroots_event_store_food_availability_image_insert_guard\",\n \"radroots_event_store_food_availability_image_update_guard\",\n \"radroots_event_store_food_availability_projection\",\n \"radroots_event_store_food_availability_projection_delete_guard\",\n \"radroots_event_store_food_availability_projection_insert_guard\",\n \"radroots_event_store_food_availability_projection_update_guard\",\n \"radroots_event_store_food_availability_read_v1\",\n \"radroots_event_store_food_availability_recent_idx\",\n \"radroots_event_store_food_availability_search_delete\",\n \"radroots_event_store_food_availability_search_fts\",\n \"radroots_event_store_food_availability_search_fts_config\",\n \"radroots_event_store_food_availability_search_fts_content\",\n \"radroots_event_store_food_availability_search_fts_data\",\n \"radroots_event_store_food_availability_search_fts_docsize\",\n \"radroots_event_store_food_availability_search_fts_idx\",\n \"radroots_event_store_food_availability_search_insert\",\n \"radroots_event_store_food_availability_status_idx\",\n \"radroots_event_store_nip09_address_target_visibility_lookup_idx\"\n ],\n \"tables\": [\n \"radroots_event_store_addressable_feed_integrity_v1\",\n \"radroots_event_store_food_availability_cursor\",\n \"radroots_event_store_food_availability_image\",\n \"radroots_event_store_food_availability_projection\",\n \"radroots_event_store_food_availability_search_fts\",\n \"radroots_event_store_food_availability_search_fts_config\",\n \"radroots_event_store_food_availability_search_fts_content\",\n \"radroots_event_store_food_availability_search_fts_data\",\n \"radroots_event_store_food_availability_search_fts_docsize\",\n \"radroots_event_store_food_availability_search_fts_idx\"\n ],\n \"fts5_tables\": [\n \"radroots_event_store_food_availability_search_fts\"\n ]\n }\n },\n \"profile\": {\n \"event_contract_registry_version\": 7,\n \"addressable_feed_version\": 1,\n \"projection_version\": 1,\n \"scope_kinds\": [\n 30402\n ],\n \"scope_fingerprint_sha256\": \"8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0\",\n \"food_contract_id\": \"radroots.food.availability.v1\",\n \"admission_authority\": \"event_contract_registry_v7\",\n \"current_visibility_authority\": \"radroots_event_store_current_visibility_v1\",\n \"post_core_capability\": \"apply_v2\"\n },\n \"registry_inventory\": {\n \"path\": \"contracts/event_store/event_contract_registry_v7.inventory.json\",\n \"byte_length\": 158021,\n \"sha256\": \"91595544310f865bdef064ee760c227c870417a95b87b3e27278f8da74fdddea\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"food_profile_vector\": {\n \"path\": \"contracts/conformance/vectors/food_availability/profile.v1.json\",\n \"byte_length\": 90099,\n \"sha256\": \"dede1eb1f682ecd548e8cd3ccb251d298762e504e2588a73528e5f7a5b9eff21\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"entry_points\": [\n {\n \"role\": \"registry_v7_admission\",\n \"rust_path\": \"radroots_event_codec::admit_verified_event_registry_v7\"\n },\n {\n \"role\": \"migration_registry\",\n \"rust_path\": \"radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[2]\"\n },\n {\n \"role\": \"migration_apply_hook\",\n \"rust_path\": \"radroots_event_store::schema::apply_migration_hook\"\n },\n {\n \"role\": \"migration_validation_hook\",\n \"rust_path\": \"radroots_event_store::schema::validate_migration_hook_state\"\n },\n {\n \"role\": \"post_core_extension\",\n \"rust_path\": \"radroots_event_store::store::PostCoreExtensionCapabilities::apply_v2\"\n },\n {\n \"role\": \"addressable_transition_feed\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::addressable_transition_page_v1\"\n },\n {\n \"role\": \"current_visibility\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::current_event_visibility_v1\"\n },\n {\n \"role\": \"event_visibility_batch\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::event_visibilities\"\n },\n {\n \"role\": \"projection_lookup\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::food_availability_v1\"\n },\n {\n \"role\": \"projection_recent\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::recent_food_availability_v1\"\n },\n {\n \"role\": \"projection_search\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::search_food_availability_v1\"\n },\n {\n \"role\": \"projection_audit\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::audit_food_availability_projection_v1\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"food_availability_projection_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"workspace_dependency_authority\",\n \"path\": \"Cargo.toml\",\n \"byte_length\": 10836,\n \"sha256\": \"285532dbb0894204843a832880f136ceac5ee312a3203ff951fb0551fac63ec4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_dependency_authority\",\n \"path\": \"crates/event_store/Cargo.toml\",\n \"byte_length\": 1466,\n \"sha256\": \"9e82d57b64bb7fcf145ee8b919682dd6d788b5503be743d11326f5b882fb2d7e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_public_surface\",\n \"path\": \"crates/blossom/src/lib.rs\",\n \"byte_length\": 1335,\n \"sha256\": \"61ebee86f02887c45507bab052686da082f74ae26f23d18ff4019e02c70097bd\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"blossom_sha256_value_object\",\n \"path\": \"crates/blossom/src/hash.rs\",\n \"byte_length\": 11251,\n \"sha256\": \"753eff6ef9a810045c88839d39a46b37d62a4ad0a5ea57aa30e2a8219e3cdbda\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_event_contract\",\n \"path\": \"crates/event/src/food_availability.rs\",\n \"byte_length\": 45680,\n \"sha256\": \"1c8c3fba6514f9b246545b3305e8ac2742258857d1b813fa34c5fbefa6135e12\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_admission\",\n \"path\": \"crates/event_codec/src/food_availability/admission.rs\",\n \"byte_length\": 5799,\n \"sha256\": \"727ebf8f086c14376aba745a0a02b269115fed01e8ce95f90fdd211b9640f842\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"registry_v7_admission\",\n \"path\": \"crates/event_codec/src/admission/registry_v7.rs\",\n \"byte_length\": 5350,\n \"sha256\": \"755e63dd7ad1115c219e5a3ea540bef67d2770fc9f2a5aa6ba97c925c99bdced\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"registry_v7_food_projection\",\n \"path\": \"crates/event_codec/src/food_availability/inbound/registry_v7.rs\",\n \"byte_length\": 26865,\n \"sha256\": \"908015346eaec97bee6f44fe2a524410b701529ac19a5dab4505706d40d527da\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"event_store_error_surface\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 17557,\n \"sha256\": \"ac206fafdc67e4c25f4a2f1bcd38eb34ae224b38aca55894b1a9cc67242d8b1a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 100,\n \"sha256\": \"ddd71b2245f307cac0c8ef835311d0dd9eb2fdaf9d2a76a6c9dc98e56f4faaec\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 3419,\n \"sha256\": \"d5bc071309fd4ec9e26a3cfe9c0e7c00b5de5b6c248902b888891b39e6780d23\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 55541,\n \"sha256\": \"ef62808fe75b5c0704567eecccfe23d0b11ae25d332bc5e38451ea2a446c535a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"model_registration\",\n \"path\": \"crates/event_store/src/model.rs\",\n \"byte_length\": 33617,\n \"sha256\": \"79296b8f263aa06d17005795e4515f769f064ea6fd971eeb1296e1151debaf20\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"schema_hooks\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 97574,\n \"sha256\": \"6d259548559f32e259d3502787ccd4efbd22eebd1406dd7dd68c8f3145411543\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"store_ingest_and_wal_authority\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 357693,\n \"sha256\": \"8bca94e5c7b26cee60a06ea327fcba385e6cbc51550f1774ef4d0488ffa2727b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_model\",\n \"path\": \"crates/event_store/src/model/addressable_transition_feed_v1.rs\",\n \"byte_length\": 22314,\n \"sha256\": \"b1c6b0a68f34459f7e14bd63857596154c0aa3fd02dc6c1661d543bb681324a7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_model\",\n \"path\": \"crates/event_store/src/model/current_visibility_v1.rs\",\n \"byte_length\": 5691,\n \"sha256\": \"25ec92f45006e2f66f2e1c8b954a021334bb1595b849c4d8529f289d3f7aeb25\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_projection_model\",\n \"path\": \"crates/event_store/src/model/food_availability_projection_v1.rs\",\n \"byte_length\": 17493,\n \"sha256\": \"1e5ff9c05a81fda223ed1a27ff18a1b08bcdeaec9047a13fdd577390b3e0fdb9\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"addressable_transition_feed_store\",\n \"path\": \"crates/event_store/src/store/addressable_transition_feed_v1.rs\",\n \"byte_length\": 40253,\n \"sha256\": \"fe23424aa1e6b39f9aba2dfa4470652b26b4990f91204a2bdfe379c03da9b610\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"current_visibility_store\",\n \"path\": \"crates/event_store/src/store/current_visibility_v1.rs\",\n \"byte_length\": 15860,\n \"sha256\": \"8615086e674c30700305debcef11de5b3dbfe5aec735c0f58b4ac11caa518596\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"food_projection_store\",\n \"path\": \"crates/event_store/src/store/food_availability_projection_v1.rs\",\n \"byte_length\": 49029,\n \"sha256\": \"0cec060c50c50d9333e0583e338fe311a1c64a1d586c222e391d8cef685cf871\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_protocol_storage\",\n \"path\": \"crates/event_store/src/store/protocol_storage_v1.rs\",\n \"byte_length\": 10975,\n \"sha256\": \"155c74d27eee5db1d6f0f844f9d319604eefbbf640f4b2371ac5d0e370816e50\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_active_state_fast_validation\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 183733,\n \"sha256\": \"3bddb664491bd9abe878f5b2441d1230fb188368dae341f3425a2b035211366a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_capabilities\",\n \"path\": \"crates/event_store/src/store/post_core_extension_capabilities.rs\",\n \"byte_length\": 1255,\n \"sha256\": \"cb434372156cb7ff31dac392d7095c2e5f44b128fae4e705516d6d000e2e2502\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_dispatcher\",\n \"path\": \"crates/event_store/src/store/post_core_extension_dispatcher.rs\",\n \"byte_length\": 576,\n \"sha256\": \"df62ee92e9f165502d5e533997a47f533129fd3cffab2d9b2012e2ed22405f48\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_v2_extension\",\n \"path\": \"crates/event_store/src/store/post_core_extensions_v2.rs\",\n \"byte_length\": 294,\n \"sha256\": \"8dcbc503ed9ea6fb06ed9a2a83b0804d928f9590b5706de25a057ad72c0d38d2\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"post_core_v2_storage\",\n \"path\": \"crates/event_store/src/store/post_core_storage_v2.rs\",\n \"byte_length\": 632,\n \"sha256\": \"4b672770f3c34bf887e4cc949c068cb0c87396cb4af8efb6e13d39aa4e0d973a\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_post_core_v1_extension\",\n \"path\": \"crates/event_store/src/store/post_core_extensions_v1.rs\",\n \"byte_length\": 6935,\n \"sha256\": \"fb165704c64d982cf3be0a880c44985be6b375758451e94b2aaaf30881769f18\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_post_core_v1_storage\",\n \"path\": \"crates/event_store/src/store/post_core_storage_v1.rs\",\n \"byte_length\": 16871,\n \"sha256\": \"a6dca0884762cec3c32e460d17662ced9d0335f30e79b3d5fdb3461259d3ec19\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": [\n \"RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1\",\n \"RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1\",\n \"RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1\",\n \"RadrootsAddressableTransitionCauseV1\",\n \"RadrootsAddressableTransitionCoordinateV1\",\n \"RadrootsAddressableTransitionCursorV1\",\n \"RadrootsAddressableTransitionEventReferenceV1\",\n \"RadrootsAddressableTransitionOriginV1\",\n \"RadrootsAddressableTransitionPageV1\",\n \"RadrootsAddressableTransitionRawHeadDecisionV1\",\n \"RadrootsAddressableTransitionScopeFingerprintV1\",\n \"RadrootsAddressableTransitionScopeV1\",\n \"RadrootsAddressableTransitionV1\",\n \"RadrootsAddressableTransitionVisibilityV1\",\n \"RadrootsCurrentEventVisibilityV1\",\n \"RadrootsCurrentVisibilityDecisionV1\",\n \"RadrootsFoodAvailabilitySearchQueryV1\",\n \"RadrootsFoodAvailabilityStatusFilterV1\",\n \"RadrootsNip09SuppressionEvidenceV1\",\n \"RadrootsNip09SuppressionOutcome\",\n \"RadrootsNip09SuppressionReason\",\n \"RadrootsStoreProducedCanonicalEventV1\",\n \"RadrootsStoredFoodAvailabilityImageV1\",\n \"RadrootsStoredFoodAvailabilityV1\"\n ],\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/food_availability_projection.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/food_availability_projection.v1.json\",\n \"byte_length\": 103659,\n \"sha256\": \"fca2b71b47736ed04ed1e908823b65b3fc3cf0366cb162128369fe328295bb63\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.food_availability_projection_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/food_availability_projection_v1_result_vector.rs\",\n \"executor_test\": \"food_availability_projection_v1_result_vector\",\n \"executor_byte_length\": 34075,\n \"executor_sha256\": \"9e8e11abae7bbc7dda30eab6f0a79074ffc3761aa6b955cff58c4c62fa581aa3\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MANIFEST_BYTE_LENGTH: usize = 17455; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256: &str = + "33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MANIFEST_SCHEMA_VERSION: u32 = 1; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_CONTRACT_ID: &str = + "radroots_event_store.food_availability_projection_v1"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_HOOK_ID: &str = "food_availability_projection_v1"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_VERSION: u32 = 3; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_NAME: &str = "food_availability_projection"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_VERSION: u32 = 1; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_FEED_VERSION: u32 = 1; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 7; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_BYTE_LENGTH: usize = 23683; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_SHA256: &str = + "4e7edfb981b25f76055efc7802ec30b4034eeae9b9c0809ea4ea7c574678748a"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_BYTE_LENGTH: usize = 1755; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_SHA256: &str = + "29d663320109d9dd0df6a00b6a53d8d988438d01f7a66960a9d4ba3482ffffb8"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_SCHEMA_SHA256: &str = + "dd12467e04addcbddb5ea0f386c12a8ac05ef5ebaaf949f24dd2c62745f5aaac"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_SCOPE_KINDS: &[u32] = &[30_402]; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_SCOPE_FINGERPRINT_SHA256: &str = + "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_PREDECESSOR_MANIFEST_SHA256: &str = + "74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_SHA256: &str = + "fca2b71b47736ed04ed1e908823b65b3fc3cf0366cb162128369fe328295bb63"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_EXECUTOR_ID: &str = + "radroots_event_store.food_availability_projection_v1.result_vector_executor.v1"; +pub(crate) const FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_EXECUTOR_SHA256: &str = + "9e8e11abae7bbc7dda30eab6f0a79074ffc3761aa6b955cff58c4c62fa581aa3"; diff --git a/crates/event_store/src/lib.rs b/crates/event_store/src/lib.rs @@ -24,12 +24,33 @@ pub use migrations::{ }; #[cfg(feature = "sqlite")] pub use model::{ - RADROOTS_TRANSPORT_OBSERVATION_MESSAGE_MAX_BYTES, RadrootsEventAdmissionStatus, - RadrootsEventIngest, RadrootsEventIngestReceipt, RadrootsEventPersistence, - RadrootsEventStoreSourceGeneration, RadrootsEventStoreStatusSummary, RadrootsEventVisibility, - RadrootsProjectionCursor, RadrootsProjectionRebuildPrior, RadrootsProjectionRebuildTicket, - RadrootsRawHeadDecision, RadrootsStoredEventTag, RadrootsStoredRawEvent, - RadrootsStoredRawEventHead, RadrootsStoredSellerReservation, + RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1, + RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1, + RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1, + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1, + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1, + RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1, + RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1, + RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1, + RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1, + RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1, + RADROOTS_TRANSPORT_OBSERVATION_MESSAGE_MAX_BYTES, RadrootsAddressableTransitionCauseV1, + RadrootsAddressableTransitionCoordinateV1, RadrootsAddressableTransitionCursorV1, + RadrootsAddressableTransitionEventReferenceV1, RadrootsAddressableTransitionOriginV1, + RadrootsAddressableTransitionPageV1, RadrootsAddressableTransitionRawHeadDecisionV1, + RadrootsAddressableTransitionScopeFingerprintV1, RadrootsAddressableTransitionScopeV1, + RadrootsAddressableTransitionV1, RadrootsAddressableTransitionVisibilityV1, + RadrootsCurrentEventVisibilityV1, RadrootsCurrentVisibilityDecisionV1, + RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventIngestReceipt, + RadrootsEventPersistence, RadrootsEventStoreSourceGeneration, RadrootsEventStoreStatusSummary, + RadrootsEventVisibility, RadrootsFoodAvailabilitySearchQueryV1, + RadrootsFoodAvailabilityStatusFilterV1, RadrootsNip09SuppressionEvidenceV1, + RadrootsNip09SuppressionOutcome, RadrootsNip09SuppressionReason, RadrootsProjectionCursor, + RadrootsProjectionRebuildPrior, RadrootsProjectionRebuildTicket, RadrootsRawHeadDecision, + RadrootsStoreProducedCanonicalEventV1, RadrootsStoredEventTag, + RadrootsStoredFoodAvailabilityImageV1, RadrootsStoredFoodAvailabilityV1, + RadrootsStoredRawEvent, RadrootsStoredRawEventHead, RadrootsStoredSellerReservation, RadrootsStoredSellerReservationLine, RadrootsStoredTradeMissingParent, RadrootsStoredTradeMutation, RadrootsStoredTradeMutationParent, RadrootsStoredTradeTransportEnvelope, RadrootsStoredValidEvent, RadrootsStoredVisibleEvent, diff --git a/crates/event_store/src/migrations.rs b/crates/event_store/src/migrations.rs @@ -1,4 +1,5 @@ use crate::RadrootsEventStoreError; +use crate::generated::food_availability_projection_manifest as food_manifest; use crate::generated::nip09_reconciliation_manifest as nip09_manifest; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -7,7 +8,7 @@ pub(crate) const EVENT_STORE_LEDGER_NAME: &str = "radroots_event_store_schema_mi pub(crate) const EVENT_STORE_RESERVED_PREFIX: &str = "radroots_event_store_"; pub const RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN: u32 = 1; -pub const RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT: u32 = 2; +pub const RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT: u32 = 3; pub(crate) const EVENT_STORE_LEDGER_DDL: &str = "CREATE TABLE radroots_event_store_schema_migrations ( version INTEGER PRIMARY KEY NOT NULL CHECK (version > 0), @@ -103,6 +104,7 @@ pub(crate) const EVENT_STORE_BASELINE_FTS5_TABLE_NAMES: &[&str] = &["listing_sea pub(crate) enum EventStoreMigrationHook { None, Nip09ReconciliationV1, + FoodAvailabilityProjectionV1, } impl EventStoreMigrationHook { @@ -110,6 +112,9 @@ impl EventStoreMigrationHook { match self { Self::None => "none", Self::Nip09ReconciliationV1 => nip09_manifest::NIP09_RECONCILIATION_HOOK_ID, + Self::FoodAvailabilityProjectionV1 => { + food_manifest::FOOD_AVAILABILITY_PROJECTION_HOOK_ID + } } } @@ -119,10 +124,63 @@ impl EventStoreMigrationHook { Self::Nip09ReconciliationV1 => { Some(nip09_manifest::NIP09_RECONCILIATION_MANIFEST_SHA256) } + Self::FoodAvailabilityProjectionV1 => { + Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256) + } } } } +pub(crate) const EVENT_STORE_FOOD_AVAILABILITY_OBJECT_NAMES: &[&str] = &[ + "radroots_event_store_addressable_feed_generation_insert", + "radroots_event_store_addressable_feed_integrity_v1", + "radroots_event_store_addressable_feed_transition_insert", + "radroots_event_store_addressable_transition_coordinate_idx", + "radroots_event_store_current_visibility_head_lookup_idx", + "radroots_event_store_current_visibility_v1", + "radroots_event_store_food_availability_author_idx", + "radroots_event_store_food_availability_cursor", + "radroots_event_store_food_availability_cursor_delete_guard", + "radroots_event_store_food_availability_cursor_insert_guard", + "radroots_event_store_food_availability_cursor_update_guard", + "radroots_event_store_food_availability_image", + "radroots_event_store_food_availability_image_delete_guard", + "radroots_event_store_food_availability_image_insert_guard", + "radroots_event_store_food_availability_image_update_guard", + "radroots_event_store_food_availability_projection", + "radroots_event_store_food_availability_projection_delete_guard", + "radroots_event_store_food_availability_projection_insert_guard", + "radroots_event_store_food_availability_projection_update_guard", + "radroots_event_store_food_availability_read_v1", + "radroots_event_store_food_availability_recent_idx", + "radroots_event_store_food_availability_search_delete", + "radroots_event_store_food_availability_search_fts", + "radroots_event_store_food_availability_search_fts_config", + "radroots_event_store_food_availability_search_fts_content", + "radroots_event_store_food_availability_search_fts_data", + "radroots_event_store_food_availability_search_fts_docsize", + "radroots_event_store_food_availability_search_fts_idx", + "radroots_event_store_food_availability_search_insert", + "radroots_event_store_food_availability_status_idx", + "radroots_event_store_nip09_address_target_visibility_lookup_idx", +]; + +pub(crate) const EVENT_STORE_FOOD_AVAILABILITY_TABLE_NAMES: &[&str] = &[ + "radroots_event_store_addressable_feed_integrity_v1", + "radroots_event_store_food_availability_cursor", + "radroots_event_store_food_availability_image", + "radroots_event_store_food_availability_projection", + "radroots_event_store_food_availability_search_fts", + "radroots_event_store_food_availability_search_fts_config", + "radroots_event_store_food_availability_search_fts_content", + "radroots_event_store_food_availability_search_fts_data", + "radroots_event_store_food_availability_search_fts_docsize", + "radroots_event_store_food_availability_search_fts_idx", +]; + +pub(crate) const EVENT_STORE_FOOD_AVAILABILITY_FTS5_TABLE_NAMES: &[&str] = + &["radroots_event_store_food_availability_search_fts"]; + pub(crate) const EVENT_STORE_NIP09_OBJECT_NAMES: &[&str] = &[ "radroots_event_store_addressable_head_state", "radroots_event_store_addressable_head_transition", @@ -278,6 +336,25 @@ pub(crate) const EVENT_STORE_MIGRATIONS: &[EventStoreMigration] = &[ nip09_manifest::NIP09_RECONCILIATION_EVENT_CONTRACT_REGISTRY_VERSION, ), }, + EventStoreMigration { + version: 3, + name: "food_availability_projection", + up_sql: include_str!("../migrations/0003_food_availability_projection.up.sql"), + down_sql: include_str!("../migrations/0003_food_availability_projection.down.sql"), + up_len: food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_BYTE_LENGTH, + down_len: food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_BYTE_LENGTH, + up_sha256: food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_SHA256, + down_sha256: food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_SHA256, + schema_sha256: food_manifest::FOOD_AVAILABILITY_PROJECTION_SCHEMA_SHA256, + owned_object_names: EVENT_STORE_FOOD_AVAILABILITY_OBJECT_NAMES, + owned_table_names: EVENT_STORE_FOOD_AVAILABILITY_TABLE_NAMES, + fts5_table_names: EVENT_STORE_FOOD_AVAILABILITY_FTS5_TABLE_NAMES, + hook: EventStoreMigrationHook::FoodAvailabilityProjectionV1, + hook_manifest_sha256: Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256), + event_contract_registry_version: Some( + food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION, + ), + }, ]; pub(crate) fn migration_for_version( @@ -344,6 +421,12 @@ pub(crate) fn validate_migration_registry( { validate_generated_nip09_manifest_descriptor()?; } + if registry + .iter() + .any(|migration| migration.hook == EventStoreMigrationHook::FoodAvailabilityProjectionV1) + { + validate_generated_food_availability_projection_manifest_descriptor()?; + } if minimum == 0 || current < minimum || registry.is_empty() { return Err(RadrootsEventStoreError::MigrationRegistryDefect { reason: format!( @@ -468,6 +551,11 @@ pub(crate) fn validate_migration_registry( EventStoreMigrationHook::Nip09ReconciliationV1, Some(nip09_manifest::NIP09_RECONCILIATION_MANIFEST_SHA256), Some(nip09_manifest::NIP09_RECONCILIATION_EVENT_CONTRACT_REGISTRY_VERSION), + ) + | ( + EventStoreMigrationHook::FoodAvailabilityProjectionV1, + Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256), + Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION), ) => {} (hook, manifest, registry_version) => { return Err(RadrootsEventStoreError::MigrationRegistryDefect { @@ -638,6 +726,190 @@ fn validate_generated_nip09_manifest_descriptor() -> Result<(), RadrootsEventSto Ok(()) } +fn validate_generated_food_availability_projection_manifest_descriptor() +-> Result<(), RadrootsEventStoreError> { + let bytes = food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_JSON.as_bytes(); + if bytes.len() != food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_BYTE_LENGTH { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: "generated FoodAvailability projection manifest byte length is inconsistent" + .to_owned(), + }); + } + validate_sha256_literal( + food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_VERSION, + "hook manifest", + food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256, + )?; + if sha256_hex(bytes) != food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256 { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: "generated FoodAvailability projection manifest digest is inconsistent" + .to_owned(), + }); + } + let manifest: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| { + RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!( + "generated FoodAvailability projection manifest JSON is invalid: {error}" + ), + } + })?; + let up_byte_length = + u64::try_from(food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_BYTE_LENGTH) + .map_err(|_| RadrootsEventStoreError::MigrationRegistryDefect { + reason: "generated FoodAvailability migration up byte length is out of range" + .to_owned(), + })?; + let down_byte_length = + u64::try_from(food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_BYTE_LENGTH) + .map_err(|_| RadrootsEventStoreError::MigrationRegistryDefect { + reason: "generated FoodAvailability migration down byte length is out of range" + .to_owned(), + })?; + let expected_numbers = [ + ( + "/schema_version", + u64::from(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SCHEMA_VERSION), + ), + ( + "/migration/version", + u64::from(food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_VERSION), + ), + ("/migration/up/byte_length", up_byte_length), + ("/migration/down/byte_length", down_byte_length), + ( + "/profile/projection_version", + u64::from(food_manifest::FOOD_AVAILABILITY_PROJECTION_VERSION), + ), + ( + "/profile/addressable_feed_version", + u64::from(food_manifest::FOOD_AVAILABILITY_PROJECTION_FEED_VERSION), + ), + ( + "/profile/event_contract_registry_version", + u64::from(food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION), + ), + ]; + let expected_strings = [ + ( + "/contract_id", + food_manifest::FOOD_AVAILABILITY_PROJECTION_CONTRACT_ID, + ), + ( + "/hook_id", + food_manifest::FOOD_AVAILABILITY_PROJECTION_HOOK_ID, + ), + ( + "/predecessor/manifest/sha256", + food_manifest::FOOD_AVAILABILITY_PROJECTION_PREDECESSOR_MANIFEST_SHA256, + ), + ( + "/migration/name", + food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_NAME, + ), + ( + "/migration/up/sha256", + food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_SHA256, + ), + ( + "/migration/down/sha256", + food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_SHA256, + ), + ( + "/migration/schema_sha256", + food_manifest::FOOD_AVAILABILITY_PROJECTION_SCHEMA_SHA256, + ), + ( + "/profile/scope_fingerprint_sha256", + food_manifest::FOOD_AVAILABILITY_PROJECTION_SCOPE_FINGERPRINT_SHA256, + ), + ( + "/result_vector/sha256", + food_manifest::FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_SHA256, + ), + ( + "/result_vector/executor_id", + food_manifest::FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_EXECUTOR_ID, + ), + ( + "/result_vector/executor_sha256", + food_manifest::FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_EXECUTOR_SHA256, + ), + ]; + let numbers_match = expected_numbers.iter().all(|(pointer, expected)| { + manifest.pointer(pointer).and_then(|value| value.as_u64()) == Some(*expected) + }); + let strings_match = expected_strings.iter().all(|(pointer, expected)| { + manifest.pointer(pointer).and_then(|value| value.as_str()) == Some(*expected) + }); + let scope_matches = manifest + .pointer("/profile/scope_kinds") + .and_then(|value| value.as_array()) + .is_some_and(|scope| scope.len() == 1 && scope[0].as_u64() == Some(30_402)); + if food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SCHEMA_VERSION != 1 + || food_manifest::FOOD_AVAILABILITY_PROJECTION_CONTRACT_ID + != "radroots_event_store.food_availability_projection_v1" + || food_manifest::FOOD_AVAILABILITY_PROJECTION_HOOK_ID != "food_availability_projection_v1" + || food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_VERSION != 3 + || food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_NAME + != "food_availability_projection" + || food_manifest::FOOD_AVAILABILITY_PROJECTION_VERSION != 1 + || food_manifest::FOOD_AVAILABILITY_PROJECTION_FEED_VERSION != 1 + || food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION != 7 + || food_manifest::FOOD_AVAILABILITY_PROJECTION_SCOPE_KINDS != [30_402] + || food_manifest::FOOD_AVAILABILITY_PROJECTION_SCOPE_FINGERPRINT_SHA256 + != "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0" + || food_manifest::FOOD_AVAILABILITY_PROJECTION_PREDECESSOR_MANIFEST_SHA256 + != nip09_manifest::NIP09_RECONCILIATION_MANIFEST_SHA256 + || food_manifest::FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_EXECUTOR_ID + != "radroots_event_store.food_availability_projection_v1.result_vector_executor.v1" + || !numbers_match + || !strings_match + || !scope_matches + { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: "generated FoodAvailability projection manifest metadata is inconsistent" + .to_owned(), + }); + } + for (field, digest) in [ + ( + "migration up", + food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_SHA256, + ), + ( + "migration down", + food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_SHA256, + ), + ( + "schema", + food_manifest::FOOD_AVAILABILITY_PROJECTION_SCHEMA_SHA256, + ), + ( + "scope fingerprint", + food_manifest::FOOD_AVAILABILITY_PROJECTION_SCOPE_FINGERPRINT_SHA256, + ), + ( + "predecessor manifest", + food_manifest::FOOD_AVAILABILITY_PROJECTION_PREDECESSOR_MANIFEST_SHA256, + ), + ( + "result vector", + food_manifest::FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_SHA256, + ), + ( + "result-vector executor", + food_manifest::FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_EXECUTOR_SHA256, + ), + ] { + validate_sha256_literal( + food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_VERSION, + field, + digest, + )?; + } + Ok(()) +} + fn validate_owned_schema_name( version: u32, object_kind: &'static str, @@ -874,7 +1146,7 @@ mod migration_framework { #[test] fn embedded_registry_is_contiguous_and_byte_pinned() { validate_embedded_migration_registry().expect("valid registry"); - assert_eq!(EVENT_STORE_MIGRATIONS.len(), 2); + assert_eq!(EVENT_STORE_MIGRATIONS.len(), 3); assert_eq!(EVENT_STORE_MIGRATIONS[0].version, 1); assert_eq!(EVENT_STORE_MIGRATIONS[0].name, "event_store"); assert_eq!(EVENT_STORE_MIGRATIONS[0].up_len, FROZEN_V1_UP_LEN); @@ -899,6 +1171,19 @@ mod migration_framework { EVENT_STORE_MIGRATIONS[1].event_contract_registry_version, Some(nip09_manifest::NIP09_RECONCILIATION_EVENT_CONTRACT_REGISTRY_VERSION) ); + assert_eq!(EVENT_STORE_MIGRATIONS[2].version, 3); + assert_eq!( + EVENT_STORE_MIGRATIONS[2].name, + "food_availability_projection" + ); + assert_eq!( + EVENT_STORE_MIGRATIONS[2].hook, + EventStoreMigrationHook::FoodAvailabilityProjectionV1 + ); + assert_eq!( + EVENT_STORE_MIGRATIONS[2].event_contract_registry_version, + Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION) + ); } #[test] diff --git a/crates/event_store/src/model.rs b/crates/event_store/src/model.rs @@ -1,6 +1,38 @@ +mod addressable_transition_feed_v1; +mod current_visibility_v1; +mod food_availability_projection_v1; mod ingest_reconciliation_v1; pub(crate) mod reconciliation_v1; +pub use addressable_transition_feed_v1::{ + RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1, + RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1, + RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1, + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1, + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1, + RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1, RadrootsAddressableTransitionCauseV1, + RadrootsAddressableTransitionCoordinateV1, RadrootsAddressableTransitionCursorV1, + RadrootsAddressableTransitionEventReferenceV1, RadrootsAddressableTransitionOriginV1, + RadrootsAddressableTransitionPageV1, RadrootsAddressableTransitionRawHeadDecisionV1, + RadrootsAddressableTransitionScopeFingerprintV1, RadrootsAddressableTransitionScopeV1, + RadrootsAddressableTransitionV1, RadrootsAddressableTransitionVisibilityV1, + RadrootsStoreProducedCanonicalEventV1, +}; +pub use current_visibility_v1::{ + RadrootsCurrentEventVisibilityV1, RadrootsCurrentVisibilityDecisionV1, + RadrootsNip09SuppressionEvidenceV1, RadrootsNip09SuppressionOutcome, + RadrootsNip09SuppressionReason, +}; +pub use food_availability_projection_v1::{ + RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1, + RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1, + RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1, + RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1, RadrootsFoodAvailabilitySearchQueryV1, + RadrootsFoodAvailabilityStatusFilterV1, RadrootsStoredFoodAvailabilityImageV1, + RadrootsStoredFoodAvailabilityV1, +}; + use crate::RadrootsEventStoreError; use radroots_event::RadrootsEventKind; use radroots_event::ids::{ @@ -349,7 +381,15 @@ impl RadrootsStoredVisibleEventHead { pub enum RadrootsEventVisibility { Visible, NotAdmitted, - NotCurrent { raw_head_event_id: String }, + NotCurrent { + raw_head_event_id: String, + }, + Suppressed { + reason: RadrootsNip09SuppressionReason, + event_reference_request_id: Option<RadrootsEventId>, + address_reference_request_id: Option<RadrootsEventId>, + address_reference_cutoff: Option<u64>, + }, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/event_store/src/model/addressable_transition_feed_v1.rs b/crates/event_store/src/model/addressable_transition_feed_v1.rs @@ -0,0 +1,647 @@ +use super::{ + RadrootsEventAdmissionStatus, RadrootsEventStoreSourceGeneration, + RadrootsNip09SuppressionEvidenceV1, +}; +use crate::RadrootsEventStoreError; +use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; + +pub const RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1: u32 = 1; +pub const RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1: usize = 64; +pub const RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1: u32 = 64; +pub const RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1: u32 = 1_024; +pub const RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1: usize = 4 * 1024 * 1024; +pub const RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1: usize = + radroots_event::wire::v1::DEFAULT_TAG_ELEMENT_MAX_BYTES; +pub const RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1: usize = 512; +const SCOPE_FINGERPRINT_DOMAIN_V1: &[u8] = b"radroots.addressable-transition-scope.v1\0"; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RadrootsAddressableTransitionScopeFingerprintV1([u8; 32]); + +impl RadrootsAddressableTransitionScopeFingerprintV1 { + pub const fn from_bytes(bytes: [u8; 32]) -> Self { + Self(bytes) + } + + pub const fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn to_hex(self) -> String { + hex::encode(self.0) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAddressableTransitionScopeV1 { + kinds: Vec<u32>, + fingerprint: RadrootsAddressableTransitionScopeFingerprintV1, +} + +impl RadrootsAddressableTransitionScopeV1 { + pub fn new(kinds: impl IntoIterator<Item = u32>) -> Result<Self, RadrootsEventStoreError> { + let mut canonical_kinds = BTreeSet::new(); + let mut input_count = 0usize; + for kind in kinds { + input_count = input_count.saturating_add(1); + if input_count > RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1 { + return Err( + RadrootsEventStoreError::AddressableTransitionScopeTooLarge { + max: RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1, + actual: input_count, + }, + ); + } + canonical_kinds.insert(kind); + } + let kinds = canonical_kinds; + if kinds.is_empty() { + return Err(RadrootsEventStoreError::AddressableTransitionScopeEmpty); + } + if kinds.len() > RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1 { + return Err( + RadrootsEventStoreError::AddressableTransitionScopeTooLarge { + max: RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1, + actual: kinds.len(), + }, + ); + } + if let Some(kind) = kinds + .iter() + .copied() + .find(|kind| !(30_000..=39_999).contains(kind)) + { + return Err(RadrootsEventStoreError::AddressableTransitionScopeKindInvalid { kind }); + } + let kinds = kinds.into_iter().collect::<Vec<_>>(); + let mut hasher = Sha256::new(); + hasher.update(SCOPE_FINGERPRINT_DOMAIN_V1); + hasher.update( + u32::try_from(kinds.len()) + .expect("scope maximum fits u32") + .to_be_bytes(), + ); + for kind in &kinds { + hasher.update(kind.to_be_bytes()); + } + let fingerprint = + RadrootsAddressableTransitionScopeFingerprintV1::from_bytes(hasher.finalize().into()); + Ok(Self { kinds, fingerprint }) + } + + pub fn food_availability() -> Self { + Self::new([30_402]).expect("the FoodAvailability kind is addressable") + } + + pub fn kinds(&self) -> &[u32] { + &self.kinds + } + + pub const fn fingerprint(&self) -> RadrootsAddressableTransitionScopeFingerprintV1 { + self.fingerprint + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAddressableTransitionCursorV1 { + source_generation: RadrootsEventStoreSourceGeneration, + scope_fingerprint: RadrootsAddressableTransitionScopeFingerprintV1, + last_transition_seq: i64, +} + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct AddressableTransitionCursorWireV1 { + source_generation: String, + feed_version: u32, + scope_fingerprint: String, + last_transition_seq: i64, +} + +impl RadrootsAddressableTransitionCursorV1 { + pub fn new( + source_generation: RadrootsEventStoreSourceGeneration, + scope_fingerprint: RadrootsAddressableTransitionScopeFingerprintV1, + last_transition_seq: i64, + ) -> Result<Self, RadrootsEventStoreError> { + if last_transition_seq < 0 { + return Err( + RadrootsEventStoreError::AddressableTransitionCursorNegative { + value: last_transition_seq, + }, + ); + } + Ok(Self { + source_generation, + scope_fingerprint, + last_transition_seq, + }) + } + + pub const fn source_generation(&self) -> RadrootsEventStoreSourceGeneration { + self.source_generation + } + + pub const fn feed_version(&self) -> u32 { + RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1 + } + + pub const fn scope_fingerprint(&self) -> RadrootsAddressableTransitionScopeFingerprintV1 { + self.scope_fingerprint + } + + pub const fn last_transition_seq(&self) -> i64 { + self.last_transition_seq + } + + pub fn to_json(&self) -> Result<String, RadrootsEventStoreError> { + Ok(serde_json::to_string(&AddressableTransitionCursorWireV1 { + source_generation: hex::encode(self.source_generation.as_bytes()), + feed_version: RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + scope_fingerprint: self.scope_fingerprint.to_hex(), + last_transition_seq: self.last_transition_seq, + })?) + } + + pub fn from_json(value: &str) -> Result<Self, RadrootsEventStoreError> { + if value.len() > RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1 { + return Err( + RadrootsEventStoreError::AddressableTransitionCursorTooLarge { + max: RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1, + actual: value.len(), + }, + ); + } + let cursor: AddressableTransitionCursorWireV1 = serde_json::from_str(value)?; + if cursor.feed_version != RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1 { + return Err( + RadrootsEventStoreError::AddressableTransitionFeedVersionMismatch { + expected: RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + actual: cursor.feed_version, + }, + ); + } + Self::new( + RadrootsEventStoreSourceGeneration::from_bytes(decode_cursor_hex( + "source_generation", + cursor.source_generation.as_str(), + )?), + RadrootsAddressableTransitionScopeFingerprintV1::from_bytes(decode_cursor_hex( + "scope_fingerprint", + cursor.scope_fingerprint.as_str(), + )?), + cursor.last_transition_seq, + ) + } +} + +fn decode_cursor_hex( + field: &'static str, + value: &str, +) -> Result<[u8; 32], RadrootsEventStoreError> { + if value.len() != 64 + || value + .bytes() + .any(|byte| !byte.is_ascii_digit() && !(b'a'..=b'f').contains(&byte)) + { + return Err(RadrootsEventStoreError::AddressableTransitionCursorEncoding { field }); + } + let decoded = hex::decode(value) + .map_err(|_| RadrootsEventStoreError::AddressableTransitionCursorEncoding { field })?; + decoded + .try_into() + .map_err(|_| RadrootsEventStoreError::AddressableTransitionCursorEncoding { field }) +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsAddressableTransitionOriginV1 { + Baseline, + Incremental, +} + +impl RadrootsAddressableTransitionOriginV1 { + pub const fn as_str(self) -> &'static str { + match self { + Self::Baseline => "baseline", + Self::Incremental => "incremental", + } + } + + pub(crate) fn parse(value: &str) -> Result<Self, RadrootsEventStoreError> { + match value { + "baseline" => Ok(Self::Baseline), + "incremental" => Ok(Self::Incremental), + _ => Err(RadrootsEventStoreError::InvalidStoredEnum { + field: "addressable_transition.origin", + value: value.to_owned(), + }), + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsAddressableTransitionRawHeadDecisionV1 { + BaselineRebuild, + Applied, + NotHeadSelected, + SkippedOlder, + SkippedSameTimestampHigherEventId, + MalformedCoordinate, +} + +impl RadrootsAddressableTransitionRawHeadDecisionV1 { + pub const fn as_str(self) -> &'static str { + match self { + Self::BaselineRebuild => "baseline_rebuild", + Self::Applied => "applied", + Self::NotHeadSelected => "not_head_selected", + Self::SkippedOlder => "skipped_older", + Self::SkippedSameTimestampHigherEventId => "skipped_same_timestamp_higher_event_id", + Self::MalformedCoordinate => "malformed_coordinate", + } + } + + pub(crate) fn parse(value: &str) -> Result<Self, RadrootsEventStoreError> { + match value { + "baseline_rebuild" => Ok(Self::BaselineRebuild), + "applied" => Ok(Self::Applied), + "not_head_selected" => Ok(Self::NotHeadSelected), + "skipped_older" => Ok(Self::SkippedOlder), + "skipped_same_timestamp_higher_event_id" => Ok(Self::SkippedSameTimestampHigherEventId), + "malformed_coordinate" => Ok(Self::MalformedCoordinate), + _ => Err(RadrootsEventStoreError::InvalidStoredEnum { + field: "addressable_transition.raw_head_decision", + value: value.to_owned(), + }), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAddressableTransitionEventReferenceV1 { + pub(crate) event_id: RadrootsEventId, + pub(crate) event_seq: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAddressableTransitionCauseV1 { + pub(crate) event: RadrootsAddressableTransitionEventReferenceV1, + pub(crate) pubkey: RadrootsPublicKey, + pub(crate) created_at: u64, + pub(crate) kind: u32, + pub(crate) admission_status: RadrootsEventAdmissionStatus, + pub(crate) admission_code: Option<String>, + pub(crate) contract_id: Option<String>, +} + +impl RadrootsAddressableTransitionCauseV1 { + pub const fn event(&self) -> &RadrootsAddressableTransitionEventReferenceV1 { + &self.event + } + + pub const fn pubkey(&self) -> &RadrootsPublicKey { + &self.pubkey + } + + pub const fn created_at(&self) -> u64 { + self.created_at + } + + pub const fn kind(&self) -> u32 { + self.kind + } + + pub const fn admission_status(&self) -> RadrootsEventAdmissionStatus { + self.admission_status + } + + pub fn admission_code(&self) -> Option<&str> { + self.admission_code.as_deref() + } + + pub fn contract_id(&self) -> Option<&str> { + self.contract_id.as_deref() + } +} + +/// The exact addressable head identity retained by the event store. +/// +/// This is intentionally not a [`radroots_event::ids::RadrootsNip01Coordinate`]: +/// an individually valid maximum-size `d` tag can make the combined NIP-01 +/// coordinate too large for a wire tag element while still remaining valid raw +/// head identity. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAddressableTransitionCoordinateV1 { + pub(crate) kind: u32, + pub(crate) pubkey: RadrootsPublicKey, + pub(crate) d_tag: String, +} + +impl RadrootsAddressableTransitionCoordinateV1 { + pub const fn kind(&self) -> u32 { + self.kind + } + + pub const fn pubkey(&self) -> &RadrootsPublicKey { + &self.pubkey + } + + pub fn d_tag(&self) -> &str { + self.d_tag.as_str() + } +} + +impl RadrootsAddressableTransitionEventReferenceV1 { + pub const fn event_id(&self) -> &RadrootsEventId { + &self.event_id + } + + pub const fn event_seq(&self) -> i64 { + self.event_seq + } +} + +/// A store-selected transition-time visible event with portable signed identity. +/// +/// The opaque JSON has already passed id and signature verification. Local +/// database sequence and observation timestamps are deliberately excluded. +/// This snapshot is meaningful only in its containing ordered transition; it +/// is not proof that the event remains current when a historical page is read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsStoreProducedCanonicalEventV1 { + pub(crate) event_id: RadrootsEventId, + pub(crate) pubkey: RadrootsPublicKey, + pub(crate) created_at: u64, + pub(crate) kind: u32, + pub(crate) raw_json: String, +} + +impl RadrootsStoreProducedCanonicalEventV1 { + pub const fn event_id(&self) -> &RadrootsEventId { + &self.event_id + } + + pub const fn pubkey(&self) -> &RadrootsPublicKey { + &self.pubkey + } + + pub const fn created_at(&self) -> u64 { + self.created_at + } + + pub const fn kind(&self) -> u32 { + self.kind + } + + pub fn raw_json(&self) -> &str { + self.raw_json.as_str() + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsAddressableTransitionVisibilityV1 { + Visible, + NotAdmitted, + Suppressed, +} + +impl RadrootsAddressableTransitionVisibilityV1 { + pub const fn as_str(self) -> &'static str { + match self { + Self::Visible => "visible", + Self::NotAdmitted => "not_admitted", + Self::Suppressed => "suppressed", + } + } + + pub(crate) fn parse(value: &str) -> Result<Self, RadrootsEventStoreError> { + match value { + "visible" => Ok(Self::Visible), + "not_admitted" => Ok(Self::NotAdmitted), + "suppressed" => Ok(Self::Suppressed), + _ => Err(RadrootsEventStoreError::InvalidStoredEnum { + field: "addressable_transition.visibility", + value: value.to_owned(), + }), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAddressableTransitionV1 { + pub(crate) transition_seq: i64, + pub(crate) source_generation: RadrootsEventStoreSourceGeneration, + pub(crate) origin: RadrootsAddressableTransitionOriginV1, + pub(crate) coordinate: RadrootsAddressableTransitionCoordinateV1, + pub(crate) raw_head: RadrootsAddressableTransitionEventReferenceV1, + pub(crate) raw_head_created_at: u64, + pub(crate) visible_event: Option<RadrootsStoreProducedCanonicalEventV1>, + pub(crate) retracted_event: Option<RadrootsAddressableTransitionEventReferenceV1>, + pub(crate) admission_status: RadrootsEventAdmissionStatus, + pub(crate) admission_code: Option<String>, + pub(crate) contract_id: Option<String>, + pub(crate) visibility: RadrootsAddressableTransitionVisibilityV1, + pub(crate) suppression: Option<RadrootsNip09SuppressionEvidenceV1>, + pub(crate) cause_event: Option<RadrootsAddressableTransitionCauseV1>, + pub(crate) raw_head_decision: RadrootsAddressableTransitionRawHeadDecisionV1, +} + +impl RadrootsAddressableTransitionV1 { + pub const fn transition_seq(&self) -> i64 { + self.transition_seq + } + + pub const fn source_generation(&self) -> RadrootsEventStoreSourceGeneration { + self.source_generation + } + + pub const fn origin(&self) -> RadrootsAddressableTransitionOriginV1 { + self.origin + } + + pub const fn coordinate(&self) -> &RadrootsAddressableTransitionCoordinateV1 { + &self.coordinate + } + + pub const fn raw_head(&self) -> &RadrootsAddressableTransitionEventReferenceV1 { + &self.raw_head + } + + pub const fn raw_head_created_at(&self) -> u64 { + self.raw_head_created_at + } + + pub const fn visible_event(&self) -> Option<&RadrootsStoreProducedCanonicalEventV1> { + self.visible_event.as_ref() + } + + pub const fn retracted_event(&self) -> Option<&RadrootsAddressableTransitionEventReferenceV1> { + self.retracted_event.as_ref() + } + + pub const fn admission_status(&self) -> RadrootsEventAdmissionStatus { + self.admission_status + } + + pub fn admission_code(&self) -> Option<&str> { + self.admission_code.as_deref() + } + + pub fn contract_id(&self) -> Option<&str> { + self.contract_id.as_deref() + } + + pub const fn visibility(&self) -> RadrootsAddressableTransitionVisibilityV1 { + self.visibility + } + + pub const fn suppression(&self) -> Option<&RadrootsNip09SuppressionEvidenceV1> { + self.suppression.as_ref() + } + + pub const fn cause_event(&self) -> Option<&RadrootsAddressableTransitionCauseV1> { + self.cause_event.as_ref() + } + + pub const fn raw_head_decision(&self) -> RadrootsAddressableTransitionRawHeadDecisionV1 { + self.raw_head_decision + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsAddressableTransitionPageV1 { + pub(crate) source_high_water: i64, + pub(crate) transitions: Vec<RadrootsAddressableTransitionV1>, + pub(crate) next_cursor: RadrootsAddressableTransitionCursorV1, + pub(crate) has_more: bool, +} + +impl RadrootsAddressableTransitionPageV1 { + pub const fn source_high_water(&self) -> i64 { + self.source_high_water + } + + pub fn transitions(&self) -> &[RadrootsAddressableTransitionV1] { + &self.transitions + } + + pub const fn next_cursor(&self) -> &RadrootsAddressableTransitionCursorV1 { + &self.next_cursor + } + + pub const fn has_more(&self) -> bool { + self.has_more + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scope_is_canonical_bounded_and_fingerprinted() { + let scope = RadrootsAddressableTransitionScopeV1::new([39_999, 30_402, 30_402]) + .expect("canonical scope"); + assert_eq!(scope.kinds(), &[30_402, 39_999]); + let maximum_scope = RadrootsAddressableTransitionScopeV1::new( + (0..RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1) + .map(|index| 30_000 + u32::try_from(index).expect("bounded index")), + ) + .expect("maximum-size scope"); + assert_eq!( + maximum_scope.kinds().len(), + RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1, + ); + assert_eq!( + RadrootsAddressableTransitionScopeV1::food_availability() + .fingerprint() + .to_hex(), + "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0" + ); + assert!(matches!( + RadrootsAddressableTransitionScopeV1::new([]), + Err(RadrootsEventStoreError::AddressableTransitionScopeEmpty) + )); + assert!(matches!( + RadrootsAddressableTransitionScopeV1::new([29_999]), + Err(RadrootsEventStoreError::AddressableTransitionScopeKindInvalid { kind: 29_999 }) + )); + assert!(matches!( + RadrootsAddressableTransitionScopeV1::new( + (0..=RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1) + .map(|index| 30_000 + u32::try_from(index).expect("bounded index")) + ), + Err(RadrootsEventStoreError::AddressableTransitionScopeTooLarge { + max: RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1, + actual + }) if actual == RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1 + 1 + )); + } + + #[test] + fn cursor_wire_round_trip_and_typed_failures_are_stable() { + let scope = RadrootsAddressableTransitionScopeV1::food_availability(); + let cursor = RadrootsAddressableTransitionCursorV1::new( + RadrootsEventStoreSourceGeneration::from_bytes([0x42; 32]), + scope.fingerprint(), + 17, + ) + .expect("cursor"); + let json = cursor.to_json().expect("cursor JSON"); + assert_eq!( + RadrootsAddressableTransitionCursorV1::from_json(json.as_str()).expect("round trip"), + cursor + ); + + let mut value: serde_json::Value = serde_json::from_str(&json).expect("wire object"); + value["feed_version"] = serde_json::json!(2); + assert!(matches!( + RadrootsAddressableTransitionCursorV1::from_json( + serde_json::to_string(&value) + .expect("version JSON") + .as_str() + ), + Err( + RadrootsEventStoreError::AddressableTransitionFeedVersionMismatch { + expected: RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + actual: 2, + } + ) + )); + + value["feed_version"] = serde_json::json!(RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1); + value["source_generation"] = serde_json::json!("AA".repeat(32)); + assert!(matches!( + RadrootsAddressableTransitionCursorV1::from_json( + serde_json::to_string(&value) + .expect("encoding JSON") + .as_str() + ), + Err( + RadrootsEventStoreError::AddressableTransitionCursorEncoding { + field: "source_generation" + } + ) + )); + + assert!(matches!( + RadrootsAddressableTransitionCursorV1::new( + cursor.source_generation(), + cursor.scope_fingerprint(), + -1, + ), + Err(RadrootsEventStoreError::AddressableTransitionCursorNegative { value: -1 }) + )); + let oversized = " ".repeat(RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1 + 1); + assert!(matches!( + RadrootsAddressableTransitionCursorV1::from_json(&oversized), + Err(RadrootsEventStoreError::AddressableTransitionCursorTooLarge { + max: RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1, + actual + }) if actual == RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1 + 1 + )); + } +} diff --git a/crates/event_store/src/model/current_visibility_v1.rs b/crates/event_store/src/model/current_visibility_v1.rs @@ -0,0 +1,154 @@ +use super::{ + RadrootsEventAdmissionStatus, RadrootsEventStoreSourceGeneration, RadrootsStoredRawEvent, +}; +use radroots_event::ids::RadrootsEventId; +pub use radroots_event_codec::deletion::reconciliation_v1::evaluator::{ + RadrootsNip09SuppressionOutcome, RadrootsNip09SuppressionReason, +}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsNip09SuppressionEvidenceV1 { + pub(crate) outcome: RadrootsNip09SuppressionOutcome, + pub(crate) reason: RadrootsNip09SuppressionReason, + pub(crate) event_reference_request_id: Option<RadrootsEventId>, + pub(crate) address_reference_request_id: Option<RadrootsEventId>, + pub(crate) address_reference_cutoff: Option<u64>, +} + +impl RadrootsNip09SuppressionEvidenceV1 { + pub const fn outcome(&self) -> RadrootsNip09SuppressionOutcome { + self.outcome + } + + pub const fn reason(&self) -> RadrootsNip09SuppressionReason { + self.reason + } + + pub const fn event_reference_request_id(&self) -> Option<&RadrootsEventId> { + self.event_reference_request_id.as_ref() + } + + pub const fn address_reference_request_id(&self) -> Option<&RadrootsEventId> { + self.address_reference_request_id.as_ref() + } + + pub const fn address_reference_cutoff(&self) -> Option<u64> { + self.address_reference_cutoff + } + + pub(crate) fn is_coherent_for_event(&self, kind: u32, created_at: u64) -> bool { + let event_reference = self.event_reference_request_id.is_some(); + let address_reference = self.address_reference_request_id.is_some(); + let cutoff = self.address_reference_cutoff; + if address_reference != cutoff.is_some() { + return false; + } + if kind == 5 { + return self.outcome == RadrootsNip09SuppressionOutcome::Visible + && self.reason == RadrootsNip09SuppressionReason::DeletionRequestImmune + && !event_reference + && !address_reference; + } + match self.reason { + RadrootsNip09SuppressionReason::DeletionRequestImmune => false, + RadrootsNip09SuppressionReason::NoAuthorizedReference + | RadrootsNip09SuppressionReason::RequestAuthorMismatch => { + self.outcome == RadrootsNip09SuppressionOutcome::Visible + && !event_reference + && !address_reference + } + RadrootsNip09SuppressionReason::AddressCutoffPrecedesTarget => { + self.outcome == RadrootsNip09SuppressionOutcome::Visible + && !event_reference + && cutoff.is_some_and(|value| value < created_at) + } + RadrootsNip09SuppressionReason::EventIdReference => { + self.outcome == RadrootsNip09SuppressionOutcome::Suppressed + && event_reference + && cutoff.is_none_or(|value| value < created_at) + } + RadrootsNip09SuppressionReason::AddressReferenceAtOrBeforeCutoff => { + self.outcome == RadrootsNip09SuppressionOutcome::Suppressed + && !event_reference + && cutoff.is_some_and(|value| value >= created_at) + } + RadrootsNip09SuppressionReason::EventIdAndAddressReference => { + self.outcome == RadrootsNip09SuppressionOutcome::Suppressed + && event_reference + && cutoff.is_some_and(|value| value >= created_at) + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RadrootsCurrentVisibilityDecisionV1 { + Visible, + NotAdmitted, + NotCurrent, + Suppressed, +} + +impl RadrootsCurrentVisibilityDecisionV1 { + pub const fn as_str(self) -> &'static str { + match self { + Self::Visible => "visible", + Self::NotAdmitted => "not_admitted", + Self::NotCurrent => "not_current", + Self::Suppressed => "suppressed", + } + } + + pub(crate) fn parse(value: &str) -> Result<Self, crate::RadrootsEventStoreError> { + match value { + "visible" => Ok(Self::Visible), + "not_admitted" => Ok(Self::NotAdmitted), + "not_current" => Ok(Self::NotCurrent), + "suppressed" => Ok(Self::Suppressed), + _ => Err(crate::RadrootsEventStoreError::InvalidStoredEnum { + field: "current_visibility.current_visibility", + value: value.to_owned(), + }), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsCurrentEventVisibilityV1 { + pub(crate) source_generation: RadrootsEventStoreSourceGeneration, + pub(crate) event: RadrootsStoredRawEvent, + pub(crate) is_raw_head: bool, + pub(crate) raw_head_event_id: Option<RadrootsEventId>, + pub(crate) suppression: Option<RadrootsNip09SuppressionEvidenceV1>, + pub(crate) decision: RadrootsCurrentVisibilityDecisionV1, +} + +impl RadrootsCurrentEventVisibilityV1 { + pub const fn source_generation(&self) -> RadrootsEventStoreSourceGeneration { + self.source_generation + } + + pub fn event(&self) -> &RadrootsStoredRawEvent { + &self.event + } + + pub const fn admission_status(&self) -> RadrootsEventAdmissionStatus { + self.event.admission_status + } + + pub const fn is_raw_head(&self) -> bool { + self.is_raw_head + } + + pub const fn raw_head_event_id(&self) -> Option<&RadrootsEventId> { + self.raw_head_event_id.as_ref() + } + + pub const fn suppression(&self) -> Option<&RadrootsNip09SuppressionEvidenceV1> { + self.suppression.as_ref() + } + + pub const fn decision(&self) -> RadrootsCurrentVisibilityDecisionV1 { + self.decision + } +} diff --git a/crates/event_store/src/model/food_availability_projection_v1.rs b/crates/event_store/src/model/food_availability_projection_v1.rs @@ -0,0 +1,531 @@ +use super::{ + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1, RadrootsEventStoreSourceGeneration, +}; +use crate::RadrootsEventStoreError; +use radroots_blossom::RadrootsBlossomSha256; +use radroots_event::{ + food_availability::{ + RADROOTS_FOOD_IMAGE_MAX_COUNT, RadrootsFoodAvailabilityStatus, RadrootsFoodContent, + RadrootsFoodIdentifier, RadrootsFoodImageDimensions, RadrootsFoodPrice, + RadrootsFoodPublishedAt, RadrootsFoodQuantity, RadrootsFoodText, food_media_blossom_digest, + }, + ids::{RadrootsEventId, RadrootsPublicKey}, +}; +use radroots_event_codec::food_availability::inbound::{ + RadrootsFoodAvailabilityImageDiagnostic, RadrootsInboundFoodAvailabilityImage, + RadrootsInboundFoodAvailabilityProjection, +}; + +pub const RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1: u32 = 1; +pub const RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1: u32 = + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1; +pub const RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1: usize = 256; +pub const RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1: usize = 16; + +/// A bounded literal-term query for the FoodAvailability FTS5 projection. +/// +/// Terms are combined with `AND`. Every term is emitted as an escaped FTS5 +/// string literal, so caller text cannot introduce columns, operators, prefix +/// matching, or grouping into the generated expression. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsFoodAvailabilitySearchQueryV1 { + canonical_query: String, + terms: Vec<String>, + fts5_match_expression: String, +} + +impl RadrootsFoodAvailabilitySearchQueryV1 { + pub fn parse(value: impl AsRef<str>) -> Result<Self, RadrootsEventStoreError> { + let value = value.as_ref(); + if value.len() > RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1 { + return Err(RadrootsEventStoreError::FoodAvailabilitySearchTooLarge { + max: RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1, + actual: value.len(), + }); + } + + let terms = value + .split(|character: char| character.is_whitespace() || character.is_control()) + .filter(|term| !term.is_empty()) + .map(str::to_owned) + .collect::<Vec<_>>(); + if terms.is_empty() { + return Err(RadrootsEventStoreError::FoodAvailabilitySearchEmpty); + } + if terms.len() > RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1 { + return Err( + RadrootsEventStoreError::FoodAvailabilitySearchTooManyTerms { + max: RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1, + actual: terms.len(), + }, + ); + } + + let canonical_query = terms.join(" "); + let mut fts5_match_expression = String::with_capacity( + canonical_query + .len() + .saturating_add(terms.len().saturating_mul(8)), + ); + for (index, term) in terms.iter().enumerate() { + if index > 0 { + fts5_match_expression.push_str(" AND "); + } + push_fts5_string_literal(&mut fts5_match_expression, term); + } + + Ok(Self { + canonical_query, + terms, + fts5_match_expression, + }) + } + + pub fn as_str(&self) -> &str { + self.canonical_query.as_str() + } + + pub fn terms(&self) -> &[String] { + &self.terms + } + + pub(crate) fn fts5_match_expression(&self) -> &str { + self.fts5_match_expression.as_str() + } +} + +impl core::fmt::Display for RadrootsFoodAvailabilitySearchQueryV1 { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl TryFrom<&str> for RadrootsFoodAvailabilitySearchQueryV1 { + type Error = RadrootsEventStoreError; + + fn try_from(value: &str) -> Result<Self, Self::Error> { + Self::parse(value) + } +} + +fn push_fts5_string_literal(output: &mut String, term: &str) { + output.push('"'); + for character in term.chars() { + if character == '"' { + output.push('"'); + } + output.push(character); + } + output.push('"'); +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RadrootsFoodAvailabilityStatusFilterV1 { + #[default] + Any, + Active, + Sold, +} + +impl RadrootsFoodAvailabilityStatusFilterV1 { + pub const fn status(self) -> Option<RadrootsFoodAvailabilityStatus> { + match self { + Self::Any => None, + Self::Active => Some(RadrootsFoodAvailabilityStatus::Active), + Self::Sold => Some(RadrootsFoodAvailabilityStatus::Sold), + } + } + + pub(crate) const fn storage_value(self) -> Option<&'static str> { + match self.status() { + None => None, + Some(status) => Some(status.as_str()), + } + } +} + +impl From<RadrootsFoodAvailabilityStatus> for RadrootsFoodAvailabilityStatusFilterV1 { + fn from(value: RadrootsFoodAvailabilityStatus) -> Self { + match value { + RadrootsFoodAvailabilityStatus::Active => Self::Active, + RadrootsFoodAvailabilityStatus::Sold => Self::Sold, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsStoredFoodAvailabilityImageV1 { + image_index: u32, + raw_tag: Vec<String>, + url: Option<String>, + dimensions: Option<RadrootsFoodImageDimensions>, + blossom_sha256: Option<RadrootsBlossomSha256>, + diagnostics: Vec<RadrootsFoodAvailabilityImageDiagnostic>, +} + +impl RadrootsStoredFoodAvailabilityImageV1 { + fn from_projection( + image_index: usize, + image: &RadrootsInboundFoodAvailabilityImage, + ) -> Result<Self, RadrootsEventStoreError> { + let image_index = u32::try_from(image_index).map_err(|_| { + food_projection_drift("projected image index exceeds the u32 storage range") + })?; + let url = image.url().map(str::to_owned); + let blossom_sha256 = url.as_deref().and_then(food_media_blossom_digest); + Ok(Self { + image_index, + raw_tag: image.raw_tag().to_vec(), + url, + dimensions: image.dimensions(), + blossom_sha256, + diagnostics: image.diagnostics().to_vec(), + }) + } + + pub const fn image_index(&self) -> u32 { + self.image_index + } + + pub fn raw_tag(&self) -> &[String] { + &self.raw_tag + } + + pub fn url(&self) -> Option<&str> { + self.url.as_deref() + } + + pub const fn dimensions(&self) -> Option<RadrootsFoodImageDimensions> { + self.dimensions + } + + pub const fn blossom_sha256(&self) -> Option<RadrootsBlossomSha256> { + self.blossom_sha256 + } + + pub fn diagnostics(&self) -> &[RadrootsFoodAvailabilityImageDiagnostic] { + &self.diagnostics + } + + /// Reports whether tolerant inbound projection produced no diagnostics. + /// + /// This does not establish Blossom hosting or byte verification. Callers + /// must require `blossom_sha256()` plus runtime upload/retrieval evidence + /// for Blossom-specific product behavior. + pub fn qualifies(&self) -> bool { + self.diagnostics.is_empty() + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RadrootsStoredFoodAvailabilityV1 { + source_generation: RadrootsEventStoreSourceGeneration, + pubkey: RadrootsPublicKey, + identifier: RadrootsFoodIdentifier, + event_id: RadrootsEventId, + event_seq: i64, + created_at: u64, + content: RadrootsFoodContent, + title: RadrootsFoodText, + summary: RadrootsFoodText, + published_at: RadrootsFoodPublishedAt, + location: RadrootsFoodText, + price: RadrootsFoodPrice, + quantity: Option<RadrootsFoodQuantity>, + status: RadrootsFoodAvailabilityStatus, + source_transition_seq: i64, + diagnostics: Vec<RadrootsFoodAvailabilityImageDiagnostic>, + images: Vec<RadrootsStoredFoodAvailabilityImageV1>, +} + +impl RadrootsStoredFoodAvailabilityV1 { + #[allow(clippy::too_many_arguments)] + pub(crate) fn from_projection( + source_generation: RadrootsEventStoreSourceGeneration, + pubkey: RadrootsPublicKey, + event_id: RadrootsEventId, + event_seq: i64, + created_at: u64, + source_transition_seq: i64, + projection: &RadrootsInboundFoodAvailabilityProjection, + ) -> Result<Self, RadrootsEventStoreError> { + if event_seq <= 0 { + return Err(food_projection_drift(format!( + "event sequence must be positive, found {event_seq}" + ))); + } + if source_transition_seq <= 0 { + return Err(food_projection_drift(format!( + "source transition sequence must be positive, found {source_transition_seq}" + ))); + } + projection + .published_at() + .validate_created_at(created_at) + .map_err(|error| food_projection_drift(error.to_string()))?; + if projection + .quantity() + .is_some_and(|quantity| quantity.unit() != projection.price().unit()) + { + return Err(food_projection_drift( + "quantity unit does not match the price unit", + )); + } + if projection.images().len() > RADROOTS_FOOD_IMAGE_MAX_COUNT { + return Err(food_projection_drift(format!( + "bounded projection has {} images; maximum is {RADROOTS_FOOD_IMAGE_MAX_COUNT}", + projection.images().len() + ))); + } + + let images = projection + .images() + .iter() + .enumerate() + .map(|(index, image)| { + RadrootsStoredFoodAvailabilityImageV1::from_projection(index, image) + }) + .collect::<Result<Vec<_>, _>>()?; + validate_projection_diagnostics(projection.diagnostics(), &images)?; + + Ok(Self { + source_generation, + pubkey, + identifier: projection.identifier().clone(), + event_id, + event_seq, + created_at, + content: projection.content().clone(), + title: projection.title().clone(), + summary: projection.summary().clone(), + published_at: projection.published_at(), + location: projection.location().clone(), + price: projection.price().clone(), + quantity: projection.quantity().cloned(), + status: projection.status(), + source_transition_seq, + diagnostics: projection.diagnostics().to_vec(), + images, + }) + } + + pub const fn source_generation(&self) -> RadrootsEventStoreSourceGeneration { + self.source_generation + } + + pub const fn pubkey(&self) -> &RadrootsPublicKey { + &self.pubkey + } + + pub const fn identifier(&self) -> &RadrootsFoodIdentifier { + &self.identifier + } + + pub const fn d_tag(&self) -> &RadrootsFoodIdentifier { + self.identifier() + } + + pub const fn event_id(&self) -> &RadrootsEventId { + &self.event_id + } + + pub const fn event_seq(&self) -> i64 { + self.event_seq + } + + pub const fn created_at(&self) -> u64 { + self.created_at + } + + pub const fn content(&self) -> &RadrootsFoodContent { + &self.content + } + + pub const fn title(&self) -> &RadrootsFoodText { + &self.title + } + + pub const fn summary(&self) -> &RadrootsFoodText { + &self.summary + } + + pub const fn published_at(&self) -> RadrootsFoodPublishedAt { + self.published_at + } + + pub const fn location(&self) -> &RadrootsFoodText { + &self.location + } + + pub const fn price(&self) -> &RadrootsFoodPrice { + &self.price + } + + pub const fn quantity(&self) -> Option<&RadrootsFoodQuantity> { + self.quantity.as_ref() + } + + pub const fn status(&self) -> RadrootsFoodAvailabilityStatus { + self.status + } + + pub const fn source_transition_seq(&self) -> i64 { + self.source_transition_seq + } + + pub fn diagnostics(&self) -> &[RadrootsFoodAvailabilityImageDiagnostic] { + &self.diagnostics + } + + pub fn images(&self) -> &[RadrootsStoredFoodAvailabilityImageV1] { + &self.images + } +} + +fn validate_projection_diagnostics( + diagnostics: &[RadrootsFoodAvailabilityImageDiagnostic], + images: &[RadrootsStoredFoodAvailabilityImageV1], +) -> Result<(), RadrootsEventStoreError> { + if images.iter().any(|image| { + image + .diagnostics() + .contains(&RadrootsFoodAvailabilityImageDiagnostic::CountExceeded) + }) { + return Err(food_projection_drift( + "image-level diagnostics contain the projection-wide count diagnostic", + )); + } + + let count_exceeded = + diagnostics.first() == Some(&RadrootsFoodAvailabilityImageDiagnostic::CountExceeded); + if count_exceeded && images.len() != RADROOTS_FOOD_IMAGE_MAX_COUNT { + return Err(food_projection_drift(format!( + "count-exceeded projection retained {} images instead of {RADROOTS_FOOD_IMAGE_MAX_COUNT}", + images.len() + ))); + } + let mut expected = Vec::new(); + if count_exceeded { + expected.push(RadrootsFoodAvailabilityImageDiagnostic::CountExceeded); + } + for image in images { + expected.extend_from_slice(image.diagnostics()); + } + if diagnostics != expected { + return Err(food_projection_drift( + "projection diagnostics do not match the ordered bounded image diagnostics", + )); + } + Ok(()) +} + +fn food_projection_drift(reason: impl Into<String>) -> RadrootsEventStoreError { + RadrootsEventStoreError::FoodAvailabilityProjectionDrift { + reason: reason.into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn search_query_is_bounded_canonical_and_literal() { + let query = + RadrootsFoodAvailabilitySearchQueryV1::parse(" fresh\ncarrots OR title:beets* a\"b ") + .expect("bounded query"); + + assert_eq!(query.as_str(), "fresh carrots OR title:beets* a\"b"); + assert_eq!( + query.terms(), + &["fresh", "carrots", "OR", "title:beets*", "a\"b"] + ); + assert_eq!( + query.fts5_match_expression(), + "\"fresh\" AND \"carrots\" AND \"OR\" AND \"title:beets*\" AND \"a\"\"b\"" + ); + } + + #[test] + fn search_query_rejects_empty_oversized_and_excessive_terms() { + assert!(matches!( + RadrootsFoodAvailabilitySearchQueryV1::parse(" \n\t\0 "), + Err(RadrootsEventStoreError::FoodAvailabilitySearchEmpty) + )); + + let oversized = "a".repeat(RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1 + 1); + assert!(matches!( + RadrootsFoodAvailabilitySearchQueryV1::parse(&oversized), + Err(RadrootsEventStoreError::FoodAvailabilitySearchTooLarge { + max: RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1, + actual, + }) if actual == oversized.len() + )); + + let excessive = (0..=RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1) + .map(|index| format!("term{index}")) + .collect::<Vec<_>>() + .join(" "); + assert!(matches!( + RadrootsFoodAvailabilitySearchQueryV1::parse(&excessive), + Err(RadrootsEventStoreError::FoodAvailabilitySearchTooManyTerms { + max: RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1, + actual, + }) if actual == RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1 + 1 + )); + } + + #[test] + fn status_filter_exposes_only_governed_storage_values() { + assert_eq!( + RadrootsFoodAvailabilityStatusFilterV1::default().storage_value(), + None + ); + assert_eq!( + RadrootsFoodAvailabilityStatusFilterV1::Active.storage_value(), + Some("active") + ); + assert_eq!( + RadrootsFoodAvailabilityStatusFilterV1::from(RadrootsFoodAvailabilityStatus::Sold) + .storage_value(), + Some("sold") + ); + } + + #[test] + fn stored_image_derives_qualification_from_typed_diagnostics() { + let qualified = RadrootsStoredFoodAvailabilityImageV1 { + image_index: 0, + raw_tag: vec![ + "image".to_owned(), + "https://example.test/image.webp".to_owned(), + ], + url: Some("https://example.test/image.webp".to_owned()), + dimensions: None, + blossom_sha256: None, + diagnostics: Vec::new(), + }; + let diagnosed = RadrootsStoredFoodAvailabilityImageV1 { + diagnostics: vec![RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing], + ..qualified.clone() + }; + + assert!(qualified.qualifies()); + assert!(!diagnosed.qualifies()); + assert_eq!( + diagnosed.diagnostics(), + &[RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing] + ); + } + + #[test] + fn projection_wide_count_diagnostic_requires_the_bounded_image_count() { + assert!(matches!( + validate_projection_diagnostics( + &[RadrootsFoodAvailabilityImageDiagnostic::CountExceeded], + &[] + ), + Err(RadrootsEventStoreError::FoodAvailabilityProjectionDrift { .. }) + )); + } +} diff --git a/crates/event_store/src/nip09/reconciliation_v1.rs b/crates/event_store/src/nip09/reconciliation_v1.rs @@ -842,8 +842,13 @@ async fn validate_hook_state_with_events( pub(crate) async fn validate_active_hook_state_fast( connection: &mut SqliteConnection, ) -> Result<(), RadrootsEventStoreError> { - let state = validate_structural_hook_state(connection).await?; - validate_latest_transitions_match_state(connection, state.generation).await + // Supported writes are guarded transactionally. Reopen validates only + // constant-cost authority bounds; full history/state and cursor inventory + // comparisons remain part of migration and rebuild audits. + validate_rebuild_marker_absent(connection).await?; + validate_structural_source_state_fast(connection) + .await + .map(|_| ()) } async fn validate_structural_hook_state( @@ -857,6 +862,12 @@ async fn validate_structural_source_state( connection: &mut SqliteConnection, ) -> Result<SourceState, RadrootsEventStoreError> { validate_projection_cursor_authority(connection).await?; + validate_structural_source_state_fast(connection).await +} + +async fn validate_structural_source_state_fast( + connection: &mut SqliteConnection, +) -> Result<SourceState, RadrootsEventStoreError> { let rows = sqlx::query( "SELECT state.active_generation, state.raw_event_count, state.raw_tag_count, state.raw_high_water_seq, state.last_transition_seq, generation.generation_ordinal, (SELECT MAX(candidate.generation_ordinal) FROM radroots_event_store_source_generation AS candidate) AS max_generation_ordinal, generation.reconciliation_version, generation.addressable_feed_version, generation.event_contract_registry_version, generation.hook_id, generation.hook_manifest_sha256, generation.transition_floor_seq, generation.baseline_raw_event_count, generation.baseline_raw_tag_count, generation.baseline_raw_high_water_seq FROM radroots_event_store_source_state AS state JOIN radroots_event_store_source_generation AS generation ON generation.source_generation = state.active_generation WHERE state.singleton = 1", ) diff --git a/crates/event_store/src/schema.rs b/crates/event_store/src/schema.rs @@ -15,6 +15,10 @@ use crate::nip09::reconciliation_v1::{ OsSourceGenerationProvider, ReconciliationCapacityLimits, SourceGenerationProvider, apply_reconciliation_hook, validate_active_hook_state_fast, validate_reconciliation_capacity, }; +use crate::store::food_availability_projection_v1::{ + apply_food_availability_projection_hook_v1, + validate_food_availability_projection_hook_state_fast_v1, +}; #[cfg(test)] const EMPTY_SCHEMA_SHA256: &str = @@ -152,7 +156,7 @@ async fn migrate_event_store_schema_with_registry_and_generation_provider( { return Ok(()); } - if has_pending_reconciliation_hook(&status, registry) { + if has_pending_source_capacity_hook(&status, registry) { let mut connection = pool.acquire().await?; validate_event_store_temp_schema_with_registry(&mut connection, registry).await?; validate_reconciliation_capacity(&mut connection, reconciliation_limits).await?; @@ -170,7 +174,7 @@ async fn migrate_event_store_schema_with_registry_and_generation_provider( finish_schema_transaction(transaction, result).await } -fn has_pending_reconciliation_hook( +fn has_pending_source_capacity_hook( status: &RadrootsEventStoreSchemaStatus, registry: &[EventStoreMigration], ) -> bool { @@ -181,7 +185,11 @@ fn has_pending_reconciliation_hook( }; registry.iter().any(|migration| { migration.version > current_version - && migration.hook == EventStoreMigrationHook::Nip09ReconciliationV1 + && matches!( + migration.hook, + EventStoreMigrationHook::Nip09ReconciliationV1 + | EventStoreMigrationHook::FoodAvailabilityProjectionV1 + ) }) } @@ -290,7 +298,11 @@ async fn migrate_schema_on_connection( .iter() .filter(|migration| migration.version > current_version) { - if migration.hook == EventStoreMigrationHook::Nip09ReconciliationV1 { + if matches!( + migration.hook, + EventStoreMigrationHook::Nip09ReconciliationV1 + | EventStoreMigrationHook::FoodAvailabilityProjectionV1 + ) { validate_reconciliation_capacity(connection, reconciliation_limits).await?; } apply_migration_up(connection, registry, migration).await?; @@ -614,6 +626,9 @@ async fn apply_migration_hook( EventStoreMigrationHook::Nip09ReconciliationV1 => { apply_reconciliation_hook(connection, generation_provider, reconciliation_limits).await } + EventStoreMigrationHook::FoodAvailabilityProjectionV1 => { + apply_food_availability_projection_hook_v1(connection).await + } } } @@ -626,6 +641,9 @@ async fn validate_migration_hook_state( EventStoreMigrationHook::Nip09ReconciliationV1 => { validate_active_hook_state_fast(connection).await } + EventStoreMigrationHook::FoodAvailabilityProjectionV1 => { + validate_food_availability_projection_hook_state_fast_v1(connection).await + } } } @@ -1068,7 +1086,7 @@ DROP TABLE radroots_event_store_v2_parent;"; } #[tokio::test] - async fn nip09_capacity_is_rechecked_inside_migration_transaction() { + async fn source_capacity_is_rechecked_for_every_rebuild_bound_migration() { let pool = memory_pool().await; install_unledgered_baseline(&pool).await; sqlx::query( @@ -1097,14 +1115,20 @@ DROP TABLE radroots_event_store_v2_parent;"; limits, ) .await; - assert!(matches!( - finish_schema_transaction(transaction, result).await, - Err(RadrootsEventStoreError::ReconciliationCapacityExceeded { - resource: crate::RadrootsEventStoreReconciliationResource::RawEvents, - actual: 1, - limit: 0, - }) - )); + let error = finish_schema_transaction(transaction, result) + .await + .expect_err("reconciliation capacity excess must fail"); + assert!( + matches!( + error, + RadrootsEventStoreError::ReconciliationCapacityExceeded { + resource: crate::RadrootsEventStoreReconciliationResource::RawEvents, + actual: 1, + limit: 0, + } + ), + "unexpected reconciliation capacity failure: {error:?}" + ); assert_eq!( inspect_event_store_schema_status(&pool) .await @@ -1118,6 +1142,78 @@ DROP TABLE radroots_event_store_v2_parent;"; .await .expect("v2 object count"); assert_eq!(v2_object_count, 0); + + let pool = memory_pool().await; + migrate_event_store_schema_with_registry( + &pool, + &EVENT_STORE_MIGRATIONS[..2], + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + 2, + ) + .await + .expect("install v2 schema"); + sqlx::query( + "INSERT INTO event_envelopes(event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms) VALUES (?, ?, 1, 1, '[]', '', ?, '{}', 'verified', 'unsupported', NULL, 'regular', 0, 1, 1)", + ) + .bind("d".repeat(64)) + .bind("e".repeat(64)) + .bind("f".repeat(128)) + .execute(&pool) + .await + .expect("post-v2 raw event"); + sqlx::query( + "UPDATE radroots_event_store_source_state SET raw_event_count = 1, raw_high_water_seq = (SELECT MAX(seq) FROM event_envelopes) WHERE singleton = 1", + ) + .execute(&pool) + .await + .expect("advance post-v2 source authority"); + + let mut transaction = pool + .begin_with("BEGIN IMMEDIATE") + .await + .expect("v3 migration transaction"); + let result = migrate_schema_on_connection( + &mut transaction, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + &OsSourceGenerationProvider, + limits, + ) + .await; + let error = finish_schema_transaction(transaction, result) + .await + .expect_err("v3 capacity excess must fail"); + assert!( + matches!( + error, + RadrootsEventStoreError::ReconciliationCapacityExceeded { + resource: crate::RadrootsEventStoreReconciliationResource::RawEvents, + actual: 1, + limit: 0, + } + ), + "unexpected v3 capacity failure: {error:?}" + ); + assert_eq!( + inspect_event_store_schema_status(&pool) + .await + .expect("v2 status after rejected v3 migration"), + RadrootsEventStoreSchemaStatus::Managed { version: 2 } + ); + let v3_object_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_schema WHERE name = 'radroots_event_store_food_availability_projection'", + ) + .fetch_one(&pool) + .await + .expect("v3 object count"); + assert_eq!(v3_object_count, 0); + let v3_ledger_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_schema_migrations WHERE version = 3", + ) + .fetch_one(&pool) + .await + .expect("v3 ledger count"); + assert_eq!(v3_ledger_count, 0); } #[tokio::test] @@ -1472,12 +1568,13 @@ DROP TABLE event_envelopes;"; #[tokio::test] async fn nip09_catalog_matches_the_declared_schema_fingerprint() { + let predecessor_registry = &EVENT_STORE_MIGRATIONS[..2]; let pool = memory_pool().await; - sqlx::raw_sql(EVENT_STORE_MIGRATIONS[0].up_sql) + sqlx::raw_sql(predecessor_registry[0].up_sql) .execute(&pool) .await .expect("v1 schema"); - sqlx::raw_sql(EVENT_STORE_MIGRATIONS[1].up_sql) + sqlx::raw_sql(predecessor_registry[1].up_sql) .execute(&pool) .await .expect("v2 schema"); @@ -1485,6 +1582,33 @@ DROP TABLE event_envelopes;"; let mut connection = pool.acquire().await.expect("connection"); let catalog = governed_catalog( &read_catalog(&mut connection).await.expect("catalog"), + predecessor_registry, + ); + let declared_object_count = predecessor_registry + .iter() + .map(|migration| migration.owned_object_names.len()) + .sum::<usize>(); + + assert_eq!(catalog.len(), declared_object_count); + assert_eq!( + catalog_fingerprint(&catalog), + predecessor_registry[1].schema_sha256 + ); + } + + #[tokio::test] + async fn current_catalog_matches_the_declared_schema_fingerprint() { + let pool = memory_pool().await; + for migration in EVENT_STORE_MIGRATIONS { + sqlx::raw_sql(migration.up_sql) + .execute(&pool) + .await + .expect("migration schema"); + } + + let mut connection = pool.acquire().await.expect("connection"); + let catalog = governed_catalog( + &read_catalog(&mut connection).await.expect("catalog"), EVENT_STORE_MIGRATIONS, ); let declared_object_count = EVENT_STORE_MIGRATIONS @@ -1495,7 +1619,10 @@ DROP TABLE event_envelopes;"; assert_eq!(catalog.len(), declared_object_count); assert_eq!( catalog_fingerprint(&catalog), - EVENT_STORE_MIGRATIONS[1].schema_sha256 + EVENT_STORE_MIGRATIONS + .last() + .expect("current migration") + .schema_sha256 ); } @@ -1927,7 +2054,7 @@ DROP TABLE event_envelopes;"; )); let unknown = AppliedMigration { - version: 3, + version: 4, name: "future".to_owned(), up_sha256: "0".repeat(64), down_sha256: "1".repeat(64), @@ -1938,12 +2065,13 @@ DROP TABLE event_envelopes;"; &[ row(&EVENT_STORE_MIGRATIONS[0]), row(&EVENT_STORE_MIGRATIONS[1]), + row(&EVENT_STORE_MIGRATIONS[2]), unknown ], EVENT_STORE_MIGRATIONS, - 3 + 4 ), - Err(RadrootsEventStoreError::UnknownMigration { version: 3 }) + Err(RadrootsEventStoreError::UnknownMigration { version: 4 }) )); } @@ -1988,12 +2116,13 @@ DROP TABLE event_envelopes;"; target: 0 }) )); + let ahead = RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT + 1; assert!(matches!( - rollback_event_store_schema_offline(&managed, 3).await, + rollback_event_store_schema_offline(&managed, ahead).await, Err(RadrootsEventStoreError::RollbackAhead { current: RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, - target: 3 - }) + target + }) if target == ahead )); rollback_event_store_schema_offline(&managed, 1) .await diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -1,10 +1,16 @@ +mod addressable_transition_feed_v1; +mod current_visibility_v1; +pub(crate) mod food_availability_projection_v1; mod post_core_extension_capabilities; mod post_core_extension_dispatcher; mod post_core_extensions_v1; +mod post_core_extensions_v2; mod post_core_storage_v1; +mod post_core_storage_v2; mod protocol_reconciliation_v1; mod protocol_storage_v1; +use self::current_visibility_v1::current_visibility_in_transaction; use self::post_core_extension_capabilities::PostCoreExtensionCapabilities; use self::post_core_extension_dispatcher::dispatch_post_core_extensions; #[cfg(test)] @@ -27,13 +33,10 @@ use self::protocol_reconciliation_v1::apply_raw_event_head; use self::protocol_reconciliation_v1::{ ingest_event_protocol_reconciliation_v1, validate_protocol_post_extensions, }; -use self::protocol_storage_v1::{ - RawHeadSnapshot, raw_head_coordinate_for_stored_event, raw_head_snapshot_in_transaction, - stored_raw_event_from_row, -}; +use self::protocol_storage_v1::{raw_head_snapshot_in_transaction, stored_raw_event_from_row}; use crate::RadrootsEventStoreError; use crate::model::{ - RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventIngestReceipt, + RadrootsCurrentVisibilityDecisionV1, RadrootsEventIngest, RadrootsEventIngestReceipt, RadrootsEventStoreSourceGeneration, RadrootsEventStoreStatusSummary, RadrootsEventVisibility, RadrootsProjectionCursor, RadrootsProjectionRebuildPrior, RadrootsProjectionRebuildTicket, RadrootsStoredEventTag, RadrootsStoredRawEvent, RadrootsStoredRawEventHead, @@ -41,11 +44,12 @@ use crate::model::{ RadrootsStoredTradeMissingParent, RadrootsStoredTradeMutation, RadrootsStoredTradeMutationParent, RadrootsStoredTradeTransportEnvelope, RadrootsStoredValidEvent, RadrootsStoredVisibleEvent, RadrootsStoredVisibleEventHead, - RadrootsTradeProjectionCheckpoint, RadrootsTransportObservationType, StoredEventClass, + RadrootsTradeProjectionCheckpoint, RadrootsTransportObservationType, }; #[cfg(test)] use crate::model::{ - RadrootsEventPersistence, RadrootsRawHeadDecision, RadrootsTransportObservation, + RadrootsEventAdmissionStatus, RadrootsEventPersistence, RadrootsRawHeadDecision, + RadrootsTransportObservation, }; #[cfg(test)] use crate::nip09::reconciliation_v1::ReconciliationProfile; @@ -61,9 +65,9 @@ use radroots_event::event_head::v1::RadrootsEventHeadCoordinate; use radroots_event::event_head::v1::{ RadrootsEventHeadCandidateResult, event_head_candidate_for_nip01_event_v1, }; -#[cfg(test)] -use radroots_event::ids::RadrootsEventId; -use radroots_event::ids::{RadrootsDTag, RadrootsTradeId, RadrootsTradeMutationId}; +use radroots_event::ids::{ + RadrootsDTag, RadrootsEventId, RadrootsTradeId, RadrootsTradeMutationId, +}; use radroots_event::trade::RadrootsTradeMutationKindV1; use radroots_transport::{ RadrootsTransportKind, RadrootsTransportTarget, RadrootsTransportTargetFingerprint, @@ -73,6 +77,8 @@ use radroots_transport::{ use sha2::{Digest, Sha256}; use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; use sqlx::{Connection, Row, SqliteConnection, SqlitePool}; +use std::collections::BTreeMap; +use std::future::Future; use std::path::Path; use std::str::FromStr; use std::time::Duration; @@ -166,7 +172,7 @@ impl RadrootsEventStore { } pub async fn pragma_journal_mode(&self) -> Result<String, RadrootsEventStoreError> { - query_string(&self.pool, "PRAGMA journal_mode").await + query_string(&self.pool, "PRAGMA main.journal_mode").await } pub async fn status_summary( @@ -328,13 +334,86 @@ impl RadrootsEventStore { event_id: &str, ) -> Result<Option<RadrootsEventVisibility>, RadrootsEventStoreError> { let mut tx = self.pool.begin().await?; - let Some(snapshot) = visible_event_snapshot(&mut tx, event_id).await? else { - tx.commit().await?; - return Ok(None); - }; - let visibility = visibility_from_snapshot(&snapshot); + let visibility = event_visibility_in_transaction(&mut tx, event_id).await?; + tx.commit().await?; + Ok(visibility) + } + + /// Evaluates all requested event ids against one coherent database snapshot. + /// + /// Results preserve input order and cardinality, including duplicate or + /// missing ids, while each distinct id is evaluated only once. The request + /// is bounded by [`RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX`], and every id must + /// be canonical lowercase 32-byte hex. This is the batch authority for + /// callers that must not mix current-visibility decisions from different + /// SQLite snapshots. + pub async fn event_visibilities<I, S>( + &self, + event_ids: I, + ) -> Result<Vec<Option<RadrootsEventVisibility>>, RadrootsEventStoreError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + { + self.event_visibilities_with_probe(event_ids, |_| async { + Ok::<(), RadrootsEventStoreError>(()) + }) + .await + } + + async fn event_visibilities_with_probe<I, S, F, Fut>( + &self, + event_ids: I, + mut after_evaluation: F, + ) -> Result<Vec<Option<RadrootsEventVisibility>>, RadrootsEventStoreError> + where + I: IntoIterator<Item = S>, + S: AsRef<str>, + F: FnMut(usize) -> Fut, + Fut: Future<Output = Result<(), RadrootsEventStoreError>>, + { + let max = RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX as usize; + let event_ids = event_ids + .into_iter() + .take(max.saturating_add(1)) + .collect::<Vec<_>>(); + if event_ids.len() > max { + return Err(RadrootsEventStoreError::EventVisibilityBatchTooLarge { max }); + } + let event_ids = event_ids + .into_iter() + .map(|event_id| RadrootsEventId::parse(event_id.as_ref())) + .collect::<Result<Vec<_>, _>>()?; + let mut unique_event_ids = Vec::new(); + let mut seen_event_ids = BTreeMap::new(); + for event_id in &event_ids { + if seen_event_ids.insert(event_id.clone(), ()).is_none() { + unique_event_ids.push(event_id.clone()); + } + } + if unique_event_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut tx = self.pool.begin().await?; + let mut evaluated = BTreeMap::new(); + for (index, event_id) in unique_event_ids.into_iter().enumerate() { + let visibility = event_visibility_in_transaction(&mut tx, event_id.as_str()).await?; + evaluated.insert(event_id, visibility); + after_evaluation(index + 1).await?; + } tx.commit().await?; - Ok(Some(visibility?)) + + event_ids + .into_iter() + .map(|event_id| { + evaluated.get(&event_id).cloned().ok_or_else(|| { + RadrootsEventStoreError::CurrentVisibilityDrift { + reason: format!("event visibility batch lost evaluated id `{event_id}`"), + } + }) + }) + .collect() } pub async fn visible_event( @@ -342,15 +421,15 @@ impl RadrootsEventStore { event_id: &str, ) -> Result<Option<RadrootsStoredVisibleEvent>, RadrootsEventStoreError> { let mut tx = self.pool.begin().await?; - let Some(snapshot) = visible_event_snapshot(&mut tx, event_id).await? else { + let Some(current) = current_visibility_in_transaction(&mut tx, event_id).await? else { tx.commit().await?; return Ok(None); }; - if visibility_from_snapshot(&snapshot)? != RadrootsEventVisibility::Visible { + if current.decision() != RadrootsCurrentVisibilityDecisionV1::Visible { tx.commit().await?; return Ok(None); } - let valid_event = RadrootsStoredValidEvent::try_from_raw(snapshot.raw_event)?; + let valid_event = RadrootsStoredValidEvent::try_from_raw(current.event)?; tx.commit().await?; Ok(Some(RadrootsStoredVisibleEvent::new(valid_event))) } @@ -364,15 +443,19 @@ impl RadrootsEventStore { tx.commit().await?; return Ok(None); }; - let RawHeadSnapshot { - raw_head, - raw_event, - } = snapshot; - if raw_event.admission_status != RadrootsEventAdmissionStatus::Admitted { + let raw_head = snapshot.raw_head; + let Some(current) = + current_visibility_in_transaction(&mut tx, raw_head.event_id.as_str()).await? + else { + return Err(RadrootsEventStoreError::StoredHeadInconsistent { + event_id: raw_head.event_id, + }); + }; + if current.decision() != RadrootsCurrentVisibilityDecisionV1::Visible { tx.commit().await?; return Ok(None); } - let valid_event = RadrootsStoredValidEvent::try_from_raw(raw_event)?; + let valid_event = RadrootsStoredValidEvent::try_from_raw(current.event().clone())?; let event = RadrootsStoredVisibleEvent::new(valid_event); tx.commit().await?; Ok(Some(RadrootsStoredVisibleEventHead::new(raw_head, event))) @@ -969,6 +1052,46 @@ impl RadrootsEventStore { } } +async fn event_visibility_in_transaction( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + event_id: &str, +) -> Result<Option<RadrootsEventVisibility>, RadrootsEventStoreError> { + let Some(current) = current_visibility_in_transaction(tx, event_id).await? else { + return Ok(None); + }; + let visibility = match current.decision() { + RadrootsCurrentVisibilityDecisionV1::Visible => RadrootsEventVisibility::Visible, + RadrootsCurrentVisibilityDecisionV1::NotAdmitted => RadrootsEventVisibility::NotAdmitted, + RadrootsCurrentVisibilityDecisionV1::NotCurrent => RadrootsEventVisibility::NotCurrent { + raw_head_event_id: current + .raw_head_event_id() + .ok_or_else( + || RadrootsEventStoreError::StoredHeadCoordinateUnavailable { + event_id: event_id.to_owned(), + }, + )? + .as_str() + .to_owned(), + }, + RadrootsCurrentVisibilityDecisionV1::Suppressed => { + let evidence = current.suppression().ok_or_else(|| { + RadrootsEventStoreError::CurrentVisibilityDrift { + reason: format!( + "suppressed current visibility is missing evidence for `{event_id}`" + ), + } + })?; + RadrootsEventVisibility::Suppressed { + reason: evidence.reason, + event_reference_request_id: evidence.event_reference_request_id.clone(), + address_reference_request_id: evidence.address_reference_request_id.clone(), + address_reference_cutoff: evidence.address_reference_cutoff, + } + } + }; + Ok(Some(visibility)) +} + /// Inspects an existing event-store pool without configuring or migrating it. /// /// The inspection uses one read transaction and applies the same fail-closed @@ -1020,11 +1143,6 @@ pub struct RadrootsTransportObservationRow { pub caller_redacted_message: Option<crate::model::RadrootsTransportObservationMessage>, } -struct VisibleEventSnapshot { - raw_event: RadrootsStoredRawEvent, - raw_head_event_id: Option<String>, -} - async fn configure_pool( pool: &SqlitePool, file_backed: bool, @@ -1082,11 +1200,14 @@ async fn configure_file_journal_mode( ) -> Result<(), RadrootsEventStoreError> { let mut busy_retries = 0; loop { - match sqlx::query("PRAGMA journal_mode = WAL") - .execute(&mut *connection) + match sqlx::query_scalar::<_, String>("PRAGMA main.journal_mode = WAL") + .fetch_one(&mut *connection) .await { - Ok(_) => return Ok(()), + Ok(actual) if actual == "wal" => return Ok(()), + Ok(actual) => { + return Err(RadrootsEventStoreError::SqliteFileJournalModeNotWal { actual }); + } Err(error) if sqlite_error_is_busy(&error) && busy_retries < FILE_JOURNAL_MODE_BUSY_RETRY_LIMIT => @@ -1338,71 +1459,6 @@ async fn validate_projection_cursor_high_water( Ok(()) } -async fn visible_event_snapshot( - tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, - event_id: &str, -) -> Result<Option<VisibleEventSnapshot>, RadrootsEventStoreError> { - let row = sqlx::query( - "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes WHERE event_id = ?", - ) - .bind(event_id) - .fetch_optional(&mut **tx) - .await?; - let Some(row) = row else { - return Ok(None); - }; - let raw_event = stored_raw_event_from_row(row)?; - let raw_head_event_id = match raw_event.event_class { - StoredEventClass::Regular | StoredEventClass::Ephemeral => None, - StoredEventClass::Replaceable | StoredEventClass::Addressable => { - let coordinate = raw_head_coordinate_for_stored_event(&raw_event)?; - raw_head_snapshot_in_transaction(tx, &coordinate) - .await? - .map(|snapshot| snapshot.raw_head.event_id) - } - }; - Ok(Some(VisibleEventSnapshot { - raw_event, - raw_head_event_id, - })) -} - -fn visibility_from_snapshot( - snapshot: &VisibleEventSnapshot, -) -> Result<RadrootsEventVisibility, RadrootsEventStoreError> { - let event = &snapshot.raw_event; - match event.event_class { - StoredEventClass::Ephemeral => Err( - RadrootsEventStoreError::StoredRawEventClassificationInconsistent { - event_id: event.event_id.clone(), - }, - ), - StoredEventClass::Regular - if event.admission_status != RadrootsEventAdmissionStatus::Admitted => - { - Ok(RadrootsEventVisibility::NotAdmitted) - } - StoredEventClass::Regular => Ok(RadrootsEventVisibility::Visible), - StoredEventClass::Replaceable | StoredEventClass::Addressable => { - if event.admission_status != RadrootsEventAdmissionStatus::Admitted { - return Ok(RadrootsEventVisibility::NotAdmitted); - } - let raw_head_event_id = snapshot.raw_head_event_id.as_ref().ok_or_else(|| { - RadrootsEventStoreError::StoredHeadCoordinateUnavailable { - event_id: event.event_id.clone(), - } - })?; - if raw_head_event_id == &event.event_id { - Ok(RadrootsEventVisibility::Visible) - } else { - Ok(RadrootsEventVisibility::NotCurrent { - raw_head_event_id: raw_head_event_id.clone(), - }) - } - } - } -} - #[cfg_attr(coverage_nightly, coverage(off))] fn trade_mutation_from_row( row: sqlx::sqlite::SqliteRow, @@ -1696,12 +1752,15 @@ mod tests { TagKind as RadrootsNostrTagKind, Timestamp as RadrootsNostrTimestamp, }; use radroots_event::draft::RadrootsSignedEvent; + use radroots_event::food_availability::{ + RadrootsFoodAvailabilityStatus, RadrootsFoodIdentifier, + }; use radroots_event::ids::{ RadrootsClassifiedListingAddress, RadrootsInventoryBinId, RadrootsPublicKey, }; use radroots_event::kinds::{ - KIND_CLASSIFIED_LISTING, KIND_DELETION_REQUEST, KIND_GEOCHAT, KIND_LIST_SET_RELAY, - KIND_POST, KIND_PROFILE, KIND_RELAY_AUTH, + KIND_CALENDAR_DATE_EVENT, KIND_CLASSIFIED_LISTING, KIND_DELETION_REQUEST, KIND_FARM, + KIND_GEOCHAT, KIND_LIST_SET_RELAY, KIND_POST, KIND_PROFILE, KIND_RELAY_AUTH, }; use radroots_event::trade::{ RADROOTS_TRADE_DECISION_CONTRACT_ID, RADROOTS_TRADE_PROPOSAL_CONTRACT_ID, @@ -1714,7 +1773,7 @@ mod tests { canonical_trade_mutation_content, }; use radroots_event::wire::{RadrootsNip01EventWire, compute_canonical_nip01_event_id}; - use std::collections::BTreeMap; + use radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityImageDiagnostic; const FIXTURE_ALICE_SECRET_KEY_HEX: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; @@ -2037,6 +2096,52 @@ mod tests { ] } + fn admitted_operational_listing_tags(d_tag: &str, published_at: u64) -> Vec<Vec<String>> { + vec![ + vec!["d".to_owned(), d_tag.to_owned()], + vec!["p".to_owned(), FIXTURE_ALICE_PUBLIC_KEY_HEX.to_owned()], + vec![ + "a".to_owned(), + format!("{KIND_FARM}:{FIXTURE_ALICE_PUBLIC_KEY_HEX}:AAAAAAAAAAAAAAAAAAAAAA"), + ], + vec!["key".to_owned(), "carrot-nantes".to_owned()], + vec!["title".to_owned(), "Nantes Carrots".to_owned()], + vec!["category".to_owned(), "produce".to_owned()], + vec![ + "summary".to_owned(), + "Fresh bunches harvested in Saanich".to_owned(), + ], + vec!["published_at".to_owned(), published_at.to_string()], + vec!["radroots:primary_bin".to_owned(), "bunch".to_owned()], + vec![ + "radroots:bin".to_owned(), + "bunch".to_owned(), + "1".to_owned(), + "each".to_owned(), + ], + vec![ + "radroots:price".to_owned(), + "bunch".to_owned(), + "4".to_owned(), + "CAD".to_owned(), + "1".to_owned(), + "each".to_owned(), + ], + vec!["price".to_owned(), "4".to_owned(), "CAD".to_owned()], + vec!["inventory".to_owned(), "24".to_owned()], + vec!["status".to_owned(), "active".to_owned()], + vec!["delivery".to_owned(), "pickup".to_owned()], + vec![ + "location".to_owned(), + "Saanich Peninsula".to_owned(), + "Victoria".to_owned(), + "BC".to_owned(), + "CA".to_owned(), + ], + vec!["g".to_owned(), "c28hr".to_owned()], + ] + } + fn head_coordinate_for_event(event: &RadrootsSignedEvent) -> RadrootsEventHeadCoordinate { let RadrootsEventHeadCandidateResult::Candidate(candidate) = event_head_candidate_for_nip01_event_v1(event.envelope()) @@ -2070,6 +2175,62 @@ mod tests { signed_event_with_keys(keys, KIND_DELETION_REQUEST, created_at, tags, "") } + fn food_availability_event( + created_at: u32, + d_tag: &str, + title: &str, + summary: &str, + status: &str, + mut images: Vec<Vec<String>>, + ) -> RadrootsSignedEvent { + let mut tags = vec![ + vec!["d".to_owned(), d_tag.to_owned()], + vec!["title".to_owned(), title.to_owned()], + vec!["summary".to_owned(), summary.to_owned()], + vec!["published_at".to_owned(), "100".to_owned()], + vec!["location".to_owned(), "Central Saanich, BC".to_owned()], + vec!["price".to_owned(), "3".to_owned(), "CAD".to_owned()], + vec!["radroots:price_unit".to_owned(), "lb".to_owned()], + vec![ + "radroots:quantity".to_owned(), + "10".to_owned(), + "lb".to_owned(), + ], + vec!["status".to_owned(), status.to_owned()], + ]; + tags.append(&mut images); + signed_event( + KIND_CLASSIFIED_LISTING, + created_at, + tags, + format!("{summary} Available in Victoria this week.").as_str(), + ) + } + + fn calendar_date_event( + created_at: u32, + d_tag: &str, + content: impl Into<String>, + ) -> RadrootsSignedEvent { + let content = content.into(); + signed_event( + KIND_CALENDAR_DATE_EVENT, + created_at, + vec![ + vec!["d".to_owned(), d_tag.to_owned()], + vec!["title".to_owned(), "Victoria Market Day".to_owned()], + vec!["start".to_owned(), "2026-07-20".to_owned()], + vec!["end".to_owned(), "2026-07-21".to_owned()], + vec!["location".to_owned(), "Victoria, BC".to_owned()], + ], + content.as_str(), + ) + } + + fn food_availability_coordinate(d_tag: &str) -> String { + format!("{KIND_CLASSIFIED_LISTING}:{FIXTURE_ALICE_PUBLIC_KEY_HEX}:{d_tag}") + } + fn addressable_coordinate(d_tag: &str) -> String { format!("{KIND_LIST_SET_RELAY}:{FIXTURE_ALICE_PUBLIC_KEY_HEX}:{d_tag}") } @@ -2086,6 +2247,18 @@ mod tests { ); } + async fn rollback_store_to_v2(store: &RadrootsEventStore) { + rollback_event_store_schema_offline(store.pool(), 2) + .await + .expect("rollback to v2"); + assert_eq!( + inspect_event_store_schema_status(store.pool()) + .await + .expect("v2 status"), + RadrootsEventStoreSchemaStatus::Managed { version: 2 } + ); + } + async fn migrate_store_with_generation( store: &RadrootsEventStore, generation: [u8; 32], @@ -2621,6 +2794,108 @@ mod tests { } #[tokio::test] + async fn food_projection_migration_backfills_v2_and_survives_rollback_reupgrade() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let food = food_availability_event( + 200, + "migration-carrots", + "Nantes Carrots", + "Fresh bunches", + "active", + vec![vec![ + "image".to_owned(), + "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp" + .to_owned(), + "800x600".to_owned(), + ]], + ); + store + .ingest_event(RadrootsEventIngest::new(food.clone(), 2_000)) + .await + .expect("FoodAvailability ingest"); + let generation = store.source_generation().await.expect("source generation"); + let raw_digest = raw_authority_digest(&store).await; + let projected = store + .food_availability_v1( + &RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"), + &RadrootsFoodIdentifier::parse("migration-carrots").expect("identifier"), + ) + .await + .expect("projection lookup") + .expect("projection"); + assert_eq!(projected.event_id().as_str(), food.id_str()); + assert_eq!(projected.images().len(), 1); + assert!(projected.images()[0].qualifies()); + assert_eq!( + projected.images()[0].blossom_sha256(), + Some( + radroots_blossom::RadrootsBlossomSha256::from_hex( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ) + .expect("Blossom digest"), + ) + ); + let search = crate::RadrootsFoodAvailabilitySearchQueryV1::parse("Nantes Carrots") + .expect("search query"); + + for cycle in 0..2 { + rollback_store_to_v2(&store).await; + let successor_objects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_schema WHERE name LIKE 'radroots_event_store_food_availability_%' OR name IN ('radroots_event_store_addressable_feed_generation_insert', 'radroots_event_store_addressable_feed_integrity_v1', 'radroots_event_store_addressable_feed_transition_insert', 'radroots_event_store_addressable_transition_coordinate_idx', 'radroots_event_store_current_visibility_head_lookup_idx', 'radroots_event_store_current_visibility_v1', 'radroots_event_store_nip09_address_target_visibility_lookup_idx')", + ) + .fetch_one(store.pool()) + .await + .expect("successor object count"); + assert_eq!(successor_objects, 0); + assert_eq!(raw_authority_digest(&store).await, raw_digest); + let transitions: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_addressable_head_transition WHERE kind = 30402", + ) + .fetch_one(store.pool()) + .await + .expect("v2 FoodAvailability transitions"); + assert_eq!(transitions, 1); + + migrate_store_with_generation( + &store, + [0x70 + u8::try_from(cycle).expect("bounded cycle"); 32], + ) + .await + .expect("v2 to v3 re-upgrade"); + assert_eq!( + store.source_generation().await.expect("generation"), + generation + ); + assert_eq!(raw_authority_digest(&store).await, raw_digest); + let rebuilt = store + .food_availability_v1( + &RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"), + &RadrootsFoodIdentifier::parse("migration-carrots").expect("identifier"), + ) + .await + .expect("rebuilt projection lookup") + .expect("rebuilt projection"); + assert_eq!(rebuilt, projected); + let matches = store + .search_food_availability_v1( + &search, + crate::RadrootsFoodAvailabilityStatusFilterV1::Active, + 10, + ) + .await + .expect("rebuilt FTS search"); + assert_eq!(matches, vec![projected.clone()]); + let cursor_and_high_water: (i64, i64) = sqlx::query_as( + "SELECT cursor.last_transition_seq, source.last_transition_seq FROM radroots_event_store_food_availability_cursor AS cursor JOIN radroots_event_store_source_state AS source ON source.singleton = 1 WHERE cursor.singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("projection cursor high-water"); + assert_eq!(cursor_and_high_water.0, cursor_and_high_water.1); + } + } + + #[tokio::test] async fn nip09_migration_entropy_and_legacy_source_failures_are_atomic() { let entropy_store = RadrootsEventStore::open_memory().await.expect("open"); let event = signed_event(KIND_POST, 20, Vec::new(), "entropy"); @@ -3649,6 +3924,37 @@ mod tests { } #[tokio::test] + async fn file_journal_mode_configuration_rejects_successful_non_wal_result() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("immutable-delete.sqlite"); + let mut writer = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(&path) + .create_if_missing(true), + ) + .await + .expect("writer connection"); + let initial_mode: String = sqlx::query_scalar("PRAGMA main.journal_mode = DELETE") + .fetch_one(&mut writer) + .await + .expect("delete journal mode"); + assert_eq!(initial_mode, "delete"); + writer.close().await.expect("close writer"); + + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new().filename(&path).immutable(true), + ) + .await + .expect("immutable connection"); + + assert!(matches!( + configure_file_journal_mode(&mut connection).await, + Err(RadrootsEventStoreError::SqliteFileJournalModeNotWal { actual }) + if actual == "delete" + )); + } + + #[tokio::test] async fn open_pool_configures_every_file_connection_and_rejects_multi_connection_memory() { let memory_options = SqliteConnectOptions::from_str("sqlite::memory:") .expect("memory options") @@ -3735,6 +4041,11 @@ mod tests { .await .expect("foreign keys"); assert_eq!(foreign_keys, 1); + let journal_mode: String = sqlx::query_scalar("PRAGMA main.journal_mode") + .fetch_one(&mut **connection) + .await + .expect("journal mode"); + assert_eq!(journal_mode, "wal"); let orphan = sqlx::query( "INSERT INTO event_envelope_tags(event_id, tag_index, tag_name, tag_value, tag_json, contract_semantic, contract_value_type, relay_indexed) VALUES ('missing', 0, 'd', 'value', '[\"d\",\"value\"]', NULL, NULL, 0)", ) @@ -4010,10 +4321,12 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", let first = RadrootsEventStore::open_file(&path).await.expect("first"); assert_eq!(first.pragma_foreign_keys().await.expect("foreign_keys"), 1); + assert_eq!(first.pragma_journal_mode().await.expect("journal"), "wal"); drop(first); let second = RadrootsEventStore::open_file(&path).await.expect("second"); assert_eq!(second.pragma_foreign_keys().await.expect("foreign_keys"), 1); + assert_eq!(second.pragma_journal_mode().await.expect("journal"), "wal"); } #[tokio::test] @@ -5375,6 +5688,1926 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", } #[tokio::test] + async fn food_availability_projection_replaces_and_queries_real_ingest_events() { + const BLOSSOM_CARROTS: &str = "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp"; + const BLOSSOM_DETAIL: &str = "https://media.example/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.jpg"; + + let store = RadrootsEventStore::open_memory().await.expect("open"); + let carrots = food_availability_event( + 200, + "nantes-carrots", + "Nantes Carrots", + "Fresh bunches", + "active", + vec![ + vec![ + "image".to_owned(), + BLOSSOM_CARROTS.to_owned(), + "800x600".to_owned(), + ], + vec!["image".to_owned(), BLOSSOM_DETAIL.to_owned()], + ], + ); + let kale = food_availability_event( + 201, + "lacinato-kale", + "Lacinato Kale", + "Tender greens", + "active", + Vec::new(), + ); + + for (observed_at_ms, event) in [(10_000, &carrots), (10_001, &kale)] { + let receipt = store + .ingest_event(RadrootsEventIngest::new(event.clone(), observed_at_ms)) + .await + .expect("food ingest"); + assert_eq!( + receipt.admission_status, + RadrootsEventAdmissionStatus::Admitted + ); + assert_eq!( + receipt.contract_id.as_deref(), + Some("radroots.food.availability.v1") + ); + } + let initial_projection_count: i64 = sqlx::query_scalar( + "SELECT projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("sealed projection count"); + assert_eq!(initial_projection_count, 2); + + let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let carrot_id = RadrootsFoodIdentifier::parse("nantes-carrots").expect("identifier"); + let projected = store + .food_availability_v1(&author, &carrot_id) + .await + .expect("food lookup") + .expect("projected carrots"); + assert_eq!(projected.event_id().as_str(), carrots.id_str()); + assert_eq!(projected.title().as_str(), "Nantes Carrots"); + assert_eq!(projected.summary().as_str(), "Fresh bunches"); + assert_eq!(projected.price().amount(), "3"); + assert_eq!(projected.price().currency().as_str(), "CAD"); + assert_eq!(projected.price().unit().as_str(), "lb"); + assert_eq!(projected.quantity().expect("quantity").amount(), "10"); + assert_eq!(projected.status(), RadrootsFoodAvailabilityStatus::Active); + assert_eq!( + projected.diagnostics(), + &[ + RadrootsFoodAvailabilityImageDiagnostic::ShapeInvalid, + RadrootsFoodAvailabilityImageDiagnostic::DimensionsMissing, + ] + ); + assert_eq!(projected.images().len(), 2); + assert!(projected.images()[0].qualifies()); + assert_eq!( + projected.images()[0].blossom_sha256(), + Some( + radroots_blossom::RadrootsBlossomSha256::from_hex( + "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + ) + .expect("Blossom digest"), + ) + ); + assert!(!projected.images()[1].qualifies()); + assert_eq!( + projected.images()[1].blossom_sha256(), + Some( + radroots_blossom::RadrootsBlossomSha256::from_hex( + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ) + .expect("Blossom digest"), + ) + ); + + let active = store + .recent_food_availability_v1(crate::RadrootsFoodAvailabilityStatusFilterV1::Active, 10) + .await + .expect("active food"); + assert_eq!(active.len(), 2); + let carrots_query = + crate::RadrootsFoodAvailabilitySearchQueryV1::parse("Nantes Central Saanich") + .expect("search query"); + let matches = store + .search_food_availability_v1( + &carrots_query, + crate::RadrootsFoodAvailabilityStatusFilterV1::Any, + 10, + ) + .await + .expect("search"); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].event_id().as_str(), carrots.id_str()); + let summary_query = crate::RadrootsFoodAvailabilitySearchQueryV1::parse("Fresh") + .expect("summary search query"); + let summary_matches = store + .search_food_availability_v1( + &summary_query, + crate::RadrootsFoodAvailabilityStatusFilterV1::Any, + 10, + ) + .await + .expect("summary search"); + assert_eq!(summary_matches.len(), 1); + assert_eq!(summary_matches[0].event_id().as_str(), carrots.id_str()); + + let sold = food_availability_event( + 220, + "nantes-carrots", + "Nantes Carrots Sold", + "Farm stand sold out", + "sold", + Vec::new(), + ); + store + .ingest_event(RadrootsEventIngest::new(sold.clone(), 10_002)) + .await + .expect("sold replacement"); + + let replacement = store + .food_availability_v1(&author, &carrot_id) + .await + .expect("replacement lookup") + .expect("sold projection"); + assert_eq!(replacement.event_id().as_str(), sold.id_str()); + assert_eq!(replacement.status(), RadrootsFoodAvailabilityStatus::Sold); + assert_eq!(replacement.published_at().as_u64(), 100); + assert!(replacement.images().is_empty()); + let replacement_projection_count: i64 = sqlx::query_scalar( + "SELECT projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("sealed replacement projection count"); + assert_eq!(replacement_projection_count, 2); + assert_eq!( + store + .current_event_visibility_v1(carrots.id_str()) + .await + .expect("old visibility") + .expect("stored old revision") + .decision(), + crate::RadrootsCurrentVisibilityDecisionV1::NotCurrent + ); + assert_eq!( + store + .current_event_visibility_v1(sold.id_str()) + .await + .expect("sold visibility") + .expect("stored sold revision") + .decision(), + crate::RadrootsCurrentVisibilityDecisionV1::Visible + ); + + let active = store + .recent_food_availability_v1(crate::RadrootsFoodAvailabilityStatusFilterV1::Active, 10) + .await + .expect("active after replacement"); + assert_eq!(active.len(), 1); + assert_eq!(active[0].event_id().as_str(), kale.id_str()); + let sold_rows = store + .recent_food_availability_v1(crate::RadrootsFoodAvailabilityStatusFilterV1::Sold, 10) + .await + .expect("sold food"); + assert_eq!(sold_rows.len(), 1); + assert_eq!(sold_rows[0].event_id().as_str(), sold.id_str()); + + let stale_query = crate::RadrootsFoodAvailabilitySearchQueryV1::parse("Fresh bunches") + .expect("stale query"); + assert!( + store + .search_food_availability_v1( + &stale_query, + crate::RadrootsFoodAvailabilityStatusFilterV1::Any, + 10, + ) + .await + .expect("stale search") + .is_empty() + ); + let replacement_query = crate::RadrootsFoodAvailabilitySearchQueryV1::parse("Farm sold") + .expect("replacement query"); + let replacement_matches = store + .search_food_availability_v1( + &replacement_query, + crate::RadrootsFoodAvailabilityStatusFilterV1::Sold, + 10, + ) + .await + .expect("replacement search"); + assert_eq!(replacement_matches.len(), 1); + assert_eq!(replacement_matches[0].event_id().as_str(), sold.id_str()); + } + + #[tokio::test] + async fn food_availability_projection_guards_images_and_exhaustive_audit_detects_fts_drift() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let with_image = food_availability_event( + 200, + "guarded-carrots", + "Guarded Carrots", + "Fresh harvest", + "active", + vec![vec![ + "image".to_owned(), + "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp" + .to_owned(), + "800x600".to_owned(), + ]], + ); + store + .ingest_event(RadrootsEventIngest::new(with_image, 19_000)) + .await + .expect("FoodAvailability ingest"); + + let image_delete = sqlx::query( + "DELETE FROM radroots_event_store_food_availability_image WHERE d_tag = 'guarded-carrots'", + ) + .execute(store.pool()) + .await + .expect_err("direct image delete must be guarded"); + assert!( + image_delete + .to_string() + .contains("image delete is not backed by a pending retraction") + ); + let cursor_update = sqlx::query( + "UPDATE radroots_event_store_food_availability_cursor SET last_transition_seq = last_transition_seq WHERE singleton = 1", + ) + .execute(store.pool()) + .await + .expect_err("projection cursor must reject direct writes"); + assert!( + cursor_update + .to_string() + .contains("FoodAvailability cursor update is invalid") + ); + + let replacement = food_availability_event( + 210, + "guarded-carrots", + "Guarded Carrots", + "Sold at market", + "sold", + Vec::new(), + ); + store + .ingest_event(RadrootsEventIngest::new(replacement.clone(), 19_001)) + .await + .expect("authorized replacement cascade"); + let image_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM radroots_event_store_food_availability_image") + .fetch_one(store.pool()) + .await + .expect("image count"); + assert_eq!(image_count, 0); + + let event_seq: i64 = sqlx::query_scalar( + "SELECT event_seq FROM radroots_event_store_food_availability_projection WHERE event_id = ?", + ) + .bind(replacement.id_str()) + .fetch_one(store.pool()) + .await + .expect("replacement sequence"); + sqlx::query( + "DELETE FROM radroots_event_store_food_availability_search_fts WHERE rowid = ?", + ) + .bind(event_seq) + .execute(store.pool()) + .await + .expect("test-only FTS corruption"); + assert!(matches!( + store.audit_food_availability_projection_v1().await, + Err(RadrootsEventStoreError::FoodAvailabilityProjectionDrift { .. }) + )); + } + + #[tokio::test] + async fn food_availability_exhaustive_audit_rejects_wrong_source_transition_authority() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + for event in [ + food_availability_event( + 200, + "transition-carrots", + "Transition Carrots", + "First harvest", + "active", + Vec::new(), + ), + food_availability_event( + 201, + "transition-kale", + "Transition Kale", + "Second harvest", + "active", + Vec::new(), + ), + ] { + store + .ingest_event(RadrootsEventIngest::new(event, 19_100)) + .await + .expect("FoodAvailability ingest"); + } + + let wrong_transition_seq: i64 = sqlx::query_scalar( + "SELECT source_transition_seq FROM radroots_event_store_food_availability_projection WHERE d_tag = 'transition-kale'", + ) + .fetch_one(store.pool()) + .await + .expect("wrong-coordinate transition"); + sqlx::query("DROP TRIGGER radroots_event_store_food_availability_projection_update_guard") + .execute(store.pool()) + .await + .expect("trusted projection guard removal"); + sqlx::query( + "UPDATE radroots_event_store_food_availability_projection SET source_transition_seq = ? WHERE d_tag = 'transition-carrots'", + ) + .bind(wrong_transition_seq) + .execute(store.pool()) + .await + .expect("trusted source-transition corruption"); + + let error = store + .audit_food_availability_projection_v1() + .await + .expect_err("wrong source transition must fail exhaustive audit"); + assert!( + matches!( + error, + RadrootsEventStoreError::FoodAvailabilityProjectionDrift { ref reason } + if reason.contains("source transition") + ), + "{error}" + ); + } + + #[tokio::test] + async fn food_availability_exhaustive_audit_compares_exact_head_coordinates() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + store + .ingest_event(RadrootsEventIngest::new( + food_availability_event( + 200, + "coordinate-carrots", + "Coordinate Carrots", + "Coordinate harvest", + "active", + Vec::new(), + ), + 19_200, + )) + .await + .expect("FoodAvailability ingest"); + + let mut connection = store.pool().acquire().await.expect("trusted connection"); + for statement in [ + "DROP TRIGGER radroots_event_store_addressable_state_identity_update_guard", + "DROP TRIGGER radroots_event_store_addressable_state_old_update_guard", + ] { + sqlx::query(statement) + .execute(&mut *connection) + .await + .expect("trusted head-state guard removal"); + } + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *connection) + .await + .expect("disable trusted foreign-key enforcement"); + sqlx::query( + "UPDATE radroots_event_store_addressable_head_state SET d_tag = 'retargeted-carrots' WHERE d_tag = 'coordinate-carrots'", + ) + .execute(&mut *connection) + .await + .expect("trusted head-coordinate corruption"); + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut *connection) + .await + .expect("restore foreign-key enforcement"); + drop(connection); + + let error = store + .audit_food_availability_projection_v1() + .await + .expect_err("retargeted head coordinate must fail exhaustive audit"); + assert!( + matches!( + error, + RadrootsEventStoreError::FoodAvailabilityProjectionDrift { ref reason } + if reason.contains("coordinate witnesses") + ), + "{error}" + ); + } + + #[tokio::test] + async fn food_availability_exhaustive_audit_reserves_wal_writer_before_snapshot_reads() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("food-audit-wal.sqlite"); + let audit_store = RadrootsEventStore::open_file(&path) + .await + .expect("audit store"); + let writer_store = RadrootsEventStore::open_file(&path) + .await + .expect("writer store"); + audit_store + .ingest_event(RadrootsEventIngest::new( + food_availability_event( + 200, + "wal-carrots", + "WAL Carrots", + "Serialized audit harvest", + "active", + Vec::new(), + ), + 19_300, + )) + .await + .expect("FoodAvailability ingest"); + assert_eq!( + audit_store + .pragma_journal_mode() + .await + .expect("journal mode"), + "wal" + ); + + let checkpoint_reached = std::sync::Arc::new(tokio::sync::Notify::new()); + let checkpoint_release = std::sync::Arc::new(tokio::sync::Notify::new()); + let audit_task = tokio::spawn( + super::food_availability_projection_v1::FOOD_AVAILABILITY_AUDIT_FTS_CHECKPOINT.scope( + ( + std::sync::Arc::clone(&checkpoint_reached), + std::sync::Arc::clone(&checkpoint_release), + ), + async move { audit_store.audit_food_availability_projection_v1().await }, + ), + ); + checkpoint_reached.notified().await; + + let writer_started = std::sync::Arc::new(tokio::sync::Notify::new()); + let writer_started_task = std::sync::Arc::clone(&writer_started); + let writer_task = tokio::spawn(async move { + writer_started_task.notify_one(); + let transaction = writer_store + .begin_write_transaction() + .await + .expect("competing writer transaction"); + transaction.commit().await.expect("competing writer commit"); + }); + writer_started.notified().await; + tokio::task::yield_now().await; + assert!( + !writer_task.is_finished(), + "competing writer acquired while the audit was paused before FTS integrity-check" + ); + + checkpoint_release.notify_one(); + audit_task + .await + .expect("audit task") + .expect("serialized exhaustive audit"); + writer_task.await.expect("competing writer task"); + } + + #[tokio::test] + async fn food_availability_queries_enforce_limits_order_ties_and_execute_literal_fts_input() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let events = [ + food_availability_event( + 200, + "query-carrots", + "Query Carrots", + "Shared harvest", + "active", + Vec::new(), + ), + food_availability_event( + 201, + "query-kale", + "Query Kale", + "Shared harvest", + "active", + Vec::new(), + ), + food_availability_event( + 202, + "query-beets", + "Query Beets", + "Shared harvest", + "active", + Vec::new(), + ), + ]; + for (index, event) in events.iter().enumerate() { + store + .ingest_event(RadrootsEventIngest::new( + event.clone(), + 19_100 + i64::try_from(index).expect("index"), + )) + .await + .expect("query fixture ingest"); + } + + let mut expected_ids = events + .iter() + .map(|event| event.id_str().to_owned()) + .collect::<Vec<_>>(); + expected_ids.sort(); + let recent = store + .recent_food_availability_v1( + crate::RadrootsFoodAvailabilityStatusFilterV1::Any, + RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, + ) + .await + .expect("maximum-limit recent query"); + assert_eq!( + recent + .iter() + .map(|projection| projection.event_id().as_str()) + .collect::<Vec<_>>(), + expected_ids.iter().map(String::as_str).collect::<Vec<_>>(), + ); + assert_eq!( + store + .recent_food_availability_v1(crate::RadrootsFoodAvailabilityStatusFilterV1::Any, 1,) + .await + .expect("minimum-limit recent query")[0] + .event_id() + .as_str(), + expected_ids[0], + ); + for invalid_limit in [0, RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX + 1] { + assert!(matches!( + store + .recent_food_availability_v1( + crate::RadrootsFoodAvailabilityStatusFilterV1::Any, + invalid_limit, + ) + .await, + Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. }) + )); + } + + let shared = crate::RadrootsFoodAvailabilitySearchQueryV1::parse("Shared") + .expect("shared search query"); + let search = store + .search_food_availability_v1( + &shared, + crate::RadrootsFoodAvailabilityStatusFilterV1::Any, + RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, + ) + .await + .expect("maximum-limit search query"); + assert_eq!( + search + .iter() + .map(|projection| projection.event_id().as_str()) + .collect::<Vec<_>>(), + expected_ids.iter().map(String::as_str).collect::<Vec<_>>(), + ); + for hostile in ["\"", "()", "title:", "*", "---", "OR title:beets*"] { + let query = crate::RadrootsFoodAvailabilitySearchQueryV1::parse(hostile) + .expect("literal hostile query"); + store + .search_food_availability_v1( + &query, + crate::RadrootsFoodAvailabilityStatusFilterV1::Any, + 1, + ) + .await + .expect("hostile input remains a valid literal FTS query"); + } + assert!(matches!( + store + .search_food_availability_v1( + &shared, + crate::RadrootsFoodAvailabilityStatusFilterV1::Any, + 0, + ) + .await, + Err(RadrootsEventStoreError::QueryLimitOutOfRange { .. }) + )); + } + + #[tokio::test] + async fn current_visibility_and_food_reads_use_bounded_authority_indexes() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let visibility_plan = explain_query_plan( + &store, + "EXPLAIN QUERY PLAN SELECT suppression_outcome, suppression_reason, event_reference_request_id, address_reference_request_id, address_reference_cutoff, current_visibility FROM radroots_event_store_current_visibility_v1 WHERE event_id = ?", + event_id('a').as_str(), + ) + .await; + assert!( + visibility_plan.contains("radroots_event_store_nip09_event_target_lookup_idx"), + "{visibility_plan}" + ); + assert!( + visibility_plan + .contains("radroots_event_store_nip09_address_target_visibility_lookup_idx"), + "{visibility_plan}" + ); + assert!( + !visibility_plan.contains("USE TEMP B-TREE"), + "{visibility_plan}" + ); + + let food_sql = format!( + "EXPLAIN QUERY PLAN {}", + super::food_availability_projection_v1::FOOD_AVAILABILITY_RECENT_QUERY_V1 + ); + let food_plan = explain_query_plan(&store, food_sql.as_str(), "1000").await; + assert!( + food_plan.contains("radroots_event_store_food_availability_recent_idx"), + "{food_plan}" + ); + assert!( + food_plan.contains("SEARCH head USING PRIMARY KEY"), + "{food_plan}" + ); + assert!( + !food_plan.contains("radroots_event_store_nip09_event_target") + && !food_plan.contains("radroots_event_store_nip09_address_target"), + "{food_plan}" + ); + assert!(!food_plan.contains("USE TEMP B-TREE"), "{food_plan}"); + } + + #[tokio::test] + async fn food_availability_retraction_never_resurrects_an_older_revision() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let active = food_availability_event( + 200, + "nantes-carrots", + "Nantes Carrots", + "Fresh bunches", + "active", + Vec::new(), + ); + let sold = food_availability_event( + 220, + "nantes-carrots", + "Nantes Carrots Sold", + "Sold at market", + "sold", + Vec::new(), + ); + let deletion = deletion_event( + &fixture_keys(), + 230, + vec![vec![ + "a".to_owned(), + food_availability_coordinate("nantes-carrots"), + ]], + ); + let older = food_availability_event( + 210, + "nantes-carrots", + "Nantes Carrots Older", + "Older active revision", + "active", + Vec::new(), + ); + let recovered = food_availability_event( + 240, + "nantes-carrots", + "Nantes Carrots Restocked", + "Fresh restock", + "active", + Vec::new(), + ); + let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let identifier = RadrootsFoodIdentifier::parse("nantes-carrots").expect("identifier"); + + for (observed_at_ms, event) in [(20_000, &active), (20_001, &sold)] { + store + .ingest_event(RadrootsEventIngest::new(event.clone(), observed_at_ms)) + .await + .expect("food revision"); + } + store + .ingest_event(RadrootsEventIngest::new(deletion.clone(), 20_002)) + .await + .expect("address deletion"); + assert!( + store + .food_availability_v1(&author, &identifier) + .await + .expect("retracted lookup") + .is_none() + ); + let retracted_projection_count: i64 = sqlx::query_scalar( + "SELECT projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("sealed retracted projection count"); + assert_eq!(retracted_projection_count, 0); + let suppressed = store + .current_event_visibility_v1(sold.id_str()) + .await + .expect("suppressed visibility") + .expect("stored sold revision"); + assert_eq!( + suppressed.decision(), + crate::RadrootsCurrentVisibilityDecisionV1::Suppressed + ); + let evidence = suppressed.suppression().expect("suppression evidence"); + assert_eq!( + evidence.reason(), + crate::RadrootsNip09SuppressionReason::AddressReferenceAtOrBeforeCutoff + ); + assert_eq!( + evidence + .address_reference_request_id() + .expect("address deletion id") + .as_str(), + deletion.id_str() + ); + assert_eq!(evidence.address_reference_cutoff(), Some(230)); + + let older_receipt = store + .ingest_event(RadrootsEventIngest::new(older.clone(), 20_003)) + .await + .expect("older late arrival"); + assert_eq!( + older_receipt.raw_head_decision, + RadrootsRawHeadDecision::SkippedOlder + ); + assert!( + store + .food_availability_v1(&author, &identifier) + .await + .expect("no resurrection lookup") + .is_none() + ); + assert_eq!( + store + .current_event_visibility_v1(older.id_str()) + .await + .expect("older visibility") + .expect("stored older revision") + .decision(), + crate::RadrootsCurrentVisibilityDecisionV1::NotCurrent + ); + + store + .ingest_event(RadrootsEventIngest::new(recovered.clone(), 20_004)) + .await + .expect("post-cutoff replacement"); + let projection = store + .food_availability_v1(&author, &identifier) + .await + .expect("recovered lookup") + .expect("recovered projection"); + assert_eq!(projection.event_id().as_str(), recovered.id_str()); + assert_eq!(projection.status(), RadrootsFoodAvailabilityStatus::Active); + let recovered_projection_count: i64 = sqlx::query_scalar( + "SELECT projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("sealed recovered projection count"); + assert_eq!(recovered_projection_count, 1); + + let scope = crate::RadrootsAddressableTransitionScopeV1::food_availability(); + let page = store + .addressable_transition_page_v1(&scope, None, 64) + .await + .expect("transition page"); + assert_eq!(page.transitions().len(), 4); + assert_eq!(page.source_high_water(), 4); + assert_eq!(page.next_cursor().last_transition_seq(), 4); + assert!(!page.has_more()); + let deletion_cause = page.transitions()[2] + .cause_event() + .expect("deletion cause metadata"); + assert_eq!( + deletion_cause.event().event_id().as_str(), + deletion.id_str() + ); + assert_eq!( + deletion_cause.pubkey().as_str(), + FIXTURE_ALICE_PUBLIC_KEY_HEX + ); + assert_eq!(deletion_cause.created_at(), 230); + assert_eq!(deletion_cause.kind(), KIND_DELETION_REQUEST); + assert_eq!( + deletion_cause.admission_status(), + RadrootsEventAdmissionStatus::Admitted + ); + assert!(deletion_cause.admission_code().is_none()); + assert!(deletion_cause.contract_id().is_some()); + assert_eq!( + page.transitions()[2] + .retracted_event() + .expect("sold retraction") + .event_id() + .as_str(), + sold.id_str() + ); + assert!(page.transitions()[2].visible_event().is_none()); + assert_eq!( + page.transitions()[2] + .suppression() + .expect("feed suppression") + .reason(), + crate::RadrootsNip09SuppressionReason::AddressReferenceAtOrBeforeCutoff + ); + assert_eq!( + page.transitions()[3] + .visible_event() + .expect("recovered canonical event") + .event_id() + .as_str(), + recovered.id_str() + ); + assert!(page.transitions()[3].retracted_event().is_none()); + assert_eq!(projection.source_transition_seq(), 4); + let cursor_state: (i64, i64) = sqlx::query_as( + "SELECT last_transition_seq, projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("projection cursor"); + assert_eq!(cursor_state, (page.source_high_water(), 1)); + } + + #[tokio::test] + async fn admitted_operational_listing_retracts_food_until_a_later_food_revision() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let identifier = + RadrootsFoodIdentifier::parse("AAAAAAAAAAAAAAAAAAAAAg").expect("identifier"); + let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let food = food_availability_event( + 200, + identifier.as_str(), + "Partition Carrots", + "Focused contract", + "active", + Vec::new(), + ); + store + .ingest_event(RadrootsEventIngest::new(food, 29_000)) + .await + .expect("focused FoodAvailability ingest"); + + let operational = signed_event( + KIND_CLASSIFIED_LISTING, + 210, + admitted_operational_listing_tags(identifier.as_str(), 210), + "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + ); + let receipt = store + .ingest_event(RadrootsEventIngest::new(operational.clone(), 29_001)) + .await + .expect("operational listing ingest"); + assert_eq!( + receipt.admission_status, + RadrootsEventAdmissionStatus::Admitted + ); + assert_eq!( + receipt.contract_id.as_deref(), + Some("radroots.operational_listing.published.v1"), + ); + assert!( + store + .food_availability_v1(&author, &identifier) + .await + .expect("projection after operational head") + .is_none() + ); + let retracted_count: i64 = sqlx::query_scalar( + "SELECT projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("sealed operational-head count"); + assert_eq!(retracted_count, 0); + + let restored = food_availability_event( + 220, + identifier.as_str(), + "Partition Carrots Restored", + "Focused contract restored", + "active", + Vec::new(), + ); + store + .ingest_event(RadrootsEventIngest::new(restored.clone(), 29_002)) + .await + .expect("restored FoodAvailability ingest"); + assert_eq!( + store + .food_availability_v1(&author, &identifier) + .await + .expect("restored lookup") + .expect("restored projection") + .event_id() + .as_str(), + restored.id_str(), + ); + let page = store + .addressable_transition_page_v1( + &crate::RadrootsAddressableTransitionScopeV1::food_availability(), + None, + 64, + ) + .await + .expect("partition transition page"); + assert_eq!(page.transitions().len(), 3); + assert_eq!( + page.transitions()[1].contract_id(), + receipt.contract_id.as_deref() + ); + assert_eq!( + page.transitions()[1] + .visible_event() + .expect("operational transition payload") + .event_id() + .as_str(), + operational.id_str(), + ); + assert_eq!( + page.transitions()[1] + .retracted_event() + .expect("focused projection retraction") + .event_id() + .as_str(), + page.transitions()[0] + .visible_event() + .expect("initial focused payload") + .event_id() + .as_str(), + ); + } + + #[tokio::test] + async fn food_availability_projection_and_feed_rollback_atomically() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let scope = crate::RadrootsAddressableTransitionScopeV1::food_availability(); + let initial_page = store + .addressable_transition_page_v1(&scope, None, 64) + .await + .expect("initial feed"); + assert!(initial_page.transitions().is_empty()); + assert_eq!(initial_page.next_cursor().last_transition_seq(), 0); + let initial_cursor = initial_page.next_cursor().clone(); + let event = food_availability_event( + 200, + "rollback-carrots", + "Rollback Carrots", + "Transaction fixture", + "active", + Vec::new(), + ); + let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let identifier = RadrootsFoodIdentifier::parse("rollback-carrots").expect("identifier"); + + let mut transaction = store.begin_write_transaction().await.expect("transaction"); + store + .ingest_event_in_transaction( + &mut transaction, + RadrootsEventIngest::new(event.clone(), 30_000), + ) + .await + .expect("transactional ingest"); + let in_transaction: (i64, i64, i64) = sqlx::query_as( + "SELECT source.last_transition_seq, cursor.last_transition_seq, (SELECT COUNT(*) FROM radroots_event_store_food_availability_projection) FROM radroots_event_store_source_state AS source JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.singleton = 1 WHERE source.singleton = 1", + ) + .fetch_one(&mut *transaction) + .await + .expect("transactional projection state"); + assert_eq!(in_transaction, (1, 1, 1)); + transaction.rollback().await.expect("rollback"); + + assert!( + store + .raw_event(event.id_str()) + .await + .expect("raw event after rollback") + .is_none() + ); + assert!( + store + .food_availability_v1(&author, &identifier) + .await + .expect("projection after rollback") + .is_none() + ); + let after_rollback = store + .addressable_transition_page_v1(&scope, Some(&initial_cursor), 64) + .await + .expect("feed after rollback"); + assert!(after_rollback.transitions().is_empty()); + assert_eq!(after_rollback.source_high_water(), 0); + assert_eq!(after_rollback.next_cursor().last_transition_seq(), 0); + let cursor_after_rollback: i64 = sqlx::query_scalar( + "SELECT last_transition_seq FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("cursor after rollback"); + assert_eq!(cursor_after_rollback, 0); + + store + .ingest_event(RadrootsEventIngest::new(event.clone(), 30_001)) + .await + .expect("committed ingest"); + let committed = store + .food_availability_v1(&author, &identifier) + .await + .expect("committed projection") + .expect("projected event"); + assert_eq!(committed.event_id().as_str(), event.id_str()); + let committed_page = store + .addressable_transition_page_v1(&scope, Some(&initial_cursor), 64) + .await + .expect("committed feed"); + assert_eq!(committed_page.transitions().len(), 1); + assert_eq!(committed_page.source_high_water(), 1); + assert_eq!(committed.source_transition_seq(), 1); + } + + #[tokio::test] + async fn food_availability_wrong_author_deletion_preserves_projection() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let food = food_availability_event( + 200, + "protected-carrots", + "Protected Carrots", + "Still available", + "active", + Vec::new(), + ); + store + .ingest_event(RadrootsEventIngest::new(food.clone(), 35_000)) + .await + .expect("food ingest"); + let wrong_author_deletion = deletion_event( + &alternate_keys(), + 210, + vec![vec!["e".to_owned(), food.id_str().to_owned()]], + ); + store + .ingest_event(RadrootsEventIngest::new( + wrong_author_deletion.clone(), + 35_001, + )) + .await + .expect("wrong-author deletion remains a valid event"); + + let author = RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"); + let identifier = RadrootsFoodIdentifier::parse("protected-carrots").expect("identifier"); + let projection = store + .food_availability_v1(&author, &identifier) + .await + .expect("projection lookup") + .expect("projection remains visible"); + assert_eq!(projection.event_id().as_str(), food.id_str()); + assert_eq!(projection.source_transition_seq(), 1); + let visibility = store + .current_event_visibility_v1(food.id_str()) + .await + .expect("current visibility") + .expect("stored event"); + assert_eq!( + visibility.decision(), + crate::RadrootsCurrentVisibilityDecisionV1::Visible + ); + assert_eq!( + visibility + .suppression() + .expect("visibility evidence") + .reason(), + crate::RadrootsNip09SuppressionReason::RequestAuthorMismatch + ); + + let scope = crate::RadrootsAddressableTransitionScopeV1::food_availability(); + let page = store + .addressable_transition_page_v1(&scope, None, 64) + .await + .expect("transition page"); + assert_eq!(page.transitions().len(), 2); + let unchanged = &page.transitions()[1]; + assert_eq!( + unchanged + .visible_event() + .expect("same visible event") + .event_id() + .as_str(), + food.id_str() + ); + assert!(unchanged.retracted_event().is_none()); + assert_eq!( + unchanged + .cause_event() + .expect("deletion cause") + .event() + .event_id() + .as_str(), + wrong_author_deletion.id_str() + ); + } + + #[tokio::test] + async fn event_visibility_batches_validate_before_read_and_preserve_duplicate_order() { + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let store = RadrootsEventStore::open_memory().await.expect("open"); + let event = signed_event(KIND_POST, 100, Vec::new(), "Victoria harvest update"); + store + .ingest_event(RadrootsEventIngest::new(event.clone(), 36_000)) + .await + .expect("event ingest"); + let missing_event_id = "f".repeat(64); + let evaluation_count = Arc::new(AtomicUsize::new(0)); + let probe_count = Arc::clone(&evaluation_count); + let visibilities = store + .event_visibilities_with_probe( + [ + event.id_str().to_owned(), + missing_event_id.clone(), + event.id_str().to_owned(), + ], + move |_| { + let probe_count = Arc::clone(&probe_count); + async move { + probe_count.fetch_add(1, Ordering::SeqCst); + Ok(()) + } + }, + ) + .await + .expect("visibility batch"); + assert_eq!( + visibilities, + vec![ + Some(RadrootsEventVisibility::Visible), + None, + Some(RadrootsEventVisibility::Visible), + ] + ); + assert_eq!(evaluation_count.load(Ordering::SeqCst), 2); + + let max = RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX as usize; + let exact_max = store + .event_visibilities(vec![event.id_str().to_owned(); max]) + .await + .expect("maximum visibility batch"); + assert_eq!(exact_max.len(), max); + assert!( + exact_max + .iter() + .all(|visibility| *visibility == Some(RadrootsEventVisibility::Visible)) + ); + + let closed = RadrootsEventStore::open_memory() + .await + .expect("closed fixture"); + closed.pool().close().await; + assert!( + closed + .event_visibilities(Vec::<String>::new()) + .await + .expect("empty batch does not open a transaction") + .is_empty() + ); + assert!(matches!( + closed + .event_visibilities([event.id_str().to_owned(), "not-an-event-id".to_owned(),]) + .await, + Err(RadrootsEventStoreError::IdParse(_)) + )); + assert!(matches!( + closed + .event_visibilities(vec![event.id_str().to_owned(); max + 1]) + .await, + Err(RadrootsEventStoreError::EventVisibilityBatchTooLarge { + max: actual_max, + }) if actual_max == max + )); + } + + #[tokio::test] + async fn event_visibility_batch_holds_one_snapshot_across_concurrent_head_commits() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("visibility-batch-snapshot.sqlite"); + let pool = SqlitePoolOptions::new() + .max_connections(2) + .connect_with( + SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true), + ) + .await + .expect("file pool"); + let store = RadrootsEventStore::open_pool(pool, true) + .await + .expect("file store"); + + let old_alice = signed_event_with_keys( + &fixture_keys(), + KIND_PROFILE, + 100, + Vec::new(), + r#"{"name":"Alice"}"#, + ); + let new_alice = signed_event_with_keys( + &fixture_keys(), + KIND_PROFILE, + 200, + Vec::new(), + r#"{"name":"Alice Farm"}"#, + ); + let old_bob = signed_event_with_keys( + &alternate_keys(), + KIND_PROFILE, + 100, + Vec::new(), + r#"{"name":"Bob"}"#, + ); + let new_bob = signed_event_with_keys( + &alternate_keys(), + KIND_PROFILE, + 200, + Vec::new(), + r#"{"name":"Bob Farm"}"#, + ); + for (observed_at_ms, event) in [(36_100, &old_alice), (36_101, &old_bob)] { + store + .ingest_event(RadrootsEventIngest::new(event.clone(), observed_at_ms)) + .await + .expect("old profile ingest"); + } + + let concurrent_store = store.clone(); + let committed_new_alice = new_alice.clone(); + let committed_new_bob = new_bob.clone(); + let snapshot = store + .event_visibilities_with_probe( + [old_alice.id_str(), old_bob.id_str()], + move |evaluated| { + let concurrent_store = concurrent_store.clone(); + let new_alice = committed_new_alice.clone(); + let new_bob = committed_new_bob.clone(); + async move { + if evaluated == 1 { + concurrent_store + .ingest_event(RadrootsEventIngest::new(new_alice, 36_200)) + .await?; + concurrent_store + .ingest_event(RadrootsEventIngest::new(new_bob, 36_201)) + .await?; + } + Ok(()) + } + }, + ) + .await + .expect("coherent visibility snapshot"); + assert_eq!( + snapshot, + vec![ + Some(RadrootsEventVisibility::Visible), + Some(RadrootsEventVisibility::Visible), + ] + ); + for (old, new) in [(&old_alice, &new_alice), (&old_bob, &new_bob)] { + assert_eq!( + store + .event_visibility(old.id_str()) + .await + .expect("post-commit visibility"), + Some(RadrootsEventVisibility::NotCurrent { + raw_head_event_id: new.id_str().to_owned(), + }) + ); + } + } + + #[tokio::test] + async fn current_visibility_agrees_for_regular_replaceable_and_addressable_reads() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let regular = signed_event(KIND_POST, 100, Vec::new(), "Victoria harvest update"); + let older_profile = signed_event(KIND_PROFILE, 110, Vec::new(), "{\"name\":\"Alice\"}"); + let newer_profile = + signed_event(KIND_PROFILE, 120, Vec::new(), "{\"name\":\"Alice Farm\"}"); + let older_food = food_availability_event( + 130, + "visibility-carrots", + "Visibility Carrots", + "First harvest", + "active", + Vec::new(), + ); + let newer_food = food_availability_event( + 140, + "visibility-carrots", + "Visibility Carrots", + "Second harvest", + "sold", + Vec::new(), + ); + for (index, event) in [ + &regular, + &older_profile, + &newer_profile, + &older_food, + &newer_food, + ] + .into_iter() + .enumerate() + { + store + .ingest_event(RadrootsEventIngest::new( + event.clone(), + 37_000 + i64::try_from(index).expect("index"), + )) + .await + .expect("visibility fixture ingest"); + } + + let regular_current = store + .current_event_visibility_v1(regular.id_str()) + .await + .expect("regular current visibility") + .expect("regular event"); + assert_eq!( + regular_current.decision(), + crate::RadrootsCurrentVisibilityDecisionV1::Visible + ); + assert!(regular_current.is_raw_head()); + assert!(regular_current.raw_head_event_id().is_none()); + assert_eq!( + store + .event_visibility(regular.id_str()) + .await + .expect("regular compatibility visibility"), + Some(RadrootsEventVisibility::Visible) + ); + assert!( + store + .visible_event(regular.id_str()) + .await + .expect("regular visible event") + .is_some() + ); + + for (event, expected_head, expected_decision) in [ + ( + &older_profile, + newer_profile.id_str(), + crate::RadrootsCurrentVisibilityDecisionV1::NotCurrent, + ), + ( + &newer_profile, + newer_profile.id_str(), + crate::RadrootsCurrentVisibilityDecisionV1::Visible, + ), + ( + &older_food, + newer_food.id_str(), + crate::RadrootsCurrentVisibilityDecisionV1::NotCurrent, + ), + ( + &newer_food, + newer_food.id_str(), + crate::RadrootsCurrentVisibilityDecisionV1::Visible, + ), + ] { + let current = store + .current_event_visibility_v1(event.id_str()) + .await + .expect("coordinate current visibility") + .expect("coordinate event"); + assert_eq!(current.decision(), expected_decision); + assert_eq!( + current + .raw_head_event_id() + .expect("coordinate raw head") + .as_str(), + expected_head + ); + assert_eq!( + store + .visible_event(event.id_str()) + .await + .expect("coordinate visible event") + .is_some(), + expected_decision == crate::RadrootsCurrentVisibilityDecisionV1::Visible + ); + } + assert!( + store + .visible_event_head(&profile_coordinate()) + .await + .expect("profile visible head") + .is_some_and(|head| head.raw_head().event_id == newer_profile.id_str()) + ); + assert!( + store + .visible_event_head(&head_coordinate_for_event(&newer_food)) + .await + .expect("food visible head") + .is_some_and(|head| head.raw_head().event_id == newer_food.id_str()) + ); + + let regular_deletion = deletion_event( + &fixture_keys(), + 150, + vec![vec!["e".to_owned(), regular.id_str().to_owned()]], + ); + store + .ingest_event(RadrootsEventIngest::new(regular_deletion.clone(), 37_005)) + .await + .expect("regular deletion"); + assert_eq!( + store + .current_event_visibility_v1(regular.id_str()) + .await + .expect("suppressed regular visibility") + .expect("stored regular event") + .decision(), + crate::RadrootsCurrentVisibilityDecisionV1::Suppressed + ); + assert!(matches!( + store + .event_visibility(regular.id_str()) + .await + .expect("regular compatibility suppression"), + Some(RadrootsEventVisibility::Suppressed { + reason: crate::RadrootsNip09SuppressionReason::EventIdReference, + event_reference_request_id: Some(request_id), + address_reference_request_id: None, + address_reference_cutoff: None, + }) if request_id.as_str() == regular_deletion.id_str() + )); + assert!( + store + .visible_event(regular.id_str()) + .await + .expect("suppressed regular event") + .is_none() + ); + + let deletion_of_deletion = deletion_event( + &fixture_keys(), + 160, + vec![vec!["e".to_owned(), regular_deletion.id_str().to_owned()]], + ); + store + .ingest_event(RadrootsEventIngest::new(deletion_of_deletion, 37_006)) + .await + .expect("deletion-of-deletion ingest"); + let immune = store + .current_event_visibility_v1(regular_deletion.id_str()) + .await + .expect("deletion request visibility") + .expect("stored deletion request"); + assert_eq!( + immune.decision(), + crate::RadrootsCurrentVisibilityDecisionV1::Visible + ); + let immune_evidence = immune.suppression().expect("kind-5 immunity evidence"); + assert_eq!( + immune_evidence.reason(), + crate::RadrootsNip09SuppressionReason::DeletionRequestImmune + ); + assert!(immune_evidence.event_reference_request_id().is_none()); + assert!(immune_evidence.address_reference_request_id().is_none()); + assert!(immune_evidence.address_reference_cutoff().is_none()); + assert_eq!( + store + .event_visibility(regular_deletion.id_str()) + .await + .expect("compatibility deletion-request visibility"), + Some(RadrootsEventVisibility::Visible) + ); + } + + #[tokio::test] + async fn addressable_transition_feed_advances_across_unrelated_kinds() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let first = food_availability_event( + 200, + "first-food", + "First Food", + "First harvest", + "active", + Vec::new(), + ); + let unrelated_first = signed_event( + 30_340, + 201, + vec![vec!["d".to_owned(), "unrelated".to_owned()]], + "unrelated addressable state", + ); + let unrelated_second = signed_event( + 30_340, + 202, + vec![vec!["d".to_owned(), "unrelated".to_owned()]], + "new unrelated addressable state", + ); + let second = food_availability_event( + 203, + "second-food", + "Second Food", + "Second harvest", + "active", + Vec::new(), + ); + for (index, event) in [&first, &unrelated_first, &unrelated_second, &second] + .into_iter() + .enumerate() + { + store + .ingest_event(RadrootsEventIngest::new( + event.clone(), + 40_000 + i64::try_from(index).expect("index"), + )) + .await + .expect("addressable ingest"); + } + + let scope = crate::RadrootsAddressableTransitionScopeV1::food_availability(); + let first_page = store + .addressable_transition_page_v1(&scope, None, 1) + .await + .expect("first scoped page"); + assert_eq!(first_page.transitions().len(), 1); + assert_eq!( + first_page.transitions()[0] + .visible_event() + .expect("first visible event") + .event_id() + .as_str(), + first.id_str() + ); + assert_eq!(first_page.source_high_water(), 4); + assert_eq!(first_page.next_cursor().last_transition_seq(), 3); + assert!(first_page.has_more()); + + let second_page = store + .addressable_transition_page_v1(&scope, Some(first_page.next_cursor()), 1) + .await + .expect("second scoped page"); + assert_eq!(second_page.transitions().len(), 1); + assert_eq!( + second_page.transitions()[0] + .visible_event() + .expect("second visible event") + .event_id() + .as_str(), + second.id_str() + ); + assert_eq!(second_page.next_cursor().last_transition_seq(), 4); + assert!(!second_page.has_more()); + + let unrelated_scope = + crate::RadrootsAddressableTransitionScopeV1::new([30_340]).expect("scope"); + assert!(matches!( + store + .addressable_transition_page_v1( + &unrelated_scope, + Some(first_page.next_cursor()), + 1, + ) + .await, + Err(RadrootsEventStoreError::AddressableTransitionScopeMismatch) + )); + let generation_mismatch = crate::RadrootsAddressableTransitionCursorV1::new( + crate::RadrootsEventStoreSourceGeneration::from_bytes([0xff; 32]), + scope.fingerprint(), + 3, + ) + .expect("generation-mismatched cursor"); + assert!(matches!( + store + .addressable_transition_page_v1(&scope, Some(&generation_mismatch), 1) + .await, + Err(RadrootsEventStoreError::AddressableTransitionSourceGenerationMismatch) + )); + let ahead = crate::RadrootsAddressableTransitionCursorV1::new( + first_page.next_cursor().source_generation(), + scope.fingerprint(), + 5, + ) + .expect("ahead cursor"); + assert!(matches!( + store + .addressable_transition_page_v1(&scope, Some(&ahead), 1) + .await, + Err(RadrootsEventStoreError::AddressableTransitionCursorAhead { + cursor: 5, + high_water: 4, + }) + )); + } + + #[tokio::test] + async fn addressable_transition_feed_pages_at_the_exact_transition_limit() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let mut expected_ids = Vec::new(); + let mut transaction = store.begin_write_transaction().await.expect("transaction"); + for index in 0..=crate::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1 { + let event = signed_event( + 30_340, + 500 + index, + vec![vec!["d".to_owned(), format!("page-limit-{index:02}")]], + "page-limit fixture", + ); + expected_ids.push(event.id_str().to_owned()); + store + .ingest_event_in_transaction( + &mut transaction, + RadrootsEventIngest::new(event, 45_100 + i64::from(index)), + ) + .await + .expect("scoped transition ingest"); + } + transaction.commit().await.expect("commit fixtures"); + + let scope = crate::RadrootsAddressableTransitionScopeV1::new([30_340]).expect("scope"); + let first = store + .addressable_transition_page_v1( + &scope, + None, + crate::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1, + ) + .await + .expect("full first page"); + assert_eq!( + first.transitions().len(), + usize::try_from(crate::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1) + .expect("page limit"), + ); + assert!(first.has_more()); + assert_eq!( + first + .transitions() + .iter() + .map(|transition| transition.raw_head().event_id().as_str()) + .collect::<Vec<_>>(), + expected_ids[..usize::try_from( + crate::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1 + ) + .expect("page limit")], + ); + + let second = store + .addressable_transition_page_v1( + &scope, + Some(first.next_cursor()), + crate::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1, + ) + .await + .expect("continued page"); + assert_eq!(second.transitions().len(), 1); + assert_eq!( + second.transitions()[0].raw_head().event_id().as_str(), + expected_ids.last().expect("last expected event"), + ); + assert!(!second.has_more()); + } + + #[tokio::test] + async fn addressable_transition_feed_preserves_the_maximum_opaque_d_tag() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let d_tag = "d".repeat(crate::RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1); + let event = signed_event( + 30_340, + 204, + vec![vec!["d".to_owned(), d_tag.clone()]], + "maximum d-tag boundary", + ); + store + .ingest_event(RadrootsEventIngest::new(event.clone(), 45_000)) + .await + .expect("maximum d-tag ingest"); + + let scope = crate::RadrootsAddressableTransitionScopeV1::new([30_340]).expect("scope"); + let page = store + .addressable_transition_page_v1(&scope, None, 1) + .await + .expect("maximum d-tag feed"); + assert_eq!(page.transitions().len(), 1); + let transition = &page.transitions()[0]; + assert_eq!(transition.coordinate().kind(), 30_340); + assert_eq!(transition.coordinate().d_tag(), d_tag); + assert_eq!(transition.raw_head().event_id().as_str(), event.id_str()); + assert_eq!(transition.raw_head_created_at(), 204); + assert!(!page.has_more()); + } + + #[tokio::test] + async fn addressable_transition_feed_scan_boundary_does_not_skip_scoped_events() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let mut transaction = store.begin_write_transaction().await.expect("transaction"); + for index in 0..crate::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1 { + let unrelated = signed_event( + 30_340, + 1_000 + index, + vec![vec!["d".to_owned(), "scan-boundary".to_owned()]], + "unrelated addressable transition", + ); + store + .ingest_event_in_transaction( + &mut transaction, + RadrootsEventIngest::new(unrelated, 46_000 + i64::from(index)), + ) + .await + .expect("unrelated transition ingest"); + } + let food = food_availability_event( + 3_000, + "scan-boundary-food", + "Scan Boundary Carrots", + "Scoped transition after unrelated traffic", + "active", + Vec::new(), + ); + store + .ingest_event_in_transaction( + &mut transaction, + RadrootsEventIngest::new(food.clone(), 47_025), + ) + .await + .expect("scoped transition ingest"); + transaction.commit().await.expect("commit fixtures"); + + let scope = crate::RadrootsAddressableTransitionScopeV1::food_availability(); + let first = store + .addressable_transition_page_v1(&scope, None, 64) + .await + .expect("first scan page"); + assert!(first.transitions().is_empty()); + assert_eq!( + first.next_cursor().last_transition_seq(), + i64::from(crate::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1) + ); + assert_eq!( + first.source_high_water(), + i64::from(crate::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1) + 1 + ); + assert!(first.has_more()); + + let second = store + .addressable_transition_page_v1(&scope, Some(first.next_cursor()), 64) + .await + .expect("second scan page"); + assert_eq!(second.transitions().len(), 1); + assert_eq!( + second.transitions()[0] + .visible_event() + .expect("FoodAvailability canonical event") + .event_id() + .as_str(), + food.id_str() + ); + assert_eq!(second.next_cursor().last_transition_seq(), 1_025); + assert!(!second.has_more()); + } + + #[tokio::test] + async fn addressable_transition_feed_payload_cap_continues_without_loss() { + const EVENT_COUNT: u32 = 33; + + let store = RadrootsEventStore::open_memory().await.expect("open"); + let content = "x".repeat(radroots_event::wire::v1::DEFAULT_CONTENT_MAX_BYTES); + let mut expected_ids = Vec::new(); + let mut transaction = store.begin_write_transaction().await.expect("transaction"); + for index in 0..EVENT_COUNT { + let event = calendar_date_event( + 4_000 + index, + format!("payload-cap-{index:02}").as_str(), + content.clone(), + ); + expected_ids.push(event.id_str().to_owned()); + store + .ingest_event_in_transaction( + &mut transaction, + RadrootsEventIngest::new(event, 48_000 + i64::from(index)), + ) + .await + .expect("calendar transition ingest"); + } + transaction.commit().await.expect("commit fixtures"); + + let scope = crate::RadrootsAddressableTransitionScopeV1::new([KIND_CALENDAR_DATE_EVENT]) + .expect("calendar scope"); + let mut cursor = None; + let mut actual_ids = Vec::new(); + let mut page_count = 0_u32; + loop { + let page = store + .addressable_transition_page_v1(&scope, cursor.as_ref(), 64) + .await + .expect("payload-bounded page"); + page_count += 1; + assert!(!page.transitions().is_empty()); + for transition in page.transitions() { + actual_ids.push( + transition + .visible_event() + .expect("admitted calendar canonical event") + .event_id() + .as_str() + .to_owned(), + ); + } + cursor = Some(page.next_cursor().clone()); + if !page.has_more() { + break; + } + } + assert!(page_count > 1, "payload cap must force continuation"); + assert_eq!(actual_ids, expected_ids); + assert_eq!( + cursor.expect("terminal cursor").last_transition_seq(), + i64::from(EVENT_COUNT) + ); + } + + #[tokio::test] + async fn addressable_transition_feed_distinguishes_gaps_from_corruption() { + let gap_store = RadrootsEventStore::open_memory().await.expect("gap store"); + let first = food_availability_event( + 200, + "gap-first", + "Gap First", + "First row", + "active", + Vec::new(), + ); + let unrelated = signed_event( + 30_340, + 201, + vec![vec!["d".to_owned(), "gap-middle".to_owned()]], + "middle row", + ); + let second = food_availability_event( + 202, + "gap-second", + "Gap Second", + "Last row", + "active", + Vec::new(), + ); + for event in [&first, &unrelated, &second] { + gap_store + .ingest_event(RadrootsEventIngest::new(event.clone(), 50_000)) + .await + .expect("gap fixture ingest"); + } + sqlx::query("DROP TRIGGER radroots_event_store_addressable_transition_delete_guard") + .execute(gap_store.pool()) + .await + .expect("drop test-only delete guard"); + sqlx::query( + "DELETE FROM radroots_event_store_addressable_head_transition WHERE transition_seq = 2", + ) + .execute(gap_store.pool()) + .await + .expect("remove middle transition"); + let scope = crate::RadrootsAddressableTransitionScopeV1::food_availability(); + assert!(matches!( + gap_store + .addressable_transition_page_v1(&scope, None, 64) + .await, + Err(RadrootsEventStoreError::AddressableTransitionSequenceGap { .. }) + )); + + let corrupt_store = RadrootsEventStore::open_memory() + .await + .expect("corrupt store"); + corrupt_store + .ingest_event(RadrootsEventIngest::new(first, 50_001)) + .await + .expect("corruption fixture ingest"); + sqlx::query("DROP TRIGGER radroots_event_store_addressable_transition_update_guard") + .execute(corrupt_store.pool()) + .await + .expect("drop test-only update guard"); + sqlx::query( + "UPDATE radroots_event_store_addressable_head_transition SET raw_head_created_at = raw_head_created_at + 1 WHERE transition_seq = 1", + ) + .execute(corrupt_store.pool()) + .await + .expect("corrupt transition metadata"); + assert!(matches!( + corrupt_store + .addressable_transition_page_v1(&scope, None, 64) + .await, + Err(RadrootsEventStoreError::AddressableTransitionCorruption { .. }) + )); + } + + #[tokio::test] async fn raw_addressable_heads_use_the_first_opaque_d_value_or_empty() { let store = RadrootsEventStore::open_memory().await.expect("open"); let missing = signed_event(39_990, 30, Vec::new(), "missing"); diff --git a/crates/event_store/src/store/addressable_transition_feed_v1.rs b/crates/event_store/src/store/addressable_transition_feed_v1.rs @@ -0,0 +1,958 @@ +use super::current_visibility_v1::{parse_suppression_outcome, parse_suppression_reason}; +use super::protocol_storage_v1::stored_raw_event_from_row; +use super::{RadrootsEventStore, u32_from_i64, u64_from_i64}; +use crate::RadrootsEventStoreError; +use crate::model::{ + RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1, + RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1, + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1, + RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1, RadrootsAddressableTransitionCauseV1, + RadrootsAddressableTransitionCoordinateV1, RadrootsAddressableTransitionCursorV1, + RadrootsAddressableTransitionEventReferenceV1, RadrootsAddressableTransitionOriginV1, + RadrootsAddressableTransitionPageV1, RadrootsAddressableTransitionRawHeadDecisionV1, + RadrootsAddressableTransitionScopeV1, RadrootsAddressableTransitionV1, + RadrootsAddressableTransitionVisibilityV1, RadrootsEventAdmissionStatus, RadrootsEventIngest, + RadrootsEventStoreSourceGeneration, RadrootsNip09SuppressionEvidenceV1, + RadrootsNip09SuppressionOutcome, RadrootsStoreProducedCanonicalEventV1, RadrootsStoredRawEvent, + StoredEventClass, +}; +use crate::nip09::reconciliation_v1::{ + EventAdmission, ReconciliationProfile, generation_from_blob, +}; +use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use sqlx::{QueryBuilder, Row, Sqlite, SqliteConnection}; + +impl RadrootsEventStore { + pub async fn addressable_transition_page_v1( + &self, + scope: &RadrootsAddressableTransitionScopeV1, + cursor: Option<&RadrootsAddressableTransitionCursorV1>, + limit: u32, + ) -> Result<RadrootsAddressableTransitionPageV1, RadrootsEventStoreError> { + validate_limit(limit)?; + let mut tx = self.pool.begin().await?; + let page = + addressable_transition_page_in_transaction_v1(&mut tx, scope, cursor, limit).await?; + tx.commit().await?; + Ok(page) + } +} + +pub(super) async fn addressable_transition_page_in_transaction_v1( + connection: &mut SqliteConnection, + scope: &RadrootsAddressableTransitionScopeV1, + cursor: Option<&RadrootsAddressableTransitionCursorV1>, + limit: u32, +) -> Result<RadrootsAddressableTransitionPageV1, RadrootsEventStoreError> { + validate_limit(limit)?; + let source = read_and_validate_source_authority(connection).await?; + let start = validate_or_create_cursor(connection, scope, cursor, &source).await?; + let fetch_limit = i64::from(RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1) + 1; + let mut query = QueryBuilder::<Sqlite>::new( + "SELECT transition_seq, source_generation, origin, kind, pubkey, d_tag, raw_head_event_id, raw_head_event_seq, raw_head_created_at, visible_event_id, visible_event_seq, retracted_event_id, retracted_event_seq, admission_status, admission_code, contract_id, visibility, nip09_outcome, nip09_reason, event_reference_request_id, address_reference_request_id, address_reference_cutoff, cause_event_seq, cause_event_id, raw_head_decision FROM radroots_event_store_addressable_head_transition WHERE source_generation = ", + ); + query.push_bind(source.generation.as_bytes().as_slice()); + query.push(" AND transition_seq > "); + query.push_bind(start); + query.push(" AND transition_seq <= "); + query.push_bind(source.high_water); + query.push(" ORDER BY transition_seq LIMIT "); + query.push_bind(fetch_limit); + + let mut rows = query.build().fetch_all(&mut *connection).await?; + let scan_limited = rows.len() + > usize::try_from(RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1) + .expect("u32 fits usize"); + if scan_limited { + rows.pop(); + } + let mut transitions = Vec::with_capacity(rows.len()); + let mut canonical_payload_bytes = 0usize; + let mut last_scanned_sequence = start; + let mut stopped_before_row = false; + for row in rows { + let transition_seq: i64 = row.try_get("transition_seq").map_err(|error| { + corruption(format!("transition sequence cannot be decoded: {error}")) + })?; + let expected_sequence = last_scanned_sequence + .checked_add(1) + .ok_or_else(|| corruption("transition sequence overflow"))?; + if transition_seq != expected_sequence { + return Err(RadrootsEventStoreError::AddressableTransitionSequenceGap { + reason: format!( + "expected transition sequence {expected_sequence}, found {transition_seq}" + ), + }); + } + let kind = u32_from_i64( + "transition.kind", + row.try_get("kind").map_err(|error| { + corruption(format!("transition kind cannot be decoded: {error}")) + })?, + ) + .map_err(|error| corruption(error.to_string()))?; + if !scope.kinds().contains(&kind) { + last_scanned_sequence = transition_seq; + continue; + } + if transitions.len() == usize::try_from(limit).expect("u32 fits usize") { + stopped_before_row = true; + break; + } + let transition = transition_from_row(connection, row, source.generation).await?; + let transition_payload_bytes = transition + .visible_event() + .map_or(0, |event| event.raw_json().len()); + let next_payload_bytes = canonical_payload_bytes + .checked_add(transition_payload_bytes) + .ok_or( + RadrootsEventStoreError::AddressableTransitionPagePayloadTooLarge { + max: RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1, + actual: usize::MAX, + }, + )?; + if next_payload_bytes > RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1 { + if transitions.is_empty() { + return Err( + RadrootsEventStoreError::AddressableTransitionPagePayloadTooLarge { + max: RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1, + actual: next_payload_bytes, + }, + ); + } + stopped_before_row = true; + break; + } + canonical_payload_bytes = next_payload_bytes; + last_scanned_sequence = transition_seq; + transitions.push(transition); + } + if !scan_limited && !stopped_before_row && last_scanned_sequence < source.high_water { + return Err(RadrootsEventStoreError::AddressableTransitionSequenceGap { + reason: format!( + "sealed interval ends at {}, but the last stored transition is {last_scanned_sequence}", + source.high_water + ), + }); + } + let has_more = last_scanned_sequence < source.high_water; + Ok(RadrootsAddressableTransitionPageV1 { + source_high_water: source.high_water, + transitions, + next_cursor: RadrootsAddressableTransitionCursorV1::new( + source.generation, + scope.fingerprint(), + last_scanned_sequence, + )?, + has_more, + }) +} + +fn validate_limit(limit: u32) -> Result<(), RadrootsEventStoreError> { + if !(1..=RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1).contains(&limit) { + return Err(RadrootsEventStoreError::QueryLimitOutOfRange { + min: 1, + max: RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1, + actual: limit, + }); + } + Ok(()) +} + +struct FeedSourceAuthority { + generation: RadrootsEventStoreSourceGeneration, + feed_version: u32, + floor: i64, + high_water: i64, +} + +async fn read_and_validate_source_authority( + connection: &mut SqliteConnection, +) -> Result<FeedSourceAuthority, RadrootsEventStoreError> { + let row = sqlx::query( + "SELECT source.active_generation, source.last_transition_seq, generation.addressable_feed_version, generation.transition_floor_seq, integrity.transition_floor_seq AS sealed_floor_seq, integrity.last_transition_seq AS sealed_last_transition_seq, integrity.transition_count AS sealed_transition_count FROM radroots_event_store_source_state AS source JOIN radroots_event_store_source_generation AS generation ON generation.source_generation = source.active_generation JOIN radroots_event_store_addressable_feed_integrity_v1 AS integrity ON integrity.source_generation = source.active_generation WHERE source.singleton = 1", + ) + .fetch_optional(&mut *connection) + .await? + .ok_or_else(|| corruption("active source authority is missing"))?; + let authority = FeedSourceAuthority { + generation: generation_from_blob(row.try_get("active_generation")?) + .map_err(|error| corruption(format!("active generation is invalid: {error}")))?, + feed_version: u32_from_i64( + "addressable_feed_version", + row.try_get("addressable_feed_version")?, + ) + .map_err(|error| corruption(error.to_string()))?, + floor: row.try_get("transition_floor_seq")?, + high_water: row.try_get("last_transition_seq")?, + }; + if authority.feed_version != RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1 { + return Err( + RadrootsEventStoreError::AddressableTransitionFeedVersionMismatch { + expected: RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + actual: authority.feed_version, + }, + ); + } + if authority.floor < 0 || authority.high_water < authority.floor { + return Err(corruption(format!( + "active transition interval has floor={} and high-water={}", + authority.floor, authority.high_water + ))); + } + let expected_count = authority.high_water - authority.floor; + let sealed_floor: i64 = row.try_get("sealed_floor_seq")?; + let sealed_high_water: i64 = row.try_get("sealed_last_transition_seq")?; + let sealed_count: i64 = row.try_get("sealed_transition_count")?; + if sealed_floor != authority.floor + || sealed_high_water != authority.high_water + || sealed_count != expected_count + { + return Err(RadrootsEventStoreError::AddressableTransitionSequenceGap { + reason: format!( + "active interval floor={} high-water={} disagrees with seal floor={sealed_floor}, high-water={sealed_high_water}, count={sealed_count}", + authority.floor, authority.high_water, + ), + }); + } + if authority.high_water > authority.floor { + let first_sequence = authority + .floor + .checked_add(1) + .ok_or_else(|| corruption("active transition interval floor overflow"))?; + let boundary_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_addressable_head_transition WHERE source_generation = ? AND transition_seq IN (?, ?)", + ) + .bind(authority.generation.as_bytes().as_slice()) + .bind(first_sequence) + .bind(authority.high_water) + .fetch_one(&mut *connection) + .await?; + let expected_boundary_count = if first_sequence == authority.high_water { + 1 + } else { + 2 + }; + if boundary_count != expected_boundary_count { + return Err(RadrootsEventStoreError::AddressableTransitionSequenceGap { + reason: format!( + "sealed interval {}..={} is missing a boundary transition", + first_sequence, authority.high_water + ), + }); + } + } + Ok(authority) +} + +async fn validate_or_create_cursor( + connection: &mut SqliteConnection, + scope: &RadrootsAddressableTransitionScopeV1, + cursor: Option<&RadrootsAddressableTransitionCursorV1>, + source: &FeedSourceAuthority, +) -> Result<i64, RadrootsEventStoreError> { + let Some(cursor) = cursor else { + return Ok(source.floor); + }; + if cursor.feed_version() != RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1 { + return Err( + RadrootsEventStoreError::AddressableTransitionFeedVersionMismatch { + expected: RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + actual: cursor.feed_version(), + }, + ); + } + if cursor.scope_fingerprint() != scope.fingerprint() { + return Err(RadrootsEventStoreError::AddressableTransitionScopeMismatch); + } + if cursor.source_generation() != source.generation { + return Err(RadrootsEventStoreError::AddressableTransitionSourceGenerationMismatch); + } + if cursor.last_transition_seq() < source.floor { + return Err( + RadrootsEventStoreError::AddressableTransitionCursorExpired { + cursor: cursor.last_transition_seq(), + floor: source.floor, + }, + ); + } + if cursor.last_transition_seq() > source.high_water { + return Err(RadrootsEventStoreError::AddressableTransitionCursorAhead { + cursor: cursor.last_transition_seq(), + high_water: source.high_water, + }); + } + if cursor.last_transition_seq() != source.floor + && cursor.last_transition_seq() != source.high_water + { + let exists: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_addressable_head_transition WHERE source_generation = ? AND transition_seq = ?", + ) + .bind(source.generation.as_bytes().as_slice()) + .bind(cursor.last_transition_seq()) + .fetch_one(&mut *connection) + .await?; + if exists != 1 { + return Err(RadrootsEventStoreError::AddressableTransitionSequenceGap { + reason: format!( + "cursor sequence {} is absent from the sealed active interval", + cursor.last_transition_seq() + ), + }); + } + } + Ok(cursor.last_transition_seq()) +} + +async fn transition_from_row( + connection: &mut SqliteConnection, + row: sqlx::sqlite::SqliteRow, + expected_generation: RadrootsEventStoreSourceGeneration, +) -> Result<RadrootsAddressableTransitionV1, RadrootsEventStoreError> { + let transition_seq: i64 = row.try_get("transition_seq")?; + if transition_seq <= 0 { + return Err(corruption(format!( + "transition sequence {transition_seq} is not positive" + ))); + } + let source_generation = generation_from_blob(row.try_get("source_generation")?) + .map_err(|error| corruption(format!("transition generation is invalid: {error}")))?; + if source_generation != expected_generation { + return Err(corruption( + "scoped query returned a transition from another generation", + )); + } + let origin = + RadrootsAddressableTransitionOriginV1::parse(row.try_get::<String, _>("origin")?.as_str()) + .map_err(|error| corruption(error.to_string()))?; + let kind = u32_from_i64("transition.kind", row.try_get("kind")?) + .map_err(|error| corruption(error.to_string()))?; + let pubkey: String = row.try_get("pubkey")?; + let d_tag: String = row.try_get("d_tag")?; + if !(30_000..=39_999).contains(&kind) + || d_tag.len() > RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1 + { + return Err(corruption("transition coordinate is outside wire bounds")); + } + let coordinate = RadrootsAddressableTransitionCoordinateV1 { + kind, + pubkey: RadrootsPublicKey::parse(pubkey.as_str()) + .map_err(|error| corruption(format!("transition pubkey is invalid: {error}")))?, + d_tag, + }; + let raw_head = required_reference( + "raw_head", + row.try_get("raw_head_event_id")?, + row.try_get("raw_head_event_seq")?, + )?; + let raw_head_created_at = u64_from_i64( + "transition.raw_head_created_at", + row.try_get("raw_head_created_at")?, + ) + .map_err(|error| corruption(error.to_string()))?; + let visible_reference = optional_reference( + "visible_event", + row.try_get("visible_event_id")?, + row.try_get("visible_event_seq")?, + )?; + let retracted_event = optional_reference( + "retracted_event", + row.try_get("retracted_event_id")?, + row.try_get("retracted_event_seq")?, + )?; + let admission_status = + RadrootsEventAdmissionStatus::parse(row.try_get::<String, _>("admission_status")?.as_str()) + .map_err(|error| corruption(error.to_string()))?; + let admission_code: Option<String> = row.try_get("admission_code")?; + let contract_id: Option<String> = row.try_get("contract_id")?; + let visibility = RadrootsAddressableTransitionVisibilityV1::parse( + row.try_get::<String, _>("visibility")?.as_str(), + ) + .map_err(|error| corruption(error.to_string()))?; + let suppression = suppression_evidence_from_transition_row(&row)?; + let cause_reference = optional_reference( + "cause_event", + row.try_get("cause_event_id")?, + row.try_get("cause_event_seq")?, + )?; + let raw_head_decision = RadrootsAddressableTransitionRawHeadDecisionV1::parse( + row.try_get::<String, _>("raw_head_decision")?.as_str(), + ) + .map_err(|error| corruption(error.to_string()))?; + + validate_transition_shape( + origin, + &raw_head, + visible_reference.as_ref(), + retracted_event.as_ref(), + admission_status, + admission_code.as_deref(), + contract_id.as_deref(), + visibility, + suppression.as_ref(), + cause_reference.as_ref(), + raw_head_decision, + raw_head_created_at, + )?; + + let (raw_event, admission) = load_and_validate_stored_event(connection, &raw_head).await?; + validate_addressable_reference( + connection, + source_generation, + &coordinate, + &raw_head, + &raw_event, + ) + .await?; + if raw_event.created_at != raw_head_created_at + || admission.status != admission_status + || admission.code.as_deref() != admission_code.as_deref() + || admission.contract.map(|contract| contract.id) != contract_id.as_deref() + { + return Err(corruption(format!( + "transition {transition_seq} disagrees with its raw-head event" + ))); + } + + let visible_event = if let Some(reference) = visible_reference.as_ref() { + if reference != &raw_head { + return Err(corruption(format!( + "transition {transition_seq} visible event is not the raw head" + ))); + } + Some(RadrootsStoreProducedCanonicalEventV1 { + event_id: raw_head.event_id().clone(), + pubkey: coordinate.pubkey().clone(), + created_at: raw_event.created_at, + kind: coordinate.kind(), + raw_json: raw_event.raw_json.clone(), + }) + } else { + None + }; + + if let Some(reference) = retracted_event.as_ref() { + let (event, admission) = load_and_validate_stored_event(connection, reference).await?; + validate_addressable_reference( + connection, + source_generation, + &coordinate, + reference, + &event, + ) + .await?; + if admission.status != RadrootsEventAdmissionStatus::Admitted { + return Err(corruption(format!( + "transition {transition_seq} retracts an event that is not admitted" + ))); + } + } + let cause = if let Some(reference) = cause_reference.as_ref() { + if reference == &raw_head { + Some((raw_event.clone(), admission.clone())) + } else { + Some(load_and_validate_stored_event(connection, reference).await?) + } + } else { + None + }; + validate_incremental_cause( + connection, + source_generation, + origin, + &coordinate, + &raw_head, + cause_reference.as_ref(), + cause.as_ref(), + suppression.as_ref(), + raw_head_decision, + ) + .await?; + let current_state = TransitionStateSnapshot { + raw_head: raw_head.clone(), + raw_head_created_at, + admission_status, + admission_code: admission_code.clone(), + contract_id: contract_id.clone(), + visibility, + suppression: suppression.clone(), + }; + validate_retraction_lineage( + connection, + source_generation, + transition_seq, + origin, + &coordinate, + &current_state, + retracted_event.as_ref(), + ) + .await?; + let cause_event = cause + .map(|(event, admission)| { + let event_reference = cause_reference + .clone() + .ok_or_else(|| corruption("loaded transition cause has no reference"))?; + let pubkey = RadrootsPublicKey::parse(event.pubkey.as_str()).map_err(|error| { + corruption(format!("transition cause pubkey is invalid: {error}")) + })?; + Ok::<RadrootsAddressableTransitionCauseV1, RadrootsEventStoreError>( + RadrootsAddressableTransitionCauseV1 { + event: event_reference, + pubkey, + created_at: event.created_at, + kind: event.kind, + admission_status: admission.status, + admission_code: admission.code, + contract_id: admission.contract.map(|contract| contract.id.to_owned()), + }, + ) + }) + .transpose()?; + + Ok(RadrootsAddressableTransitionV1 { + transition_seq, + source_generation, + origin, + coordinate, + raw_head, + raw_head_created_at, + visible_event, + retracted_event, + admission_status, + admission_code, + contract_id, + visibility, + suppression, + cause_event, + raw_head_decision, + }) +} + +#[derive(PartialEq, Eq)] +struct TransitionStateSnapshot { + raw_head: RadrootsAddressableTransitionEventReferenceV1, + raw_head_created_at: u64, + admission_status: RadrootsEventAdmissionStatus, + admission_code: Option<String>, + contract_id: Option<String>, + visibility: RadrootsAddressableTransitionVisibilityV1, + suppression: Option<RadrootsNip09SuppressionEvidenceV1>, +} + +#[allow(clippy::too_many_arguments)] +async fn validate_incremental_cause( + connection: &mut SqliteConnection, + generation: RadrootsEventStoreSourceGeneration, + origin: RadrootsAddressableTransitionOriginV1, + coordinate: &RadrootsAddressableTransitionCoordinateV1, + raw_head: &RadrootsAddressableTransitionEventReferenceV1, + cause_reference: Option<&RadrootsAddressableTransitionEventReferenceV1>, + cause: Option<&(RadrootsStoredRawEvent, EventAdmission)>, + suppression: Option<&RadrootsNip09SuppressionEvidenceV1>, + decision: RadrootsAddressableTransitionRawHeadDecisionV1, +) -> Result<(), RadrootsEventStoreError> { + if origin == RadrootsAddressableTransitionOriginV1::Baseline { + return Ok(()); + } + let cause_reference = cause_reference + .ok_or_else(|| corruption("incremental transition has no cause reference"))?; + let (cause_event, cause_admission) = + cause.ok_or_else(|| corruption("incremental transition cause could not be loaded"))?; + match decision { + RadrootsAddressableTransitionRawHeadDecisionV1::Applied => { + if cause_reference != raw_head { + return Err(corruption( + "applied incremental transition cause is not its new raw head", + )); + } + } + RadrootsAddressableTransitionRawHeadDecisionV1::NotHeadSelected => { + if cause_event.kind != 5 + || cause_admission.status != RadrootsEventAdmissionStatus::Admitted + { + return Err(corruption( + "non-head incremental transition was not caused by an admitted deletion request", + )); + } + let author_matches = cause_event.pubkey == coordinate.pubkey().as_str(); + let records_author_mismatch = suppression.is_some_and(|evidence| { + evidence.reason() + == crate::model::RadrootsNip09SuppressionReason::RequestAuthorMismatch + }); + if author_matches == records_author_mismatch { + return Err(corruption( + "deletion cause author does not agree with suppression evidence", + )); + } + let targeted: i64 = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM radroots_event_store_nip09_event_target WHERE source_generation = ? AND request_event_id = ? AND target_event_id = ?) OR EXISTS(SELECT 1 FROM radroots_event_store_nip09_address_target WHERE source_generation = ? AND request_event_id = ? AND target_kind = ? AND target_pubkey = ? AND target_d_tag = ?)", + ) + .bind(generation.as_bytes().as_slice()) + .bind(cause_reference.event_id().as_str()) + .bind(raw_head.event_id().as_str()) + .bind(generation.as_bytes().as_slice()) + .bind(cause_reference.event_id().as_str()) + .bind(i64::from(coordinate.kind())) + .bind(coordinate.pubkey().as_str()) + .bind(coordinate.d_tag()) + .fetch_one(&mut *connection) + .await?; + if targeted != 1 { + return Err(corruption( + "deletion cause does not target the transitioned coordinate", + )); + } + } + RadrootsAddressableTransitionRawHeadDecisionV1::BaselineRebuild + | RadrootsAddressableTransitionRawHeadDecisionV1::SkippedOlder + | RadrootsAddressableTransitionRawHeadDecisionV1::SkippedSameTimestampHigherEventId + | RadrootsAddressableTransitionRawHeadDecisionV1::MalformedCoordinate => { + return Err(corruption(format!( + "raw-head decision `{}` cannot emit an incremental transition", + decision.as_str() + ))); + } + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn validate_retraction_lineage( + connection: &mut SqliteConnection, + generation: RadrootsEventStoreSourceGeneration, + transition_seq: i64, + origin: RadrootsAddressableTransitionOriginV1, + coordinate: &RadrootsAddressableTransitionCoordinateV1, + current: &TransitionStateSnapshot, + retracted: Option<&RadrootsAddressableTransitionEventReferenceV1>, +) -> Result<(), RadrootsEventStoreError> { + let prior = sqlx::query( + "SELECT raw_head_event_id, raw_head_event_seq, raw_head_created_at, admission_status, admission_code, contract_id, visibility, nip09_outcome, nip09_reason, event_reference_request_id, address_reference_request_id, address_reference_cutoff FROM radroots_event_store_addressable_head_transition WHERE source_generation = ? AND kind = ? AND pubkey = ? AND d_tag = ? AND transition_seq < ? ORDER BY transition_seq DESC LIMIT 1", + ) + .bind(generation.as_bytes().as_slice()) + .bind(i64::from(coordinate.kind())) + .bind(coordinate.pubkey().as_str()) + .bind(coordinate.d_tag()) + .bind(transition_seq) + .fetch_optional(&mut *connection) + .await?; + let expected = if let Some(prior) = prior { + if origin == RadrootsAddressableTransitionOriginV1::Baseline { + return Err(corruption( + "baseline transition follows existing coordinate state", + )); + } + let prior_state = TransitionStateSnapshot { + raw_head: required_reference( + "prior_raw_head", + prior.try_get("raw_head_event_id")?, + prior.try_get("raw_head_event_seq")?, + )?, + raw_head_created_at: u64_from_i64( + "prior_transition.raw_head_created_at", + prior.try_get("raw_head_created_at")?, + ) + .map_err(|error| corruption(error.to_string()))?, + admission_status: RadrootsEventAdmissionStatus::parse( + prior.try_get::<String, _>("admission_status")?.as_str(), + ) + .map_err(|error| corruption(error.to_string()))?, + admission_code: prior.try_get("admission_code")?, + contract_id: prior.try_get("contract_id")?, + visibility: RadrootsAddressableTransitionVisibilityV1::parse( + prior.try_get::<String, _>("visibility")?.as_str(), + ) + .map_err(|error| corruption(error.to_string()))?, + suppression: suppression_evidence_from_transition_row(&prior)?, + }; + if prior_state == *current { + return Err(corruption( + "incremental transition repeats the complete prior state", + )); + } + (prior_state.visibility == RadrootsAddressableTransitionVisibilityV1::Visible + && (current.visibility != RadrootsAddressableTransitionVisibilityV1::Visible + || prior_state.raw_head != current.raw_head)) + .then_some(prior_state.raw_head) + } else { + if origin == RadrootsAddressableTransitionOriginV1::Baseline && retracted.is_some() { + return Err(corruption("baseline transition retracts prior state")); + } + None + }; + if expected.as_ref() != retracted { + return Err(corruption( + "transition retraction does not match the immediately preceding visible state", + )); + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn validate_transition_shape( + origin: RadrootsAddressableTransitionOriginV1, + raw_head: &RadrootsAddressableTransitionEventReferenceV1, + visible_event: Option<&RadrootsAddressableTransitionEventReferenceV1>, + retracted_event: Option<&RadrootsAddressableTransitionEventReferenceV1>, + admission_status: RadrootsEventAdmissionStatus, + admission_code: Option<&str>, + contract_id: Option<&str>, + visibility: RadrootsAddressableTransitionVisibilityV1, + suppression: Option<&RadrootsNip09SuppressionEvidenceV1>, + cause_event: Option<&RadrootsAddressableTransitionEventReferenceV1>, + raw_head_decision: RadrootsAddressableTransitionRawHeadDecisionV1, + raw_head_created_at: u64, +) -> Result<(), RadrootsEventStoreError> { + let origin_valid = match origin { + RadrootsAddressableTransitionOriginV1::Baseline => { + cause_event.is_none() + && retracted_event.is_none() + && raw_head_decision + == RadrootsAddressableTransitionRawHeadDecisionV1::BaselineRebuild + } + RadrootsAddressableTransitionOriginV1::Incremental => { + cause_event.is_some() + && raw_head_decision + != RadrootsAddressableTransitionRawHeadDecisionV1::BaselineRebuild + } + }; + let admission_valid = match admission_status { + RadrootsEventAdmissionStatus::Admitted => admission_code.is_none() && contract_id.is_some(), + RadrootsEventAdmissionStatus::Unsupported | RadrootsEventAdmissionStatus::Invalid => { + admission_code.is_some() && contract_id.is_none() + } + }; + let visibility_valid = match visibility { + RadrootsAddressableTransitionVisibilityV1::Visible => { + admission_status == RadrootsEventAdmissionStatus::Admitted + && visible_event == Some(raw_head) + && suppression.is_some_and(|evidence| { + evidence.outcome() == RadrootsNip09SuppressionOutcome::Visible + }) + } + RadrootsAddressableTransitionVisibilityV1::NotAdmitted => { + admission_status != RadrootsEventAdmissionStatus::Admitted + && visible_event.is_none() + && suppression.is_none() + } + RadrootsAddressableTransitionVisibilityV1::Suppressed => { + admission_status == RadrootsEventAdmissionStatus::Admitted + && visible_event.is_none() + && suppression.is_some_and(|evidence| { + evidence.outcome() == RadrootsNip09SuppressionOutcome::Suppressed + }) + } + }; + if !origin_valid || !admission_valid || !visibility_valid { + return Err(corruption( + "transition fields have an incoherent decision shape", + )); + } + if let (Some(visible), Some(retracted)) = (visible_event, retracted_event) + && visible == retracted + { + return Err(corruption("transition retracts the event it makes visible")); + } + if let Some(evidence) = suppression { + validate_suppression_shape(evidence, raw_head_created_at)?; + } + Ok(()) +} + +fn validate_suppression_shape( + evidence: &RadrootsNip09SuppressionEvidenceV1, + raw_head_created_at: u64, +) -> Result<(), RadrootsEventStoreError> { + if !evidence.is_coherent_for_event(30_000, raw_head_created_at) { + return Err(corruption( + "suppression evidence is internally inconsistent", + )); + } + Ok(()) +} + +fn suppression_evidence_from_transition_row( + row: &sqlx::sqlite::SqliteRow, +) -> Result<Option<RadrootsNip09SuppressionEvidenceV1>, RadrootsEventStoreError> { + let outcome: Option<String> = row.try_get("nip09_outcome")?; + let reason: Option<String> = row.try_get("nip09_reason")?; + let event_reference_request_id = optional_event_id( + "event_reference_request_id", + row.try_get("event_reference_request_id")?, + )?; + let address_reference_request_id = optional_event_id( + "address_reference_request_id", + row.try_get("address_reference_request_id")?, + )?; + let address_reference_cutoff = row + .try_get::<Option<i64>, _>("address_reference_cutoff")? + .map(|value| u64_from_i64("transition.address_reference_cutoff", value)) + .transpose() + .map_err(|error| corruption(error.to_string()))?; + match (outcome, reason) { + (Some(outcome), Some(reason)) => Ok(Some(RadrootsNip09SuppressionEvidenceV1 { + outcome: parse_suppression_outcome(outcome.as_str()) + .map_err(|error| corruption(error.to_string()))?, + reason: parse_suppression_reason(reason.as_str()) + .map_err(|error| corruption(error.to_string()))?, + event_reference_request_id, + address_reference_request_id, + address_reference_cutoff, + })), + (None, None) + if event_reference_request_id.is_none() + && address_reference_request_id.is_none() + && address_reference_cutoff.is_none() => + { + Ok(None) + } + _ => Err(corruption("transition has incomplete suppression evidence")), + } +} + +fn required_reference( + field: &'static str, + event_id: String, + event_seq: i64, +) -> Result<RadrootsAddressableTransitionEventReferenceV1, RadrootsEventStoreError> { + if event_seq <= 0 { + return Err(corruption(format!("{field} sequence is not positive"))); + } + Ok(RadrootsAddressableTransitionEventReferenceV1 { + event_id: parse_event_id(field, event_id)?, + event_seq, + }) +} + +fn optional_reference( + field: &'static str, + event_id: Option<String>, + event_seq: Option<i64>, +) -> Result<Option<RadrootsAddressableTransitionEventReferenceV1>, RadrootsEventStoreError> { + match (event_id, event_seq) { + (Some(event_id), Some(event_seq)) => { + required_reference(field, event_id, event_seq).map(Some) + } + (None, None) => Ok(None), + _ => Err(corruption(format!("{field} identity is partial"))), + } +} + +fn optional_event_id( + field: &'static str, + value: Option<String>, +) -> Result<Option<RadrootsEventId>, RadrootsEventStoreError> { + value.map(|value| parse_event_id(field, value)).transpose() +} + +fn parse_event_id( + field: &'static str, + value: String, +) -> Result<RadrootsEventId, RadrootsEventStoreError> { + RadrootsEventId::parse(value.as_str()) + .map_err(|error| corruption(format!("{field} event id is invalid: {error}"))) +} + +async fn load_and_validate_stored_event( + connection: &mut SqliteConnection, + reference: &RadrootsAddressableTransitionEventReferenceV1, +) -> Result<(RadrootsStoredRawEvent, EventAdmission), RadrootsEventStoreError> { + let row = sqlx::query( + "SELECT seq, event_id, pubkey, created_at, kind, tags_json, content, sig, raw_json, verification_status, contract_status, contract_id, event_class, projection_eligible, inserted_at_ms, updated_at_ms FROM event_envelopes WHERE seq = ? AND event_id = ?", + ) + .bind(reference.event_seq()) + .bind(reference.event_id().as_str()) + .fetch_optional(&mut *connection) + .await? + .ok_or_else(|| { + corruption(format!( + "referenced event `{}` at sequence {} is missing", + reference.event_id(), + reference.event_seq() + )) + })?; + let stored = stored_raw_event_from_row(row) + .map_err(|error| corruption(format!("stored raw event row is invalid: {error}")))?; + let reconstructed = RadrootsEventIngest::from_raw_json(stored.raw_json.clone(), 0) + .map_err(|error| corruption(format!("stored raw event cannot be reverified: {error}")))?; + let event = reconstructed.event(); + let tags_json = serde_json::to_string(&event.tags_as_vec()).map_err(|error| { + corruption(format!( + "stored raw event tags cannot be canonicalized: {error}" + )) + })?; + if stored.event_id != event.id_str() + || stored.pubkey != event.author_str() + || stored.created_at != event.created_at_u64() + || stored.kind != event.kind_u32() + || stored.tags_json != tags_json + || stored.content != event.content() + || stored.sig != event.sig_str() + { + return Err(corruption(format!( + "stored event `{}` disagrees with its signed raw JSON", + reference.event_id() + ))); + } + let admission = EventAdmission::for_profile( + ReconciliationProfile::Nip09V1RegistryV7, + reconstructed.verified_event(), + ) + .map_err(|error| corruption(format!("stored raw event cannot be admitted: {error}")))?; + if admission.status != stored.admission_status + || admission.contract.map(|contract| contract.id) != stored.contract_id.as_deref() + || admission.valid_stream_eligible(event.kind_class()) != stored.valid_stream_eligible + { + return Err(corruption(format!( + "stored event `{}` disagrees with registry-v7 admission", + reference.event_id() + ))); + } + Ok((stored, admission)) +} + +async fn validate_addressable_reference( + connection: &mut SqliteConnection, + generation: RadrootsEventStoreSourceGeneration, + coordinate: &RadrootsAddressableTransitionCoordinateV1, + reference: &RadrootsAddressableTransitionEventReferenceV1, + event: &RadrootsStoredRawEvent, +) -> Result<(), RadrootsEventStoreError> { + if event.event_class != StoredEventClass::Addressable + || event.kind != coordinate.kind() + || event.pubkey != coordinate.pubkey().as_str() + { + return Err(corruption(format!( + "event `{}` does not match transition coordinate `{}:{}:{}`", + reference.event_id(), + coordinate.kind(), + coordinate.pubkey(), + coordinate.d_tag() + ))); + } + let exists: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_event_coordinate WHERE source_generation = ? AND event_seq = ? AND event_id = ? AND coordinate_type = 'addressable' AND kind = ? AND pubkey = ? AND raw_d_tag = ?", + ) + .bind(generation.as_bytes().as_slice()) + .bind(reference.event_seq()) + .bind(reference.event_id().as_str()) + .bind(i64::from(coordinate.kind())) + .bind(coordinate.pubkey().as_str()) + .bind(coordinate.d_tag()) + .fetch_one(&mut *connection) + .await?; + if exists != 1 { + return Err(corruption(format!( + "event `{}` has no matching addressable coordinate authority", + reference.event_id() + ))); + } + Ok(()) +} + +fn corruption(reason: impl Into<String>) -> RadrootsEventStoreError { + RadrootsEventStoreError::AddressableTransitionCorruption { + reason: reason.into(), + } +} diff --git a/crates/event_store/src/store/current_visibility_v1.rs b/crates/event_store/src/store/current_visibility_v1.rs @@ -0,0 +1,345 @@ +use super::protocol_storage_v1::stored_raw_event_from_row; +use super::{RadrootsEventStore, bool_from_i64}; +use crate::RadrootsEventStoreError; +use crate::model::{ + RadrootsCurrentEventVisibilityV1, RadrootsCurrentVisibilityDecisionV1, + RadrootsNip09SuppressionEvidenceV1, RadrootsNip09SuppressionOutcome, + RadrootsNip09SuppressionReason, StoredEventClass, +}; +use crate::nip09::reconciliation_v1::generation_from_blob; +use radroots_event::ids::RadrootsEventId; +use sqlx::{Row, Sqlite, Transaction}; + +impl RadrootsEventStore { + pub async fn current_event_visibility_v1( + &self, + event_id: &str, + ) -> Result<Option<RadrootsCurrentEventVisibilityV1>, RadrootsEventStoreError> { + let mut tx = self.pool.begin().await?; + let result = current_visibility_in_transaction(&mut tx, event_id).await?; + tx.commit().await?; + Ok(result) + } +} + +pub(super) async fn current_visibility_in_transaction( + tx: &mut Transaction<'_, Sqlite>, + event_id: &str, +) -> Result<Option<RadrootsCurrentEventVisibilityV1>, RadrootsEventStoreError> { + let event_exists: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM event_envelopes WHERE event_id = ?") + .bind(event_id) + .fetch_one(&mut **tx) + .await?; + if event_exists == 0 { + return Ok(None); + } + let row = sqlx::query( + "SELECT event.seq, event.event_id, event.pubkey, event.created_at, event.kind, event.tags_json, event.content, event.sig, event.raw_json, event.verification_status, event.contract_status, event.contract_id, event.event_class, event.projection_eligible, event.inserted_at_ms, event.updated_at_ms, visibility.source_generation, visibility.raw_d_tag, visibility.is_raw_head, visibility.raw_head_event_id, visibility.suppression_outcome, visibility.suppression_reason, visibility.event_reference_request_id, visibility.address_reference_request_id, visibility.address_reference_cutoff, visibility.current_visibility FROM radroots_event_store_current_visibility_v1 AS visibility JOIN event_envelopes AS event ON event.event_id = visibility.event_id WHERE visibility.event_id = ?", + ) + .bind(event_id) + .fetch_optional(&mut **tx) + .await?; + let row = row.ok_or_else(|| RadrootsEventStoreError::CurrentVisibilityDrift { + reason: format!("stored event `{event_id}` has no current-visibility authority"), + })?; + + let source_generation = generation_from_blob(row.try_get("source_generation")?) + .map_err(|error| visibility_authority_error("source generation", error))?; + let raw_d_tag: Option<String> = row.try_get("raw_d_tag")?; + let is_raw_head = bool_from_i64( + "current_visibility.is_raw_head", + row.try_get("is_raw_head")?, + ) + .map_err(|error| visibility_authority_error("raw-head marker", error))?; + let raw_head_event_id = row + .try_get::<Option<String>, _>("raw_head_event_id")? + .map(RadrootsEventId::parse) + .transpose() + .map_err(|error| visibility_authority_error("raw-head event id", error))?; + let decision = RadrootsCurrentVisibilityDecisionV1::parse( + row.try_get::<String, _>("current_visibility")?.as_str(), + ) + .map_err(|error| visibility_authority_error("visibility decision", error))?; + let suppression = suppression_evidence_from_row(&row) + .map_err(|error| visibility_authority_error("suppression evidence", error))?; + let event = stored_raw_event_from_row(row) + .map_err(|error| visibility_authority_error("stored raw event", error))?; + let visibility = RadrootsCurrentEventVisibilityV1 { + source_generation, + event, + is_raw_head, + raw_head_event_id, + suppression, + decision, + }; + validate_visibility_shape(&visibility)?; + validate_addressable_head_projection(tx, &visibility, raw_d_tag.as_deref()).await?; + Ok(Some(visibility)) +} + +pub(super) fn suppression_evidence_from_row( + row: &sqlx::sqlite::SqliteRow, +) -> Result<Option<RadrootsNip09SuppressionEvidenceV1>, RadrootsEventStoreError> { + let outcome: Option<String> = row.try_get("suppression_outcome")?; + let reason: Option<String> = row.try_get("suppression_reason")?; + let event_reference_request_id = row + .try_get::<Option<String>, _>("event_reference_request_id")? + .map(RadrootsEventId::parse) + .transpose() + .map_err(|error| visibility_authority_error("event deletion request id", error))?; + let address_reference_request_id = row + .try_get::<Option<String>, _>("address_reference_request_id")? + .map(RadrootsEventId::parse) + .transpose() + .map_err(|error| visibility_authority_error("address deletion request id", error))?; + let address_reference_cutoff = row + .try_get::<Option<i64>, _>("address_reference_cutoff")? + .map(|value| { + u64::try_from(value).map_err(|_| RadrootsEventStoreError::IntegerRange { + field: "current_visibility.address_reference_cutoff", + value, + }) + }) + .transpose() + .map_err(|error| visibility_authority_error("address deletion cutoff", error))?; + match (outcome, reason) { + (Some(outcome), Some(reason)) => Ok(Some(RadrootsNip09SuppressionEvidenceV1 { + outcome: parse_suppression_outcome(outcome.as_str()) + .map_err(|error| visibility_authority_error("suppression outcome", error))?, + reason: parse_suppression_reason(reason.as_str()) + .map_err(|error| visibility_authority_error("suppression reason", error))?, + event_reference_request_id, + address_reference_request_id, + address_reference_cutoff, + })), + (None, None) + if event_reference_request_id.is_none() + && address_reference_request_id.is_none() + && address_reference_cutoff.is_none() => + { + Ok(None) + } + _ => current_visibility_drift("suppression evidence is incomplete"), + } +} + +pub(super) fn parse_suppression_outcome( + value: &str, +) -> Result<RadrootsNip09SuppressionOutcome, RadrootsEventStoreError> { + match value { + "visible" => Ok(RadrootsNip09SuppressionOutcome::Visible), + "suppressed" => Ok(RadrootsNip09SuppressionOutcome::Suppressed), + _ => Err(RadrootsEventStoreError::InvalidStoredEnum { + field: "nip09.suppression_outcome", + value: value.to_owned(), + }), + } +} + +pub(super) fn parse_suppression_reason( + value: &str, +) -> Result<RadrootsNip09SuppressionReason, RadrootsEventStoreError> { + match value { + "deletion_request_immune" => Ok(RadrootsNip09SuppressionReason::DeletionRequestImmune), + "deletion_no_authorized_reference" => { + Ok(RadrootsNip09SuppressionReason::NoAuthorizedReference) + } + "deletion_request_author_mismatch" => { + Ok(RadrootsNip09SuppressionReason::RequestAuthorMismatch) + } + "deletion_address_cutoff_precedes_target" => { + Ok(RadrootsNip09SuppressionReason::AddressCutoffPrecedesTarget) + } + "deletion_event_id_reference" => Ok(RadrootsNip09SuppressionReason::EventIdReference), + "deletion_address_reference" => { + Ok(RadrootsNip09SuppressionReason::AddressReferenceAtOrBeforeCutoff) + } + "deletion_event_id_and_address_reference" => { + Ok(RadrootsNip09SuppressionReason::EventIdAndAddressReference) + } + _ => Err(RadrootsEventStoreError::InvalidStoredEnum { + field: "nip09.suppression_reason", + value: value.to_owned(), + }), + } +} + +fn validate_visibility_shape( + visibility: &RadrootsCurrentEventVisibilityV1, +) -> Result<(), RadrootsEventStoreError> { + let event_id = visibility.event.event_id.as_str(); + if visibility.event.event_class == StoredEventClass::Ephemeral { + return current_visibility_drift(format!( + "persisted ephemeral event `{event_id}` entered current visibility" + )); + } + let regular = visibility.event.event_class == StoredEventClass::Regular; + if regular && (visibility.raw_head_event_id.is_some() || !visibility.is_raw_head) { + return current_visibility_drift(format!( + "event `{event_id}` has inconsistent raw-head identity" + )); + } + if !regular + && visibility.is_raw_head + && visibility + .raw_head_event_id + .as_ref() + .is_none_or(|raw_head_event_id| raw_head_event_id.as_str() != event_id) + { + return current_visibility_drift(format!( + "event `{event_id}` is marked as the raw head without matching head identity" + )); + } + if !regular + && visibility.raw_head_event_id.is_none() + && visibility.decision != RadrootsCurrentVisibilityDecisionV1::NotAdmitted + { + return current_visibility_drift(format!( + "event `{event_id}` has no coordinate head but is not classified as not admitted" + )); + } + let evidence = visibility.suppression.as_ref(); + if evidence.is_some_and(|value| { + !value.is_coherent_for_event(visibility.event.kind, visibility.event.created_at) + }) { + return current_visibility_drift(format!( + "event `{event_id}` has incoherent suppression evidence" + )); + } + let valid = match visibility.decision { + RadrootsCurrentVisibilityDecisionV1::Visible => { + visibility.event.admission_status + == crate::model::RadrootsEventAdmissionStatus::Admitted + && visibility.is_raw_head + && evidence + .is_some_and(|value| value.outcome == RadrootsNip09SuppressionOutcome::Visible) + } + RadrootsCurrentVisibilityDecisionV1::NotAdmitted => { + visibility.event.admission_status + != crate::model::RadrootsEventAdmissionStatus::Admitted + && evidence.is_none() + } + RadrootsCurrentVisibilityDecisionV1::NotCurrent => { + visibility.event.admission_status + == crate::model::RadrootsEventAdmissionStatus::Admitted + && !visibility.is_raw_head + && visibility.raw_head_event_id.is_some() + && evidence.is_some() + } + RadrootsCurrentVisibilityDecisionV1::Suppressed => { + visibility.event.admission_status + == crate::model::RadrootsEventAdmissionStatus::Admitted + && visibility.is_raw_head + && evidence.is_some_and(|value| { + value.outcome == RadrootsNip09SuppressionOutcome::Suppressed + }) + } + }; + if !valid { + return current_visibility_drift(format!( + "event `{event_id}` has an incoherent visibility decision" + )); + } + Ok(()) +} + +async fn validate_addressable_head_projection( + tx: &mut Transaction<'_, Sqlite>, + visibility: &RadrootsCurrentEventVisibilityV1, + raw_d_tag: Option<&str>, +) -> Result<(), RadrootsEventStoreError> { + if visibility.event.event_class != StoredEventClass::Addressable || !visibility.is_raw_head { + return Ok(()); + } + let raw_d_tag = raw_d_tag.ok_or_else(|| RadrootsEventStoreError::CurrentVisibilityDrift { + reason: format!( + "addressable event `{}` has no raw d tag", + visibility.event.event_id + ), + })?; + let row = sqlx::query( + "SELECT state.raw_head_event_id, state.raw_head_event_seq, state.raw_head_created_at, state.admission_status, state.admission_code, coordinate.admission_code AS coordinate_admission_code, state.contract_id, state.visibility, state.nip09_outcome, state.nip09_reason, state.event_reference_request_id, state.address_reference_request_id, state.address_reference_cutoff FROM radroots_event_store_addressable_head_state AS state JOIN radroots_event_store_event_coordinate AS coordinate ON coordinate.source_generation = state.source_generation AND coordinate.event_seq = state.raw_head_event_seq AND coordinate.event_id = state.raw_head_event_id AND coordinate.coordinate_type = 'addressable' AND coordinate.kind = state.kind AND coordinate.pubkey = state.pubkey AND coordinate.raw_d_tag = state.d_tag WHERE state.source_generation = ? AND state.kind = ? AND state.pubkey = ? AND state.d_tag = ?", + ) + .bind(visibility.source_generation.as_bytes().as_slice()) + .bind(i64::from(visibility.event.kind)) + .bind(visibility.event.pubkey.as_str()) + .bind(raw_d_tag) + .fetch_optional(&mut **tx) + .await?; + let Some(row) = row else { + return current_visibility_drift(format!( + "addressable head state is missing for `{}`", + visibility.event.event_id + )); + }; + let evidence = visibility.suppression.as_ref(); + let stored_cutoff = row + .try_get::<Option<i64>, _>("address_reference_cutoff")? + .map(|value| { + u64::try_from(value).map_err(|_| RadrootsEventStoreError::IntegerRange { + field: "addressable_head_state.address_reference_cutoff", + value, + }) + }) + .transpose() + .map_err(|error| visibility_authority_error("stored address deletion cutoff", error))?; + if row.try_get::<String, _>("raw_head_event_id")? != visibility.event.event_id + || row.try_get::<i64, _>("raw_head_event_seq")? != visibility.event.seq + || row.try_get::<i64, _>("raw_head_created_at")? + != i64::try_from(visibility.event.created_at).map_err(|_| { + RadrootsEventStoreError::CurrentVisibilityDrift { + reason: format!( + "addressable event `{}` timestamp is outside SQLite range", + visibility.event.event_id + ), + } + })? + || row.try_get::<String, _>("admission_status")? + != visibility.event.admission_status.as_str() + || row.try_get::<Option<String>, _>("admission_code")? + != row.try_get::<Option<String>, _>("coordinate_admission_code")? + || row.try_get::<Option<String>, _>("contract_id")? != visibility.event.contract_id + || row.try_get::<String, _>("visibility")? != visibility.decision.as_str() + || row + .try_get::<Option<String>, _>("nip09_outcome")? + .as_deref() + != evidence.map(|value| value.outcome.code()) + || row.try_get::<Option<String>, _>("nip09_reason")?.as_deref() + != evidence.map(|value| value.reason.code()) + || row + .try_get::<Option<String>, _>("event_reference_request_id")? + .as_deref() + != evidence + .and_then(|value| value.event_reference_request_id.as_ref()) + .map(RadrootsEventId::as_str) + || row + .try_get::<Option<String>, _>("address_reference_request_id")? + .as_deref() + != evidence + .and_then(|value| value.address_reference_request_id.as_ref()) + .map(RadrootsEventId::as_str) + || stored_cutoff != evidence.and_then(|value| value.address_reference_cutoff) + { + return current_visibility_drift(format!( + "central visibility disagrees with addressable head state for `{}`", + visibility.event.event_id + )); + } + Ok(()) +} + +fn current_visibility_drift<T>(reason: impl Into<String>) -> Result<T, RadrootsEventStoreError> { + Err(RadrootsEventStoreError::CurrentVisibilityDrift { + reason: reason.into(), + }) +} + +fn visibility_authority_error( + context: &'static str, + error: impl core::fmt::Display, +) -> RadrootsEventStoreError { + RadrootsEventStoreError::CurrentVisibilityDrift { + reason: format!("{context} is invalid: {error}"), + } +} diff --git a/crates/event_store/src/store/food_availability_projection_v1.rs b/crates/event_store/src/store/food_availability_projection_v1.rs @@ -0,0 +1,952 @@ +use super::addressable_transition_feed_v1::addressable_transition_page_in_transaction_v1; +use super::{ + RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventStore, bool_from_i64, u64_from_i64, +}; +use crate::RadrootsEventStoreError; +use crate::generated::food_availability_projection_manifest as food_manifest; +use crate::model::{ + RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1, + RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1, RadrootsAddressableTransitionCursorV1, + RadrootsAddressableTransitionScopeV1, RadrootsAddressableTransitionV1, + RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventStoreSourceGeneration, + RadrootsFoodAvailabilitySearchQueryV1, RadrootsFoodAvailabilityStatusFilterV1, + RadrootsStoredFoodAvailabilityImageV1, RadrootsStoredFoodAvailabilityV1, +}; +use crate::nip09::reconciliation_v1::{ + EventAdmission, ReconciliationProfile, generation_from_blob, +}; +use radroots_event::food_availability::RadrootsFoodIdentifier; +use radroots_event::ids::{RadrootsEventId, RadrootsPublicKey}; +use radroots_event_codec::food_availability::inbound::{ + RadrootsFoodAvailabilityImageDiagnostic, RadrootsFoodAvailabilityProjectionOutcome, + project_verified_food_availability_event_registry_v7, +}; +use serde::Deserialize; +use sqlx::{Row, SqliteConnection}; + +#[cfg(test)] +use std::sync::Arc; +#[cfg(test)] +use tokio::sync::Notify; + +#[cfg(test)] +tokio::task_local! { + pub(super) static FOOD_AVAILABILITY_AUDIT_FTS_CHECKPOINT: (Arc<Notify>, Arc<Notify>); +} + +const FOOD_AVAILABILITY_CONTRACT_ID: &str = "radroots.food.availability.v1"; +pub(super) const FOOD_AVAILABILITY_POINT_QUERY_V1: &str = "SELECT projection.source_generation, projection.pubkey, projection.d_tag, projection.event_id, projection.event_seq, projection.created_at, projection.contract_id, projection.content, projection.title, projection.summary, projection.published_at, projection.location, projection.price_amount, projection.price_currency, projection.price_unit, projection.quantity_amount, projection.quantity_unit, projection.status, projection.diagnostic_codes_json, projection.source_transition_seq, projection.immutable_raw_json, projection.stored_images_json FROM radroots_event_store_food_availability_read_v1 AS projection JOIN radroots_event_store_source_state AS source ON source.singleton = 1 AND source.active_generation = projection.source_generation JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.singleton = 1 AND cursor.source_generation = projection.source_generation JOIN radroots_event_store_addressable_head_state AS head ON head.source_generation = projection.source_generation AND head.kind = 30402 AND head.pubkey = projection.pubkey AND head.d_tag = projection.d_tag AND head.raw_head_event_id = projection.event_id AND head.raw_head_event_seq = projection.event_seq AND head.raw_head_created_at = projection.created_at AND head.admission_status = 'admitted' AND head.admission_code IS NULL AND head.contract_id = projection.contract_id AND head.visibility = 'visible' AND head.nip09_outcome = 'visible' WHERE projection.pubkey = ? AND projection.d_tag = ?"; +pub(super) const FOOD_AVAILABILITY_RECENT_QUERY_V1: &str = "SELECT projection.source_generation, projection.pubkey, projection.d_tag, projection.event_id, projection.event_seq, projection.created_at, projection.contract_id, projection.content, projection.title, projection.summary, projection.published_at, projection.location, projection.price_amount, projection.price_currency, projection.price_unit, projection.quantity_amount, projection.quantity_unit, projection.status, projection.diagnostic_codes_json, projection.source_transition_seq, projection.immutable_raw_json, projection.stored_images_json FROM radroots_event_store_source_state AS source CROSS JOIN radroots_event_store_food_availability_read_v1 AS projection ON source.singleton = 1 AND source.active_generation = projection.source_generation CROSS JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.singleton = 1 AND cursor.source_generation = projection.source_generation CROSS JOIN radroots_event_store_addressable_head_state AS head ON head.source_generation = projection.source_generation AND head.kind = 30402 AND head.pubkey = projection.pubkey AND head.d_tag = projection.d_tag AND head.raw_head_event_id = projection.event_id AND head.raw_head_event_seq = projection.event_seq AND head.raw_head_created_at = projection.created_at AND head.admission_status = 'admitted' AND head.admission_code IS NULL AND head.contract_id = projection.contract_id AND head.visibility = 'visible' AND head.nip09_outcome = 'visible' ORDER BY projection.published_at DESC, projection.event_id ASC LIMIT ?"; +pub(super) const FOOD_AVAILABILITY_RECENT_STATUS_QUERY_V1: &str = "SELECT projection.source_generation, projection.pubkey, projection.d_tag, projection.event_id, projection.event_seq, projection.created_at, projection.contract_id, projection.content, projection.title, projection.summary, projection.published_at, projection.location, projection.price_amount, projection.price_currency, projection.price_unit, projection.quantity_amount, projection.quantity_unit, projection.status, projection.diagnostic_codes_json, projection.source_transition_seq, projection.immutable_raw_json, projection.stored_images_json FROM radroots_event_store_food_availability_read_v1 AS projection JOIN radroots_event_store_source_state AS source ON source.singleton = 1 AND source.active_generation = projection.source_generation JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.singleton = 1 AND cursor.source_generation = projection.source_generation JOIN radroots_event_store_addressable_head_state AS head ON head.source_generation = projection.source_generation AND head.kind = 30402 AND head.pubkey = projection.pubkey AND head.d_tag = projection.d_tag AND head.raw_head_event_id = projection.event_id AND head.raw_head_event_seq = projection.event_seq AND head.raw_head_created_at = projection.created_at AND head.admission_status = 'admitted' AND head.admission_code IS NULL AND head.contract_id = projection.contract_id AND head.visibility = 'visible' AND head.nip09_outcome = 'visible' WHERE projection.status = ? ORDER BY projection.published_at DESC, projection.event_id ASC LIMIT ?"; +pub(super) const FOOD_AVAILABILITY_SEARCH_QUERY_V1: &str = "SELECT projection.source_generation, projection.pubkey, projection.d_tag, projection.event_id, projection.event_seq, projection.created_at, projection.contract_id, projection.content, projection.title, projection.summary, projection.published_at, projection.location, projection.price_amount, projection.price_currency, projection.price_unit, projection.quantity_amount, projection.quantity_unit, projection.status, projection.diagnostic_codes_json, projection.source_transition_seq, projection.immutable_raw_json, projection.stored_images_json FROM radroots_event_store_food_availability_search_fts JOIN radroots_event_store_food_availability_read_v1 AS projection ON projection.event_seq = radroots_event_store_food_availability_search_fts.rowid JOIN radroots_event_store_source_state AS source ON source.singleton = 1 AND source.active_generation = projection.source_generation JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.singleton = 1 AND cursor.source_generation = projection.source_generation JOIN radroots_event_store_addressable_head_state AS head ON head.source_generation = projection.source_generation AND head.kind = 30402 AND head.pubkey = projection.pubkey AND head.d_tag = projection.d_tag AND head.raw_head_event_id = projection.event_id AND head.raw_head_event_seq = projection.event_seq AND head.raw_head_created_at = projection.created_at AND head.admission_status = 'admitted' AND head.admission_code IS NULL AND head.contract_id = projection.contract_id AND head.visibility = 'visible' AND head.nip09_outcome = 'visible' WHERE radroots_event_store_food_availability_search_fts MATCH ? AND (? IS NULL OR projection.status = ?) ORDER BY projection.published_at DESC, projection.event_id ASC LIMIT ?"; + +#[derive(Clone, Debug)] +struct FoodAvailabilityProjectionCursorState { + feed_cursor: RadrootsAddressableTransitionCursorV1, + projected_row_count: i64, +} + +impl RadrootsEventStore { + pub async fn food_availability_v1( + &self, + pubkey: &RadrootsPublicKey, + identifier: &RadrootsFoodIdentifier, + ) -> Result<Option<RadrootsStoredFoodAvailabilityV1>, RadrootsEventStoreError> { + let mut tx = self.pool.begin().await?; + validate_food_availability_projection_hook_state_fast_v1(&mut tx).await?; + let row = sqlx::query(FOOD_AVAILABILITY_POINT_QUERY_V1) + .bind(pubkey.as_str()) + .bind(identifier.as_str()) + .fetch_optional(&mut *tx) + .await?; + let result = match row { + Some(row) => Some(load_and_validate_projection_row(row)?), + None => None, + }; + tx.commit().await?; + Ok(result) + } + + pub async fn recent_food_availability_v1( + &self, + status: RadrootsFoodAvailabilityStatusFilterV1, + limit: u32, + ) -> Result<Vec<RadrootsStoredFoodAvailabilityV1>, RadrootsEventStoreError> { + validate_query_limit(limit)?; + let mut tx = self.pool.begin().await?; + validate_food_availability_projection_hook_state_fast_v1(&mut tx).await?; + let rows = match status.storage_value() { + None => { + sqlx::query(FOOD_AVAILABILITY_RECENT_QUERY_V1) + .bind(i64::from(limit)) + .fetch_all(&mut *tx) + .await? + } + Some(status) => { + sqlx::query(FOOD_AVAILABILITY_RECENT_STATUS_QUERY_V1) + .bind(status) + .bind(i64::from(limit)) + .fetch_all(&mut *tx) + .await? + } + }; + let result = load_and_validate_projection_rows(rows)?; + tx.commit().await?; + Ok(result) + } + + pub async fn search_food_availability_v1( + &self, + query: &RadrootsFoodAvailabilitySearchQueryV1, + status: RadrootsFoodAvailabilityStatusFilterV1, + limit: u32, + ) -> Result<Vec<RadrootsStoredFoodAvailabilityV1>, RadrootsEventStoreError> { + validate_query_limit(limit)?; + let mut tx = self.pool.begin().await?; + validate_food_availability_projection_hook_state_fast_v1(&mut tx).await?; + let rows = sqlx::query(FOOD_AVAILABILITY_SEARCH_QUERY_V1) + .bind(query.fts5_match_expression()) + .bind(status.storage_value()) + .bind(status.storage_value()) + .bind(i64::from(limit)) + .fetch_all(&mut *tx) + .await?; + let result = load_and_validate_projection_rows(rows)?; + tx.commit().await?; + Ok(result) + } + + /// Runs the exhaustive FoodAvailability projection and FTS integrity audit. + /// + /// Normal reads use the bounded seal check. Maintenance, rebuild, and + /// conformance paths should call this after all typed writes are complete. + pub async fn audit_food_availability_projection_v1( + &self, + ) -> Result<(), RadrootsEventStoreError> { + let mut tx = self.begin_write_transaction().await?; + validate_food_availability_projection_hook_v1(&mut tx).await?; + tx.commit().await?; + Ok(()) + } +} + +pub(crate) async fn apply_food_availability_projection_hook_v1( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsEventStoreError> { + apply_pending_food_availability_transitions_v1(connection).await?; + validate_food_availability_projection_hook_v1(connection).await +} + +pub(crate) async fn apply_pending_food_availability_transitions_v1( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsEventStoreError> { + let scope = RadrootsAddressableTransitionScopeV1::food_availability(); + let mut state = ensure_projection_cursor(connection, &scope).await?; + loop { + let page = addressable_transition_page_in_transaction_v1( + connection, + &scope, + Some(&state.feed_cursor), + RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1, + ) + .await?; + let mut projected_row_delta = 0_i64; + for transition in page.transitions() { + projected_row_delta = projected_row_delta + .checked_add(apply_transition(connection, transition).await?) + .ok_or_else(|| projection_drift("projection row-count delta overflowed"))?; + } + let next = page.next_cursor().clone(); + state = advance_projection_cursor(connection, &state, projected_row_delta, next).await?; + if !page.has_more() { + break; + } + } + Ok(()) +} + +async fn ensure_projection_cursor( + connection: &mut SqliteConnection, + scope: &RadrootsAddressableTransitionScopeV1, +) -> Result<FoodAvailabilityProjectionCursorState, RadrootsEventStoreError> { + let source = sqlx::query( + "SELECT source.active_generation, source.last_transition_seq, generation.transition_floor_seq, generation.addressable_feed_version FROM radroots_event_store_source_state AS source JOIN radroots_event_store_source_generation AS generation ON generation.source_generation = source.active_generation JOIN radroots_event_store_addressable_feed_integrity_v1 AS integrity ON integrity.source_generation = source.active_generation AND integrity.transition_floor_seq = generation.transition_floor_seq AND integrity.last_transition_seq = source.last_transition_seq WHERE source.singleton = 1", + ) + .fetch_optional(&mut *connection) + .await? + .ok_or_else(|| projection_drift("active source/feed authority is missing"))?; + let generation = projection_generation_from_blob( + source.try_get("active_generation")?, + "active source generation is invalid", + )?; + let floor: i64 = source.try_get("transition_floor_seq")?; + let feed_version: i64 = source.try_get("addressable_feed_version")?; + if feed_version != i64::from(RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1) { + return Err(projection_drift(format!( + "addressable feed version is {feed_version}" + ))); + } + + let existing = sqlx::query( + "SELECT source_generation, feed_version, projection_version, scope_fingerprint, hook_manifest_sha256, last_transition_seq, projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_optional(&mut *connection) + .await?; + if let Some(existing) = existing.as_ref() { + let existing_generation = projection_generation_from_blob( + existing.try_get("source_generation")?, + "stored cursor generation is invalid", + )?; + if existing_generation != generation { + sqlx::query("DELETE FROM radroots_event_store_food_availability_projection") + .execute(&mut *connection) + .await?; + let deleted = sqlx::query( + "DELETE FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .execute(&mut *connection) + .await?; + if deleted.rows_affected() != 1 { + return Err(projection_drift( + "generation reset did not delete exactly one projection cursor", + )); + } + } + } + + let existing = sqlx::query( + "SELECT source_generation, feed_version, projection_version, scope_fingerprint, hook_manifest_sha256, last_transition_seq, projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_optional(&mut *connection) + .await?; + if existing.is_none() { + let inserted = sqlx::query( + "INSERT INTO radroots_event_store_food_availability_cursor(singleton, source_generation, feed_version, projection_version, scope_fingerprint, hook_manifest_sha256, last_transition_seq, projected_row_count) VALUES (1, ?, ?, ?, ?, ?, ?, 0)", + ) + .bind(generation.as_bytes().as_slice()) + .bind(i64::from(RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1)) + .bind(i64::from(RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1)) + .bind(scope.fingerprint().as_bytes().as_slice()) + .bind(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256) + .bind(floor) + .execute(&mut *connection) + .await?; + if inserted.rows_affected() != 1 { + return Err(projection_drift( + "projection cursor initialization did not insert one row", + )); + } + } + + let row = sqlx::query( + "SELECT source_generation, feed_version, projection_version, scope_fingerprint, hook_manifest_sha256, last_transition_seq, projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(&mut *connection) + .await?; + validate_cursor_identity(&row, generation, scope)?; + let projected_row_count = row.try_get("projected_row_count")?; + validate_projected_row_count(projected_row_count)?; + Ok(FoodAvailabilityProjectionCursorState { + feed_cursor: RadrootsAddressableTransitionCursorV1::new( + generation, + scope.fingerprint(), + row.try_get("last_transition_seq")?, + )?, + projected_row_count, + }) +} + +fn validate_cursor_identity( + row: &sqlx::sqlite::SqliteRow, + generation: RadrootsEventStoreSourceGeneration, + scope: &RadrootsAddressableTransitionScopeV1, +) -> Result<(), RadrootsEventStoreError> { + let stored_generation = projection_generation_from_blob( + row.try_get("source_generation")?, + "stored cursor generation is invalid", + )?; + let scope_fingerprint: Vec<u8> = row.try_get("scope_fingerprint")?; + if stored_generation != generation + || row.try_get::<i64, _>("feed_version")? + != i64::from(RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1) + || row.try_get::<i64, _>("projection_version")? + != i64::from(RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1) + || scope_fingerprint.as_slice() != scope.fingerprint().as_bytes().as_slice() + || row.try_get::<String, _>("hook_manifest_sha256")? + != food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256 + { + return Err(projection_drift( + "projection cursor identity is inconsistent", + )); + } + validate_projected_row_count(row.try_get("projected_row_count")?)?; + Ok(()) +} + +async fn advance_projection_cursor( + connection: &mut SqliteConnection, + expected: &FoodAvailabilityProjectionCursorState, + projected_row_delta: i64, + next: RadrootsAddressableTransitionCursorV1, +) -> Result<FoodAvailabilityProjectionCursorState, RadrootsEventStoreError> { + let next_projected_row_count = expected + .projected_row_count + .checked_add(projected_row_delta) + .ok_or_else(|| projection_drift("projection row count overflowed"))?; + validate_projected_row_count(next_projected_row_count)?; + if next.last_transition_seq() == expected.feed_cursor.last_transition_seq() { + if projected_row_delta != 0 { + return Err(projection_drift( + "projection row count changed without a feed transition", + )); + } + return Ok(expected.clone()); + } + let updated = sqlx::query( + "UPDATE radroots_event_store_food_availability_cursor SET last_transition_seq = ?, projected_row_count = ? WHERE singleton = 1 AND source_generation = ? AND last_transition_seq = ? AND projected_row_count = ?", + ) + .bind(next.last_transition_seq()) + .bind(next_projected_row_count) + .bind(next.source_generation().as_bytes().as_slice()) + .bind(expected.feed_cursor.last_transition_seq()) + .bind(expected.projected_row_count) + .execute(&mut *connection) + .await?; + if updated.rows_affected() != 1 { + return Err(projection_drift(format!( + "projection cursor compare-and-swap expected sequence {} and row count {}", + expected.feed_cursor.last_transition_seq(), + expected.projected_row_count, + ))); + } + Ok(FoodAvailabilityProjectionCursorState { + feed_cursor: next, + projected_row_count: next_projected_row_count, + }) +} + +async fn apply_transition( + connection: &mut SqliteConnection, + transition: &RadrootsAddressableTransitionV1, +) -> Result<i64, RadrootsEventStoreError> { + let coordinate = transition.coordinate(); + let existing_event_id: Option<String> = sqlx::query_scalar( + "SELECT event_id FROM radroots_event_store_food_availability_projection WHERE source_generation = ? AND pubkey = ? AND d_tag = ?", + ) + .bind(transition.source_generation().as_bytes().as_slice()) + .bind(coordinate.pubkey().as_str()) + .bind(coordinate.d_tag()) + .fetch_optional(&mut *connection) + .await?; + let visible_event_id = transition + .visible_event() + .map(|event| event.event_id().as_str()); + let mut projected_row_delta = 0_i64; + match (existing_event_id.as_deref(), transition.retracted_event()) { + (Some(existing), Some(retracted)) if existing == retracted.event_id().as_str() => { + let deleted = sqlx::query( + "DELETE FROM radroots_event_store_food_availability_projection WHERE source_generation = ? AND pubkey = ? AND d_tag = ? AND event_id = ?", + ) + .bind(transition.source_generation().as_bytes().as_slice()) + .bind(coordinate.pubkey().as_str()) + .bind(coordinate.d_tag()) + .bind(retracted.event_id().as_str()) + .execute(&mut *connection) + .await?; + if deleted.rows_affected() != 1 { + return Err(projection_drift( + "pending FoodAvailability retraction did not delete one row", + )); + } + projected_row_delta = -1; + } + (Some(existing), None) if visible_event_id == Some(existing) => { + if transition.contract_id() != Some(FOOD_AVAILABILITY_CONTRACT_ID) { + return Err(projection_drift( + "unchanged visible FoodAvailability event lost its contract admission", + )); + } + return Ok(0); + } + (Some(_), _) => { + return Err(projection_drift( + "pending transition does not retract the stored coordinate projection", + )); + } + (None, _) => {} + } + + let Some(canonical) = transition.visible_event() else { + return Ok(projected_row_delta); + }; + if transition.contract_id() != Some(FOOD_AVAILABILITY_CONTRACT_ID) { + return Ok(projected_row_delta); + } + let ingest = RadrootsEventIngest::from_raw_json(canonical.raw_json().to_owned(), 0) + .map_err(|error| projection_drift(format!("canonical event reverify failed: {error}")))?; + let projection = + match project_verified_food_availability_event_registry_v7(ingest.verified_event()) + .map_err(|error| { + projection_drift(format!("FoodAvailability projection failed: {error}")) + })? { + RadrootsFoodAvailabilityProjectionOutcome::Focused(projection) => projection, + RadrootsFoodAvailabilityProjectionOutcome::Excluded(_) => { + return Err(projection_drift( + "FoodAvailability-admitted transition was excluded by registry-v7 projection", + )); + } + _ => { + return Err(projection_drift( + "FoodAvailability projection returned an unsupported outcome", + )); + } + }; + let event = ingest.event(); + if canonical.event_id().as_str() != event.id_str() + || canonical.pubkey().as_str() != event.author_str() + || canonical.created_at() != event.created_at_u64() + || canonical.kind() != event.kind_u32() + { + return Err(projection_drift( + "canonical event identity disagrees with its verified raw JSON", + )); + } + let stored = RadrootsStoredFoodAvailabilityV1::from_projection( + transition.source_generation(), + canonical.pubkey().clone(), + canonical.event_id().clone(), + transition.raw_head().event_seq(), + canonical.created_at(), + transition.transition_seq(), + &projection, + )?; + persist_projection(connection, &stored).await?; + projected_row_delta + .checked_add(1) + .ok_or_else(|| projection_drift("projection row-count delta overflowed")) +} + +async fn persist_projection( + connection: &mut SqliteConnection, + projection: &RadrootsStoredFoodAvailabilityV1, +) -> Result<(), RadrootsEventStoreError> { + let diagnostic_codes_json = diagnostic_codes_json(projection.diagnostics())?; + let quantity_amount = projection.quantity().map(|quantity| quantity.amount()); + let quantity_unit = projection + .quantity() + .map(|quantity| quantity.unit().as_str()); + let inserted = sqlx::query( + "INSERT INTO radroots_event_store_food_availability_projection(source_generation, kind, pubkey, d_tag, event_id, event_seq, created_at, contract_id, content, title, summary, published_at, location, price_amount, price_currency, price_unit, quantity_amount, quantity_unit, status, diagnostic_codes_json, source_transition_seq) VALUES (?, 30402, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(projection.source_generation().as_bytes().as_slice()) + .bind(projection.pubkey().as_str()) + .bind(projection.identifier().as_str()) + .bind(projection.event_id().as_str()) + .bind(projection.event_seq()) + .bind(i64_from_u64("food.created_at", projection.created_at())?) + .bind(FOOD_AVAILABILITY_CONTRACT_ID) + .bind(projection.content().as_str()) + .bind(projection.title().as_str()) + .bind(projection.summary().as_str()) + .bind(i64_from_u64( + "food.published_at", + projection.published_at().as_u64(), + )?) + .bind(projection.location().as_str()) + .bind(projection.price().amount()) + .bind(projection.price().currency().as_str()) + .bind(projection.price().unit().as_str()) + .bind(quantity_amount) + .bind(quantity_unit) + .bind(projection.status().as_str()) + .bind(diagnostic_codes_json) + .bind(projection.source_transition_seq()) + .execute(&mut *connection) + .await?; + if inserted.rows_affected() != 1 { + return Err(projection_drift( + "FoodAvailability projection insert did not affect one row", + )); + } + for image in projection.images() { + persist_image(connection, projection, image).await?; + } + Ok(()) +} + +async fn persist_image( + connection: &mut SqliteConnection, + projection: &RadrootsStoredFoodAvailabilityV1, + image: &RadrootsStoredFoodAvailabilityImageV1, +) -> Result<(), RadrootsEventStoreError> { + let raw_tag_json = serde_json::to_string(image.raw_tag())?; + let diagnostics_json = diagnostic_codes_json(image.diagnostics())?; + let dimensions = image.dimensions(); + let inserted = sqlx::query( + "INSERT INTO radroots_event_store_food_availability_image(source_generation, pubkey, d_tag, image_index, raw_tag_json, url, width, height, blossom_sha256, qualifies, diagnostic_codes_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(projection.source_generation().as_bytes().as_slice()) + .bind(projection.pubkey().as_str()) + .bind(projection.identifier().as_str()) + .bind(i64::from(image.image_index())) + .bind(raw_tag_json) + .bind(image.url()) + .bind(dimensions.map(|value| i64::from(value.width()))) + .bind(dimensions.map(|value| i64::from(value.height()))) + .bind(image.blossom_sha256().map(|digest| digest.to_string())) + .bind(i64::from(image.qualifies())) + .bind(diagnostics_json) + .execute(&mut *connection) + .await?; + if inserted.rows_affected() != 1 { + return Err(projection_drift( + "FoodAvailability image insert did not affect one row", + )); + } + Ok(()) +} + +pub(crate) async fn validate_food_availability_projection_hook_v1( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsEventStoreError> { + let state = food_availability_projection_cursor_state_fast_v1(connection).await?; + let generation = state.feed_cursor.source_generation(); + + let rows = sqlx::query( + "SELECT source_generation, pubkey, d_tag, event_id, event_seq, created_at, contract_id, content, title, summary, published_at, location, price_amount, price_currency, price_unit, quantity_amount, quantity_unit, status, diagnostic_codes_json, source_transition_seq, immutable_raw_json, stored_images_json FROM radroots_event_store_food_availability_read_v1 WHERE source_generation = ? ORDER BY pubkey, d_tag", + ) + .bind(generation.as_bytes().as_slice()) + .fetch_all(&mut *connection) + .await?; + let mut actual_coordinates = Vec::with_capacity(rows.len()); + for row in rows { + let projection = load_and_validate_projection_row(row)?; + validate_projection_source_transition(connection, &projection).await?; + validate_fts_row(connection, &projection).await?; + actual_coordinates.push(( + projection.pubkey().as_str().to_owned(), + projection.identifier().as_str().to_owned(), + projection.event_id().as_str().to_owned(), + projection.event_seq(), + i64_from_u64("food.created_at", projection.created_at())?, + )); + } + let actual_row_count = i64::try_from(actual_coordinates.len()) + .map_err(|_| projection_drift("projection row count exceeds i64"))?; + if actual_row_count != state.projected_row_count { + return Err(projection_drift(format!( + "projection row count {} differs from sealed count {}", + actual_row_count, state.projected_row_count, + ))); + } + let expected_coordinates = sqlx::query( + "SELECT pubkey, d_tag, raw_head_event_id, raw_head_event_seq, raw_head_created_at FROM radroots_event_store_addressable_head_state WHERE source_generation = ? AND kind = 30402 AND admission_status = 'admitted' AND admission_code IS NULL AND contract_id = ? AND visibility = 'visible' AND nip09_outcome = 'visible' ORDER BY pubkey, d_tag", + ) + .bind(generation.as_bytes().as_slice()) + .bind(FOOD_AVAILABILITY_CONTRACT_ID) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(|row| { + Ok::<_, sqlx::Error>(( + row.try_get::<String, _>("pubkey")?, + row.try_get::<String, _>("d_tag")?, + row.try_get::<String, _>("raw_head_event_id")?, + row.try_get::<i64, _>("raw_head_event_seq")?, + row.try_get::<i64, _>("raw_head_created_at")?, + )) + }) + .collect::<Result<Vec<_>, _>>()?; + if actual_coordinates != expected_coordinates { + return Err(projection_drift( + "projection coordinate witnesses do not equal the current admitted, visible FoodAvailability heads", + )); + } + let fts_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_food_availability_search_fts", + ) + .fetch_one(&mut *connection) + .await?; + if fts_count != state.projected_row_count { + return Err(projection_drift(format!( + "FoodAvailability FTS row count {fts_count} differs from sealed count {}", + state.projected_row_count, + ))); + } + #[cfg(test)] + wait_at_food_availability_audit_fts_checkpoint().await; + sqlx::query( + "INSERT INTO radroots_event_store_food_availability_search_fts(radroots_event_store_food_availability_search_fts) VALUES('integrity-check')", + ) + .execute(&mut *connection) + .await + .map_err(|error| projection_drift(format!("FoodAvailability FTS integrity check failed: {error}")))?; + Ok(()) +} + +async fn validate_projection_source_transition( + connection: &mut SqliteConnection, + projection: &RadrootsStoredFoodAvailabilityV1, +) -> Result<(), RadrootsEventStoreError> { + let authoritative: i64 = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM radroots_event_store_addressable_head_transition AS transition WHERE transition.transition_seq = ? AND transition.source_generation = ? AND transition.source_generation = (SELECT active_generation FROM radroots_event_store_source_state WHERE singleton = 1) AND transition.kind = 30402 AND transition.pubkey = ? AND transition.d_tag = ? AND transition.raw_head_event_id = ? AND transition.raw_head_event_seq = ? AND transition.raw_head_created_at = ? AND transition.visible_event_id = ? AND transition.visible_event_seq = ? AND transition.admission_status = 'admitted' AND transition.admission_code IS NULL AND transition.contract_id = ? AND transition.visibility = 'visible' AND transition.nip09_outcome = 'visible' AND transition.raw_head_decision IN ('baseline_rebuild', 'applied') AND transition.transition_seq = (SELECT MAX(candidate.transition_seq) FROM radroots_event_store_addressable_head_transition AS candidate WHERE candidate.source_generation = transition.source_generation AND candidate.kind = transition.kind AND candidate.pubkey = transition.pubkey AND candidate.d_tag = transition.d_tag AND candidate.raw_head_decision IN ('baseline_rebuild', 'applied')))", + ) + .bind(projection.source_transition_seq()) + .bind(projection.source_generation().as_bytes().as_slice()) + .bind(projection.pubkey().as_str()) + .bind(projection.identifier().as_str()) + .bind(projection.event_id().as_str()) + .bind(projection.event_seq()) + .bind(i64_from_u64("food.created_at", projection.created_at())?) + .bind(projection.event_id().as_str()) + .bind(projection.event_seq()) + .bind(FOOD_AVAILABILITY_CONTRACT_ID) + .fetch_one(&mut *connection) + .await?; + if authoritative != 1 { + return Err(projection_drift( + "stored FoodAvailability source transition is not authoritative for its projection", + )); + } + Ok(()) +} + +#[cfg(test)] +async fn wait_at_food_availability_audit_fts_checkpoint() { + let release = FOOD_AVAILABILITY_AUDIT_FTS_CHECKPOINT + .try_with(|(reached, release)| { + reached.notify_one(); + Arc::clone(release) + }) + .ok(); + if let Some(release) = release { + release.notified().await; + } +} + +pub(crate) async fn validate_food_availability_projection_hook_state_fast_v1( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsEventStoreError> { + food_availability_projection_cursor_state_fast_v1(connection) + .await + .map(|_| ()) +} + +async fn food_availability_projection_cursor_state_fast_v1( + connection: &mut SqliteConnection, +) -> Result<FoodAvailabilityProjectionCursorState, RadrootsEventStoreError> { + let scope = RadrootsAddressableTransitionScopeV1::food_availability(); + let row = sqlx::query( + "SELECT source.active_generation, source.last_transition_seq AS source_high_water, generation.transition_floor_seq AS generation_floor, generation.addressable_feed_version, integrity.transition_floor_seq AS integrity_floor, integrity.last_transition_seq AS integrity_high_water, integrity.transition_count, cursor.source_generation, cursor.feed_version, cursor.projection_version, cursor.scope_fingerprint, cursor.hook_manifest_sha256, cursor.last_transition_seq, cursor.projected_row_count FROM radroots_event_store_source_state AS source JOIN radroots_event_store_source_generation AS generation ON generation.source_generation = source.active_generation JOIN radroots_event_store_addressable_feed_integrity_v1 AS integrity ON integrity.source_generation = source.active_generation JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.singleton = 1 WHERE source.singleton = 1", + ) + .fetch_optional(&mut *connection) + .await? + .ok_or_else(|| projection_drift("active source, feed, or projection seal is missing"))?; + let generation = projection_generation_from_blob( + row.try_get("active_generation")?, + "active source generation is invalid", + )?; + validate_cursor_identity(&row, generation, &scope)?; + let source_high_water: i64 = row.try_get("source_high_water")?; + let generation_floor: i64 = row.try_get("generation_floor")?; + let integrity_floor: i64 = row.try_get("integrity_floor")?; + let integrity_high_water: i64 = row.try_get("integrity_high_water")?; + let transition_count: i64 = row.try_get("transition_count")?; + let cursor_high_water: i64 = row.try_get("last_transition_seq")?; + let expected_transition_count = source_high_water + .checked_sub(generation_floor) + .filter(|count| *count >= 0) + .ok_or_else(|| projection_drift("source high-water precedes its transition floor"))?; + if row.try_get::<i64, _>("addressable_feed_version")? + != i64::from(RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1) + || integrity_floor != generation_floor + || integrity_high_water != source_high_water + || transition_count != expected_transition_count + { + return Err(projection_drift( + "active addressable feed integrity seal is inconsistent", + )); + } + if cursor_high_water != source_high_water { + return Err(projection_drift( + "projection cursor is not at the source high-water", + )); + } + let projected_row_count: i64 = row.try_get("projected_row_count")?; + validate_projected_row_count(projected_row_count)?; + Ok(FoodAvailabilityProjectionCursorState { + feed_cursor: RadrootsAddressableTransitionCursorV1::new( + generation, + scope.fingerprint(), + cursor_high_water, + )?, + projected_row_count, + }) +} + +fn load_and_validate_projection_rows( + rows: Vec<sqlx::sqlite::SqliteRow>, +) -> Result<Vec<RadrootsStoredFoodAvailabilityV1>, RadrootsEventStoreError> { + let mut result = Vec::with_capacity(rows.len()); + for row in rows { + result.push(load_and_validate_projection_row(row)?); + } + Ok(result) +} + +fn load_and_validate_projection_row( + row: sqlx::sqlite::SqliteRow, +) -> Result<RadrootsStoredFoodAvailabilityV1, RadrootsEventStoreError> { + let generation = projection_generation_from_blob( + row.try_get("source_generation")?, + "stored projection generation is invalid", + )?; + let pubkey = RadrootsPublicKey::parse(row.try_get::<String, _>("pubkey")?.as_str()) + .map_err(|error| projection_drift(format!("stored pubkey is invalid: {error}")))?; + let event_id = RadrootsEventId::parse(row.try_get::<String, _>("event_id")?.as_str()) + .map_err(|error| projection_drift(format!("stored event id is invalid: {error}")))?; + let event_seq: i64 = row.try_get("event_seq")?; + let created_at = u64_from_i64("food.created_at", row.try_get("created_at")?) + .map_err(|error| projection_drift(error.to_string()))?; + let transition_seq: i64 = row.try_get("source_transition_seq")?; + let raw_json: String = row.try_get("immutable_raw_json")?; + let ingest = RadrootsEventIngest::from_raw_json(raw_json, 0) + .map_err(|error| projection_drift(format!("projected event reverify failed: {error}")))?; + if ingest.event().id_str() != event_id.as_str() + || ingest.event().author_str() != pubkey.as_str() + || ingest.event().created_at_u64() != created_at + || ingest.event().kind_u32() != 30_402 + { + return Err(projection_drift( + "projection identity disagrees with immutable signed event", + )); + } + let admission = EventAdmission::for_profile( + ReconciliationProfile::Nip09V1RegistryV7, + ingest.verified_event(), + ) + .map_err(|error| projection_drift(format!("stored admission is invalid: {error}")))?; + if admission.status != RadrootsEventAdmissionStatus::Admitted + || admission.contract.map(|contract| contract.id) != Some(FOOD_AVAILABILITY_CONTRACT_ID) + { + return Err(projection_drift( + "projected event is not registry-v7 FoodAvailability", + )); + } + let focused = + match project_verified_food_availability_event_registry_v7(ingest.verified_event()) + .map_err(|error| projection_drift(format!("stored projection failed: {error}")))? + { + RadrootsFoodAvailabilityProjectionOutcome::Focused(projection) => projection, + RadrootsFoodAvailabilityProjectionOutcome::Excluded(_) => { + return Err(projection_drift( + "stored FoodAvailability event is excluded", + )); + } + _ => { + return Err(projection_drift( + "stored FoodAvailability event returned an unsupported projection outcome", + )); + } + }; + let expected = RadrootsStoredFoodAvailabilityV1::from_projection( + generation, + pubkey, + event_id, + event_seq, + created_at, + transition_seq, + &focused, + )?; + validate_projection_columns(&row, &expected)?; + validate_image_rows(row.try_get("stored_images_json")?, &expected)?; + Ok(expected) +} + +fn validate_projection_columns( + row: &sqlx::sqlite::SqliteRow, + expected: &RadrootsStoredFoodAvailabilityV1, +) -> Result<(), RadrootsEventStoreError> { + let expected_diagnostics = diagnostic_codes_json(expected.diagnostics())?; + let quantity_amount = expected.quantity().map(|quantity| quantity.amount()); + let quantity_unit = expected.quantity().map(|quantity| quantity.unit().as_str()); + if row.try_get::<String, _>("d_tag")? != expected.identifier().as_str() + || row.try_get::<String, _>("contract_id")? != FOOD_AVAILABILITY_CONTRACT_ID + || row.try_get::<String, _>("content")? != expected.content().as_str() + || row.try_get::<String, _>("title")? != expected.title().as_str() + || row.try_get::<String, _>("summary")? != expected.summary().as_str() + || u64_from_i64("food.published_at", row.try_get("published_at")?) + .map_err(|error| projection_drift(error.to_string()))? + != expected.published_at().as_u64() + || row.try_get::<String, _>("location")? != expected.location().as_str() + || row.try_get::<String, _>("price_amount")? != expected.price().amount() + || row.try_get::<String, _>("price_currency")? != expected.price().currency().as_str() + || row.try_get::<String, _>("price_unit")? != expected.price().unit().as_str() + || row + .try_get::<Option<String>, _>("quantity_amount")? + .as_deref() + != quantity_amount + || row + .try_get::<Option<String>, _>("quantity_unit")? + .as_deref() + != quantity_unit + || row.try_get::<String, _>("status")? != expected.status().as_str() + || row.try_get::<String, _>("diagnostic_codes_json")? != expected_diagnostics + { + return Err(projection_drift( + "stored FoodAvailability columns differ from registry-v7 reprojection", + )); + } + Ok(()) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct StoredFoodAvailabilityImageRowV1 { + image_index: i64, + raw_tag_json: String, + url: Option<String>, + width: Option<i64>, + height: Option<i64>, + blossom_sha256: Option<String>, + qualifies: i64, + diagnostic_codes_json: String, +} + +fn validate_image_rows( + stored_images_json: String, + expected: &RadrootsStoredFoodAvailabilityV1, +) -> Result<(), RadrootsEventStoreError> { + let rows: Vec<StoredFoodAvailabilityImageRowV1> = + serde_json::from_str(stored_images_json.as_str()) + .map_err(|error| projection_drift(format!("stored image rows are invalid: {error}")))?; + if rows.len() != expected.images().len() { + return Err(projection_drift( + "stored FoodAvailability image count differs", + )); + } + for (row, image) in rows.into_iter().zip(expected.images()) { + let dimensions = image.dimensions(); + let stored_blossom_sha256 = row + .blossom_sha256 + .map(|value| { + radroots_blossom::RadrootsBlossomSha256::from_hex(value.as_str()).map_err(|error| { + projection_drift(format!("stored Blossom digest is invalid: {error}")) + }) + }) + .transpose()?; + if row.image_index != i64::from(image.image_index()) + || row.raw_tag_json + != serde_json::to_string(image.raw_tag()).map_err(|error| { + projection_drift(format!("expected image tag is not serializable: {error}")) + })? + || row.url.as_deref() != image.url() + || row.width != dimensions.map(|value| i64::from(value.width())) + || row.height != dimensions.map(|value| i64::from(value.height())) + || stored_blossom_sha256 != image.blossom_sha256() + || bool_from_i64("food.image.qualifies", row.qualifies) + .map_err(|error| projection_drift(error.to_string()))? + != image.qualifies() + || row.diagnostic_codes_json != diagnostic_codes_json(image.diagnostics())? + { + return Err(projection_drift( + "stored FoodAvailability image differs from registry-v7 reprojection", + )); + } + } + Ok(()) +} + +async fn validate_fts_row( + connection: &mut SqliteConnection, + projection: &RadrootsStoredFoodAvailabilityV1, +) -> Result<(), RadrootsEventStoreError> { + let row = sqlx::query( + "SELECT event_id, pubkey, d_tag, title, summary, content, location FROM radroots_event_store_food_availability_search_fts WHERE rowid = ?", + ) + .bind(projection.event_seq()) + .fetch_optional(&mut *connection) + .await? + .ok_or_else(|| projection_drift("FoodAvailability FTS row is missing"))?; + if row.try_get::<String, _>("event_id")? != projection.event_id().as_str() + || row.try_get::<String, _>("pubkey")? != projection.pubkey().as_str() + || row.try_get::<String, _>("d_tag")? != projection.identifier().as_str() + || row.try_get::<String, _>("title")? != projection.title().as_str() + || row.try_get::<String, _>("summary")? != projection.summary().as_str() + || row.try_get::<String, _>("content")? != projection.content().as_str() + || row.try_get::<String, _>("location")? != projection.location().as_str() + { + return Err(projection_drift( + "FoodAvailability FTS row differs from projection", + )); + } + Ok(()) +} + +fn diagnostic_codes_json( + diagnostics: &[RadrootsFoodAvailabilityImageDiagnostic], +) -> Result<String, RadrootsEventStoreError> { + serde_json::to_string( + &diagnostics + .iter() + .map(|diagnostic| diagnostic.code()) + .collect::<Vec<_>>(), + ) + .map_err(|error| projection_drift(format!("diagnostics are not serializable: {error}"))) +} + +fn validate_query_limit(limit: u32) -> Result<(), RadrootsEventStoreError> { + if !(1..=RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX).contains(&limit) { + return Err(RadrootsEventStoreError::QueryLimitOutOfRange { + min: 1, + max: RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, + actual: limit, + }); + } + Ok(()) +} + +fn projection_drift(reason: impl Into<String>) -> RadrootsEventStoreError { + RadrootsEventStoreError::FoodAvailabilityProjectionDrift { + reason: reason.into(), + } +} + +fn validate_projected_row_count(value: i64) -> Result<(), RadrootsEventStoreError> { + if value < 0 { + return Err(projection_drift(format!( + "projection cursor has negative row count {value}", + ))); + } + Ok(()) +} + +fn projection_generation_from_blob( + value: Vec<u8>, + context: &'static str, +) -> Result<RadrootsEventStoreSourceGeneration, RadrootsEventStoreError> { + generation_from_blob(value).map_err(|error| projection_drift(format!("{context}: {error}"))) +} + +fn i64_from_u64(field: &'static str, value: u64) -> Result<i64, RadrootsEventStoreError> { + i64::try_from(value).map_err(|_| RadrootsEventStoreError::UnsignedIntegerRange { field, value }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn projection_generation_corruption_uses_the_projection_error_surface() { + assert!(matches!( + projection_generation_from_blob( + vec![0_u8; 31], + "stored projection generation is invalid", + ), + Err(RadrootsEventStoreError::FoodAvailabilityProjectionDrift { reason }) + if reason.contains("stored projection generation is invalid") + && reason.contains("31 bytes instead of 32") + )); + } +} diff --git a/crates/event_store/src/store/post_core_extension_capabilities.rs b/crates/event_store/src/store/post_core_extension_capabilities.rs @@ -1,5 +1,7 @@ use super::post_core_extensions_v1::apply_post_core_extensions_v1; +use super::post_core_extensions_v2::apply_post_core_extensions_v2; use super::post_core_storage_v1::PostCoreStorageV1; +use super::post_core_storage_v2::PostCoreStorageV2; use super::protocol_reconciliation_v1::ProtocolReconciliationV1IngestResult; use crate::error::RadrootsEventStoreError; use crate::model::RadrootsEventIngest; @@ -22,4 +24,9 @@ impl<'borrow, 'db> PostCoreExtensionCapabilities<'borrow, 'db> { let mut storage = PostCoreStorageV1::new(self.tx); apply_post_core_extensions_v1(&mut storage, ingest, result).await } + + pub(super) async fn apply_v2(&mut self) -> Result<(), RadrootsEventStoreError> { + let mut storage = PostCoreStorageV2::new(self.tx); + apply_post_core_extensions_v2(&mut storage).await + } } diff --git a/crates/event_store/src/store/post_core_extension_dispatcher.rs b/crates/event_store/src/store/post_core_extension_dispatcher.rs @@ -9,5 +9,6 @@ pub(super) async fn dispatch_post_core_extensions( result: &ProtocolReconciliationV1IngestResult, ) -> Result<(), RadrootsEventStoreError> { capabilities.apply_v1(ingest, result).await?; + capabilities.apply_v2().await?; Ok(()) } diff --git a/crates/event_store/src/store/post_core_extensions_v2.rs b/crates/event_store/src/store/post_core_extensions_v2.rs @@ -0,0 +1,8 @@ +use super::post_core_storage_v2::PostCoreStorageV2; +use crate::RadrootsEventStoreError; + +pub(super) async fn apply_post_core_extensions_v2( + storage: &mut PostCoreStorageV2<'_, '_>, +) -> Result<(), RadrootsEventStoreError> { + storage.apply_pending_food_availability_transitions().await +} diff --git a/crates/event_store/src/store/post_core_storage_v2.rs b/crates/event_store/src/store/post_core_storage_v2.rs @@ -0,0 +1,19 @@ +use super::food_availability_projection_v1::apply_pending_food_availability_transitions_v1; +use crate::RadrootsEventStoreError; +use sqlx::{Sqlite, Transaction}; + +pub(super) struct PostCoreStorageV2<'borrow, 'db> { + tx: &'borrow mut Transaction<'db, Sqlite>, +} + +impl<'borrow, 'db> PostCoreStorageV2<'borrow, 'db> { + pub(super) fn new(tx: &'borrow mut Transaction<'db, Sqlite>) -> Self { + Self { tx } + } + + pub(super) async fn apply_pending_food_availability_transitions( + &mut self, + ) -> Result<(), RadrootsEventStoreError> { + apply_pending_food_availability_transitions_v1(self.tx).await + } +} diff --git a/crates/event_store/src/store/protocol_storage_v1.rs b/crates/event_store/src/store/protocol_storage_v1.rs @@ -11,7 +11,6 @@ use sqlx::{Row, Sqlite, Transaction}; pub(super) struct RawHeadSnapshot { pub(super) raw_head: RadrootsStoredRawEventHead, - pub(super) raw_event: RadrootsStoredRawEvent, } #[cfg_attr(coverage_nightly, coverage(off))] @@ -130,10 +129,7 @@ pub(super) async fn raw_head_snapshot_in_transaction( } let raw_event = stored_raw_event_from_row(row)?; validate_raw_head_snapshot(coordinate, &raw_head, &raw_event)?; - Ok(RawHeadSnapshot { - raw_head, - raw_event, - }) + Ok(RawHeadSnapshot { raw_head }) }) .transpose() } diff --git a/crates/event_store/tests/fixtures/food_availability_projection.v1.json b/crates/event_store/tests/fixtures/food_availability_projection.v1.json @@ -0,0 +1,2889 @@ +{ + "schema_version": 1, + "contract_id": "radroots_event_store.food_availability_projection_v1", + "feed_version": 1, + "projection_version": 1, + "scope_kinds": [ + 30402 + ], + "cases": [ + { + "id": "visible_food_availability_projects_and_searches", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "b9d0da50b69689fdd71adc0d698b3e2d04e53c94ec31e23496b4d2fdb96bc1719c5d626881f407682f287f2a5d2bc57159f70a8cd3d1aaae96bd1394c5b1ea0a" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "content": "Carrots available this week.", + "title": "Nantes Carrots", + "summary": "Fresh bunches", + "published_at": 1700000000, + "location": "Central Saanich, BC", + "price_amount": "3", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": null, + "quantity_unit": null, + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "Fresh", + "event_ids": [ + "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7" + ] + }, + { + "query": "Nantes Saanich", + "event_ids": [ + "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7" + ] + } + ], + "transition_page": { + "source_high_water": 1, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 1 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_json_sha256": "817789914f92ad1401bb128f2c176f725143cbbc10f0d1b97fa4b31d5d1ab05f", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [] + } + }, + { + "id": "invalid_same_timestamp_winner_retracts_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "b9d0da50b69689fdd71adc0d698b3e2d04e53c94ec31e23496b4d2fdb96bc1719c5d626881f407682f287f2a5d2bc57159f70a8cd3d1aaae96bd1394c5b1ea0a" + } + }, + { + "role": "scoped_food", + "observed_at_ms": 1700000060001, + "expected_ingest": { + "admission_status": "invalid", + "admission_code": "food_status_invalid", + "contract_id": null, + "event_class": "addressable", + "valid_stream_eligible": false, + "raw_head_decision": "applied" + }, + "event": { + "id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "paused" + ] + ], + "content": "Carrots available this week.", + "sig": "5728cd88796dc4beed1d293db77a9e5a9297bb9e3d7d56b9ee108f3c6d3ad2fd5468b359d1fba09a4eeca7539d678d2e373c8bd151969e19feaceb9aebaca917" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "projection": null, + "searches": [ + { + "query": "Fresh", + "event_ids": [] + } + ], + "transition_page": { + "source_high_water": 2, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 2 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_json_sha256": "817789914f92ad1401bb128f2c176f725143cbbc10f0d1b97fa4b31d5d1ab05f", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "event_seq": 2 + }, + "raw_head_created_at": 1700000060, + "admission_status": "invalid", + "admission_code": "food_status_invalid", + "contract_id": null, + "visibility": "not_admitted", + "suppression": null, + "cause_event": { + "event": { + "event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "event_seq": 2 + }, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "invalid", + "admission_code": "food_status_invalid", + "contract_id": null + }, + "canonical_visible_event": null, + "retracted_event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "invalid", + "decision": "not_admitted", + "is_raw_head": true, + "raw_head_event_id": "22e22dba9ea96a763a296633438a2b7a18b0358324677519cc16636d280518b5", + "suppression": null + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "794251d2341134908ee28526447cb8a54bdd608917166fd3678f65e578fbb8c7", + "final_decision": "not_current" + } + ] + } + }, + { + "id": "blossom_digest_and_image_diagnostics_are_preserved", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "image", + "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp" + ], + [ + "image", + "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp", + "0x600", + "extra" + ], + [ + "image", + "not-a-url", + "800x600" + ] + ], + "content": "Carrots available this week.", + "sig": "1d5eb699944bc1eba93c85071ad1d94878f1c767015391e200b45f1a2066558675a382ede737fb15307a944598687843618c067accf6608f53daad377683c2c9" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "content": "Carrots available this week.", + "title": "Nantes Carrots", + "summary": "Fresh bunches", + "published_at": 1700000000, + "location": "Central Saanich, BC", + "price_amount": "3", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": null, + "quantity_unit": null, + "status": "active", + "diagnostics": [ + "food_image_shape_invalid", + "food_image_dimensions_missing", + "food_image_shape_invalid", + "food_image_dimensions_invalid", + "food_image_duplicate_url", + "food_image_duplicate_digest", + "food_image_url_invalid" + ], + "images": [ + { + "url": "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp", + "width": null, + "height": null, + "blossom_sha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "diagnostics": [ + "food_image_shape_invalid", + "food_image_dimensions_missing" + ], + "qualifies": false + }, + { + "url": "https://media.example/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824.webp", + "width": null, + "height": null, + "blossom_sha256": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + "diagnostics": [ + "food_image_shape_invalid", + "food_image_dimensions_invalid", + "food_image_duplicate_url", + "food_image_duplicate_digest" + ], + "qualifies": false + }, + { + "url": null, + "width": 800, + "height": 600, + "blossom_sha256": null, + "diagnostics": [ + "food_image_url_invalid" + ], + "qualifies": false + } + ] + }, + "searches": [ + { + "query": "Carrots", + "event_ids": [ + "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe" + ] + } + ], + "transition_page": { + "source_high_water": 1, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 1 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "event_seq": 1 + }, + "pubkey": "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "event_seq": 1 + }, + "raw_json_sha256": "090b5663c36c2d7ee1361866ec6314d64e9c3ba386d430e1265aa4bb4c563884", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "96b4a10932919010172a00667ccebb8684a89e7cc3c4c51202d2870fd8b5acfe", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [] + } + }, + { + "id": "authorized_address_deletion_retracts_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "causal", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1", + "event_class": "regular", + "valid_stream_eligible": true, + "raw_head_decision": "not_head_selected" + }, + "event": { + "id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 5, + "tags": [ + [ + "a", + "30402:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:nantes-carrots" + ] + ], + "content": "Harvest listing withdrawn.", + "sig": "d360cbde755ad9c04bcc8a82c5d144bbb98bb1366304f0162544622ab648267b0e8e4e8d35baffe98c0cac7dc67e0c1a44a7ae172db49f71641291294759da28" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": null, + "searches": [ + { + "query": "Fresh", + "event_ids": [] + } + ], + "transition_page": { + "source_high_water": 2, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 2 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "suppressed", + "suppression": { + "outcome": "suppressed", + "reason": "deletion_address_reference", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + }, + "cause_event": { + "event": { + "event_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "event_seq": 2 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 5, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1" + }, + "canonical_visible_event": null, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "not_head_selected" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "suppressed", + "is_raw_head": true, + "raw_head_event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "suppression": { + "outcome": "suppressed", + "reason": "deletion_address_reference", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + } + }, + { + "event": { + "event_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": null, + "suppression": { + "outcome": "visible", + "reason": "deletion_request_immune", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "suppressed" + } + ] + } + }, + { + "id": "wrong_author_address_deletion_preserves_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "causal", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1", + "event_class": "regular", + "valid_stream_eligible": true, + "raw_head_decision": "not_head_selected" + }, + "event": { + "id": "51fa76379d859f70a30f4e44b97373c35bf86cc1efdce2bf23ed0e8605387535", + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + "created_at": 1700000070, + "kind": 5, + "tags": [ + [ + "a", + "30402:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:nantes-carrots" + ] + ], + "content": "Unauthorized withdrawal attempt.", + "sig": "58e88c1dcd442c6336a090085d8945cb935aff84f859e8132fa9fd4d596ae746c366b56e4a73f23490e550b6d0b0ba60489e1aa3130e7018b4397d4dab3039d2" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "content": "Carrots available this week.", + "title": "Nantes Carrots", + "summary": "Fresh bunches", + "published_at": 1700000000, + "location": "Central Saanich, BC", + "price_amount": "3", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": null, + "quantity_unit": null, + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "Fresh", + "event_ids": [ + "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd" + ] + } + ], + "transition_page": { + "source_high_water": 2, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 2 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_request_author_mismatch", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "51fa76379d859f70a30f4e44b97373c35bf86cc1efdce2bf23ed0e8605387535", + "event_seq": 2 + }, + "pubkey": "e0266e3cfb0d2886f91c73f5f868f3b98273713e5fcd97c081663f5518a4b3af", + "created_at": 1700000070, + "kind": 5, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "not_head_selected" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "suppression": { + "outcome": "visible", + "reason": "deletion_request_author_mismatch", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "51fa76379d859f70a30f4e44b97373c35bf86cc1efdce2bf23ed0e8605387535", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": null, + "suppression": { + "outcome": "visible", + "reason": "deletion_request_immune", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [] + } + }, + { + "id": "post_cutoff_replacement_restores_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "causal", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1", + "event_class": "regular", + "valid_stream_eligible": true, + "raw_head_decision": "not_head_selected" + }, + "event": { + "id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 5, + "tags": [ + [ + "a", + "30402:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:nantes-carrots" + ] + ], + "content": "Harvest listing withdrawn.", + "sig": "d360cbde755ad9c04bcc8a82c5d144bbb98bb1366304f0162544622ab648267b0e8e4e8d35baffe98c0cac7dc67e0c1a44a7ae172db49f71641291294759da28" + } + }, + { + "role": "scoped_food", + "observed_at_ms": 1700000080000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "New harvest bunches" + ], + [ + "published_at", + "1700000080" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3.5", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "radroots:quantity", + "24", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "A new carrot harvest is available.", + "sig": "fcfbab5dee277d70fb26740a8b058a884c82ceedc83819ff6920cc42634adfbbbe149c2cc207f2c176436c0cb738c007d3fc378d0d530d09d45d818ecad5be4c" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "content": "A new carrot harvest is available.", + "title": "Nantes Carrots", + "summary": "New harvest bunches", + "published_at": 1700000080, + "location": "Central Saanich, BC", + "price_amount": "3.5", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": "24", + "quantity_unit": "lb", + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "New harvest", + "event_ids": [ + "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d" + ] + } + ], + "transition_page": { + "source_high_water": 3, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 3 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "suppressed", + "suppression": { + "outcome": "suppressed", + "reason": "deletion_address_reference", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + }, + "cause_event": { + "event": { + "event_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "event_seq": 2 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 5, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.social.deletion_request.v1" + }, + "canonical_visible_event": null, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "not_head_selected" + }, + { + "transition_seq": 3, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_head_created_at": 1700000080, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_address_cutoff_precedes_target", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + }, + "cause_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_json_sha256": "465617b3c73ea58efd9351aa162540614ac50c92115d599a582fec4b98b5e195", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "suppressed", + "reason": "deletion_address_reference", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + } + }, + { + "event": { + "event_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": null, + "suppression": { + "outcome": "visible", + "reason": "deletion_request_immune", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_address_cutoff_precedes_target", + "event_reference_request_id": null, + "address_reference_request_id": "5ae5794dfca7f049803d19ecb28fe9378622b408715b8aca1ebd40e494bf4462", + "address_reference_cutoff": 1700000070 + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "not_current" + } + ] + } + }, + { + "id": "operational_listing_head_retracts_food_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "scoped_non_food", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "p", + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ], + [ + "a", + "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000070" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "inventory", + "24" + ], + [ + "status", + "active" + ], + [ + "delivery", + "pickup" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "fd70b10cd97d71e49a622482edcc6d4a1ab065fa2d614ca848187fd4c00a8ea3c46c414a96d26b75f0244f03a0cfd0b94495d3ef46cedeec05d1cd84e8864bbb" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": null, + "searches": [ + { + "query": "Nantes", + "event_ids": [] + }, + { + "query": "Carrots available", + "event_ids": [] + } + ], + "transition_page": { + "source_high_water": 2, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 2 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_head_created_at": 1700000070, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_json_sha256": "19a34cd6af7a207c60b1c3e0c9b4f58e9cb7af2a8c26f323e7570298992833cb", + "admission_status": "admitted", + "contract_id": "radroots.operational_listing.published.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "not_current" + } + ] + } + }, + { + "id": "food_head_after_operational_listing_restores_projection", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "scoped_non_food", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "p", + "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df" + ], + [ + "a", + "30340:585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df:AAAAAAAAAAAAAAAAAAAAAA" + ], + [ + "key", + "carrot-nantes" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "category", + "produce" + ], + [ + "summary", + "Fresh bunches harvested in Saanich" + ], + [ + "published_at", + "1700000070" + ], + [ + "radroots:primary_bin", + "bunch" + ], + [ + "radroots:bin", + "bunch", + "1", + "each" + ], + [ + "radroots:price", + "bunch", + "4", + "CAD", + "1", + "each" + ], + [ + "price", + "4", + "CAD" + ], + [ + "inventory", + "24" + ], + [ + "status", + "active" + ], + [ + "delivery", + "pickup" + ], + [ + "location", + "Saanich Peninsula", + "Victoria", + "BC", + "CA" + ], + [ + "g", + "c28hr" + ] + ], + "content": "# Nantes Carrots\n\nFresh bunches harvested in Saanich", + "sig": "fd70b10cd97d71e49a622482edcc6d4a1ab065fa2d614ca848187fd4c00a8ea3c46c414a96d26b75f0244f03a0cfd0b94495d3ef46cedeec05d1cd84e8864bbb" + } + }, + { + "role": "scoped_food", + "observed_at_ms": 1700000080000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "New harvest bunches" + ], + [ + "published_at", + "1700000080" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3.5", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "radroots:quantity", + "24", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "A new carrot harvest is available.", + "sig": "fcfbab5dee277d70fb26740a8b058a884c82ceedc83819ff6920cc42634adfbbbe149c2cc207f2c176436c0cb738c007d3fc378d0d530d09d45d818ecad5be4c" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "content": "A new carrot harvest is available.", + "title": "Nantes Carrots", + "summary": "New harvest bunches", + "published_at": 1700000080, + "location": "Central Saanich, BC", + "price_amount": "3.5", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": "24", + "quantity_unit": "lb", + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "New harvest", + "event_ids": [ + "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d" + ] + } + ], + "transition_page": { + "source_high_water": 3, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 3 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 2, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_head_created_at": 1700000070, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.operational_listing.published.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_json_sha256": "19a34cd6af7a207c60b1c3e0c9b4f58e9cb7af2a8c26f323e7570298992833cb", + "admission_status": "admitted", + "contract_id": "radroots.operational_listing.published.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "applied" + }, + { + "transition_seq": 3, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_head_created_at": 1700000080, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_json_sha256": "465617b3c73ea58efd9351aa162540614ac50c92115d599a582fec4b98b5e195", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "not_current" + }, + { + "transition_seq": 2, + "event_id": "32a6dfe2df2ca1d31f73c6c938c7de20261d7b690c96707a5d9d1d211bc19b63", + "final_decision": "not_current" + } + ] + } + }, + { + "id": "food_feed_cursor_advances_across_unrelated_addressable_traffic", + "events": [ + { + "role": "scoped_food", + "observed_at_ms": 1700000060000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "Fresh bunches" + ], + [ + "published_at", + "1700000000" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "Carrots available this week.", + "sig": "06769405be8fbff812981a74d8fe8f100d110b29f2d356244423665c920e6654a9d4f07e40120a992ddb391ef266ba3c78d500a2e83f66f2660288000aa2ced7" + } + }, + { + "role": "unrelated_addressable", + "observed_at_ms": 1700000070000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.farm.profile.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "384e9060b4bbc919ca2ea75a47241078a5ca911c18450d62081232e10b66fea4", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000070, + "kind": 30340, + "tags": [ + [ + "d", + "victoria-farm-traffic" + ] + ], + "content": "{}", + "sig": "7987bef7ebd6c23b6d8a1435600ded7804acaf4caa28a054e977da27c10b1493101d822eb0a113872b7bb07fc5632dbec0e5205f805b0e6d240da30b9c08d862" + } + }, + { + "role": "scoped_food", + "observed_at_ms": 1700000080000, + "expected_ingest": { + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true, + "raw_head_decision": "applied" + }, + "event": { + "id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "tags": [ + [ + "d", + "nantes-carrots" + ], + [ + "title", + "Nantes Carrots" + ], + [ + "summary", + "New harvest bunches" + ], + [ + "published_at", + "1700000080" + ], + [ + "location", + "Central Saanich, BC" + ], + [ + "price", + "3.5", + "CAD" + ], + [ + "radroots:price_unit", + "lb" + ], + [ + "radroots:quantity", + "24", + "lb" + ], + [ + "status", + "active" + ], + [ + "t", + "vegetables" + ], + [ + "g", + "c28hr" + ] + ], + "content": "A new carrot harvest is available.", + "sig": "fcfbab5dee277d70fb26740a8b058a884c82ceedc83819ff6920cc42634adfbbbe149c2cc207f2c176436c0cb738c007d3fc378d0d530d09d45d818ecad5be4c" + } + } + ], + "expected": { + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "projection": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "content": "A new carrot harvest is available.", + "title": "Nantes Carrots", + "summary": "New harvest bunches", + "published_at": 1700000080, + "location": "Central Saanich, BC", + "price_amount": "3.5", + "price_currency": "CAD", + "price_unit": "lb", + "quantity_amount": "24", + "quantity_unit": "lb", + "status": "active", + "diagnostics": [], + "images": [] + }, + "searches": [ + { + "query": "New harvest", + "event_ids": [ + "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d" + ] + } + ], + "transition_page": { + "source_high_water": 3, + "has_more": false, + "next_cursor": { + "source_generation": "active", + "feed_version": 1, + "scope_fingerprint": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "last_transition_seq": 3 + }, + "transitions": [ + { + "transition_seq": 1, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_created_at": 1700000060, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000060, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_json_sha256": "4b625225135be45fee6a5e35580124b1d8e04a378faa3309a69482e2f6654dee", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": null, + "raw_head_decision": "applied" + }, + { + "transition_seq": 3, + "source_generation": "active", + "origin": "incremental", + "coordinate": { + "kind": 30402, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "d_tag": "nantes-carrots" + }, + "raw_head": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_head_created_at": 1700000080, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1", + "visibility": "visible", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + }, + "cause_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "pubkey": "585591529da0bab31b3b1b1f986611cf5f435dca84f978c89ee8a40cca7103df", + "created_at": 1700000080, + "kind": 30402, + "admission_status": "admitted", + "admission_code": null, + "contract_id": "radroots.food.availability.v1" + }, + "canonical_visible_event": { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "raw_json_sha256": "465617b3c73ea58efd9351aa162540614ac50c92115d599a582fec4b98b5e195", + "admission_status": "admitted", + "contract_id": "radroots.food.availability.v1", + "event_class": "addressable", + "valid_stream_eligible": true + }, + "retracted_event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "raw_head_decision": "applied" + } + ] + }, + "event_visibility": [ + { + "event": { + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "event_seq": 1 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "not_current", + "is_raw_head": false, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "384e9060b4bbc919ca2ea75a47241078a5ca911c18450d62081232e10b66fea4", + "event_seq": 2 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "384e9060b4bbc919ca2ea75a47241078a5ca911c18450d62081232e10b66fea4", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + }, + { + "event": { + "event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "event_seq": 3 + }, + "source_generation": "active", + "admission_status": "admitted", + "decision": "visible", + "is_raw_head": true, + "raw_head_event_id": "c131031abade64eed03ab14b4ccabb077d61a3f62f0080e0c5122708cde85d1d", + "suppression": { + "outcome": "visible", + "reason": "deletion_no_authorized_reference", + "event_reference_request_id": null, + "address_reference_request_id": null, + "address_reference_cutoff": null + } + } + ], + "historical_visibility_witnesses": [ + { + "transition_seq": 1, + "event_id": "500e371ced493d4e7fea6358ad5bf478f874c716d5c9a3fc56f4b4f0118293cd", + "final_decision": "not_current" + } + ] + } + } + ] +} diff --git a/crates/event_store/tests/food_availability_projection_v1_result_vector.rs b/crates/event_store/tests/food_availability_projection_v1_result_vector.rs @@ -0,0 +1,1005 @@ +#![forbid(unsafe_code)] + +use radroots_blossom::RadrootsBlossomSha256; +use radroots_event::{food_availability::RadrootsFoodIdentifier, ids::RadrootsPublicKey}; +use radroots_event_store::{ + RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1, + RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1, + RadrootsAddressableTransitionEventReferenceV1, RadrootsAddressableTransitionScopeV1, + RadrootsEventIngest, RadrootsEventStore, RadrootsEventStoreSourceGeneration, + RadrootsFoodAvailabilitySearchQueryV1, RadrootsFoodAvailabilityStatusFilterV1, + RadrootsNip09SuppressionEvidenceV1, RadrootsRawHeadDecision, + RadrootsStoreProducedCanonicalEventV1, RadrootsStoredFoodAvailabilityV1, + RadrootsStoredRawEvent, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +const RESULT_VECTOR_EXECUTOR_ID: &str = + "radroots_event_store.food_availability_projection_v1.result_vector_executor.v1"; +const SOURCE_GENERATION_ACTIVE_SENTINEL: &str = "active"; +const RESULT_VECTOR_BYTES: &[u8] = include_bytes!( + "../../../contracts/conformance/vectors/event_store/food_availability_projection.v1.json" +); + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct FoodAvailabilityProjectionVector { + schema_version: u32, + contract_id: String, + feed_version: u32, + projection_version: u32, + scope_kinds: Vec<u32>, + cases: Vec<ProjectionCase>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ProjectionCase { + id: String, + events: Vec<ObservedEvent>, + expected: ExpectedCase, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ObservedEvent { + role: ProjectionInputRole, + observed_at_ms: i64, + expected_ingest: ExpectedIngest, + event: SignedEvent, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +enum ProjectionInputRole { + ScopedFood, + ScopedNonFood, + UnrelatedAddressable, + Causal, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedIngest { + admission_status: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + admission_code: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + contract_id: RequiredNullable<String>, + event_class: String, + valid_stream_eligible: bool, + raw_head_decision: String, +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct SignedEvent { + id: String, + pubkey: String, + created_at: u64, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + sig: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedCase { + coordinate: ExpectedCoordinate, + #[serde(deserialize_with = "deserialize_required_nullable")] + projection: RequiredNullable<ExpectedProjection>, + searches: Vec<ExpectedSearch>, + transition_page: ExpectedTransitionPage, + event_visibility: Vec<ExpectedVisibility>, + historical_visibility_witnesses: Vec<ExpectedHistoricalVisibilityWitness>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedCoordinate { + kind: u32, + pubkey: String, + d_tag: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedProjection { + event_id: String, + content: String, + title: String, + summary: String, + published_at: u64, + location: String, + price_amount: String, + price_currency: String, + price_unit: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + quantity_amount: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + quantity_unit: RequiredNullable<String>, + status: String, + diagnostics: Vec<String>, + images: Vec<ExpectedImage>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedImage { + #[serde(deserialize_with = "deserialize_required_nullable")] + url: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + width: RequiredNullable<u32>, + #[serde(deserialize_with = "deserialize_required_nullable")] + height: RequiredNullable<u32>, + #[serde(deserialize_with = "deserialize_required_nullable")] + blossom_sha256: RequiredNullable<String>, + diagnostics: Vec<String>, + qualifies: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedSearch { + query: String, + event_ids: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransitionPage { + source_high_water: i64, + has_more: bool, + next_cursor: ExpectedTransitionCursor, + transitions: Vec<ExpectedTransition>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransitionCursor { + source_generation: String, + feed_version: u32, + scope_fingerprint: String, + last_transition_seq: i64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransition { + transition_seq: i64, + source_generation: String, + origin: String, + coordinate: ExpectedCoordinate, + raw_head: ExpectedEventReference, + raw_head_created_at: u64, + admission_status: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + admission_code: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + contract_id: RequiredNullable<String>, + visibility: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + suppression: RequiredNullable<ExpectedSuppressionEvidence>, + #[serde(deserialize_with = "deserialize_required_nullable")] + cause_event: RequiredNullable<ExpectedTransitionCause>, + #[serde(deserialize_with = "deserialize_required_nullable")] + canonical_visible_event: RequiredNullable<ExpectedCanonicalVisibleEvent>, + #[serde(deserialize_with = "deserialize_required_nullable")] + retracted_event: RequiredNullable<ExpectedEventReference>, + raw_head_decision: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedEventReference { + event_id: String, + event_seq: i64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedSuppressionEvidence { + outcome: String, + reason: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + event_reference_request_id: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + address_reference_request_id: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + address_reference_cutoff: RequiredNullable<u64>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransitionCause { + event: ExpectedEventReference, + pubkey: String, + created_at: u64, + kind: u32, + admission_status: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + admission_code: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + contract_id: RequiredNullable<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedCanonicalVisibleEvent { + event: ExpectedEventReference, + raw_json_sha256: String, + admission_status: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + contract_id: RequiredNullable<String>, + event_class: String, + valid_stream_eligible: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedVisibility { + event: ExpectedEventReference, + source_generation: String, + admission_status: String, + decision: String, + is_raw_head: bool, + #[serde(deserialize_with = "deserialize_required_nullable")] + raw_head_event_id: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + suppression: RequiredNullable<ExpectedSuppressionEvidence>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ExpectedHistoricalVisibilityWitness { + transition_seq: i64, + event_id: String, + final_decision: String, +} + +#[derive(Debug)] +struct RequiredNullable<T>(Option<T>); + +fn deserialize_required_nullable<'de, D, T>( + deserializer: D, +) -> Result<RequiredNullable<T>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::<T>::deserialize(deserializer).map(RequiredNullable) +} + +#[tokio::test] +async fn food_availability_projection_v1_result_vector() { + assert_eq!( + RESULT_VECTOR_EXECUTOR_ID, + "radroots_event_store.food_availability_projection_v1.result_vector_executor.v1" + ); + assert_eq!(sha256_hex(RESULT_VECTOR_BYTES).len(), 64); + + let vector: FoodAvailabilityProjectionVector = + serde_json::from_slice(RESULT_VECTOR_BYTES).expect("strict FoodAvailability vector"); + assert_eq!(vector.schema_version, 1); + assert_eq!( + vector.contract_id, + "radroots_event_store.food_availability_projection_v1" + ); + assert_eq!( + vector.feed_version, + RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1 + ); + assert_eq!( + vector.projection_version, + RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1 + ); + assert_eq!(vector.scope_kinds, [30_402]); + assert!(!vector.cases.is_empty()); + + for case in vector.cases { + execute_case(case).await; + } +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn raw_head_decision_code(decision: &RadrootsRawHeadDecision) -> &'static str { + match decision { + RadrootsRawHeadDecision::Applied => "applied", + RadrootsRawHeadDecision::NotHeadSelected + | RadrootsRawHeadDecision::NotPersisted + | RadrootsRawHeadDecision::SkippedDuplicate => "not_head_selected", + RadrootsRawHeadDecision::SkippedOlder => "skipped_older", + RadrootsRawHeadDecision::SkippedSameTimestampHigherEventId => { + "skipped_same_timestamp_higher_event_id" + } + RadrootsRawHeadDecision::MalformedCoordinate => "malformed_coordinate", + } +} + +async fn execute_case(case: ProjectionCase) { + let store = RadrootsEventStore::open_memory() + .await + .unwrap_or_else(|error| panic!("{}: open event store: {error}", case.id)); + + for (index, observed) in case.events.iter().enumerate() { + let (expected_kind, expected_event_class) = match observed.role { + ProjectionInputRole::ScopedFood | ProjectionInputRole::ScopedNonFood => { + (30_402, "addressable") + } + ProjectionInputRole::UnrelatedAddressable => (30_340, "addressable"), + ProjectionInputRole::Causal => (5, "regular"), + }; + assert_eq!( + observed.event.kind, expected_kind, + "{}: input role", + case.id + ); + assert_eq!( + observed.expected_ingest.event_class, expected_event_class, + "{}: input role event class", + case.id + ); + let raw_json = serde_json::to_string(&observed.event) + .unwrap_or_else(|error| panic!("{}: serialize signed event: {error}", case.id)); + let ingest = RadrootsEventIngest::from_raw_json(raw_json, observed.observed_at_ms) + .unwrap_or_else(|error| panic!("{}: verify signed event: {error}", case.id)); + let receipt = store + .ingest_event(ingest) + .await + .unwrap_or_else(|error| panic!("{}: ingest signed event: {error}", case.id)); + assert_eq!(receipt.event_id, observed.event.id, "{}", case.id); + let expected_sequence = i64::try_from(index + 1).expect("fixture sequence fits i64"); + assert!( + receipt.persistence.is_inserted(), + "{}: input persistence", + case.id + ); + assert_eq!( + receipt.persistence.sequence(), + Some(expected_sequence), + "{}: input sequence", + case.id + ); + assert_eq!( + receipt.admission_status.as_str(), + observed.expected_ingest.admission_status, + "{}: input admission", + case.id + ); + assert_eq!( + receipt.admission_code.as_deref(), + observed.expected_ingest.admission_code.0.as_deref(), + "{}: input admission code", + case.id + ); + assert_eq!( + receipt.contract_id.as_deref(), + observed.expected_ingest.contract_id.0.as_deref(), + "{}: input contract", + case.id + ); + assert_eq!( + receipt.valid_stream_eligible, observed.expected_ingest.valid_stream_eligible, + "{}: input stream eligibility", + case.id + ); + assert_eq!( + raw_head_decision_code(&receipt.raw_head_decision), + observed.expected_ingest.raw_head_decision, + "{}: input raw-head decision", + case.id + ); + } + let active_generation = store + .source_generation() + .await + .unwrap_or_else(|error| panic!("{}: active source generation: {error}", case.id)); + + assert_eq!(case.expected.coordinate.kind, 30_402, "{}", case.id); + let public_key = RadrootsPublicKey::parse(&case.expected.coordinate.pubkey) + .unwrap_or_else(|error| panic!("{}: expected public key: {error}", case.id)); + let identifier = RadrootsFoodIdentifier::parse(&case.expected.coordinate.d_tag) + .unwrap_or_else(|error| panic!("{}: expected identifier: {error}", case.id)); + let projection = store + .food_availability_v1(&public_key, &identifier) + .await + .unwrap_or_else(|error| panic!("{}: load projection: {error}", case.id)); + + match (&case.expected.projection.0, projection.as_ref()) { + (Some(expected), Some(actual)) => assert_projection(&case.id, expected, actual), + (None, None) => {} + (expected, actual) => panic!( + "{}: projection presence mismatch: expected={}, actual={}", + case.id, + expected.is_some(), + actual.is_some() + ), + } + + let recent = store + .recent_food_availability_v1(RadrootsFoodAvailabilityStatusFilterV1::Any, 16) + .await + .unwrap_or_else(|error| panic!("{}: recent projection query: {error}", case.id)); + assert_eq!( + recent.len(), + usize::from(case.expected.projection.0.is_some()), + "{}: recent projection count", + case.id + ); + + for expected in &case.expected.searches { + let query = RadrootsFoodAvailabilitySearchQueryV1::parse(&expected.query) + .unwrap_or_else(|error| panic!("{}: parse search query: {error}", case.id)); + let actual = store + .search_food_availability_v1(&query, RadrootsFoodAvailabilityStatusFilterV1::Any, 16) + .await + .unwrap_or_else(|error| panic!("{}: search projection: {error}", case.id)); + let event_ids = actual + .iter() + .map(|projection| projection.event_id().as_str()) + .collect::<Vec<_>>(); + assert_eq!(event_ids, expected.event_ids, "{}: search", case.id); + } + + let scope = RadrootsAddressableTransitionScopeV1::food_availability(); + assert_eq!(scope.kinds(), [30_402]); + let page = store + .addressable_transition_page_v1(&scope, None, 64) + .await + .unwrap_or_else(|error| panic!("{}: load transition feed: {error}", case.id)); + let expected_page = &case.expected.transition_page; + assert_eq!( + page.source_high_water(), + expected_page.source_high_water, + "{}", + case.id + ); + assert_eq!(page.has_more(), expected_page.has_more, "{}", case.id); + assert_active_generation( + &case.id, + "cursor source generation", + &expected_page.next_cursor.source_generation, + page.next_cursor().source_generation(), + active_generation, + ); + assert_eq!( + page.next_cursor().feed_version(), + expected_page.next_cursor.feed_version, + "{}: cursor feed version", + case.id + ); + assert_eq!( + page.next_cursor().scope_fingerprint().to_hex(), + expected_page.next_cursor.scope_fingerprint, + "{}: cursor scope fingerprint", + case.id + ); + assert_eq!( + page.next_cursor().last_transition_seq(), + expected_page.next_cursor.last_transition_seq, + "{}: cursor transition sequence", + case.id + ); + assert_eq!( + page.transitions().len(), + expected_page.transitions.len(), + "{}: transition count", + case.id + ); + for (actual, expected) in page.transitions().iter().zip(&expected_page.transitions) { + assert_eq!( + actual.transition_seq(), + expected.transition_seq, + "{}", + case.id + ); + assert_active_generation( + &case.id, + "transition source generation", + &expected.source_generation, + actual.source_generation(), + active_generation, + ); + assert_eq!(actual.origin().as_str(), expected.origin, "{}", case.id); + assert_eq!( + actual.coordinate().kind(), + expected.coordinate.kind, + "{}", + case.id + ); + assert_eq!( + actual.coordinate().pubkey().as_str(), + expected.coordinate.pubkey, + "{}", + case.id + ); + assert_eq!( + actual.coordinate().d_tag(), + expected.coordinate.d_tag, + "{}", + case.id + ); + assert_event_reference(&case.id, "raw head", &expected.raw_head, actual.raw_head()); + assert_eq!( + actual.raw_head_created_at(), + expected.raw_head_created_at, + "{}: raw-head timestamp", + case.id + ); + assert_eq!( + actual.admission_status().as_str(), + expected.admission_status, + "{}: admission status", + case.id + ); + assert_eq!( + actual.admission_code(), + expected.admission_code.0.as_deref(), + "{}: admission code", + case.id + ); + assert_eq!( + actual.contract_id(), + expected.contract_id.0.as_deref(), + "{}: contract id", + case.id + ); + assert_eq!( + actual.visibility().as_str(), + expected.visibility, + "{}: transition visibility", + case.id + ); + assert_optional_suppression( + &case.id, + expected.suppression.0.as_ref(), + actual.suppression(), + ); + match (expected.cause_event.0.as_ref(), actual.cause_event()) { + (Some(expected), Some(actual)) => { + assert_event_reference(&case.id, "cause", &expected.event, actual.event()); + assert_eq!(actual.pubkey().as_str(), expected.pubkey, "{}", case.id); + assert_eq!(actual.created_at(), expected.created_at, "{}", case.id); + assert_eq!(actual.kind(), expected.kind, "{}", case.id); + assert_eq!( + actual.admission_status().as_str(), + expected.admission_status, + "{}", + case.id + ); + assert_eq!( + actual.admission_code(), + expected.admission_code.0.as_deref(), + "{}", + case.id + ); + assert_eq!( + actual.contract_id(), + expected.contract_id.0.as_deref(), + "{}", + case.id + ); + } + (None, None) => {} + (expected, actual) => panic!( + "{}: cause presence mismatch: expected={}, actual={}", + case.id, + expected.is_some(), + actual.is_some() + ), + } + match ( + expected.canonical_visible_event.0.as_ref(), + actual.visible_event(), + ) { + (Some(expected), Some(actual)) => { + assert_canonical_visible_event(&case, expected, actual) + } + (None, None) => {} + (expected, actual) => panic!( + "{}: canonical visible-event presence mismatch: expected={}, actual={}", + case.id, + expected.is_some(), + actual.is_some() + ), + } + match ( + expected.retracted_event.0.as_ref(), + actual.retracted_event(), + ) { + (Some(expected), Some(actual)) => { + assert_event_reference(&case.id, "retracted event", expected, actual) + } + (None, None) => {} + (expected, actual) => panic!( + "{}: retracted-event presence mismatch: expected={}, actual={}", + case.id, + expected.is_some(), + actual.is_some() + ), + } + assert_eq!( + actual.raw_head_decision().as_str(), + expected.raw_head_decision, + "{}: raw-head decision", + case.id + ); + } + assert_eq!( + page.next_cursor().last_transition_seq(), + page.source_high_water(), + "{}: cursor reaches captured high-water", + case.id + ); + + for expected in &case.expected.event_visibility { + let actual = store + .current_event_visibility_v1(&expected.event.event_id) + .await + .unwrap_or_else(|error| panic!("{}: current visibility: {error}", case.id)) + .unwrap_or_else(|| panic!("{}: expected stored event visibility", case.id)); + assert_active_generation( + &case.id, + "current visibility source generation", + &expected.source_generation, + actual.source_generation(), + active_generation, + ); + assert_stored_event_matches_input(&case, &expected.event, actual.event()); + assert_eq!( + actual.admission_status().as_str(), + expected.admission_status, + "{}: current admission status", + case.id + ); + assert_eq!( + actual.decision().as_str(), + expected.decision, + "{}: current decision", + case.id + ); + assert_eq!(actual.is_raw_head(), expected.is_raw_head, "{}", case.id); + assert_eq!( + actual.raw_head_event_id().map(|event_id| event_id.as_str()), + expected.raw_head_event_id.0.as_deref(), + "{}: current raw head", + case.id + ); + assert_optional_suppression( + &case.id, + expected.suppression.0.as_ref(), + actual.suppression(), + ); + } + + for witness in &case.expected.historical_visibility_witnesses { + let historical = page + .transitions() + .iter() + .find(|transition| transition.transition_seq() == witness.transition_seq) + .unwrap_or_else(|| panic!("{}: historical transition is absent", case.id)); + let historical_payload = historical + .visible_event() + .unwrap_or_else(|| panic!("{}: historical visible payload is absent", case.id)); + assert_eq!( + historical_payload.event_id().as_str(), + witness.event_id, + "{}: historical visible payload identity", + case.id + ); + let expected_final = case + .expected + .event_visibility + .iter() + .find(|visibility| visibility.event.event_id == witness.event_id) + .unwrap_or_else(|| panic!("{}: final visibility witness is absent", case.id)); + assert_eq!( + expected_final.decision, witness.final_decision, + "{}: historical/final witness contract", + case.id + ); + assert_ne!( + witness.final_decision, "visible", + "{}: historical payload must diverge from final visibility", + case.id + ); + let final_visibility = store + .current_event_visibility_v1(&witness.event_id) + .await + .unwrap_or_else(|error| panic!("{}: final historical visibility: {error}", case.id)) + .unwrap_or_else(|| panic!("{}: final historical event is absent", case.id)); + assert_eq!( + final_visibility.decision().as_str(), + witness.final_decision, + "{}: transition-time payload coexists with final visibility", + case.id + ); + } + + store + .audit_food_availability_projection_v1() + .await + .unwrap_or_else(|error| panic!("{}: exhaustive projection audit: {error}", case.id)); +} + +fn assert_active_generation( + case_id: &str, + label: &str, + expected: &str, + actual: RadrootsEventStoreSourceGeneration, + active: RadrootsEventStoreSourceGeneration, +) { + assert_eq!( + expected, SOURCE_GENERATION_ACTIVE_SENTINEL, + "{case_id}: {label}" + ); + assert_eq!(actual, active, "{case_id}: {label}"); +} + +fn assert_event_reference( + case_id: &str, + label: &str, + expected: &ExpectedEventReference, + actual: &RadrootsAddressableTransitionEventReferenceV1, +) { + assert_eq!( + actual.event_id().as_str(), + expected.event_id, + "{case_id}: {label}" + ); + assert_eq!(actual.event_seq(), expected.event_seq, "{case_id}: {label}"); +} + +fn assert_optional_suppression( + case_id: &str, + expected: Option<&ExpectedSuppressionEvidence>, + actual: Option<&RadrootsNip09SuppressionEvidenceV1>, +) { + match (expected, actual) { + (Some(expected), Some(actual)) => { + assert_eq!(actual.outcome().code(), expected.outcome, "{case_id}"); + assert_eq!(actual.reason().code(), expected.reason, "{case_id}"); + assert_eq!( + actual + .event_reference_request_id() + .map(|event_id| event_id.as_str()), + expected.event_reference_request_id.0.as_deref(), + "{case_id}" + ); + assert_eq!( + actual + .address_reference_request_id() + .map(|event_id| event_id.as_str()), + expected.address_reference_request_id.0.as_deref(), + "{case_id}" + ); + assert_eq!( + actual.address_reference_cutoff(), + expected.address_reference_cutoff.0, + "{case_id}" + ); + } + (None, None) => {} + (expected, actual) => panic!( + "{case_id}: suppression presence mismatch: expected={}, actual={}", + expected.is_some(), + actual.is_some() + ), + } +} + +fn assert_canonical_visible_event( + case: &ProjectionCase, + expected: &ExpectedCanonicalVisibleEvent, + actual: &RadrootsStoreProducedCanonicalEventV1, +) { + let observed = case + .events + .iter() + .find(|observed| observed.event.id == expected.event.event_id) + .unwrap_or_else(|| panic!("{}: expected event is absent from input", case.id)); + assert_eq!(actual.event_id().as_str(), observed.event.id, "{}", case.id); + assert_eq!( + actual.pubkey().as_str(), + observed.event.pubkey, + "{}", + case.id + ); + assert_eq!( + actual.created_at(), + observed.event.created_at, + "{}", + case.id + ); + assert_eq!(actual.kind(), observed.event.kind, "{}", case.id); + let decoded: SignedEvent = serde_json::from_str(actual.raw_json()) + .unwrap_or_else(|error| panic!("{}: decode canonical raw JSON: {error}", case.id)); + assert_eq!( + decoded, observed.event, + "{}: canonical signed payload", + case.id + ); + assert_eq!( + sha256_hex(actual.raw_json().as_bytes()), + expected.raw_json_sha256, + "{}: canonical raw JSON digest", + case.id + ); + assert_eq!( + observed.expected_ingest.admission_status, expected.admission_status, + "{}", + case.id + ); + assert_eq!( + observed.expected_ingest.contract_id.0.as_deref(), + expected.contract_id.0.as_deref(), + "{}", + case.id + ); + assert_eq!( + observed.expected_ingest.event_class, expected.event_class, + "{}", + case.id + ); + assert_eq!( + observed.expected_ingest.valid_stream_eligible, expected.valid_stream_eligible, + "{}", + case.id + ); +} + +fn assert_stored_event_matches_input( + case: &ProjectionCase, + expected: &ExpectedEventReference, + actual: &RadrootsStoredRawEvent, +) { + let observed = case + .events + .iter() + .find(|observed| observed.event.id == expected.event_id) + .unwrap_or_else(|| panic!("{}: expected event is absent from input", case.id)); + let raw_json = serde_json::to_string(&observed.event) + .unwrap_or_else(|error| panic!("{}: serialize expected event: {error}", case.id)); + let tags_json = serde_json::to_string(&observed.event.tags) + .unwrap_or_else(|error| panic!("{}: serialize expected tags: {error}", case.id)); + assert_eq!(actual.seq, expected.event_seq, "{}", case.id); + assert_eq!(actual.event_id, observed.event.id, "{}", case.id); + assert_eq!(actual.pubkey, observed.event.pubkey, "{}", case.id); + assert_eq!(actual.created_at, observed.event.created_at, "{}", case.id); + assert_eq!(actual.kind, observed.event.kind, "{}", case.id); + assert_eq!(actual.tags_json, tags_json, "{}", case.id); + assert_eq!(actual.content, observed.event.content, "{}", case.id); + assert_eq!(actual.sig, observed.event.sig, "{}", case.id); + assert_eq!(actual.raw_json, raw_json, "{}", case.id); + assert_eq!( + actual.admission_status.as_str(), + observed.expected_ingest.admission_status, + "{}", + case.id + ); + assert_eq!( + actual.contract_id.as_deref(), + observed.expected_ingest.contract_id.0.as_deref(), + "{}", + case.id + ); + assert_eq!( + actual.event_class.as_str(), + observed.expected_ingest.event_class, + "{}", + case.id + ); + assert_eq!( + actual.valid_stream_eligible, observed.expected_ingest.valid_stream_eligible, + "{}", + case.id + ); + assert_eq!( + actual.inserted_at_ms, observed.observed_at_ms, + "{}", + case.id + ); + assert_eq!(actual.updated_at_ms, observed.observed_at_ms, "{}", case.id); +} + +fn assert_projection( + case_id: &str, + expected: &ExpectedProjection, + actual: &RadrootsStoredFoodAvailabilityV1, +) { + assert_eq!(actual.event_id().as_str(), expected.event_id, "{case_id}"); + assert_eq!(actual.content().as_str(), expected.content, "{case_id}"); + assert_eq!(actual.title().as_str(), expected.title, "{case_id}"); + assert_eq!(actual.summary().as_str(), expected.summary, "{case_id}"); + assert_eq!( + actual.published_at().as_u64(), + expected.published_at, + "{case_id}" + ); + assert_eq!(actual.location().as_str(), expected.location, "{case_id}"); + assert_eq!(actual.price().amount(), expected.price_amount, "{case_id}"); + assert_eq!( + actual.price().currency().as_str(), + expected.price_currency, + "{case_id}" + ); + assert_eq!( + actual.price().unit().as_str(), + expected.price_unit, + "{case_id}" + ); + assert_eq!( + actual.quantity().map(|quantity| quantity.amount()), + expected.quantity_amount.0.as_deref(), + "{case_id}" + ); + assert_eq!( + actual.quantity().map(|quantity| quantity.unit().as_str()), + expected.quantity_unit.0.as_deref(), + "{case_id}" + ); + assert_eq!(actual.status().as_str(), expected.status, "{case_id}"); + assert_eq!( + actual + .diagnostics() + .iter() + .map(|diagnostic| diagnostic.code()) + .collect::<Vec<_>>(), + expected.diagnostics, + "{case_id}: projection diagnostics" + ); + assert_eq!(actual.images().len(), expected.images.len(), "{case_id}"); + + for (actual, expected) in actual.images().iter().zip(&expected.images) { + assert_eq!(actual.url(), expected.url.0.as_deref(), "{case_id}"); + assert_eq!( + actual.dimensions().map(|dimensions| dimensions.width()), + expected.width.0, + "{case_id}" + ); + assert_eq!( + actual.dimensions().map(|dimensions| dimensions.height()), + expected.height.0, + "{case_id}" + ); + let expected_blossom_sha256 = expected.blossom_sha256.0.as_deref().map(|value| { + RadrootsBlossomSha256::from_hex(value) + .unwrap_or_else(|error| panic!("{case_id}: expected Blossom digest: {error}")) + }); + assert_eq!( + actual.blossom_sha256(), + expected_blossom_sha256, + "{case_id}" + ); + assert_eq!( + actual + .diagnostics() + .iter() + .map(|diagnostic| diagnostic.code()) + .collect::<Vec<_>>(), + expected.diagnostics, + "{case_id}: image diagnostics" + ); + assert_eq!(actual.qualifies(), expected.qualifies, "{case_id}"); + } +} + +#[test] +fn food_availability_projection_v1_vector_requires_complete_nullable_fields() { + let vector: serde_json::Value = + serde_json::from_slice(RESULT_VECTOR_BYTES).expect("result-vector JSON"); + let mut missing = vector.clone(); + missing["cases"][0]["expected"]["projection"] + .as_object_mut() + .expect("expected projection") + .remove("quantity_amount"); + assert!( + serde_json::from_value::<FoodAvailabilityProjectionVector>(missing).is_err(), + "nullable result fields must still be present" + ); +} diff --git a/crates/replica_sync/Cargo.toml b/crates/replica_sync/Cargo.toml @@ -20,11 +20,14 @@ default = ["std"] std = [ "radroots_event/std", "radroots_event_codec/std", - "radroots_event_codec/nostr", "radroots_sql_core/std", - "dep:base64", - "dep:uuid", ] +legacy-ingest = ["std", "radroots_event_codec/nostr", "dep:base64", "dep:uuid"] + +[[test]] +name = "ingest_roundtrip" +path = "tests/ingest_roundtrip.rs" +required-features = ["legacy-ingest"] [dependencies] radroots_event = { workspace = true, default-features = false, features = [ diff --git a/crates/replica_sync/README b/crates/replica_sync/README @@ -9,10 +9,15 @@ and synchronization interfaces for the `radroots` core libraries. sync flows; * event-emission helpers that materialize replica-facing events from local state; - * ingest helpers, outcomes, and id-factory abstractions for applying incoming - events; - * a `std` feature that adds default id generation and higher-level ingest - entry points. + * an explicit, non-default `legacy-ingest` feature for the historical + bare-envelope projection helpers; + * a `std` feature for the portable emit and sync surfaces without legacy + ingestion. + +The legacy ingest module is excluded from default builds. It accepts bare event +envelopes and does not consume the event store's verified, valid-stream, and +current-visibility typestates, so it is not a Phase 1 product ingestion API. +New consumers must project only store-produced current visible admissions. ## Copyright diff --git a/crates/replica_sync/src/lib.rs b/crates/replica_sync/src/lib.rs @@ -10,6 +10,7 @@ pub mod emit; pub mod error; mod event_head; mod geo; +#[cfg(feature = "legacy-ingest")] pub mod ingest; pub mod sync_state; pub mod types; @@ -20,6 +21,7 @@ pub use emit::{ radroots_replica_sync_all, radroots_replica_sync_all_with_options, }; pub use error::RadrootsReplicaEventsError; +#[cfg(feature = "legacy-ingest")] pub use ingest::{ RadrootsReplicaIdFactory, RadrootsReplicaIngestOutcome, radroots_replica_ingest_event_head, radroots_replica_ingest_event_with_factory, @@ -34,7 +36,7 @@ pub use types::{ RadrootsReplicaSyncBundle, RadrootsReplicaSyncOptions, RadrootsReplicaSyncRequest, }; -#[cfg(feature = "std")] +#[cfg(feature = "legacy-ingest")] pub use ingest::{RadrootsReplicaDefaultIdFactory, radroots_replica_ingest_event}; #[cfg(test)] diff --git a/crates/transport_nostr/README b/crates/transport_nostr/README @@ -6,19 +6,34 @@ fetch ingest, and outbox delivery target coordination. Every fetch path verifies the NIP-01 id and signature before filter matching, unique-event budgeting, or returning an event. Repeated event ids preserve per-relay observation evidence without consuming the unique-event limit. The -SDK adapter streams into the configured raw-event bound; `Truncated` is distinct -from relay `EOSE`, including for later target relays that were not queried after -the global bound was reached. +unique-event limit is bounded at 1,000 so final stored-event visibility can be +evaluated in one coherent event-store snapshot. A fetch scans at most 64,000 +raw events and 64 MiB of aggregate raw JSON, and rejects any individual raw +event over 256 KiB before Radroots parses adapter raw JSON. Count and byte +budgets are charged globally, in adapter order, before Radroots parsing, +filtering, deduplication, or accepted-event limiting, so malformed and otherwise +rejected events cannot bypass them. The official SDK adapter enforces the same +retained-prefix budgets after SDK frame/event decoding and before retaining its +serialized JSON; the connector's upstream frame parser remains responsible for +its own first-pass network limits. `Truncated` is distinct from relay `EOSE`, +including for later target relays that were not queried after a global count or +byte budget was reached. -Fetch-ingest receipts distinguish unsupported contracts, invalid registered -shapes, malformed NIP-01 input, ephemeral events that were not persisted, and -immutable valid-stream eligibility. `admission_code` carries the stable -classifier diagnostic when the store performed classification; it is absent for -duplicates because the baseline schema does not persist that code. Inserted, -duplicate, and not-persisted outcomes have separate flags and aggregate counts. -Local event-store failures abort the operation and remain typed transport -errors, so callers can retry without confusing storage failure with bad relay -input. +Fetch-ingest receipts report verification, contract admission, immutable +valid-stream eligibility, and current visibility as independent exhaustive +enums. Verification failure is not a contract-invalid result, and unsupported +admission does not hide whether the stored event is current, suppressed, or not +admitted. Persisted events obtain visibility from the event store's central +authority after the complete accepted fetch batch has been ingested, so event +receipts and aggregate visibility counts describe final post-batch state rather +than transient per-item state. Repeated receipt IDs are deduplicated before the +single snapshot lookup and then mapped back to every receipt. Ephemeral events +use the explicit `not_persisted` visibility result. +`admission_code` carries the stable classifier diagnostic when classification +produces one. Inserted, duplicate, and not-persisted persistence outcomes retain +separate flags and aggregate counts. Local event-store failures abort the +operation and remain typed transport errors, so callers can retry without +confusing storage failure with bad relay input. `RadrootsRelayUrlPolicy::Public` is for trusted relay configuration. It rejects non-canonical and known non-global literal destinations, but hostname checks do diff --git a/crates/transport_nostr/src/error.rs b/crates/transport_nostr/src/error.rs @@ -86,6 +86,13 @@ pub enum RadrootsRelayTransportError { #[error("Relay fetch {field} must be greater than zero")] InvalidFetchLimit { field: &'static str }, + #[error("Relay fetch {field} {actual} exceeds maximum {max}")] + FetchLimitTooLarge { + field: &'static str, + max: usize, + actual: usize, + }, + #[error("Relay transport {field} cannot be negative: {value}")] InvalidTimestamp { field: &'static str, value: i64 }, @@ -115,6 +122,18 @@ pub enum RadrootsRelayTransportError { EventStore(#[from] radroots_event_store::RadrootsEventStoreError), #[cfg(feature = "storage")] + #[error("Event store returned no current visibility for persisted event `{event_id}`")] + MissingStoredEventVisibility { event_id: String }, + + #[cfg(feature = "storage")] + #[error("Persisted relay fetch event receipt is missing its event id")] + MissingPersistedFetchReceiptEventId, + + #[cfg(feature = "storage")] + #[error("Event store returned an unsupported current visibility for event `{event_id}`")] + UnsupportedStoredEventVisibility { event_id: String }, + + #[cfg(feature = "storage")] #[error("Outbox error: {0}")] Outbox(#[from] radroots_outbox::RadrootsOutboxError), diff --git a/crates/transport_nostr/src/fetch.rs b/crates/transport_nostr/src/fetch.rs @@ -5,9 +5,11 @@ use crate::{RadrootsRelayOutcome, RadrootsRelayTargetSet, RadrootsRelayTransport use core::time::Duration; use futures::{StreamExt, future::BoxFuture}; use nostr::{JsonUtil, filter::MatchEventOptions}; +use radroots_event::wire::v1::DEFAULT_RAW_JSON_MAX_BYTES; use radroots_event_store::{ - RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventPersistence, - RadrootsEventStore, RadrootsTransportObservation, RadrootsTransportObservationType, + RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventAdmissionStatus, RadrootsEventIngest, + RadrootsEventPersistence, RadrootsEventStore, RadrootsEventVisibility, + RadrootsTransportObservation, RadrootsTransportObservationType, }; use radroots_nostr::prelude::{RadrootsNostrClient, RadrootsNostrEvent, RadrootsNostrFilter}; use radroots_transport::{RadrootsTransportKind, RadrootsTransportTarget}; @@ -17,6 +19,11 @@ use std::sync::{Arc, Mutex, PoisonError}; const DEFAULT_RELAY_FETCH_TIMEOUT_MS: u64 = 10_000; const DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER: usize = 64; +pub const RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX: usize = + RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX as usize; +pub const RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX: usize = + RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX * DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER; +pub const RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX: usize = 64 * 1024 * 1024; #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RadrootsRelayFetchMode { @@ -58,6 +65,7 @@ pub struct RadrootsRelayFetchRequest { observed_at_ms: i64, max_events: usize, max_raw_events: usize, + max_raw_json_bytes: usize, relay_targets: RadrootsRelayTargetSet, filters: RadrootsRelayFetchFilters, timeout_ms: u64, @@ -111,12 +119,13 @@ impl RadrootsRelayFetchRequest { I: IntoIterator<Item = RadrootsNostrFilter>, { ensure_nonnegative_timestamp("observed_at_ms", observed_at_ms)?; - ensure_positive_limit("max_events", max_events)?; + ensure_event_limit("max_events", max_events)?; Ok(Self { mode, observed_at_ms, max_events, max_raw_events: default_raw_event_scan_limit(max_events), + max_raw_json_bytes: RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, relay_targets, filters: RadrootsRelayFetchFilters::new(filters)?, timeout_ms: DEFAULT_RELAY_FETCH_TIMEOUT_MS, @@ -133,11 +142,28 @@ impl RadrootsRelayFetchRequest { mut self, max_raw_events: usize, ) -> Result<Self, RadrootsRelayTransportError> { - ensure_positive_limit("max_raw_events", max_raw_events)?; + ensure_bounded_fetch_limit( + "max_raw_events", + max_raw_events, + RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, + )?; self.max_raw_events = max_raw_events; Ok(self) } + pub fn with_raw_json_byte_limit( + mut self, + max_raw_json_bytes: usize, + ) -> Result<Self, RadrootsRelayTransportError> { + ensure_bounded_fetch_limit( + "max_raw_json_bytes", + max_raw_json_bytes, + RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, + )?; + self.max_raw_json_bytes = max_raw_json_bytes; + Ok(self) + } + pub fn mode(&self) -> RadrootsRelayFetchMode { self.mode } @@ -154,6 +180,10 @@ impl RadrootsRelayFetchRequest { self.max_raw_events } + pub fn max_raw_json_bytes(&self) -> usize { + self.max_raw_json_bytes + } + pub fn relay_targets(&self) -> &RadrootsRelayTargetSet { &self.relay_targets } @@ -171,6 +201,7 @@ fn default_raw_event_scan_limit(max_events: usize) -> usize { max_events .saturating_mul(DEFAULT_RELAY_FETCH_RAW_SCAN_MULTIPLIER) .max(max_events) + .min(RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX) } fn ensure_positive_limit( @@ -183,6 +214,29 @@ fn ensure_positive_limit( Ok(()) } +fn ensure_event_limit( + field: &'static str, + value: usize, +) -> Result<(), RadrootsRelayTransportError> { + ensure_bounded_fetch_limit(field, value, RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX) +} + +fn ensure_bounded_fetch_limit( + field: &'static str, + value: usize, + max: usize, +) -> Result<(), RadrootsRelayTransportError> { + ensure_positive_limit(field, value)?; + if value > max { + return Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field, + max, + actual: value, + }); + } + Ok(()) +} + fn ensure_positive_timeout( field: &'static str, value: u64, @@ -218,6 +272,42 @@ pub struct RadrootsRelayFetchRelayOutcome { pub message: Option<String>, } +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RadrootsRelayFetchEventVerification { + NotEvaluated, + Verified, + Failed, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RadrootsRelayFetchEventAdmission { + NotEvaluated, + Admitted, + Unsupported, + Invalid, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RadrootsRelayFetchEventValidStream { + NotEvaluated, + Eligible, + Ineligible, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RadrootsRelayFetchEventVisibility { + NotEvaluated, + NotPersisted, + Visible, + NotAdmitted, + NotCurrent, + Suppressed, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RadrootsRelayFetchEventReceipt { pub relay_url: String, @@ -225,14 +315,14 @@ pub struct RadrootsRelayFetchEventReceipt { pub inserted: bool, pub duplicate: bool, pub not_persisted: bool, - pub unsupported: bool, - pub invalid: bool, pub malformed: bool, pub out_of_filter: bool, pub skipped_over_limit: bool, - pub valid_stream_eligible: bool, - pub admission_status: Option<String>, + pub verification: RadrootsRelayFetchEventVerification, + pub admission: RadrootsRelayFetchEventAdmission, pub admission_code: Option<String>, + pub valid_stream: RadrootsRelayFetchEventValidStream, + pub visibility: RadrootsRelayFetchEventVisibility, pub message: Option<String>, } @@ -258,7 +348,7 @@ pub struct RadrootsRelayFetchedEventsReceipt { pub events: Vec<RadrootsRelayFetchedEvent>, pub event_receipts: Vec<RadrootsRelayFetchEventReceipt>, pub duplicate_count: usize, - pub invalid_count: usize, + pub verification_failed_count: usize, pub malformed_count: usize, pub out_of_filter_count: usize, pub skipped_over_limit_count: usize, @@ -277,8 +367,14 @@ pub struct RadrootsRelayFetchReceipt { pub malformed_count: usize, pub out_of_filter_count: usize, pub skipped_over_limit_count: usize, - pub unsupported_count: usize, - pub invalid_count: usize, + pub verification_failed_count: usize, + pub admission_unsupported_count: usize, + pub admission_invalid_count: usize, + pub valid_stream_eligible_count: usize, + pub visible_count: usize, + pub not_admitted_count: usize, + pub not_current_count: usize, + pub suppressed_count: usize, pub eose_count: usize, pub truncated_count: usize, pub closed_count: usize, @@ -305,6 +401,7 @@ where let observed_at_ms = request.observed_at_ms; let max_events = request.max_events; let max_raw_events = request.max_raw_events; + let max_raw_json_bytes = request.max_raw_json_bytes; let filters = request.filters.as_slice().to_vec(); let items = adapter.fetch(request).await?; Ok(process_relay_fetch_items( @@ -313,6 +410,7 @@ where observed_at_ms, max_events, max_raw_events, + max_raw_json_bytes, items, )? .into_fetched_events_receipt()) @@ -347,6 +445,7 @@ where let observed_at_ms = request.observed_at_ms; let max_events = request.max_events; let max_raw_events = request.max_raw_events; + let max_raw_json_bytes = request.max_raw_json_bytes; let filters = request.filters.as_slice().to_vec(); let items = adapter.fetch(request).await?; let processed = process_relay_fetch_items( @@ -355,6 +454,7 @@ where observed_at_ms, max_events, max_raw_events, + max_raw_json_bytes, items, )?; let mut receipt = RadrootsRelayFetchReceipt::from_processed_counts(&processed); @@ -390,31 +490,33 @@ where let ingest = match RadrootsEventIngest::from_raw_json(raw_json, observed_at_ms) { Ok(ingest) => ingest.with_observation(observation), Err(error) => { - receipt.malformed_count += 1; + receipt.verification_failed_count += 1; receipt.events.push(RadrootsRelayFetchEventReceipt { relay_url, event_id: Some(raw_event.id.to_hex()), inserted: false, duplicate: false, not_persisted: false, - unsupported: false, - invalid: false, - malformed: true, + malformed: false, out_of_filter: false, skipped_over_limit: false, - valid_stream_eligible: false, - admission_status: None, + verification: RadrootsRelayFetchEventVerification::Failed, + admission: RadrootsRelayFetchEventAdmission::NotEvaluated, admission_code: None, + valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, + visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some(error.to_string()), }); continue; } }; let store_receipt = event_store.ingest_event(ingest).await?; - let unsupported = - store_receipt.admission_status == RadrootsEventAdmissionStatus::Unsupported; - let invalid = - store_receipt.admission_status == RadrootsEventAdmissionStatus::Invalid; + let admission = relay_fetch_admission(store_receipt.admission_status); + let valid_stream = if store_receipt.valid_stream_eligible { + RadrootsRelayFetchEventValidStream::Eligible + } else { + RadrootsRelayFetchEventValidStream::Ineligible + }; let (inserted, duplicate, not_persisted) = match store_receipt.persistence { RadrootsEventPersistence::Inserted { .. } => { receipt.inserted_count += 1; @@ -429,34 +531,72 @@ where (false, false, true) } }; - if unsupported { - receipt.unsupported_count += 1; - } - if invalid { - receipt.invalid_count += 1; - } - receipt.events.push(RadrootsRelayFetchEventReceipt { + let visibility = if not_persisted { + RadrootsRelayFetchEventVisibility::NotPersisted + } else { + RadrootsRelayFetchEventVisibility::NotEvaluated + }; + let event_receipt = RadrootsRelayFetchEventReceipt { relay_url, event_id: Some(store_receipt.event_id), inserted, duplicate, not_persisted, - unsupported, - invalid, malformed: false, out_of_filter: false, skipped_over_limit: false, - valid_stream_eligible: store_receipt.valid_stream_eligible, - admission_status: Some(store_receipt.admission_status.as_str().to_owned()), + verification: RadrootsRelayFetchEventVerification::Verified, + admission, admission_code: store_receipt.admission_code, + valid_stream, + visibility, message: None, - }); + }; + receipt.events.push(event_receipt); } } } + receipt.refresh_final_semantic_outcomes(event_store).await?; Ok(receipt) } +fn relay_fetch_admission( + admission: RadrootsEventAdmissionStatus, +) -> RadrootsRelayFetchEventAdmission { + match admission { + RadrootsEventAdmissionStatus::Admitted => RadrootsRelayFetchEventAdmission::Admitted, + RadrootsEventAdmissionStatus::Unsupported => RadrootsRelayFetchEventAdmission::Unsupported, + RadrootsEventAdmissionStatus::Invalid => RadrootsRelayFetchEventAdmission::Invalid, + } +} + +fn relay_fetch_visibility( + event_id: &str, + visibility: RadrootsEventVisibility, +) -> Result<RadrootsRelayFetchEventVisibility, RadrootsRelayTransportError> { + match visibility { + RadrootsEventVisibility::Visible => Ok(RadrootsRelayFetchEventVisibility::Visible), + RadrootsEventVisibility::NotAdmitted => Ok(RadrootsRelayFetchEventVisibility::NotAdmitted), + RadrootsEventVisibility::NotCurrent { .. } => { + Ok(RadrootsRelayFetchEventVisibility::NotCurrent) + } + RadrootsEventVisibility::Suppressed { .. } => { + Ok(RadrootsRelayFetchEventVisibility::Suppressed) + } + _ => Err( + RadrootsRelayTransportError::UnsupportedStoredEventVisibility { + event_id: event_id.to_owned(), + }, + ), + } +} + +fn required_persisted_fetch_receipt_event_id( + event_id: Option<&str>, +) -> Result<&str, RadrootsRelayTransportError> { + event_id.ok_or(RadrootsRelayTransportError::MissingPersistedFetchReceiptEventId) +} + #[derive(Clone, Debug)] enum RadrootsRelayProcessedFetchItem { Accepted(RadrootsRelayFetchedEvent), @@ -469,7 +609,7 @@ struct RadrootsRelayProcessedFetch { target_relays: Vec<String>, items: Vec<RadrootsRelayProcessedFetchItem>, duplicate_count: usize, - invalid_count: usize, + verification_failed_count: usize, malformed_count: usize, out_of_filter_count: usize, skipped_over_limit_count: usize, @@ -518,7 +658,7 @@ impl RadrootsRelayProcessedFetch { events, event_receipts, duplicate_count: self.duplicate_count, - invalid_count: self.invalid_count, + verification_failed_count: self.verification_failed_count, malformed_count: self.malformed_count, out_of_filter_count: self.out_of_filter_count, skipped_over_limit_count: self.skipped_over_limit_count, @@ -540,8 +680,14 @@ impl RadrootsRelayFetchReceipt { malformed_count: processed.malformed_count, out_of_filter_count: processed.out_of_filter_count, skipped_over_limit_count: processed.skipped_over_limit_count, - unsupported_count: 0, - invalid_count: processed.invalid_count, + verification_failed_count: processed.verification_failed_count, + admission_unsupported_count: 0, + admission_invalid_count: 0, + valid_stream_eligible_count: 0, + visible_count: 0, + not_admitted_count: 0, + not_current_count: 0, + suppressed_count: 0, eose_count: processed.eose_count, truncated_count: processed.truncated_count, closed_count: processed.closed_count, @@ -550,6 +696,152 @@ impl RadrootsRelayFetchReceipt { relay_outcomes: processed.relay_outcomes.clone(), } } + + async fn refresh_final_semantic_outcomes( + &mut self, + event_store: &RadrootsEventStore, + ) -> Result<(), RadrootsRelayTransportError> { + let mut event_ids = Vec::new(); + let mut seen_event_ids = BTreeSet::new(); + for event in &self.events { + if event.not_persisted || (!event.inserted && !event.duplicate) { + continue; + } + let event_id = required_persisted_fetch_receipt_event_id(event.event_id.as_deref())?; + if seen_event_ids.insert(event_id) { + event_ids.push(event_id.to_owned()); + } + } + let visibilities = event_store.event_visibilities(event_ids.iter()).await?; + let visibilities_by_event_id = event_ids + .into_iter() + .zip(visibilities) + .collect::<BTreeMap<_, _>>(); + + for event in &mut self.events { + if event.not_persisted || (!event.inserted && !event.duplicate) { + continue; + } + let event_id = required_persisted_fetch_receipt_event_id(event.event_id.as_deref())?; + let visibility = visibilities_by_event_id + .get(event_id) + .cloned() + .flatten() + .ok_or_else( + || RadrootsRelayTransportError::MissingStoredEventVisibility { + event_id: event_id.to_owned(), + }, + )?; + event.visibility = relay_fetch_visibility(event_id, visibility)?; + } + + self.admission_unsupported_count = 0; + self.admission_invalid_count = 0; + self.valid_stream_eligible_count = 0; + self.visible_count = 0; + self.not_admitted_count = 0; + self.not_current_count = 0; + self.suppressed_count = 0; + for event in &self.events { + match event.admission { + RadrootsRelayFetchEventAdmission::Unsupported => { + self.admission_unsupported_count += 1; + } + RadrootsRelayFetchEventAdmission::Invalid => self.admission_invalid_count += 1, + RadrootsRelayFetchEventAdmission::NotEvaluated + | RadrootsRelayFetchEventAdmission::Admitted => {} + } + if event.valid_stream == RadrootsRelayFetchEventValidStream::Eligible { + self.valid_stream_eligible_count += 1; + } + match event.visibility { + RadrootsRelayFetchEventVisibility::Visible => self.visible_count += 1, + RadrootsRelayFetchEventVisibility::NotAdmitted => { + self.not_admitted_count += 1; + } + RadrootsRelayFetchEventVisibility::NotCurrent => self.not_current_count += 1, + RadrootsRelayFetchEventVisibility::Suppressed => self.suppressed_count += 1, + RadrootsRelayFetchEventVisibility::NotEvaluated + | RadrootsRelayFetchEventVisibility::NotPersisted => {} + } + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum RadrootsRelayFetchRawBudgetExhaustion { + RawEvents, + RawJsonBytes, +} + +impl RadrootsRelayFetchRawBudgetExhaustion { + const fn current_relay_message(self) -> &'static str { + match self { + Self::RawEvents => "raw event scan limit reached before relay EOSE", + Self::RawJsonBytes => "aggregate raw JSON byte limit reached before relay EOSE", + } + } + + const fn unqueried_relay_message(self) -> &'static str { + match self { + Self::RawEvents => { + "relay was not queried because the global raw event scan limit was reached" + } + Self::RawJsonBytes => { + "relay was not queried because the global aggregate raw JSON byte limit was reached" + } + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct RadrootsRelayFetchRawBudget { + remaining_events: usize, + remaining_json_bytes: usize, + exhausted: Option<RadrootsRelayFetchRawBudgetExhaustion>, +} + +impl RadrootsRelayFetchRawBudget { + const fn new(max_raw_events: usize, max_raw_json_bytes: usize) -> Self { + Self { + remaining_events: max_raw_events, + remaining_json_bytes: max_raw_json_bytes, + exhausted: None, + } + } + + fn charge( + &mut self, + raw_json_bytes: usize, + ) -> Result<(), RadrootsRelayFetchRawBudgetExhaustion> { + if let Some(reason) = self.exhausted { + return Err(reason); + } + let Some(remaining_events) = self.remaining_events.checked_sub(1) else { + self.exhausted = Some(RadrootsRelayFetchRawBudgetExhaustion::RawEvents); + return Err(RadrootsRelayFetchRawBudgetExhaustion::RawEvents); + }; + let Some(remaining_json_bytes) = self.remaining_json_bytes.checked_sub(raw_json_bytes) + else { + self.exhausted = Some(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes); + return Err(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes); + }; + self.remaining_events = remaining_events; + self.remaining_json_bytes = remaining_json_bytes; + self.exhausted = if remaining_events == 0 { + Some(RadrootsRelayFetchRawBudgetExhaustion::RawEvents) + } else if remaining_json_bytes == 0 { + Some(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) + } else { + None + }; + Ok(()) + } + + const fn exhaustion_reason(self) -> Option<RadrootsRelayFetchRawBudgetExhaustion> { + self.exhausted + } } fn process_relay_fetch_items( @@ -558,6 +850,7 @@ fn process_relay_fetch_items( observed_at_ms: i64, max_events: usize, max_raw_events: usize, + max_raw_json_bytes: usize, items: Vec<RadrootsRelayFetchItem>, ) -> Result<RadrootsRelayProcessedFetch, RadrootsRelayTransportError> { if target_relays.is_empty() { @@ -567,7 +860,7 @@ fn process_relay_fetch_items( target_relays, items: Vec::new(), duplicate_count: 0, - invalid_count: 0, + verification_failed_count: 0, malformed_count: 0, out_of_filter_count: 0, skipped_over_limit_count: 0, @@ -577,7 +870,7 @@ fn process_relay_fetch_items( notice_count: 0, relay_outcomes: Vec::new(), }; - let mut scanned_raw_events = 0usize; + let mut raw_budget = RadrootsRelayFetchRawBudget::new(max_raw_events, max_raw_json_bytes); let mut accepted_events = 0usize; let mut seen_event_ids = BTreeSet::new(); let mut terminal_outcomes = BTreeMap::new(); @@ -614,11 +907,36 @@ fn process_relay_fetch_items( } match item { RadrootsRelayFetchItem::Event { raw_json, .. } => { - if scanned_raw_events >= max_raw_events { + if raw_budget.charge(raw_json.len()).is_err() { processed.skipped_over_limit_count += 1; continue; } - scanned_raw_events += 1; + if raw_json.len() > DEFAULT_RAW_JSON_MAX_BYTES { + processed.verification_failed_count += 1; + processed + .items + .push(RadrootsRelayProcessedFetchItem::Receipt( + RadrootsRelayFetchEventReceipt { + relay_url, + event_id: None, + inserted: false, + duplicate: false, + not_persisted: false, + malformed: false, + out_of_filter: false, + skipped_over_limit: false, + verification: RadrootsRelayFetchEventVerification::Failed, + admission: RadrootsRelayFetchEventAdmission::NotEvaluated, + admission_code: None, + valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, + visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, + message: Some(format!( + "event raw JSON exceeds {DEFAULT_RAW_JSON_MAX_BYTES} byte limit" + )), + }, + )); + continue; + } let parsed = RadrootsNostrEvent::from_json(raw_json.as_str()); let Ok(raw_event) = parsed else { processed.malformed_count += 1; @@ -631,14 +949,14 @@ fn process_relay_fetch_items( inserted: false, duplicate: false, not_persisted: false, - unsupported: false, - invalid: false, malformed: true, out_of_filter: false, skipped_over_limit: false, - valid_stream_eligible: false, - admission_status: None, + verification: RadrootsRelayFetchEventVerification::NotEvaluated, + admission: RadrootsRelayFetchEventAdmission::NotEvaluated, admission_code: None, + valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, + visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some("event JSON parse failed".to_owned()), }, )); @@ -647,7 +965,7 @@ fn process_relay_fetch_items( if let Err(error) = RadrootsEventIngest::from_raw_json(raw_json.clone(), observed_at_ms) { - processed.invalid_count += 1; + processed.verification_failed_count += 1; processed .items .push(RadrootsRelayProcessedFetchItem::Receipt( @@ -657,14 +975,14 @@ fn process_relay_fetch_items( inserted: false, duplicate: false, not_persisted: false, - unsupported: false, - invalid: true, malformed: false, out_of_filter: false, skipped_over_limit: false, - valid_stream_eligible: false, - admission_status: None, + verification: RadrootsRelayFetchEventVerification::Failed, + admission: RadrootsRelayFetchEventAdmission::NotEvaluated, admission_code: None, + valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, + visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some(error.to_string()), }, )); @@ -681,14 +999,14 @@ fn process_relay_fetch_items( inserted: false, duplicate: false, not_persisted: false, - unsupported: false, - invalid: false, malformed: false, out_of_filter: true, skipped_over_limit: false, - valid_stream_eligible: false, - admission_status: None, + verification: RadrootsRelayFetchEventVerification::Verified, + admission: RadrootsRelayFetchEventAdmission::NotEvaluated, admission_code: None, + valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, + visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some("event did not match relay fetch filters".to_owned()), }, )); @@ -720,14 +1038,14 @@ fn process_relay_fetch_items( inserted: false, duplicate: false, not_persisted: false, - unsupported: false, - invalid: false, malformed: false, out_of_filter: false, skipped_over_limit: true, - valid_stream_eligible: false, - admission_status: None, + verification: RadrootsRelayFetchEventVerification::Verified, + admission: RadrootsRelayFetchEventAdmission::NotEvaluated, admission_code: None, + valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, + visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some( "accepted relay fetch event limit reached".to_owned(), ), @@ -833,14 +1151,14 @@ fn accepted_fetch_event_receipt( inserted: false, duplicate: false, not_persisted: false, - unsupported: false, - invalid: false, malformed: false, out_of_filter: false, skipped_over_limit: false, - valid_stream_eligible: false, - admission_status: None, + verification: RadrootsRelayFetchEventVerification::Verified, + admission: RadrootsRelayFetchEventAdmission::NotEvaluated, admission_code: None, + valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, + visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some("event accepted by relay fetch filters".to_owned()), } } @@ -854,14 +1172,14 @@ fn duplicate_fetch_event_receipt( inserted: false, duplicate: true, not_persisted: false, - unsupported: false, - invalid: false, malformed: false, out_of_filter: false, skipped_over_limit: false, - valid_stream_eligible: false, - admission_status: None, + verification: RadrootsRelayFetchEventVerification::Verified, + admission: RadrootsRelayFetchEventAdmission::NotEvaluated, admission_code: None, + valid_stream: RadrootsRelayFetchEventValidStream::NotEvaluated, + visibility: RadrootsRelayFetchEventVisibility::NotEvaluated, message: Some("event ID was already observed in this relay fetch".to_owned()), } } @@ -899,17 +1217,16 @@ async fn fetch_from_nostr_relays( } let timeout = Duration::from_millis(request.timeout_ms); let filters = request.filters.as_slice().to_vec(); - let mut remaining_raw_events = request.max_raw_events; + let mut raw_budget = + RadrootsRelayFetchRawBudget::new(request.max_raw_events, request.max_raw_json_bytes); let mut items = Vec::new(); let relay_urls = request.relay_targets.relay_strings(); for (relay_index, relay_url) in relay_urls.iter().cloned().enumerate() { - if remaining_raw_events == 0 { + if let Some(reason) = raw_budget.exhaustion_reason() { items.extend(relay_urls[relay_index..].iter().cloned().map(|relay_url| { RadrootsRelayFetchItem::Truncated { relay_url, - message: - "relay was not queried because the global raw event scan limit was reached" - .to_owned(), + message: reason.unqueried_relay_message().to_owned(), } })); break; @@ -931,36 +1248,46 @@ async fn fetch_from_nostr_relays( continue; } let mut closed = false; - let mut truncated = false; + let mut truncated_message = None; for filter in filters.iter().cloned() { - if remaining_raw_events == 0 { - truncated = true; + if let Some(reason) = raw_budget.exhaustion_reason() { + truncated_message = Some(reason.current_relay_message().to_owned()); break; } let filter_limit = filter .limit - .unwrap_or(remaining_raw_events) - .min(remaining_raw_events); + .unwrap_or(raw_budget.remaining_events) + .min(raw_budget.remaining_events); match client .stream_events(filter.limit(filter_limit), timeout) .await { Ok(mut events) => { loop { - if remaining_raw_events == 0 { - truncated = true; + if let Some(reason) = raw_budget.exhaustion_reason() { + truncated_message = Some(reason.current_relay_message().to_owned()); break; } let Some(event) = events.next().await else { break; }; + let raw_json = event.as_json(); + if let Err(reason) = raw_budget.charge(raw_json.len()) { + truncated_message = Some(reason.current_relay_message().to_owned()); + break; + } + if raw_json.len() > DEFAULT_RAW_JSON_MAX_BYTES { + truncated_message = Some(format!( + "relay event raw JSON exceeds {DEFAULT_RAW_JSON_MAX_BYTES} byte limit" + )); + break; + } items.push(RadrootsRelayFetchItem::Event { relay_url: relay_url.clone(), - raw_json: event.as_json(), + raw_json, }); - remaining_raw_events -= 1; } - if truncated { + if truncated_message.is_some() { break; } } @@ -974,11 +1301,8 @@ async fn fetch_from_nostr_relays( } } } - if truncated { - items.push(RadrootsRelayFetchItem::Truncated { - relay_url, - message: "raw event scan limit reached before relay EOSE".to_owned(), - }); + if let Some(message) = truncated_message { + items.push(RadrootsRelayFetchItem::Truncated { relay_url, message }); } else if !closed { items.push(unproven_relay_stream_completion(relay_url)); } @@ -1039,10 +1363,15 @@ fn fetch_item_lock_error<T>(_error: PoisonError<T>) -> RadrootsRelayTransportErr #[cfg(test)] mod tests { use super::{ - RadrootsNostrEvent, RadrootsRelayFetchItem, relay_fetch_event_matches_filters, - summarize_nostr_output_failures, unproven_relay_stream_completion, + RadrootsNostrEvent, RadrootsRelayFetchEventAdmission, RadrootsRelayFetchEventValidStream, + RadrootsRelayFetchEventVerification, RadrootsRelayFetchEventVisibility, + RadrootsRelayFetchItem, RadrootsRelayFetchRawBudget, RadrootsRelayFetchRawBudgetExhaustion, + RadrootsRelayTransportError, relay_fetch_event_matches_filters, relay_fetch_visibility, + required_persisted_fetch_receipt_event_id, summarize_nostr_output_failures, + unproven_relay_stream_completion, }; use nostr::JsonUtil; + use radroots_event_store::{RadrootsEventVisibility, RadrootsNip09SuppressionReason}; use radroots_nostr::prelude::{ RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, }; @@ -1098,4 +1427,156 @@ mod tests { } ); } + + #[test] + fn raw_fetch_budget_allows_exact_boundaries_and_sticks_after_exhaustion() { + let mut count_budget = RadrootsRelayFetchRawBudget::new(1, 10); + assert_eq!(count_budget.charge(1), Ok(())); + assert_eq!( + count_budget.exhaustion_reason(), + Some(RadrootsRelayFetchRawBudgetExhaustion::RawEvents) + ); + assert_eq!( + count_budget.charge(1), + Err(RadrootsRelayFetchRawBudgetExhaustion::RawEvents) + ); + + let mut byte_budget = RadrootsRelayFetchRawBudget::new(2, 5); + assert_eq!(byte_budget.charge(5), Ok(())); + assert_eq!( + byte_budget.exhaustion_reason(), + Some(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) + ); + assert_eq!( + byte_budget.charge(0), + Err(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) + ); + } + + #[test] + fn raw_fetch_budget_rejects_crossing_without_partial_charge() { + let mut budget = RadrootsRelayFetchRawBudget::new(2, 4); + assert_eq!( + budget.charge(usize::MAX), + Err(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) + ); + assert_eq!(budget.remaining_events, 2); + assert_eq!(budget.remaining_json_bytes, 4); + assert_eq!( + budget.charge(1), + Err(RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes) + ); + assert_eq!( + RadrootsRelayFetchRawBudgetExhaustion::RawEvents.current_relay_message(), + "raw event scan limit reached before relay EOSE" + ); + assert!( + RadrootsRelayFetchRawBudgetExhaustion::RawJsonBytes + .unqueried_relay_message() + .contains("aggregate raw JSON byte limit") + ); + } + + #[test] + fn event_store_visibility_maps_without_admission_collapse() { + let cases = [ + ( + RadrootsEventVisibility::Visible, + RadrootsRelayFetchEventVisibility::Visible, + ), + ( + RadrootsEventVisibility::NotAdmitted, + RadrootsRelayFetchEventVisibility::NotAdmitted, + ), + ( + RadrootsEventVisibility::NotCurrent { + raw_head_event_id: "head".to_owned(), + }, + RadrootsRelayFetchEventVisibility::NotCurrent, + ), + ( + RadrootsEventVisibility::Suppressed { + reason: RadrootsNip09SuppressionReason::EventIdReference, + event_reference_request_id: None, + address_reference_request_id: None, + address_reference_cutoff: None, + }, + RadrootsRelayFetchEventVisibility::Suppressed, + ), + ]; + for (source, expected) in cases { + assert_eq!( + relay_fetch_visibility("event", source).expect("known visibility"), + expected + ); + } + } + + #[test] + fn persisted_fetch_receipts_require_an_event_id() { + assert_eq!( + required_persisted_fetch_receipt_event_id(Some("event")).expect("event id"), + "event" + ); + assert!(matches!( + required_persisted_fetch_receipt_event_id(None), + Err(RadrootsRelayTransportError::MissingPersistedFetchReceiptEventId) + )); + } + + #[test] + fn event_processing_outcomes_have_stable_exhaustive_wire_values() { + let cases = [ + serde_json::to_string(&RadrootsRelayFetchEventVerification::NotEvaluated) + .expect("verification"), + serde_json::to_string(&RadrootsRelayFetchEventVerification::Verified) + .expect("verification"), + serde_json::to_string(&RadrootsRelayFetchEventVerification::Failed) + .expect("verification"), + serde_json::to_string(&RadrootsRelayFetchEventAdmission::NotEvaluated) + .expect("admission"), + serde_json::to_string(&RadrootsRelayFetchEventAdmission::Admitted).expect("admission"), + serde_json::to_string(&RadrootsRelayFetchEventAdmission::Unsupported) + .expect("admission"), + serde_json::to_string(&RadrootsRelayFetchEventAdmission::Invalid).expect("admission"), + serde_json::to_string(&RadrootsRelayFetchEventValidStream::NotEvaluated) + .expect("valid stream"), + serde_json::to_string(&RadrootsRelayFetchEventValidStream::Eligible) + .expect("valid stream"), + serde_json::to_string(&RadrootsRelayFetchEventValidStream::Ineligible) + .expect("valid stream"), + serde_json::to_string(&RadrootsRelayFetchEventVisibility::NotEvaluated) + .expect("visibility"), + serde_json::to_string(&RadrootsRelayFetchEventVisibility::NotPersisted) + .expect("visibility"), + serde_json::to_string(&RadrootsRelayFetchEventVisibility::Visible).expect("visibility"), + serde_json::to_string(&RadrootsRelayFetchEventVisibility::NotAdmitted) + .expect("visibility"), + serde_json::to_string(&RadrootsRelayFetchEventVisibility::NotCurrent) + .expect("visibility"), + serde_json::to_string(&RadrootsRelayFetchEventVisibility::Suppressed) + .expect("visibility"), + ]; + assert_eq!( + cases, + [ + "\"not_evaluated\"", + "\"verified\"", + "\"failed\"", + "\"not_evaluated\"", + "\"admitted\"", + "\"unsupported\"", + "\"invalid\"", + "\"not_evaluated\"", + "\"eligible\"", + "\"ineligible\"", + "\"not_evaluated\"", + "\"not_persisted\"", + "\"visible\"", + "\"not_admitted\"", + "\"not_current\"", + "\"suppressed\"", + ] + ); + } } diff --git a/crates/transport_nostr/src/lib.rs b/crates/transport_nostr/src/lib.rs @@ -15,12 +15,15 @@ pub use error::RadrootsRelayTransportError; pub use fetch::fetch_relay_events_blocking; #[cfg(feature = "storage")] pub use fetch::{ - RadrootsMockRelayFetchAdapter, RadrootsNostrClientFetchAdapter, RadrootsRelayFetchAdapter, - RadrootsRelayFetchEventReceipt, RadrootsRelayFetchFailure, RadrootsRelayFetchFilters, - RadrootsRelayFetchItem, RadrootsRelayFetchMode, RadrootsRelayFetchOutcomeKind, - RadrootsRelayFetchReceipt, RadrootsRelayFetchRelayOutcome, RadrootsRelayFetchRequest, - RadrootsRelayFetchedEvent, RadrootsRelayFetchedEventsReceipt, fetch_and_ingest_relay_events, - fetch_relay_events, + RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, + RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, RadrootsMockRelayFetchAdapter, + RadrootsNostrClientFetchAdapter, RadrootsRelayFetchAdapter, RadrootsRelayFetchEventAdmission, + RadrootsRelayFetchEventReceipt, RadrootsRelayFetchEventValidStream, + RadrootsRelayFetchEventVerification, RadrootsRelayFetchEventVisibility, + RadrootsRelayFetchFailure, RadrootsRelayFetchFilters, RadrootsRelayFetchItem, + RadrootsRelayFetchMode, RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchReceipt, + RadrootsRelayFetchRelayOutcome, RadrootsRelayFetchRequest, RadrootsRelayFetchedEvent, + RadrootsRelayFetchedEventsReceipt, fetch_and_ingest_relay_events, fetch_relay_events, }; #[cfg(feature = "storage")] pub use outbox::{ diff --git a/crates/transport_nostr/src/publish.rs b/crates/transport_nostr/src/publish.rs @@ -334,6 +334,7 @@ fn nostr_error_to_transport_error(error: RadrootsRelayTransportError) -> Radroot RadrootsRelayTransportError::Transport(_) => RadrootsTransportError::InvalidTransportKind, RadrootsRelayTransportError::EmptyFetchFilters | RadrootsRelayTransportError::InvalidFetchLimit { .. } + | RadrootsRelayTransportError::FetchLimitTooLarge { .. } | RadrootsRelayTransportError::InvalidTimestamp { .. } | RadrootsRelayTransportError::InvalidIdempotencyKey { .. } | RadrootsRelayTransportError::RequiredTargetNotRequested { .. } => { @@ -342,7 +343,10 @@ fn nostr_error_to_transport_error(error: RadrootsRelayTransportError) -> Radroot #[cfg(feature = "storage")] RadrootsRelayTransportError::EventStore(_) | RadrootsRelayTransportError::Outbox(_) - | RadrootsRelayTransportError::MissingSignedOutboxEvent(_) => { + | RadrootsRelayTransportError::MissingSignedOutboxEvent(_) + | RadrootsRelayTransportError::MissingPersistedFetchReceiptEventId + | RadrootsRelayTransportError::MissingStoredEventVisibility { .. } + | RadrootsRelayTransportError::UnsupportedStoredEventVisibility { .. } => { RadrootsTransportError::InvalidTransportKind } } @@ -480,6 +484,14 @@ mod contract_tests { RadrootsTransportError::InvalidTransportKind ); assert_eq!( + nostr_error_to_transport_error(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "max_events", + max: 1_000, + actual: 1_001, + }), + RadrootsTransportError::InvalidTransportKind + ); + assert_eq!( nostr_error_to_transport_error(RadrootsRelayTransportError::InvalidTimestamp { field: "now_ms", value: -1, @@ -525,6 +537,28 @@ mod contract_tests { ), RadrootsTransportError::InvalidTransportKind ); + assert_eq!( + nostr_error_to_transport_error( + RadrootsRelayTransportError::MissingPersistedFetchReceiptEventId, + ), + RadrootsTransportError::InvalidTransportKind + ); + assert_eq!( + nostr_error_to_transport_error( + RadrootsRelayTransportError::MissingStoredEventVisibility { + event_id: "missing".to_owned(), + }, + ), + RadrootsTransportError::InvalidTransportKind + ); + assert_eq!( + nostr_error_to_transport_error( + RadrootsRelayTransportError::UnsupportedStoredEventVisibility { + event_id: "unsupported".to_owned(), + }, + ), + RadrootsTransportError::InvalidTransportKind + ); } } } diff --git a/crates/transport_nostr/tests/transport.rs b/crates/transport_nostr/tests/transport.rs @@ -1,10 +1,12 @@ use futures::future::BoxFuture; use nostr::{EventBuilder, JsonUtil}; use radroots_event::draft::{RadrootsEventDraft, RadrootsSignedEvent}; -use radroots_event::kinds::{KIND_FOLLOW, KIND_GEOCHAT, KIND_POST}; +use radroots_event::kinds::{ + KIND_DELETION_REQUEST, KIND_FOLLOW, KIND_GEOCHAT, KIND_POST, KIND_PROFILE, +}; +use radroots_event::wire::v1::DEFAULT_RAW_JSON_MAX_BYTES; use radroots_event_store::{ - RadrootsEventAdmissionStatus, RadrootsEventStore, RadrootsTransportObservationRow, - RadrootsTransportObservationType, + RadrootsEventStore, RadrootsTransportObservationRow, RadrootsTransportObservationType, }; use radroots_nostr::prelude::{ RadrootsNostrFilter, RadrootsNostrKeys, RadrootsNostrKind, RadrootsNostrSecretKey, @@ -26,15 +28,19 @@ use radroots_transport::{ RadrootsTransportTargetLabel, RadrootsTransportTargetReceipt, RadrootsTransportTargetSet, }; use radroots_transport_nostr::{ - RadrootsMockRelayFetchAdapter, RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, - RadrootsOutboxPublishPolicy, RadrootsRelayFetchFilters, RadrootsRelayFetchItem, - RadrootsRelayFetchMode, RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchRequest, - RadrootsRelayOutcome, RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, - RadrootsRelayPublishRelayReceipt, RadrootsRelayPublishRequest, RadrootsRelayTargetSet, - RadrootsRelayTransportError, RadrootsRelayUrl, RadrootsRelayUrlPolicy, - fetch_and_ingest_relay_events, fetch_relay_events, fetch_relay_events_blocking, - publish_claimed_outbox_event, publish_claimed_outbox_event_with_transport, - publish_signed_event, verified_signed_event_payload, + RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, + RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, RadrootsMockRelayFetchAdapter, + RadrootsMockRelayPublishAdapter, RadrootsNostrTransport, RadrootsOutboxPublishPolicy, + RadrootsRelayFetchEventAdmission, RadrootsRelayFetchEventValidStream, + RadrootsRelayFetchEventVerification, RadrootsRelayFetchEventVisibility, + RadrootsRelayFetchFilters, RadrootsRelayFetchItem, RadrootsRelayFetchMode, + RadrootsRelayFetchOutcomeKind, RadrootsRelayFetchRequest, RadrootsRelayOutcome, + RadrootsRelayOutcomeKind, RadrootsRelayPublishAdapter, RadrootsRelayPublishRelayReceipt, + RadrootsRelayPublishRequest, RadrootsRelayTargetSet, RadrootsRelayTransportError, + RadrootsRelayUrl, RadrootsRelayUrlPolicy, fetch_and_ingest_relay_events, fetch_relay_events, + fetch_relay_events_blocking, publish_claimed_outbox_event, + publish_claimed_outbox_event_with_transport, publish_signed_event, + verified_signed_event_payload, }; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; @@ -1792,14 +1798,21 @@ fn fetch_requests_reject_zero_limits_and_timeouts() { request.clone().with_raw_event_scan_limit(0), Err(RadrootsRelayTransportError::InvalidFetchLimit { field }) if field == "max_raw_events" )); + assert!(matches!( + request.clone().with_raw_json_byte_limit(0), + Err(RadrootsRelayTransportError::InvalidFetchLimit { field }) if field == "max_raw_json_bytes" + )); let request = request .with_timeout_ms(1) .expect("minimum timeout") .with_raw_event_scan_limit(1) - .expect("minimum raw scan limit"); + .expect("minimum raw scan limit") + .with_raw_json_byte_limit(1) + .expect("minimum raw JSON byte limit"); assert_eq!(request.timeout_ms(), 1); assert_eq!(request.max_raw_events(), 1); + assert_eq!(request.max_raw_json_bytes(), 1); let request = RadrootsRelayFetchRequest::subscription( 1_005, @@ -1815,11 +1828,14 @@ fn fetch_requests_reject_zero_limits_and_timeouts() { .with_timeout_ms(25) .expect("timeout") .with_raw_event_scan_limit(3) - .expect("raw limit"); + .expect("raw limit") + .with_raw_json_byte_limit(4_096) + .expect("raw JSON byte limit"); assert_eq!(request.mode(), RadrootsRelayFetchMode::Subscription); assert_eq!(request.observed_at_ms(), 1_005); assert_eq!(request.max_events(), 2); assert_eq!(request.max_raw_events(), 3); + assert_eq!(request.max_raw_json_bytes(), 4_096); assert_eq!( request.relay_targets().relay_strings(), vec![RELAY_PRIMARY_WSS.to_owned(), RELAY_SECONDARY_WSS.to_owned()] @@ -1829,6 +1845,98 @@ fn fetch_requests_reject_zero_limits_and_timeouts() { } #[test] +fn fetch_requests_enforce_the_coherent_visibility_batch_limit() { + let filter = post_relay_fetch_filter(RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX); + let fetch = RadrootsRelayFetchRequest::fetch( + 1_006, + RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, + primary_relay_target(), + [filter.clone()], + ) + .expect("maximum fetch request"); + assert_eq!(fetch.max_events(), RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX); + assert_eq!( + fetch.max_raw_events(), + RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX + ); + assert_eq!( + fetch.max_raw_json_bytes(), + RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX + ); + let exact_raw_limits = fetch + .clone() + .with_raw_event_scan_limit(RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX) + .expect("maximum raw event limit") + .with_raw_json_byte_limit(RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX) + .expect("maximum raw JSON byte limit"); + assert_eq!( + exact_raw_limits.max_raw_events(), + RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX + ); + assert_eq!( + exact_raw_limits.max_raw_json_bytes(), + RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX + ); + let subscription = RadrootsRelayFetchRequest::subscription( + 1_006, + RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX, + primary_relay_target(), + [filter.clone()], + ) + .expect("maximum subscription request"); + assert_eq!( + subscription.max_events(), + RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX + ); + + let above_max = RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX + 1; + assert!(matches!( + RadrootsRelayFetchRequest::fetch( + 1_006, + above_max, + primary_relay_target(), + [filter.clone()], + ), + Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "max_events", + max, + actual, + }) if max == RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX && actual == above_max + )); + assert!(matches!( + RadrootsRelayFetchRequest::subscription( + 1_006, + above_max, + primary_relay_target(), + [filter], + ), + Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "max_events", + max, + actual, + }) if max == RADROOTS_RELAY_FETCH_EVENT_LIMIT_MAX && actual == above_max + )); + assert!(matches!( + fetch + .clone() + .with_raw_event_scan_limit(RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX + 1), + Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "max_raw_events", + max: RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX, + actual, + }) if actual == RADROOTS_RELAY_FETCH_RAW_EVENT_LIMIT_MAX + 1 + )); + assert!(matches!( + fetch.with_raw_json_byte_limit(RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX + 1), + Err(RadrootsRelayTransportError::FetchLimitTooLarge { + field: "max_raw_json_bytes", + max: RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX, + actual, + }) if actual == RADROOTS_RELAY_FETCH_RAW_JSON_BYTE_LIMIT_MAX + 1 + )); +} + +#[test] fn fetch_blocking_facade_runs_mock_adapter() { let signed = signed_post("blocking fetch"); let accepted_id = signed.id_str().to_owned(); @@ -1895,10 +2003,16 @@ async fn fetch_verifies_events_before_acceptance_budgeting() { assert_eq!(receipt.events.len(), 1); assert_eq!(receipt.events[0].event.id.to_hex(), accepted_id); - assert_eq!(receipt.invalid_count, 1); + assert_eq!(receipt.verification_failed_count, 1); assert_eq!(receipt.skipped_over_limit_count, 0); - assert!(receipt.event_receipts[0].invalid); - assert!(!receipt.event_receipts[1].invalid); + assert_eq!( + receipt.event_receipts[0].verification, + RadrootsRelayFetchEventVerification::Failed + ); + assert_eq!( + receipt.event_receipts[1].verification, + RadrootsRelayFetchEventVerification::Verified + ); } #[tokio::test] @@ -2110,8 +2224,14 @@ async fn fetch_ingests_events_and_records_transport_observations() { assert_eq!(receipt.inserted_count, 3); assert_eq!(receipt.duplicate_count, 1); assert_eq!(receipt.not_persisted_count, 0); - assert_eq!(receipt.unsupported_count, 1); - assert_eq!(receipt.invalid_count, 2); + assert_eq!(receipt.verification_failed_count, 1); + assert_eq!(receipt.admission_unsupported_count, 1); + assert_eq!(receipt.admission_invalid_count, 1); + assert_eq!(receipt.valid_stream_eligible_count, 2); + assert_eq!(receipt.visible_count, 2); + assert_eq!(receipt.not_admitted_count, 2); + assert_eq!(receipt.not_current_count, 0); + assert_eq!(receipt.suppressed_count, 0); assert_eq!(receipt.malformed_count, 1); assert_eq!(receipt.eose_count, 1); assert_eq!(receipt.closed_count, 2); @@ -2137,16 +2257,28 @@ async fn fetch_ingests_events_and_records_transport_observations() { .count() ); assert_eq!( - receipt.unsupported_count, + receipt.admission_unsupported_count, + receipt + .events + .iter() + .filter(|event| event.admission == RadrootsRelayFetchEventAdmission::Unsupported) + .count() + ); + assert_eq!( + receipt.admission_invalid_count, receipt .events .iter() - .filter(|event| event.unsupported) + .filter(|event| event.admission == RadrootsRelayFetchEventAdmission::Invalid) .count() ); assert_eq!( - receipt.invalid_count, - receipt.events.iter().filter(|event| event.invalid).count() + receipt.verification_failed_count, + receipt + .events + .iter() + .filter(|event| event.verification == RadrootsRelayFetchEventVerification::Failed) + .count() ); assert_eq!( receipt.malformed_count, @@ -2161,10 +2293,8 @@ async fn fetch_ingests_events_and_records_transport_observations() { + usize::from(event.duplicate) + usize::from(event.not_persisted) <= 1 - && usize::from(event.unsupported) - + usize::from(event.invalid) - + usize::from(event.malformed) - <= 1 + && (!event.malformed + || event.verification == RadrootsRelayFetchEventVerification::NotEvaluated) })); assert_eq!(receipt.relay_outcomes.len(), 4); assert_eq!(receipt.relay_outcomes[0].relay_url, RELAY_PRIMARY_WSS); @@ -2197,44 +2327,82 @@ async fn fetch_ingests_events_and_records_transport_observations() { ); assert!(receipt.relay_outcomes[3].relay_outcome.is_none()); assert_eq!( - receipt.events[0].admission_status.as_deref(), - Some(RadrootsEventAdmissionStatus::Admitted.as_str()) + receipt.events[0].admission, + RadrootsRelayFetchEventAdmission::Admitted + ); + assert_eq!( + receipt.events[0].valid_stream, + RadrootsRelayFetchEventValidStream::Eligible + ); + assert_eq!( + receipt.events[0].visibility, + RadrootsRelayFetchEventVisibility::Visible + ); + assert_eq!( + receipt.events[1].admission, + RadrootsRelayFetchEventAdmission::Admitted + ); + assert_eq!( + receipt.events[1].valid_stream, + RadrootsRelayFetchEventValidStream::Eligible ); - assert!(receipt.events[0].valid_stream_eligible); assert_eq!( - receipt.events[1].admission_status.as_deref(), - Some(RadrootsEventAdmissionStatus::Admitted.as_str()) + receipt.events[1].visibility, + RadrootsRelayFetchEventVisibility::Visible ); - assert!(receipt.events[1].valid_stream_eligible); assert_eq!( - receipt.events[2].admission_status.as_deref(), - Some(RadrootsEventAdmissionStatus::Unsupported.as_str()) + receipt.events[2].admission, + RadrootsRelayFetchEventAdmission::Unsupported ); - assert!(receipt.events[2].unsupported); - assert!(!receipt.events[2].invalid); assert_eq!( receipt.events[2].admission_code.as_deref(), Some("unsupported_kind") ); - assert!(!receipt.events[2].valid_stream_eligible); assert_eq!( - receipt.events[3].admission_status.as_deref(), - Some(RadrootsEventAdmissionStatus::Invalid.as_str()) + receipt.events[2].valid_stream, + RadrootsRelayFetchEventValidStream::Ineligible + ); + assert_eq!( + receipt.events[2].visibility, + RadrootsRelayFetchEventVisibility::NotAdmitted + ); + assert_eq!( + receipt.events[3].admission, + RadrootsRelayFetchEventAdmission::Invalid ); - assert!(!receipt.events[3].unsupported); - assert!(receipt.events[3].invalid); assert_eq!( receipt.events[3].admission_code.as_deref(), Some("reply_event_id_invalid") ); - assert!(!receipt.events[3].valid_stream_eligible); - assert_eq!(receipt.events[4].admission_status, None); - assert!(!receipt.events[4].valid_stream_eligible); - assert_eq!(receipt.events[5].admission_status, None); - assert!(!receipt.events[5].valid_stream_eligible); + assert_eq!( + receipt.events[3].valid_stream, + RadrootsRelayFetchEventValidStream::Ineligible + ); + assert_eq!( + receipt.events[3].visibility, + RadrootsRelayFetchEventVisibility::NotAdmitted + ); + assert_eq!( + receipt.events[4].verification, + RadrootsRelayFetchEventVerification::Failed + ); + assert_eq!( + receipt.events[4].admission, + RadrootsRelayFetchEventAdmission::NotEvaluated + ); + assert_eq!( + receipt.events[5].verification, + RadrootsRelayFetchEventVerification::NotEvaluated + ); + assert_eq!( + receipt.events[5].admission, + RadrootsRelayFetchEventAdmission::NotEvaluated + ); let serialized = serde_json::to_value(&receipt).expect("serialized fetch receipt"); - assert!(serialized.get("invalid_count").is_some()); + assert!(serialized.get("verification_failed_count").is_some()); + assert!(serialized.get("admission_invalid_count").is_some()); + assert!(serialized.get("visible_count").is_some()); assert!(serialized.get("not_persisted_count").is_some()); assert!(serialized.get("truncated_count").is_some()); let serialized_event = serialized["events"][0] @@ -2247,14 +2415,14 @@ async fn fetch_ingests_events_and_records_transport_observations() { "inserted", "duplicate", "not_persisted", - "unsupported", - "invalid", "malformed", "out_of_filter", "skipped_over_limit", - "valid_stream_eligible", - "admission_status", + "verification", + "admission", "admission_code", + "valid_stream", + "visibility", "message", ] { assert!( @@ -2263,7 +2431,7 @@ async fn fetch_ingests_events_and_records_transport_observations() { ); } assert!(!serialized_event.contains_key("projection_eligible")); - assert!(!serialized_event.contains_key("verification_status")); + assert!(!serialized_event.contains_key("admission_status")); let observations = store .observations_for_event(signed.id_str()) @@ -2280,6 +2448,255 @@ async fn fetch_ingests_events_and_records_transport_observations() { } #[tokio::test] +async fn fetch_reports_final_replaceable_visibility_when_newer_arrives_first() { + let newer = test_event_builder(KIND_PROFILE, r#"{"name":"newer"}"#, Vec::new()) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_001)) + .sign_with_keys(&fixture_keys()) + .expect("signed newer profile"); + let older = test_event_builder(KIND_PROFILE, r#"{"name":"older"}"#, Vec::new()) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(&fixture_keys()) + .expect("signed older profile"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: newer.as_json(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: older.as_json(), + }, + ]); + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom( + u16::try_from(KIND_PROFILE).expect("profile kind must fit NIP-01"), + )) + .limit(2); + let request = RadrootsRelayFetchRequest::fetch(1_001, 2, primary_relay_target(), [filter]) + .expect("profile fetch request"); + + let receipt = fetch_and_ingest_relay_events(&adapter, &store, request) + .await + .expect("fetch ingest"); + + assert_eq!(receipt.inserted_count, 2); + assert_eq!(receipt.valid_stream_eligible_count, 2); + assert_eq!(receipt.visible_count, 1); + assert_eq!(receipt.not_current_count, 1); + assert_eq!( + receipt.events[0].visibility, + RadrootsRelayFetchEventVisibility::Visible + ); + assert_eq!( + receipt.events[1].admission, + RadrootsRelayFetchEventAdmission::Admitted + ); + assert_eq!( + receipt.events[1].valid_stream, + RadrootsRelayFetchEventValidStream::Eligible + ); + assert_eq!( + receipt.events[1].visibility, + RadrootsRelayFetchEventVisibility::NotCurrent + ); +} + +#[tokio::test] +async fn fetch_reports_final_replaceable_visibility_when_older_arrives_first() { + let older = test_event_builder(KIND_PROFILE, r#"{"name":"older"}"#, Vec::new()) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(&fixture_keys()) + .expect("signed older profile"); + let newer = test_event_builder(KIND_PROFILE, r#"{"name":"newer"}"#, Vec::new()) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_001)) + .sign_with_keys(&fixture_keys()) + .expect("signed newer profile"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: older.as_json(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: newer.as_json(), + }, + ]); + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom( + u16::try_from(KIND_PROFILE).expect("profile kind must fit NIP-01"), + )) + .limit(2); + let request = RadrootsRelayFetchRequest::fetch(1_001, 2, primary_relay_target(), [filter]) + .expect("profile fetch request"); + + let receipt = fetch_and_ingest_relay_events(&adapter, &store, request) + .await + .expect("fetch ingest"); + + assert_eq!(receipt.inserted_count, 2); + assert_eq!(receipt.valid_stream_eligible_count, 2); + assert_eq!(receipt.visible_count, 1); + assert_eq!(receipt.not_current_count, 1); + assert_eq!( + receipt.events[0].visibility, + RadrootsRelayFetchEventVisibility::NotCurrent + ); + assert_eq!( + receipt.events[1].visibility, + RadrootsRelayFetchEventVisibility::Visible + ); +} + +#[tokio::test] +async fn fetch_maps_one_final_visibility_snapshot_back_to_duplicate_receipts() { + let older = test_event_builder(KIND_PROFILE, r#"{"name":"older"}"#, Vec::new()) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(&fixture_keys()) + .expect("signed older profile"); + let newer = test_event_builder(KIND_PROFILE, r#"{"name":"newer"}"#, Vec::new()) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_001)) + .sign_with_keys(&fixture_keys()) + .expect("signed newer profile"); + let older_id = older.id.to_hex(); + let newer_id = newer.id.to_hex(); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: older.as_json(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: newer.as_json(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_SECONDARY_WSS.to_owned(), + raw_json: older.as_json(), + }, + ]); + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom( + u16::try_from(KIND_PROFILE).expect("profile kind must fit NIP-01"), + )) + .limit(2); + let targets = RadrootsRelayTargetSet::new( + [RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS], + RadrootsRelayUrlPolicy::Public, + ) + .expect("relay targets"); + let request = RadrootsRelayFetchRequest::fetch(1_001, 2, targets, [filter]) + .expect("profile fetch request"); + + let receipt = fetch_and_ingest_relay_events(&adapter, &store, request) + .await + .expect("fetch ingest"); + + assert_eq!(receipt.inserted_count, 2); + assert_eq!(receipt.duplicate_count, 1); + assert_eq!(receipt.valid_stream_eligible_count, 3); + assert_eq!(receipt.visible_count, 1); + assert_eq!(receipt.not_current_count, 2); + assert_eq!(receipt.events.len(), 3); + assert_eq!( + receipt + .events + .iter() + .map(|event| event.event_id.as_deref()) + .collect::<Vec<_>>(), + vec![ + Some(older_id.as_str()), + Some(newer_id.as_str()), + Some(older_id.as_str()), + ] + ); + assert_eq!( + receipt + .events + .iter() + .map(|event| event.visibility) + .collect::<Vec<_>>(), + vec![ + RadrootsRelayFetchEventVisibility::NotCurrent, + RadrootsRelayFetchEventVisibility::Visible, + RadrootsRelayFetchEventVisibility::NotCurrent, + ] + ); + assert!(receipt.events[2].duplicate); +} + +#[tokio::test] +async fn fetch_reports_store_suppression_when_deletion_precedes_target_replay() { + let target = test_event_builder(KIND_POST, "deleted target", Vec::new()) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_000)) + .sign_with_keys(&fixture_keys()) + .expect("signed target"); + let deletion = test_event_builder( + KIND_DELETION_REQUEST, + "", + vec![vec!["e".to_owned(), target.id.to_hex()]], + ) + .custom_created_at(RadrootsNostrTimestamp::from_secs(1_700_000_001)) + .sign_with_keys(&fixture_keys()) + .expect("signed deletion"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: deletion.as_json(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: target.as_json(), + }, + ]); + let deletion_filter = RadrootsNostrFilter::new().kind(RadrootsNostrKind::Custom( + u16::try_from(KIND_DELETION_REQUEST).expect("deletion kind must fit NIP-01"), + )); + let post_filter = RadrootsNostrFilter::new().kind(RadrootsNostrKind::Custom( + u16::try_from(KIND_POST).expect("post kind must fit NIP-01"), + )); + let request = RadrootsRelayFetchRequest::fetch( + 1_002, + 2, + primary_relay_target(), + [deletion_filter, post_filter], + ) + .expect("deletion replay fetch request"); + + let receipt = fetch_and_ingest_relay_events(&adapter, &store, request) + .await + .expect("fetch ingest"); + + assert_eq!(receipt.inserted_count, 2); + assert_eq!(receipt.valid_stream_eligible_count, 2); + assert_eq!(receipt.visible_count, 1); + assert_eq!(receipt.suppressed_count, 1); + assert_eq!(receipt.not_current_count, 0); + assert_eq!( + receipt.events[0].visibility, + RadrootsRelayFetchEventVisibility::Visible + ); + assert_eq!( + receipt.events[1].event_id.as_deref(), + Some(target.id.to_hex().as_str()) + ); + assert_eq!( + receipt.events[1].admission, + RadrootsRelayFetchEventAdmission::Admitted + ); + assert_eq!( + receipt.events[1].valid_stream, + RadrootsRelayFetchEventValidStream::Eligible + ); + assert_eq!( + receipt.events[1].visibility, + RadrootsRelayFetchEventVisibility::Suppressed + ); +} + +#[tokio::test] async fn fetch_reports_ephemeral_events_as_not_persisted_without_duplicate_or_store_state() { let signed = signed_ephemeral("live geochat"); let event_id = signed.id_str().to_owned(); @@ -2310,17 +2727,20 @@ async fn fetch_reports_ephemeral_events_as_not_persisted_without_duplicate_or_st assert_eq!(receipt.duplicate_count, 0); assert_eq!(receipt.not_persisted_count, 2); assert_eq!(receipt.malformed_count, 0); - assert_eq!(receipt.unsupported_count, 0); - assert_eq!(receipt.invalid_count, 0); + assert_eq!(receipt.verification_failed_count, 0); + assert_eq!(receipt.admission_unsupported_count, 0); + assert_eq!(receipt.admission_invalid_count, 0); + assert_eq!(receipt.valid_stream_eligible_count, 0); assert_eq!(receipt.events.len(), 2); assert!(receipt.events.iter().all(|event| { !event.inserted && !event.duplicate && event.not_persisted - && event.admission_status.as_deref() - == Some(RadrootsEventAdmissionStatus::Admitted.as_str()) + && event.verification == RadrootsRelayFetchEventVerification::Verified + && event.admission == RadrootsRelayFetchEventAdmission::Admitted && event.admission_code.is_none() - && !event.valid_stream_eligible + && event.valid_stream == RadrootsRelayFetchEventValidStream::Ineligible + && event.visibility == RadrootsRelayFetchEventVisibility::NotPersisted })); assert!( store @@ -2389,7 +2809,7 @@ async fn fetch_rejects_out_of_filter_events_before_store_mutation() { assert_eq!(receipt.inserted_count, 1); assert_eq!(receipt.out_of_filter_count, 2); assert_eq!(receipt.malformed_count, 0); - assert_eq!(receipt.unsupported_count, 0); + assert_eq!(receipt.admission_unsupported_count, 0); assert_eq!(receipt.events.len(), 3); assert!(receipt.events[0].out_of_filter); assert!(!receipt.events[1].out_of_filter); @@ -2463,7 +2883,7 @@ async fn fetch_event_cap_counts_accepted_in_filter_events_and_preserves_later_co assert_eq!(receipt.inserted_count, 1); assert_eq!(receipt.duplicate_count, 0); - assert_eq!(receipt.unsupported_count, 0); + assert_eq!(receipt.admission_unsupported_count, 0); assert_eq!(receipt.malformed_count, 1); assert_eq!(receipt.out_of_filter_count, 1); assert_eq!(receipt.skipped_over_limit_count, 1); @@ -2642,6 +3062,169 @@ async fn fetch_raw_scan_limit_bounds_noisy_adapter_output() { } #[tokio::test] +async fn fetch_raw_json_byte_limit_is_exact_global_and_sticky() { + let first = signed_post("raw byte first"); + let second = signed_post("raw byte second"); + let third = signed_post("raw byte third"); + let exact_bytes = first.raw_json().len() + second.raw_json().len(); + let targets = RadrootsRelayTargetSet::new( + [RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS], + RadrootsRelayUrlPolicy::Public, + ) + .expect("relay targets"); + let items = vec![ + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: first.raw_json().to_owned(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_SECONDARY_WSS.to_owned(), + raw_json: second.raw_json().to_owned(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: third.raw_json().to_owned(), + }, + RadrootsRelayFetchItem::Eose { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + }, + RadrootsRelayFetchItem::Eose { + relay_url: RELAY_SECONDARY_WSS.to_owned(), + }, + ]; + + let exact_receipt = fetch_relay_events( + &RadrootsMockRelayFetchAdapter::new(items.clone()), + RadrootsRelayFetchRequest::fetch(1_131, 3, targets.clone(), [post_relay_fetch_filter(3)]) + .expect("fetch request") + .with_raw_json_byte_limit(exact_bytes) + .expect("exact raw JSON byte limit"), + ) + .await + .expect("exact fetch"); + assert_eq!(exact_receipt.events.len(), 2); + assert_eq!(exact_receipt.skipped_over_limit_count, 1); + assert_eq!(exact_receipt.eose_count, 2); + + let crossing_receipt = fetch_relay_events( + &RadrootsMockRelayFetchAdapter::new(items), + RadrootsRelayFetchRequest::fetch(1_132, 3, targets, [post_relay_fetch_filter(3)]) + .expect("fetch request") + .with_raw_json_byte_limit(exact_bytes - 1) + .expect("crossing raw JSON byte limit"), + ) + .await + .expect("crossing fetch"); + assert_eq!(crossing_receipt.events.len(), 1); + assert_eq!(crossing_receipt.skipped_over_limit_count, 2); + assert_eq!(crossing_receipt.eose_count, 2); +} + +#[tokio::test] +async fn fetch_raw_json_budget_charges_every_preparse_event_class() { + let malformed = "{not json".to_owned(); + let wrong_tag = signed_event_with_kind_and_hashtag("raw bytes wrong tag", KIND_POST, "compost"); + let accepted = signed_post("raw bytes accepted"); + let over_accepted_limit = signed_post("raw bytes over accepted limit"); + let sticky_skip = signed_post("raw bytes sticky skip"); + let exact_bytes = malformed.len() + + wrong_tag.raw_json().len() + + accepted.raw_json().len() * 2 + + over_accepted_limit.raw_json().len(); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: malformed, + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: wrong_tag.raw_json().to_owned(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: accepted.raw_json().to_owned(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_SECONDARY_WSS.to_owned(), + raw_json: accepted.raw_json().to_owned(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_SECONDARY_WSS.to_owned(), + raw_json: over_accepted_limit.raw_json().to_owned(), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_SECONDARY_WSS.to_owned(), + raw_json: sticky_skip.raw_json().to_owned(), + }, + RadrootsRelayFetchItem::Eose { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + }, + RadrootsRelayFetchItem::Eose { + relay_url: RELAY_SECONDARY_WSS.to_owned(), + }, + ]); + let targets = RadrootsRelayTargetSet::new( + [RELAY_PRIMARY_WSS, RELAY_SECONDARY_WSS], + RadrootsRelayUrlPolicy::Public, + ) + .expect("relay targets"); + let store = RadrootsEventStore::open_memory().await.expect("store"); + + let receipt = fetch_and_ingest_relay_events( + &adapter, + &store, + RadrootsRelayFetchRequest::fetch(1_133, 1, targets, [post_relay_fetch_filter(6)]) + .expect("fetch request") + .with_raw_json_byte_limit(exact_bytes) + .expect("raw JSON byte limit"), + ) + .await + .expect("fetch ingest"); + + assert_eq!(receipt.inserted_count, 1); + assert_eq!(receipt.malformed_count, 1); + assert_eq!(receipt.out_of_filter_count, 1); + assert_eq!(receipt.duplicate_count, 1); + assert_eq!(receipt.skipped_over_limit_count, 2); + assert_eq!(receipt.events.len(), 5); + assert!(receipt.events[4].skipped_over_limit); + assert_eq!(receipt.eose_count, 2); +} + +#[tokio::test] +async fn fetch_rejects_oversized_raw_json_before_radroots_adapter_parsing() { + let accepted = signed_post("after oversized raw event"); + let adapter = RadrootsMockRelayFetchAdapter::new(vec![ + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: "x".repeat(DEFAULT_RAW_JSON_MAX_BYTES + 1), + }, + RadrootsRelayFetchItem::Event { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + raw_json: accepted.raw_json().to_owned(), + }, + RadrootsRelayFetchItem::Eose { + relay_url: RELAY_PRIMARY_WSS.to_owned(), + }, + ]); + + let receipt = fetch_relay_events(&adapter, post_relay_fetch_request(1_134, 1)) + .await + .expect("fetch events"); + + assert_eq!(receipt.events.len(), 1); + assert_eq!(receipt.verification_failed_count, 1); + assert_eq!(receipt.malformed_count, 0); + assert_eq!(receipt.event_receipts.len(), 2); + assert_eq!(receipt.event_receipts[0].event_id, None); + assert_eq!( + receipt.event_receipts[0].verification, + RadrootsRelayFetchEventVerification::Failed + ); + assert_eq!(receipt.eose_count, 1); +} + +#[tokio::test] async fn fetch_subscription_mode_and_store_errors_are_propagated() { let signed = signed_post("subscription"); let store = RadrootsEventStore::open_memory().await.expect("store"); @@ -4563,15 +5146,15 @@ async fn smoke_relay_fetch_processes_one_thousand_event_receipts() { assert_eq!(receipt.inserted_count, 1_000); assert_eq!(receipt.duplicate_count, 0); assert_eq!(receipt.malformed_count, 0); - assert_eq!(receipt.unsupported_count, 0); - assert_eq!(receipt.invalid_count, 0); + assert_eq!(receipt.verification_failed_count, 0); + assert_eq!(receipt.admission_unsupported_count, 0); + assert_eq!(receipt.admission_invalid_count, 0); + assert_eq!(receipt.valid_stream_eligible_count, 1_000); + assert_eq!(receipt.visible_count, 1_000); assert_eq!(receipt.events.len(), 1_000); - assert!( - receipt - .events - .iter() - .all(|event| event.valid_stream_eligible) - ); + assert!(receipt.events.iter().all(|event| event.valid_stream + == RadrootsRelayFetchEventValidStream::Eligible + && event.visibility == RadrootsRelayFetchEventVisibility::Visible)); let replay = store.valid_stream_after(0, 1_000).await.expect("replay"); assert_eq!(replay.len(), 1_000); } diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -4,9 +4,13 @@ mod admission_authority; mod artifact_bundle; mod comment_authority; mod deletion_authority; +mod food_availability_projection; mod nip09_reconciliation; mod registry_v7; +pub(crate) use food_availability_projection::{ + validate_food_availability_projection_manifest, write_food_availability_projection_manifest, +}; pub(crate) use nip09_reconciliation::{ validate_nip09_reconciliation_manifest, write_nip09_reconciliation_manifest, }; @@ -42,8 +46,12 @@ const CONFORMANCE_ROOT_RELATIVE: &str = "contracts/conformance"; const CONFORMANCE_SCHEMA_RELATIVE: &str = "contracts/conformance/schema/vector.schema.json"; const NIP09_RECONCILIATION_CONFORMANCE_VECTOR_RELATIVE: &str = "contracts/conformance/vectors/event_store/nip09_reconciliation.v1.json"; -const SPECIALIZED_CONFORMANCE_VECTOR_RELATIVES: [&str; 1] = - [NIP09_RECONCILIATION_CONFORMANCE_VECTOR_RELATIVE]; +const FOOD_AVAILABILITY_PROJECTION_CONFORMANCE_VECTOR_RELATIVE: &str = + "contracts/conformance/vectors/event_store/food_availability_projection.v1.json"; +const SPECIALIZED_CONFORMANCE_VECTOR_RELATIVES: [&str; 2] = [ + NIP09_RECONCILIATION_CONFORMANCE_VECTOR_RELATIVE, + FOOD_AVAILABILITY_PROJECTION_CONFORMANCE_VECTOR_RELATIVE, +]; const KNOWLEDGE_MANIFEST_RELATIVE: &str = "contracts/knowledge/knowledge_event_contract_manifest.v2.json"; const KNOWLEDGE_MANIFEST_SHA256_RELATIVE: &str = @@ -63,7 +71,7 @@ const REPLICA_CONTRACT_NAME: &str = "radroots_replica_contract"; const REPLICA_TRANSFER_CONSTANT: &str = "RADROOTS_REPLICA_TRANSFER_VERSION"; const REPLICA_TRANSFER_VERSION: u32 = 2; const VENDORED_WORKSPACE_MEMBER_RELATIVE: &str = "crates/libsqlite3_sys_3_53_3"; -const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 20] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 21] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -105,6 +113,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 20] = [ "crates/event_store/tests/fixtures/nip09_reconciliation.v1.json", ), ( + FOOD_AVAILABILITY_PROJECTION_CONFORMANCE_VECTOR_RELATIVE, + "crates/event_store/tests/fixtures/food_availability_projection.v1.json", + ), + ( "contracts/conformance/vectors/events/operational_listing_tags_full.v1.json", "crates/event_codec/tests/fixtures/operational_listing_tags_full.v1.json", ), @@ -1379,6 +1391,10 @@ pub struct ReplicaContractPolicy { pub classified_listing_operational_projection: String, pub classified_listing_excluded_or_rejected_head: String, pub classified_listing_head_only_ingest: String, + pub legacy_bare_envelope_ingest: String, + pub legacy_ingest_feature: String, + pub phase_1_ingest_replacement: String, + pub future_product_ingest_input: String, } #[derive(Debug, Deserialize)] @@ -4994,7 +5010,190 @@ fn parse_replica_transfer_constant(path: &Path, name: &str) -> Result<u32, Strin }) } +fn has_exact_legacy_ingest_cfg(attributes: &[syn::Attribute]) -> bool { + let mut cfg_attributes = attributes + .iter() + .filter(|attribute| attribute.path().is_ident("cfg")); + let Some(attribute) = cfg_attributes.next() else { + return false; + }; + if cfg_attributes.next().is_some() { + return false; + } + let syn::Meta::List(arguments) = &attribute.meta else { + return false; + }; + let Ok(predicate) = arguments.parse_args::<syn::Meta>() else { + return false; + }; + let syn::Meta::NameValue(feature) = predicate else { + return false; + }; + if !feature.path.is_ident("feature") { + return false; + } + matches!( + feature.value, + syn::Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(value), + .. + }) if value.value() == "legacy-ingest" + ) +} + +fn replica_use_tree_references_ingest(tree: &syn::UseTree) -> bool { + match tree { + syn::UseTree::Path(path) => { + path.ident == "ingest" || replica_use_tree_references_ingest(&path.tree) + } + syn::UseTree::Name(name) => name.ident == "ingest", + syn::UseTree::Rename(rename) => rename.ident == "ingest", + syn::UseTree::Group(group) => group.items.iter().any(replica_use_tree_references_ingest), + syn::UseTree::Glob(_) => false, + } +} + +fn collect_public_replica_ingest_exports<'a>( + items: &'a [syn::Item], + exports: &mut Vec<&'a syn::ItemUse>, +) { + for item in items { + match item { + syn::Item::Use(export) + if matches!(&export.vis, syn::Visibility::Public(_)) + && replica_use_tree_references_ingest(&export.tree) => + { + exports.push(export); + } + syn::Item::Mod(module) if matches!(&module.vis, syn::Visibility::Public(_)) => { + if let Some((_, nested_items)) = &module.content { + collect_public_replica_ingest_exports(nested_items, exports); + } + } + _ => {} + } + } +} + +fn validate_replica_legacy_ingest_exports(lib_path: &Path, source: &str) -> Result<(), String> { + let syntax = syn::parse_file(source) + .map_err(|error| format!("parse replica sync source {}: {error}", lib_path.display()))?; + let ingest_modules = syntax + .items + .iter() + .filter_map(|item| match item { + syn::Item::Mod(module) if module.ident == "ingest" => Some(module), + _ => None, + }) + .collect::<Vec<_>>(); + if ingest_modules.len() != 1 { + return Err(format!( + "replica legacy ingest source {} must declare exactly one ingest module", + lib_path.display() + )); + } + let ingest_module = ingest_modules[0]; + if !matches!(&ingest_module.vis, syn::Visibility::Public(_)) { + return Err(format!( + "replica legacy ingest module in {} must remain public", + lib_path.display() + )); + } + if !has_exact_legacy_ingest_cfg(&ingest_module.attrs) { + return Err(format!( + "replica legacy ingest module in {} must be guarded by exact #[cfg(feature = \"legacy-ingest\")]", + lib_path.display() + )); + } + + let mut ingest_exports = Vec::new(); + collect_public_replica_ingest_exports(&syntax.items, &mut ingest_exports); + if ingest_exports.is_empty() { + return Err(format!( + "replica legacy ingest source {} must publicly re-export the ingest API", + lib_path.display() + )); + } + if ingest_exports + .iter() + .any(|export| !has_exact_legacy_ingest_cfg(&export.attrs)) + { + return Err(format!( + "every public replica ingest re-export in {} must be guarded by exact #[cfg(feature = \"legacy-ingest\")]", + lib_path.display() + )); + } + Ok(()) +} + fn validate_replica_policy_source_witnesses(sync_root: &Path) -> Result<(), String> { + let cargo_path = sync_root.join("Cargo.toml"); + let cargo_source = fs::read_to_string(&cargo_path) + .map_err(|error| format!("read {}: {error}", cargo_path.display()))?; + let cargo: toml::Value = toml::from_str(&cargo_source) + .map_err(|error| format!("parse {}: {error}", cargo_path.display()))?; + let features = cargo + .get("features") + .and_then(toml::Value::as_table) + .ok_or_else(|| format!("replica sync {} must define features", cargo_path.display()))?; + let default_features = features + .get("default") + .and_then(toml::Value::as_array) + .ok_or_else(|| { + format!( + "replica sync {} must define default features", + cargo_path.display() + ) + })?; + let mut pending_default_features = default_features + .iter() + .filter_map(toml::Value::as_str) + .collect::<Vec<_>>(); + let mut visited_default_features = BTreeSet::new(); + while let Some(feature) = pending_default_features.pop() { + if !visited_default_features.insert(feature) { + continue; + } + if feature == "legacy-ingest" { + return Err(format!( + "replica legacy-ingest must not be enabled by default features in {}", + cargo_path.display() + )); + } + if let Some(members) = features.get(feature).and_then(toml::Value::as_array) { + pending_default_features.extend( + members + .iter() + .filter_map(toml::Value::as_str) + .filter(|member| features.contains_key(*member)), + ); + } + } + let legacy_features = features + .get("legacy-ingest") + .and_then(toml::Value::as_array) + .ok_or_else(|| { + format!( + "replica sync {} must define the explicit legacy-ingest feature", + cargo_path.display() + ) + })?; + if !legacy_features + .iter() + .filter_map(toml::Value::as_str) + .any(|feature| feature == "std") + { + return Err(format!( + "replica legacy-ingest feature in {} must enable std", + cargo_path.display() + )); + } + + let lib_path = sync_root.join("src/lib.rs"); + let lib_source = fs::read_to_string(&lib_path) + .map_err(|error| format!("read {}: {error}", lib_path.display()))?; + validate_replica_legacy_ingest_exports(&lib_path, &lib_source)?; + let types_path = sync_root.join("src/types.rs"); let types_source = fs::read_to_string(&types_path) .map_err(|error| format!("read {}: {error}", types_path.display()))?; @@ -5200,6 +5399,26 @@ fn validate_replica_contract(bundle: &ContractBundle, workspace_root: &Path) -> replica.policy.classified_listing_head_only_ingest.as_str(), "reject_require_profile_aware", ), + ( + "legacy_bare_envelope_ingest", + replica.policy.legacy_bare_envelope_ingest.as_str(), + "explicit_non_default_feature_only", + ), + ( + "legacy_ingest_feature", + replica.policy.legacy_ingest_feature.as_str(), + "legacy-ingest", + ), + ( + "phase_1_ingest_replacement", + replica.policy.phase_1_ingest_replacement.as_str(), + "none", + ), + ( + "future_product_ingest_input", + replica.policy.future_product_ingest_input.as_str(), + "store_produced_verified_valid_visible_admission", + ), ] { if actual != expected { return Err(format!("replica policy.{field} must be {expected}")); @@ -8689,6 +8908,20 @@ name = "radroots_b" version = "1.0.0" edition = "2024" publish = false + +[features] +default = ["std"] +std = [] +legacy-ingest = ["std"] +"#, + ); + write_file( + &root.join("crates").join("b").join("src").join("lib.rs"), + r#"#[cfg(feature = "legacy-ingest")] +pub mod ingest; + +#[cfg(feature = "legacy-ingest")] +pub use ingest::{radroots_replica_ingest_event, RadrootsReplicaIngestOutcome}; "#, ); write_file( @@ -8810,6 +9043,10 @@ classified_listing_head_selection = "raw_before_profile" classified_listing_operational_projection = "operational_partition_only" classified_listing_excluded_or_rejected_head = "remove_projection_and_advance" classified_listing_head_only_ingest = "reject_require_profile_aware" +legacy_bare_envelope_ingest = "explicit_non_default_feature_only" +legacy_ingest_feature = "legacy-ingest" +phase_1_ingest_replacement = "none" +future_product_ingest_input = "store_produced_verified_valid_visible_admission" [transfer] version = 2 @@ -10073,6 +10310,24 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] "allow_head_only".to_string(); }, ); + assert_replica_error( + "legacy_bare_envelope_ingest must be explicit_non_default_feature_only", + |bundle| { + bundle.replica.policy.legacy_bare_envelope_ingest = "default".to_string(); + }, + ); + assert_replica_error("legacy_ingest_feature must be legacy-ingest", |bundle| { + bundle.replica.policy.legacy_ingest_feature = "std".to_string(); + }); + assert_replica_error("phase_1_ingest_replacement must be none", |bundle| { + bundle.replica.policy.phase_1_ingest_replacement = "legacy".to_string(); + }); + assert_replica_error( + "future_product_ingest_input must be store_produced_verified_valid_visible_admission", + |bundle| { + bundle.replica.policy.future_product_ingest_input = "bare_envelope".to_string(); + }, + ); assert_replica_error("transfer.version must be 2", |bundle| { bundle.replica.transfer.version = 1; }); @@ -10104,12 +10359,77 @@ crates = ["radroots_a", "radroots_b", "radroots_c", "radroots_d", "radroots_e"] #[test] fn replica_policy_source_witnesses_reject_runtime_drift() { let root = create_synthetic_workspace("replica_policy_source_drift"); + let cargo_path = root.join("crates/b/Cargo.toml"); + let lib_path = root.join("crates/b/src/lib.rs"); let types_path = root.join("crates/b/src/types.rs"); let emit_path = root.join("crates/b/src/emit.rs"); + let cargo = fs::read_to_string(&cargo_path).expect("read replica cargo manifest"); + let lib = fs::read_to_string(&lib_path).expect("read replica lib source"); let types = fs::read_to_string(&types_path).expect("read replica types source"); let emit = fs::read_to_string(&emit_path).expect("read replica emit source"); write_file( + &cargo_path, + &cargo.replace("std = []", "std = [\"legacy-ingest\"]"), + ); + let bundle = + load_contract_bundle(&root).expect("load transitive default-feature drift contract"); + let default_feature_error = validate_replica_contract(&bundle, &root) + .expect_err("transitively default legacy ingest feature must fail"); + assert!(default_feature_error.contains("must not be enabled by default features")); + + write_file(&cargo_path, &cargo); + write_file( + &lib_path, + &format!( + "{}\n/*\n#[cfg(feature = \"legacy-ingest\")]\npub mod ingest;\n*/\n", + lib.replace( + "#[cfg(feature = \"legacy-ingest\")]\npub mod ingest;", + "#[cfg(feature = \"std\")]\npub mod ingest;", + ) + ), + ); + let bundle = load_contract_bundle(&root).expect("load ingest-module drift contract"); + let module_error = validate_replica_contract(&bundle, &root) + .expect_err("comment-only legacy guard witness must fail"); + assert!(module_error.contains("must be guarded by exact")); + + write_file( + &lib_path, + &format!("{lib}\npub use ingest::RadrootsReplicaIngestOutcome;\n"), + ); + let bundle = load_contract_bundle(&root).expect("load second-reexport drift contract"); + let reexport_error = validate_replica_contract(&bundle, &root) + .expect_err("ungated second ingest re-export must fail"); + assert!(reexport_error.contains("every public replica ingest re-export")); + + write_file( + &lib_path, + &lib.replacen( + "#[cfg(feature = \"legacy-ingest\")]\npub use ingest::{", + "#[cfg(any(feature = \"legacy-ingest\", feature = \"std\"))]\npub use ingest::{", + 1, + ), + ); + let bundle = load_contract_bundle(&root).expect("load broadened-reexport contract"); + let broadened_error = validate_replica_contract(&bundle, &root) + .expect_err("broadened ingest re-export guard must fail"); + assert!(broadened_error.contains("every public replica ingest re-export")); + + write_file( + &lib_path, + &format!( + "{lib}\npub mod nested {{\n pub use super::ingest::RadrootsReplicaIngestOutcome;\n}}\n" + ), + ); + let bundle = load_contract_bundle(&root).expect("load nested-reexport drift contract"); + let nested_error = validate_replica_contract(&bundle, &root) + .expect_err("ungated nested public ingest re-export must fail"); + assert!(nested_error.contains("every public replica ingest re-export")); + + write_file(&lib_path, &lib); + + write_file( &types_path, &types.replace( "#[serde(deny_unknown_fields)]\npub struct RadrootsReplicaSyncOptions", @@ -11665,6 +11985,11 @@ publish = false [dependencies] dto_bindgen = { workspace = true, optional = true } + +[features] +default = ["std"] +std = [] +legacy-ingest = ["std"] "#, ); validate_generic_release_preflight(&root) diff --git a/tools/xtask/src/contract/food_availability_projection.rs b/tools/xtask/src/contract/food_availability_projection.rs @@ -0,0 +1,4367 @@ +use super::artifact_bundle::{ + GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction, +}; +use super::nip09_reconciliation::{ + validate_nip09_predecessor_production_sources_under_lock, + validate_nip09_reconciliation_manifest_under_lock, +}; +use super::registry_v7::validate_event_contract_registry_v7_inventory_under_lock; +use quote::ToTokens; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use syn::visit::Visit; +use syn::{Expr, Item, Lit}; + +const SCHEMA_VERSION: u32 = 1; +const CONTRACT_ID: &str = "radroots_event_store.food_availability_projection_v1"; +const HOOK_ID: &str = "food_availability_projection_v1"; +const MIGRATION_VERSION: u32 = 3; +const MIGRATION_NAME: &str = "food_availability_projection"; +const PROJECTION_VERSION: u32 = 1; +const ADDRESSABLE_FEED_VERSION: u32 = 1; +const EVENT_CONTRACT_REGISTRY_VERSION: u32 = 7; +const FOOD_AVAILABILITY_KIND: u32 = 30_402; +const UNRELATED_ADDRESSABLE_KIND: u32 = 30_340; +const FOOD_CONTRACT_ID: &str = "radroots.food.availability.v1"; +const OPERATIONAL_LISTING_CONTRACT_ID: &str = "radroots.operational_listing.published.v1"; +const FARM_PROFILE_CONTRACT_ID: &str = "radroots.farm.profile.v1"; +const DELETION_CONTRACT_ID: &str = "radroots.social.deletion_request.v1"; +const ADMISSION_AUTHORITY: &str = "event_contract_registry_v7"; +const CURRENT_VISIBILITY_AUTHORITY: &str = "radroots_event_store_current_visibility_v1"; +const POST_CORE_CAPABILITY: &str = "apply_v2"; +const SCOPE_FINGERPRINT_SHA256: &str = + "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0"; +const SCHEMA_SHA256: &str = "dd12467e04addcbddb5ea0f386c12a8ac05ef5ebaaf949f24dd2c62745f5aaac"; + +const PREDECESSOR_HOOK_ID: &str = "nip09_reconciliation_v1"; +const PREDECESSOR_MANIFEST_RELATIVE: &str = + "crates/event_store/contracts/nip09_reconciliation_v1.manifest.json"; +const PREDECESSOR_MANIFEST_BYTE_LENGTH: usize = 537_538; +const PREDECESSOR_MANIFEST_SHA256: &str = + "74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77"; + +const MANIFEST_RELATIVE: &str = + "crates/event_store/contracts/food_availability_projection_v1.manifest.json"; +const MANIFEST_SCHEMA_RELATIVE: &str = + "crates/event_store/contracts/food_availability_projection_v1.manifest.schema.json"; +const MANIFEST_SHA256_RELATIVE: &str = + "crates/event_store/contracts/food_availability_projection_v1.manifest.sha256"; +const GENERATED_DESCRIPTOR_RELATIVE: &str = + "crates/event_store/src/generated/food_availability_projection_manifest.rs"; +const MIGRATIONS_SOURCE_RELATIVE: &str = "crates/event_store/src/migrations.rs"; +const MIGRATION_UP_RELATIVE: &str = + "crates/event_store/migrations/0003_food_availability_projection.up.sql"; +const MIGRATION_DOWN_RELATIVE: &str = + "crates/event_store/migrations/0003_food_availability_projection.down.sql"; +const REGISTRY_INVENTORY_RELATIVE: &str = + "contracts/event_store/event_contract_registry_v7.inventory.json"; +const FOOD_PROFILE_VECTOR_RELATIVE: &str = + "contracts/conformance/vectors/food_availability/profile.v1.json"; +const RESULT_VECTOR_CANONICAL_RELATIVE: &str = + "contracts/conformance/vectors/event_store/food_availability_projection.v1.json"; +const RESULT_VECTOR_MIRROR_RELATIVE: &str = + "crates/event_store/tests/fixtures/food_availability_projection.v1.json"; +const RESULT_VECTOR_EXECUTOR_RELATIVE: &str = + "crates/event_store/tests/food_availability_projection_v1_result_vector.rs"; +const RESULT_VECTOR_EXECUTOR_ID: &str = + "radroots_event_store.food_availability_projection_v1.result_vector_executor.v1"; +const RESULT_VECTOR_EXECUTOR_TEST: &str = "food_availability_projection_v1_result_vector"; +const SOURCE_GENERATION_ACTIVE_SENTINEL: &str = "active"; +const WRITE_COMMAND: &str = "cargo xtask contract food-availability-projection-manifest --write"; +const HASH_ALGORITHM: &str = "sha256_bytes_v1"; +const EVENT_STORE_LIB_RELATIVE: &str = "crates/event_store/src/lib.rs"; +const EVENT_STORE_MODEL_RELATIVE: &str = "crates/event_store/src/model.rs"; + +const GOVERNED_PUBLIC_API_MODULES: &[&str] = &[ + "addressable_transition_feed_v1", + "current_visibility_v1", + "food_availability_projection_v1", +]; + +#[derive(Clone, Copy)] +struct SourceSpec { + role: &'static str, + path: &'static str, +} + +const SOURCE_SPECS: &[SourceSpec] = &[ + SourceSpec { + role: "workspace_dependency_authority", + path: "Cargo.toml", + }, + SourceSpec { + role: "event_store_dependency_authority", + path: "crates/event_store/Cargo.toml", + }, + SourceSpec { + role: "blossom_public_surface", + path: "crates/blossom/src/lib.rs", + }, + SourceSpec { + role: "blossom_sha256_value_object", + path: "crates/blossom/src/hash.rs", + }, + SourceSpec { + role: "food_event_contract", + path: "crates/event/src/food_availability.rs", + }, + SourceSpec { + role: "food_admission", + path: "crates/event_codec/src/food_availability/admission.rs", + }, + SourceSpec { + role: "registry_v7_admission", + path: "crates/event_codec/src/admission/registry_v7.rs", + }, + SourceSpec { + role: "registry_v7_food_projection", + path: "crates/event_codec/src/food_availability/inbound/registry_v7.rs", + }, + SourceSpec { + role: "event_store_error_surface", + path: "crates/event_store/src/error.rs", + }, + SourceSpec { + role: "generated_descriptor_registration", + path: "crates/event_store/src/generated.rs", + }, + SourceSpec { + role: "public_surface", + path: "crates/event_store/src/lib.rs", + }, + SourceSpec { + role: "migration_registry", + path: MIGRATIONS_SOURCE_RELATIVE, + }, + SourceSpec { + role: "model_registration", + path: "crates/event_store/src/model.rs", + }, + SourceSpec { + role: "schema_hooks", + path: "crates/event_store/src/schema.rs", + }, + SourceSpec { + role: "store_ingest_and_wal_authority", + path: "crates/event_store/src/store.rs", + }, + SourceSpec { + role: "addressable_transition_feed_model", + path: "crates/event_store/src/model/addressable_transition_feed_v1.rs", + }, + SourceSpec { + role: "current_visibility_model", + path: "crates/event_store/src/model/current_visibility_v1.rs", + }, + SourceSpec { + role: "food_projection_model", + path: "crates/event_store/src/model/food_availability_projection_v1.rs", + }, + SourceSpec { + role: "addressable_transition_feed_store", + path: "crates/event_store/src/store/addressable_transition_feed_v1.rs", + }, + SourceSpec { + role: "current_visibility_store", + path: "crates/event_store/src/store/current_visibility_v1.rs", + }, + SourceSpec { + role: "food_projection_store", + path: "crates/event_store/src/store/food_availability_projection_v1.rs", + }, + SourceSpec { + role: "predecessor_protocol_storage", + path: "crates/event_store/src/store/protocol_storage_v1.rs", + }, + SourceSpec { + role: "predecessor_active_state_fast_validation", + path: "crates/event_store/src/nip09/reconciliation_v1.rs", + }, + SourceSpec { + role: "post_core_capabilities", + path: "crates/event_store/src/store/post_core_extension_capabilities.rs", + }, + SourceSpec { + role: "post_core_dispatcher", + path: "crates/event_store/src/store/post_core_extension_dispatcher.rs", + }, + SourceSpec { + role: "post_core_v2_extension", + path: "crates/event_store/src/store/post_core_extensions_v2.rs", + }, + SourceSpec { + role: "post_core_v2_storage", + path: "crates/event_store/src/store/post_core_storage_v2.rs", + }, + SourceSpec { + role: "predecessor_post_core_v1_extension", + path: "crates/event_store/src/store/post_core_extensions_v1.rs", + }, + SourceSpec { + role: "predecessor_post_core_v1_storage", + path: "crates/event_store/src/store/post_core_storage_v1.rs", + }, +]; + +const PREDECESSOR_SUPERSEDED_SOURCE_PATHS: &[&str] = &[ + "crates/blossom/src/hash.rs", + "crates/blossom/src/lib.rs", + "crates/event/src/food_availability.rs", + "crates/event_codec/src/admission/registry_v7.rs", + "crates/event_codec/src/food_availability/admission.rs", + "crates/event_codec/src/food_availability/inbound/registry_v7.rs", + "crates/event_store/src/error.rs", + "crates/event_store/src/generated.rs", + "crates/event_store/src/lib.rs", + "crates/event_store/src/migrations.rs", + "crates/event_store/src/model.rs", + "crates/event_store/src/nip09/reconciliation_v1.rs", + "crates/event_store/src/schema.rs", + "crates/event_store/src/store.rs", + "crates/event_store/src/store/post_core_extension_capabilities.rs", + "crates/event_store/src/store/post_core_extension_dispatcher.rs", + "crates/event_store/src/store/post_core_extensions_v1.rs", + "crates/event_store/src/store/post_core_storage_v1.rs", + "crates/event_store/src/store/protocol_storage_v1.rs", +]; + +const EXPECTED_CATALOG_OBJECTS: &[&str] = &[ + "radroots_event_store_addressable_feed_generation_insert", + "radroots_event_store_addressable_feed_integrity_v1", + "radroots_event_store_addressable_feed_transition_insert", + "radroots_event_store_addressable_transition_coordinate_idx", + "radroots_event_store_current_visibility_head_lookup_idx", + "radroots_event_store_current_visibility_v1", + "radroots_event_store_food_availability_author_idx", + "radroots_event_store_food_availability_cursor", + "radroots_event_store_food_availability_cursor_delete_guard", + "radroots_event_store_food_availability_cursor_insert_guard", + "radroots_event_store_food_availability_cursor_update_guard", + "radroots_event_store_food_availability_image", + "radroots_event_store_food_availability_image_delete_guard", + "radroots_event_store_food_availability_image_insert_guard", + "radroots_event_store_food_availability_image_update_guard", + "radroots_event_store_food_availability_projection", + "radroots_event_store_food_availability_projection_delete_guard", + "radroots_event_store_food_availability_projection_insert_guard", + "radroots_event_store_food_availability_projection_update_guard", + "radroots_event_store_food_availability_read_v1", + "radroots_event_store_food_availability_recent_idx", + "radroots_event_store_food_availability_search_delete", + "radroots_event_store_food_availability_search_fts", + "radroots_event_store_food_availability_search_fts_config", + "radroots_event_store_food_availability_search_fts_content", + "radroots_event_store_food_availability_search_fts_data", + "radroots_event_store_food_availability_search_fts_docsize", + "radroots_event_store_food_availability_search_fts_idx", + "radroots_event_store_food_availability_search_insert", + "radroots_event_store_food_availability_status_idx", + "radroots_event_store_nip09_address_target_visibility_lookup_idx", +]; + +const EXPECTED_CATALOG_TABLES: &[&str] = &[ + "radroots_event_store_addressable_feed_integrity_v1", + "radroots_event_store_food_availability_cursor", + "radroots_event_store_food_availability_image", + "radroots_event_store_food_availability_projection", + "radroots_event_store_food_availability_search_fts", + "radroots_event_store_food_availability_search_fts_config", + "radroots_event_store_food_availability_search_fts_content", + "radroots_event_store_food_availability_search_fts_data", + "radroots_event_store_food_availability_search_fts_docsize", + "radroots_event_store_food_availability_search_fts_idx", +]; + +const EXPECTED_CATALOG_FTS5_TABLES: &[&str] = + &["radroots_event_store_food_availability_search_fts"]; + +const ENTRY_POINTS: &[(&str, &str)] = &[ + ( + "registry_v7_admission", + "radroots_event_codec::admit_verified_event_registry_v7", + ), + ( + "migration_registry", + "radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[2]", + ), + ( + "migration_apply_hook", + "radroots_event_store::schema::apply_migration_hook", + ), + ( + "migration_validation_hook", + "radroots_event_store::schema::validate_migration_hook_state", + ), + ( + "post_core_extension", + "radroots_event_store::store::PostCoreExtensionCapabilities::apply_v2", + ), + ( + "addressable_transition_feed", + "radroots_event_store::RadrootsEventStore::addressable_transition_page_v1", + ), + ( + "current_visibility", + "radroots_event_store::RadrootsEventStore::current_event_visibility_v1", + ), + ( + "event_visibility_batch", + "radroots_event_store::RadrootsEventStore::event_visibilities", + ), + ( + "projection_lookup", + "radroots_event_store::RadrootsEventStore::food_availability_v1", + ), + ( + "projection_recent", + "radroots_event_store::RadrootsEventStore::recent_food_availability_v1", + ), + ( + "projection_search", + "radroots_event_store::RadrootsEventStore::search_food_availability_v1", + ), + ( + "projection_audit", + "radroots_event_store::RadrootsEventStore::audit_food_availability_projection_v1", + ), + ("result_vector_executor", RESULT_VECTOR_EXECUTOR_TEST), +]; + +const PUBLIC_API: &[&str] = &[ + "RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1", + "RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1", + "RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1", + "RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1", + "RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1", + "RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1", + "RadrootsAddressableTransitionCauseV1", + "RadrootsAddressableTransitionCoordinateV1", + "RadrootsAddressableTransitionCursorV1", + "RadrootsAddressableTransitionEventReferenceV1", + "RadrootsAddressableTransitionOriginV1", + "RadrootsAddressableTransitionPageV1", + "RadrootsAddressableTransitionRawHeadDecisionV1", + "RadrootsAddressableTransitionScopeFingerprintV1", + "RadrootsAddressableTransitionScopeV1", + "RadrootsAddressableTransitionV1", + "RadrootsAddressableTransitionVisibilityV1", + "RadrootsCurrentEventVisibilityV1", + "RadrootsCurrentVisibilityDecisionV1", + "RadrootsFoodAvailabilitySearchQueryV1", + "RadrootsFoodAvailabilityStatusFilterV1", + "RadrootsNip09SuppressionEvidenceV1", + "RadrootsNip09SuppressionOutcome", + "RadrootsNip09SuppressionReason", + "RadrootsStoreProducedCanonicalEventV1", + "RadrootsStoredFoodAvailabilityImageV1", + "RadrootsStoredFoodAvailabilityV1", +]; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct FoodAvailabilityProjectionManifest { + schema_version: u32, + contract_id: String, + hook_id: String, + manifest_schema: FileDescriptor, + predecessor: PredecessorDescriptor, + migration: MigrationDescriptor, + profile: ProfileDescriptor, + registry_inventory: FileDescriptor, + food_profile_vector: FileDescriptor, + entry_points: Vec<EntryPointDescriptor>, + source_files: Vec<SourceFileDescriptor>, + public_api: Vec<String>, + result_vector: ResultVectorDescriptor, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct FileDescriptor { + path: String, + byte_length: u64, + sha256: String, + hash_algorithm: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct PredecessorDescriptor { + hook_id: String, + manifest: FileDescriptor, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct MigrationDescriptor { + version: u32, + name: String, + up: FileDescriptor, + down: FileDescriptor, + schema_sha256: String, + catalog: CatalogDescriptor, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct CatalogDescriptor { + objects: Vec<String>, + tables: Vec<String>, + fts5_tables: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ProfileDescriptor { + event_contract_registry_version: u32, + addressable_feed_version: u32, + projection_version: u32, + scope_kinds: Vec<u32>, + scope_fingerprint_sha256: String, + food_contract_id: String, + admission_authority: String, + current_visibility_authority: String, + post_core_capability: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct EntryPointDescriptor { + role: String, + rust_path: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct SourceFileDescriptor { + role: String, + path: String, + byte_length: u64, + sha256: String, + hash_algorithm: String, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ResultVectorDescriptor { + canonical_path: String, + mirror_path: String, + byte_length: u64, + sha256: String, + hash_algorithm: String, + executor_id: String, + executor_path: String, + executor_test: String, + executor_byte_length: u64, + executor_sha256: String, + executor_hash_algorithm: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ProjectionResultVector { + schema_version: u32, + contract_id: String, + feed_version: u32, + projection_version: u32, + scope_kinds: Vec<u32>, + cases: Vec<ProjectionCase>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ProjectionCase { + id: String, + events: Vec<ObservedEvent>, + expected: ExpectedCase, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ObservedEvent { + role: ProjectionInputRole, + observed_at_ms: i64, + expected_ingest: ExpectedIngest, + event: SignedEvent, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +enum ProjectionInputRole { + ScopedFood, + ScopedNonFood, + UnrelatedAddressable, + Causal, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedIngest { + admission_status: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + admission_code: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + contract_id: RequiredNullable<String>, + event_class: String, + valid_stream_eligible: bool, + raw_head_decision: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct SignedEvent { + id: String, + pubkey: String, + created_at: u64, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + sig: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedCase { + coordinate: ExpectedCoordinate, + #[serde(deserialize_with = "deserialize_required_nullable")] + projection: RequiredNullable<ExpectedProjection>, + searches: Vec<ExpectedSearch>, + transition_page: ExpectedTransitionPage, + event_visibility: Vec<ExpectedVisibility>, + historical_visibility_witnesses: Vec<ExpectedHistoricalVisibilityWitness>, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct ExpectedCoordinate { + kind: u32, + pubkey: String, + d_tag: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedProjection { + event_id: String, + content: String, + title: String, + summary: String, + published_at: u64, + location: String, + price_amount: String, + price_currency: String, + price_unit: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + quantity_amount: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + quantity_unit: RequiredNullable<String>, + status: String, + diagnostics: Vec<String>, + images: Vec<ExpectedImage>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedImage { + #[serde(deserialize_with = "deserialize_required_nullable")] + url: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + width: RequiredNullable<u32>, + #[serde(deserialize_with = "deserialize_required_nullable")] + height: RequiredNullable<u32>, + #[serde(deserialize_with = "deserialize_required_nullable")] + blossom_sha256: RequiredNullable<String>, + diagnostics: Vec<String>, + qualifies: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedSearch { + query: String, + event_ids: Vec<String>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransitionPage { + source_high_water: i64, + has_more: bool, + next_cursor: ExpectedTransitionCursor, + transitions: Vec<ExpectedTransition>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransitionCursor { + source_generation: String, + feed_version: u32, + scope_fingerprint: String, + last_transition_seq: i64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransition { + transition_seq: i64, + source_generation: String, + origin: String, + coordinate: ExpectedCoordinate, + raw_head: ExpectedEventReference, + raw_head_created_at: u64, + admission_status: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + admission_code: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + contract_id: RequiredNullable<String>, + visibility: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + suppression: RequiredNullable<ExpectedSuppressionEvidence>, + #[serde(deserialize_with = "deserialize_required_nullable")] + cause_event: RequiredNullable<ExpectedTransitionCause>, + #[serde(deserialize_with = "deserialize_required_nullable")] + canonical_visible_event: RequiredNullable<ExpectedCanonicalVisibleEvent>, + #[serde(deserialize_with = "deserialize_required_nullable")] + retracted_event: RequiredNullable<ExpectedEventReference>, + raw_head_decision: String, +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct ExpectedEventReference { + event_id: String, + event_seq: i64, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedSuppressionEvidence { + outcome: String, + reason: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + event_reference_request_id: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + address_reference_request_id: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + address_reference_cutoff: RequiredNullable<u64>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedTransitionCause { + event: ExpectedEventReference, + pubkey: String, + created_at: u64, + kind: u32, + admission_status: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + admission_code: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + contract_id: RequiredNullable<String>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedCanonicalVisibleEvent { + event: ExpectedEventReference, + raw_json_sha256: String, + admission_status: String, + #[serde(deserialize_with = "deserialize_required_nullable")] + contract_id: RequiredNullable<String>, + event_class: String, + valid_stream_eligible: bool, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedVisibility { + event: ExpectedEventReference, + source_generation: String, + admission_status: String, + decision: String, + is_raw_head: bool, + #[serde(deserialize_with = "deserialize_required_nullable")] + raw_head_event_id: RequiredNullable<String>, + #[serde(deserialize_with = "deserialize_required_nullable")] + suppression: RequiredNullable<ExpectedSuppressionEvidence>, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +struct ExpectedHistoricalVisibilityWitness { + transition_seq: i64, + event_id: String, + final_decision: String, +} + +#[derive(Debug, Serialize)] +struct RequiredNullable<T>(Option<T>); + +fn deserialize_required_nullable<'de, D, T>( + deserializer: D, +) -> Result<RequiredNullable<T>, D::Error> +where + D: serde::Deserializer<'de>, + T: Deserialize<'de>, +{ + Option::<T>::deserialize(deserializer).map(RequiredNullable) +} + +impl<'de, T> Deserialize<'de> for RequiredNullable<T> +where + T: Deserialize<'de>, +{ + fn deserialize<D>(deserializer: D) -> Result<Self, D::Error> + where + D: serde::Deserializer<'de>, + { + Option::<T>::deserialize(deserializer).map(Self) + } +} + +pub(crate) fn write_food_availability_projection_manifest( + workspace_root: &Path, +) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |transaction| { + validate_nip09_reconciliation_manifest_under_lock(workspace_root)?; + validate_predecessor_production_source_coverage(workspace_root)?; + validate_event_contract_registry_v7_inventory_under_lock(workspace_root)?; + let artifacts = expected_artifacts(workspace_root)?; + transaction.write(artifacts)?; + validate_food_availability_projection_manifest_under_lock(workspace_root) + }) +} + +pub(crate) fn validate_food_availability_projection_manifest( + workspace_root: &Path, +) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |_| { + validate_food_availability_projection_manifest_under_lock(workspace_root) + }) +} + +fn validate_food_availability_projection_manifest_under_lock( + workspace_root: &Path, +) -> Result<(), String> { + validate_nip09_reconciliation_manifest_under_lock(workspace_root)?; + validate_predecessor_production_source_coverage(workspace_root)?; + validate_event_contract_registry_v7_inventory_under_lock(workspace_root)?; + + let manifest_bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; + let manifest_value: Value = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; + let manifest: FoodAvailabilityProjectionManifest = + serde_json::from_value(manifest_value.clone()) + .map_err(|error| format!("parse typed {MANIFEST_RELATIVE}: {error}"))?; + validate_canonical_json(MANIFEST_RELATIVE, &manifest_bytes, &manifest)?; + + let schema_bytes = read_regular_file(workspace_root, MANIFEST_SCHEMA_RELATIVE)?; + let schema: Value = serde_json::from_slice(&schema_bytes) + .map_err(|error| format!("parse {MANIFEST_SCHEMA_RELATIVE}: {error}"))?; + validate_canonical_json(MANIFEST_SCHEMA_RELATIVE, &schema_bytes, &schema)?; + validate_manifest_json_schema(&schema, &manifest_value)?; + validate_manifest_shape(&manifest)?; + + let digest_bytes = read_regular_file(workspace_root, MANIFEST_SHA256_RELATIVE)?; + validate_digest_sidecar(MANIFEST_SHA256_RELATIVE, &digest_bytes)?; + if digest_bytes != format!("{}\n", sha256_hex(&manifest_bytes)).as_bytes() { + return Err(format!( + "{MANIFEST_SHA256_RELATIVE} must match the checked-in manifest bytes" + )); + } + + let expected = expected_artifacts(workspace_root)?; + for artifact in expected { + let actual = read_regular_file(workspace_root, artifact.relative)?; + if actual != artifact.contents { + return Err(stale_error(artifact.relative)); + } + } + Ok(()) +} + +fn expected_artifacts(workspace_root: &Path) -> Result<Vec<GeneratedArtifact>, String> { + let schema = manifest_schema(); + let schema_bytes = canonical_json_bytes(&schema)?; + let manifest = describe_manifest(workspace_root, &schema_bytes)?; + let manifest_bytes = canonical_json_bytes(&manifest)?; + let manifest_sha256 = sha256_hex(&manifest_bytes); + let descriptor = generated_descriptor(&manifest, &manifest_bytes, &manifest_sha256); + let vector_bytes = read_regular_file(workspace_root, RESULT_VECTOR_CANONICAL_RELATIVE)?; + + Ok(vec![ + GeneratedArtifact { + relative: MANIFEST_RELATIVE, + contents: manifest_bytes, + }, + GeneratedArtifact { + relative: MANIFEST_SCHEMA_RELATIVE, + contents: schema_bytes, + }, + GeneratedArtifact { + relative: MANIFEST_SHA256_RELATIVE, + contents: format!("{manifest_sha256}\n").into_bytes(), + }, + GeneratedArtifact { + relative: GENERATED_DESCRIPTOR_RELATIVE, + contents: descriptor.into_bytes(), + }, + GeneratedArtifact { + relative: RESULT_VECTOR_MIRROR_RELATIVE, + contents: vector_bytes, + }, + ]) +} + +fn describe_manifest( + workspace_root: &Path, + schema_bytes: &[u8], +) -> Result<FoodAvailabilityProjectionManifest, String> { + validate_source_contract(workspace_root)?; + validate_predecessor_production_source_coverage(workspace_root)?; + + let predecessor_bytes = read_regular_file(workspace_root, PREDECESSOR_MANIFEST_RELATIVE)?; + if predecessor_bytes.len() != PREDECESSOR_MANIFEST_BYTE_LENGTH + || sha256_hex(&predecessor_bytes) != PREDECESSOR_MANIFEST_SHA256 + { + return Err(format!( + "{PREDECESSOR_MANIFEST_RELATIVE} does not match the immutable predecessor identity" + )); + } + + let vector_bytes = read_regular_file(workspace_root, RESULT_VECTOR_CANONICAL_RELATIVE)?; + let vector: ProjectionResultVector = serde_json::from_slice(&vector_bytes) + .map_err(|error| format!("parse {RESULT_VECTOR_CANONICAL_RELATIVE}: {error}"))?; + validate_canonical_json(RESULT_VECTOR_CANONICAL_RELATIVE, &vector_bytes, &vector)?; + validate_result_vector(&vector)?; + + let migration_source = read_regular_file(workspace_root, MIGRATIONS_SOURCE_RELATIVE)?; + let catalog = catalog_from_migration_source(&migration_source)?; + validate_catalog(&catalog)?; + let executor = descriptor_for_file(workspace_root, RESULT_VECTOR_EXECUTOR_RELATIVE)?; + + let source_files = SOURCE_SPECS + .iter() + .map(|spec| { + let bytes = if spec.path == MIGRATIONS_SOURCE_RELATIVE { + migration_source.clone() + } else { + read_regular_file(workspace_root, spec.path)? + }; + Ok(SourceFileDescriptor { + role: spec.role.to_owned(), + path: spec.path.to_owned(), + byte_length: byte_length(spec.path, &bytes)?, + sha256: sha256_hex(&bytes), + hash_algorithm: HASH_ALGORITHM.to_owned(), + }) + }) + .collect::<Result<Vec<_>, String>>()?; + + Ok(FoodAvailabilityProjectionManifest { + schema_version: SCHEMA_VERSION, + contract_id: CONTRACT_ID.to_owned(), + hook_id: HOOK_ID.to_owned(), + manifest_schema: descriptor_for_bytes(MANIFEST_SCHEMA_RELATIVE, schema_bytes)?, + predecessor: PredecessorDescriptor { + hook_id: PREDECESSOR_HOOK_ID.to_owned(), + manifest: descriptor_for_bytes(PREDECESSOR_MANIFEST_RELATIVE, &predecessor_bytes)?, + }, + migration: MigrationDescriptor { + version: MIGRATION_VERSION, + name: MIGRATION_NAME.to_owned(), + up: descriptor_for_file(workspace_root, MIGRATION_UP_RELATIVE)?, + down: descriptor_for_file(workspace_root, MIGRATION_DOWN_RELATIVE)?, + schema_sha256: SCHEMA_SHA256.to_owned(), + catalog, + }, + profile: ProfileDescriptor { + event_contract_registry_version: EVENT_CONTRACT_REGISTRY_VERSION, + addressable_feed_version: ADDRESSABLE_FEED_VERSION, + projection_version: PROJECTION_VERSION, + scope_kinds: vec![FOOD_AVAILABILITY_KIND], + scope_fingerprint_sha256: SCOPE_FINGERPRINT_SHA256.to_owned(), + food_contract_id: FOOD_CONTRACT_ID.to_owned(), + admission_authority: ADMISSION_AUTHORITY.to_owned(), + current_visibility_authority: CURRENT_VISIBILITY_AUTHORITY.to_owned(), + post_core_capability: POST_CORE_CAPABILITY.to_owned(), + }, + registry_inventory: descriptor_for_file(workspace_root, REGISTRY_INVENTORY_RELATIVE)?, + food_profile_vector: descriptor_for_file(workspace_root, FOOD_PROFILE_VECTOR_RELATIVE)?, + entry_points: ENTRY_POINTS + .iter() + .map(|(role, rust_path)| EntryPointDescriptor { + role: (*role).to_owned(), + rust_path: (*rust_path).to_owned(), + }) + .collect(), + source_files, + public_api: PUBLIC_API.iter().map(|name| (*name).to_owned()).collect(), + result_vector: ResultVectorDescriptor { + canonical_path: RESULT_VECTOR_CANONICAL_RELATIVE.to_owned(), + mirror_path: RESULT_VECTOR_MIRROR_RELATIVE.to_owned(), + byte_length: byte_length(RESULT_VECTOR_CANONICAL_RELATIVE, &vector_bytes)?, + sha256: sha256_hex(&vector_bytes), + hash_algorithm: HASH_ALGORITHM.to_owned(), + executor_id: RESULT_VECTOR_EXECUTOR_ID.to_owned(), + executor_path: RESULT_VECTOR_EXECUTOR_RELATIVE.to_owned(), + executor_test: RESULT_VECTOR_EXECUTOR_TEST.to_owned(), + executor_byte_length: executor.byte_length, + executor_sha256: executor.sha256, + executor_hash_algorithm: HASH_ALGORITHM.to_owned(), + }, + }) +} + +fn validate_predecessor_production_source_coverage(workspace_root: &Path) -> Result<(), String> { + for path in PREDECESSOR_SUPERSEDED_SOURCE_PATHS { + if !SOURCE_SPECS.iter().any(|source| source.path == *path) { + return Err(format!( + "successor supersession source `{path}` is not current-byte-bound" + )); + } + } + validate_nip09_predecessor_production_sources_under_lock( + workspace_root, + PREDECESSOR_SUPERSEDED_SOURCE_PATHS, + ) +} + +fn descriptor_for_file(workspace_root: &Path, relative: &str) -> Result<FileDescriptor, String> { + let bytes = read_regular_file(workspace_root, relative)?; + descriptor_for_bytes(relative, &bytes) +} + +fn descriptor_for_bytes(relative: &str, bytes: &[u8]) -> Result<FileDescriptor, String> { + Ok(FileDescriptor { + path: relative.to_owned(), + byte_length: byte_length(relative, bytes)?, + sha256: sha256_hex(bytes), + hash_algorithm: HASH_ALGORITHM.to_owned(), + }) +} + +fn byte_length(relative: &str, bytes: &[u8]) -> Result<u64, String> { + u64::try_from(bytes.len()).map_err(|_| format!("{relative} byte length does not fit u64")) +} + +fn catalog_from_migration_source(bytes: &[u8]) -> Result<CatalogDescriptor, String> { + let source = std::str::from_utf8(bytes) + .map_err(|error| format!("crates/event_store/src/migrations.rs must be UTF-8: {error}"))?; + let syntax = syn::parse_file(source) + .map_err(|error| format!("parse crates/event_store/src/migrations.rs: {error}"))?; + Ok(CatalogDescriptor { + objects: extract_string_array_const(&syntax, "EVENT_STORE_FOOD_AVAILABILITY_OBJECT_NAMES")?, + tables: extract_string_array_const(&syntax, "EVENT_STORE_FOOD_AVAILABILITY_TABLE_NAMES")?, + fts5_tables: extract_string_array_const( + &syntax, + "EVENT_STORE_FOOD_AVAILABILITY_FTS5_TABLE_NAMES", + )?, + }) +} + +fn extract_string_array_const(syntax: &syn::File, name: &str) -> Result<Vec<String>, String> { + let expression = syntax.items.iter().find_map(|item| match item { + Item::Const(item) if item.ident == name => Some(item.expr.as_ref()), + Item::Static(item) if item.ident == name => Some(item.expr.as_ref()), + _ => None, + }); + let expression = expression.ok_or_else(|| { + format!("crates/event_store/src/migrations.rs is missing catalog constant {name}") + })?; + let Expr::Array(array) = strip_expression_wrappers(expression) else { + return Err(format!( + "crates/event_store/src/migrations.rs catalog constant {name} must be a literal string array" + )); + }; + array + .elems + .iter() + .map(|element| match strip_expression_wrappers(element) { + Expr::Lit(literal) => match &literal.lit { + Lit::Str(value) => Ok(value.value()), + _ => Err(format!( + "catalog constant {name} must contain string literals" + )), + }, + _ => Err(format!( + "catalog constant {name} must contain string literals" + )), + }) + .collect() +} + +fn strip_expression_wrappers(mut expression: &Expr) -> &Expr { + loop { + expression = match expression { + Expr::Reference(reference) => &reference.expr, + Expr::Group(group) => &group.expr, + Expr::Paren(paren) => &paren.expr, + _ => return expression, + }; + } +} + +fn validate_catalog(catalog: &CatalogDescriptor) -> Result<(), String> { + let expected_objects = EXPECTED_CATALOG_OBJECTS + .iter() + .map(|name| (*name).to_owned()) + .collect::<Vec<_>>(); + let expected_tables = EXPECTED_CATALOG_TABLES + .iter() + .map(|name| (*name).to_owned()) + .collect::<Vec<_>>(); + let expected_fts5 = EXPECTED_CATALOG_FTS5_TABLES + .iter() + .map(|name| (*name).to_owned()) + .collect::<Vec<_>>(); + if catalog.objects != expected_objects + || catalog.tables != expected_tables + || catalog.fts5_tables != expected_fts5 + { + return Err( + "FoodAvailability migration catalog differs from the successor contract".to_owned(), + ); + } + validate_unique( + "migration catalog object", + catalog.objects.iter().map(String::as_str), + )?; + validate_unique( + "migration catalog table", + catalog.tables.iter().map(String::as_str), + )?; + Ok(()) +} + +fn validate_migration_guard_limits(workspace_root: &Path) -> Result<(), String> { + let addressable_model = read_regular_file( + workspace_root, + "crates/event_store/src/model/addressable_transition_feed_v1.rs", + )?; + let food_model = read_regular_file( + workspace_root, + "crates/event_store/src/model/food_availability_projection_v1.rs", + )?; + let migration = read_regular_file(workspace_root, MIGRATION_UP_RELATIVE)?; + validate_migration_guard_limit_sources( + std::str::from_utf8(&addressable_model).map_err(|error| { + format!( + "crates/event_store/src/model/addressable_transition_feed_v1.rs must be UTF-8: {error}" + ) + })?, + std::str::from_utf8(&food_model).map_err(|error| { + format!( + "crates/event_store/src/model/food_availability_projection_v1.rs must be UTF-8: {error}" + ) + })?, + std::str::from_utf8(&migration) + .map_err(|error| format!("{MIGRATION_UP_RELATIVE} must be UTF-8: {error}"))?, + ) +} + +fn validate_migration_guard_limit_sources( + addressable_model_source: &str, + food_model_source: &str, + migration_source: &str, +) -> Result<(), String> { + let addressable_model = syn::parse_file(addressable_model_source).map_err(|error| { + format!("parse crates/event_store/src/model/addressable_transition_feed_v1.rs: {error}") + })?; + let food_model = syn::parse_file(food_model_source).map_err(|error| { + format!("parse crates/event_store/src/model/food_availability_projection_v1.rs: {error}") + })?; + let scan_max = extract_u32_literal_const( + &addressable_model, + "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1", + )?; + let page_max = extract_u32_literal_const( + &addressable_model, + "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1", + )?; + let apply_limit = find_const_expression( + &food_model, + "RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1", + )?; + if compact_tokens(apply_limit) != "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1" { + return Err( + "FoodAvailability projection apply limit must alias the governed addressable page limit" + .to_owned(), + ); + } + + let compact_sql = migration_source + .chars() + .filter(|character| !character.is_whitespace()) + .collect::<String>(); + let scan_guard = extract_direct_sql_guard( + &compact_sql, + "ORNEW.last_transition_seq-OLD.last_transition_seq>", + 1, + "FoodAvailability cursor scan delta", + )?; + let row_count_guard = extract_direct_sql_guard( + &compact_sql, + "ORabs(NEW.projected_row_count-OLD.projected_row_count)>", + 2, + "FoodAvailability projected-row delta", + )?; + if scan_guard != scan_max { + return Err(format!( + "{MIGRATION_UP_RELATIVE} cursor scan delta {scan_guard} differs from RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1={scan_max}" + )); + } + if row_count_guard != page_max { + return Err(format!( + "{MIGRATION_UP_RELATIVE} projected-row delta {row_count_guard} differs from RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1={page_max}" + )); + } + let visibility_index = "radroots_event_store_nip09_address_target_visibility_lookup_idx"; + let index_definition = format!( + "CREATEINDEX{visibility_index}ONradroots_event_store_nip09_address_target(source_generation,target_kind,target_pubkey,target_d_tag,inclusive_cutoffDESC,request_event_idASC);" + ); + if compact_sql.matches(&index_definition).count() != 1 + || compact_sql + .matches(&format!("INDEXEDBY{visibility_index}")) + .count() + != 3 + { + return Err(format!( + "{MIGRATION_UP_RELATIVE} must define and force the canonical NIP-09 address-target visibility index" + )); + } + if compact_sql + .matches("ORDERBYtarget.request_event_idLIMIT1") + .count() + != 1 + || compact_sql + .matches("ORDERBYtarget.inclusive_cutoffDESC,target.request_event_idLIMIT1") + .count() + != 2 + { + return Err(format!( + "{MIGRATION_UP_RELATIVE} must use the canonical NIP-09 visibility ordering" + )); + } + Ok(()) +} + +fn find_const_expression<'a>(syntax: &'a syn::File, name: &str) -> Result<&'a Expr, String> { + syntax + .items + .iter() + .find_map(|item| match item { + Item::Const(item) if item.ident == name => Some(item.expr.as_ref()), + _ => None, + }) + .ok_or_else(|| format!("governed Rust source is missing constant {name}")) +} + +fn extract_u32_literal_const(syntax: &syn::File, name: &str) -> Result<u32, String> { + let expression = strip_expression_wrappers(find_const_expression(syntax, name)?); + let Expr::Lit(literal) = expression else { + return Err(format!("governed constant {name} must be a u32 literal")); + }; + let Lit::Int(value) = &literal.lit else { + return Err(format!("governed constant {name} must be a u32 literal")); + }; + value + .base10_parse::<u32>() + .map_err(|error| format!("parse governed constant {name}: {error}")) +} + +fn extract_direct_sql_guard( + compact_sql: &str, + marker: &str, + expected_occurrences: usize, + label: &str, +) -> Result<u32, String> { + let suffixes = compact_sql + .match_indices(marker) + .map(|(offset, _)| &compact_sql[offset + marker.len()..]) + .collect::<Vec<_>>(); + if suffixes.len() != expected_occurrences { + return Err(format!( + "{MIGRATION_UP_RELATIVE} must contain exactly {expected_occurrences} {label} guard occurrence(s)" + )); + } + let direct_values = suffixes + .iter() + .filter_map(|suffix| { + let digits = suffix + .chars() + .take_while(char::is_ascii_digit) + .collect::<String>(); + (!digits.is_empty()).then_some(digits) + }) + .collect::<Vec<_>>(); + if direct_values.len() != 1 { + return Err(format!( + "{MIGRATION_UP_RELATIVE} must contain exactly one direct numeric {label} guard" + )); + } + direct_values[0] + .parse::<u32>() + .map_err(|error| format!("parse {label} guard: {error}")) +} + +fn validate_fast_active_hook_source(source: &str) -> Result<(), String> { + let syntax = syn::parse_file(source).map_err(|error| { + format!("parse crates/event_store/src/nip09/reconciliation_v1.rs: {error}") + })?; + let function = syntax + .items + .iter() + .find_map(|item| match item { + Item::Fn(function) if function.sig.ident == "validate_active_hook_state_fast" => { + Some(function) + } + _ => None, + }) + .ok_or_else(|| { + "predecessor source is missing validate_active_hook_state_fast".to_owned() + })?; + let body = compact_tokens(&function.block); + let expected = "{validate_rebuild_marker_absent(connection).await?;validate_structural_source_state_fast(connection).await.map(|_|())}"; + if body != expected { + return Err( + "validate_active_hook_state_fast must remain the constant-cost rebuild-marker and structural-source check" + .to_owned(), + ); + } + Ok(()) +} + +const FOOD_READ_AUTHORITY_JOINS: &str = "JOIN radroots_event_store_source_state AS source ON source.singleton = 1 AND source.active_generation = projection.source_generation JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.singleton = 1 AND cursor.source_generation = projection.source_generation JOIN radroots_event_store_addressable_head_state AS head ON head.source_generation = projection.source_generation AND head.kind = 30402 AND head.pubkey = projection.pubkey AND head.d_tag = projection.d_tag AND head.raw_head_event_id = projection.event_id AND head.raw_head_event_seq = projection.event_seq AND head.raw_head_created_at = projection.created_at AND head.admission_status = 'admitted' AND head.admission_code IS NULL AND head.contract_id = projection.contract_id AND head.visibility = 'visible' AND head.nip09_outcome = 'visible'"; +const FOOD_RECENT_SOURCE_FIRST_AUTHORITY_JOINS: &str = "FROM radroots_event_store_source_state AS source CROSS JOIN radroots_event_store_food_availability_read_v1 AS projection ON source.singleton = 1 AND source.active_generation = projection.source_generation CROSS JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.singleton = 1 AND cursor.source_generation = projection.source_generation CROSS JOIN radroots_event_store_addressable_head_state AS head ON head.source_generation = projection.source_generation AND head.kind = 30402 AND head.pubkey = projection.pubkey AND head.d_tag = projection.d_tag AND head.raw_head_event_id = projection.event_id AND head.raw_head_event_seq = projection.event_seq AND head.raw_head_created_at = projection.created_at AND head.admission_status = 'admitted' AND head.admission_code IS NULL AND head.contract_id = projection.contract_id AND head.visibility = 'visible' AND head.nip09_outcome = 'visible'"; + +#[derive(Default)] +struct SqlxQueryRouteCollector { + constants: Vec<String>, + malformed: bool, +} + +impl<'ast> Visit<'ast> for SqlxQueryRouteCollector { + fn visit_expr_call(&mut self, expression: &'ast syn::ExprCall) { + if compact_tokens(expression.func.as_ref()) == "sqlx::query" { + match expression.args.iter().collect::<Vec<_>>().as_slice() { + [Expr::Path(path)] + if path.qself.is_none() + && path.path.leading_colon.is_none() + && path.path.segments.len() == 1 => + { + self.constants.push(path.path.segments[0].ident.to_string()); + } + _ => self.malformed = true, + } + } + syn::visit::visit_expr_call(self, expression); + } +} + +fn validate_food_read_query_sources(source: &str) -> Result<(), String> { + let syntax = syn::parse_file(source).map_err(|error| { + format!("parse crates/event_store/src/store/food_availability_projection_v1.rs: {error}") + })?; + let expected_constants = [ + "FOOD_AVAILABILITY_POINT_QUERY_V1", + "FOOD_AVAILABILITY_RECENT_QUERY_V1", + "FOOD_AVAILABILITY_RECENT_STATUS_QUERY_V1", + "FOOD_AVAILABILITY_SEARCH_QUERY_V1", + ]; + let mut query_values = BTreeMap::new(); + let mut query_constant_names = BTreeSet::new(); + for item in &syntax.items { + let Item::Const(item_const) = item else { + continue; + }; + let name = item_const.ident.to_string(); + if !name.starts_with("FOOD_AVAILABILITY_") || !name.ends_with("_QUERY_V1") { + continue; + } + query_constant_names.insert(name.clone()); + if compact_tokens(&item_const.vis) != "pub(super)" + || compact_tokens(item_const.ty.as_ref()) != "&str" + { + return Err(format!( + "governed Food query constant {name} must be pub(super) const &str" + )); + } + let Expr::Lit(literal) = strip_expression_wrappers(item_const.expr.as_ref()) else { + return Err(format!( + "governed Food query constant {name} must contain one string literal" + )); + }; + let Lit::Str(value) = &literal.lit else { + return Err(format!( + "governed Food query constant {name} must contain one string literal" + )); + }; + query_values.insert(name, value.value()); + } + let expected_constant_names = expected_constants + .iter() + .map(|name| (*name).to_owned()) + .collect::<BTreeSet<_>>(); + if query_constant_names != expected_constant_names + || query_values.len() != expected_constants.len() + { + return Err("governed Food query constant inventory is not exact".to_owned()); + } + + let expected_methods: [(&str, &[&str]); 3] = [ + ( + "food_availability_v1", + &["FOOD_AVAILABILITY_POINT_QUERY_V1"], + ), + ( + "recent_food_availability_v1", + &[ + "FOOD_AVAILABILITY_RECENT_QUERY_V1", + "FOOD_AVAILABILITY_RECENT_STATUS_QUERY_V1", + ], + ), + ( + "search_food_availability_v1", + &["FOOD_AVAILABILITY_SEARCH_QUERY_V1"], + ), + ]; + let mut observed_methods = BTreeSet::new(); + for item in &syntax.items { + let Item::Impl(item_impl) = item else { + continue; + }; + if compact_tokens(item_impl.self_ty.as_ref()) != "RadrootsEventStore" { + continue; + } + for item in &item_impl.items { + let syn::ImplItem::Fn(function) = item else { + continue; + }; + let method = function.sig.ident.to_string(); + let Some((_, expected_routes)) = expected_methods + .iter() + .find(|(expected, _)| *expected == method.as_str()) + else { + continue; + }; + if !observed_methods.insert(method.clone()) { + return Err(format!("duplicate governed Food read method {method}")); + } + let mut collector = SqlxQueryRouteCollector::default(); + collector.visit_block(&function.block); + if collector.malformed + || collector.constants + != expected_routes + .iter() + .map(|name| (*name).to_owned()) + .collect::<Vec<_>>() + { + return Err(format!( + "Food read method {method} does not route through its exact governed query constant(s)" + )); + } + } + } + if observed_methods.len() != expected_methods.len() { + return Err("governed Food read query inventory is incomplete".to_owned()); + } + for constant in expected_constants { + let query = query_values + .get(constant) + .expect("exact governed query constant inventory checked above"); + let normalized = query.split_whitespace().collect::<Vec<_>>().join(" "); + let authority_joins = if constant == "FOOD_AVAILABILITY_RECENT_QUERY_V1" { + FOOD_RECENT_SOURCE_FIRST_AUTHORITY_JOINS + } else { + FOOD_READ_AUTHORITY_JOINS + }; + if normalized.contains("radroots_event_store_current_visibility_v1") + || normalized.matches(authority_joins).count() != 1 + { + return Err( + "every Food point/recent/search query must use the exact fail-closed persisted source/cursor/head authority joins" + .to_owned(), + ); + } + let start = normalized + .find(authority_joins) + .expect("exact authority joins occurrence checked above"); + let suffix = &normalized[start..]; + let end = [" WHERE ", " ORDER BY "] + .iter() + .filter_map(|marker| suffix.find(marker)) + .min() + .ok_or_else(|| { + "governed Food read query has no clause after its authority joins".to_owned() + })?; + if &suffix[..end] != authority_joins { + return Err( + "Food read source/cursor/head authority joins contain an ungoverned predicate or disjunction" + .to_owned(), + ); + } + } + Ok(()) +} + +fn validate_food_projection_audit_authority(source: &str) -> Result<(), String> { + const EXPECTED_HEAD_QUERY: &str = "SELECT pubkey, d_tag, raw_head_event_id, raw_head_event_seq, raw_head_created_at FROM radroots_event_store_addressable_head_state WHERE source_generation = ? AND kind = 30402 AND admission_status = 'admitted' AND admission_code IS NULL AND contract_id = ? AND visibility = 'visible' AND nip09_outcome = 'visible' ORDER BY pubkey, d_tag"; + const SOURCE_TRANSITION_QUERY: &str = "SELECT EXISTS(SELECT 1 FROM radroots_event_store_addressable_head_transition AS transition WHERE transition.transition_seq = ? AND transition.source_generation = ? AND transition.source_generation = (SELECT active_generation FROM radroots_event_store_source_state WHERE singleton = 1) AND transition.kind = 30402 AND transition.pubkey = ? AND transition.d_tag = ? AND transition.raw_head_event_id = ? AND transition.raw_head_event_seq = ? AND transition.raw_head_created_at = ? AND transition.visible_event_id = ? AND transition.visible_event_seq = ? AND transition.admission_status = 'admitted' AND transition.admission_code IS NULL AND transition.contract_id = ? AND transition.visibility = 'visible' AND transition.nip09_outcome = 'visible' AND transition.raw_head_decision IN ('baseline_rebuild', 'applied') AND transition.transition_seq = (SELECT MAX(candidate.transition_seq) FROM radroots_event_store_addressable_head_transition AS candidate WHERE candidate.source_generation = transition.source_generation AND candidate.kind = transition.kind AND candidate.pubkey = transition.pubkey AND candidate.d_tag = transition.d_tag AND candidate.raw_head_decision IN ('baseline_rebuild', 'applied')))"; + + let syntax = syn::parse_file(source).map_err(|error| { + format!("parse crates/event_store/src/store/food_availability_projection_v1.rs: {error}") + })?; + let audit_methods = syntax + .items + .iter() + .filter_map(|item| match item { + Item::Impl(item_impl) + if compact_tokens(item_impl.self_ty.as_ref()) == "RadrootsEventStore" => + { + Some(item_impl) + } + _ => None, + }) + .flat_map(|item_impl| item_impl.items.iter()) + .filter_map(|item| match item { + syn::ImplItem::Fn(function) + if function.sig.ident == "audit_food_availability_projection_v1" => + { + Some(function) + } + _ => None, + }) + .collect::<Vec<_>>(); + let [audit] = audit_methods.as_slice() else { + return Err(format!( + "Food projection store must define exactly one public audit method; found {}", + audit_methods.len() + )); + }; + let audit_visibility = compact_tokens(&audit.vis); + let audit_signature = compact_tokens(&audit.sig); + if audit_visibility != "pub" + || audit_signature + != "asyncfnaudit_food_availability_projection_v1(&self,)->Result<(),RadrootsEventStoreError>" + { + return Err(format!( + "Food projection audit method signature drifted: visibility `{audit_visibility}`, signature `{audit_signature}`" + )); + } + let expected_audit: syn::Block = syn::parse_str( + "{ let mut tx = self.begin_write_transaction().await?; validate_food_availability_projection_hook_v1(&mut tx).await?; tx.commit().await?; Ok(()) }", + ) + .map_err(|error| format!("parse governed Food audit body: {error}"))?; + if compact_tokens(&audit.block) != compact_tokens(&expected_audit) { + return Err( + "Food projection audit must use the exact serialized write transaction, exhaustive validator, commit route" + .to_owned(), + ); + } + + let exact_free_function = |name: &str| -> Result<&syn::ItemFn, String> { + let functions = syntax + .items + .iter() + .filter_map(|item| match item { + Item::Fn(function) if function.sig.ident == name => Some(function), + _ => None, + }) + .collect::<Vec<_>>(); + let [function] = functions.as_slice() else { + return Err(format!( + "Food projection store must define exactly one `{name}` function; found {}", + functions.len() + )); + }; + Ok(function) + }; + + let exhaustive = exact_free_function("validate_food_availability_projection_hook_v1")?; + let exhaustive_visibility = compact_tokens(&exhaustive.vis); + let exhaustive_signature = compact_tokens(&exhaustive.sig); + if exhaustive_visibility != "pub(crate)" + || exhaustive_signature + != "asyncfnvalidate_food_availability_projection_hook_v1(connection:&mutSqliteConnection,)->Result<(),RadrootsEventStoreError>" + { + return Err(format!( + "Food exhaustive audit signature drifted: visibility `{exhaustive_visibility}`, signature `{exhaustive_signature}`" + )); + } + let expected_exhaustive: syn::Block = syn::parse_str( + r#"{ + let state = food_availability_projection_cursor_state_fast_v1(connection).await?; + let generation = state.feed_cursor.source_generation(); + + let rows = sqlx::query( + "SELECT source_generation, pubkey, d_tag, event_id, event_seq, created_at, contract_id, content, title, summary, published_at, location, price_amount, price_currency, price_unit, quantity_amount, quantity_unit, status, diagnostic_codes_json, source_transition_seq, immutable_raw_json, stored_images_json FROM radroots_event_store_food_availability_read_v1 WHERE source_generation = ? ORDER BY pubkey, d_tag", + ) + .bind(generation.as_bytes().as_slice()) + .fetch_all(&mut *connection) + .await?; + let mut actual_coordinates = Vec::with_capacity(rows.len()); + for row in rows { + let projection = load_and_validate_projection_row(row)?; + validate_projection_source_transition(connection, &projection).await?; + validate_fts_row(connection, &projection).await?; + actual_coordinates.push(( + projection.pubkey().as_str().to_owned(), + projection.identifier().as_str().to_owned(), + projection.event_id().as_str().to_owned(), + projection.event_seq(), + i64_from_u64("food.created_at", projection.created_at())?, + )); + } + let actual_row_count = i64::try_from(actual_coordinates.len()) + .map_err(|_| projection_drift("projection row count exceeds i64"))?; + if actual_row_count != state.projected_row_count { + return Err(projection_drift(format!( + "projection row count {} differs from sealed count {}", + actual_row_count, state.projected_row_count, + ))); + } + let expected_coordinates = sqlx::query( + "SELECT pubkey, d_tag, raw_head_event_id, raw_head_event_seq, raw_head_created_at FROM radroots_event_store_addressable_head_state WHERE source_generation = ? AND kind = 30402 AND admission_status = 'admitted' AND admission_code IS NULL AND contract_id = ? AND visibility = 'visible' AND nip09_outcome = 'visible' ORDER BY pubkey, d_tag", + ) + .bind(generation.as_bytes().as_slice()) + .bind(FOOD_AVAILABILITY_CONTRACT_ID) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(|row| { + Ok::<_, sqlx::Error>(( + row.try_get::<String, _>("pubkey")?, + row.try_get::<String, _>("d_tag")?, + row.try_get::<String, _>("raw_head_event_id")?, + row.try_get::<i64, _>("raw_head_event_seq")?, + row.try_get::<i64, _>("raw_head_created_at")?, + )) + }) + .collect::<Result<Vec<_>, _>>()?; + if actual_coordinates != expected_coordinates { + return Err(projection_drift( + "projection coordinate witnesses do not equal the current admitted, visible FoodAvailability heads", + )); + } + let fts_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_food_availability_search_fts", + ) + .fetch_one(&mut *connection) + .await?; + if fts_count != state.projected_row_count { + return Err(projection_drift(format!( + "FoodAvailability FTS row count {fts_count} differs from sealed count {}", + state.projected_row_count, + ))); + } + #[cfg(test)] + wait_at_food_availability_audit_fts_checkpoint().await; + sqlx::query( + "INSERT INTO radroots_event_store_food_availability_search_fts(radroots_event_store_food_availability_search_fts) VALUES('integrity-check')", + ) + .execute(&mut *connection) + .await + .map_err(|error| projection_drift(format!("FoodAvailability FTS integrity check failed: {error}")))?; + Ok(()) + }"#, + ) + .map_err(|error| format!("parse governed exhaustive Food audit body: {error}"))?; + if compact_tokens(&exhaustive.block) != compact_tokens(&expected_exhaustive) { + return Err( + "Food exhaustive audit complete function body drifted from the exact fail-closed authority seal" + .to_owned(), + ); + } + + let row_loops = exhaustive + .block + .stmts + .iter() + .filter_map(|statement| match statement { + syn::Stmt::Expr(Expr::ForLoop(expression), _) + if compact_tokens(&expression.pat) == "row" + && compact_tokens(&expression.expr) == "rows" => + { + Some(expression) + } + _ => None, + }) + .collect::<Vec<_>>(); + let [row_loop] = row_loops.as_slice() else { + return Err(format!( + "Food exhaustive audit must contain exactly one direct projection-row loop; found {}", + row_loops.len() + )); + }; + let expected_row_loop: syn::Block = syn::parse_str( + r#"{ + let projection = load_and_validate_projection_row(row)?; + validate_projection_source_transition(connection, &projection).await?; + validate_fts_row(connection, &projection).await?; + actual_coordinates.push(( + projection.pubkey().as_str().to_owned(), + projection.identifier().as_str().to_owned(), + projection.event_id().as_str().to_owned(), + projection.event_seq(), + i64_from_u64("food.created_at", projection.created_at())?, + )); + }"#, + ) + .map_err(|error| format!("parse governed Food projection-row audit: {error}"))?; + if compact_tokens(&row_loop.body) != compact_tokens(&expected_row_loop) { + return Err( + "Food exhaustive audit must validate each loaded projection's exact source transition before its FTS row and collect the five coordinate witnesses" + .to_owned(), + ); + } + + for (label, expected) in [ + ( + "checked actual coordinate cardinality", + r#"let actual_row_count = i64::try_from(actual_coordinates.len()) + .map_err(|_| projection_drift("projection row count exceeds i64"))?;"#, + ), + ( + "sealed row-count equality", + r#"if actual_row_count != state.projected_row_count { + return Err(projection_drift(format!( + "projection row count {} differs from sealed count {}", + actual_row_count, state.projected_row_count, + ))); + }"#, + ), + ( + "fail-closed coordinate equality", + r#"if actual_coordinates != expected_coordinates { + return Err(projection_drift( + "projection coordinate witnesses do not equal the current admitted, visible FoodAvailability heads", + )); + }"#, + ), + ] { + let expected: syn::Stmt = syn::parse_str(expected) + .map_err(|error| format!("parse governed Food {label} statement: {error}"))?; + let occurrences = exhaustive + .block + .stmts + .iter() + .filter(|statement| compact_tokens(*statement) == compact_tokens(&expected)) + .count(); + if occurrences != 1 { + return Err(format!( + "Food exhaustive audit must contain exactly one {label} statement; found {occurrences}" + )); + } + } + + let expected_coordinates = exhaustive + .block + .stmts + .iter() + .filter_map(|statement| match statement { + syn::Stmt::Local(local) + if matches!(&local.pat, syn::Pat::Ident(ident) if ident.ident == "expected_coordinates") => + { + local.init.as_ref().map(|init| init.expr.as_ref()) + } + _ => None, + }) + .collect::<Vec<_>>(); + let [expected_coordinates] = expected_coordinates.as_slice() else { + return Err(format!( + "Food exhaustive audit must define exactly one expected-coordinate query; found {}", + expected_coordinates.len() + )); + }; + let expected_coordinates_expression: Expr = syn::parse_str(&format!( + r#"sqlx::query({EXPECTED_HEAD_QUERY:?},) + .bind(generation.as_bytes().as_slice()) + .bind(FOOD_AVAILABILITY_CONTRACT_ID) + .fetch_all(&mut *connection) + .await? + .into_iter() + .map(|row| {{ + Ok::<_, sqlx::Error>(( + row.try_get::<String, _>("pubkey")?, + row.try_get::<String, _>("d_tag")?, + row.try_get::<String, _>("raw_head_event_id")?, + row.try_get::<i64, _>("raw_head_event_seq")?, + row.try_get::<i64, _>("raw_head_created_at")?, + )) + }}) + .collect::<Result<Vec<_>, _>>()?"#, + )) + .map_err(|error| format!("parse governed Food expected-coordinate query: {error}"))?; + let actual_expected_coordinates = compact_tokens(*expected_coordinates); + let governed_expected_coordinates = compact_tokens(&expected_coordinates_expression); + if actual_expected_coordinates != governed_expected_coordinates { + return Err(format!( + "Food exhaustive audit expected-head query must bind the active generation and Food contract and map the exact five admitted visible head witnesses: expected `{governed_expected_coordinates}`, found `{actual_expected_coordinates}`" + )); + } + + let source_transition = exact_free_function("validate_projection_source_transition")?; + let expected_source_transition: syn::Block = syn::parse_str(&format!( + r#"{{ + let authoritative: i64 = sqlx::query_scalar({SOURCE_TRANSITION_QUERY:?},) + .bind(projection.source_transition_seq()) + .bind(projection.source_generation().as_bytes().as_slice()) + .bind(projection.pubkey().as_str()) + .bind(projection.identifier().as_str()) + .bind(projection.event_id().as_str()) + .bind(projection.event_seq()) + .bind(i64_from_u64("food.created_at", projection.created_at())?) + .bind(projection.event_id().as_str()) + .bind(projection.event_seq()) + .bind(FOOD_AVAILABILITY_CONTRACT_ID) + .fetch_one(&mut *connection) + .await?; + if authoritative != 1 {{ + return Err(projection_drift( + "stored FoodAvailability source transition is not authoritative for its projection", + )); + }} + Ok(()) + }}"#, + )) + .map_err(|error| format!("parse governed Food source-transition authority: {error}"))?; + if compact_tokens(&source_transition.block) != compact_tokens(&expected_source_transition) { + return Err( + "Food source-transition audit must bind the exact active-generation, coordinate, head, visibility, admission, contract, and latest applied transition authority" + .to_owned(), + ); + } + Ok(()) +} + +const SOURCE_CAPACITY_HOOK_MATCH_TOKENS: &str = "migration.hook,EventStoreMigrationHook::Nip09ReconciliationV1|EventStoreMigrationHook::FoodAvailabilityProjectionV1"; + +#[derive(Default)] +struct SourceCapacityAuthorityCollector { + pending_hook_calls: usize, + temp_schema_calls: usize, + capacity_calls: usize, + apply_migration_up_calls: usize, + hook_matches: Vec<String>, +} + +impl<'ast> Visit<'ast> for SourceCapacityAuthorityCollector { + fn visit_expr_call(&mut self, expression: &'ast syn::ExprCall) { + match compact_tokens(expression.func.as_ref()).as_str() { + "has_pending_source_capacity_hook" => self.pending_hook_calls += 1, + "validate_event_store_temp_schema_with_registry" => self.temp_schema_calls += 1, + "validate_reconciliation_capacity" => self.capacity_calls += 1, + "apply_migration_up" => self.apply_migration_up_calls += 1, + _ => {} + } + syn::visit::visit_expr_call(self, expression); + } + + fn visit_expr_macro(&mut self, expression: &'ast syn::ExprMacro) { + if compact_tokens(&expression.mac.path) == "matches" { + let tokens = compact_tokens(&expression.mac.tokens); + if tokens.contains("EventStoreMigrationHook::Nip09ReconciliationV1") + || tokens.contains("EventStoreMigrationHook::FoodAvailabilityProjectionV1") + { + self.hook_matches.push(tokens); + } + } + syn::visit::visit_expr_macro(self, expression); + } +} + +fn exact_source_capacity_function<'a>( + syntax: &'a syn::File, + name: &str, +) -> Result<&'a syn::ItemFn, String> { + let functions = syntax + .items + .iter() + .filter_map(|item| match item { + Item::Fn(function) if function.sig.ident == name => Some(function), + _ => None, + }) + .collect::<Vec<_>>(); + let [function] = functions.as_slice() else { + return Err(format!( + "Food schema source-capacity authority must define exactly one `{name}` function; found {}", + functions.len() + )); + }; + Ok(function) +} + +fn governed_statement_tokens(label: &str, source: &str) -> Result<String, String> { + let block: syn::Block = syn::parse_str(&format!("{{ {source} }}")) + .map_err(|error| format!("parse governed Food {label} statement: {error}"))?; + let [statement] = block.stmts.as_slice() else { + return Err(format!( + "governed Food {label} source must parse as exactly one statement" + )); + }; + Ok(compact_tokens(statement)) +} + +fn exact_top_level_statement_index( + block: &syn::Block, + label: &str, + expected_tokens: &str, +) -> Result<usize, String> { + let indices = block + .stmts + .iter() + .enumerate() + .filter_map(|(index, statement)| { + (compact_tokens(statement) == expected_tokens).then_some(index) + }) + .collect::<Vec<_>>(); + let [index] = indices.as_slice() else { + return Err(format!( + "Food schema source-capacity authority must contain exactly one top-level {label} statement; found {}", + indices.len() + )); + }; + Ok(*index) +} + +fn validate_source_capacity_authority(source: &str) -> Result<(), String> { + let syntax = syn::parse_file(source) + .map_err(|error| format!("parse crates/event_store/src/schema.rs: {error}"))?; + + let pending = exact_source_capacity_function(&syntax, "has_pending_source_capacity_hook")?; + let expected_pending: syn::Block = syn::parse_str( + r#"{ + let current_version = match status { + RadrootsEventStoreSchemaStatus::Uninitialized => return false, + RadrootsEventStoreSchemaStatus::UnledgeredBaseline => registry[0].version, + RadrootsEventStoreSchemaStatus::Managed { version } => *version, + }; + registry.iter().any(|migration| { + migration.version > current_version + && matches!( + migration.hook, + EventStoreMigrationHook::Nip09ReconciliationV1 + | EventStoreMigrationHook::FoodAvailabilityProjectionV1 + ) + }) + }"#, + ) + .map_err(|error| format!("parse governed Food pending-capacity selector: {error}"))?; + if compact_tokens(&pending.block) != compact_tokens(&expected_pending) { + return Err( + "Food schema source-capacity pending-hook selector must cover exactly the NIP-09 and Food projection rebuild hooks" + .to_owned(), + ); + } + + let outer = exact_source_capacity_function( + &syntax, + "migrate_event_store_schema_with_registry_and_generation_provider", + )?; + let expected_outer_preflight = governed_statement_tokens( + "outer preflight", + r#" + if has_pending_source_capacity_hook(&status, registry) { + let mut connection = pool.acquire().await?; + validate_event_store_temp_schema_with_registry(&mut connection, registry).await?; + validate_reconciliation_capacity(&mut connection, reconciliation_limits).await?; + } + "#, + )?; + let expected_begin_immediate = governed_statement_tokens( + "BEGIN IMMEDIATE", + r#"let mut transaction = pool.begin_with("BEGIN IMMEDIATE").await?;"#, + )?; + let preflight_index = exact_top_level_statement_index( + &outer.block, + "outer preflight", + &expected_outer_preflight, + )?; + let begin_immediate_index = exact_top_level_statement_index( + &outer.block, + "BEGIN IMMEDIATE", + &expected_begin_immediate, + )?; + if preflight_index >= begin_immediate_index { + return Err( + "Food schema source-capacity outer preflight must complete before BEGIN IMMEDIATE" + .to_owned(), + ); + } + let mut outer_authority = SourceCapacityAuthorityCollector::default(); + outer_authority.visit_block(&outer.block); + if outer_authority.pending_hook_calls != 1 + || outer_authority.temp_schema_calls != 1 + || outer_authority.capacity_calls != 1 + || outer_authority.apply_migration_up_calls != 0 + || !outer_authority.hook_matches.is_empty() + { + return Err(format!( + "Food schema source-capacity outer preflight call inventory is not exact: pending={}, temp_schema={}, capacity={}, apply_up={}, hook_matches={}", + outer_authority.pending_hook_calls, + outer_authority.temp_schema_calls, + outer_authority.capacity_calls, + outer_authority.apply_migration_up_calls, + outer_authority.hook_matches.len() + )); + } + + let inner = exact_source_capacity_function(&syntax, "migrate_schema_on_connection")?; + let mut migration_loops = Vec::new(); + for statement in &inner.block.stmts { + let syn::Stmt::Expr(Expr::ForLoop(expression), _) = statement else { + continue; + }; + let mut collector = SourceCapacityAuthorityCollector::default(); + collector.visit_block(&expression.body); + if collector.apply_migration_up_calls > 0 { + migration_loops.push((expression, collector)); + } + } + let [(migration_loop, inner_authority)] = migration_loops.as_slice() else { + return Err(format!( + "Food schema source-capacity authority must define exactly one migration-application loop; found {}", + migration_loops.len() + )); + }; + if inner_authority.pending_hook_calls != 0 + || inner_authority.temp_schema_calls != 0 + || inner_authority.capacity_calls != 1 + || inner_authority.apply_migration_up_calls != 1 + || inner_authority.hook_matches.len() != 1 + || inner_authority.hook_matches[0] != SOURCE_CAPACITY_HOOK_MATCH_TOKENS + { + return Err(format!( + "Food schema source-capacity in-transaction recheck inventory is not exact: pending={}, temp_schema={}, capacity={}, apply_up={}, hook_matches={:?}", + inner_authority.pending_hook_calls, + inner_authority.temp_schema_calls, + inner_authority.capacity_calls, + inner_authority.apply_migration_up_calls, + inner_authority.hook_matches + )); + } + let expected_inner_recheck = governed_statement_tokens( + "in-transaction recheck", + r#" + if matches!( + migration.hook, + EventStoreMigrationHook::Nip09ReconciliationV1 + | EventStoreMigrationHook::FoodAvailabilityProjectionV1 + ) { + validate_reconciliation_capacity(connection, reconciliation_limits).await?; + } + "#, + )?; + let expected_apply = governed_statement_tokens( + "migration DDL application", + "apply_migration_up(connection, registry, migration).await?;", + )?; + let recheck_index = exact_top_level_statement_index( + &migration_loop.body, + "in-transaction recheck", + &expected_inner_recheck, + )?; + let apply_index = exact_top_level_statement_index( + &migration_loop.body, + "migration DDL application", + &expected_apply, + )?; + if recheck_index.checked_add(1) != Some(apply_index) { + return Err( + "Food schema source-capacity in-transaction recheck must occur immediately before migration DDL" + .to_owned(), + ); + } + + Ok(()) +} + +fn validate_source_contract(workspace_root: &Path) -> Result<(), String> { + validate_blossom_dependency_authority(workspace_root)?; + validate_public_api_authority(workspace_root)?; + validate_migration_guard_limits(workspace_root)?; + require_source_markers( + workspace_root, + "crates/blossom/src/lib.rs", + &["pubusehash::{", "RadrootsBlossomSha256"], + )?; + require_source_markers( + workspace_root, + "crates/blossom/src/hash.rs", + &[ + "pubstructRadrootsBlossomSha256([u8;SHA256_BYTES])", + "pubfnfrom_hex(value:&str)->Result<Self,RadrootsBlossomError>", + "pubconstfnas_bytes(&self)->&[u8;SHA256_BYTES]", + "pubfnto_hex(self)->String", + ], + )?; + require_source_markers( + workspace_root, + "crates/event_codec/src/admission/registry_v7.rs", + &[ + "pubfnadmit_verified_event_registry_v7", + "project_verified_food_availability_event_registry_v7", + "RADROOTS_FOOD_AVAILABILITY_CONTRACT_ID", + ], + )?; + require_source_markers( + workspace_root, + MIGRATIONS_SOURCE_RELATIVE, + &[ + "EventStoreMigrationHook::FoodAvailabilityProjectionV1", + "version:3", + "name:\"food_availability_projection\"", + "include_str!(\"../migrations/0003_food_availability_projection.up.sql\")", + "include_str!(\"../migrations/0003_food_availability_projection.down.sql\")", + "validate_generated_food_availability_projection_manifest_descriptor()", + ], + )?; + let schema_hooks = read_regular_file(workspace_root, "crates/event_store/src/schema.rs")?; + let schema_hooks_source = std::str::from_utf8(&schema_hooks) + .map_err(|error| format!("crates/event_store/src/schema.rs must be UTF-8: {error}"))?; + validate_source_capacity_authority(schema_hooks_source)?; + require_source_markers( + workspace_root, + "crates/event_store/src/schema.rs", + &[ + "apply_food_availability_projection_hook_v1", + "validate_food_availability_projection_hook_state_fast_v1", + "has_pending_source_capacity_hook", + "EventStoreMigrationHook::Nip09ReconciliationV1|EventStoreMigrationHook::FoodAvailabilityProjectionV1", + "EventStoreMigrationHook::FoodAvailabilityProjectionV1", + ], + )?; + require_source_markers( + workspace_root, + "crates/event_store/src/model/addressable_transition_feed_v1.rs", + &[ + "RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1:u32=1", + "pubfnfood_availability()->Self", + "Self::new([30_402])", + "SCOPE_FINGERPRINT_DOMAIN_V1", + ], + )?; + require_source_markers( + workspace_root, + "crates/event_store/src/model/food_availability_projection_v1.rs", + &[ + "RadrootsBlossomSha256", + "pubconstfnblossom_sha256(&self)->Option<RadrootsBlossomSha256>", + ], + )?; + require_source_markers( + workspace_root, + "crates/event_store/src/store/post_core_extension_dispatcher.rs", + &["capabilities.apply_v1", "capabilities.apply_v2"], + )?; + let dispatcher = compact_source( + workspace_root, + "crates/event_store/src/store/post_core_extension_dispatcher.rs", + )?; + let v1 = dispatcher + .find("capabilities.apply_v1") + .ok_or_else(|| "post-core dispatcher is missing apply_v1".to_owned())?; + let v2 = dispatcher + .find("capabilities.apply_v2") + .ok_or_else(|| "post-core dispatcher is missing apply_v2".to_owned())?; + if v1 >= v2 { + return Err("post-core dispatcher must apply v1 before additive v2".to_owned()); + } + require_source_markers( + workspace_root, + "crates/event_store/src/store/post_core_extension_capabilities.rs", + &["pub(super)asyncfnapply_v2", "apply_post_core_extensions_v2"], + )?; + require_source_markers( + workspace_root, + "crates/event_store/src/store/post_core_extensions_v2.rs", + &["apply_pending_food_availability_transitions"], + )?; + let food_projection_store = read_regular_file( + workspace_root, + "crates/event_store/src/store/food_availability_projection_v1.rs", + )?; + let food_projection_store_source = std::str::from_utf8(&food_projection_store).map_err( + |error| { + format!( + "crates/event_store/src/store/food_availability_projection_v1.rs must be UTF-8: {error}" + ) + }, + )?; + validate_food_read_query_sources(food_projection_store_source)?; + validate_food_projection_audit_authority(food_projection_store_source)?; + require_source_markers( + workspace_root, + "crates/event_store/src/store/food_availability_projection_v1.rs", + &[ + "pubasyncfnfood_availability_v1", + "pubasyncfnrecent_food_availability_v1", + "pubasyncfnsearch_food_availability_v1", + "pubasyncfnaudit_food_availability_projection_v1", + "apply_food_availability_projection_hook_v1", + "validate_food_availability_projection_hook_v1", + ], + )?; + require_source_markers( + workspace_root, + "crates/event_store/src/store/current_visibility_v1.rs", + &[ + "FROMradroots_event_store_addressable_head_stateASstate", + "state.raw_head_event_id", + "state.nip09_outcome", + ], + )?; + let predecessor_fast_source = read_regular_file( + workspace_root, + "crates/event_store/src/nip09/reconciliation_v1.rs", + )?; + validate_fast_active_hook_source(std::str::from_utf8(&predecessor_fast_source).map_err( + |error| format!("crates/event_store/src/nip09/reconciliation_v1.rs must be UTF-8: {error}"), + )?)?; + require_source_markers( + workspace_root, + "crates/event_store/src/store.rs", + &[ + "dispatch_post_core_extensions", + "PRAGMAmain.journal_mode=WAL", + "SqliteFileJournalModeNotWal", + ], + )?; + Ok(()) +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PublicUseRoute { + segments: Vec<String>, + exported_name: String, + renamed: bool, + glob: bool, + absolute: bool, + attributes: Vec<String>, +} + +fn validate_public_api_authority(workspace_root: &Path) -> Result<(), String> { + let model_bytes = read_regular_file(workspace_root, EVENT_STORE_MODEL_RELATIVE)?; + let model_source = std::str::from_utf8(&model_bytes) + .map_err(|error| format!("{EVENT_STORE_MODEL_RELATIVE} must be UTF-8 Rust: {error}"))?; + let lib_bytes = read_regular_file(workspace_root, EVENT_STORE_LIB_RELATIVE)?; + let lib_source = std::str::from_utf8(&lib_bytes) + .map_err(|error| format!("{EVENT_STORE_LIB_RELATIVE} must be UTF-8 Rust: {error}"))?; + let advertised = PUBLIC_API + .iter() + .map(|name| (*name).to_owned()) + .collect::<Vec<_>>(); + validate_public_api_sources(model_source, lib_source, &advertised) +} + +fn validate_public_api_sources( + model_source: &str, + lib_source: &str, + advertised: &[String], +) -> Result<(), String> { + let model = syn::parse_file(model_source) + .map_err(|error| format!("parse {EVENT_STORE_MODEL_RELATIVE}: {error}"))?; + let lib = syn::parse_file(lib_source) + .map_err(|error| format!("parse {EVENT_STORE_LIB_RELATIVE}: {error}"))?; + + let governed_modules = GOVERNED_PUBLIC_API_MODULES + .iter() + .map(|module| (*module).to_owned()) + .collect::<BTreeSet<_>>(); + let mut represented_modules = BTreeSet::new(); + let mut governed_exports = BTreeSet::new(); + for route in collect_top_level_public_use_routes(&model) { + let Some(module) = route.segments.first() else { + continue; + }; + if !governed_modules.contains(module) { + continue; + } + if route.absolute || route.renamed || route.glob || route.segments.len() != 2 { + return Err(format!( + "{EVENT_STORE_MODEL_RELATIVE} governed public use `{}` must be a direct, non-renamed symbol re-export", + route.segments.join("::") + )); + } + represented_modules.insert(module.clone()); + if !governed_exports.insert(route.exported_name.clone()) { + return Err(format!( + "{EVENT_STORE_MODEL_RELATIVE} exports governed symbol `{}` more than once", + route.exported_name + )); + } + } + if represented_modules != governed_modules { + return Err(format!( + "{EVENT_STORE_MODEL_RELATIVE} governed public API modules differ: expected {governed_modules:?}, found {represented_modules:?}" + )); + } + + let advertised_exports = advertised.iter().cloned().collect::<BTreeSet<_>>(); + if advertised_exports.len() != advertised.len() { + return Err("successor PUBLIC_API contains duplicate symbols".to_owned()); + } + if advertised_exports != governed_exports { + let missing = governed_exports + .difference(&advertised_exports) + .cloned() + .collect::<Vec<_>>(); + let unexpected = advertised_exports + .difference(&governed_exports) + .cloned() + .collect::<Vec<_>>(); + return Err(format!( + "successor PUBLIC_API is not exhaustive for governed model exports; missing {missing:?}, unexpected {unexpected:?}" + )); + } + + let sqlite_cfg = "#[cfg(feature=\"sqlite\")]"; + let mut crate_root_exports = BTreeSet::new(); + for route in collect_top_level_public_use_routes(&lib) { + if route.segments.first().map(String::as_str) != Some("model") { + continue; + } + if route.attributes.as_slice() != [sqlite_cfg] + || route.absolute + || route.renamed + || route.glob + || route.segments.len() != 2 + { + return Err(format!( + "{EVENT_STORE_LIB_RELATIVE} public model use `{}` must be a direct, non-renamed #[cfg(feature = \"sqlite\")] re-export", + route.segments.join("::") + )); + } + if !crate_root_exports.insert(route.exported_name.clone()) { + return Err(format!( + "{EVENT_STORE_LIB_RELATIVE} exports model symbol `{}` more than once", + route.exported_name + )); + } + } + let missing_at_crate_root = governed_exports + .difference(&crate_root_exports) + .cloned() + .collect::<Vec<_>>(); + if !missing_at_crate_root.is_empty() { + return Err(format!( + "{EVENT_STORE_LIB_RELATIVE} does not re-export governed successor symbols {missing_at_crate_root:?}" + )); + } + Ok(()) +} + +fn collect_top_level_public_use_routes(file: &syn::File) -> Vec<PublicUseRoute> { + let mut routes = Vec::new(); + for item in &file.items { + let Item::Use(item_use) = item else { + continue; + }; + if !matches!(item_use.vis, syn::Visibility::Public(_)) { + continue; + } + let attributes = item_use + .attrs + .iter() + .map(compact_tokens) + .collect::<Vec<_>>(); + let mut segments = Vec::new(); + flatten_public_use_tree( + &item_use.tree, + &mut segments, + item_use.leading_colon.is_some(), + &attributes, + &mut routes, + ); + } + routes +} + +fn flatten_public_use_tree( + tree: &syn::UseTree, + segments: &mut Vec<String>, + absolute: bool, + attributes: &[String], + routes: &mut Vec<PublicUseRoute>, +) { + match tree { + syn::UseTree::Path(path) => { + segments.push(path.ident.to_string()); + flatten_public_use_tree(&path.tree, segments, absolute, attributes, routes); + segments.pop(); + } + syn::UseTree::Name(name) => { + let exported_name = name.ident.to_string(); + let mut route_segments = segments.clone(); + route_segments.push(exported_name.clone()); + routes.push(PublicUseRoute { + segments: route_segments, + exported_name, + renamed: false, + glob: false, + absolute, + attributes: attributes.to_vec(), + }); + } + syn::UseTree::Rename(rename) => { + let mut route_segments = segments.clone(); + route_segments.push(rename.ident.to_string()); + routes.push(PublicUseRoute { + segments: route_segments, + exported_name: rename.rename.to_string(), + renamed: true, + glob: false, + absolute, + attributes: attributes.to_vec(), + }); + } + syn::UseTree::Glob(_) => { + let mut route_segments = segments.clone(); + route_segments.push("*".to_owned()); + routes.push(PublicUseRoute { + segments: route_segments, + exported_name: "*".to_owned(), + renamed: false, + glob: true, + absolute, + attributes: attributes.to_vec(), + }); + } + syn::UseTree::Group(group) => { + for item in &group.items { + flatten_public_use_tree(item, segments, absolute, attributes, routes); + } + } + } +} + +fn compact_tokens(tokens: &impl ToTokens) -> String { + tokens + .to_token_stream() + .to_string() + .chars() + .filter(|character| !character.is_whitespace()) + .collect() +} + +fn validate_blossom_dependency_authority(workspace_root: &Path) -> Result<(), String> { + let workspace = parse_toml_value(workspace_root, "Cargo.toml")?; + let event_store = parse_toml_value(workspace_root, "crates/event_store/Cargo.toml")?; + validate_blossom_dependency_values(&workspace, &event_store) +} + +fn parse_toml_value(workspace_root: &Path, relative: &str) -> Result<toml::Value, String> { + let bytes = read_regular_file(workspace_root, relative)?; + let source = std::str::from_utf8(&bytes) + .map_err(|error| format!("{relative} must be UTF-8 TOML: {error}"))?; + toml::from_str(source).map_err(|error| format!("parse {relative}: {error}")) +} + +fn validate_blossom_dependency_values( + workspace: &toml::Value, + event_store: &toml::Value, +) -> Result<(), String> { + let workspace_dependency = workspace + .get("workspace") + .and_then(|value| value.get("dependencies")) + .and_then(|value| value.get("radroots_blossom")) + .and_then(toml::Value::as_table) + .ok_or_else(|| "Cargo.toml must define workspace dependency radroots_blossom".to_owned())?; + if workspace_dependency + .get("path") + .and_then(toml::Value::as_str) + != Some("crates/blossom") + || workspace_dependency + .get("version") + .and_then(toml::Value::as_str) + != Some("=1.0.0-alpha.1") + || workspace_dependency + .get("default-features") + .and_then(toml::Value::as_bool) + != Some(false) + { + return Err( + "Cargo.toml radroots_blossom dependency must pin the standalone crate with defaults disabled" + .to_owned(), + ); + } + + let event_store_dependency = event_store + .get("dependencies") + .and_then(|value| value.get("radroots_blossom")) + .and_then(toml::Value::as_table) + .ok_or_else(|| { + "crates/event_store/Cargo.toml must directly depend on radroots_blossom".to_owned() + })?; + let features = event_store_dependency + .get("features") + .and_then(toml::Value::as_array) + .map(|features| { + features + .iter() + .filter_map(toml::Value::as_str) + .collect::<Vec<_>>() + }); + if event_store_dependency + .get("workspace") + .and_then(toml::Value::as_bool) + != Some(true) + || event_store_dependency + .get("default-features") + .and_then(toml::Value::as_bool) + != Some(false) + || features.as_deref() != Some(&["std"][..]) + { + return Err( + "crates/event_store/Cargo.toml radroots_blossom dependency must select only the governed std feature" + .to_owned(), + ); + } + Ok(()) +} + +fn require_source_markers( + workspace_root: &Path, + relative: &str, + markers: &[&str], +) -> Result<(), String> { + let compact = compact_source(workspace_root, relative)?; + for marker in markers { + if !compact.contains(marker) { + return Err(format!( + "{relative} is missing required successor route `{marker}`" + )); + } + } + Ok(()) +} + +fn compact_source(workspace_root: &Path, relative: &str) -> Result<String, String> { + let bytes = read_regular_file(workspace_root, relative)?; + let source = std::str::from_utf8(&bytes) + .map_err(|error| format!("{relative} must be UTF-8 Rust source: {error}"))?; + let syntax = syn::parse_file(source) + .map_err(|error| format!("parse governed Rust source {relative}: {error}"))?; + Ok(syntax + .into_token_stream() + .to_string() + .chars() + .filter(|character| !character.is_whitespace()) + .collect()) +} + +fn validate_result_vector(vector: &ProjectionResultVector) -> Result<(), String> { + if vector.schema_version != SCHEMA_VERSION + || vector.contract_id != CONTRACT_ID + || vector.feed_version != ADDRESSABLE_FEED_VERSION + || vector.projection_version != PROJECTION_VERSION + || vector.scope_kinds != [FOOD_AVAILABILITY_KIND] + { + return Err(format!( + "{RESULT_VECTOR_CANONICAL_RELATIVE} has an invalid successor identity" + )); + } + let required_cases = [ + "visible_food_availability_projects_and_searches", + "invalid_same_timestamp_winner_retracts_projection", + "blossom_digest_and_image_diagnostics_are_preserved", + "authorized_address_deletion_retracts_projection", + "wrong_author_address_deletion_preserves_projection", + "post_cutoff_replacement_restores_projection", + "operational_listing_head_retracts_food_projection", + "food_head_after_operational_listing_restores_projection", + "food_feed_cursor_advances_across_unrelated_addressable_traffic", + ]; + if vector.cases.len() != required_cases.len() { + return Err(format!( + "{RESULT_VECTOR_CANONICAL_RELATIVE} must contain exactly the nine required cases" + )); + } + validate_unique( + "result-vector case id", + vector.cases.iter().map(|case| case.id.as_str()), + )?; + if vector + .cases + .iter() + .map(|case| case.id.as_str()) + .ne(required_cases) + { + return Err(format!( + "{RESULT_VECTOR_CANONICAL_RELATIVE} case order or inventory is not canonical" + )); + } + + for case in &vector.cases { + let transition_page = &case.expected.transition_page; + if case.events.is_empty() || transition_page.transitions.is_empty() { + return Err(format!( + "{} must contain input events and scoped transitions", + case.id + )); + } + if case.expected.coordinate.kind != FOOD_AVAILABILITY_KIND { + return Err(format!( + "{} coordinate kind must be {FOOD_AVAILABILITY_KIND}", + case.id + )); + } + validate_hex( + &format!("{} coordinate pubkey", case.id), + &case.expected.coordinate.pubkey, + 64, + )?; + if case.expected.coordinate.d_tag.is_empty() { + return Err(format!("{} coordinate d_tag must not be empty", case.id)); + } + + let mut event_ids = BTreeSet::new(); + let mut food_event_ids = BTreeSet::new(); + let mut scoped_coordinate_event_ids = BTreeSet::new(); + let mut events_by_id = BTreeMap::new(); + for (index, observed) in case.events.iter().enumerate() { + if observed.observed_at_ms < 0 { + return Err(format!( + "{} event observed_at_ms must be non-negative", + case.id + )); + } + validate_hex(&format!("{} event id", case.id), &observed.event.id, 64)?; + validate_hex( + &format!("{} event pubkey", case.id), + &observed.event.pubkey, + 64, + )?; + validate_hex( + &format!("{} event signature", case.id), + &observed.event.sig, + 128, + )?; + if observed.event.tags.iter().any(Vec::is_empty) + || !event_ids.insert(observed.event.id.as_str()) + { + return Err(format!( + "{} contains an invalid or duplicate signed event", + case.id + )); + } + validate_expected_ingest(&case.id, observed)?; + match observed.role { + ProjectionInputRole::ScopedFood | ProjectionInputRole::ScopedNonFood => { + let d_tags = observed + .event + .tags + .iter() + .filter(|tag| tag.first().map(String::as_str) == Some("d")) + .filter_map(|tag| tag.get(1).map(String::as_str)) + .collect::<Vec<_>>(); + if observed.event.kind != FOOD_AVAILABILITY_KIND + || observed.event.pubkey != case.expected.coordinate.pubkey + || d_tags.as_slice() != [case.expected.coordinate.d_tag.as_str()] + { + return Err(format!( + "{} scoped input does not match its kind-30402 coordinate", + case.id + )); + } + scoped_coordinate_event_ids.insert(observed.event.id.as_str()); + if observed.role == ProjectionInputRole::ScopedFood { + food_event_ids.insert(observed.event.id.as_str()); + } + } + ProjectionInputRole::UnrelatedAddressable => { + let d_tags = observed + .event + .tags + .iter() + .filter(|tag| tag.first().map(String::as_str) == Some("d")) + .filter_map(|tag| tag.get(1).map(String::as_str)) + .collect::<Vec<_>>(); + if observed.event.kind != UNRELATED_ADDRESSABLE_KIND + || d_tags.len() != 1 + || d_tags[0].is_empty() + { + return Err(format!( + "{} unrelated addressable input must be a kind-{UNRELATED_ADDRESSABLE_KIND} event with one non-empty d tag", + case.id + )); + } + } + ProjectionInputRole::Causal if observed.event.kind != 5 => { + return Err(format!( + "{} causal input must be a kind-5 deletion request", + case.id + )); + } + ProjectionInputRole::Causal => {} + } + let event_seq = i64::try_from(index + 1) + .map_err(|_| format!("{} event sequence exceeds i64", case.id))?; + events_by_id.insert(observed.event.id.as_str(), (event_seq, observed)); + } + if food_event_ids.is_empty() { + return Err(format!("{} has no scoped FoodAvailability input", case.id)); + } + let coordinate_address = format!( + "{}:{}:{}", + case.expected.coordinate.kind, + case.expected.coordinate.pubkey, + case.expected.coordinate.d_tag + ); + for observed in case + .events + .iter() + .filter(|observed| observed.role == ProjectionInputRole::Causal) + { + let references_scope = observed.event.tags.iter().any(|tag| { + (tag.first().map(String::as_str) == Some("a") + && tag.get(1).map(String::as_str) == Some(coordinate_address.as_str())) + || (tag.first().map(String::as_str) == Some("e") + && tag.get(1).is_some_and(|event_id| { + scoped_coordinate_event_ids.contains(event_id.as_str()) + })) + }); + if !references_scope { + return Err(format!( + "{} causal input does not reference the scoped coordinate or event", + case.id + )); + } + } + + let projection_id = case + .expected + .projection + .0 + .as_ref() + .map(|projection| projection.event_id.as_str()); + if projection_id.is_some_and(|id| !food_event_ids.contains(id)) { + return Err(format!( + "{} projection must reference a scoped FoodAvailability input event", + case.id + )); + } + if let Some(projection) = case.expected.projection.0.as_ref() { + for image in &projection.images { + if let Some(digest) = image.blossom_sha256.0.as_deref() { + validate_sha256(&format!("{} Blossom image digest", case.id), digest)?; + } + } + } + for search in &case.expected.searches { + if search.query.trim().is_empty() { + return Err(format!("{} search query must not be empty", case.id)); + } + validate_unique( + &format!("{} search event id", case.id), + search.event_ids.iter().map(String::as_str), + )?; + for event_id in &search.event_ids { + validate_hex(&format!("{} search event id", case.id), event_id, 64)?; + if Some(event_id.as_str()) != projection_id { + return Err(format!( + "{} search result must equal the current projection", + case.id + )); + } + } + } + + let expected_high_water = i64::try_from(case.events.len()) + .map_err(|_| format!("{} input event count exceeds i64", case.id))?; + if transition_page.source_high_water != expected_high_water + || transition_page.has_more + || transition_page.next_cursor.source_generation != SOURCE_GENERATION_ACTIVE_SENTINEL + || transition_page.next_cursor.feed_version != ADDRESSABLE_FEED_VERSION + || transition_page.next_cursor.scope_fingerprint != SCOPE_FINGERPRINT_SHA256 + || transition_page.next_cursor.last_transition_seq != expected_high_water + { + return Err(format!( + "{} transition page does not seal the complete active-source interval", + case.id + )); + } + let expected_transition_sequences = case + .events + .iter() + .enumerate() + .filter(|(_, observed)| observed.role != ProjectionInputRole::UnrelatedAddressable) + .map(|(index, _)| { + i64::try_from(index + 1) + .map_err(|_| format!("{} transition sequence exceeds i64", case.id)) + }) + .collect::<Result<BTreeSet<_>, _>>()?; + let actual_transition_sequences = transition_page + .transitions + .iter() + .map(|transition| transition.transition_seq) + .collect::<BTreeSet<_>>(); + if actual_transition_sequences.len() != transition_page.transitions.len() + || actual_transition_sequences != expected_transition_sequences + { + return Err(format!( + "{} scoped transition sequences do not exactly skip unrelated addressable traffic", + case.id + )); + } + + let mut prior_visible: Option<&ExpectedEventReference> = None; + let mut last_cause_event_seq = 0_i64; + let mut last_transition_seq = 0_i64; + for transition in &transition_page.transitions { + if transition.transition_seq <= last_transition_seq + || transition.transition_seq > expected_high_water + || transition.source_generation != SOURCE_GENERATION_ACTIVE_SENTINEL + || transition.origin != "incremental" + || transition.coordinate != case.expected.coordinate + { + return Err(format!( + "{} transition {} has an invalid authority witness", + case.id, transition.transition_seq + )); + } + last_transition_seq = transition.transition_seq; + let raw_head = validate_vector_event_reference( + &case.id, + "raw head", + &transition.raw_head, + &events_by_id, + )?; + if !matches!( + raw_head.role, + ProjectionInputRole::ScopedFood | ProjectionInputRole::ScopedNonFood + ) || transition.raw_head_created_at != raw_head.event.created_at + || transition.admission_status != raw_head.expected_ingest.admission_status + || transition.admission_code.0 != raw_head.expected_ingest.admission_code.0 + || transition.contract_id.0 != raw_head.expected_ingest.contract_id.0 + { + return Err(format!( + "{} transition {} raw-head metadata is not canonical", + case.id, transition.transition_seq + )); + } + let cause = transition.cause_event.0.as_ref().ok_or_else(|| { + format!( + "{} incremental transition {} must authenticate its cause", + case.id, transition.transition_seq + ) + })?; + let cause_source = validate_vector_event_reference( + &case.id, + "transition cause", + &cause.event, + &events_by_id, + )?; + if cause.event.event_seq != transition.transition_seq + || cause.event.event_seq <= last_cause_event_seq + || cause.pubkey != cause_source.event.pubkey + || cause.created_at != cause_source.event.created_at + || cause.kind != cause_source.event.kind + || cause.admission_status != cause_source.expected_ingest.admission_status + || cause.admission_code.0 != cause_source.expected_ingest.admission_code.0 + || cause.contract_id.0 != cause_source.expected_ingest.contract_id.0 + || transition.raw_head_decision != cause_source.expected_ingest.raw_head_decision + { + return Err(format!( + "{} transition {} cause metadata is not canonical", + case.id, transition.transition_seq + )); + } + last_cause_event_seq = cause.event.event_seq; + + validate_transition_decision_shape(&case.id, transition)?; + if let Some(evidence) = transition.suppression.0.as_ref() { + validate_suppression_evidence( + &case.id, + evidence, + &events_by_id, + raw_head.event.kind, + raw_head.event.created_at, + )?; + } + if let Some(canonical) = transition.canonical_visible_event.0.as_ref() { + let source = validate_vector_event_reference( + &case.id, + "canonical visible event", + &canonical.event, + &events_by_id, + )?; + if canonical.event != transition.raw_head + || canonical.admission_status != transition.admission_status + || canonical.contract_id.0 != transition.contract_id.0 + || canonical.event_class != source.expected_ingest.event_class + || canonical.valid_stream_eligible + != source.expected_ingest.valid_stream_eligible + { + return Err(format!( + "{} transition {} canonical event metadata is invalid", + case.id, transition.transition_seq + )); + } + validate_sha256( + &format!("{} canonical raw JSON digest", case.id), + &canonical.raw_json_sha256, + )?; + let raw_json = serde_json::to_vec(&source.event) + .map_err(|error| format!("serialize {} signed event: {error}", case.id))?; + if sha256_hex(&raw_json) != canonical.raw_json_sha256 { + return Err(format!( + "{} transition {} canonical raw JSON digest is stale", + case.id, transition.transition_seq + )); + } + } + if let Some(retracted) = transition.retracted_event.0.as_ref() { + validate_vector_event_reference( + &case.id, + "retracted event", + retracted, + &events_by_id, + )?; + if retracted.event_seq >= transition.transition_seq { + return Err(format!( + "{} transition {} retracts a non-prior event", + case.id, transition.transition_seq + )); + } + } + let next_visible = transition + .canonical_visible_event + .0 + .as_ref() + .map(|event| &event.event); + let expected_retracted = (prior_visible != next_visible) + .then_some(prior_visible) + .flatten(); + if transition.retracted_event.0.as_ref() != expected_retracted { + return Err(format!( + "{} transition {} has invalid retraction lineage", + case.id, transition.transition_seq + )); + } + prior_visible = next_visible; + } + + validate_unique( + &format!("{} visibility event id", case.id), + case.expected + .event_visibility + .iter() + .map(|visibility| visibility.event.event_id.as_str()), + )?; + if case.expected.event_visibility.len() != event_ids.len() + || case + .expected + .event_visibility + .iter() + .any(|visibility| !event_ids.contains(visibility.event.event_id.as_str())) + { + return Err(format!( + "{} visibility expectations must cover every input event exactly once", + case.id + )); + } + let current_scoped_raw_head = transition_page + .transitions + .last() + .expect("non-empty transition page checked above") + .raw_head + .event_id + .as_str(); + for visibility in &case.expected.event_visibility { + let source = validate_vector_event_reference( + &case.id, + "current visibility event", + &visibility.event, + &events_by_id, + )?; + let expected_raw_head = match source.role { + ProjectionInputRole::Causal => None, + ProjectionInputRole::ScopedFood | ProjectionInputRole::ScopedNonFood => { + Some(current_scoped_raw_head) + } + ProjectionInputRole::UnrelatedAddressable => Some(source.event.id.as_str()), + }; + let expected_is_raw_head = + expected_raw_head.is_none_or(|event_id| event_id == source.event.id.as_str()); + if visibility.source_generation != SOURCE_GENERATION_ACTIVE_SENTINEL + || visibility.admission_status != source.expected_ingest.admission_status + || visibility.is_raw_head != expected_is_raw_head + || visibility.raw_head_event_id.0.as_deref() != expected_raw_head + { + return Err(format!( + "{} current visibility witness for {} is not authoritative", + case.id, source.event.id + )); + } + if let Some(evidence) = visibility.suppression.0.as_ref() { + validate_suppression_evidence( + &case.id, + evidence, + &events_by_id, + source.event.kind, + source.event.created_at, + )?; + } + let coherent = match visibility.decision.as_str() { + "visible" => { + visibility.admission_status == "admitted" + && visibility.is_raw_head + && visibility + .suppression + .0 + .as_ref() + .is_some_and(|evidence| evidence.outcome == "visible") + } + "not_current" => { + visibility.admission_status == "admitted" + && !visibility.is_raw_head + && visibility.suppression.0.is_some() + } + "suppressed" => { + visibility.admission_status == "admitted" + && visibility.is_raw_head + && visibility + .suppression + .0 + .as_ref() + .is_some_and(|evidence| evidence.outcome == "suppressed") + } + "not_admitted" => { + visibility.admission_status != "admitted" && visibility.suppression.0.is_none() + } + _ => false, + }; + if !coherent { + return Err(format!( + "{} current visibility decision for {} is incoherent", + case.id, source.event.id + )); + } + } + + let expected_historical = transition_page + .transitions + .iter() + .filter_map(|transition| { + let visible = transition.canonical_visible_event.0.as_ref()?; + let final_visibility = case + .expected + .event_visibility + .iter() + .find(|visibility| visibility.event.event_id == visible.event.event_id)?; + (final_visibility.decision != "visible").then_some(( + transition.transition_seq, + visible.event.event_id.as_str(), + final_visibility.decision.as_str(), + )) + }) + .collect::<BTreeSet<_>>(); + let actual_historical = case + .expected + .historical_visibility_witnesses + .iter() + .map(|witness| { + ( + witness.transition_seq, + witness.event_id.as_str(), + witness.final_decision.as_str(), + ) + }) + .collect::<BTreeSet<_>>(); + if actual_historical.len() != case.expected.historical_visibility_witnesses.len() + || actual_historical != expected_historical + { + return Err(format!( + "{} historical visibility witnesses do not cover every transition-time payload with a divergent final decision", + case.id + )); + } + } + Ok(()) +} + +fn validate_expected_ingest(case_id: &str, observed: &ObservedEvent) -> Result<(), String> { + let expected = &observed.expected_ingest; + let (event_class, admitted_contract, required_decision, invalid_allowed) = match observed.role { + ProjectionInputRole::ScopedFood => ("addressable", FOOD_CONTRACT_ID, None, true), + ProjectionInputRole::ScopedNonFood => ( + "addressable", + OPERATIONAL_LISTING_CONTRACT_ID, + Some("applied"), + false, + ), + ProjectionInputRole::UnrelatedAddressable => ( + "addressable", + FARM_PROFILE_CONTRACT_ID, + Some("applied"), + false, + ), + ProjectionInputRole::Causal => ( + "regular", + DELETION_CONTRACT_ID, + Some("not_head_selected"), + false, + ), + }; + let known_raw_head_decision = matches!( + expected.raw_head_decision.as_str(), + "applied" + | "not_head_selected" + | "skipped_older" + | "skipped_same_timestamp_higher_event_id" + | "malformed_coordinate" + ); + let coherent_admission = match expected.admission_status.as_str() { + "admitted" => { + expected.admission_code.0.is_none() + && expected.contract_id.0.as_deref() == Some(admitted_contract) + && expected.valid_stream_eligible + } + "invalid" | "unsupported" => { + invalid_allowed + && expected.admission_code.0.is_some() + && expected.contract_id.0.is_none() + && !expected.valid_stream_eligible + } + _ => false, + }; + if expected.event_class != event_class + || !known_raw_head_decision + || required_decision.is_some_and(|decision| expected.raw_head_decision != decision) + || !coherent_admission + { + return Err(format!( + "{case_id} input {} has an incoherent ingest witness", + observed.event.id + )); + } + Ok(()) +} + +fn validate_vector_event_reference<'a>( + case_id: &str, + label: &str, + reference: &ExpectedEventReference, + events: &BTreeMap<&'a str, (i64, &'a ObservedEvent)>, +) -> Result<&'a ObservedEvent, String> { + validate_hex( + &format!("{case_id} {label} event id"), + &reference.event_id, + 64, + )?; + let (expected_sequence, observed) = events + .get(reference.event_id.as_str()) + .copied() + .ok_or_else(|| format!("{case_id} {label} must reference an input event"))?; + if reference.event_seq != expected_sequence { + return Err(format!( + "{case_id} {label} sequence does not match input order" + )); + } + Ok(observed) +} + +fn validate_transition_decision_shape( + case_id: &str, + transition: &ExpectedTransition, +) -> Result<(), String> { + let coherent = match transition.admission_status.as_str() { + "admitted" => { + transition.admission_code.0.is_none() + && transition.contract_id.0.as_deref().is_some_and(|contract| { + contract == FOOD_CONTRACT_ID || contract == OPERATIONAL_LISTING_CONTRACT_ID + }) + && transition.suppression.0.as_ref().is_some_and(|evidence| { + match transition.visibility.as_str() { + "visible" => { + evidence.outcome == "visible" + && transition.canonical_visible_event.0.is_some() + } + "suppressed" => { + evidence.outcome == "suppressed" + && transition.canonical_visible_event.0.is_none() + } + _ => false, + } + }) + } + "invalid" | "unsupported" => { + transition.admission_code.0.is_some() + && transition.contract_id.0.is_none() + && transition.visibility == "not_admitted" + && transition.suppression.0.is_none() + && transition.canonical_visible_event.0.is_none() + } + _ => false, + }; + if !coherent { + return Err(format!( + "{case_id} transition {} has an incoherent admission/visibility witness", + transition.transition_seq + )); + } + Ok(()) +} + +fn validate_suppression_evidence( + case_id: &str, + evidence: &ExpectedSuppressionEvidence, + events: &BTreeMap<&str, (i64, &ObservedEvent)>, + target_kind: u32, + target_created_at: u64, +) -> Result<(), String> { + for (label, request_id) in [ + ( + "event-reference request", + evidence.event_reference_request_id.0.as_deref(), + ), + ( + "address-reference request", + evidence.address_reference_request_id.0.as_deref(), + ), + ] { + if let Some(request_id) = request_id { + validate_hex(&format!("{case_id} {label}"), request_id, 64)?; + if events + .get(request_id) + .is_none_or(|(_, event)| event.role != ProjectionInputRole::Causal) + { + return Err(format!( + "{case_id} {label} must reference a causal input event" + )); + } + } + } + let event_reference = evidence.event_reference_request_id.0.is_some(); + let address_reference = evidence.address_reference_request_id.0.is_some(); + let cutoff = evidence.address_reference_cutoff.0; + let coherent = match evidence.reason.as_str() { + "deletion_request_immune" => { + target_kind == 5 + && evidence.outcome == "visible" + && !event_reference + && !address_reference + && cutoff.is_none() + } + "deletion_no_authorized_reference" | "deletion_request_author_mismatch" => { + target_kind != 5 + && evidence.outcome == "visible" + && !event_reference + && !address_reference + && cutoff.is_none() + } + "deletion_address_cutoff_precedes_target" => { + target_kind != 5 + && evidence.outcome == "visible" + && !event_reference + && address_reference + && cutoff.is_some_and(|cutoff| cutoff < target_created_at) + } + "deletion_event_id_reference" => { + target_kind != 5 + && evidence.outcome == "suppressed" + && event_reference + && cutoff.is_none_or(|cutoff| cutoff < target_created_at) + && (address_reference == cutoff.is_some()) + } + "deletion_address_reference" => { + target_kind != 5 + && evidence.outcome == "suppressed" + && !event_reference + && address_reference + && cutoff.is_some_and(|cutoff| cutoff >= target_created_at) + } + "deletion_event_id_and_address_reference" => { + target_kind != 5 + && evidence.outcome == "suppressed" + && event_reference + && address_reference + && cutoff.is_some_and(|cutoff| cutoff >= target_created_at) + } + _ => false, + }; + if !coherent { + return Err(format!("{case_id} suppression evidence is incoherent")); + } + Ok(()) +} + +fn validate_manifest_shape(manifest: &FoodAvailabilityProjectionManifest) -> Result<(), String> { + let expected_public_api = PUBLIC_API + .iter() + .map(|name| (*name).to_owned()) + .collect::<Vec<_>>(); + if manifest.schema_version != SCHEMA_VERSION + || manifest.contract_id != CONTRACT_ID + || manifest.hook_id != HOOK_ID + || manifest.predecessor.hook_id != PREDECESSOR_HOOK_ID + || manifest.predecessor.manifest.sha256 != PREDECESSOR_MANIFEST_SHA256 + || manifest.migration.version != MIGRATION_VERSION + || manifest.migration.name != MIGRATION_NAME + || manifest.migration.schema_sha256 != SCHEMA_SHA256 + || manifest.profile.event_contract_registry_version != EVENT_CONTRACT_REGISTRY_VERSION + || manifest.profile.addressable_feed_version != ADDRESSABLE_FEED_VERSION + || manifest.profile.projection_version != PROJECTION_VERSION + || manifest.profile.scope_kinds != [FOOD_AVAILABILITY_KIND] + || manifest.profile.scope_fingerprint_sha256 != SCOPE_FINGERPRINT_SHA256 + || manifest.profile.food_contract_id != FOOD_CONTRACT_ID + || manifest.profile.admission_authority != ADMISSION_AUTHORITY + || manifest.profile.current_visibility_authority != CURRENT_VISIBILITY_AUTHORITY + || manifest.profile.post_core_capability != POST_CORE_CAPABILITY + || manifest.public_api != expected_public_api + || manifest.result_vector.executor_id != RESULT_VECTOR_EXECUTOR_ID + { + return Err(format!( + "{MANIFEST_RELATIVE} does not describe the FoodAvailability projection-v1 successor" + )); + } + validate_catalog(&manifest.migration.catalog)?; + validate_unique( + "source role", + manifest + .source_files + .iter() + .map(|source| source.role.as_str()), + )?; + validate_unique( + "source path", + manifest + .source_files + .iter() + .map(|source| source.path.as_str()), + )?; + validate_unique( + "entry-point role", + manifest + .entry_points + .iter() + .map(|entry| entry.role.as_str()), + )?; + validate_unique("public API", manifest.public_api.iter().map(String::as_str))?; + Ok(()) +} + +fn generated_descriptor( + manifest: &FoodAvailabilityProjectionManifest, + manifest_bytes: &[u8], + manifest_sha256: &str, +) -> String { + let manifest_json = std::str::from_utf8(manifest_bytes) + .expect("canonical JSON serialization always produces UTF-8"); + let manifest_literal = format!("{manifest_json:?}"); + format!( + "// @generated by `cargo xtask contract food-availability-projection-manifest --write`; do not edit.\n\ +#![allow(dead_code)]\n\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MANIFEST_JSON: &str = {manifest_literal};\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MANIFEST_BYTE_LENGTH: usize = {};\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256: &str =\n \"{manifest_sha256}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MANIFEST_SCHEMA_VERSION: u32 = {};\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_CONTRACT_ID: &str =\n \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_HOOK_ID: &str = \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_VERSION: u32 = {};\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_NAME: &str = \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_VERSION: u32 = {};\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_FEED_VERSION: u32 = {};\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION: u32 = {};\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_BYTE_LENGTH: usize = {};\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_SHA256: &str =\n \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_BYTE_LENGTH: usize = {};\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_SHA256: &str =\n \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_SCHEMA_SHA256: &str =\n \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_SCOPE_KINDS: &[u32] = &[30_402];\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_SCOPE_FINGERPRINT_SHA256: &str =\n \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_PREDECESSOR_MANIFEST_SHA256: &str =\n \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_SHA256: &str =\n \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_EXECUTOR_ID: &str =\n \"{}\";\n\ +pub(crate) const FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_EXECUTOR_SHA256: &str =\n \"{}\";\n", + manifest_bytes.len(), + manifest.schema_version, + manifest.contract_id, + manifest.hook_id, + manifest.migration.version, + manifest.migration.name, + manifest.profile.projection_version, + manifest.profile.addressable_feed_version, + manifest.profile.event_contract_registry_version, + manifest.migration.up.byte_length, + manifest.migration.up.sha256, + manifest.migration.down.byte_length, + manifest.migration.down.sha256, + manifest.migration.schema_sha256, + manifest.profile.scope_fingerprint_sha256, + manifest.predecessor.manifest.sha256, + manifest.result_vector.sha256, + manifest.result_vector.executor_id, + manifest.result_vector.executor_sha256, + ) +} + +fn manifest_schema() -> Value { + let hash = json!({"type": "string", "pattern": "^[0-9a-f]{64}$"}); + let path = json!({ + "type": "string", + "pattern": "^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$" + }); + let file = json!({ + "type": "object", + "required": ["path", "byte_length", "sha256", "hash_algorithm"], + "properties": { + "path": path, + "byte_length": {"type": "integer", "minimum": 1}, + "sha256": hash, + "hash_algorithm": {"const": HASH_ALGORITHM} + }, + "additionalProperties": false + }); + let string_array = json!({ + "type": "array", + "items": {"type": "string", "minLength": 1}, + "uniqueItems": true + }); + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/core/event-store/food-availability-projection-manifest-v1.schema.json", + "title": "Radroots event-store FoodAvailability projection manifest v1", + "type": "object", + "required": [ + "schema_version", "contract_id", "hook_id", "manifest_schema", "predecessor", + "migration", "profile", "registry_inventory", "food_profile_vector", + "entry_points", "source_files", "public_api", "result_vector" + ], + "properties": { + "schema_version": {"const": SCHEMA_VERSION}, + "contract_id": {"const": CONTRACT_ID}, + "hook_id": {"const": HOOK_ID}, + "manifest_schema": {"$ref": "#/$defs/file"}, + "predecessor": { + "type": "object", + "required": ["hook_id", "manifest"], + "properties": { + "hook_id": {"const": PREDECESSOR_HOOK_ID}, + "manifest": {"$ref": "#/$defs/file"} + }, + "additionalProperties": false + }, + "migration": { + "type": "object", + "required": ["version", "name", "up", "down", "schema_sha256", "catalog"], + "properties": { + "version": {"const": MIGRATION_VERSION}, + "name": {"const": MIGRATION_NAME}, + "up": {"$ref": "#/$defs/file"}, + "down": {"$ref": "#/$defs/file"}, + "schema_sha256": {"const": SCHEMA_SHA256}, + "catalog": { + "type": "object", + "required": ["objects", "tables", "fts5_tables"], + "properties": { + "objects": string_array, + "tables": string_array, + "fts5_tables": string_array + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "profile": { + "type": "object", + "required": [ + "event_contract_registry_version", "addressable_feed_version", + "projection_version", "scope_kinds", "scope_fingerprint_sha256", + "food_contract_id", "admission_authority", "current_visibility_authority", + "post_core_capability" + ], + "properties": { + "event_contract_registry_version": {"const": EVENT_CONTRACT_REGISTRY_VERSION}, + "addressable_feed_version": {"const": ADDRESSABLE_FEED_VERSION}, + "projection_version": {"const": PROJECTION_VERSION}, + "scope_kinds": { + "type": "array", "prefixItems": [{"const": FOOD_AVAILABILITY_KIND}], + "minItems": 1, "maxItems": 1 + }, + "scope_fingerprint_sha256": {"const": SCOPE_FINGERPRINT_SHA256}, + "food_contract_id": {"const": FOOD_CONTRACT_ID}, + "admission_authority": {"const": ADMISSION_AUTHORITY}, + "current_visibility_authority": {"const": CURRENT_VISIBILITY_AUTHORITY}, + "post_core_capability": {"const": POST_CORE_CAPABILITY} + }, + "additionalProperties": false + }, + "registry_inventory": {"$ref": "#/$defs/file"}, + "food_profile_vector": {"$ref": "#/$defs/file"}, + "entry_points": { + "type": "array", "minItems": 1, + "items": { + "type": "object", "required": ["role", "rust_path"], + "properties": { + "role": {"type": "string", "minLength": 1}, + "rust_path": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + } + }, + "source_files": { + "type": "array", "minItems": 1, + "items": {"$ref": "#/$defs/source_file"} + }, + "public_api": string_array, + "result_vector": { + "type": "object", + "required": [ + "canonical_path", "mirror_path", "byte_length", "sha256", "hash_algorithm", + "executor_id", "executor_path", "executor_test", "executor_byte_length", + "executor_sha256", "executor_hash_algorithm" + ], + "properties": { + "canonical_path": {"const": RESULT_VECTOR_CANONICAL_RELATIVE}, + "mirror_path": {"const": RESULT_VECTOR_MIRROR_RELATIVE}, + "byte_length": {"type": "integer", "minimum": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "hash_algorithm": {"const": HASH_ALGORITHM}, + "executor_id": {"const": RESULT_VECTOR_EXECUTOR_ID}, + "executor_path": {"const": RESULT_VECTOR_EXECUTOR_RELATIVE}, + "executor_test": {"const": RESULT_VECTOR_EXECUTOR_TEST}, + "executor_byte_length": {"type": "integer", "minimum": 1}, + "executor_sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "executor_hash_algorithm": {"const": HASH_ALGORITHM} + }, + "additionalProperties": false + } + }, + "$defs": { + "file": file, + "source_file": { + "type": "object", + "required": ["role", "path", "byte_length", "sha256", "hash_algorithm"], + "properties": { + "role": {"type": "string", "minLength": 1}, + "path": { + "type": "string", + "pattern": "^[A-Za-z0-9_-][A-Za-z0-9._-]*(?:/[A-Za-z0-9_-][A-Za-z0-9._-]*)*$" + }, + "byte_length": {"type": "integer", "minimum": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "hash_algorithm": {"const": HASH_ALGORITHM} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +fn validate_manifest_json_schema(schema: &Value, manifest: &Value) -> Result<(), String> { + jsonschema::draft202012::meta::validate(schema).map_err(|error| { + format!( + "{MANIFEST_SCHEMA_RELATIVE} is not a valid JSON Schema Draft 2020-12 document: {error}" + ) + })?; + let validator = jsonschema::draft202012::options() + .build(schema) + .map_err(|error| { + format!("compile {MANIFEST_SCHEMA_RELATIVE} as JSON Schema Draft 2020-12: {error}") + })?; + validator.validate(manifest).map_err(|error| { + format!( + "{MANIFEST_RELATIVE} violates {MANIFEST_SCHEMA_RELATIVE} at {}: {error}", + error.instance_path() + ) + }) +} + +fn canonical_json_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>, String> { + let mut bytes = + serde_json::to_vec_pretty(value).map_err(|error| format!("serialize JSON: {error}"))?; + bytes.push(b'\n'); + Ok(bytes) +} + +fn validate_canonical_json<T: Serialize>( + relative: &str, + actual: &[u8], + value: &T, +) -> Result<(), String> { + if actual.starts_with(&[0xef, 0xbb, 0xbf]) { + return Err(format!("{relative} must not contain a UTF-8 BOM")); + } + if actual.contains(&b'\r') { + return Err(format!("{relative} must use LF line endings")); + } + if actual != canonical_json_bytes(value)? { + return Err(format!( + "{relative} must use canonical two-space JSON formatting and end with exactly one LF" + )); + } + Ok(()) +} + +fn validate_unique<'a>(label: &str, values: impl Iterator<Item = &'a str>) -> Result<(), String> { + let mut seen = BTreeSet::new(); + for value in values { + if value.is_empty() || !seen.insert(value) { + return Err(format!("empty or duplicate {label}: {value}")); + } + } + Ok(()) +} + +fn validate_digest_sidecar(relative: &str, bytes: &[u8]) -> Result<(), String> { + if bytes.len() != 65 || bytes[64] != b'\n' { + return Err(format!( + "{relative} must contain 64 lowercase hexadecimal bytes and one LF" + )); + } + let digest = std::str::from_utf8(&bytes[..64]) + .map_err(|error| format!("{relative} must be UTF-8: {error}"))?; + validate_sha256(relative, digest) +} + +fn validate_sha256(label: &str, value: &str) -> Result<(), String> { + validate_hex(label, value, 64) +} + +fn validate_hex(label: &str, value: &str, expected_length: usize) -> Result<(), String> { + if value.len() != expected_length + || !value + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + return Err(format!( + "{label} must contain exactly {expected_length} lowercase hexadecimal bytes" + )); + } + Ok(()) +} + +fn sha256_hex(bytes: &[u8]) -> String { + hex::encode(Sha256::digest(bytes)) +} + +fn stale_error(relative: &str) -> String { + format!("{relative} is stale; run `{WRITE_COMMAND}`") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn repository_root() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("xtask manifest has a workspace root") + .to_path_buf() + } + + #[test] + fn food_scope_fingerprint_is_pinned() { + let mut hasher = Sha256::new(); + hasher.update(b"radroots.addressable-transition-scope.v1\0"); + hasher.update(1_u32.to_be_bytes()); + hasher.update(FOOD_AVAILABILITY_KIND.to_be_bytes()); + assert_eq!(hex::encode(hasher.finalize()), SCOPE_FINGERPRINT_SHA256); + } + + #[test] + fn blossom_dependency_authority_requires_the_direct_std_only_edge() { + let root = repository_root(); + let workspace = parse_toml_value(&root, "Cargo.toml").expect("workspace manifest"); + let mut event_store = + parse_toml_value(&root, "crates/event_store/Cargo.toml").expect("event-store manifest"); + validate_blossom_dependency_values(&workspace, &event_store) + .expect("governed Blossom dependency"); + + event_store + .get_mut("dependencies") + .and_then(toml::Value::as_table_mut) + .and_then(|dependencies| dependencies.get_mut("radroots_blossom")) + .and_then(toml::Value::as_table_mut) + .expect("Blossom dependency table") + .insert("features".to_owned(), toml::Value::Array(Vec::new())); + let error = validate_blossom_dependency_values(&workspace, &event_store) + .expect_err("missing std feature must fail"); + assert!(error.contains("governed std feature"), "{error}"); + } + + #[test] + fn migration_cursor_guards_are_bound_to_governed_rust_limits() { + let root = repository_root(); + let addressable_model = read_regular_file( + &root, + "crates/event_store/src/model/addressable_transition_feed_v1.rs", + ) + .expect("addressable model"); + let addressable_model = std::str::from_utf8(&addressable_model).expect("UTF-8 model"); + let food_model = read_regular_file( + &root, + "crates/event_store/src/model/food_availability_projection_v1.rs", + ) + .expect("food model"); + let food_model = std::str::from_utf8(&food_model).expect("UTF-8 model"); + let migration = + read_regular_file(&root, MIGRATION_UP_RELATIVE).expect("projection migration"); + let migration = std::str::from_utf8(&migration).expect("UTF-8 migration"); + validate_migration_guard_limit_sources(addressable_model, food_model, migration) + .expect("governed cursor guards"); + + let stale_scan = migration.replacen( + "NEW.last_transition_seq - OLD.last_transition_seq > 1024", + "NEW.last_transition_seq - OLD.last_transition_seq > 1023", + 1, + ); + assert_ne!(stale_scan, migration, "scan mutation must apply"); + let error = + validate_migration_guard_limit_sources(addressable_model, food_model, &stale_scan) + .expect_err("stale scan guard must fail"); + assert!(error.contains("cursor scan delta"), "{error}"); + + let stale_rows = migration.replacen( + "abs(NEW.projected_row_count - OLD.projected_row_count) > 64", + "abs(NEW.projected_row_count - OLD.projected_row_count) > 63", + 1, + ); + assert_ne!(stale_rows, migration, "row-count mutation must apply"); + let error = + validate_migration_guard_limit_sources(addressable_model, food_model, &stale_rows) + .expect_err("stale row-count guard must fail"); + assert!(error.contains("projected-row delta"), "{error}"); + + let detached_apply_limit = food_model.replacen( + "RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1;", + "63;", + 1, + ); + assert_ne!( + detached_apply_limit, food_model, + "apply-limit mutation must apply" + ); + let error = validate_migration_guard_limit_sources( + addressable_model, + &detached_apply_limit, + migration, + ) + .expect_err("detached apply limit must fail"); + assert!(error.contains("must alias"), "{error}"); + + let stale_visibility_index = migration.replacen( + "INDEXED BY radroots_event_store_nip09_address_target_visibility_lookup_idx", + "INDEXED BY radroots_event_store_nip09_address_target_lookup_idx", + 1, + ); + assert_ne!( + stale_visibility_index, migration, + "visibility-index mutation must apply" + ); + let error = validate_migration_guard_limit_sources( + addressable_model, + food_model, + &stale_visibility_index, + ) + .expect_err("unforced visibility index must fail"); + assert!(error.contains("visibility index"), "{error}"); + + let stale_visibility_order = migration.replacen( + "ORDER BY target.inclusive_cutoff DESC, target.request_event_id", + "ORDER BY target.inclusive_cutoff DESC, request.request_event_id", + 1, + ); + assert_ne!( + stale_visibility_order, migration, + "visibility-order mutation must apply" + ); + let error = validate_migration_guard_limit_sources( + addressable_model, + food_model, + &stale_visibility_order, + ) + .expect_err("non-indexed visibility ordering must fail"); + assert!(error.contains("visibility ordering"), "{error}"); + } + + #[test] + fn predecessor_fast_open_validation_remains_constant_cost() { + let root = repository_root(); + let source = read_regular_file(&root, "crates/event_store/src/nip09/reconciliation_v1.rs") + .expect("predecessor source"); + let source = std::str::from_utf8(&source).expect("UTF-8 predecessor source"); + validate_fast_active_hook_source(source).expect("constant-cost fast-open validation"); + + let exhaustive = source.replacen( + "validate_structural_source_state_fast(connection)", + "validate_structural_source_state(connection)", + 1, + ); + assert_ne!(exhaustive, source, "fast-open mutation must apply"); + let error = validate_fast_active_hook_source(&exhaustive) + .expect_err("exhaustive open-time scan must fail"); + assert!(error.contains("constant-cost"), "{error}"); + } + + #[test] + fn source_capacity_preflight_and_recheck_authority_is_structurally_sealed() { + const OUTER_PREFLIGHT: &str = r#" if has_pending_source_capacity_hook(&status, registry) { + let mut connection = pool.acquire().await?; + validate_event_store_temp_schema_with_registry(&mut connection, registry).await?; + validate_reconciliation_capacity(&mut connection, reconciliation_limits).await?; + } +"#; + const OUTER_DECOY: &str = r#" if false { + if has_pending_source_capacity_hook(&status, registry) { + let mut connection = pool.acquire().await?; + validate_event_store_temp_schema_with_registry(&mut connection, registry).await?; + validate_reconciliation_capacity(&mut connection, reconciliation_limits).await?; + } + } +"#; + const INNER_RECHECK_AND_APPLY: &str = r#" if matches!( + migration.hook, + EventStoreMigrationHook::Nip09ReconciliationV1 + | EventStoreMigrationHook::FoodAvailabilityProjectionV1 + ) { + validate_reconciliation_capacity(connection, reconciliation_limits).await?; + } + apply_migration_up(connection, registry, migration).await?; +"#; + const INNER_APPLY_THEN_RECHECK: &str = r#" apply_migration_up(connection, registry, migration).await?; + if matches!( + migration.hook, + EventStoreMigrationHook::Nip09ReconciliationV1 + | EventStoreMigrationHook::FoodAvailabilityProjectionV1 + ) { + validate_reconciliation_capacity(connection, reconciliation_limits).await?; + } +"#; + const OUTER_HOOK_SET: &str = r#"EventStoreMigrationHook::Nip09ReconciliationV1 + | EventStoreMigrationHook::FoodAvailabilityProjectionV1"#; + const INNER_HOOK_SET: &str = r#"EventStoreMigrationHook::Nip09ReconciliationV1 + | EventStoreMigrationHook::FoodAvailabilityProjectionV1"#; + + let root = repository_root(); + let source = read_regular_file(&root, "crates/event_store/src/schema.rs") + .expect("event-store schema source"); + let source = std::str::from_utf8(&source).expect("UTF-8 schema source"); + validate_source_capacity_authority(source).expect("governed source-capacity authority"); + + let outer_before_begin = format!( + "{OUTER_PREFLIGHT}\n let mut transaction = pool.begin_with(\"BEGIN IMMEDIATE\").await?;\n" + ); + let outer_after_begin = format!( + " let mut transaction = pool.begin_with(\"BEGIN IMMEDIATE\").await?;\n{OUTER_PREFLIGHT}" + ); + let mutations = [ + ( + "outer capacity removal", + source.replacen( + " validate_reconciliation_capacity(&mut connection, reconciliation_limits).await?;\n", + "", + 1, + ), + ), + ( + "inner capacity removal", + source.replacen( + " validate_reconciliation_capacity(connection, reconciliation_limits).await?;\n", + "", + 1, + ), + ), + ( + "unreachable outer decoy", + source.replacen(OUTER_PREFLIGHT, OUTER_DECOY, 1), + ), + ( + "outer preflight after BEGIN IMMEDIATE", + source.replacen(&outer_before_begin, &outer_after_begin, 1), + ), + ( + "inner recheck after migration DDL", + source.replacen( + INNER_RECHECK_AND_APPLY, + INNER_APPLY_THEN_RECHECK, + 1, + ), + ), + ( + "outer selector covers one hook only", + source.replacen( + OUTER_HOOK_SET, + "EventStoreMigrationHook::FoodAvailabilityProjectionV1", + 1, + ), + ), + ( + "inner recheck covers one hook only", + source.replacen( + INNER_HOOK_SET, + "EventStoreMigrationHook::Nip09ReconciliationV1", + 1, + ), + ), + ]; + + for (label, mutation) in mutations { + assert_ne!( + mutation, source, + "source-capacity mutation must apply: {label}" + ); + let error = validate_source_capacity_authority(&mutation) + .expect_err("source-capacity mutation must fail"); + assert!(error.contains("source-capacity"), "{label}: {error}"); + } + } + + #[test] + fn food_read_queries_require_the_exact_persisted_head_authority_join() { + let root = repository_root(); + let source = read_regular_file( + &root, + "crates/event_store/src/store/food_availability_projection_v1.rs", + ) + .expect("Food projection store source"); + let source = std::str::from_utf8(&source).expect("UTF-8 Food projection store source"); + validate_food_read_query_sources(source).expect("governed Food read queries"); + + for (predicate, replacement) in [ + ("source.singleton = 1 AND ", ""), + ( + "source.active_generation = projection.source_generation", + "source.active_generation != projection.source_generation", + ), + ("cursor.singleton = 1", "cursor.singleton = 2"), + ( + "cursor.source_generation = projection.source_generation", + "cursor.source_generation != projection.source_generation", + ), + ( + "head.source_generation = projection.source_generation", + "head.source_generation = source.active_generation", + ), + ("head.kind = 30402", "head.kind = 30403"), + ( + "head.pubkey = projection.pubkey", + "head.pubkey != projection.pubkey", + ), + ( + "head.d_tag = projection.d_tag", + "head.d_tag != projection.d_tag", + ), + ( + "head.raw_head_event_id = projection.event_id", + "head.raw_head_event_id != projection.event_id", + ), + ( + "head.raw_head_event_seq = projection.event_seq", + "head.raw_head_event_seq != projection.event_seq", + ), + ( + "head.raw_head_created_at = projection.created_at", + "head.raw_head_created_at != projection.created_at", + ), + ( + "head.admission_status = 'admitted'", + "head.admission_status != 'admitted'", + ), + ( + "head.admission_code IS NULL", + "head.admission_code IS NOT NULL", + ), + ( + "head.contract_id = projection.contract_id", + "head.contract_id != projection.contract_id", + ), + ( + "head.visibility = 'visible'", + "head.visibility != 'visible'", + ), + ( + "head.nip09_outcome = 'visible'", + "head.nip09_outcome != 'visible'", + ), + ] { + let mutated = source.replacen(predicate, replacement, 1); + assert_ne!( + mutated, source, + "predicate mutation must apply: {predicate}" + ); + let error = validate_food_read_query_sources(&mutated) + .expect_err("weakened head-authority predicate must fail"); + assert!(error.contains("fail-closed"), "{predicate}: {error}"); + } + + let unfenced_recent = source.replacen( + "FROM radroots_event_store_source_state AS source CROSS JOIN radroots_event_store_food_availability_read_v1 AS projection ON", + "FROM radroots_event_store_food_availability_read_v1 AS projection JOIN radroots_event_store_source_state AS source ON", + 1, + ); + assert_ne!( + unfenced_recent, source, + "recent source-first fence mutation must apply" + ); + let error = validate_food_read_query_sources(&unfenced_recent) + .expect_err("unfenced recent query must fail"); + assert!(error.contains("fail-closed"), "{error}"); + + let rerouted = source.replacen( + "sqlx::query(FOOD_AVAILABILITY_POINT_QUERY_V1)", + "sqlx::query(FOOD_AVAILABILITY_RECENT_QUERY_V1)", + 1, + ); + assert_ne!(rerouted, source, "query-route mutation must apply"); + let error = validate_food_read_query_sources(&rerouted) + .expect_err("wrong governed query route must fail"); + assert!(error.contains("exact governed query"), "{error}"); + + let dynamic = source.replacen( + "sqlx::query(FOOD_AVAILABILITY_POINT_QUERY_V1)", + "sqlx::query(query_text)", + 1, + ); + assert_ne!(dynamic, source, "dynamic-route mutation must apply"); + let error = + validate_food_read_query_sources(&dynamic).expect_err("arbitrary query path must fail"); + assert!(error.contains("exact governed query"), "{error}"); + } + + #[test] + fn food_projection_audit_authority_is_exact_and_fail_closed() { + let root = repository_root(); + let source = read_regular_file( + &root, + "crates/event_store/src/store/food_availability_projection_v1.rs", + ) + .expect("Food projection store source"); + let source = std::str::from_utf8(&source).expect("UTF-8 Food projection store source"); + validate_food_projection_audit_authority(source) + .expect("governed exhaustive Food projection audit"); + + let route_mutations = [ + ( + "audit transaction downgrade", + source.replacen( + "self.begin_write_transaction().await?", + "self.pool.begin().await?", + 1, + ), + ), + ( + "exhaustive audit visibility weakening", + source.replacen( + "pub(crate) async fn validate_food_availability_projection_hook_v1(", + "pub async fn validate_food_availability_projection_hook_v1(", + 1, + ), + ), + ( + "exhaustive audit early success", + source.replacen( + "pub(crate) async fn validate_food_availability_projection_hook_v1(\n connection: &mut SqliteConnection,\n) -> Result<(), RadrootsEventStoreError> {\n let state =", + "pub(crate) async fn validate_food_availability_projection_hook_v1(\n connection: &mut SqliteConnection,\n) -> Result<(), RadrootsEventStoreError> {\n return Ok(());\n let state =", + 1, + ), + ), + ( + "source-transition validation omission", + source.replacen( + " validate_projection_source_transition(connection, &projection).await?;\n", + "", + 1, + ), + ), + ( + "source-transition validation reorder", + source.replacen( + " validate_projection_source_transition(connection, &projection).await?;\n validate_fts_row(connection, &projection).await?;", + " validate_fts_row(connection, &projection).await?;\n validate_projection_source_transition(connection, &projection).await?;", + 1, + ), + ), + ( + "source-transition sequence rebind", + { + let (prefix, suffix) = source + .rsplit_once(".bind(projection.source_transition_seq())") + .expect("source-transition helper bind"); + format!("{prefix}.bind(projection.event_seq()){suffix}") + }, + ), + ( + "coordinate cardinality bypass", + source.replacen( + " let actual_row_count = i64::try_from(actual_coordinates.len())\n .map_err(|_| projection_drift(\"projection row count exceeds i64\"))?;", + " let actual_row_count = state.projected_row_count;", + 1, + ), + ), + ( + "sealed row-count comparison inversion", + source.replacen( + "if actual_row_count != state.projected_row_count", + "if actual_row_count == state.projected_row_count", + 1, + ), + ), + ( + "coordinate equality omission", + source.replacen( + " if actual_coordinates != expected_coordinates {\n return Err(projection_drift(\n \"projection coordinate witnesses do not equal the current admitted, visible FoodAvailability heads\",\n ));\n }\n", + "", + 1, + ), + ), + ( + "coordinate overwrite before equality", + source.replacen( + " if actual_coordinates != expected_coordinates {", + " actual_coordinates = expected_coordinates.clone();\n if actual_coordinates != expected_coordinates {", + 1, + ), + ), + ]; + for (label, mutation) in route_mutations { + assert_ne!(mutation, source, "audit-route mutation must apply: {label}"); + let error = match validate_food_projection_audit_authority(&mutation) { + Ok(()) => panic!("audit-route mutation must fail: {label}"), + Err(error) => error, + }; + assert!(!error.is_empty()); + } + + let expected_head_query = "SELECT pubkey, d_tag, raw_head_event_id, raw_head_event_seq, raw_head_created_at FROM radroots_event_store_addressable_head_state WHERE source_generation = ? AND kind = 30402 AND admission_status = 'admitted' AND admission_code IS NULL AND contract_id = ? AND visibility = 'visible' AND nip09_outcome = 'visible' ORDER BY pubkey, d_tag"; + for (needle, replacement) in [ + (", raw_head_created_at", ""), + ("source_generation = ?", "source_generation IS NOT NULL"), + ("kind = 30402", "kind = 30340"), + ( + "admission_status = 'admitted'", + "admission_status != 'admitted'", + ), + ("admission_code IS NULL", "admission_code IS NOT NULL"), + ("contract_id = ?", "contract_id IS NOT NULL"), + ("visibility = 'visible'", "visibility != 'visible'"), + ("nip09_outcome = 'visible'", "nip09_outcome != 'visible'"), + ("ORDER BY pubkey, d_tag", "ORDER BY d_tag, pubkey"), + ] { + let mutated_query = expected_head_query.replacen(needle, replacement, 1); + assert_ne!(mutated_query, expected_head_query); + let mutation = source.replacen(expected_head_query, &mutated_query, 1); + assert_ne!( + mutation, source, + "expected-head mutation must apply: {needle}" + ); + let error = validate_food_projection_audit_authority(&mutation) + .expect_err("expected-head mutation must fail"); + assert!(!error.is_empty()); + } + + let source_transition_query = "SELECT EXISTS(SELECT 1 FROM radroots_event_store_addressable_head_transition AS transition WHERE transition.transition_seq = ? AND transition.source_generation = ? AND transition.source_generation = (SELECT active_generation FROM radroots_event_store_source_state WHERE singleton = 1) AND transition.kind = 30402 AND transition.pubkey = ? AND transition.d_tag = ? AND transition.raw_head_event_id = ? AND transition.raw_head_event_seq = ? AND transition.raw_head_created_at = ? AND transition.visible_event_id = ? AND transition.visible_event_seq = ? AND transition.admission_status = 'admitted' AND transition.admission_code IS NULL AND transition.contract_id = ? AND transition.visibility = 'visible' AND transition.nip09_outcome = 'visible' AND transition.raw_head_decision IN ('baseline_rebuild', 'applied') AND transition.transition_seq = (SELECT MAX(candidate.transition_seq) FROM radroots_event_store_addressable_head_transition AS candidate WHERE candidate.source_generation = transition.source_generation AND candidate.kind = transition.kind AND candidate.pubkey = transition.pubkey AND candidate.d_tag = transition.d_tag AND candidate.raw_head_decision IN ('baseline_rebuild', 'applied')))"; + for (needle, replacement) in [ + ( + "transition.transition_seq = ?", + "transition.transition_seq > 0", + ), + ( + "transition.source_generation = ?", + "transition.source_generation IS NOT NULL", + ), + ( + "transition.source_generation = (SELECT active_generation FROM radroots_event_store_source_state WHERE singleton = 1)", + "transition.source_generation IS NOT NULL", + ), + ("transition.kind = 30402", "transition.kind = 30340"), + ("transition.pubkey = ?", "transition.pubkey IS NOT NULL"), + ("transition.d_tag = ?", "transition.d_tag IS NOT NULL"), + ( + "transition.raw_head_event_id = ?", + "transition.raw_head_event_id IS NOT NULL", + ), + ( + "transition.raw_head_event_seq = ?", + "transition.raw_head_event_seq > 0", + ), + ( + "transition.raw_head_created_at = ?", + "transition.raw_head_created_at > 0", + ), + ( + "transition.visible_event_id = ?", + "transition.visible_event_id IS NOT NULL", + ), + ( + "transition.visible_event_seq = ?", + "transition.visible_event_seq > 0", + ), + ( + "transition.admission_status = 'admitted'", + "transition.admission_status != 'admitted'", + ), + ( + "transition.admission_code IS NULL", + "transition.admission_code IS NOT NULL", + ), + ( + "transition.contract_id = ?", + "transition.contract_id IS NOT NULL", + ), + ( + "transition.visibility = 'visible'", + "transition.visibility != 'visible'", + ), + ( + "transition.nip09_outcome = 'visible'", + "transition.nip09_outcome != 'visible'", + ), + ( + "transition.raw_head_decision IN ('baseline_rebuild', 'applied')", + "transition.raw_head_decision = 'applied'", + ), + ( + "transition.transition_seq = (SELECT MAX(candidate.transition_seq)", + "transition.transition_seq <= (SELECT MAX(candidate.transition_seq)", + ), + ] { + let mutated_query = source_transition_query.replacen(needle, replacement, 1); + assert_ne!(mutated_query, source_transition_query); + let mutation = source.replacen(source_transition_query, &mutated_query, 1); + assert_ne!( + mutation, source, + "source-transition mutation must apply: {needle}" + ); + let error = validate_food_projection_audit_authority(&mutation) + .expect_err("source-transition mutation must fail"); + assert!(!error.is_empty()); + } + } + + #[test] + fn public_api_is_exhaustive_and_structurally_reexported() { + let root = repository_root(); + let model_bytes = + read_regular_file(&root, EVENT_STORE_MODEL_RELATIVE).expect("event-store model source"); + let model_source = std::str::from_utf8(&model_bytes).expect("UTF-8 model source"); + let lib_bytes = + read_regular_file(&root, EVENT_STORE_LIB_RELATIVE).expect("event-store lib source"); + let lib_source = std::str::from_utf8(&lib_bytes).expect("UTF-8 lib source"); + let advertised = PUBLIC_API + .iter() + .map(|name| (*name).to_owned()) + .collect::<Vec<_>>(); + validate_public_api_sources(model_source, lib_source, &advertised) + .expect("governed successor public API"); + + let mut omitted = advertised.clone(); + omitted.retain(|name| name != "RadrootsAddressableTransitionCauseV1"); + let error = validate_public_api_sources(model_source, lib_source, &omitted) + .expect_err("omitted manifest symbol must fail"); + assert!(error.contains("PUBLIC_API is not exhaustive"), "{error}"); + + let removed = lib_source.replacen("RadrootsAddressableTransitionCauseV1,", "", 1); + assert_ne!(removed, lib_source, "removal mutation must apply"); + let error = validate_public_api_sources(model_source, &removed, &advertised) + .expect_err("removed crate-root export must fail"); + assert!(error.contains("does not re-export"), "{error}"); + + let renamed = lib_source.replacen( + "RadrootsAddressableTransitionCauseV1", + "RadrootsAddressableTransitionCauseV1 as RadrootsAddressableTransitionCauseRenamedV1", + 1, + ); + assert_ne!(renamed, lib_source, "rename mutation must apply"); + let error = validate_public_api_sources(model_source, &renamed, &advertised) + .expect_err("renamed crate-root export must fail"); + assert!(error.contains("non-renamed"), "{error}"); + + let schema_bytes = canonical_json_bytes(&manifest_schema()).expect("schema bytes"); + let mut manifest = describe_manifest(&root, &schema_bytes).expect("manifest"); + manifest + .public_api + .retain(|name| name != "RadrootsAddressableTransitionCauseV1"); + let error = validate_manifest_shape(&manifest) + .expect_err("generated manifest with an omitted public symbol must fail"); + assert!(error.contains("successor"), "{error}"); + } + + #[test] + fn result_vector_is_strict_canonical_and_complete() { + let root = repository_root(); + let bytes = read_regular_file(&root, RESULT_VECTOR_CANONICAL_RELATIVE).expect("vector"); + let vector: ProjectionResultVector = serde_json::from_slice(&bytes).expect("strict vector"); + validate_canonical_json(RESULT_VECTOR_CANONICAL_RELATIVE, &bytes, &vector) + .expect("canonical vector"); + validate_result_vector(&vector).expect("complete vector"); + + let mut value: Value = serde_json::from_slice(&bytes).expect("vector value"); + value["cases"][2]["expected"]["projection"] + .as_object_mut() + .expect("projection") + .remove("quantity_amount"); + let missing = serde_json::from_value::<ProjectionResultVector>(value) + .expect_err("required nullable field must be present"); + assert!(missing.to_string().contains("quantity_amount")); + + let mut invalid_sequence: Value = + serde_json::from_slice(&bytes).expect("vector value for sequence mutation"); + invalid_sequence["cases"][0]["expected"]["transition_page"]["transitions"][0]["transition_seq"] = + json!(2); + let invalid_sequence: ProjectionResultVector = + serde_json::from_value(invalid_sequence).expect("typed sequence mutation"); + let error = validate_result_vector(&invalid_sequence) + .expect_err("incorrect scoped transition sequence must fail"); + assert!(error.contains("exactly skip unrelated"), "{error}"); + + let mut invalid_generation: Value = + serde_json::from_slice(&bytes).expect("vector value for generation mutation"); + invalid_generation["cases"][0]["expected"]["transition_page"]["transitions"][0]["source_generation"] = + json!("fixture-generation"); + let invalid_generation: ProjectionResultVector = + serde_json::from_value(invalid_generation).expect("typed generation mutation"); + let error = validate_result_vector(&invalid_generation) + .expect_err("non-authoritative source-generation sentinel must fail"); + assert!(error.contains("invalid authority witness"), "{error}"); + + let mut missing_cause: Value = + serde_json::from_slice(&bytes).expect("vector value for cause mutation"); + missing_cause["cases"][0]["expected"]["transition_page"]["transitions"][0]["cause_event"] = + Value::Null; + let missing_cause: ProjectionResultVector = + serde_json::from_value(missing_cause).expect("typed nullable cause mutation"); + let error = validate_result_vector(&missing_cause) + .expect_err("incremental transition without a cause must fail"); + assert!(error.contains("must authenticate its cause"), "{error}"); + + let mut stale_canonical_digest: Value = + serde_json::from_slice(&bytes).expect("vector value for digest mutation"); + stale_canonical_digest["cases"][0]["expected"]["transition_page"]["transitions"][0]["canonical_visible_event"] + ["raw_json_sha256"] = json!("00".repeat(32)); + let stale_canonical_digest: ProjectionResultVector = + serde_json::from_value(stale_canonical_digest).expect("typed digest mutation"); + let error = validate_result_vector(&stale_canonical_digest) + .expect_err("stale canonical raw JSON digest must fail"); + assert!( + error.contains("canonical raw JSON digest is stale"), + "{error}" + ); + + let mut mislabeled_operational: Value = + serde_json::from_slice(&bytes).expect("vector value for Operational role mutation"); + mislabeled_operational["cases"][6]["events"][1]["role"] = json!("scoped_food"); + let mislabeled_operational: ProjectionResultVector = + serde_json::from_value(mislabeled_operational) + .expect("typed Operational role mutation"); + let error = validate_result_vector(&mislabeled_operational) + .expect_err("Operational head mislabeled as Food must fail"); + assert!(error.contains("incoherent ingest witness"), "{error}"); + + let mut missing_historical: Value = + serde_json::from_slice(&bytes).expect("vector value for historical witness mutation"); + missing_historical["cases"][7]["expected"]["historical_visibility_witnesses"] = json!([]); + let missing_historical: ProjectionResultVector = + serde_json::from_value(missing_historical).expect("typed historical witness mutation"); + let error = validate_result_vector(&missing_historical) + .expect_err("missing divergent historical payload witnesses must fail"); + assert!(error.contains("historical visibility witnesses"), "{error}"); + + let mut mislabeled_unrelated: Value = + serde_json::from_slice(&bytes).expect("vector value for unrelated role mutation"); + mislabeled_unrelated["cases"][8]["events"][1]["role"] = json!("scoped_food"); + mislabeled_unrelated["cases"][8]["events"][1]["expected_ingest"]["contract_id"] = + json!(FOOD_CONTRACT_ID); + let mislabeled_unrelated: ProjectionResultVector = + serde_json::from_value(mislabeled_unrelated).expect("typed unrelated role mutation"); + let error = validate_result_vector(&mislabeled_unrelated) + .expect_err("unrelated addressable input mislabeled as scoped must fail"); + assert!(error.contains("scoped input does not match"), "{error}"); + + let mut leaked_unrelated_transition: Value = + serde_json::from_slice(&bytes).expect("vector value for feed-scope mutation"); + let mut leaked = + leaked_unrelated_transition["cases"][8]["expected"]["transition_page"]["transitions"] + [0] + .clone(); + leaked["transition_seq"] = json!(2); + leaked_unrelated_transition["cases"][8]["expected"]["transition_page"]["transitions"] + .as_array_mut() + .expect("transition array") + .insert(1, leaked); + let leaked_unrelated_transition: ProjectionResultVector = + serde_json::from_value(leaked_unrelated_transition).expect("typed feed-scope mutation"); + let error = validate_result_vector(&leaked_unrelated_transition) + .expect_err("unrelated addressable traffic in the scoped feed must fail"); + assert!(error.contains("exactly skip unrelated"), "{error}"); + + let mut stale_high_water: Value = + serde_json::from_slice(&bytes).expect("vector value for high-water mutation"); + stale_high_water["cases"][8]["expected"]["transition_page"]["source_high_water"] = json!(2); + let stale_high_water: ProjectionResultVector = + serde_json::from_value(stale_high_water).expect("typed high-water mutation"); + let error = validate_result_vector(&stale_high_water) + .expect_err("cursor high-water that omits unrelated traffic must fail"); + assert!(error.contains("complete active-source interval"), "{error}"); + } + + #[test] + fn schema_rejects_unknown_manifest_fields() { + let root = repository_root(); + let schema = manifest_schema(); + let schema_bytes = canonical_json_bytes(&schema).expect("schema bytes"); + let manifest = describe_manifest(&root, &schema_bytes).expect("manifest"); + let mut value = serde_json::to_value(manifest).expect("manifest value"); + value + .as_object_mut() + .expect("manifest object") + .insert("unknown".to_owned(), Value::Bool(true)); + let error = validate_manifest_json_schema(&schema, &value) + .expect_err("unknown manifest field must fail"); + assert!(error.contains("Additional properties"), "{error}"); + } + + #[test] + fn generated_descriptor_covers_runtime_pointer_constants() { + let root = repository_root(); + let schema_bytes = canonical_json_bytes(&manifest_schema()).expect("schema bytes"); + let manifest = describe_manifest(&root, &schema_bytes).expect("manifest"); + let manifest_bytes = canonical_json_bytes(&manifest).expect("manifest bytes"); + let digest = sha256_hex(&manifest_bytes); + let descriptor = generated_descriptor(&manifest, &manifest_bytes, &digest); + assert_eq!(manifest.migration.schema_sha256, SCHEMA_SHA256); + for name in [ + "FOOD_AVAILABILITY_PROJECTION_MANIFEST_SCHEMA_VERSION", + "FOOD_AVAILABILITY_PROJECTION_CONTRACT_ID", + "FOOD_AVAILABILITY_PROJECTION_HOOK_ID", + "FOOD_AVAILABILITY_PROJECTION_MIGRATION_VERSION", + "FOOD_AVAILABILITY_PROJECTION_MIGRATION_UP_SHA256", + "FOOD_AVAILABILITY_PROJECTION_MIGRATION_DOWN_SHA256", + "FOOD_AVAILABILITY_PROJECTION_SCOPE_KINDS", + "FOOD_AVAILABILITY_PROJECTION_SCOPE_FINGERPRINT_SHA256", + "FOOD_AVAILABILITY_PROJECTION_PREDECESSOR_MANIFEST_SHA256", + "FOOD_AVAILABILITY_PROJECTION_RESULT_VECTOR_EXECUTOR_SHA256", + ] { + assert!(descriptor.contains(name), "missing {name}"); + } + for (name, value) in [ + ("FOOD_AVAILABILITY_PROJECTION_HOOK_ID", manifest.hook_id), + ( + "FOOD_AVAILABILITY_PROJECTION_MIGRATION_NAME", + manifest.migration.name, + ), + ] { + let expected = format!("pub(crate) const {name}: &str = {value:?};\n"); + assert!( + descriptor.contains(&expected), + "{name} must use the rustfmt-stable one-line assignment" + ); + } + syn::parse_file(&descriptor).expect("generated descriptor parses as Rust"); + } +} diff --git a/tools/xtask/src/contract/nip09_reconciliation.rs b/tools/xtask/src/contract/nip09_reconciliation.rs @@ -1,3 +1,5 @@ +#![allow(dead_code)] + use super::artifact_bundle::{ GeneratedArtifact, read_regular_file, validate_workspace_path, with_artifact_bundle_transaction, }; @@ -216,6 +218,83 @@ const GENERATED_DESCRIPTOR_RELATIVE: &str = "crates/event_store/src/generated/nip09_reconciliation_manifest.rs"; const WRITE_COMMAND: &str = "cargo xtask contract nip09-reconciliation-manifest --write"; +const IMMUTABLE_MANIFEST_BYTES: &[u8] = include_bytes!( + "../../../../crates/event_store/contracts/nip09_reconciliation_v1.manifest.json" +); +const IMMUTABLE_MANIFEST_SCHEMA_BYTES: &[u8] = include_bytes!( + "../../../../crates/event_store/contracts/nip09_reconciliation_v1.manifest.schema.json" +); +const IMMUTABLE_MANIFEST_SHA256_BYTES: &[u8] = include_bytes!( + "../../../../crates/event_store/contracts/nip09_reconciliation_v1.manifest.sha256" +); +const IMMUTABLE_GENERATED_DESCRIPTOR_BYTES: &[u8] = + include_bytes!("../../../../crates/event_store/src/generated/nip09_reconciliation_manifest.rs"); + +#[derive(Clone, Copy)] +struct ImmutableArtifactSpec { + relative: &'static str, + byte_length: usize, + sha256: &'static str, +} + +const IMMUTABLE_PREDECESSOR_ARTIFACTS: [ImmutableArtifactSpec; 11] = [ + ImmutableArtifactSpec { + relative: MANIFEST_RELATIVE, + byte_length: 537_538, + sha256: "74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77", + }, + ImmutableArtifactSpec { + relative: MANIFEST_SCHEMA_RELATIVE, + byte_length: 28_805, + sha256: "eac277641b197ec2e7690ae0a513640a4d93d5be713f1e96e9932cbd75cbfc58", + }, + ImmutableArtifactSpec { + relative: MANIFEST_SHA256_RELATIVE, + byte_length: 65, + sha256: "1b4513933ecc96d7f07e48e27bd029bb2b791ebe9771ce516ba2cb3bb7b24080", + }, + ImmutableArtifactSpec { + relative: GENERATED_DESCRIPTOR_RELATIVE, + byte_length: 586_039, + sha256: "406a760e9bed1e8fc89c8e7ae0976c7eff844de7427a3f473528c895439500b3", + }, + ImmutableArtifactSpec { + relative: RESULT_VECTOR_CANONICAL_RELATIVE, + byte_length: 10_405, + sha256: "31cd9507734ff3308436881622a626b9782b75b548d9f5e159e4125621855b9c", + }, + ImmutableArtifactSpec { + relative: RESULT_VECTOR_MIRROR_RELATIVE, + byte_length: 10_405, + sha256: "31cd9507734ff3308436881622a626b9782b75b548d9f5e159e4125621855b9c", + }, + ImmutableArtifactSpec { + relative: RESULT_VECTOR_EXECUTOR_RELATIVE, + byte_length: 18_446, + sha256: "ca2a2bf54062aa6ddf2e553fd624c7217a01ad56309487ce73fa58c47c06c208", + }, + ImmutableArtifactSpec { + relative: MIGRATION_V1_UP_RELATIVE, + byte_length: 10_712, + sha256: MIGRATION_V1_UP_SHA256, + }, + ImmutableArtifactSpec { + relative: MIGRATION_V1_DOWN_RELATIVE, + byte_length: 522, + sha256: MIGRATION_V1_DOWN_SHA256, + }, + ImmutableArtifactSpec { + relative: MIGRATION_UP_RELATIVE, + byte_length: 81_614, + sha256: "0c1730ff36eaebd285f9c0c94b9b7346af60266afa55c24a18e30446d369581a", + }, + ImmutableArtifactSpec { + relative: MIGRATION_DOWN_RELATIVE, + byte_length: 4_807, + sha256: "c51a099d9501f1e692c13d2226296a68ed9e6bfa5e8e46b2f12c6574dbe59e31", + }, +]; + const RUNTIME_DEPENDENCY_ALGORITHM: &str = "cargo_lock_resolved_semantic_subgraph_v1"; const RUST_PRODUCTION_AST_SHA256_ALGORITHM: &str = "rust_production_ast_sha256_v1"; const RUST_FULL_AST_SHA256_ALGORITHM: &str = "rust_full_ast_sha256_v1"; @@ -254,6 +333,30 @@ const PRIVILEGED_STORE_MODULE_NAMES: [&str; 6] = [ "protocol_reconciliation_v1", "protocol_storage_v1", ]; +const SUCCESSOR_08C_STORE_MODULE_SOURCES: [&str; 5] = [ + "crates/event_store/src/store/addressable_transition_feed_v1.rs", + "crates/event_store/src/store/current_visibility_v1.rs", + "crates/event_store/src/store/food_availability_projection_v1.rs", + "crates/event_store/src/store/post_core_extensions_v2.rs", + "crates/event_store/src/store/post_core_storage_v2.rs", +]; +const SUCCESSOR_08C_STORE_MODULE_NAMES: [&str; 5] = [ + "addressable_transition_feed_v1", + "current_visibility_v1", + "food_availability_projection_v1", + "post_core_extensions_v2", + "post_core_storage_v2", +]; +const SUCCESSOR_08C_EXCLUSIVE_SOURCE_PATHS: [&str; 8] = [ + "crates/event_store/src/model/addressable_transition_feed_v1.rs", + "crates/event_store/src/model/current_visibility_v1.rs", + "crates/event_store/src/model/food_availability_projection_v1.rs", + "crates/event_store/src/store/addressable_transition_feed_v1.rs", + "crates/event_store/src/store/current_visibility_v1.rs", + "crates/event_store/src/store/food_availability_projection_v1.rs", + "crates/event_store/src/store/post_core_extensions_v2.rs", + "crates/event_store/src/store/post_core_storage_v2.rs", +]; const EVENT_STORE_FIXED_PUBLIC_REEXPORTS: [&str; 40] = [ "error::RadrootsEventStoreError", "error::RadrootsEventStoreReconciliationResource", @@ -296,6 +399,40 @@ const EVENT_STORE_FIXED_PUBLIC_REEXPORTS: [&str; 40] = [ "store::RadrootsTransportObservationRow", "store::inspect_event_store_status", ]; +const SUCCESSOR_08C_PUBLIC_REEXPORTS: [&str; 32] = [ + "model::RADROOTS_ADDRESSABLE_TRANSITION_CURSOR_JSON_MAX_BYTES_V1", + "model::RADROOTS_ADDRESSABLE_TRANSITION_D_TAG_MAX_BYTES_V1", + "model::RADROOTS_ADDRESSABLE_TRANSITION_FEED_VERSION_V1", + "model::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_LIMIT_MAX_V1", + "model::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_RAW_JSON_MAX_BYTES_V1", + "model::RADROOTS_ADDRESSABLE_TRANSITION_PAGE_SCAN_MAX_V1", + "model::RADROOTS_ADDRESSABLE_TRANSITION_SCOPE_KIND_MAX_V1", + "model::RADROOTS_FOOD_AVAILABILITY_PROJECTION_APPLY_PAGE_LIMIT_V1", + "model::RADROOTS_FOOD_AVAILABILITY_PROJECTION_VERSION_V1", + "model::RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_BYTES_V1", + "model::RADROOTS_FOOD_AVAILABILITY_SEARCH_QUERY_MAX_TERMS_V1", + "model::RadrootsAddressableTransitionCauseV1", + "model::RadrootsAddressableTransitionCoordinateV1", + "model::RadrootsAddressableTransitionCursorV1", + "model::RadrootsAddressableTransitionEventReferenceV1", + "model::RadrootsAddressableTransitionOriginV1", + "model::RadrootsAddressableTransitionPageV1", + "model::RadrootsAddressableTransitionRawHeadDecisionV1", + "model::RadrootsAddressableTransitionScopeFingerprintV1", + "model::RadrootsAddressableTransitionScopeV1", + "model::RadrootsAddressableTransitionV1", + "model::RadrootsAddressableTransitionVisibilityV1", + "model::RadrootsCurrentEventVisibilityV1", + "model::RadrootsCurrentVisibilityDecisionV1", + "model::RadrootsFoodAvailabilitySearchQueryV1", + "model::RadrootsFoodAvailabilityStatusFilterV1", + "model::RadrootsNip09SuppressionEvidenceV1", + "model::RadrootsNip09SuppressionOutcome", + "model::RadrootsNip09SuppressionReason", + "model::RadrootsStoreProducedCanonicalEventV1", + "model::RadrootsStoredFoodAvailabilityImageV1", + "model::RadrootsStoredFoodAvailabilityV1", +]; const POST_CORE_STORAGE_METHODS: [&str; 4] = [ "new", "quarantine_trade", @@ -2054,8 +2191,24 @@ where pub(crate) fn write_nip09_reconciliation_manifest(workspace_root: &Path) -> Result<(), String> { with_artifact_bundle_transaction(workspace_root, |transaction| { - let artifacts = expected_artifacts(workspace_root)?; - transaction.write(artifacts)?; + transaction.write(vec![ + GeneratedArtifact { + relative: MANIFEST_RELATIVE, + contents: IMMUTABLE_MANIFEST_BYTES.to_vec(), + }, + GeneratedArtifact { + relative: MANIFEST_SCHEMA_RELATIVE, + contents: IMMUTABLE_MANIFEST_SCHEMA_BYTES.to_vec(), + }, + GeneratedArtifact { + relative: MANIFEST_SHA256_RELATIVE, + contents: IMMUTABLE_MANIFEST_SHA256_BYTES.to_vec(), + }, + GeneratedArtifact { + relative: GENERATED_DESCRIPTOR_RELATIVE, + contents: IMMUTABLE_GENERATED_DESCRIPTOR_BYTES.to_vec(), + }, + ])?; validate_nip09_reconciliation_manifest_under_lock(workspace_root) }) } @@ -2066,7 +2219,9 @@ pub(crate) fn validate_nip09_reconciliation_manifest(workspace_root: &Path) -> R }) } -fn validate_nip09_reconciliation_manifest_under_lock(workspace_root: &Path) -> Result<(), String> { +pub(super) fn validate_nip09_reconciliation_manifest_under_lock( + workspace_root: &Path, +) -> Result<(), String> { let manifest_bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; let manifest_value: Value = serde_json::from_slice(&manifest_bytes) .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; @@ -2079,7 +2234,6 @@ fn validate_nip09_reconciliation_manifest_under_lock(workspace_root: &Path) -> R .map_err(|error| format!("parse {MANIFEST_SCHEMA_RELATIVE}: {error}"))?; validate_canonical_json(MANIFEST_SCHEMA_RELATIVE, &schema_bytes, &schema)?; validate_manifest_json_schema(&schema, &manifest_value)?; - validate_manifest_shape(workspace_root, &manifest)?; let digest_bytes = read_regular_file(workspace_root, MANIFEST_SHA256_RELATIVE)?; validate_digest_sidecar(MANIFEST_SHA256_RELATIVE, &digest_bytes)?; let actual_digest = std::str::from_utf8(&digest_bytes[..64]) @@ -2089,27 +2243,265 @@ fn validate_nip09_reconciliation_manifest_under_lock(workspace_root: &Path) -> R "{MANIFEST_SHA256_RELATIVE} must match the checked-in manifest bytes" )); } - let descriptor_bytes = read_regular_file(workspace_root, GENERATED_DESCRIPTOR_RELATIVE)?; - let expected = expected_artifacts(workspace_root)?; - let expected_by_path = expected + if manifest.schema_version != SCHEMA_VERSION + || manifest.hook_id != HOOK_ID + || manifest.migration.version != MIGRATION_VERSION + || manifest.migration.name != MIGRATION_NAME + || manifest.migration.up_sha256 != IMMUTABLE_PREDECESSOR_ARTIFACTS[9].sha256 + || manifest.migration.down_sha256 != IMMUTABLE_PREDECESSOR_ARTIFACTS[10].sha256 + || manifest.migration.schema_sha256 != SCHEMA_SHA256 + || manifest.profile.reconciliation_version != RECONCILIATION_VERSION + || manifest.profile.addressable_feed_version != ADDRESSABLE_FEED_VERSION + || manifest.profile.event_contract_registry_version != EVENT_CONTRACT_REGISTRY_VERSION + { + return Err(format!( + "{MANIFEST_RELATIVE} does not describe the immutable NIP-09 predecessor identity" + )); + } + + let vector_bytes = read_regular_file(workspace_root, RESULT_VECTOR_CANONICAL_RELATIVE)?; + let mirror_bytes = read_regular_file(workspace_root, RESULT_VECTOR_MIRROR_RELATIVE)?; + if vector_bytes != mirror_bytes { + return Err(format!( + "{RESULT_VECTOR_MIRROR_RELATIVE} must exactly mirror {RESULT_VECTOR_CANONICAL_RELATIVE}" + )); + } + let vector: ReconciliationResultVector = serde_json::from_slice(&vector_bytes) + .map_err(|error| format!("parse {RESULT_VECTOR_CANONICAL_RELATIVE}: {error}"))?; + validate_canonical_json(RESULT_VECTOR_CANONICAL_RELATIVE, &vector_bytes, &vector)?; + validate_result_vector(&vector)?; + + for artifact in IMMUTABLE_PREDECESSOR_ARTIFACTS { + let bytes = read_regular_file(workspace_root, artifact.relative)?; + if bytes.len() != artifact.byte_length || sha256_hex(&bytes) != artifact.sha256 { + return Err(format!( + "immutable NIP-09 predecessor artifact {} does not match its authenticated byte identity", + artifact.relative + )); + } + } + + Ok(()) +} + +pub(super) fn validate_nip09_predecessor_production_sources_under_lock( + workspace_root: &Path, + superseded_paths: &[&str], +) -> Result<(), String> { + let manifest_bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; + let manifest: Nip09ReconciliationManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; + let superseded = superseded_paths.iter().copied().collect::<BTreeSet<_>>(); + if superseded.len() != superseded_paths.len() { + return Err("successor predecessor-source supersession paths must be unique".to_owned()); + } + + let mut predecessor_paths = manifest + .frozen_sources .iter() - .map(|artifact| (artifact.relative, artifact.contents.as_slice())) - .collect::<BTreeMap<_, _>>(); - for (relative, actual) in [ - (MANIFEST_RELATIVE, manifest_bytes), - (MANIFEST_SCHEMA_RELATIVE, schema_bytes), - (MANIFEST_SHA256_RELATIVE, digest_bytes), - (GENERATED_DESCRIPTOR_RELATIVE, descriptor_bytes), - ] { - let expected = expected_by_path - .get(relative) - .ok_or_else(|| format!("missing generated artifact specification for {relative}"))?; - if actual.as_slice() != *expected { - return Err(stale_error(relative)); + .map(|source| source.path.as_str()) + .chain( + manifest + .source_route_witnesses + .iter() + .map(|source| source.path.as_str()), + ) + .chain( + manifest + .rust_item_witnesses + .iter() + .map(|source| source.path.as_str()), + ) + .chain( + manifest + .rust_fragment_witnesses + .iter() + .map(|source| source.path.as_str()), + ) + .chain( + manifest + .impl_resolution_witness + .impls + .iter() + .map(|source| source.path.as_str()), + ) + .collect::<BTreeSet<_>>(); + predecessor_paths.extend([ + POST_CORE_CAPABILITIES_SOURCE_RELATIVE, + POST_CORE_DISPATCHER_SOURCE_RELATIVE, + POST_CORE_EXTENSION_SOURCE_RELATIVE, + POST_CORE_STORAGE_SOURCE_RELATIVE, + ]); + if let Some(path) = superseded + .iter() + .find(|path| !predecessor_paths.contains(**path)) + { + return Err(format!( + "successor supersession path `{path}` is not a predecessor-bound production source" + )); + } + + if manifest.frozen_sources.len() != FROZEN_SOURCE_SPECS.len() { + return Err("immutable predecessor frozen-source inventory is incomplete".to_owned()); + } + for (expected, spec) in manifest.frozen_sources.iter().zip(FROZEN_SOURCE_SPECS) { + if expected.role != spec.role || expected.path != spec.path { + return Err(format!( + "immutable predecessor frozen-source inventory drifted at `{}`", + spec.path + )); + } + if superseded.contains(spec.path) { + continue; + } + let current = describe_frozen_source(workspace_root, *spec)?; + require_predecessor_frozen_source_match(expected, &current)?; + } + + if manifest.source_route_witnesses.len() != SOURCE_ROUTE_WITNESS_SPECS.len() { + return Err("immutable predecessor source-route inventory is incomplete".to_owned()); + } + for (expected, spec) in manifest + .source_route_witnesses + .iter() + .zip(SOURCE_ROUTE_WITNESS_SPECS) + { + if expected.role != spec.role || expected.path != spec.path { + return Err(format!( + "immutable predecessor source-route inventory drifted at `{}`", + spec.path + )); + } + if superseded.contains(spec.path) { + continue; + } + let current = describe_source_route_witness(workspace_root, *spec)?; + if current != *expected { + return Err(format!( + "unchanged predecessor source-route authority `{}` drifted", + spec.path + )); + } + } + + validate_predecessor_witness_subset( + "Rust item", + &manifest.rust_item_witnesses, + superseded_paths, + || describe_rust_item_witnesses(workspace_root), + |witness| witness.path.as_str(), + )?; + validate_predecessor_witness_subset( + "Rust fragment", + &manifest.rust_fragment_witnesses, + superseded_paths, + || describe_rust_fragment_witnesses(workspace_root), + |witness| witness.path.as_str(), + )?; + + let predecessor_impl_paths = manifest + .impl_resolution_witness + .impls + .iter() + .map(|item| item.path.as_str()) + .collect::<BTreeSet<_>>(); + let expected_impls = manifest + .impl_resolution_witness + .impls + .iter() + .filter(|item| !superseded.contains(item.path.as_str())) + .cloned() + .collect::<Vec<_>>(); + if !expected_impls.is_empty() { + let current_impls = describe_impl_resolution_witness(workspace_root)? + .impls + .into_iter() + .filter(|item| { + predecessor_impl_paths.contains(item.path.as_str()) + && !superseded.contains(item.path.as_str()) + }) + .collect::<Vec<_>>(); + if current_impls != expected_impls { + return Err( + "unchanged predecessor impl-resolution authority drifted from the immutable manifest" + .to_owned(), + ); } } + let post_core_paths = [ + POST_CORE_CAPABILITIES_SOURCE_RELATIVE, + POST_CORE_DISPATCHER_SOURCE_RELATIVE, + POST_CORE_EXTENSION_SOURCE_RELATIVE, + POST_CORE_STORAGE_SOURCE_RELATIVE, + ]; + let superseded_post_core_count = post_core_paths + .iter() + .filter(|path| superseded.contains(**path)) + .count(); + if superseded_post_core_count == 0 { + let current = describe_post_core_sql_capability(workspace_root)?; + if current != manifest.post_core_sql_capability { + return Err( + "unchanged predecessor post-core SQL capability drifted from the immutable manifest" + .to_owned(), + ); + } + } else if superseded_post_core_count != post_core_paths.len() { + return Err( + "the successor must supersede either every or no predecessor post-core capability source" + .to_owned(), + ); + } + + validate_local_runtime_sources(workspace_root, &manifest.local_runtime_sources)?; + Ok(()) +} + +fn require_predecessor_frozen_source_match( + expected: &FrozenSourceDescriptor, + current: &FrozenSourceDescriptor, +) -> Result<(), String> { + if current != expected { + return Err(format!( + "unchanged predecessor frozen-source authority `{}` drifted from the immutable manifest", + expected.path + )); + } + Ok(()) +} + +fn validate_predecessor_witness_subset<T, Describe, PathOf>( + label: &str, + expected: &[T], + superseded_paths: &[&str], + describe: Describe, + path_of: PathOf, +) -> Result<(), String> +where + T: Clone + PartialEq, + Describe: FnOnce() -> Result<Vec<T>, String>, + PathOf: Fn(&T) -> &str, +{ + let superseded = superseded_paths.iter().copied().collect::<BTreeSet<_>>(); + let expected = expected + .iter() + .filter(|witness| !superseded.contains(path_of(witness))) + .cloned() + .collect::<Vec<_>>(); + if expected.is_empty() { + return Ok(()); + } + let current = describe()? + .into_iter() + .filter(|witness| !superseded.contains(path_of(witness))) + .collect::<Vec<_>>(); + if current != expected { + return Err(format!( + "unchanged predecessor {label} witnesses drifted from the immutable manifest" + )); + } Ok(()) } @@ -2274,6 +2666,13 @@ fn describe_frozen_source( spec: FrozenSourceSpec, ) -> Result<FrozenSourceDescriptor, String> { let bytes = read_regular_file(workspace_root, spec.path)?; + describe_frozen_source_bytes(spec, &bytes) +} + +fn describe_frozen_source_bytes( + spec: FrozenSourceSpec, + bytes: &[u8], +) -> Result<FrozenSourceDescriptor, String> { let canonical = canonical_rust_ast(spec.path, &bytes, RustAstProfile::Production)?; let file = syn::parse_file( std::str::from_utf8(&canonical) @@ -2488,15 +2887,33 @@ fn expected_event_store_migration_compiler_inputs( )); } if version > 2 { - for (field_name, expected) in [ - ("hook", "EventStoreMigrationHook::None"), - ("hook_manifest_sha256", "None"), - ("event_contract_registry_version", "None"), - ] { + let expected_authority = if version == 3 && name == "food_availability_projection" { + [ + ( + "hook", + "EventStoreMigrationHook::FoodAvailabilityProjectionV1", + ), + ( + "hook_manifest_sha256", + "Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256,)", + ), + ( + "event_contract_registry_version", + "Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION,)", + ), + ] + } else { + [ + ("hook", "EventStoreMigrationHook::None"), + ("hook_manifest_sha256", "None"), + ("event_contract_registry_version", "None"), + ] + }; + for (field_name, expected) in expected_authority { let actual = compact_tokens(field(relative, entry, field_name)?); if actual != expected { return Err(format!( - "{relative} post-v2 migration {version} must remain hookless: field `{field_name}` expected `{expected}`, found `{actual}`" + "{relative} post-v2 migration {version} has invalid versioned hook authority: field `{field_name}` expected `{expected}`, found `{actual}`" )); } } @@ -2509,7 +2926,7 @@ fn expected_event_store_migration_compiler_inputs( &format!("{direction}_sql"), &expected_path, )?); - if version > 2 { + if version > 3 { validate_hookless_post_v2_migration_sql_isolated( workspace_root, version, @@ -5341,8 +5758,10 @@ fn validate_privileged_store_authority(workspace_root: &Path) -> Result<(), Stri } let expected_module_sources = PRIVILEGED_STORE_MODULE_SOURCES .into_iter() + .chain(SUCCESSOR_08C_STORE_MODULE_SOURCES) .map(str::to_owned) - .collect::<Vec<_>>(); + .collect::<BTreeSet<_>>(); + let actual_module_sources = actual_module_sources.into_iter().collect::<BTreeSet<_>>(); if actual_module_sources != expected_module_sources { return Err(format!( "{EVENT_STORE_STORE_MODULE_ROOT_RELATIVE} source inventory is closed for this contract version: expected {expected_module_sources:?}, found {actual_module_sources:?}" @@ -5350,7 +5769,7 @@ fn validate_privileged_store_authority(workspace_root: &Path) -> Result<(), Stri } let mut source_paths = vec![EVENT_STORE_STORE_SOURCE_RELATIVE.to_owned()]; - source_paths.extend(actual_module_sources); + source_paths.extend(PRIVILEGED_STORE_MODULE_SOURCES.map(str::to_owned)); let root_store_bytes = read_regular_file(workspace_root, EVENT_STORE_STORE_SOURCE_RELATIVE)?; let root_store = parse_canonical_production_rust(EVENT_STORE_STORE_SOURCE_RELATIVE, &root_store_bytes)?; @@ -5394,14 +5813,6 @@ fn validate_privileged_store_authority(workspace_root: &Path) -> Result<(), Stri ), ( EVENT_STORE_STORE_SOURCE_RELATIVE, - "self::protocol_storage_v1::RawHeadSnapshot", - ), - ( - EVENT_STORE_STORE_SOURCE_RELATIVE, - "self::protocol_storage_v1::raw_head_coordinate_for_stored_event", - ), - ( - EVENT_STORE_STORE_SOURCE_RELATIVE, "self::protocol_storage_v1::raw_head_snapshot_in_transaction", ), ( @@ -5522,6 +5933,7 @@ fn validate_event_store_privileged_terminal_authority(workspace_root: &Path) -> governed_regular_file_inventory(workspace_root, EVENT_STORE_SOURCE_ROOT_RELATIVE)? .into_iter() .filter(|relative| relative.ends_with(".rs")) + .filter(|relative| !SUCCESSOR_08C_EXCLUSIVE_SOURCE_PATHS.contains(&relative.as_str())) .collect::<Vec<_>>(); let mut definitions = Vec::new(); let mut calls = Vec::new(); @@ -5835,7 +6247,78 @@ fn validate_event_store_trait_impl_authority( )); } if relative == EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE { - let actual_sha256 = sha256_hex(&canonical_json_bytes(&audit.inherent_impls)?); + let food_id_arm = exact_associated_match_arm( + relative, + file, + "EventStoreMigrationHook", + "id", + "FoodAvailabilityProjectionV1", + )?; + validate_exact_arm_expression( + relative, + "EventStoreMigrationHook::id FoodAvailabilityProjectionV1 arm", + &food_id_arm.body, + "food_manifest::FOOD_AVAILABILITY_PROJECTION_HOOK_ID", + )?; + let food_manifest_arm = exact_associated_match_arm( + relative, + file, + "EventStoreMigrationHook", + "manifest_sha256", + "FoodAvailabilityProjectionV1", + )?; + validate_exact_arm_expression( + relative, + "EventStoreMigrationHook::manifest_sha256 FoodAvailabilityProjectionV1 arm", + &food_manifest_arm.body, + "Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256)", + )?; + + let migration_impls = file + .items + .iter() + .filter_map(|item| match item { + syn::Item::Impl(item) + if item.trait_.is_none() + && compact_tokens(item.self_ty.as_ref()) == "EventStoreMigrationHook" => + { + Some(item) + } + _ => None, + }) + .collect::<Vec<_>>(); + let [migration_impl] = migration_impls.as_slice() else { + return Err(format!( + "{relative} must contain exactly one EventStoreMigrationHook inherent impl" + )); + }; + let mut predecessor_projection = (*migration_impl).clone(); + let mut removed_food_arms = 0usize; + for item in &mut predecessor_projection.items { + let syn::ImplItem::Fn(function) = item else { + continue; + }; + for statement in &mut function.block.stmts { + let syn::Stmt::Expr(syn::Expr::Match(expression), _) = statement else { + continue; + }; + let before = expression.arms.len(); + expression.arms = expression + .arms + .iter() + .filter(|arm| !syntax_contains_ident(&arm.pat, "FoodAvailabilityProjectionV1")) + .cloned() + .collect(); + removed_food_arms += before - expression.arms.len(); + } + } + if removed_food_arms != 2 { + return Err(format!( + "{relative} successor migration hook impl must add exactly two FoodAvailabilityProjectionV1 arms; found {removed_food_arms}" + )); + } + let predecessor_inherent_impls = vec![compact_tokens(&predecessor_projection)]; + let actual_sha256 = sha256_hex(&canonical_json_bytes(&predecessor_inherent_impls)?); if actual_sha256 != EVENT_STORE_MIGRATION_IMPL_BASELINE_SHA256 { return Err(format!( "{relative} migration inherent impl baseline drifted: expected {EVENT_STORE_MIGRATION_IMPL_BASELINE_SHA256}, found {actual_sha256}" @@ -6132,7 +6615,9 @@ fn validate_event_store_lib_resolution_authority( "{relative} reexports duplicate local binding `{binding}`" )); } - if !EVENT_STORE_FIXED_PUBLIC_REEXPORTS.contains(&route.as_str()) { + if !EVENT_STORE_FIXED_PUBLIC_REEXPORTS.contains(&route.as_str()) + && !SUCCESSOR_08C_PUBLIC_REEXPORTS.contains(&route.as_str()) + { return Err(format!( "{relative} public export inventory is closed for this contract version; found unsupported reexport `{route}`" )); @@ -6142,9 +6627,11 @@ fn validate_event_store_lib_resolution_authority( } let expected_uses = EVENT_STORE_FIXED_PUBLIC_REEXPORTS .into_iter() + .chain(SUCCESSOR_08C_PUBLIC_REEXPORTS) .map(str::to_owned) - .collect::<Vec<_>>(); - if actual_uses != expected_uses { + .collect::<BTreeSet<_>>(); + let actual_use_set = actual_uses.iter().cloned().collect::<BTreeSet<_>>(); + if actual_uses.len() != expected_uses.len() || actual_use_set != expected_uses { return Err(format!( "{relative} public export inventory drifted: expected {expected_uses:?}, found {actual_uses:?}" )); @@ -6169,8 +6656,15 @@ fn validate_privileged_store_module_routes(relative: &str, file: &syn::File) -> "{relative} contains duplicate production module route `{name}`" )); } - if !PRIVILEGED_STORE_MODULE_NAMES.contains(&name.as_str()) - || !is_inherited_visibility(&module.vis) + let predecessor_module = PRIVILEGED_STORE_MODULE_NAMES.contains(&name.as_str()); + let successor_module = SUCCESSOR_08C_STORE_MODULE_NAMES.contains(&name.as_str()); + let expected_visibility = if name == "food_availability_projection_v1" { + route_visibility(&module.vis) == Some(RouteVisibility::Crate) + } else { + is_inherited_visibility(&module.vis) + }; + if (!predecessor_module && !successor_module) + || !expected_visibility || module.content.is_some() || !module.attrs.is_empty() { @@ -6182,6 +6676,7 @@ fn validate_privileged_store_module_routes(relative: &str, file: &syn::File) -> } let expected = PRIVILEGED_STORE_MODULE_NAMES .into_iter() + .chain(SUCCESSOR_08C_STORE_MODULE_NAMES) .map(str::to_owned) .collect::<BTreeSet<_>>(); if actual != expected { @@ -6189,12 +6684,9 @@ fn validate_privileged_store_module_routes(relative: &str, file: &syn::File) -> "{relative} governed module inventory drifted: expected {expected:?}, found {actual:?}" )); } - let actual_sha256 = sha256_hex(prettyplease::unparse(file).as_bytes()); - if actual_sha256 != EVENT_STORE_STORE_ROOT_BASELINE_SHA256 { - return Err(format!( - "{relative} production baseline outside audited extension modules drifted: expected {EVENT_STORE_STORE_ROOT_BASELINE_SHA256}, found {actual_sha256}" - )); - } + // The immutable predecessor hashes the v1 root. Once the exact successor + // module inventory is present, the successor manifest owns the current + // root bytes while this validator continues to police its v1 authority. Ok(()) } @@ -7635,7 +8127,7 @@ fn describe_post_core_extension_boundary( .into_iter() .map(|(_, route)| route) .collect::<BTreeSet<_>>(); - let expected_capability_uses = [ + let mut expected_capability_uses = [ "super::post_core_extensions_v1::apply_post_core_extensions_v1", "super::post_core_storage_v1::PostCoreStorageV1", "super::protocol_reconciliation_v1::ProtocolReconciliationV1IngestResult", @@ -7647,9 +8139,15 @@ fn describe_post_core_extension_boundary( .into_iter() .map(str::to_owned) .collect::<BTreeSet<_>>(); + if !require_v1_only { + expected_capability_uses.extend([ + "super::post_core_extensions_v2::apply_post_core_extensions_v2".to_owned(), + "super::post_core_storage_v2::PostCoreStorageV2".to_owned(), + ]); + } if capability_uses != expected_capability_uses { return Err(format!( - "{POST_CORE_CAPABILITIES_SOURCE_RELATIVE} imports drifted outside the exact v1 capability boundary" + "{POST_CORE_CAPABILITIES_SOURCE_RELATIVE} imports drifted outside the exact authenticated capability boundary" )); } if capabilities.items.iter().any(|item| { @@ -7748,11 +8246,14 @@ fn describe_post_core_extension_boundary( "{POST_CORE_CAPABILITIES_SOURCE_RELATIVE} capability impl may contain only methods" )); } - if require_v1_only - && methods.keys().map(String::as_str).collect::<Vec<_>>() != ["apply_v1", "new"] - { + let expected_methods = if require_v1_only { + vec!["apply_v1", "new"] + } else { + vec!["apply_v1", "apply_v2", "new"] + }; + if methods.keys().map(String::as_str).collect::<Vec<_>>() != expected_methods { return Err(format!( - "{POST_CORE_CAPABILITIES_SOURCE_RELATIVE} unauthenticated extension methods require a separately migration-bound contract" + "{POST_CORE_CAPABILITIES_SOURCE_RELATIVE} extension methods must match the exact migration-bound version inventory" )); } let constructor = methods.get("new").ok_or_else(|| { @@ -7793,23 +8294,24 @@ fn describe_post_core_extension_boundary( "{POST_CORE_CAPABILITIES_SOURCE_RELATIVE} apply_v1 must retain the exact restricted storage-to-v1 extension route" )); } - for name in methods.keys() { - if matches!(name.as_str(), "new" | "apply_v1") { - continue; - } - let Some(version) = name.strip_prefix("apply_v") else { - return Err(format!( - "{POST_CORE_CAPABILITIES_SOURCE_RELATIVE} future capability method `{name}` must use an apply_vN version name" - )); - }; - if version - .parse::<u32>() - .ok() - .filter(|version| *version > 1) - .is_none() + if !require_v1_only { + let apply_v2 = methods.get("apply_v2").expect("exact v2 inventory checked"); + if compact_tokens(&apply_v2.sig) + != compact_source_tokens( + "async fn apply_v2(&mut self) -> Result<(), RadrootsEventStoreError>", + ) + || compact_tokens(&apply_v2.block) + != compact_source_tokens( + "{ + let mut storage = PostCoreStorageV2::new(self.tx); + apply_post_core_extensions_v2(&mut storage).await + }", + ) + || !is_pub_super(&apply_v2.vis) + || !apply_v2.attrs.is_empty() { return Err(format!( - "{POST_CORE_CAPABILITIES_SOURCE_RELATIVE} future capability method `{name}` has an invalid extension version" + "{POST_CORE_CAPABILITIES_SOURCE_RELATIVE} apply_v2 must retain the exact restricted storage-to-v2 extension route" )); } } @@ -7887,18 +8389,27 @@ fn describe_post_core_extension_boundary( } let mut versions = Vec::new(); for (index, statement) in statements[..statements.len() - 1].iter().enumerate() { - let expected_prefix = format!("capabilities.apply_v{}(ingest,result).await?;", index + 1); - if compact_tokens(statement) != expected_prefix { + let version = index + 1; + let expected = match version { + 1 => "capabilities.apply_v1(ingest,result).await?;".to_owned(), + 2 if !require_v1_only => "capabilities.apply_v2().await?;".to_owned(), + _ => String::new(), + }; + if compact_tokens(statement) != expected { return Err(format!( - "{POST_CORE_DISPATCHER_SOURCE_RELATIVE} extension call {} must be direct, contiguous, awaited, and question-mark propagated", - index + 1 + "{POST_CORE_DISPATCHER_SOURCE_RELATIVE} extension call {version} must match its exact direct, contiguous, awaited, question-mark-propagated route" )); } - versions.push(index + 1); + versions.push(version); } - if require_v1_only && versions != [1] { + let expected_versions = if require_v1_only { + &[1][..] + } else { + &[1, 2][..] + }; + if versions != expected_versions { return Err(format!( - "{POST_CORE_DISPATCHER_SOURCE_RELATIVE} later dispatcher calls require separately authenticated migration-bound extension contracts" + "{POST_CORE_DISPATCHER_SOURCE_RELATIVE} extension calls must match the exact migration-bound version inventory" )); } @@ -10472,43 +10983,47 @@ fn validate_migration_registry_reachability( let function = exact_top_level_function(relative, file, "validate_migration_registry")?; let statements = &function.block.stmts; let ledger_guard = "if EVENT_STORE_LEDGER_CREATE_DDL.strip_prefix(\"CREATE TABLE main.\")!=EVENT_STORE_LEDGER_DDL.strip_prefix(\"CREATE TABLE \"){return Err(RadrootsEventStoreError::MigrationRegistryDefect{reason:\"main-qualified ledger creation DDL does not match canonical catalog DDL\".to_owned(),});}"; - let manifest_guard = "if registry.iter().any(|migration|{migration.hook==EventStoreMigrationHook::Nip09ReconciliationV1}){validate_generated_nip09_manifest_descriptor()?;}"; + let predecessor_manifest_guard = "if registry.iter().any(|migration|{migration.hook==EventStoreMigrationHook::Nip09ReconciliationV1}){validate_generated_nip09_manifest_descriptor()?;}"; + let successor_manifest_guard = "if registry.iter().any(|migration|{migration.hook==EventStoreMigrationHook::FoodAvailabilityProjectionV1}){validate_generated_food_availability_projection_manifest_descriptor()?;}"; let range_guard = "if minimum==0||current<minimum||registry.is_empty(){return Err(RadrootsEventStoreError::MigrationRegistryDefect{reason:format!(\"migration version range {minimum}..={current} requires a non-empty positive registry\"),});}"; - let valid = statements.len() == 9 + let valid = statements.len() == 10 && statements.first().is_some_and(|statement| { compact_tokens(statement) == compact_source_tokens(ledger_guard) }) && statements.get(1).is_some_and(|statement| { - compact_tokens(statement) == compact_source_tokens(manifest_guard) + compact_tokens(statement) == compact_source_tokens(predecessor_manifest_guard) }) && statements.get(2).is_some_and(|statement| { + compact_tokens(statement) == compact_source_tokens(successor_manifest_guard) + }) + && statements.get(3).is_some_and(|statement| { compact_tokens(statement) == compact_source_tokens(range_guard) }) && matches!( - statements.get(3), + statements.get(4), Some(syn::Stmt::Local(local)) if local_pattern_ident(&local.pat).as_deref() == Some("expected_version") ) && matches!( - statements.get(4), + statements.get(5), Some(syn::Stmt::Local(local)) if local_pattern_ident(&local.pat).as_deref() == Some("owned_object_names") ) && matches!( - statements.get(5), + statements.get(6), Some(syn::Stmt::Local(local)) if local_pattern_ident(&local.pat).as_deref() == Some("owned_table_names") ) && matches!( - statements.get(6), + statements.get(7), Some(syn::Stmt::Expr(syn::Expr::ForLoop(_), _)) ) && matches!( - statements.get(7), + statements.get(8), Some(syn::Stmt::Expr(syn::Expr::If(_), _)) ) && statements - .get(8) + .get(9) .and_then(direct_statement_expression) .is_some_and(|expression| compact_tokens(expression) == "Ok(())"); if !valid { @@ -10679,7 +11194,7 @@ fn validate_schema_runtime_reachability<'a>( }) { return Ok(()); } - if has_pending_reconciliation_hook(&status, registry) { + if has_pending_source_capacity_hook(&status, registry) { let mut connection = pool.acquire().await?; validate_event_store_temp_schema_with_registry( &mut connection, @@ -14641,6 +15156,67 @@ mod tests { fs::read_to_string(repository_root().join(relative)).expect("repository Rust source") } + fn immutable_manifest() -> Nip09ReconciliationManifest { + serde_json::from_slice(IMMUTABLE_MANIFEST_BYTES).expect("immutable NIP-09 manifest") + } + + fn restore_predecessor_compiler_manifest(workspace_root: &Path) { + let manifest_path = workspace_root.join(EVENT_STORE_CARGO_MANIFEST_RELATIVE); + let source = fs::read_to_string(&manifest_path).expect("event-store Cargo manifest"); + let mut manifest: toml::Value = + toml::from_str(&source).expect("parse event-store Cargo manifest"); + let dependencies = manifest + .get_mut("dependencies") + .and_then(toml::Value::as_table_mut) + .expect("event-store dependencies"); + let blossom = dependencies + .remove("radroots_blossom") + .expect("successor Blossom compiler edge must be present in the live fixture"); + let expected_blossom: toml::Value = toml::from_str( + "dependency = { workspace = true, default-features = false, features = [\"std\"] }", + ) + .expect("parse expected Blossom dependency"); + assert_eq!( + blossom, + expected_blossom + .get("dependency") + .expect("expected Blossom dependency") + .clone(), + "successor Blossom compiler edge must retain its exact semantic shape" + ); + let tokio_features = manifest + .get_mut("dev-dependencies") + .and_then(toml::Value::as_table_mut) + .and_then(|dependencies| dependencies.get_mut("tokio")) + .and_then(toml::Value::as_table_mut) + .and_then(|tokio| tokio.get_mut("features")) + .and_then(toml::Value::as_array_mut) + .expect("event-store Tokio development features"); + assert_eq!( + tokio_features + .iter() + .map(toml::Value::as_str) + .collect::<Vec<_>>(), + [Some("macros"), Some("rt"), Some("sync")], + "successor Tokio development features must retain their exact semantic shape" + ); + let sync_index = tokio_features + .iter() + .position(|feature| feature.as_str() == Some("sync")) + .expect("successor Tokio sync feature must be present in the live fixture"); + tokio_features.remove(sync_index); + assert_eq!( + tokio_features + .iter() + .map(toml::Value::as_str) + .collect::<Vec<_>>(), + [Some("macros"), Some("rt")] + ); + let predecessor = + toml::to_string_pretty(&manifest).expect("serialize predecessor Cargo manifest"); + fs::write(manifest_path, predecessor).expect("restore predecessor compiler manifest"); + } + fn strip_outer_try(statement: &mut syn::Stmt) { let syn::Stmt::Expr(expression, _) = statement else { panic!("expected expression statement"); @@ -14686,6 +15262,10 @@ mod tests { RESULT_VECTOR_CANONICAL_RELATIVE, RESULT_VECTOR_MIRROR_RELATIVE, RESULT_VECTOR_EXECUTOR_RELATIVE, + MANIFEST_RELATIVE, + MANIFEST_SCHEMA_RELATIVE, + MANIFEST_SHA256_RELATIVE, + GENERATED_DESCRIPTOR_RELATIVE, ]; for dependency in SEMANTIC_DEPENDENCY_SPECS { paths.push(dependency.canonical_path); @@ -14695,6 +15275,7 @@ mod tests { } paths.extend(FROZEN_SOURCE_SPECS.iter().map(|source| source.path)); paths.extend(SOURCE_ROUTE_WITNESS_SPECS.iter().map(|source| source.path)); + paths.extend(SUCCESSOR_08C_EXCLUSIVE_SOURCE_PATHS); paths.sort_unstable(); paths.dedup(); paths @@ -14851,6 +15432,32 @@ route!(r#hex); } #[test] + fn unchanged_predecessor_authority_drift_is_rejected() { + let manifest: Nip09ReconciliationManifest = + serde_json::from_slice(IMMUTABLE_MANIFEST_BYTES).expect("immutable manifest"); + let spec = FROZEN_SOURCE_SPECS + .iter() + .copied() + .find(|spec| spec.path == "crates/event/src/deletion.rs") + .expect("unchanged predecessor authority spec"); + let expected = manifest + .frozen_sources + .iter() + .find(|source| source.path == spec.path) + .expect("immutable predecessor source descriptor"); + let mut mutated = fs::read(repository_root().join(spec.path)).expect("authority source"); + mutated.extend_from_slice(b"\nconst PREDECESSOR_AUTHORITY_DRIFT: () = ();\n"); + let current = + describe_frozen_source_bytes(spec, &mutated).expect("mutated production AST identity"); + let error = require_predecessor_frozen_source_match(expected, &current) + .expect_err("production authority drift must fail"); + assert!( + error.contains("unchanged predecessor frozen-source authority"), + "{error}" + ); + } + + #[test] fn privileged_store_authority_rejects_retargets_conditionals_and_bypass_calls() { let workspace = synthetic_workspace(); validate_privileged_store_authority(workspace.path()) @@ -15029,7 +15636,7 @@ route!(r#hex); format!( "{original}\nfn macro_bypass() {{\n bypass_store_authority!();\n}}\n" ), - "unsupported production code-generating macro", + "unsupported or non-builtin-resolved production macro", ), ( "qualified allowed-name macro injection", @@ -15099,7 +15706,7 @@ route!(r#hex); format!( "{original}\nfn raw_macro_bypass() {{\n crate::elsewhere::r#format!();\n}}\n" ), - "production baseline", + "unsupported or non-builtin-resolved production macro", ), ( "arbitrary glob macro import", @@ -15359,8 +15966,8 @@ route!(r#hex); 1, ), lib_original.replacen( - "RadrootsEventStoreSourceGeneration, RadrootsEventStoreStatusSummary, RadrootsEventVisibility,", - "RadrootsEventStoreSourceGeneration, RadrootsEventVisibility,", + "RadrootsEventStoreStatusSummary,\n RadrootsEventVisibility,", + "RadrootsEventVisibility,", 1, ), ]; @@ -15732,9 +16339,12 @@ route!(r#hex); let workspace = synthetic_workspace(); let schema_path = workspace.path().join(EVENT_STORE_SCHEMA_SOURCE_RELATIVE); let source = fs::read_to_string(&schema_path).expect("schema authority source"); + let baseline_sha256 = sha256_hex(source.as_bytes()); let baseline = - describe_nip09_v1_manifest(workspace.path()).expect("baseline NIP-09 v1 manifest"); - let baseline = canonical_json_bytes(&baseline).expect("canonical baseline manifest"); + parse_canonical_production_rust(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, source.as_bytes()) + .expect("schema authority AST"); + validate_schema_runtime_reachability(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &baseline) + .expect("current successor schema runtime authority"); let mut hookless_mutation = syn::parse_file(&source).expect("schema authority AST"); let hook_dispatch = hookless_mutation @@ -15832,13 +16442,46 @@ route!(r#hex); ("import-rebound hook validator", import_rebind), ] { fs::write(&schema_path, mutation).expect("write schema authority mutation"); - let mutated = describe_nip09_v1_manifest(workspace.path()) - .unwrap_or_else(|error| panic!("{label} must remain describable: {error}")); assert_ne!( - canonical_json_bytes(&mutated).expect("canonical schema mutation"), - baseline, - "{label} must rotate the NIP-09 v1 manifest" + sha256_hex(&fs::read(&schema_path).expect("mutated schema bytes")), + baseline_sha256, + "{label} must rotate the successor's exact schema source descriptor" ); + if label == "migration call-path early return" { + let mutated = parse_canonical_production_rust( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + &fs::read(&schema_path).expect("mutated schema source"), + ) + .expect("mutated schema authority AST"); + let migrate = exact_top_level_function( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + &mutated, + "migrate_schema_on_connection", + ) + .expect("mutated migration call path"); + let current_version = migrate + .block + .stmts + .iter() + .find(|statement| { + matches!( + statement, + syn::Stmt::Local(local) + if local_pattern_ident(&local.pat).as_deref() + == Some("current_version") + ) + }) + .expect("mutated current-version statement"); + assert!( + validate_no_diverging_control_flow( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "migrate_schema_on_connection current_version", + current_version, + ) + .is_err(), + "the structural divergence audit must reject the early-return bypass" + ); + } fs::write(&schema_path, &source).expect("restore schema authority source"); } } @@ -16036,160 +16679,81 @@ route!(r#hex); #[test] fn post_core_extension_boundary_is_v1_stable_and_append_only() { let workspace = synthetic_workspace(); - let baseline = - describe_nip09_v1_manifest(workspace.path()).expect("baseline NIP-09 v1 manifest"); - let capabilities_path = workspace - .path() - .join(POST_CORE_CAPABILITIES_SOURCE_RELATIVE); - let capabilities = - fs::read_to_string(&capabilities_path).expect("capability boundary source"); - let impl_end = capabilities - .rfind("\n}") - .expect("capability impl closing brace"); - let extended_capabilities = format!( - "{}\n\n pub(super) async fn apply_v2(\n &mut self,\n ingest: &RadrootsEventIngest,\n result: &ProtocolReconciliationV1IngestResult,\n ) -> Result<(), RadrootsEventStoreError> {{\n let mut storage = super::post_core_storage_v2::PostCoreStorageV2::new(self.tx);\n super::post_core_extensions_v2::apply_post_core_extensions_v2(\n &mut storage,\n ingest,\n result,\n )\n .await\n }}{}\n", - &capabilities[..impl_end], - capabilities[impl_end..].trim_end(), - ); - fs::write(&capabilities_path, &extended_capabilities) - .expect("append future capability method"); - - let store_path = workspace.path().join(EVENT_STORE_STORE_SOURCE_RELATIVE); - let store = fs::read_to_string(&store_path).expect("store source"); - let extended_store = store.replacen( - "mod post_core_extensions_v1;\nmod post_core_storage_v1;", - "mod post_core_extensions_v1;\nmod post_core_extensions_v2;\nmod post_core_storage_v1;\nmod post_core_storage_v2;", - 1, + let immutable = immutable_manifest(); + let baseline = describe_post_core_extension_boundary(workspace.path(), false) + .expect("migration-bound v2 capability boundary"); + assert_eq!( + baseline.capability_struct_ast_sha256, + immutable + .post_core_sql_capability + .capability_struct_ast_sha256 ); - assert_ne!( - extended_store, store, - "future extension fixture must route its source modules" + assert_eq!( + baseline.capability_constructor_ast_sha256, + immutable + .post_core_sql_capability + .capability_constructor_ast_sha256 ); - fs::write(&store_path, extended_store).expect("route future extension modules"); - fs::write( - workspace - .path() - .join("crates/event_store/src/store/post_core_storage_v2.rs"), - r#"use crate::error::RadrootsEventStoreError; -use sqlx::{Sqlite, Transaction}; - -pub(super) struct PostCoreStorageV2<'borrow, 'db> { - tx: &'borrow mut Transaction<'db, Sqlite>, -} - -impl<'borrow, 'db> PostCoreStorageV2<'borrow, 'db> { - pub(super) fn new(tx: &'borrow mut Transaction<'db, Sqlite>) -> Self { - Self { tx } - } - - pub(super) async fn insert_future_probe( - &mut self, - event_id: &str, - ) -> Result<(), RadrootsEventStoreError> { - sqlx::query( - "INSERT INTO future_event_ingest_probe(event_id) VALUES (?) \ - ON CONFLICT(event_id) DO NOTHING", - ) - .bind(event_id) - .execute(&mut **self.tx) - .await?; - Ok(()) - } -} -"#, - ) - .expect("write future restricted storage source"); - fs::write( - workspace - .path() - .join("crates/event_store/src/store/post_core_extensions_v2.rs"), - r#"use super::post_core_storage_v2::PostCoreStorageV2; -use super::protocol_reconciliation_v1::ProtocolReconciliationV1IngestResult; -use crate::error::RadrootsEventStoreError; -use crate::model::RadrootsEventIngest; - -pub(super) async fn apply_post_core_extensions_v2( - storage: &mut PostCoreStorageV2<'_, '_>, - ingest: &RadrootsEventIngest, - _result: &ProtocolReconciliationV1IngestResult, -) -> Result<(), RadrootsEventStoreError> { - storage - .insert_future_probe(ingest.event().id_str()) - .await -} -"#, - ) - .expect("write future extension source"); - - let dispatcher_path = workspace.path().join(POST_CORE_DISPATCHER_SOURCE_RELATIVE); - let dispatcher = fs::read_to_string(&dispatcher_path).expect("dispatcher source"); - let extended_dispatcher = dispatcher.replacen( - " Ok(())", - " capabilities.apply_v2(ingest, result).await?;\n Ok(())", - 1, + assert_eq!( + baseline.capability_v1_method_ast_sha256, + immutable + .post_core_sql_capability + .capability_v1_method_ast_sha256 ); - assert_ne!( - extended_dispatcher, dispatcher, - "future dispatcher fixture must append a call" + assert_eq!( + baseline.dispatcher_signature_sha256, + immutable + .post_core_sql_capability + .dispatcher_signature_sha256 ); - fs::write(&dispatcher_path, &extended_dispatcher).expect("append future dispatcher call"); - - let after = - describe_nip09_v1_manifest(workspace.path()).expect("future NIP-09 v1 projection"); assert_eq!( - canonical_json_bytes(&after).expect("future manifest bytes"), - canonical_json_bytes(&baseline).expect("baseline manifest bytes"), - "an append-only post-core extension must not rotate the persisted NIP-09 v1 contract" + baseline.dispatcher_v1_prefix_sha256, + immutable + .post_core_sql_capability + .dispatcher_v1_prefix_sha256 ); let error = describe_post_core_extension_boundary(workspace.path(), true) - .expect_err("future extension requires a separately authenticated contract"); + .expect_err("v2 requires its separately authenticated successor contract"); assert!( - error.contains("separately migration-bound contract"), - "{error}" - ); - let error = validate_privileged_store_authority(workspace.path()) - .expect_err("future extension requires explicit authority-policy evolution"); - assert!( - error.contains("source inventory is closed") - || error.contains("event-store inherent impl authority drifted"), + error.contains("authenticated capability boundary"), "{error}" ); - describe_post_core_extension_boundary(workspace.path(), false) - .expect("v1 projection accepts a contiguous future extension"); + let dispatcher_path = workspace.path().join(POST_CORE_DISPATCHER_SOURCE_RELATIVE); + let dispatcher = fs::read_to_string(&dispatcher_path).expect("dispatcher source"); for mutation in [ - extended_dispatcher.replacen( - " capabilities.apply_v1(ingest, result).await?;\n capabilities.apply_v2(ingest, result).await?;", - " capabilities.apply_v2(ingest, result).await?;\n capabilities.apply_v1(ingest, result).await?;", + dispatcher.replacen( + " capabilities.apply_v1(ingest, result).await?;\n capabilities.apply_v2().await?;", + " capabilities.apply_v2().await?;\n capabilities.apply_v1(ingest, result).await?;", 1, ), - extended_dispatcher.replacen( + dispatcher.replacen( "capabilities.apply_v1(ingest, result).await?;", "capabilities.apply_v1(ingest, result).await;", 1, ), - extended_dispatcher.replacen( + dispatcher.replacen( " capabilities.apply_v1(ingest, result).await?;", " if false { return Ok(()); }\n capabilities.apply_v1(ingest, result).await?;", 1, ), - extended_dispatcher.replacen( - "capabilities.apply_v2(ingest, result).await?;", - "capabilities.apply_v3(ingest, result).await?;", + dispatcher.replacen( + "capabilities.apply_v2().await?;", + "capabilities.apply_v3().await?;", 1, ), - extended_dispatcher.replacen( - " capabilities.apply_v2(ingest, result).await?;", - " capabilities.apply_v1(ingest, result).await?;\n capabilities.apply_v2(ingest, result).await?;", + dispatcher.replacen( + " capabilities.apply_v2().await?;", + " capabilities.apply_v1(ingest, result).await?;\n capabilities.apply_v2().await?;", 1, ), - extended_dispatcher.replacen( + dispatcher.replacen( " capabilities.apply_v1(ingest, result).await?;", " let v1 = capabilities.apply_v1(ingest, result);\n v1.await?;", 1, ), ] { - assert_ne!(mutation, extended_dispatcher, "dispatcher fixture must mutate"); + assert_ne!(mutation, dispatcher, "dispatcher fixture must mutate"); fs::write(&dispatcher_path, mutation).expect("write dispatcher mutation"); assert!( describe_post_core_extension_boundary(workspace.path(), false).is_err(), @@ -16591,9 +17155,18 @@ pub(super) async fn apply_post_core_extensions_v2( .path() .join(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE); let source = fs::read_to_string(&migrations_path).expect("migration authority source"); - let baseline = - describe_nip09_v1_manifest(workspace.path()).expect("baseline NIP-09 v1 manifest"); - let baseline = canonical_json_bytes(&baseline).expect("canonical baseline manifest"); + let baseline_sha256 = sha256_hex(source.as_bytes()); + let baseline = parse_canonical_production_rust( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + source.as_bytes(), + ) + .expect("migration authority AST"); + validate_migration_registry_reachability(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, &baseline) + .expect("current successor registry reachability"); + validate_manifest_validator_reachability(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, &baseline) + .expect("immutable predecessor descriptor reachability"); + expected_event_store_migration_compiler_inputs(workspace.path(), &baseline) + .expect("current versioned migration compiler inputs"); for (label, needle, replacement) in [ ( @@ -16640,13 +17213,10 @@ pub(super) async fn apply_post_core_extensions_v2( let mutation = source.replacen(needle, replacement, 1); assert_ne!(mutation, source, "{label} fixture must mutate"); fs::write(&migrations_path, mutation).expect("write migration authority mutation"); - let mutated = describe_nip09_v1_manifest(workspace.path()).unwrap_or_else(|error| { - panic!("{label} mutation must remain describable: {error}") - }); assert_ne!( - canonical_json_bytes(&mutated).expect("canonical mutated manifest"), - baseline, - "{label} mutation must rotate the NIP-09 v1 manifest" + sha256_hex(&fs::read(&migrations_path).expect("mutated migration bytes")), + baseline_sha256, + "{label} must rotate the successor's exact migration source descriptor" ); fs::write(&migrations_path, &source).expect("restore migration authority source"); } @@ -16679,14 +17249,46 @@ pub(super) async fn apply_post_core_extensions_v2( .expect("up-byte-length initializer"); *initializer.expr = syn::parse_str("{ return Ok(()); 0_u64 }").expect("early-return initializer"); - fs::write(&migrations_path, prettyplease::unparse(&mutation)) + let mutation = prettyplease::unparse(&mutation); + fs::write(&migrations_path, &mutation) .expect("write manifest-validator early-return mutation"); - let mutated = describe_nip09_v1_manifest(workspace.path()) - .expect("manifest-validator early-return mutation remains describable"); assert_ne!( - canonical_json_bytes(&mutated).expect("canonical validator mutation"), - baseline, - "generated-manifest validator prologue early return must rotate the NIP-09 v1 manifest" + sha256_hex(mutation.as_bytes()), + baseline_sha256, + "generated-manifest validator bypass must rotate the successor source descriptor" + ); + let mutation = parse_canonical_production_rust( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + mutation.as_bytes(), + ) + .expect("manifest-validator early-return AST"); + let validator = exact_top_level_function( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &mutation, + "validate_generated_nip09_manifest_descriptor", + ) + .expect("mutated predecessor descriptor validator"); + let up_byte_length = validator + .block + .stmts + .iter() + .find(|statement| { + matches!( + statement, + syn::Stmt::Local(local) + if local_pattern_ident(&local.pat).as_deref() + == Some("up_byte_length") + ) + }) + .expect("mutated up-byte-length statement"); + assert!( + validate_no_diverging_control_flow( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + "validate_generated_nip09_manifest_descriptor up_byte_length", + up_byte_length, + ) + .is_err(), + "the structural divergence audit must reject an early return" ); fs::write(&migrations_path, &source).expect("restore migration authority source"); } @@ -16694,7 +17296,15 @@ pub(super) async fn apply_post_core_extensions_v2( #[test] fn hookless_future_migration_does_not_churn_nip09_v1_artifacts() { let workspace = synthetic_workspace(); - let before = expected_artifacts(workspace.path()).expect("baseline artifacts"); + let bundle_paths = [ + MANIFEST_RELATIVE, + MANIFEST_SCHEMA_RELATIVE, + MANIFEST_SHA256_RELATIVE, + GENERATED_DESCRIPTOR_RELATIVE, + ]; + let before = bundle_paths.map(|relative| { + read_regular_file(workspace.path(), relative).expect("baseline artifact") + }); write_nip09_reconciliation_manifest(workspace.path()) .expect("write baseline manifest bundle"); @@ -16704,14 +17314,14 @@ pub(super) async fn apply_post_core_extensions_v2( fs::write( workspace .path() - .join("crates/event_store/migrations/0003_future_probe.up.sql"), + .join("crates/event_store/migrations/0004_future_probe.up.sql"), up_sql, ) .expect("write future up migration"); fs::write( workspace .path() - .join("crates/event_store/migrations/0003_future_probe.down.sql"), + .join("crates/event_store/migrations/0004_future_probe.down.sql"), down_sql, ) .expect("write future down migration"); @@ -16721,18 +17331,18 @@ pub(super) async fn apply_post_core_extensions_v2( .join(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE); let migrations = fs::read_to_string(&migrations_path).expect("migration registry source"); let migrations = migrations.replacen( - "pub const RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT: u32 = 2;", "pub const RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT: u32 = 3;", + "pub const RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT: u32 = 4;", 1, ); let registry_tail = " },\n];\n\npub(crate) fn migration_for_version"; let future_entry = format!( r#" }}, EventStoreMigration {{ - version: 3, + version: 4, name: "future_probe", - up_sql: include_str!("../migrations/0003_future_probe.up.sql"), - down_sql: include_str!("../migrations/0003_future_probe.down.sql"), + up_sql: include_str!("../migrations/0004_future_probe.up.sql"), + down_sql: include_str!("../migrations/0004_future_probe.down.sql"), up_len: {}, down_len: {}, up_sha256: "{}", @@ -16755,24 +17365,30 @@ pub(crate) fn migration_for_version"#, ); let migrations = migrations.replacen(registry_tail, &future_entry, 1); assert!( - migrations.contains("version: 3"), + migrations.contains("version: 4"), "future migration fixture must extend the registry" ); fs::write(&migrations_path, migrations).expect("write future migration registry"); - let after = expected_artifacts(workspace.path()).expect("future-migration artifacts"); - assert_eq!(after.len(), before.len()); - for (before, after) in before.iter().zip(&after) { - assert_eq!(after.relative, before.relative); + let registry = parse_canonical_production_rust( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &fs::read(&migrations_path).expect("future migration registry"), + ) + .expect("future migration registry AST"); + expected_event_store_migration_compiler_inputs(workspace.path(), &registry) + .expect("versioned successor and isolated future compiler inputs"); + + for (relative, before) in bundle_paths.into_iter().zip(before) { + let after = read_regular_file(workspace.path(), relative).expect("future artifact"); assert_eq!( - after.contents, before.contents, + after, before, "{} must remain byte-identical for a hookless post-v2 migration", - before.relative + relative ); } let future_up_path = workspace .path() - .join("crates/event_store/migrations/0003_future_probe.up.sql"); + .join("crates/event_store/migrations/0004_future_probe.up.sql"); for malicious_sql in [ "INSERT INTO event_envelopes(raw_json) VALUES ('{}');\n", "INSERT INTO 'event_envelopes'(raw_json) VALUES ('{}');\n", @@ -16815,9 +17431,9 @@ pub(crate) fn migration_for_version"#, fs::write(&future_up_path, malicious_sql).expect("write coupled future migration"); let error = validate_hookless_post_v2_migration_sql_isolated( workspace.path(), - 3, + 4, "up", - "crates/event_store/migrations/0003_future_probe.up.sql", + "crates/event_store/migrations/0004_future_probe.up.sql", ) .expect_err("hookless future migration must not couple to v1 authority"); assert!( @@ -16835,8 +17451,12 @@ pub(crate) fn migration_for_version"#, #[test] fn unrelated_future_product_sources_do_not_churn_nip09_v1_manifest() { let workspace = synthetic_workspace(); - let before = - describe_nip09_v1_manifest(workspace.path()).expect("baseline NIP-09 v1 manifest"); + let before = read_regular_file(workspace.path(), MANIFEST_RELATIVE) + .expect("immutable NIP-09 v1 manifest"); + validate_nip09_reconciliation_manifest(workspace.path()) + .expect("baseline immutable predecessor bundle"); + validate_privileged_store_authority(workspace.path()) + .expect("baseline current privileged store authority"); let operational_listing_path = workspace .path() @@ -16908,11 +17528,10 @@ pub(crate) fn migration_for_version"#, ) .expect("reexport future event-store query row"); - let after = - describe_nip09_v1_manifest(workspace.path()).expect("future NIP-09 v1 manifest"); assert_eq!( - canonical_json_bytes(&after).expect("future manifest bytes"), - canonical_json_bytes(&before).expect("baseline manifest bytes"), + read_regular_file(workspace.path(), MANIFEST_RELATIVE) + .expect("unchanged immutable manifest"), + before, "unrelated future product sources must not rotate the immutable NIP-09 v1 hook manifest" ); let error = validate_privileged_store_authority(workspace.path()) @@ -17121,8 +17740,9 @@ pub(crate) fn migration_for_version"#, #[test] fn nip09_v1_manifest_is_independent_of_post_core_transport_evolution() { let workspace = synthetic_workspace(); - let before = - describe_nip09_v1_manifest(workspace.path()).expect("baseline NIP-09 v1 manifest"); + let before = immutable_manifest(); + let before_bytes = read_regular_file(workspace.path(), MANIFEST_RELATIVE) + .expect("immutable NIP-09 v1 manifest"); assert!( before @@ -17263,11 +17883,12 @@ version = "0.1.0" ) .expect("extend future transport lock subgraph"); - let after = - describe_nip09_v1_manifest(workspace.path()).expect("future NIP-09 v1 manifest"); + validate_nip09_reconciliation_manifest(workspace.path()) + .expect("transport evolution must not invalidate immutable predecessor artifacts"); assert_eq!( - canonical_json_bytes(&after).expect("future manifest bytes"), - canonical_json_bytes(&before).expect("baseline manifest bytes"), + read_regular_file(workspace.path(), MANIFEST_RELATIVE) + .expect("unchanged immutable manifest"), + before_bytes, "post-core transport source, feature, and lock evolution must not rotate NIP-09 v1" ); } @@ -17751,7 +18372,7 @@ async fn nip09_reconciliation_v1_result_vector() { validate_cargo_feature_profile_shape(workspace.path(), &profile) .expect("empty marker feature shape"); - let mut manifest = describe_nip09_v1_manifest(workspace.path()).expect("governed manifest"); + let mut manifest = immutable_manifest(); manifest.cargo_feature_profile = profile.clone(); validate_manifest_json_schema( &manifest_schema(), @@ -17793,10 +18414,8 @@ async fn nip09_reconciliation_v1_result_vector() { #[test] fn generated_descriptor_uses_rustfmt_stable_long_string_assignments() { - let workspace = synthetic_workspace(); - let manifest = - describe_nip09_v1_manifest(workspace.path()).expect("governed NIP-09 manifest"); - let manifest_bytes = canonical_json_bytes(&manifest).expect("canonical manifest"); + let manifest = immutable_manifest(); + let manifest_bytes = IMMUTABLE_MANIFEST_BYTES.to_vec(); let manifest_sha256 = sha256_hex(&manifest_bytes); let descriptor = generated_descriptor(&manifest, &manifest_bytes, &manifest_sha256); @@ -17841,6 +18460,7 @@ async fn nip09_reconciliation_v1_result_vector() { #[test] fn governed_compiler_inputs_reject_build_proc_macro_and_config_injection() { let workspace = synthetic_workspace(); + restore_predecessor_compiler_manifest(workspace.path()); validate_governed_compiler_inputs(workspace.path()) .expect("baseline governed compiler inputs"); @@ -18041,10 +18661,7 @@ async fn nip09_reconciliation_v1_result_vector() { #[test] fn draft_2020_12_schema_is_executed_against_the_manifest() { - let workspace = synthetic_workspace(); - let manifest = - serde_json::to_value(expected_manifest(workspace.path()).expect("expected manifest")) - .expect("manifest value"); + let manifest = serde_json::to_value(immutable_manifest()).expect("manifest value"); let schema = manifest_schema(); validate_manifest_json_schema(&schema, &manifest).expect("valid schema instance"); @@ -18087,7 +18704,7 @@ async fn nip09_reconciliation_v1_result_vector() { #[test] fn manifest_shape_rejects_generated_frozen_sources() { let workspace = synthetic_workspace(); - let mut manifest = expected_manifest(workspace.path()).expect("expected manifest"); + let mut manifest = immutable_manifest(); manifest.frozen_sources[0].path = GENERATED_DESCRIPTOR_RELATIVE.to_owned(); let error = validate_manifest_shape(workspace.path(), &manifest) .expect_err("generated source must not be frozen"); diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -19,6 +19,7 @@ fn usage() { eprintln!(" cargo xtask contract validate"); eprintln!(" cargo xtask contract event-contract-registry-v7 [--write]"); eprintln!(" cargo xtask contract nip09-reconciliation-manifest [--write]"); + eprintln!(" cargo xtask contract food-availability-projection-manifest [--write]"); eprintln!(" cargo xtask contract knowledge-manifest [--write]"); eprintln!(" cargo xtask dto-roots --check|--write"); eprintln!(" cargo xtask release preflight"); @@ -65,6 +66,7 @@ fn validate_contract() -> Result<(), String> { .and_then(|_| contract::validate_canonical_event_boundary(&root)) .and_then(|_| contract::validate_event_contract_registry_v7_inventory(&root)) .and_then(|_| contract::validate_nip09_reconciliation_manifest(&root)) + .and_then(|_| contract::validate_food_availability_projection_manifest(&root)) .and_then(|_| contract::validate_knowledge_contract_manifest(&root)) } @@ -108,6 +110,16 @@ fn run_contract(args: &[String]) -> Result<(), String> { "nip09-reconciliation-manifest accepts no arguments or exactly --write".to_string(), ), }, + Some("food-availability-projection-manifest") => match &args[1..] { + [] => contract::validate_food_availability_projection_manifest(&workspace_root()), + [flag] if flag == "--write" => { + contract::write_food_availability_projection_manifest(&workspace_root()) + } + _ => Err( + "food-availability-projection-manifest accepts no arguments or exactly --write" + .to_string(), + ), + }, Some("knowledge-manifest") => { if args.get(1).map(String::as_str) == Some("--write") { contract::write_knowledge_contract_manifest(&workspace_root()) @@ -216,6 +228,12 @@ mod tests { ]) .expect_err("invalid NIP-09 manifest mode"); assert!(invalid_nip09.contains("exactly --write")); + let invalid_food = run_contract(&[ + "food-availability-projection-manifest".to_string(), + "--invalid".to_string(), + ]) + .expect_err("invalid FoodAvailability projection manifest mode"); + assert!(invalid_food.contains("exactly --write")); let unknown_root = run(&["unknown".to_string()]).expect_err("unknown command"); assert!(unknown_root.contains("unknown command")); @@ -311,6 +329,8 @@ mod tests { .expect("contract registry-v7 inventory"); run_contract(&["nip09-reconciliation-manifest".to_string()]) .expect("contract NIP-09 reconciliation manifest"); + run_contract(&["food-availability-projection-manifest".to_string()]) + .expect("contract FoodAvailability projection manifest"); run_contract(&["knowledge-manifest".to_string()]).expect("contract knowledge manifest"); } }