lib

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

commit 08d7134408cd95c9d3fbda1aa60ae4f46682f5e4
parent 577efcb4fe88f7aaff75bdd5a16a09debbcfc034
Author: triesap <tyson@radroots.org>
Date:   Tue, 21 Jul 2026 09:31:52 +0000

event-store: enforce retained source maintenance authority

- add prospective raw-source capacity and finite generation enforcement
- reject encoding drift and destructive source-history rollback
- seal migration, reopen, ingest, and rebuild authority atomically
- preserve immutable NIP-09 and FoodAvailability predecessors

Diffstat:
MCHANGELOG.md | 23+++++++++++++++++++++++
Acontracts/conformance/vectors/event_store/source_maintenance.v1.json | 408+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcontracts/releases/1.0.0-alpha.1.toml | 16++++++++++++++++
Mcrates/event_store/README | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Acrates/event_store/contracts/source_maintenance_v1.manifest.json | 357+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/contracts/source_maintenance_v1.manifest.schema.json | 493+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/contracts/source_maintenance_v1.manifest.sha256 | 1+
Acrates/event_store/migrations/0004_source_maintenance.down.sql | 138+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/migrations/0004_source_maintenance.up.sql | 583+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/error.rs | 52++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/event_store/src/generated.rs | 1+
Acrates/event_store/src/generated/source_maintenance_manifest.rs | 54++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/lib.rs | 12+++++++++++-
Mcrates/event_store/src/migrations.rs | 402++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/event_store/src/nip09/reconciliation_v1.rs | 228++++++++++++++++++++++++++++++++++++++++++++-----------------------------------
Mcrates/event_store/src/schema.rs | 1177++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
Acrates/event_store/src/source_maintenance_v1.rs | 1197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/event_store/src/store.rs | 918+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/event_store/src/store/protocol_reconciliation_v1.rs | 45+++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/tests/fixtures/source_maintenance.v1.json | 408+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/event_store/tests/source_maintenance_v1_result_vector.rs | 632+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/contract.rs | 23+++++++++++++++++++++--
Mtools/xtask/src/contract/food_availability_projection.rs | 718+++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------
Mtools/xtask/src/contract/nip09_reconciliation.rs | 3290+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
Atools/xtask/src/contract/source_maintenance.rs | 4284+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtools/xtask/src/main.rs | 24++++++++++++++++++++----
26 files changed, 14850 insertions(+), 716 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -114,6 +114,29 @@ publish policy both pass for the same source revision. 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. +- Event-store schema v4 adds a persisted raw-source capacity seal for event + rows, tag rows, and their governed UTF-8 text bytes. Unique durable ingest + now refuses prospective capacity excess before mutation, database reopen + performs a bounded full recount, and independent file pools serialize an + exact final capacity slot. Every supplied main database must report UTF-8 + before schema or journal mutation. Retained source history stops at eight + generations before requesting fresh-store replacement and resync, and + production rollback cannot cross the migration that introduced that + append-only history. The + authenticated SourceMaintenance successor binds the immutable schema-v3 + predecessor, migration and runtime sources, breaking capacity-error API + replacements, and an executable result vector. Schema v4 is intentionally + non-additive: it replaces exactly the Food projection delete guard, Food image + delete guard, and source rebuild-marker insert guard, requires that exact + symmetric catalog delta, and restores the exact v3 trigger SQL on rollback. + A drifted v3 predecessor is rejected atomically rather than repaired during + upgrade; repair authorization is reserved for a future rebuild after exact + managed-v4 catalog, ledger, and migration history plus immutable raw/source + lineage and capacity validation. Derived hook state is the repair target, + not a repair precondition. The former + `RadrootsEventStoreReconciliationResource` type and + `ReconciliationCapacityExceeded` error variant are replaced by the + versioned source-capacity resource and typed capacity/history errors. - 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 diff --git a/contracts/conformance/vectors/event_store/source_maintenance.v1.json b/contracts/conformance/vectors/event_store/source_maintenance.v1.json @@ -0,0 +1,408 @@ +{ + "schema_version": 1, + "contract_id": "radroots_event_store.source_maintenance_v1", + "capacity_version": 1, + "limits": { + "raw_events": 25000, + "raw_tags": 250000, + "raw_event_text_bytes": 67108864, + "raw_tag_text_bytes": 33554432, + "retained_source_generations": 8 + }, + "accounting": { + "algorithm": "sqlite_cast_blob_octet_sum_v1", + "raw_event_columns": [ + "event_id", + "pubkey", + "tags_json", + "content", + "sig", + "raw_json" + ], + "raw_tag_columns": [ + "event_id", + "tag_name", + "tag_value", + "tag_json" + ], + "nullable_raw_tag_columns": [ + "tag_value" + ] + }, + "cases": [ + { + "id": "fresh_store_zero_authority", + "execution": "direct_executor", + "authority": "source_maintenance_v1_result_vector", + "authority_path": "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "durable_unique_append_updates_all_dimensions", + "execution": "direct_executor", + "authority": "source_maintenance_v1_result_vector", + "authority_path": "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "duplicate_at_exact_boundary_is_idempotent", + "execution": "delegated_rust_test", + "authority": "exact_capacity_boundary_allows_duplicate_observation_and_ephemeral_noop", + "authority_path": "crates/event_store/src/store.rs", + "resource": "raw_events", + "boundary": "exact", + "expected_outcome": "accepted_without_capacity_delta", + "error_domain": null, + "expected_error": null + }, + { + "id": "ephemeral_consumes_no_capacity", + "execution": "direct_executor", + "authority": "source_maintenance_v1_result_vector", + "authority_path": "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted_without_capacity_delta", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_event_count_exact", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_events", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_event_count_one_over", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_events", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "raw_tag_count_exact", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_tags", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_tag_count_one_over", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_tags", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "raw_event_text_bytes_exact", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_event_text_bytes", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_event_text_bytes_one_over", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_event_text_bytes", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "raw_tag_text_bytes_exact", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_tag_text_bytes", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_tag_text_bytes_one_over", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_tag_text_bytes", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "outer_transaction_rollback_restores_capacity", + "execution": "direct_executor", + "authority": "source_maintenance_v1_result_vector", + "authority_path": "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rolled_back_without_capacity_delta", + "error_domain": null, + "expected_error": null + }, + { + "id": "failed_nested_ingest_rolls_back_savepoint_only", + "execution": "delegated_rust_test", + "authority": "borrowed_ingest_savepoint_rolls_back_post_core_authority_forge", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "failed_ingest_rolled_back_and_prior_caller_work_preserved", + "error_domain": "typed", + "expected_error": "MigrationHookStateDrift" + }, + { + "id": "v3_to_v4_under_limit_succeeds", + "execution": "delegated_rust_test", + "authority": "v3_to_v4_under_limit_backfills_exact_capacity_and_preserves_source", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "under_limit", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "v3_to_v4_prior_transition_drift_is_atomic", + "execution": "delegated_rust_test", + "authority": "v3_to_v4_rejects_prior_transition_drift_atomically", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "corrupt_managed_v3", + "expected_outcome": "rejected_before_v4_schema_ledger_or_predecessor_trigger_mutation", + "error_domain": "typed", + "expected_error": "MigrationHookStateDrift" + }, + { + "id": "v3_to_v4_one_over_is_atomic", + "execution": "delegated_rust_test", + "authority": "source_capacity_is_rechecked_for_every_rebuild_bound_migration", + "authority_path": "crates/event_store/src/schema.rs", + "resource": "raw_events", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "v3_to_v4_persisted_ephemeral_is_atomic", + "execution": "delegated_rust_test", + "authority": "v4_rejects_persisted_legacy_ephemeral_rows_atomically", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "PersistedEphemeralRawEvent" + }, + { + "id": "reopen_rejects_incoherent_capacity_authority", + "execution": "delegated_rust_test", + "authority": "reopen_full_measure_detects_every_persisted_capacity_dimension", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_on_reopen", + "error_domain": "typed", + "expected_error": "SourceCapacityStateDrift" + }, + { + "id": "reopen_stops_at_first_raw_event_one_over", + "execution": "delegated_rust_test", + "authority": "reopen_stops_at_the_first_raw_event_one_over_before_ephemeral_probe", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_events", + "boundary": "one_over", + "expected_outcome": "rejected_at_scan_bound", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "retained_generation_rebuild_exact", + "execution": "delegated_rust_test", + "authority": "ninth_current_v4_rebuild_is_typed_and_preflight_atomic", + "authority_path": "crates/event_store/src/store.rs", + "resource": "retained_source_generations", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "ninth_rebuild_is_typed_and_atomic", + "execution": "delegated_rust_test", + "authority": "ninth_current_v4_rebuild_is_typed_and_preflight_atomic", + "authority_path": "crates/event_store/src/store.rs", + "resource": "retained_source_generations", + "boundary": "one_over", + "expected_outcome": "rejected_before_entropy_or_mutation", + "error_domain": "typed", + "expected_error": "SourceGenerationHistoryLimitReached" + }, + { + "id": "retained_generation_sql_backstop_one_over", + "execution": "delegated_sql_test", + "authority": "generation_sql_backstop_allows_exact_append_and_is_conflict_safe_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "retained_source_generations", + "boundary": "one_over", + "expected_outcome": "rejected_by_sql_backstop", + "error_domain": "sqlite_database", + "expected_error": "event-store retained source generation limit reached; replace and resync into a fresh store" + }, + { + "id": "rebuild_marker_accepts_consistent_seals", + "execution": "delegated_rust_test", + "authority": "current_v4_rebuild_rotates_capacity_and_food_authority_end_to_end", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "rebuild_marker_rejects_incoherent_seals", + "execution": "delegated_sql_test", + "authority": "marker_close_sql_backstop_rejects_each_required_seal_drift", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_by_sql_backstop", + "error_domain": "sqlite_database", + "expected_error": "event-store rebuild marker cannot close before capacity, NIP-09, and FoodAvailability seals agree" + }, + { + "id": "v4_marker_repair_binds_exact_prior_and_floor", + "execution": "delegated_rust_test", + "authority": "v4_marker_open_allows_repairing_prior_transition_high_water_drift", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "managed_v4_rebuild", + "expected_outcome": "accepts_derived_high_water_repair_and_rejects_wrong_prior_or_floor", + "error_domain": "sqlite_database", + "expected_error": "exact raw and prior source authority" + }, + { + "id": "v4_food_reset_requires_target_rotation", + "execution": "delegated_rust_test", + "authority": "v4_food_reset_requires_marker_rotation_and_preserves_target_rows", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "managed_v4_rebuild", + "expected_outcome": "historical_rows_deleted_only_after_rotation_and_target_rows_preserved", + "error_domain": null, + "expected_error": null + }, + { + "id": "v4_down_restores_predecessor_triggers", + "execution": "delegated_rust_test", + "authority": "v4_down_restores_exact_predecessor_trigger_sql_and_fingerprint", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "v4_to_v3", + "expected_outcome": "restored_exact_predecessor_trigger_sql_and_v3_fingerprint", + "error_domain": null, + "expected_error": null + }, + { + "id": "utf16_open_file_rejected_before_mutation", + "execution": "delegated_rust_test", + "authority": "open_file_rejects_utf16_main_database_before_schema_or_journal_mutation", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_before_schema_or_journal_mutation", + "error_domain": "typed", + "expected_error": "SqliteMainDatabaseEncodingNotUtf8" + }, + { + "id": "utf16_open_pool_rejected_before_mutation", + "execution": "delegated_rust_test", + "authority": "open_pool_rejects_utf16_main_database_before_schema_or_journal_mutation", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_before_schema_or_journal_mutation", + "error_domain": "typed", + "expected_error": "SqliteMainDatabaseEncodingNotUtf8" + }, + { + "id": "utf8_non_ascii_nul_reopen_accounting", + "execution": "delegated_rust_test", + "authority": "utf8_file_reopen_preserves_non_ascii_and_nul_capacity_accounting", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted_with_exact_capacity_after_reopen", + "error_domain": null, + "expected_error": null + }, + { + "id": "generation_destructive_rollback_rejected", + "execution": "delegated_rust_test", + "authority": "rollback_rejects_below_floor_ahead_unmanaged_and_generation_destructive_targets", + "authority_path": "crates/event_store/src/schema.rs", + "resource": "retained_source_generations", + "boundary": null, + "expected_outcome": "rejected_before_mutation_with_status_and_history_preserved", + "error_domain": "typed", + "expected_error": "RollbackWouldDiscardSourceGenerationHistory" + }, + { + "id": "generation_destructive_two_step_rollback_rejected", + "execution": "delegated_rust_test", + "authority": "rollback_cannot_bypass_generation_history_guard_through_version_three", + "authority_path": "crates/event_store/src/schema.rs", + "resource": "retained_source_generations", + "boundary": null, + "expected_outcome": "rejected_before_mutation_after_history_preserving_intermediate_rollback", + "error_domain": "typed", + "expected_error": "RollbackWouldDiscardSourceGenerationHistory" + }, + { + "id": "independent_pool_last_byte_slot_race", + "execution": "delegated_rust_test", + "authority": "independent_file_pools_serialize_the_last_raw_event_byte_capacity_slot", + "authority_path": "crates/event_store/src/store.rs", + "resource": "raw_event_text_bytes", + "boundary": "exact", + "expected_outcome": "exactly_one_accepted_one_typed_rejection_and_clean_reopen", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + } + ] +} diff --git a/contracts/releases/1.0.0-alpha.1.toml b/contracts/releases/1.0.0-alpha.1.toml @@ -404,6 +404,22 @@ semver_impacts = [ 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 = "event-store-source-maintenance-authority" +classification = "breaking" +semver_impacts = [ + "add_exported_type", + "add_exported_function", + "add_exported_constant", + "add_enum_variant", + "add_conformance_vector", + "remove_exported_type", + "change_exported_enum_variant", + "change_exported_constant_value", + "change_exported_algorithm_behavior", +] +summary = "Advance the event store to schema version 4 with prospective retained-source capacity enforcement across independent file pools, UTF-8 preflight before schema or journal mutation, bounded reopen recounts, rollback-protected finite generation history, coherent NIP-09 and FoodAvailability rebuild seals, typed capacity and recovery failures, and an authenticated executable SourceMaintenance successor contract that replaces exactly radroots_event_store_food_availability_image_delete_guard, radroots_event_store_food_availability_projection_delete_guard, and radroots_event_store_source_rebuild_marker_insert_guard; rejects drifted v3 upgrades atomically; restores the exact predecessor trigger SQL on rollback; and reserves future derived-state repair for an exact managed-v4 catalog, ledger, migration history, immutable raw/source lineage, and capacity without requiring derived hook health as a precondition." + +[[changes]] id = "transport-event-outcomes-and-replica-quarantine" classification = "breaking" semver_impacts = [ diff --git a/crates/event_store/README b/crates/event_store/README @@ -49,8 +49,21 @@ 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 3 composes NIP-09 reconciliation with a generation-bound current -visibility view, generic addressable transition feed, and focused kind-`30402` +Every unique durable event is charged prospectively against the retained +raw-source event, tag, event-text-byte, and tag-text-byte limits before its raw +row is inserted. Admitted, unsupported, and contract-invalid durable events are +charged identically because all remain part of rebuild authority. Event-id +existence is checked first, so a duplicate at an exact capacity boundary still +follows the observation path without consuming capacity. The source-capacity +seal advances in the same savepoint as raw and derived authority; any ingest +failure rolls all four dimensions back. `source_capacity_v1` exposes the fast +persisted seal. Migration and database reopen perform the exhaustive raw-row +recount. + +Schema version 4 adds persisted source-capacity and retained-generation +authority to the version-3 composition of 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. @@ -76,10 +89,18 @@ boundary. A caller that executes arbitrary DML, reproduces an internal maintenance protocol, disables connection invariants, or changes the schema can subvert derived authority and is outside the supported mutation model. Typed event-store methods are the supported integrity-enforced write surface. +Replacing a database shared with unrelated caller-owned tables also removes +that unrelated state. Capacity recovery therefore requires a new disposable +event-cache database, not replacement of a shared application database. `open_pool` inspects the opened main database rather than trusting URL text, validates the declared backing mode, rejects multi-connection in-memory pools, -and configures every file-pool connection with foreign-key enforcement and the +and requires `PRAGMA main.encoding` to report exactly `UTF-8` on every supplied +connection before any schema or journal mutation. This makes Rust prospective +UTF-8 byte accounting identical to the SQLite `CAST(... AS BLOB)` authority; +pre-created UTF-16 databases fail with +`SqliteMainDatabaseEncodingNotUtf8` and are not remediated in place. The store +then configures every file-pool connection with foreign-key enforcement and the required busy timeout before migrations or writes. Backing inspection uses SQLite's non-shadowable `PRAGMA database_list`. Every supplied connection is also checked for temporary schema objects whose name or target table collides @@ -122,10 +143,17 @@ than quadratic per-ingest scans. ## Schema authority Every constructor converges the database through a versioned migration -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 +authority whose current version is `4`. The frozen `0001_event_store`, +`0002_nip09`, and `0003_food_availability_projection` predecessors remain +byte-identical and byte-pinned. The non-additive `0004_source_maintenance` is +also pinned by byte length and SHA-256 in the embedded registry. It +authenticates exactly three predecessor-trigger replacements: +`radroots_event_store_food_availability_image_delete_guard`, +`radroots_event_store_food_availability_projection_delete_guard`, and +`radroots_event_store_source_rebuild_marker_insert_guard`. Its declared catalog +delta requires exactly those three definitions to change while its new owned +objects are added or removed; the down migration restores the exact version-3 +trigger SQL. Fresh databases install the migrations 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, @@ -166,7 +194,13 @@ 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. +this checkpoint does not yet expose such a maintenance operation. Repair of +derived transition high-water or Food projection state is authorized only +inside that future rebuild after the exact managed-v4 catalog, ledger, and +migration history plus immutable raw/source lineage and capacity validate. +Derived hook state is the repair target, not a repair precondition. A drifted +managed-v3 database is rejected atomically before v4 changes begin; schema +upgrade is not a repair path for corrupt v3 authority. Every pending rebuild-bound migration, including `0002_nip09` and `0003_food_availability_projection`, preflights and then rechecks under the @@ -177,17 +211,37 @@ 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 -`ReconciliationCapacityExceeded` before schema changes; local cache owners can -reduce or rebuild the cache and retry. +`SourceCapacityExceeded` before schema changes or durable append. Raw rows are +never pruned in place. Recovery is to select a bounded source set and resync it +into a new disposable cache. + +Source generations are append-only and never pruned. At most eight generations +may be retained, bounding how many partitions of generation-scoped NIP-09 +logical authority can accumulate. For the governed 25,000-event and +250,000-tag source limits, the audited generation-partitioned NIP-09 bound is +`4E + 2T + 2 = 600,002` logical rows per generation: generation, coordinate, +request, combined event/address target, head-state, transition, and feed- +integrity rows. Eight retained generations therefore admit at most 4,800,016 +such logical rows. The current Food projection and FTS authority are reset +rather than retained for every generation. This is only a logical-row bound; +it is not a total SQLite row-count, byte-size, index-entry, or FTS-segment +bound. Once the eighth generation is retained, rebuild returns +`SourceGenerationHistoryLimitReached` with the current count and limit. The +supported recovery is replacement and resync into a fresh disposable cache, +subject to the shared-database warning above. `inspect_event_store_schema_status` classifies a pool as `Uninitialized`, `UnledgeredBaseline`, or `Managed` without configuring or migrating it. `schema_status` exposes the same read-only inspection on an opened store. `rollback_to_schema_version_and_close` validates every descending step under -`BEGIN EXCLUSIVE`, cannot cross the public version floor, consumes the store, -and closes its shared pool on every outcome. Independent SQLite pools for the -same file must be quiesced before this offline maintenance operation. -Destructive removal is intentionally unavailable in the public API. +`BEGIN EXCLUSIVE`, cannot cross the public version floor, and cannot cross the +applied NIP-09 source-generation migration because doing so would discard its +append-only history. `RollbackWouldDiscardSourceGenerationHistory` reports the +current, requested, and protected floor versions before any down migration is +applied. The operation consumes the store and closes its shared pool on every +outcome. Independent SQLite pools for the same file must be quiesced before +this offline maintenance operation. Destructive removal is intentionally +unavailable in the public API. ## Status inspection diff --git a/crates/event_store/contracts/source_maintenance_v1.manifest.json b/crates/event_store/contracts/source_maintenance_v1.manifest.json @@ -0,0 +1,357 @@ +{ + "schema_version": 1, + "contract_id": "radroots_event_store.source_maintenance_v1", + "hook_id": "source_maintenance_v1", + "manifest_schema": { + "path": "crates/event_store/contracts/source_maintenance_v1.manifest.schema.json", + "byte_length": 12315, + "sha256": "ad4a6c8ae9488fc8033792bc6952af04687f312901c1847d8c668a62913bb642", + "hash_algorithm": "sha256_bytes_v1" + }, + "predecessor": { + "hook_id": "food_availability_projection_v1", + "manifest": { + "path": "crates/event_store/contracts/food_availability_projection_v1.manifest.json", + "byte_length": 17455, + "sha256": "33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23", + "hash_algorithm": "sha256_bytes_v1" + } + }, + "migration": { + "version": 4, + "name": "source_maintenance", + "up": { + "path": "crates/event_store/migrations/0004_source_maintenance.up.sql", + "byte_length": 19841, + "sha256": "425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d", + "hash_algorithm": "sha256_bytes_v1" + }, + "down": { + "path": "crates/event_store/migrations/0004_source_maintenance.down.sql", + "byte_length": 5172, + "sha256": "fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860", + "hash_algorithm": "sha256_bytes_v1" + }, + "schema_sha256": "d526d96ea02be12b4b0aed99e97cfdde17c4474ace67111506a7b900ee78b186", + "catalog": { + "objects": [ + "radroots_event_store_source_capacity_delete_guard", + "radroots_event_store_source_capacity_insert_guard", + "radroots_event_store_source_capacity_marker_close_guard", + "radroots_event_store_source_capacity_update_guard", + "radroots_event_store_source_capacity_v1", + "radroots_event_store_source_generation_capacity_advance", + "radroots_event_store_source_generation_capacity_guard" + ], + "replaced_objects": [ + "radroots_event_store_food_availability_image_delete_guard", + "radroots_event_store_food_availability_projection_delete_guard", + "radroots_event_store_source_rebuild_marker_insert_guard" + ], + "tables": [ + "radroots_event_store_source_capacity_v1" + ], + "fts5_tables": [] + } + }, + "source_maintenance": { + "version": 1, + "event_contract_registry_version": 7, + "capacity_authority_id": "radroots_event_store_source_capacity_v1", + "accounting": { + "algorithm": "sqlite_cast_blob_octet_sum_v1", + "raw_event_columns": [ + "event_id", + "pubkey", + "tags_json", + "content", + "sig", + "raw_json" + ], + "raw_tag_columns": [ + "event_id", + "tag_name", + "tag_value", + "tag_json" + ], + "nullable_raw_tag_columns": [ + "tag_value" + ] + }, + "limits": { + "raw_events": 25000, + "raw_tags": 250000, + "raw_event_text_bytes": 67108864, + "raw_tag_text_bytes": 33554432, + "retained_source_generations": 8 + }, + "reopen_validation": { + "mode": "bounded_full_raw_recount_v1", + "raw_event_rejection_scan_bound": 25001, + "raw_tag_rejection_scan_bound": 250001, + "generation_history_validation": "bounded_count_plus_active_ordinal_v1", + "retained_generation_rejection_scan_bound": 9 + }, + "rebuild_seal": { + "nip09_hook_id": "nip09_reconciliation_v1", + "nip09_manifest_sha256": "74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77", + "food_hook_id": "food_availability_projection_v1", + "food_manifest_sha256": "33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23", + "food_scope_fingerprint_sha256": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0", + "active_generation_authority": "radroots_event_store_source_state", + "marker_close_authority": "radroots_event_store_source_capacity_marker_close_guard" + } + }, + "entry_points": [ + { + "role": "migration_registry", + "rust_path": "radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[3]" + }, + { + "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": "capacity_query", + "rust_path": "radroots_event_store::RadrootsEventStore::source_capacity_v1" + }, + { + "role": "raw_append_preflight", + "rust_path": "radroots_event_store::source_maintenance_v1::preflight_unique_raw_source_append_v1" + }, + { + "role": "raw_append_advance", + "rust_path": "radroots_event_store::source_maintenance_v1::advance_source_capacity_after_insert_v1" + }, + { + "role": "generation_append_preflight", + "rust_path": "radroots_event_store::source_maintenance_v1::preflight_source_generation_append_v1" + }, + { + "role": "generation_rebuild_bind", + "rust_path": "radroots_event_store::source_maintenance_v1::bind_source_capacity_to_generation_v1" + }, + { + "role": "sqlite_encoding_preflight", + "rust_path": "radroots_event_store::store::validate_main_database_encoding" + }, + { + "role": "source_generation_history_rollback_guard", + "rust_path": "radroots_event_store::schema::validate_rollback_preserves_source_generation_history" + }, + { + "role": "result_vector_executor", + "rust_path": "source_maintenance_v1_result_vector" + } + ], + "source_files": [ + { + "role": "event_store_error_and_limits", + "path": "crates/event_store/src/error.rs", + "byte_length": 19421, + "sha256": "4772e041cb20a4963afb2f3159804c777e2f2be61bfdb6ee267e7a7c04258972", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "generated_descriptor_registration", + "path": "crates/event_store/src/generated.rs", + "byte_length": 144, + "sha256": "6b0a8d6f249bd4fc3f878d37cb5e418680f0f1be2d9eec2518dedf03efc47121", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "public_surface", + "path": "crates/event_store/src/lib.rs", + "byte_length": 3844, + "sha256": "3cd9653bcb752fb3c4442d4904b98a0a6208011a9a238125b7b7073d7f4e312b", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "migration_registry", + "path": "crates/event_store/src/migrations.rs", + "byte_length": 73585, + "sha256": "a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "predecessor_model_public_surface", + "path": "crates/event_store/src/model.rs", + "byte_length": 33617, + "sha256": "79296b8f263aa06d17005795e4515f769f064ea6fd971eeb1296e1151debaf20", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "source_generation_rebuild_authority", + "path": "crates/event_store/src/nip09/reconciliation_v1.rs", + "byte_length": 184407, + "sha256": "c455d40fc736e3db264f567c7809af7bd897d89be8a33dfb21667a6ef6b8d6c6", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "schema_migration_and_reopen_authority", + "path": "crates/event_store/src/schema.rs", + "byte_length": 146146, + "sha256": "93b060e80d3edd73f86208e4bf698fa9d53eaf1eeb04526c9261fb8b5726fb0d", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "public_store_and_transaction_authority", + "path": "crates/event_store/src/store.rs", + "byte_length": 394574, + "sha256": "db57dc3e35e64c7194683142fe55edba853671a829269449dd2273056dfc3a0e", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "raw_ingest_capacity_authority", + "path": "crates/event_store/src/store/protocol_reconciliation_v1.rs", + "byte_length": 30140, + "sha256": "210112eeaa6975a3b4fbb97d5c52588f8c6d8d07975e531d39737fd11235de51", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "source_maintenance_runtime", + "path": "crates/event_store/src/source_maintenance_v1.rs", + "byte_length": 51756, + "sha256": "f8d5b62f0613104aa86658d5bf1baade92c7df83f00ef0cddadd734b9797afca", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "artifact_transaction_authority", + "path": "tools/xtask/src/contract/artifact_bundle.rs", + "byte_length": 38279, + "sha256": "f326ea57b56d40135f95b6b1e15961f66eed363337180a6d12a5ea903e1a9a29", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "predecessor_successor_governance", + "path": "tools/xtask/src/contract/food_availability_projection.rs", + "byte_length": 194875, + "sha256": "63cc3e6985741e2002ae59f56500bf8d6e0a8246f97a0b8b7f2d2372ef0642e0", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "transitive_predecessor_membership_governance", + "path": "tools/xtask/src/contract/nip09_reconciliation.rs", + "byte_length": 834200, + "sha256": "155374b306f3b30f6095dbfc203150c928b7418e646764011b0401996a636f84", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "source_maintenance_governance", + "path": "tools/xtask/src/contract/source_maintenance.rs", + "byte_length": 176693, + "sha256": "88ea58150c49faaad7dde6c9cc89a92ad6b693b112fd55041ac17984bc5c6700", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "contract_command_authority", + "path": "tools/xtask/src/contract.rs", + "byte_length": 479173, + "sha256": "15b3e754ed6794c8f4c48d7bd316b05ff90578137adb0275e1febdf2891707b4", + "hash_algorithm": "sha256_bytes_v1" + }, + { + "role": "xtask_dispatch_and_release_preflight", + "path": "tools/xtask/src/main.rs", + "byte_length": 14077, + "sha256": "d815e65241e47143a51dd87d95e014d975be1d9b94075f3920e4812f41188353", + "hash_algorithm": "sha256_bytes_v1" + } + ], + "public_api": { + "inherited_predecessor_symbols": [ + "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" + ], + "added_symbols": [ + "RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1", + "RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1", + "RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1", + "RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1", + "RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1", + "RadrootsEventStoreSourceCapacityResourceV1", + "RadrootsEventStoreSourceCapacityV1" + ], + "methods": [ + "RadrootsEventStore::source_capacity_v1", + "RadrootsEventStoreSourceCapacityResourceV1::as_str", + "RadrootsEventStoreSourceCapacityV1::source_generation", + "RadrootsEventStoreSourceCapacityV1::raw_event_count", + "RadrootsEventStoreSourceCapacityV1::raw_tag_count", + "RadrootsEventStoreSourceCapacityV1::raw_event_text_bytes", + "RadrootsEventStoreSourceCapacityV1::raw_tag_text_bytes", + "RadrootsEventStoreSourceCapacityV1::raw_high_water_seq", + "RadrootsEventStoreSourceCapacityV1::retained_generation_count", + "RadrootsEventStoreSourceCapacityV1::retained_generation_limit" + ], + "error_variants": [ + "SourceCapacityExceeded", + "SourceGenerationHistoryLimitReached", + "PersistedEphemeralRawEvent", + "SourceCapacityStateDrift", + "SqliteMainDatabaseEncodingNotUtf8", + "RollbackWouldDiscardSourceGenerationHistory" + ], + "removed_symbols": [ + "RadrootsEventStoreReconciliationResource", + "RadrootsEventStoreError::ReconciliationCapacityExceeded" + ], + "breaking_replacements": [ + { + "removed": "RadrootsEventStoreReconciliationResource", + "replacement": "RadrootsEventStoreSourceCapacityResourceV1" + }, + { + "removed": "RadrootsEventStoreError::ReconciliationCapacityExceeded", + "replacement": "RadrootsEventStoreError::SourceCapacityExceeded" + } + ] + }, + "result_vector": { + "canonical_path": "contracts/conformance/vectors/event_store/source_maintenance.v1.json", + "mirror_path": "crates/event_store/tests/fixtures/source_maintenance.v1.json", + "byte_length": 16253, + "sha256": "997aba2604a2b9d199fb87dc9d07942ca50d91863aeadcf3eeacf16d191dd71f", + "hash_algorithm": "sha256_bytes_v1", + "executor_id": "radroots_event_store.source_maintenance_v1.result_vector_executor.v1", + "executor_path": "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "executor_test": "source_maintenance_v1_result_vector", + "executor_byte_length": 23510, + "executor_sha256": "a7487afdfe19fc5fc794811d0f0e6035203e1aabcf0a33a1d398f6b3555d38f3", + "executor_hash_algorithm": "sha256_bytes_v1" + } +} diff --git a/crates/event_store/contracts/source_maintenance_v1.manifest.schema.json b/crates/event_store/contracts/source_maintenance_v1.manifest.schema.json @@ -0,0 +1,493 @@ +{ + "$defs": { + "accounting": { + "additionalProperties": false, + "properties": { + "algorithm": { + "const": "sqlite_cast_blob_octet_sum_v1" + }, + "nullable_raw_tag_columns": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "raw_event_columns": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "raw_tag_columns": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "algorithm", + "raw_event_columns", + "raw_tag_columns", + "nullable_raw_tag_columns" + ], + "type": "object" + }, + "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" + }, + "limits": { + "additionalProperties": false, + "properties": { + "raw_event_text_bytes": { + "const": 67108864 + }, + "raw_events": { + "const": 25000 + }, + "raw_tag_text_bytes": { + "const": 33554432 + }, + "raw_tags": { + "const": 250000 + }, + "retained_source_generations": { + "const": 8 + } + }, + "required": [ + "raw_events", + "raw_tags", + "raw_event_text_bytes", + "raw_tag_text_bytes", + "retained_source_generations" + ], + "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/contracts/event-store/source-maintenance-v1-manifest.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "additionalProperties": false, + "properties": { + "contract_id": { + "const": "radroots_event_store.source_maintenance_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" + }, + "hook_id": { + "const": "source_maintenance_v1" + }, + "manifest_schema": { + "$ref": "#/$defs/file" + }, + "migration": { + "additionalProperties": false, + "properties": { + "catalog": { + "additionalProperties": false, + "properties": { + "fts5_tables": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "objects": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "replaced_objects": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "tables": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "objects", + "replaced_objects", + "tables", + "fts5_tables" + ], + "type": "object" + }, + "down": { + "$ref": "#/$defs/file" + }, + "name": { + "const": "source_maintenance" + }, + "schema_sha256": { + "const": "d526d96ea02be12b4b0aed99e97cfdde17c4474ace67111506a7b900ee78b186" + }, + "up": { + "$ref": "#/$defs/file" + }, + "version": { + "const": 4 + } + }, + "required": [ + "version", + "name", + "up", + "down", + "schema_sha256", + "catalog" + ], + "type": "object" + }, + "predecessor": { + "additionalProperties": false, + "properties": { + "hook_id": { + "const": "food_availability_projection_v1" + }, + "manifest": { + "$ref": "#/$defs/file" + } + }, + "required": [ + "hook_id", + "manifest" + ], + "type": "object" + }, + "public_api": { + "additionalProperties": false, + "properties": { + "added_symbols": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "breaking_replacements": { + "items": { + "additionalProperties": false, + "properties": { + "removed": { + "minLength": 1, + "type": "string" + }, + "replacement": { + "minLength": 1, + "type": "string" + } + }, + "required": [ + "removed", + "replacement" + ], + "type": "object" + }, + "type": "array" + }, + "error_variants": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "inherited_predecessor_symbols": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "methods": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + }, + "removed_symbols": { + "items": { + "minLength": 1, + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "inherited_predecessor_symbols", + "added_symbols", + "methods", + "error_variants", + "removed_symbols", + "breaking_replacements" + ], + "type": "object" + }, + "result_vector": { + "additionalProperties": false, + "properties": { + "byte_length": { + "minimum": 1, + "type": "integer" + }, + "canonical_path": { + "const": "contracts/conformance/vectors/event_store/source_maintenance.v1.json" + }, + "executor_byte_length": { + "minimum": 1, + "type": "integer" + }, + "executor_hash_algorithm": { + "const": "sha256_bytes_v1" + }, + "executor_id": { + "const": "radroots_event_store.source_maintenance_v1.result_vector_executor.v1" + }, + "executor_path": { + "const": "crates/event_store/tests/source_maintenance_v1_result_vector.rs" + }, + "executor_sha256": { + "pattern": "^[0-9a-f]{64}$", + "type": "string" + }, + "executor_test": { + "const": "source_maintenance_v1_result_vector" + }, + "hash_algorithm": { + "const": "sha256_bytes_v1" + }, + "mirror_path": { + "const": "crates/event_store/tests/fixtures/source_maintenance.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" + }, + "source_maintenance": { + "additionalProperties": false, + "properties": { + "accounting": { + "$ref": "#/$defs/accounting" + }, + "capacity_authority_id": { + "const": "radroots_event_store_source_capacity_v1" + }, + "event_contract_registry_version": { + "const": 7 + }, + "limits": { + "$ref": "#/$defs/limits" + }, + "rebuild_seal": { + "additionalProperties": false, + "properties": { + "active_generation_authority": { + "const": "radroots_event_store_source_state" + }, + "food_hook_id": { + "const": "food_availability_projection_v1" + }, + "food_manifest_sha256": { + "const": "33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23" + }, + "food_scope_fingerprint_sha256": { + "const": "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0" + }, + "marker_close_authority": { + "const": "radroots_event_store_source_capacity_marker_close_guard" + }, + "nip09_hook_id": { + "const": "nip09_reconciliation_v1" + }, + "nip09_manifest_sha256": { + "const": "74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77" + } + }, + "required": [ + "nip09_hook_id", + "nip09_manifest_sha256", + "food_hook_id", + "food_manifest_sha256", + "food_scope_fingerprint_sha256", + "active_generation_authority", + "marker_close_authority" + ], + "type": "object" + }, + "reopen_validation": { + "additionalProperties": false, + "properties": { + "generation_history_validation": { + "const": "bounded_count_plus_active_ordinal_v1" + }, + "mode": { + "const": "bounded_full_raw_recount_v1" + }, + "raw_event_rejection_scan_bound": { + "const": 25001 + }, + "raw_tag_rejection_scan_bound": { + "const": 250001 + }, + "retained_generation_rejection_scan_bound": { + "const": 9 + } + }, + "required": [ + "mode", + "raw_event_rejection_scan_bound", + "raw_tag_rejection_scan_bound", + "generation_history_validation", + "retained_generation_rejection_scan_bound" + ], + "type": "object" + }, + "version": { + "const": 1 + } + }, + "required": [ + "version", + "event_contract_registry_version", + "capacity_authority_id", + "accounting", + "limits", + "reopen_validation", + "rebuild_seal" + ], + "type": "object" + } + }, + "required": [ + "schema_version", + "contract_id", + "hook_id", + "manifest_schema", + "predecessor", + "migration", + "source_maintenance", + "entry_points", + "source_files", + "public_api", + "result_vector" + ], + "title": "Radroots event-store SourceMaintenance v1 manifest", + "type": "object" +} diff --git a/crates/event_store/contracts/source_maintenance_v1.manifest.sha256 b/crates/event_store/contracts/source_maintenance_v1.manifest.sha256 @@ -0,0 +1 @@ +e8911e6e5710278969cbd15557a5b856b1575dfd11a655711403598370b41221 diff --git a/crates/event_store/migrations/0004_source_maintenance.down.sql b/crates/event_store/migrations/0004_source_maintenance.down.sql @@ -0,0 +1,138 @@ +DROP TRIGGER radroots_event_store_source_capacity_marker_close_guard; +DROP TRIGGER radroots_event_store_source_generation_capacity_advance; +DROP TRIGGER radroots_event_store_source_generation_capacity_guard; +DROP TRIGGER radroots_event_store_source_capacity_delete_guard; +DROP TRIGGER radroots_event_store_source_capacity_update_guard; +DROP TRIGGER radroots_event_store_source_capacity_insert_guard; +DROP TABLE radroots_event_store_source_capacity_v1; + +DROP TRIGGER radroots_event_store_food_availability_image_delete_guard; + +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; + +DROP TRIGGER radroots_event_store_food_availability_projection_delete_guard; + +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; + +DROP TRIGGER radroots_event_store_source_rebuild_marker_insert_guard; + +CREATE TRIGGER radroots_event_store_source_rebuild_marker_insert_guard +BEFORE INSERT ON radroots_event_store_source_rebuild_marker +WHEN EXISTS ( + SELECT 1 + FROM radroots_event_store_source_rebuild_marker +) +OR EXISTS ( + SELECT 1 + FROM radroots_event_store_source_generation + WHERE source_generation = NEW.target_generation +) +OR NEW.target_generation_ordinal != ( + SELECT COALESCE(MAX(generation_ordinal), 0) + 1 + FROM radroots_event_store_source_generation +) +OR NEW.transition_floor_seq != ( + SELECT COALESCE(MAX(transition_seq), 0) + FROM radroots_event_store_addressable_head_transition +) +OR NEW.baseline_raw_event_count != ( + SELECT COUNT(*) + FROM event_envelopes +) +OR NEW.baseline_raw_tag_count != ( + SELECT COUNT(*) + FROM event_envelope_tags +) +OR NEW.baseline_raw_high_water_seq != ( + SELECT COALESCE(MAX(seq), 0) + FROM event_envelopes +) +OR NOT ( + ( + NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_state + ) + AND NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_generation + ) + AND NEW.target_generation_ordinal = 1 + AND NEW.transition_floor_seq = 0 + AND NEW.prior_active_generation IS NULL + AND NEW.prior_raw_event_count IS NULL + AND NEW.prior_raw_tag_count IS NULL + AND NEW.prior_raw_high_water_seq IS NULL + AND NEW.prior_last_transition_seq IS NULL + ) + OR EXISTS ( + SELECT 1 + 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 + AND generation.generation_ordinal = ( + SELECT MAX(candidate.generation_ordinal) + FROM radroots_event_store_source_generation AS candidate + ) + AND NEW.target_generation_ordinal = generation.generation_ordinal + 1 + AND NEW.prior_active_generation = state.active_generation + AND NEW.prior_raw_event_count = state.raw_event_count + AND NEW.prior_raw_tag_count = state.raw_tag_count + AND NEW.prior_raw_high_water_seq = state.raw_high_water_seq + AND NEW.prior_last_transition_seq = state.last_transition_seq + AND NEW.transition_floor_seq = state.last_transition_seq + AND NEW.baseline_raw_event_count = state.raw_event_count + AND NEW.baseline_raw_tag_count = state.raw_tag_count + AND NEW.baseline_raw_high_water_seq = state.raw_high_water_seq + ) +) +BEGIN + SELECT RAISE(ABORT, 'event-store rebuild marker does not bind exact raw and prior source authority'); +END; diff --git a/crates/event_store/migrations/0004_source_maintenance.up.sql b/crates/event_store/migrations/0004_source_maintenance.up.sql @@ -0,0 +1,583 @@ +DROP TRIGGER radroots_event_store_source_rebuild_marker_insert_guard; + +CREATE TRIGGER radroots_event_store_source_rebuild_marker_insert_guard +BEFORE INSERT ON radroots_event_store_source_rebuild_marker +WHEN EXISTS ( + SELECT 1 + FROM radroots_event_store_source_rebuild_marker +) +OR EXISTS ( + SELECT 1 + FROM radroots_event_store_source_generation + WHERE source_generation = NEW.target_generation +) +OR NEW.target_generation_ordinal != ( + SELECT COALESCE(MAX(generation_ordinal), 0) + 1 + FROM radroots_event_store_source_generation +) +OR NEW.transition_floor_seq != ( + SELECT COALESCE(MAX(transition_seq), 0) + FROM radroots_event_store_addressable_head_transition +) +OR NEW.baseline_raw_event_count != ( + SELECT COUNT(*) + FROM event_envelopes +) +OR NEW.baseline_raw_tag_count != ( + SELECT COUNT(*) + FROM event_envelope_tags +) +OR NEW.baseline_raw_high_water_seq != ( + SELECT COALESCE(MAX(seq), 0) + FROM event_envelopes +) +OR NOT ( + ( + NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_state + ) + AND NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_generation + ) + AND NEW.target_generation_ordinal = 1 + AND NEW.transition_floor_seq = 0 + AND NEW.prior_active_generation IS NULL + AND NEW.prior_raw_event_count IS NULL + AND NEW.prior_raw_tag_count IS NULL + AND NEW.prior_raw_high_water_seq IS NULL + AND NEW.prior_last_transition_seq IS NULL + ) + OR EXISTS ( + SELECT 1 + 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 + AND generation.generation_ordinal = ( + SELECT MAX(candidate.generation_ordinal) + FROM radroots_event_store_source_generation AS candidate + ) + AND NEW.target_generation_ordinal = generation.generation_ordinal + 1 + AND NEW.prior_active_generation = state.active_generation + AND NEW.prior_raw_event_count = state.raw_event_count + AND NEW.prior_raw_tag_count = state.raw_tag_count + AND NEW.prior_raw_high_water_seq = state.raw_high_water_seq + AND NEW.prior_last_transition_seq = state.last_transition_seq + AND NEW.baseline_raw_event_count = state.raw_event_count + AND NEW.baseline_raw_tag_count = state.raw_tag_count + AND NEW.baseline_raw_high_water_seq = state.raw_high_water_seq + ) +) +BEGIN + SELECT RAISE(ABORT, 'event-store rebuild marker does not bind exact raw and prior source authority'); +END; + +DROP TRIGGER radroots_event_store_food_availability_projection_delete_guard; + +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 +) +AND NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_rebuild_marker AS marker + JOIN radroots_event_store_source_state AS source + ON source.singleton = marker.singleton + AND source.active_generation = marker.target_generation + WHERE marker.singleton = 1 + AND OLD.source_generation != source.active_generation +) +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability projection delete is not backed by a pending retraction or active source rebuild'); +END; + +DROP TRIGGER radroots_event_store_food_availability_image_delete_guard; + +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 +) +AND NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_rebuild_marker AS marker + JOIN radroots_event_store_source_state AS source + ON source.singleton = marker.singleton + AND source.active_generation = marker.target_generation + WHERE marker.singleton = 1 + AND OLD.source_generation != source.active_generation +) +BEGIN + SELECT RAISE(ABORT, 'event-store FoodAvailability image delete is not backed by a pending retraction or active source rebuild'); +END; + +CREATE TABLE radroots_event_store_source_capacity_v1 ( + singleton INTEGER PRIMARY KEY NOT NULL CHECK (singleton = 1), + source_generation BLOB NOT NULL UNIQUE CHECK ( + length(source_generation) = 32 + ) REFERENCES radroots_event_store_source_generation(source_generation) + ON DELETE RESTRICT, + raw_event_count INTEGER NOT NULL CHECK ( + raw_event_count >= 0 AND raw_event_count <= 25000 + ), + raw_tag_count INTEGER NOT NULL CHECK ( + raw_tag_count >= 0 AND raw_tag_count <= 250000 + ), + raw_event_bytes INTEGER NOT NULL CHECK ( + raw_event_bytes >= 0 AND raw_event_bytes <= 67108864 + ), + raw_tag_bytes INTEGER NOT NULL CHECK ( + raw_tag_bytes >= 0 AND raw_tag_bytes <= 33554432 + ), + raw_high_water_seq INTEGER NOT NULL CHECK (raw_high_water_seq >= 0), + retained_generation_count INTEGER NOT NULL CHECK ( + retained_generation_count >= 1 AND retained_generation_count <= 8 + ), + retained_generation_limit INTEGER NOT NULL CHECK ( + retained_generation_limit = 8 + ) +) STRICT, WITHOUT ROWID; + +CREATE TRIGGER radroots_event_store_source_capacity_insert_guard +BEFORE INSERT ON radroots_event_store_source_capacity_v1 +WHEN EXISTS ( + SELECT 1 + FROM radroots_event_store_source_capacity_v1 +) +OR NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_state AS state + WHERE state.singleton = 1 + AND NEW.singleton = state.singleton + AND NEW.source_generation = state.active_generation + AND NEW.raw_event_count = state.raw_event_count + AND NEW.raw_tag_count = state.raw_tag_count + AND NEW.raw_high_water_seq = state.raw_high_water_seq + AND NEW.raw_event_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM event_envelopes + LIMIT 25001 + ) + ) + AND NEW.raw_tag_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM event_envelope_tags + LIMIT 250001 + ) + ) + AND NEW.raw_event_bytes = ( + SELECT COALESCE(SUM(raw_bytes), 0) + FROM ( + SELECT + length(CAST(event_id AS BLOB)) + + length(CAST(pubkey AS BLOB)) + + length(CAST(tags_json AS BLOB)) + + length(CAST(content AS BLOB)) + + length(CAST(sig AS BLOB)) + + length(CAST(raw_json AS BLOB)) AS raw_bytes + FROM event_envelopes + LIMIT 25001 + ) + ) + AND NEW.raw_tag_bytes = ( + SELECT COALESCE(SUM(raw_bytes), 0) + FROM ( + SELECT + length(CAST(event_id AS BLOB)) + + length(CAST(tag_name AS BLOB)) + + COALESCE(length(CAST(tag_value AS BLOB)), 0) + + length(CAST(tag_json AS BLOB)) AS raw_bytes + FROM event_envelope_tags + LIMIT 250001 + ) + ) + AND NEW.retained_generation_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM radroots_event_store_source_generation + LIMIT 9 + ) + ) + AND NEW.retained_generation_limit = 8 +) +BEGIN + SELECT RAISE(ABORT, 'event-store source capacity initialization must seal exact raw and generation authority'); +END; + +CREATE TRIGGER radroots_event_store_source_capacity_update_guard +BEFORE UPDATE ON radroots_event_store_source_capacity_v1 +WHEN NOT ( + ( + NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_rebuild_marker + WHERE singleton = 1 + ) + AND NEW.singleton IS OLD.singleton + AND NEW.source_generation IS OLD.source_generation + AND NEW.retained_generation_count = OLD.retained_generation_count + AND NEW.retained_generation_limit = OLD.retained_generation_limit + AND NEW.raw_event_count = OLD.raw_event_count + 1 + AND NEW.raw_tag_count = OLD.raw_tag_count + ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM event_envelope_tags AS tag + JOIN event_envelopes AS event ON event.event_id = tag.event_id + WHERE event.seq > OLD.raw_high_water_seq + LIMIT 250001 + ) + ) + AND NEW.raw_event_bytes = OLD.raw_event_bytes + ( + SELECT COALESCE(SUM(raw_bytes), 0) + FROM ( + SELECT + length(CAST(event_id AS BLOB)) + + length(CAST(pubkey AS BLOB)) + + length(CAST(tags_json AS BLOB)) + + length(CAST(content AS BLOB)) + + length(CAST(sig AS BLOB)) + + length(CAST(raw_json AS BLOB)) AS raw_bytes + FROM event_envelopes + WHERE seq > OLD.raw_high_water_seq + LIMIT 2 + ) + ) + AND NEW.raw_tag_bytes = OLD.raw_tag_bytes + ( + SELECT COALESCE(SUM(raw_bytes), 0) + FROM ( + SELECT + length(CAST(tag.event_id AS BLOB)) + + length(CAST(tag.tag_name AS BLOB)) + + COALESCE(length(CAST(tag.tag_value AS BLOB)), 0) + + length(CAST(tag.tag_json AS BLOB)) AS raw_bytes + FROM event_envelope_tags AS tag + JOIN event_envelopes AS event ON event.event_id = tag.event_id + WHERE event.seq > OLD.raw_high_water_seq + LIMIT 250001 + ) + ) + AND NEW.raw_high_water_seq = ( + SELECT COALESCE(MAX(seq), 0) + FROM event_envelopes + ) + AND 1 = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM event_envelopes + WHERE seq > OLD.raw_high_water_seq + LIMIT 2 + ) + ) + AND EXISTS ( + SELECT 1 + FROM radroots_event_store_source_state AS state + WHERE state.singleton = 1 + AND state.active_generation = NEW.source_generation + AND state.raw_event_count = NEW.raw_event_count + AND state.raw_tag_count = NEW.raw_tag_count + AND state.raw_high_water_seq = NEW.raw_high_water_seq + ) + ) + OR ( + NEW.singleton IS OLD.singleton + AND NEW.source_generation IS OLD.source_generation + AND NEW.raw_event_count = OLD.raw_event_count + AND NEW.raw_tag_count = OLD.raw_tag_count + AND NEW.raw_event_bytes = OLD.raw_event_bytes + AND NEW.raw_tag_bytes = OLD.raw_tag_bytes + AND NEW.raw_high_water_seq = OLD.raw_high_water_seq + AND NEW.retained_generation_count = OLD.retained_generation_count + 1 + AND NEW.retained_generation_limit = OLD.retained_generation_limit + AND NEW.retained_generation_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM radroots_event_store_source_generation + LIMIT 9 + ) + ) + AND EXISTS ( + SELECT 1 + FROM radroots_event_store_source_rebuild_marker AS marker + JOIN radroots_event_store_source_generation AS generation + ON generation.source_generation = marker.target_generation + WHERE marker.singleton = 1 + AND generation.generation_ordinal = NEW.retained_generation_count + AND generation.generation_ordinal = ( + SELECT MAX(candidate.generation_ordinal) + FROM radroots_event_store_source_generation AS candidate + ) + ) + ) + OR ( + NEW.singleton IS OLD.singleton + AND NEW.source_generation IS NOT OLD.source_generation + AND NEW.raw_event_count = OLD.raw_event_count + AND NEW.raw_tag_count = OLD.raw_tag_count + AND NEW.raw_event_bytes = OLD.raw_event_bytes + AND NEW.raw_tag_bytes = OLD.raw_tag_bytes + AND NEW.raw_high_water_seq = OLD.raw_high_water_seq + AND NEW.retained_generation_count = OLD.retained_generation_count + AND NEW.retained_generation_limit = OLD.retained_generation_limit + AND EXISTS ( + SELECT 1 + FROM radroots_event_store_source_rebuild_marker AS marker + JOIN radroots_event_store_source_state AS state + ON state.singleton = marker.singleton + AND state.active_generation = marker.target_generation + WHERE marker.singleton = 1 + AND NEW.source_generation = marker.target_generation + AND NEW.source_generation = state.active_generation + AND NEW.raw_event_count = state.raw_event_count + AND NEW.raw_tag_count = state.raw_tag_count + AND NEW.raw_high_water_seq = state.raw_high_water_seq + AND NEW.raw_event_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM event_envelopes + LIMIT 25001 + ) + ) + AND NEW.raw_tag_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM event_envelope_tags + LIMIT 250001 + ) + ) + AND NEW.raw_event_bytes = ( + SELECT COALESCE(SUM(raw_bytes), 0) + FROM ( + SELECT + length(CAST(event_id AS BLOB)) + + length(CAST(pubkey AS BLOB)) + + length(CAST(tags_json AS BLOB)) + + length(CAST(content AS BLOB)) + + length(CAST(sig AS BLOB)) + + length(CAST(raw_json AS BLOB)) AS raw_bytes + FROM event_envelopes + LIMIT 25001 + ) + ) + AND NEW.raw_tag_bytes = ( + SELECT COALESCE(SUM(raw_bytes), 0) + FROM ( + SELECT + length(CAST(event_id AS BLOB)) + + length(CAST(tag_name AS BLOB)) + + COALESCE(length(CAST(tag_value AS BLOB)), 0) + + length(CAST(tag_json AS BLOB)) AS raw_bytes + FROM event_envelope_tags + LIMIT 250001 + ) + ) + AND NEW.retained_generation_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM radroots_event_store_source_generation + LIMIT 9 + ) + ) + ) + ) +) +BEGIN + SELECT RAISE(ABORT, 'event-store source capacity update is outside its append or rebuild phase'); +END; + +CREATE TRIGGER radroots_event_store_source_capacity_delete_guard +BEFORE DELETE ON radroots_event_store_source_capacity_v1 +BEGIN + SELECT RAISE(ABORT, 'event-store source capacity authority is immutable'); +END; + +CREATE TRIGGER radroots_event_store_source_generation_capacity_guard +BEFORE INSERT ON radroots_event_store_source_generation +WHEN EXISTS ( + SELECT 1 + FROM radroots_event_store_source_capacity_v1 + WHERE singleton = 1 + AND retained_generation_count >= retained_generation_limit +) +AND NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_generation + WHERE source_generation = NEW.source_generation + OR generation_ordinal = NEW.generation_ordinal +) +BEGIN + SELECT RAISE(ABORT, 'event-store retained source generation limit reached; replace and resync into a fresh store'); +END; + +CREATE TRIGGER radroots_event_store_source_generation_capacity_advance +AFTER INSERT ON radroots_event_store_source_generation +WHEN EXISTS ( + SELECT 1 + FROM radroots_event_store_source_capacity_v1 + WHERE singleton = 1 +) +BEGIN + UPDATE radroots_event_store_source_capacity_v1 + SET retained_generation_count = retained_generation_count + 1 + WHERE singleton = 1; + SELECT CASE + WHEN changes() != 1 + THEN RAISE(ABORT, 'event-store retained source generation authority did not advance') + END; +END; + +CREATE TRIGGER radroots_event_store_source_capacity_marker_close_guard +BEFORE DELETE ON radroots_event_store_source_rebuild_marker +WHEN NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_source_capacity_v1 AS capacity + JOIN radroots_event_store_source_state AS state + ON state.singleton = capacity.singleton + AND state.active_generation = capacity.source_generation + JOIN radroots_event_store_source_generation AS generation + ON generation.source_generation = state.active_generation + JOIN radroots_event_store_addressable_feed_integrity_v1 AS integrity + ON integrity.source_generation = state.active_generation + JOIN radroots_event_store_food_availability_cursor AS cursor + ON cursor.singleton = state.singleton + AND cursor.source_generation = state.active_generation + WHERE capacity.singleton = 1 + AND OLD.singleton = capacity.singleton + AND OLD.target_generation = capacity.source_generation + AND capacity.raw_event_count = state.raw_event_count + AND capacity.raw_tag_count = state.raw_tag_count + AND capacity.raw_high_water_seq = state.raw_high_water_seq + AND capacity.raw_event_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM event_envelopes + LIMIT 25001 + ) + ) + AND capacity.raw_tag_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM event_envelope_tags + LIMIT 250001 + ) + ) + AND capacity.raw_event_bytes = ( + SELECT COALESCE(SUM(raw_bytes), 0) + FROM ( + SELECT + length(CAST(event_id AS BLOB)) + + length(CAST(pubkey AS BLOB)) + + length(CAST(tags_json AS BLOB)) + + length(CAST(content AS BLOB)) + + length(CAST(sig AS BLOB)) + + length(CAST(raw_json AS BLOB)) AS raw_bytes + FROM event_envelopes + LIMIT 25001 + ) + ) + AND capacity.raw_tag_bytes = ( + SELECT COALESCE(SUM(raw_bytes), 0) + FROM ( + SELECT + length(CAST(event_id AS BLOB)) + + length(CAST(tag_name AS BLOB)) + + COALESCE(length(CAST(tag_value AS BLOB)), 0) + + length(CAST(tag_json AS BLOB)) AS raw_bytes + FROM event_envelope_tags + LIMIT 250001 + ) + ) + AND capacity.retained_generation_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM radroots_event_store_source_generation + LIMIT 9 + ) + ) + AND capacity.retained_generation_limit = 8 + AND generation.generation_ordinal = capacity.retained_generation_count + AND integrity.transition_floor_seq = generation.transition_floor_seq + AND integrity.last_transition_seq = state.last_transition_seq + AND integrity.transition_count = + state.last_transition_seq - generation.transition_floor_seq + AND cursor.feed_version = 1 + AND cursor.projection_version = 1 + AND hex(cursor.scope_fingerprint) = + '8B63C5DDC48A2CC7DB69295238B96D5F814DBA50427C80B4D0079F061E6D3DE0' + AND cursor.hook_manifest_sha256 = + '33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23' + AND cursor.last_transition_seq = state.last_transition_seq + AND cursor.projected_row_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM radroots_event_store_food_availability_projection + WHERE source_generation = state.active_generation + LIMIT 25001 + ) + ) + AND cursor.projected_row_count = ( + SELECT COUNT(*) + FROM ( + SELECT 1 + FROM radroots_event_store_food_availability_search_fts + LIMIT 25001 + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM radroots_event_store_food_availability_projection + WHERE source_generation != state.active_generation + ) +) +BEGIN + SELECT RAISE(ABORT, 'event-store rebuild marker cannot close before capacity, NIP-09, and FoodAvailability seals agree'); +END; diff --git a/crates/event_store/src/error.rs b/crates/event_store/src/error.rs @@ -4,10 +4,21 @@ use radroots_event::wire::RadrootsEventWireError; use radroots_event_codec::verification::RadrootsNip01VerificationError; use radroots_transport::RadrootsTransportError; -/// Resource dimension that bounded NIP-09 reconciliation exceeded. +/// Maximum retained raw event rows in one event store. +pub const RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1: u64 = 25_000; +/// Maximum retained raw tag rows in one event store. +pub const RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1: u64 = 250_000; +/// Maximum governed UTF-8 bytes in retained raw event text columns. +pub const RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1: u64 = 64 * 1024 * 1024; +/// Maximum governed UTF-8 bytes in retained raw tag text columns. +pub const RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1: u64 = 32 * 1024 * 1024; +/// Maximum append-only source generations retained before fresh-store resync. +pub const RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1: u32 = 8; + +/// Governed retained raw-source resource dimension. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[non_exhaustive] -pub enum RadrootsEventStoreReconciliationResource { +pub enum RadrootsEventStoreSourceCapacityResourceV1 { /// Number of retained raw-source event rows. RawEvents, /// Number of retained raw-source tag rows. @@ -18,7 +29,7 @@ pub enum RadrootsEventStoreReconciliationResource { RawTagBytes, } -impl RadrootsEventStoreReconciliationResource { +impl RadrootsEventStoreSourceCapacityResourceV1 { /// Stable diagnostic label used by the typed capacity error. pub const fn as_str(self) -> &'static str { match self { @@ -30,7 +41,7 @@ impl RadrootsEventStoreReconciliationResource { } } -impl core::fmt::Display for RadrootsEventStoreReconciliationResource { +impl core::fmt::Display for RadrootsEventStoreSourceCapacityResourceV1 { fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { formatter.write_str(self.as_str()) } @@ -114,6 +125,8 @@ pub enum RadrootsEventStoreError { SqlitePoolBackingMismatch { file_backed: bool, filename: String }, #[error("event-store SQLite connection has no main database")] SqliteMainDatabaseUnavailable, + #[error("event-store SQLite main database must use UTF-8 encoding; reported `{actual}`")] + SqliteMainDatabaseEncodingNotUtf8 { actual: String }, #[error( "event-store SQLite file connection did not enter WAL journal mode; reported `{actual}`" )] @@ -189,6 +202,14 @@ pub enum RadrootsEventStoreError { RollbackBelowVersionFloor { floor: u32, target: u32 }, #[error("event-store rollback target {target} is ahead of managed version {current}")] RollbackAhead { current: u32, target: u32 }, + #[error( + "event-store rollback from version {current} to {target} would discard retained source-generation history; minimum retained-history schema version is {floor}" + )] + RollbackWouldDiscardSourceGenerationHistory { + current: u32, + target: u32, + floor: u32, + }, #[error("event-store rollback requires a managed schema")] RollbackUnmanaged, #[error( @@ -210,15 +231,26 @@ pub enum RadrootsEventStoreError { #[error("event-store source generation entropy is unavailable")] SourceGenerationEntropyUnavailable, #[error( - "event-store NIP-09 reconciliation {resource} capacity exceeded: observed {actual}, limit {limit}" + "event-store retained source {resource} capacity exceeded: current {current}, requested additional {requested}, limit {limit}; durable append refused, retain a bounded source set in a new disposable cache" )] - /// Refuses a one-time local-store migration before unbounded retention or - /// partial writes; callers can recover by pruning or rebuilding the cache. - ReconciliationCapacityExceeded { - resource: RadrootsEventStoreReconciliationResource, - actual: u64, + /// Refuses migration or prospective durable ingest before the retained raw + /// source can become unrebuildable. Immutable raw rows are never pruned. + SourceCapacityExceeded { + resource: RadrootsEventStoreSourceCapacityResourceV1, + current: u64, + requested: u64, limit: u64, }, + #[error( + "event-store retained source generation limit reached: current {current}, limit {limit}; replace and resync into a fresh store" + )] + SourceGenerationHistoryLimitReached { current: u32, limit: u32 }, + #[error( + "event-store retained source contains ephemeral event `{event_id}` of kind {kind}; ephemeral events must be discarded" + )] + PersistedEphemeralRawEvent { event_id: String, kind: i64 }, + #[error("event-store retained source capacity authority is inconsistent: {reason}")] + SourceCapacityStateDrift { reason: String }, #[error("event-store migration hook `{hook_id}` state is invalid: {reason}")] MigrationHookStateDrift { hook_id: &'static str, diff --git a/crates/event_store/src/generated.rs b/crates/event_store/src/generated.rs @@ -1,2 +1,3 @@ pub(crate) mod food_availability_projection_manifest; pub(crate) mod nip09_reconciliation_manifest; +pub(crate) mod source_maintenance_manifest; diff --git a/crates/event_store/src/generated/source_maintenance_manifest.rs b/crates/event_store/src/generated/source_maintenance_manifest.rs @@ -0,0 +1,54 @@ +// @generated by `cargo xtask contract source-maintenance-manifest --write`; do not edit. +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_JSON: &str = "{\n \"schema_version\": 1,\n \"contract_id\": \"radroots_event_store.source_maintenance_v1\",\n \"hook_id\": \"source_maintenance_v1\",\n \"manifest_schema\": {\n \"path\": \"crates/event_store/contracts/source_maintenance_v1.manifest.schema.json\",\n \"byte_length\": 12315,\n \"sha256\": \"ad4a6c8ae9488fc8033792bc6952af04687f312901c1847d8c668a62913bb642\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"predecessor\": {\n \"hook_id\": \"food_availability_projection_v1\",\n \"manifest\": {\n \"path\": \"crates/event_store/contracts/food_availability_projection_v1.manifest.json\",\n \"byte_length\": 17455,\n \"sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n },\n \"migration\": {\n \"version\": 4,\n \"name\": \"source_maintenance\",\n \"up\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.up.sql\",\n \"byte_length\": 19841,\n \"sha256\": \"425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"down\": {\n \"path\": \"crates/event_store/migrations/0004_source_maintenance.down.sql\",\n \"byte_length\": 5172,\n \"sha256\": \"fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n \"schema_sha256\": \"d526d96ea02be12b4b0aed99e97cfdde17c4474ace67111506a7b900ee78b186\",\n \"catalog\": {\n \"objects\": [\n \"radroots_event_store_source_capacity_delete_guard\",\n \"radroots_event_store_source_capacity_insert_guard\",\n \"radroots_event_store_source_capacity_marker_close_guard\",\n \"radroots_event_store_source_capacity_update_guard\",\n \"radroots_event_store_source_capacity_v1\",\n \"radroots_event_store_source_generation_capacity_advance\",\n \"radroots_event_store_source_generation_capacity_guard\"\n ],\n \"replaced_objects\": [\n \"radroots_event_store_food_availability_image_delete_guard\",\n \"radroots_event_store_food_availability_projection_delete_guard\",\n \"radroots_event_store_source_rebuild_marker_insert_guard\"\n ],\n \"tables\": [\n \"radroots_event_store_source_capacity_v1\"\n ],\n \"fts5_tables\": []\n }\n },\n \"source_maintenance\": {\n \"version\": 1,\n \"event_contract_registry_version\": 7,\n \"capacity_authority_id\": \"radroots_event_store_source_capacity_v1\",\n \"accounting\": {\n \"algorithm\": \"sqlite_cast_blob_octet_sum_v1\",\n \"raw_event_columns\": [\n \"event_id\",\n \"pubkey\",\n \"tags_json\",\n \"content\",\n \"sig\",\n \"raw_json\"\n ],\n \"raw_tag_columns\": [\n \"event_id\",\n \"tag_name\",\n \"tag_value\",\n \"tag_json\"\n ],\n \"nullable_raw_tag_columns\": [\n \"tag_value\"\n ]\n },\n \"limits\": {\n \"raw_events\": 25000,\n \"raw_tags\": 250000,\n \"raw_event_text_bytes\": 67108864,\n \"raw_tag_text_bytes\": 33554432,\n \"retained_source_generations\": 8\n },\n \"reopen_validation\": {\n \"mode\": \"bounded_full_raw_recount_v1\",\n \"raw_event_rejection_scan_bound\": 25001,\n \"raw_tag_rejection_scan_bound\": 250001,\n \"generation_history_validation\": \"bounded_count_plus_active_ordinal_v1\",\n \"retained_generation_rejection_scan_bound\": 9\n },\n \"rebuild_seal\": {\n \"nip09_hook_id\": \"nip09_reconciliation_v1\",\n \"nip09_manifest_sha256\": \"74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77\",\n \"food_hook_id\": \"food_availability_projection_v1\",\n \"food_manifest_sha256\": \"33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23\",\n \"food_scope_fingerprint_sha256\": \"8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0\",\n \"active_generation_authority\": \"radroots_event_store_source_state\",\n \"marker_close_authority\": \"radroots_event_store_source_capacity_marker_close_guard\"\n }\n },\n \"entry_points\": [\n {\n \"role\": \"migration_registry\",\n \"rust_path\": \"radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[3]\"\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\": \"capacity_query\",\n \"rust_path\": \"radroots_event_store::RadrootsEventStore::source_capacity_v1\"\n },\n {\n \"role\": \"raw_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_unique_raw_source_append_v1\"\n },\n {\n \"role\": \"raw_append_advance\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::advance_source_capacity_after_insert_v1\"\n },\n {\n \"role\": \"generation_append_preflight\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::preflight_source_generation_append_v1\"\n },\n {\n \"role\": \"generation_rebuild_bind\",\n \"rust_path\": \"radroots_event_store::source_maintenance_v1::bind_source_capacity_to_generation_v1\"\n },\n {\n \"role\": \"sqlite_encoding_preflight\",\n \"rust_path\": \"radroots_event_store::store::validate_main_database_encoding\"\n },\n {\n \"role\": \"source_generation_history_rollback_guard\",\n \"rust_path\": \"radroots_event_store::schema::validate_rollback_preserves_source_generation_history\"\n },\n {\n \"role\": \"result_vector_executor\",\n \"rust_path\": \"source_maintenance_v1_result_vector\"\n }\n ],\n \"source_files\": [\n {\n \"role\": \"event_store_error_and_limits\",\n \"path\": \"crates/event_store/src/error.rs\",\n \"byte_length\": 19421,\n \"sha256\": \"4772e041cb20a4963afb2f3159804c777e2f2be61bfdb6ee267e7a7c04258972\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"generated_descriptor_registration\",\n \"path\": \"crates/event_store/src/generated.rs\",\n \"byte_length\": 144,\n \"sha256\": \"6b0a8d6f249bd4fc3f878d37cb5e418680f0f1be2d9eec2518dedf03efc47121\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_surface\",\n \"path\": \"crates/event_store/src/lib.rs\",\n \"byte_length\": 3844,\n \"sha256\": \"3cd9653bcb752fb3c4442d4904b98a0a6208011a9a238125b7b7073d7f4e312b\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"migration_registry\",\n \"path\": \"crates/event_store/src/migrations.rs\",\n \"byte_length\": 73585,\n \"sha256\": \"a47477d04759ec6e71d14d3d05459864eae78fce0e3c2481a1b03147f04637f7\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_model_public_surface\",\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\": \"source_generation_rebuild_authority\",\n \"path\": \"crates/event_store/src/nip09/reconciliation_v1.rs\",\n \"byte_length\": 184407,\n \"sha256\": \"c455d40fc736e3db264f567c7809af7bd897d89be8a33dfb21667a6ef6b8d6c6\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"schema_migration_and_reopen_authority\",\n \"path\": \"crates/event_store/src/schema.rs\",\n \"byte_length\": 146146,\n \"sha256\": \"93b060e80d3edd73f86208e4bf698fa9d53eaf1eeb04526c9261fb8b5726fb0d\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"public_store_and_transaction_authority\",\n \"path\": \"crates/event_store/src/store.rs\",\n \"byte_length\": 394574,\n \"sha256\": \"db57dc3e35e64c7194683142fe55edba853671a829269449dd2273056dfc3a0e\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"raw_ingest_capacity_authority\",\n \"path\": \"crates/event_store/src/store/protocol_reconciliation_v1.rs\",\n \"byte_length\": 30140,\n \"sha256\": \"210112eeaa6975a3b4fbb97d5c52588f8c6d8d07975e531d39737fd11235de51\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_runtime\",\n \"path\": \"crates/event_store/src/source_maintenance_v1.rs\",\n \"byte_length\": 51756,\n \"sha256\": \"f8d5b62f0613104aa86658d5bf1baade92c7df83f00ef0cddadd734b9797afca\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"artifact_transaction_authority\",\n \"path\": \"tools/xtask/src/contract/artifact_bundle.rs\",\n \"byte_length\": 38279,\n \"sha256\": \"f326ea57b56d40135f95b6b1e15961f66eed363337180a6d12a5ea903e1a9a29\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"predecessor_successor_governance\",\n \"path\": \"tools/xtask/src/contract/food_availability_projection.rs\",\n \"byte_length\": 194875,\n \"sha256\": \"63cc3e6985741e2002ae59f56500bf8d6e0a8246f97a0b8b7f2d2372ef0642e0\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"transitive_predecessor_membership_governance\",\n \"path\": \"tools/xtask/src/contract/nip09_reconciliation.rs\",\n \"byte_length\": 834200,\n \"sha256\": \"155374b306f3b30f6095dbfc203150c928b7418e646764011b0401996a636f84\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"source_maintenance_governance\",\n \"path\": \"tools/xtask/src/contract/source_maintenance.rs\",\n \"byte_length\": 176693,\n \"sha256\": \"88ea58150c49faaad7dde6c9cc89a92ad6b693b112fd55041ac17984bc5c6700\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"contract_command_authority\",\n \"path\": \"tools/xtask/src/contract.rs\",\n \"byte_length\": 479173,\n \"sha256\": \"15b3e754ed6794c8f4c48d7bd316b05ff90578137adb0275e1febdf2891707b4\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n },\n {\n \"role\": \"xtask_dispatch_and_release_preflight\",\n \"path\": \"tools/xtask/src/main.rs\",\n \"byte_length\": 14077,\n \"sha256\": \"d815e65241e47143a51dd87d95e014d975be1d9b94075f3920e4812f41188353\",\n \"hash_algorithm\": \"sha256_bytes_v1\"\n }\n ],\n \"public_api\": {\n \"inherited_predecessor_symbols\": [\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 \"added_symbols\": [\n \"RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1\",\n \"RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1\",\n \"RadrootsEventStoreSourceCapacityResourceV1\",\n \"RadrootsEventStoreSourceCapacityV1\"\n ],\n \"methods\": [\n \"RadrootsEventStore::source_capacity_v1\",\n \"RadrootsEventStoreSourceCapacityResourceV1::as_str\",\n \"RadrootsEventStoreSourceCapacityV1::source_generation\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_count\",\n \"RadrootsEventStoreSourceCapacityV1::raw_event_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_tag_text_bytes\",\n \"RadrootsEventStoreSourceCapacityV1::raw_high_water_seq\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_count\",\n \"RadrootsEventStoreSourceCapacityV1::retained_generation_limit\"\n ],\n \"error_variants\": [\n \"SourceCapacityExceeded\",\n \"SourceGenerationHistoryLimitReached\",\n \"PersistedEphemeralRawEvent\",\n \"SourceCapacityStateDrift\",\n \"SqliteMainDatabaseEncodingNotUtf8\",\n \"RollbackWouldDiscardSourceGenerationHistory\"\n ],\n \"removed_symbols\": [\n \"RadrootsEventStoreReconciliationResource\",\n \"RadrootsEventStoreError::ReconciliationCapacityExceeded\"\n ],\n \"breaking_replacements\": [\n {\n \"removed\": \"RadrootsEventStoreReconciliationResource\",\n \"replacement\": \"RadrootsEventStoreSourceCapacityResourceV1\"\n },\n {\n \"removed\": \"RadrootsEventStoreError::ReconciliationCapacityExceeded\",\n \"replacement\": \"RadrootsEventStoreError::SourceCapacityExceeded\"\n }\n ]\n },\n \"result_vector\": {\n \"canonical_path\": \"contracts/conformance/vectors/event_store/source_maintenance.v1.json\",\n \"mirror_path\": \"crates/event_store/tests/fixtures/source_maintenance.v1.json\",\n \"byte_length\": 16253,\n \"sha256\": \"997aba2604a2b9d199fb87dc9d07942ca50d91863aeadcf3eeacf16d191dd71f\",\n \"hash_algorithm\": \"sha256_bytes_v1\",\n \"executor_id\": \"radroots_event_store.source_maintenance_v1.result_vector_executor.v1\",\n \"executor_path\": \"crates/event_store/tests/source_maintenance_v1_result_vector.rs\",\n \"executor_test\": \"source_maintenance_v1_result_vector\",\n \"executor_byte_length\": 23510,\n \"executor_sha256\": \"a7487afdfe19fc5fc794811d0f0e6035203e1aabcf0a33a1d398f6b3555d38f3\",\n \"executor_hash_algorithm\": \"sha256_bytes_v1\"\n }\n}\n"; +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_BYTE_LENGTH: usize = 14216; +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SHA256: &str = + "e8911e6e5710278969cbd15557a5b856b1575dfd11a655711403598370b41221"; +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SCHEMA_VERSION: u32 = 1; +pub(crate) const SOURCE_MAINTENANCE_CONTRACT_ID: &str = + "radroots_event_store.source_maintenance_v1"; +pub(crate) const SOURCE_MAINTENANCE_HOOK_ID: &str = "source_maintenance_v1"; +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_VERSION: u32 = 4; +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_NAME: &str = "source_maintenance"; +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_UP_BYTE_LENGTH: usize = 19841; +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_UP_SHA256: &str = + "425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d"; +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_DOWN_BYTE_LENGTH: usize = 5172; +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_DOWN_SHA256: &str = + "fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860"; +pub(crate) const SOURCE_MAINTENANCE_SCHEMA_SHA256: &str = + "d526d96ea02be12b4b0aed99e97cfdde17c4474ace67111506a7b900ee78b186"; +pub(crate) const SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION: u32 = 7; +pub(crate) const SOURCE_MAINTENANCE_CAPACITY_VERSION: u32 = 1; +pub(crate) const SOURCE_MAINTENANCE_CAPACITY_AUTHORITY_ID: &str = + "radroots_event_store_source_capacity_v1"; +pub(crate) const SOURCE_MAINTENANCE_ACCOUNTING_ALGORITHM: &str = "sqlite_cast_blob_octet_sum_v1"; +pub(crate) const SOURCE_MAINTENANCE_RAW_EVENT_COLUMNS: &[&str] = &[ + "event_id", + "pubkey", + "tags_json", + "content", + "sig", + "raw_json", +]; +pub(crate) const SOURCE_MAINTENANCE_RAW_TAG_COLUMNS: &[&str] = + &["event_id", "tag_name", "tag_value", "tag_json"]; +pub(crate) const SOURCE_MAINTENANCE_NULLABLE_RAW_TAG_COLUMNS: &[&str] = &["tag_value"]; +pub(crate) const SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES: &[&str] = &[ + "radroots_event_store_food_availability_image_delete_guard", + "radroots_event_store_food_availability_projection_delete_guard", + "radroots_event_store_source_rebuild_marker_insert_guard", +]; +pub(crate) const SOURCE_MAINTENANCE_RAW_EVENT_COUNT_LIMIT: u64 = 25000; +pub(crate) const SOURCE_MAINTENANCE_RAW_TAG_COUNT_LIMIT: u64 = 250000; +pub(crate) const SOURCE_MAINTENANCE_RAW_EVENT_TEXT_BYTES_LIMIT: u64 = 67108864; +pub(crate) const SOURCE_MAINTENANCE_RAW_TAG_TEXT_BYTES_LIMIT: u64 = 33554432; +pub(crate) const SOURCE_MAINTENANCE_RETAINED_SOURCE_GENERATION_LIMIT: u32 = 8; +pub(crate) const SOURCE_MAINTENANCE_PREDECESSOR_HOOK_ID: &str = "food_availability_projection_v1"; +pub(crate) const SOURCE_MAINTENANCE_PREDECESSOR_MANIFEST_SHA256: &str = + "33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23"; +pub(crate) const SOURCE_MAINTENANCE_RESULT_VECTOR_SHA256: &str = + "997aba2604a2b9d199fb87dc9d07942ca50d91863aeadcf3eeacf16d191dd71f"; +pub(crate) const SOURCE_MAINTENANCE_RESULT_VECTOR_EXECUTOR_ID: &str = + "radroots_event_store.source_maintenance_v1.result_vector_executor.v1"; +pub(crate) const SOURCE_MAINTENANCE_RESULT_VECTOR_EXECUTOR_SHA256: &str = + "a7487afdfe19fc5fc794811d0f0e6035203e1aabcf0a33a1d398f6b3555d38f3"; diff --git a/crates/event_store/src/lib.rs b/crates/event_store/src/lib.rs @@ -14,10 +14,18 @@ mod nip09; #[cfg(feature = "sqlite")] mod schema; #[cfg(feature = "sqlite")] +mod source_maintenance_v1; +#[cfg(feature = "sqlite")] mod store; #[cfg(feature = "sqlite")] -pub use error::{RadrootsEventStoreError, RadrootsEventStoreReconciliationResource}; +pub use error::{ + RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1, + RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1, + RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1, RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1, + RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1, RadrootsEventStoreError, + RadrootsEventStoreSourceCapacityResourceV1, +}; #[cfg(feature = "sqlite")] pub use migrations::{ RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, @@ -61,6 +69,8 @@ pub use model::{ #[cfg(feature = "sqlite")] pub use schema::{RadrootsEventStoreSchemaStatus, inspect_event_store_schema_status}; #[cfg(feature = "sqlite")] +pub use source_maintenance_v1::RadrootsEventStoreSourceCapacityV1; +#[cfg(feature = "sqlite")] pub use store::{ RADROOTS_EVENT_STORE_CONTRACT_QUERY_LIMIT_MAX, RADROOTS_EVENT_STORE_QUERY_LIMIT_MAX, RadrootsEventStore, RadrootsTransportObservationRow, inspect_event_store_status, diff --git a/crates/event_store/src/migrations.rs b/crates/event_store/src/migrations.rs @@ -1,6 +1,7 @@ use crate::RadrootsEventStoreError; use crate::generated::food_availability_projection_manifest as food_manifest; use crate::generated::nip09_reconciliation_manifest as nip09_manifest; +use crate::generated::source_maintenance_manifest; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; @@ -8,7 +9,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 = 3; +pub const RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT: u32 = 4; pub(crate) const EVENT_STORE_LEDGER_DDL: &str = "CREATE TABLE radroots_event_store_schema_migrations ( version INTEGER PRIMARY KEY NOT NULL CHECK (version > 0), @@ -105,6 +106,7 @@ pub(crate) enum EventStoreMigrationHook { None, Nip09ReconciliationV1, FoodAvailabilityProjectionV1, + SourceMaintenanceV1, } impl EventStoreMigrationHook { @@ -115,6 +117,7 @@ impl EventStoreMigrationHook { Self::FoodAvailabilityProjectionV1 => { food_manifest::FOOD_AVAILABILITY_PROJECTION_HOOK_ID } + Self::SourceMaintenanceV1 => source_maintenance_manifest::SOURCE_MAINTENANCE_HOOK_ID, } } @@ -127,10 +130,32 @@ impl EventStoreMigrationHook { Self::FoodAvailabilityProjectionV1 => { Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256) } + Self::SourceMaintenanceV1 => { + Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256) + } } } } +pub(crate) const EVENT_STORE_SOURCE_MAINTENANCE_OBJECT_NAMES: &[&str] = &[ + "radroots_event_store_source_capacity_delete_guard", + "radroots_event_store_source_capacity_insert_guard", + "radroots_event_store_source_capacity_marker_close_guard", + "radroots_event_store_source_capacity_update_guard", + "radroots_event_store_source_capacity_v1", + "radroots_event_store_source_generation_capacity_advance", + "radroots_event_store_source_generation_capacity_guard", +]; + +pub(crate) const EVENT_STORE_SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES: &[&str] = &[ + "radroots_event_store_food_availability_image_delete_guard", + "radroots_event_store_food_availability_projection_delete_guard", + "radroots_event_store_source_rebuild_marker_insert_guard", +]; + +pub(crate) const EVENT_STORE_SOURCE_MAINTENANCE_TABLE_NAMES: &[&str] = + &["radroots_event_store_source_capacity_v1"]; + pub(crate) const EVENT_STORE_FOOD_AVAILABILITY_OBJECT_NAMES: &[&str] = &[ "radroots_event_store_addressable_feed_generation_insert", "radroots_event_store_addressable_feed_integrity_v1", @@ -292,6 +317,7 @@ pub(crate) struct EventStoreMigration { pub(crate) down_sha256: &'static str, pub(crate) schema_sha256: &'static str, pub(crate) owned_object_names: &'static [&'static str], + pub(crate) replaced_object_names: &'static [&'static str], pub(crate) owned_table_names: &'static [&'static str], pub(crate) fts5_table_names: &'static [&'static str], pub(crate) hook: EventStoreMigrationHook, @@ -311,6 +337,7 @@ pub(crate) const EVENT_STORE_MIGRATIONS: &[EventStoreMigration] = &[ down_sha256: "fa84d587f657f601947eaeb9cd239c962a48f6fcdce723588476e8d22f3c1f53", schema_sha256: "5b1f92779640f1a2dbd75e37a96996bda6c8be58883190f69eb3eced22a48f03", owned_object_names: EVENT_STORE_BASELINE_OBJECT_NAMES, + replaced_object_names: &[], owned_table_names: EVENT_STORE_BASELINE_TABLE_NAMES, fts5_table_names: EVENT_STORE_BASELINE_FTS5_TABLE_NAMES, hook: EventStoreMigrationHook::None, @@ -328,6 +355,7 @@ pub(crate) const EVENT_STORE_MIGRATIONS: &[EventStoreMigration] = &[ down_sha256: nip09_manifest::NIP09_RECONCILIATION_MIGRATION_DOWN_SHA256, schema_sha256: nip09_manifest::NIP09_RECONCILIATION_SCHEMA_SHA256, owned_object_names: EVENT_STORE_NIP09_OBJECT_NAMES, + replaced_object_names: &[], owned_table_names: EVENT_STORE_NIP09_TABLE_NAMES, fts5_table_names: &[], hook: EventStoreMigrationHook::Nip09ReconciliationV1, @@ -347,6 +375,7 @@ pub(crate) const EVENT_STORE_MIGRATIONS: &[EventStoreMigration] = &[ 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, + replaced_object_names: &[], owned_table_names: EVENT_STORE_FOOD_AVAILABILITY_TABLE_NAMES, fts5_table_names: EVENT_STORE_FOOD_AVAILABILITY_FTS5_TABLE_NAMES, hook: EventStoreMigrationHook::FoodAvailabilityProjectionV1, @@ -355,6 +384,26 @@ pub(crate) const EVENT_STORE_MIGRATIONS: &[EventStoreMigration] = &[ food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION, ), }, + EventStoreMigration { + version: 4, + name: "source_maintenance", + up_sql: include_str!("../migrations/0004_source_maintenance.up.sql"), + down_sql: include_str!("../migrations/0004_source_maintenance.down.sql"), + up_len: source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_UP_BYTE_LENGTH, + down_len: source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_DOWN_BYTE_LENGTH, + up_sha256: source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_UP_SHA256, + down_sha256: source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_DOWN_SHA256, + schema_sha256: source_maintenance_manifest::SOURCE_MAINTENANCE_SCHEMA_SHA256, + owned_object_names: EVENT_STORE_SOURCE_MAINTENANCE_OBJECT_NAMES, + replaced_object_names: EVENT_STORE_SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES, + owned_table_names: EVENT_STORE_SOURCE_MAINTENANCE_TABLE_NAMES, + fts5_table_names: &[], + hook: EventStoreMigrationHook::SourceMaintenanceV1, + hook_manifest_sha256: Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256), + event_contract_registry_version: Some( + source_maintenance_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION, + ), + }, ]; pub(crate) fn migration_for_version( @@ -427,6 +476,12 @@ pub(crate) fn validate_migration_registry( { validate_generated_food_availability_projection_manifest_descriptor()?; } + if registry + .iter() + .any(|migration| migration.hook == EventStoreMigrationHook::SourceMaintenanceV1) + { + validate_generated_source_maintenance_manifest_descriptor()?; + } if minimum == 0 || current < minimum || registry.is_empty() { return Err(RadrootsEventStoreError::MigrationRegistryDefect { reason: format!( @@ -438,7 +493,43 @@ pub(crate) fn validate_migration_registry( let mut expected_version = minimum; let mut owned_object_names = BTreeSet::new(); let mut owned_table_names = BTreeSet::new(); + let mut migration_hook_ids = BTreeSet::new(); for (index, migration) in registry.iter().enumerate() { + let canonical_hook_migration = match migration.hook { + EventStoreMigrationHook::None => None, + EventStoreMigrationHook::Nip09ReconciliationV1 => Some(( + nip09_manifest::NIP09_RECONCILIATION_MIGRATION_VERSION, + nip09_manifest::NIP09_RECONCILIATION_MIGRATION_NAME, + )), + EventStoreMigrationHook::FoodAvailabilityProjectionV1 => Some(( + food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_VERSION, + food_manifest::FOOD_AVAILABILITY_PROJECTION_MIGRATION_NAME, + )), + EventStoreMigrationHook::SourceMaintenanceV1 => Some(( + source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_VERSION, + source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_NAME, + )), + }; + if let Some((canonical_version, canonical_name)) = canonical_hook_migration { + if !migration_hook_ids.insert(migration.hook.id()) { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!( + "migration hook `{}` is declared more than once", + migration.hook.id() + ), + }); + } + if migration.version != canonical_version || migration.name != canonical_name { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!( + "migration hook `{}` is bound to canonical migration {canonical_version} `{canonical_name}`, not migration {} `{}`", + migration.hook.id(), + migration.version, + migration.name + ), + }); + } + } if migration.version != expected_version { return Err(RadrootsEventStoreError::MigrationRegistryDefect { reason: format!( @@ -486,6 +577,78 @@ pub(crate) fn validate_migration_registry( }); } } + if index == 0 && !migration.replaced_object_names.is_empty() { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: "the baseline migration cannot replace predecessor schema objects" + .to_owned(), + }); + } + if !migration.replaced_object_names.is_empty() + && (migration.hook == EventStoreMigrationHook::None + || migration.hook_manifest_sha256.is_none() + || migration.event_contract_registry_version.is_none()) + { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!( + "migration version {} replaces predecessor schema objects without an authenticated successor hook", + migration.version + ), + }); + } + let mut migration_replacement_names = BTreeSet::new(); + for object_name in migration.replaced_object_names { + validate_owned_schema_name(migration.version, "replacement object", object_name)?; + if !object_name.starts_with(EVENT_STORE_RESERVED_PREFIX) { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!( + "migration version {} replacement object `{object_name}` is outside the reserved `{EVENT_STORE_RESERVED_PREFIX}` namespace", + migration.version + ), + }); + } + if !migration_replacement_names.insert(*object_name) { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!( + "migration version {} replacement object `{object_name}` is declared more than once", + migration.version + ), + }); + } + if migration.owned_object_names.contains(object_name) + || migration.owned_table_names.contains(object_name) + { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!( + "migration version {} replacement object `{object_name}` is also newly owned by that migration", + migration.version + ), + }); + } + let prior_owners = registry[..index] + .iter() + .filter(|prior| prior.owned_object_names.contains(object_name)) + .collect::<Vec<_>>(); + if prior_owners.len() != 1 { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!( + "migration version {} replacement object `{object_name}` must be owned by exactly one prior migration; found {} owners", + migration.version, + prior_owners.len() + ), + }); + } + let prior_owner = prior_owners[0]; + if prior_owner.owned_table_names.contains(object_name) + || prior_owner.fts5_table_names.contains(object_name) + { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!( + "migration version {} replacement object `{object_name}` is a predecessor table; only non-table schema objects may be replaced", + migration.version + ), + }); + } + } for table_name in migration.owned_table_names { validate_owned_schema_name(migration.version, "table", table_name)?; if !migration.owned_object_names.contains(table_name) { @@ -556,6 +719,13 @@ pub(crate) fn validate_migration_registry( EventStoreMigrationHook::FoodAvailabilityProjectionV1, Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256), Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION), + ) + | ( + EventStoreMigrationHook::SourceMaintenanceV1, + Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256), + Some( + source_maintenance_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION, + ), ) => {} (hook, manifest, registry_version) => { return Err(RadrootsEventStoreError::MigrationRegistryDefect { @@ -910,6 +1080,224 @@ fn validate_generated_food_availability_projection_manifest_descriptor() Ok(()) } +fn validate_generated_source_maintenance_manifest_descriptor() -> Result<(), RadrootsEventStoreError> +{ + use source_maintenance_manifest as source_manifest; + + let bytes = source_manifest::SOURCE_MAINTENANCE_MANIFEST_JSON.as_bytes(); + if bytes.len() != source_manifest::SOURCE_MAINTENANCE_MANIFEST_BYTE_LENGTH { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: "generated source-maintenance manifest byte length is inconsistent".to_owned(), + }); + } + validate_sha256_literal( + source_manifest::SOURCE_MAINTENANCE_MIGRATION_VERSION, + "hook manifest", + source_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256, + )?; + if sha256_hex(bytes) != source_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256 { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: "generated source-maintenance manifest digest is inconsistent".to_owned(), + }); + } + let manifest: serde_json::Value = serde_json::from_slice(bytes).map_err(|error| { + RadrootsEventStoreError::MigrationRegistryDefect { + reason: format!("generated source-maintenance manifest JSON is invalid: {error}"), + } + })?; + let expected_numbers = [ + ( + "/schema_version", + u64::from(source_manifest::SOURCE_MAINTENANCE_MANIFEST_SCHEMA_VERSION), + ), + ( + "/migration/version", + u64::from(source_manifest::SOURCE_MAINTENANCE_MIGRATION_VERSION), + ), + ( + "/migration/up/byte_length", + source_manifest::SOURCE_MAINTENANCE_MIGRATION_UP_BYTE_LENGTH as u64, + ), + ( + "/migration/down/byte_length", + source_manifest::SOURCE_MAINTENANCE_MIGRATION_DOWN_BYTE_LENGTH as u64, + ), + ( + "/source_maintenance/version", + u64::from(source_manifest::SOURCE_MAINTENANCE_CAPACITY_VERSION), + ), + ( + "/source_maintenance/event_contract_registry_version", + u64::from(source_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION), + ), + ( + "/source_maintenance/limits/raw_events", + source_manifest::SOURCE_MAINTENANCE_RAW_EVENT_COUNT_LIMIT, + ), + ( + "/source_maintenance/limits/raw_tags", + source_manifest::SOURCE_MAINTENANCE_RAW_TAG_COUNT_LIMIT, + ), + ( + "/source_maintenance/limits/raw_event_text_bytes", + source_manifest::SOURCE_MAINTENANCE_RAW_EVENT_TEXT_BYTES_LIMIT, + ), + ( + "/source_maintenance/limits/raw_tag_text_bytes", + source_manifest::SOURCE_MAINTENANCE_RAW_TAG_TEXT_BYTES_LIMIT, + ), + ( + "/source_maintenance/limits/retained_source_generations", + u64::from(source_manifest::SOURCE_MAINTENANCE_RETAINED_SOURCE_GENERATION_LIMIT), + ), + ]; + let expected_strings = [ + ( + "/contract_id", + source_manifest::SOURCE_MAINTENANCE_CONTRACT_ID, + ), + ("/hook_id", source_manifest::SOURCE_MAINTENANCE_HOOK_ID), + ( + "/predecessor/hook_id", + source_manifest::SOURCE_MAINTENANCE_PREDECESSOR_HOOK_ID, + ), + ( + "/predecessor/manifest/sha256", + source_manifest::SOURCE_MAINTENANCE_PREDECESSOR_MANIFEST_SHA256, + ), + ( + "/migration/name", + source_manifest::SOURCE_MAINTENANCE_MIGRATION_NAME, + ), + ( + "/migration/up/sha256", + source_manifest::SOURCE_MAINTENANCE_MIGRATION_UP_SHA256, + ), + ( + "/migration/down/sha256", + source_manifest::SOURCE_MAINTENANCE_MIGRATION_DOWN_SHA256, + ), + ( + "/migration/schema_sha256", + source_manifest::SOURCE_MAINTENANCE_SCHEMA_SHA256, + ), + ( + "/source_maintenance/capacity_authority_id", + source_manifest::SOURCE_MAINTENANCE_CAPACITY_AUTHORITY_ID, + ), + ( + "/source_maintenance/accounting/algorithm", + source_manifest::SOURCE_MAINTENANCE_ACCOUNTING_ALGORITHM, + ), + ( + "/result_vector/sha256", + source_manifest::SOURCE_MAINTENANCE_RESULT_VECTOR_SHA256, + ), + ( + "/result_vector/executor_id", + source_manifest::SOURCE_MAINTENANCE_RESULT_VECTOR_EXECUTOR_ID, + ), + ( + "/result_vector/executor_sha256", + source_manifest::SOURCE_MAINTENANCE_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 string_array_matches = |pointer: &str, expected: &[&str]| { + manifest + .pointer(pointer) + .and_then(|value| value.as_array()) + .is_some_and(|values| { + values.len() == expected.len() + && values + .iter() + .zip(expected) + .all(|(value, expected)| value.as_str() == Some(*expected)) + }) + }; + if source_manifest::SOURCE_MAINTENANCE_MANIFEST_SCHEMA_VERSION != 1 + || source_manifest::SOURCE_MAINTENANCE_CONTRACT_ID + != "radroots_event_store.source_maintenance_v1" + || source_manifest::SOURCE_MAINTENANCE_HOOK_ID != "source_maintenance_v1" + || source_manifest::SOURCE_MAINTENANCE_MIGRATION_VERSION != 4 + || source_manifest::SOURCE_MAINTENANCE_MIGRATION_NAME != "source_maintenance" + || source_manifest::SOURCE_MAINTENANCE_CAPACITY_VERSION != 1 + || source_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION + != food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION + || source_manifest::SOURCE_MAINTENANCE_PREDECESSOR_HOOK_ID + != food_manifest::FOOD_AVAILABILITY_PROJECTION_HOOK_ID + || source_manifest::SOURCE_MAINTENANCE_PREDECESSOR_MANIFEST_SHA256 + != food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256 + || source_manifest::SOURCE_MAINTENANCE_RAW_EVENT_COUNT_LIMIT + != crate::RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1 + || source_manifest::SOURCE_MAINTENANCE_RAW_TAG_COUNT_LIMIT + != crate::RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1 + || source_manifest::SOURCE_MAINTENANCE_RAW_EVENT_TEXT_BYTES_LIMIT + != crate::RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1 + || source_manifest::SOURCE_MAINTENANCE_RAW_TAG_TEXT_BYTES_LIMIT + != crate::RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1 + || source_manifest::SOURCE_MAINTENANCE_RETAINED_SOURCE_GENERATION_LIMIT + != crate::RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1 + || !numbers_match + || !strings_match + || !string_array_matches( + "/migration/catalog/replaced_objects", + source_manifest::SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES, + ) + || !string_array_matches( + "/source_maintenance/accounting/raw_event_columns", + source_manifest::SOURCE_MAINTENANCE_RAW_EVENT_COLUMNS, + ) + || !string_array_matches( + "/source_maintenance/accounting/raw_tag_columns", + source_manifest::SOURCE_MAINTENANCE_RAW_TAG_COLUMNS, + ) + || !string_array_matches( + "/source_maintenance/accounting/nullable_raw_tag_columns", + source_manifest::SOURCE_MAINTENANCE_NULLABLE_RAW_TAG_COLUMNS, + ) + { + return Err(RadrootsEventStoreError::MigrationRegistryDefect { + reason: "generated source-maintenance manifest metadata is inconsistent".to_owned(), + }); + } + for (field, digest) in [ + ( + "migration up", + source_manifest::SOURCE_MAINTENANCE_MIGRATION_UP_SHA256, + ), + ( + "migration down", + source_manifest::SOURCE_MAINTENANCE_MIGRATION_DOWN_SHA256, + ), + ("schema", source_manifest::SOURCE_MAINTENANCE_SCHEMA_SHA256), + ( + "predecessor manifest", + source_manifest::SOURCE_MAINTENANCE_PREDECESSOR_MANIFEST_SHA256, + ), + ( + "result vector", + source_manifest::SOURCE_MAINTENANCE_RESULT_VECTOR_SHA256, + ), + ( + "result-vector executor", + source_manifest::SOURCE_MAINTENANCE_RESULT_VECTOR_EXECUTOR_SHA256, + ), + ] { + validate_sha256_literal( + source_manifest::SOURCE_MAINTENANCE_MIGRATION_VERSION, + field, + digest, + )?; + } + Ok(()) +} + fn validate_owned_schema_name( version: u32, object_kind: &'static str, @@ -1146,7 +1534,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(), 3); + assert_eq!(EVENT_STORE_MIGRATIONS.len(), 4); 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); @@ -1184,6 +1572,16 @@ mod migration_framework { EVENT_STORE_MIGRATIONS[2].event_contract_registry_version, Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION) ); + assert_eq!(EVENT_STORE_MIGRATIONS[3].version, 4); + assert_eq!(EVENT_STORE_MIGRATIONS[3].name, "source_maintenance"); + assert_eq!( + EVENT_STORE_MIGRATIONS[3].hook, + EventStoreMigrationHook::SourceMaintenanceV1 + ); + assert_eq!( + EVENT_STORE_MIGRATIONS[3].event_contract_registry_version, + Some(source_maintenance_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION,) + ); } #[test] diff --git a/crates/event_store/src/nip09/reconciliation_v1.rs b/crates/event_store/src/nip09/reconciliation_v1.rs @@ -11,7 +11,12 @@ use crate::model::reconciliation_v1::{ RadrootsEventAdmissionStatus, RadrootsEventIngest, RadrootsEventStoreSourceGeneration, RadrootsRawHeadDecision, StoredEventClass, tag_semantic_name, tag_value_type_name, }; -use crate::{RadrootsEventStoreError, RadrootsEventStoreReconciliationResource}; +use crate::{ + RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1, + RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1, + RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1, RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1, + RadrootsEventStoreError, RadrootsEventStoreSourceCapacityResourceV1, +}; use radroots_event::contract::registry_v7::RadrootsEventContract; use radroots_event::envelope::{RadrootsEventEnvelope, RadrootsEventKindClass}; use radroots_event::event_head::v1::{ @@ -40,11 +45,7 @@ mod result_vector_executor; const RECONCILIATION_SNAPSHOT_BATCH_SIZE: i64 = 512; const RECONCILIATION_SNAPSHOT_BATCH_LEN: usize = 512; -const RECONCILIATION_RAW_EVENT_LIMIT: u64 = 25_000; -const RECONCILIATION_RAW_TAG_LIMIT: u64 = 250_000; -const RECONCILIATION_RAW_EVENT_BYTE_LIMIT: u64 = 64 * 1024 * 1024; -const RECONCILIATION_RAW_TAG_BYTE_LIMIT: u64 = 32 * 1024 * 1024; - +const RECONCILIATION_SNAPSHOT_BATCH_COUNT: u64 = 512; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) struct ReconciliationCapacityLimits { pub(crate) raw_events: u64, @@ -56,68 +57,70 @@ pub(crate) struct ReconciliationCapacityLimits { impl ReconciliationCapacityLimits { pub(crate) const fn production() -> Self { Self { - raw_events: RECONCILIATION_RAW_EVENT_LIMIT, - raw_tags: RECONCILIATION_RAW_TAG_LIMIT, - raw_event_bytes: RECONCILIATION_RAW_EVENT_BYTE_LIMIT, - raw_tag_bytes: RECONCILIATION_RAW_TAG_BYTE_LIMIT, + raw_events: RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1, + raw_tags: RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1, + raw_event_bytes: RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1, + raw_tag_bytes: RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1, } } - const fn limit(self, resource: RadrootsEventStoreReconciliationResource) -> u64 { + pub(crate) const fn limit(self, resource: RadrootsEventStoreSourceCapacityResourceV1) -> u64 { match resource { - RadrootsEventStoreReconciliationResource::RawEvents => self.raw_events, - RadrootsEventStoreReconciliationResource::RawTags => self.raw_tags, - RadrootsEventStoreReconciliationResource::RawEventBytes => self.raw_event_bytes, - RadrootsEventStoreReconciliationResource::RawTagBytes => self.raw_tag_bytes, + RadrootsEventStoreSourceCapacityResourceV1::RawEvents => self.raw_events, + RadrootsEventStoreSourceCapacityResourceV1::RawTags => self.raw_tags, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes => self.raw_event_bytes, + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes => self.raw_tag_bytes, } } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -struct ReconciliationCapacity { - raw_events: u64, - raw_tags: u64, - raw_event_bytes: u64, - raw_tag_bytes: u64, +pub(crate) struct ReconciliationCapacity { + pub(crate) raw_events: u64, + pub(crate) raw_tags: u64, + pub(crate) raw_event_bytes: u64, + pub(crate) raw_tag_bytes: u64, } impl ReconciliationCapacity { - fn value(self, resource: RadrootsEventStoreReconciliationResource) -> u64 { + pub(crate) fn value(self, resource: RadrootsEventStoreSourceCapacityResourceV1) -> u64 { match resource { - RadrootsEventStoreReconciliationResource::RawEvents => self.raw_events, - RadrootsEventStoreReconciliationResource::RawTags => self.raw_tags, - RadrootsEventStoreReconciliationResource::RawEventBytes => self.raw_event_bytes, - RadrootsEventStoreReconciliationResource::RawTagBytes => self.raw_tag_bytes, + RadrootsEventStoreSourceCapacityResourceV1::RawEvents => self.raw_events, + RadrootsEventStoreSourceCapacityResourceV1::RawTags => self.raw_tags, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes => self.raw_event_bytes, + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes => self.raw_tag_bytes, } } - fn value_mut(&mut self, resource: RadrootsEventStoreReconciliationResource) -> &mut u64 { + fn value_mut(&mut self, resource: RadrootsEventStoreSourceCapacityResourceV1) -> &mut u64 { match resource { - RadrootsEventStoreReconciliationResource::RawEvents => &mut self.raw_events, - RadrootsEventStoreReconciliationResource::RawTags => &mut self.raw_tags, - RadrootsEventStoreReconciliationResource::RawEventBytes => &mut self.raw_event_bytes, - RadrootsEventStoreReconciliationResource::RawTagBytes => &mut self.raw_tag_bytes, + RadrootsEventStoreSourceCapacityResourceV1::RawEvents => &mut self.raw_events, + RadrootsEventStoreSourceCapacityResourceV1::RawTags => &mut self.raw_tags, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes => &mut self.raw_event_bytes, + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes => &mut self.raw_tag_bytes, } } fn checked_add( &mut self, limits: ReconciliationCapacityLimits, - resource: RadrootsEventStoreReconciliationResource, + resource: RadrootsEventStoreSourceCapacityResourceV1, amount: u64, ) -> Result<(), RadrootsEventStoreError> { let limit = limits.limit(resource); let actual = self.value(resource).checked_add(amount).ok_or( - RadrootsEventStoreError::ReconciliationCapacityExceeded { + RadrootsEventStoreError::SourceCapacityExceeded { resource, - actual: u64::MAX, + current: self.value(resource), + requested: amount, limit, }, )?; if actual > limit { - return Err(RadrootsEventStoreError::ReconciliationCapacityExceeded { + return Err(RadrootsEventStoreError::SourceCapacityExceeded { resource, - actual, + current: self.value(resource), + requested: amount, limit, }); } @@ -127,17 +130,18 @@ impl ReconciliationCapacity { fn validate(self, limits: ReconciliationCapacityLimits) -> Result<(), RadrootsEventStoreError> { for resource in [ - RadrootsEventStoreReconciliationResource::RawEvents, - RadrootsEventStoreReconciliationResource::RawTags, - RadrootsEventStoreReconciliationResource::RawEventBytes, - RadrootsEventStoreReconciliationResource::RawTagBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + RadrootsEventStoreSourceCapacityResourceV1::RawTags, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, ] { let actual = self.value(resource); let limit = limits.limit(resource); if actual > limit { - return Err(RadrootsEventStoreError::ReconciliationCapacityExceeded { + return Err(RadrootsEventStoreError::SourceCapacityExceeded { resource, - actual, + current: actual, + requested: 0, limit, }); } @@ -489,44 +493,13 @@ pub(crate) async fn validate_reconciliation_capacity( connection: &mut SqliteConnection, limits: ReconciliationCapacityLimits, ) -> Result<(), RadrootsEventStoreError> { - measure_reconciliation_capacity(connection) + measure_reconciliation_capacity_bounded(connection, limits) .await? .validate(limits) } -async fn measure_reconciliation_capacity( - connection: &mut SqliteConnection, -) -> Result<ReconciliationCapacity, RadrootsEventStoreError> { - // SQLite's maximum database size is well below i64::MAX bytes. Rust still - // performs checked sign conversion, and the loader independently performs - // checked per-row accumulation before retaining a bounded snapshot. - let row = sqlx::query( - "SELECT (SELECT COUNT(*) FROM event_envelopes) AS raw_events, (SELECT COUNT(*) FROM event_envelope_tags) AS raw_tags, (SELECT COALESCE(SUM(length(CAST(event_id AS BLOB)) + length(CAST(pubkey AS BLOB)) + length(CAST(tags_json AS BLOB)) + length(CAST(content AS BLOB)) + length(CAST(sig AS BLOB)) + length(CAST(raw_json AS BLOB))), 0) FROM event_envelopes) AS raw_event_bytes, (SELECT COALESCE(SUM(length(CAST(event_id AS BLOB)) + length(CAST(tag_name AS BLOB)) + COALESCE(length(CAST(tag_value AS BLOB)), 0) + length(CAST(tag_json AS BLOB))), 0) FROM event_envelope_tags) AS raw_tag_bytes", - ) - .fetch_one(&mut *connection) - .await?; - Ok(ReconciliationCapacity { - raw_events: reconciliation_capacity_value( - RadrootsEventStoreReconciliationResource::RawEvents, - row.try_get("raw_events")?, - )?, - raw_tags: reconciliation_capacity_value( - RadrootsEventStoreReconciliationResource::RawTags, - row.try_get("raw_tags")?, - )?, - raw_event_bytes: reconciliation_capacity_value( - RadrootsEventStoreReconciliationResource::RawEventBytes, - row.try_get("raw_event_bytes")?, - )?, - raw_tag_bytes: reconciliation_capacity_value( - RadrootsEventStoreReconciliationResource::RawTagBytes, - row.try_get("raw_tag_bytes")?, - )?, - }) -} - fn reconciliation_capacity_value( - resource: RadrootsEventStoreReconciliationResource, + resource: RadrootsEventStoreSourceCapacityResourceV1, value: i64, ) -> Result<u64, RadrootsEventStoreError> { u64::try_from(value).map_err(|_| RadrootsEventStoreError::MigrationHookStateDrift { @@ -540,6 +513,7 @@ pub(crate) async fn apply_reconciliation_hook( generation_provider: &dyn SourceGenerationProvider, limits: ReconciliationCapacityLimits, ) -> Result<(), RadrootsEventStoreError> { + crate::source_maintenance_v1::preflight_source_generation_append_v1(connection).await?; validate_rebuild_marker_absent(connection).await?; validate_projection_cursor_authority(connection).await?; let snapshot = load_reconciliation_snapshot(connection, limits).await?; @@ -646,6 +620,17 @@ pub(crate) async fn apply_reconciliation_hook( ) .await?; validate_rebuild_hook_state_with_events(connection, plan.generation, &events).await?; + if crate::source_maintenance_v1::bind_source_capacity_to_generation_v1( + connection, + plan.generation, + ) + .await? + { + crate::store::food_availability_projection_v1::apply_food_availability_projection_hook_v1( + connection, + ) + .await?; + } close_source_rebuild_marker(connection, plan.generation).await?; validate_sqlite_integrity_after_rebuild(connection).await?; validate_active_hook_state_fast(connection).await @@ -1369,7 +1354,7 @@ async fn load_reconciliation_snapshot( connection: &mut SqliteConnection, limits: ReconciliationCapacityLimits, ) -> Result<ReconciliationSnapshot, RadrootsEventStoreError> { - let measured_capacity = measure_snapshot_capacity_bounded(connection, limits).await?; + let measured_capacity = measure_reconciliation_capacity_bounded(connection, limits).await?; let mut loaded_capacity = ReconciliationCapacity::default(); let mut tags_by_event = BTreeMap::<String, Vec<StoredRawTag>>::new(); let mut next_tag_rowid = i64::MIN; @@ -1391,14 +1376,14 @@ async fn load_reconciliation_snapshot( let tag_json: String = row.try_get("tag_json")?; loaded_capacity.checked_add( limits, - RadrootsEventStoreReconciliationResource::RawTags, + RadrootsEventStoreSourceCapacityResourceV1::RawTags, 1, )?; loaded_capacity.checked_add( limits, - RadrootsEventStoreReconciliationResource::RawTagBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, text_payload_bytes( - RadrootsEventStoreReconciliationResource::RawTagBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, limits, [ event_id.len(), @@ -1453,14 +1438,14 @@ async fn load_reconciliation_snapshot( let raw_json: String = row.try_get("raw_json")?; loaded_capacity.checked_add( limits, - RadrootsEventStoreReconciliationResource::RawEvents, + RadrootsEventStoreSourceCapacityResourceV1::RawEvents, 1, )?; loaded_capacity.checked_add( limits, - RadrootsEventStoreReconciliationResource::RawEventBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, text_payload_bytes( - RadrootsEventStoreReconciliationResource::RawEventBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, limits, [ event_id.len(), @@ -1570,18 +1555,20 @@ fn compare_raw_tags( Ok(()) } -async fn measure_snapshot_capacity_bounded( +pub(crate) async fn measure_reconciliation_capacity_bounded( connection: &mut SqliteConnection, limits: ReconciliationCapacityLimits, ) -> Result<ReconciliationCapacity, RadrootsEventStoreError> { let mut capacity = ReconciliationCapacity::default(); let mut next_event_seq = i64::MIN; loop { + let (page_size, page_len) = + bounded_capacity_page_len(capacity.raw_events, limits.raw_events); let rows = sqlx::query( "SELECT seq, length(CAST(event_id AS BLOB)) + length(CAST(pubkey AS BLOB)) + length(CAST(tags_json AS BLOB)) + length(CAST(content AS BLOB)) + length(CAST(sig AS BLOB)) + length(CAST(raw_json AS BLOB)) AS raw_bytes FROM event_envelopes WHERE seq >= ? ORDER BY seq LIMIT ?", ) .bind(next_event_seq) - .bind(RECONCILIATION_SNAPSHOT_BATCH_SIZE) + .bind(page_size) .fetch_all(&mut *connection) .await?; let row_count = rows.len(); @@ -1589,14 +1576,14 @@ async fn measure_snapshot_capacity_bounded( let seq: i64 = row.try_get("seq")?; capacity.checked_add( limits, - RadrootsEventStoreReconciliationResource::RawEvents, + RadrootsEventStoreSourceCapacityResourceV1::RawEvents, 1, )?; capacity.checked_add( limits, - RadrootsEventStoreReconciliationResource::RawEventBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, reconciliation_capacity_value( - RadrootsEventStoreReconciliationResource::RawEventBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, row.try_get("raw_bytes")?, )?, )?; @@ -1609,28 +1596,33 @@ async fn measure_snapshot_capacity_bounded( } })?; } - if row_count < RECONCILIATION_SNAPSHOT_BATCH_LEN { + if row_count < page_len { break; } } let mut next_tag_rowid = i64::MIN; loop { + let (page_size, page_len) = bounded_capacity_page_len(capacity.raw_tags, limits.raw_tags); let rows = sqlx::query( "SELECT rowid AS tag_rowid, length(CAST(event_id AS BLOB)) + length(CAST(tag_name AS BLOB)) + COALESCE(length(CAST(tag_value AS BLOB)), 0) + length(CAST(tag_json AS BLOB)) AS raw_bytes FROM event_envelope_tags WHERE rowid >= ? ORDER BY rowid LIMIT ?", ) .bind(next_tag_rowid) - .bind(RECONCILIATION_SNAPSHOT_BATCH_SIZE) + .bind(page_size) .fetch_all(&mut *connection) .await?; let row_count = rows.len(); for row in rows { - capacity.checked_add(limits, RadrootsEventStoreReconciliationResource::RawTags, 1)?; capacity.checked_add( limits, - RadrootsEventStoreReconciliationResource::RawTagBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawTags, + 1, + )?; + capacity.checked_add( + limits, + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, reconciliation_capacity_value( - RadrootsEventStoreReconciliationResource::RawTagBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, row.try_get("raw_bytes")?, )?, )?; @@ -1642,13 +1634,24 @@ async fn measure_snapshot_capacity_bounded( } })?; } - if row_count < RECONCILIATION_SNAPSHOT_BATCH_LEN { + if row_count < page_len { break; } } Ok(capacity) } +fn bounded_capacity_page_len(current: u64, limit: u64) -> (i64, usize) { + let page_count = limit + .saturating_sub(current) + .saturating_add(1) + .min(RECONCILIATION_SNAPSHOT_BATCH_COUNT); + ( + i64::try_from(page_count).unwrap_or(RECONCILIATION_SNAPSHOT_BATCH_SIZE), + usize::try_from(page_count).unwrap_or(RECONCILIATION_SNAPSHOT_BATCH_LEN), + ) +} + async fn update_derived_tags( connection: &mut SqliteConnection, event: &ReconciledEvent, @@ -3542,24 +3545,25 @@ fn i64_from_usize(field: &'static str, value: usize) -> Result<i64, RadrootsEven } fn text_payload_bytes<const N: usize>( - resource: RadrootsEventStoreReconciliationResource, + resource: RadrootsEventStoreSourceCapacityResourceV1, limits: ReconciliationCapacityLimits, lengths: [usize; N], ) -> Result<u64, RadrootsEventStoreError> { let limit = limits.limit(resource); lengths.into_iter().try_fold(0_u64, |total, length| { - let length = u64::try_from(length).map_err(|_| { - RadrootsEventStoreError::ReconciliationCapacityExceeded { + let length = + u64::try_from(length).map_err(|_| RadrootsEventStoreError::SourceCapacityExceeded { resource, - actual: u64::MAX, + current: u64::MAX, + requested: 0, limit, - } - })?; + })?; total .checked_add(length) - .ok_or(RadrootsEventStoreError::ReconciliationCapacityExceeded { + .ok_or(RadrootsEventStoreError::SourceCapacityExceeded { resource, - actual: u64::MAX, + current: u64::MAX, + requested: 0, limit, }) }) @@ -3610,6 +3614,30 @@ mod tests { } } + #[test] + fn bounded_capacity_page_len_caps_gross_source_probe_at_one_over() { + for limit in [25_000_u64, 250_000_u64] { + let mut current = 0_u64; + loop { + let (sqlite_limit, fetched_len) = bounded_capacity_page_len(current, limit); + assert_eq!( + usize::try_from(sqlite_limit).expect("positive bounded page limit"), + fetched_len + ); + assert!((1..=RECONCILIATION_SNAPSHOT_BATCH_LEN).contains(&fetched_len)); + let fetched = current + u64::try_from(fetched_len).expect("bounded page length"); + if fetched > limit { + assert_eq!(fetched, limit + 1); + break; + } + current = fetched; + } + } + assert_eq!(bounded_capacity_page_len(24_576, 25_000), (425, 425)); + assert_eq!(bounded_capacity_page_len(249_856, 250_000), (145, 145)); + assert_eq!(bounded_capacity_page_len(25_000, 25_000), (1, 1)); + } + #[tokio::test] async fn source_rebuild_rotates_three_generations_with_deterministic_parity() { let pool = open_v1_test_pool().await; 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::source_maintenance_v1::{ + apply_source_maintenance_hook_v1, validate_no_persisted_ephemeral_raw_rows_v1, + validate_source_capacity_authority_full_v1, +}; use crate::store::food_availability_projection_v1::{ apply_food_availability_projection_hook_v1, validate_food_availability_projection_hook_state_fast_v1, @@ -49,6 +53,13 @@ struct AppliedMigration { schema_sha256: String, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SourceGenerationHistoryRollbackPolicy { + Preserve, + #[cfg(test)] + AllowDestructiveForMigrationTest, +} + pub async fn inspect_event_store_schema_status( pool: &SqlitePool, ) -> Result<RadrootsEventStoreSchemaStatus, RadrootsEventStoreError> { @@ -160,6 +171,9 @@ async fn migrate_event_store_schema_with_registry_and_generation_provider( 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?; + if has_pending_source_maintenance_hook(&status, registry) { + validate_no_persisted_ephemeral_raw_rows_v1(&mut connection).await?; + } } let mut transaction = pool.begin_with("BEGIN IMMEDIATE").await?; @@ -189,10 +203,26 @@ fn has_pending_source_capacity_hook( migration.hook, EventStoreMigrationHook::Nip09ReconciliationV1 | EventStoreMigrationHook::FoodAvailabilityProjectionV1 + | EventStoreMigrationHook::SourceMaintenanceV1 ) }) } +fn has_pending_source_maintenance_hook( + status: &RadrootsEventStoreSchemaStatus, + registry: &[EventStoreMigration], +) -> bool { + 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 + && migration.hook == EventStoreMigrationHook::SourceMaintenanceV1 + }) +} + pub(crate) async fn rollback_event_store_schema_offline( pool: &SqlitePool, target: u32, @@ -207,6 +237,22 @@ pub(crate) async fn rollback_event_store_schema_offline( .await } +#[cfg(test)] +pub(crate) async fn rollback_event_store_schema_offline_destructive_for_migration_test( + pool: &SqlitePool, + target: u32, +) -> Result<(), RadrootsEventStoreError> { + rollback_event_store_schema_with_registry_inner( + pool, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + target, + SourceGenerationHistoryRollbackPolicy::AllowDestructiveForMigrationTest, + ) + .await +} + async fn rollback_event_store_schema_with_registry( pool: &SqlitePool, registry: &[EventStoreMigration], @@ -214,6 +260,25 @@ async fn rollback_event_store_schema_with_registry( supported_current: u32, target: u32, ) -> Result<(), RadrootsEventStoreError> { + rollback_event_store_schema_with_registry_inner( + pool, + registry, + minimum, + supported_current, + target, + SourceGenerationHistoryRollbackPolicy::Preserve, + ) + .await +} + +async fn rollback_event_store_schema_with_registry_inner( + pool: &SqlitePool, + registry: &[EventStoreMigration], + minimum: u32, + supported_current: u32, + target: u32, + source_generation_history_policy: SourceGenerationHistoryRollbackPolicy, +) -> Result<(), RadrootsEventStoreError> { if target < minimum { return Err(RadrootsEventStoreError::RollbackBelowVersionFloor { floor: minimum, @@ -222,8 +287,14 @@ async fn rollback_event_store_schema_with_registry( } validate_migration_registry(registry, minimum, supported_current)?; let mut transaction = pool.begin_with("BEGIN EXCLUSIVE").await?; - let result = - rollback_schema_on_connection(&mut transaction, registry, supported_current, target).await; + let result = rollback_schema_on_connection( + &mut transaction, + registry, + supported_current, + target, + source_generation_history_policy, + ) + .await; finish_schema_transaction(transaction, result).await } @@ -302,8 +373,12 @@ async fn migrate_schema_on_connection( migration.hook, EventStoreMigrationHook::Nip09ReconciliationV1 | EventStoreMigrationHook::FoodAvailabilityProjectionV1 + | EventStoreMigrationHook::SourceMaintenanceV1 ) { validate_reconciliation_capacity(connection, reconciliation_limits).await?; + if migration.hook == EventStoreMigrationHook::SourceMaintenanceV1 { + validate_no_persisted_ephemeral_raw_rows_v1(connection).await?; + } } apply_migration_up(connection, registry, migration).await?; apply_migration_hook( @@ -333,6 +408,7 @@ async fn rollback_schema_on_connection( registry: &[EventStoreMigration], supported_current: u32, target: u32, + source_generation_history_policy: SourceGenerationHistoryRollbackPolicy, ) -> Result<(), RadrootsEventStoreError> { let RadrootsEventStoreSchemaStatus::Managed { version: current_version, @@ -346,6 +422,9 @@ async fn rollback_schema_on_connection( target, }); } + if source_generation_history_policy == SourceGenerationHistoryRollbackPolicy::Preserve { + validate_rollback_preserves_source_generation_history(registry, current_version, target)?; + } for version in ((target + 1)..=current_version).rev() { let migration = migration_for_version(registry, version) @@ -384,6 +463,31 @@ async fn rollback_schema_on_connection( } } +fn validate_rollback_preserves_source_generation_history( + registry: &[EventStoreMigration], + current: u32, + target: u32, +) -> Result<(), RadrootsEventStoreError> { + let Some(floor) = registry + .iter() + .find(|migration| migration.hook == EventStoreMigrationHook::Nip09ReconciliationV1) + .map(|migration| migration.version) + else { + return Ok(()); + }; + if current < floor || target >= floor { + return Ok(()); + } + + Err( + RadrootsEventStoreError::RollbackWouldDiscardSourceGenerationHistory { + current, + target, + floor, + }, + ) +} + #[cfg(test)] async fn destroy_schema_on_connection( connection: &mut SqliteConnection, @@ -493,10 +597,15 @@ fn validate_catalog_delta( .iter() .copied() .collect::<BTreeSet<_>>(); + let expected_changed = migration + .replaced_object_names + .iter() + .copied() + .collect::<BTreeSet<_>>(); let valid = match direction { - "up" => added == expected && removed.is_empty() && changed.is_empty(), - "down" => removed == expected && added.is_empty() && changed.is_empty(), + "up" => added == expected && removed.is_empty() && changed == expected_changed, + "down" => removed == expected && added.is_empty() && changed == expected_changed, _ => false, }; if !valid { @@ -504,7 +613,7 @@ fn validate_catalog_delta( version: migration.version, direction, reason: format!( - "expected {} objects {expected:?}; added {added:?}, removed {removed:?}, changed {changed:?}", + "expected {} objects {expected:?} and changed replacement objects {expected_changed:?}; added {added:?}, removed {removed:?}, changed {changed:?}", if direction == "up" { "added" } else { @@ -629,6 +738,9 @@ async fn apply_migration_hook( EventStoreMigrationHook::FoodAvailabilityProjectionV1 => { apply_food_availability_projection_hook_v1(connection).await } + EventStoreMigrationHook::SourceMaintenanceV1 => { + apply_source_maintenance_hook_v1(connection).await + } } } @@ -644,6 +756,9 @@ async fn validate_migration_hook_state( EventStoreMigrationHook::FoodAvailabilityProjectionV1 => { validate_food_availability_projection_hook_state_fast_v1(connection).await } + EventStoreMigrationHook::SourceMaintenanceV1 => { + validate_source_capacity_authority_full_v1(connection).await + } } } @@ -958,7 +1073,7 @@ mod migration_framework { use crate::migrations::sha256_hex; use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; use std::str::FromStr; - use std::time::{Duration, Instant}; + use std::time::Duration; const SYNTHETIC_V2_UP: &str = "CREATE TABLE radroots_event_store_v2_parent ( id INTEGER PRIMARY KEY NOT NULL @@ -1010,6 +1125,7 @@ DROP TABLE radroots_event_store_v2_parent;"; down_sha256: leaked_sha256(SYNTHETIC_V2_DOWN), schema_sha256, owned_object_names: SYNTHETIC_V2_OBJECT_NAMES, + replaced_object_names: &[], owned_table_names: SYNTHETIC_V2_TABLE_NAMES, fts5_table_names: NO_FTS5_TABLES, hook: crate::migrations::EventStoreMigrationHook::None, @@ -1050,6 +1166,30 @@ DROP TABLE radroots_event_store_v2_parent;"; .expect("baseline schema"); } + async fn schema_object_sql(pool: &SqlitePool, name: &str) -> String { + sqlx::query_scalar("SELECT sql FROM main.sqlite_schema WHERE name = ?") + .bind(name) + .fetch_one(pool) + .await + .expect("schema object SQL") + } + + async fn insert_test_rebuild_marker( + connection: &mut SqliteConnection, + target_generation: &[u8; 32], + transition_floor_seq: i64, + prior_last_transition_seq: i64, + ) -> Result<sqlx::sqlite::SqliteQueryResult, sqlx::Error> { + sqlx::query( + "INSERT INTO radroots_event_store_source_rebuild_marker(singleton, barrier_key, target_generation, target_generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq, prior_active_generation, prior_raw_event_count, prior_raw_tag_count, prior_raw_high_water_seq, prior_last_transition_seq) SELECT 1, 1, ?, generation.generation_ordinal + 1, generation.reconciliation_version, generation.addressable_feed_version, generation.event_contract_registry_version, generation.hook_id, generation.hook_manifest_sha256, ?, state.raw_event_count, state.raw_tag_count, state.raw_high_water_seq, state.active_generation, state.raw_event_count, state.raw_tag_count, state.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", + ) + .bind(target_generation.as_slice()) + .bind(transition_floor_seq) + .bind(prior_last_transition_seq) + .execute(connection) + .await + } + #[tokio::test] async fn later_hookless_migrations_do_not_disable_prior_hook_validation() { let store = RadrootsEventStore::open_memory().await.expect("v2 store"); @@ -1121,9 +1261,10 @@ DROP TABLE radroots_event_store_v2_parent;"; assert!( matches!( error, - RadrootsEventStoreError::ReconciliationCapacityExceeded { - resource: crate::RadrootsEventStoreReconciliationResource::RawEvents, - actual: 1, + RadrootsEventStoreError::SourceCapacityExceeded { + resource: crate::RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + current: 0, + requested: 1, limit: 0, } ), @@ -1186,9 +1327,10 @@ DROP TABLE radroots_event_store_v2_parent;"; assert!( matches!( error, - RadrootsEventStoreError::ReconciliationCapacityExceeded { - resource: crate::RadrootsEventStoreReconciliationResource::RawEvents, - actual: 1, + RadrootsEventStoreError::SourceCapacityExceeded { + resource: crate::RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + current: 0, + requested: 1, limit: 0, } ), @@ -1214,6 +1356,239 @@ DROP TABLE radroots_event_store_v2_parent;"; .await .expect("v3 ledger count"); assert_eq!(v3_ledger_count, 0); + + let pool = memory_pool().await; + migrate_event_store_schema_with_registry( + &pool, + &EVENT_STORE_MIGRATIONS[..3], + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + 3, + ) + .await + .expect("install v3 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("1".repeat(64)) + .bind("2".repeat(64)) + .bind("3".repeat(128)) + .execute(&pool) + .await + .expect("post-v3 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-v3 source authority"); + + let mut transaction = pool + .begin_with("BEGIN IMMEDIATE") + .await + .expect("v4 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("v4 capacity excess must fail"); + assert!( + matches!( + error, + RadrootsEventStoreError::SourceCapacityExceeded { + resource: crate::RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + current: 0, + requested: 1, + limit: 0, + } + ), + "unexpected v4 capacity failure: {error:?}" + ); + assert_eq!( + inspect_event_store_schema_status(&pool) + .await + .expect("v3 status after rejected v4 migration"), + RadrootsEventStoreSchemaStatus::Managed { version: 3 } + ); + let v4_object_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_schema WHERE name = 'radroots_event_store_source_capacity_v1'", + ) + .fetch_one(&pool) + .await + .expect("v4 object count"); + assert_eq!(v4_object_count, 0); + let v4_ledger_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_schema_migrations WHERE version = 4", + ) + .fetch_one(&pool) + .await + .expect("v4 ledger count"); + assert_eq!(v4_ledger_count, 0); + } + + #[tokio::test] + async fn v3_to_v4_under_limit_backfills_exact_capacity_and_preserves_source() { + let pool = memory_pool().await; + migrate_event_store_schema_with_registry( + &pool, + &EVENT_STORE_MIGRATIONS[..3], + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + 3, + ) + .await + .expect("install v3 schema"); + sqlx::query("CREATE TABLE caller_state(id INTEGER PRIMARY KEY, value TEXT NOT NULL)") + .execute(&pool) + .await + .expect("create unrelated caller table"); + sqlx::query("INSERT INTO caller_state(id, value) VALUES (1, 'preserve')") + .execute(&pool) + .await + .expect("seed unrelated caller row"); + let event_id = "7".repeat(64); + 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, '[]', 'under-limit', ?, '{}', 'verified', 'unsupported', NULL, 'regular', 0, 1, 1)", + ) + .bind(event_id.as_str()) + .bind("8".repeat(64)) + .bind("9".repeat(128)) + .execute(&pool) + .await + .expect("post-v3 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-v3 source authority"); + let generation_before: Vec<u8> = sqlx::query_scalar( + "SELECT active_generation FROM radroots_event_store_source_state WHERE singleton = 1", + ) + .fetch_one(&pool) + .await + .expect("v3 source generation"); + let event_bytes: i64 = sqlx::query_scalar( + "SELECT length(CAST(event_id AS BLOB)) + length(CAST(pubkey AS BLOB)) + length(CAST(tags_json AS BLOB)) + length(CAST(content AS BLOB)) + length(CAST(sig AS BLOB)) + length(CAST(raw_json AS BLOB)) FROM event_envelopes WHERE event_id = ?", + ) + .bind(event_id.as_str()) + .fetch_one(&pool) + .await + .expect("raw event byte authority"); + + migrate_event_store_schema_with_registry( + &pool, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + ) + .await + .expect("migrate under-limit v3 source to v4"); + + assert_eq!( + inspect_event_store_schema_status(&pool) + .await + .expect("v4 status"), + RadrootsEventStoreSchemaStatus::Managed { version: 4 } + ); + let capacity: (Vec<u8>, i64, i64, i64, i64, i64, i64) = sqlx::query_as( + "SELECT source_generation, raw_event_count, raw_tag_count, raw_event_bytes, raw_tag_bytes, retained_generation_count, retained_generation_limit FROM radroots_event_store_source_capacity_v1 WHERE singleton = 1", + ) + .fetch_one(&pool) + .await + .expect("v4 capacity authority"); + assert_eq!(capacity, (generation_before, 1, 0, event_bytes, 0, 1, 8)); + let preserved: (i64, String) = sqlx::query_as( + "SELECT (SELECT COUNT(*) FROM event_envelopes WHERE event_id = ?), (SELECT value FROM caller_state WHERE id = 1)", + ) + .bind(event_id) + .fetch_one(&pool) + .await + .expect("preserved source and caller state"); + assert_eq!(preserved, (1, "preserve".to_owned())); + } + + #[tokio::test] + async fn v4_rejects_persisted_legacy_ephemeral_rows_atomically() { + let pool = memory_pool().await; + migrate_event_store_schema_with_registry( + &pool, + &EVENT_STORE_MIGRATIONS[..3], + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + 3, + ) + .await + .expect("install v3 schema"); + let event_id = "4".repeat(64); + 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, 20000, '[]', '', ?, '{}', 'verified', 'unsupported', NULL, 'ephemeral', 0, 1, 1)", + ) + .bind(event_id.as_str()) + .bind("5".repeat(64)) + .bind("6".repeat(128)) + .execute(&pool) + .await + .expect("legacy persisted ephemeral row"); + 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 legacy source authority"); + + let mut transaction = pool + .begin_with("BEGIN IMMEDIATE") + .await + .expect("v4 migration transaction"); + let result = migrate_schema_on_connection( + &mut transaction, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + &OsSourceGenerationProvider, + ReconciliationCapacityLimits::production(), + ) + .await; + let error = finish_schema_transaction(transaction, result) + .await + .expect_err("persisted ephemeral source must reject v4"); + assert!(matches!( + error, + RadrootsEventStoreError::PersistedEphemeralRawEvent { + ref event_id, + kind: 20_000, + } if event_id == &"4".repeat(64) + )); + assert_eq!( + inspect_event_store_schema_status(&pool) + .await + .expect("v3 remains valid after rejected v4 migration"), + RadrootsEventStoreSchemaStatus::Managed { version: 3 } + ); + let v4_object_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_schema WHERE name = 'radroots_event_store_source_capacity_v1'", + ) + .fetch_one(&pool) + .await + .expect("v4 object count"); + assert_eq!(v4_object_count, 0); + let v4_ledger_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_schema_migrations WHERE version = 4", + ) + .fetch_one(&pool) + .await + .expect("v4 ledger count"); + assert_eq!(v4_ledger_count, 0); + let raw_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM event_envelopes WHERE event_id = ?") + .bind(event_id) + .fetch_one(&pool) + .await + .expect("legacy raw row remains after rollback"); + assert_eq!(raw_count, 1); } #[tokio::test] @@ -1487,6 +1862,7 @@ DROP TABLE event_envelopes;"; down_sha256: ZERO_SHA256, schema_sha256: ZERO_SHA256, owned_object_names: DELTA_OBJECT_NAMES, + replaced_object_names: &[], owned_table_names: DELTA_TABLE_NAMES, fts5_table_names: NO_FTS5_TABLES, hook: EventStoreMigrationHook::None, @@ -2054,7 +2430,7 @@ DROP TABLE event_envelopes;"; )); let unknown = AppliedMigration { - version: 4, + version: 5, name: "future".to_owned(), up_sha256: "0".repeat(64), down_sha256: "1".repeat(64), @@ -2066,12 +2442,13 @@ DROP TABLE event_envelopes;"; row(&EVENT_STORE_MIGRATIONS[0]), row(&EVENT_STORE_MIGRATIONS[1]), row(&EVENT_STORE_MIGRATIONS[2]), + row(&EVENT_STORE_MIGRATIONS[3]), unknown ], EVENT_STORE_MIGRATIONS, - 4 + 5 ), - Err(RadrootsEventStoreError::UnknownMigration { version: 4 }) + Err(RadrootsEventStoreError::UnknownMigration { version: 5 }) )); } @@ -2098,7 +2475,7 @@ DROP TABLE event_envelopes;"; } #[tokio::test] - async fn rollback_rejects_below_floor_ahead_and_unmanaged_targets() { + async fn rollback_rejects_below_floor_ahead_unmanaged_and_generation_destructive_targets() { let unmanaged = memory_pool().await; assert!(matches!( rollback_event_store_schema_offline(&unmanaged, 1).await, @@ -2124,26 +2501,106 @@ DROP TABLE event_envelopes;"; target }) if target == ahead )); + let history_before: Vec<(Vec<u8>, i64)> = sqlx::query_as( + "SELECT source_generation, generation_ordinal FROM radroots_event_store_source_generation ORDER BY generation_ordinal", + ) + .fetch_all(&managed) + .await + .expect("source-generation history before rejected rollback"); + assert!(matches!( + rollback_event_store_schema_offline(&managed, 1).await, + Err( + RadrootsEventStoreError::RollbackWouldDiscardSourceGenerationHistory { + current: RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + target: 1, + floor: 2, + } + ) + )); + assert_eq!( + inspect_event_store_schema_status(&managed) + .await + .expect("current status after rejected rollback"), + RadrootsEventStoreSchemaStatus::Managed { + version: RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + } + ); + assert_eq!( + sqlx::query_as::<_, (Vec<u8>, i64)>( + "SELECT source_generation, generation_ordinal FROM radroots_event_store_source_generation ORDER BY generation_ordinal", + ) + .fetch_all(&managed) + .await + .expect("source-generation history after rejected rollback"), + history_before + ); + + rollback_event_store_schema_offline_destructive_for_migration_test(&managed, 1) + .await + .expect("test-only destructive rollback to v1"); rollback_event_store_schema_offline(&managed, 1) .await - .expect("idempotent rollback"); + .expect("v1 to v1 idempotent rollback"); } #[tokio::test] - async fn synthetic_v2_rolls_back_to_v1() { - let registry = synthetic_v2_registry().await; - let pool = memory_pool().await; - migrate_event_store_schema_with_registry(&pool, &registry, 1, 2) + async fn rollback_cannot_bypass_generation_history_guard_through_version_three() { + let managed = memory_pool().await; + migrate_event_store_schema(&managed) .await - .expect("migrate to v2"); - - rollback_event_store_schema_with_registry(&pool, &registry, 1, 2, 1) + .expect("migration"); + rollback_event_store_schema_offline(&managed, 3) .await - .expect("rollback to v1"); - assert_eq!( - inspect_event_store_schema_status_with_registry(&pool, &registry, 2) - .await - .expect("v1 status"), + .expect("rollback to history-preserving v3"); + let history_before: Vec<(Vec<u8>, i64)> = sqlx::query_as( + "SELECT source_generation, generation_ordinal FROM radroots_event_store_source_generation ORDER BY generation_ordinal", + ) + .fetch_all(&managed) + .await + .expect("v3 source-generation history"); + + assert!(matches!( + rollback_event_store_schema_offline(&managed, 1).await, + Err( + RadrootsEventStoreError::RollbackWouldDiscardSourceGenerationHistory { + current: 3, + target: 1, + floor: 2, + } + ) + )); + assert_eq!( + inspect_event_store_schema_status(&managed) + .await + .expect("v3 status after rejected bypass"), + RadrootsEventStoreSchemaStatus::Managed { version: 3 } + ); + assert_eq!( + sqlx::query_as::<_, (Vec<u8>, i64)>( + "SELECT source_generation, generation_ordinal FROM radroots_event_store_source_generation ORDER BY generation_ordinal", + ) + .fetch_all(&managed) + .await + .expect("v3 source-generation history after rejected bypass"), + history_before + ); + } + + #[tokio::test] + async fn synthetic_v2_rolls_back_to_v1() { + let registry = synthetic_v2_registry().await; + let pool = memory_pool().await; + migrate_event_store_schema_with_registry(&pool, &registry, 1, 2) + .await + .expect("migrate to v2"); + + rollback_event_store_schema_with_registry(&pool, &registry, 1, 2, 1) + .await + .expect("rollback to v1"); + assert_eq!( + inspect_event_store_schema_status_with_registry(&pool, &registry, 2) + .await + .expect("v1 status"), RadrootsEventStoreSchemaStatus::Managed { version: 1 } ); let v2_objects: i64 = sqlx::query_scalar( @@ -2237,6 +2694,7 @@ INSERT INTO radroots_event_store_missing_v2(id) VALUES (1);"; down_sha256: leaked_sha256(DOWN), schema_sha256: ZERO_SHA256, owned_object_names: OBJECTS, + replaced_object_names: &[], owned_table_names: OBJECTS, fts5_table_names: NO_FTS5_TABLES, hook: crate::migrations::EventStoreMigrationHook::None, @@ -2394,6 +2852,659 @@ INSERT INTO caller_child(id, parent_id) VALUES (1, 999);", )); } + #[test] + fn registry_rejects_invalid_predecessor_replacement_declarations() { + const BASELINE_REPLACEMENT: &[&str] = &["event_envelope_kind_created_idx"]; + const DUPLICATE_REPLACEMENTS: &[&str] = &[ + "radroots_event_store_source_rebuild_marker_insert_guard", + "radroots_event_store_source_rebuild_marker_insert_guard", + ]; + const MISSING_REPLACEMENT: &[&str] = &["radroots_event_store_missing_guard"]; + const CURRENT_OWNED_REPLACEMENT: &[&str] = + &["radroots_event_store_source_capacity_insert_guard"]; + const TABLE_REPLACEMENT: &[&str] = &["radroots_event_store_source_state"]; + + let mut baseline = EVENT_STORE_MIGRATIONS[0]; + baseline.replaced_object_names = BASELINE_REPLACEMENT; + assert!(matches!( + validate_migration_registry(&[baseline], 1, 1), + Err(RadrootsEventStoreError::MigrationRegistryDefect { reason }) + if reason.contains("baseline migration cannot replace") + )); + + let mut hookless = synthetic_v2_descriptor(ZERO_SHA256); + hookless.replaced_object_names = BASELINE_REPLACEMENT; + assert!(matches!( + validate_migration_registry(&[EVENT_STORE_MIGRATIONS[0], hookless], 1, 2), + Err(RadrootsEventStoreError::MigrationRegistryDefect { reason }) + if reason.contains("without an authenticated successor hook") + )); + + for (replacements, expected_reason) in [ + (DUPLICATE_REPLACEMENTS, "declared more than once"), + (MISSING_REPLACEMENT, "exactly one prior migration"), + (CURRENT_OWNED_REPLACEMENT, "also newly owned"), + (TABLE_REPLACEMENT, "only non-table schema objects"), + ] { + let mut v4 = EVENT_STORE_MIGRATIONS[3]; + v4.replaced_object_names = replacements; + let registry = [ + EVENT_STORE_MIGRATIONS[0], + EVENT_STORE_MIGRATIONS[1], + EVENT_STORE_MIGRATIONS[2], + v4, + ]; + assert!(matches!( + validate_migration_registry(&registry, 1, 4), + Err(RadrootsEventStoreError::MigrationRegistryDefect { reason }) + if reason.contains(expected_reason) + )); + } + } + + #[test] + fn registry_binds_authenticated_hooks_to_one_canonical_migration() { + let mut duplicate = EVENT_STORE_MIGRATIONS[1]; + duplicate.version = 3; + duplicate.name = "duplicate_nip09"; + assert!(matches!( + validate_migration_registry( + &[EVENT_STORE_MIGRATIONS[0], EVENT_STORE_MIGRATIONS[1], duplicate], + 1, + 3, + ), + Err(RadrootsEventStoreError::MigrationRegistryDefect { reason }) + if reason.contains("migration hook `nip09_reconciliation_v1` is declared more than once") + )); + + let mut misbound = EVENT_STORE_MIGRATIONS[3]; + misbound.version = 2; + misbound.name = "future_replacement"; + assert!(matches!( + validate_migration_registry(&[EVENT_STORE_MIGRATIONS[0], misbound], 1, 2), + Err(RadrootsEventStoreError::MigrationRegistryDefect { reason }) + if reason.contains("bound to canonical migration 4 `source_maintenance`") + )); + } + + #[tokio::test] + async fn migration_catalog_delta_requires_exact_symmetric_replacements() { + const ADDED_OBJECTS: &[&str] = &["radroots_event_store_replacement_probe"]; + const REPLACED_OBJECTS: &[&str] = &["event_envelope_kind_created_idx"]; + const EXTRA_REPLACEMENTS: &[&str] = &[ + "event_envelope_kind_created_idx", + "event_envelope_projection_idx", + ]; + let pool = memory_pool().await; + install_unledgered_baseline(&pool).await; + let original_index_sql = schema_object_sql(&pool, REPLACED_OBJECTS[0]).await; + let mut connection = pool.acquire().await.expect("connection"); + let before = read_catalog(&mut connection).await.expect("before catalog"); + sqlx::raw_sql( + "DROP INDEX event_envelope_kind_created_idx; + CREATE INDEX event_envelope_kind_created_idx + ON event_envelopes(kind, event_id); + CREATE TABLE radroots_event_store_replacement_probe ( + id INTEGER PRIMARY KEY NOT NULL + ) STRICT;", + ) + .execute(&mut *connection) + .await + .expect("replacement up delta"); + let changed = read_catalog(&mut connection) + .await + .expect("changed catalog"); + let mut migration = synthetic_v2_descriptor(ZERO_SHA256); + migration.owned_object_names = ADDED_OBJECTS; + migration.owned_table_names = ADDED_OBJECTS; + migration.replaced_object_names = REPLACED_OBJECTS; + validate_catalog_delta(&before, &changed, &migration, "up") + .expect("exact up replacement delta"); + + let mut undeclared = migration; + undeclared.replaced_object_names = &[]; + assert!(matches!( + validate_catalog_delta(&before, &changed, &undeclared, "up"), + Err(RadrootsEventStoreError::MigrationCatalogDeltaMismatch { .. }) + )); + let mut missing = migration; + missing.replaced_object_names = EXTRA_REPLACEMENTS; + assert!(matches!( + validate_catalog_delta(&before, &changed, &missing, "up"), + Err(RadrootsEventStoreError::MigrationCatalogDeltaMismatch { .. }) + )); + let mut add_remove_masquerade = changed.clone(); + add_remove_masquerade.retain(|row| row.name != REPLACED_OBJECTS[0]); + assert!(matches!( + validate_catalog_delta(&before, &add_remove_masquerade, &migration, "up"), + Err(RadrootsEventStoreError::MigrationCatalogDeltaMismatch { .. }) + )); + + sqlx::raw_sql("DROP TABLE radroots_event_store_replacement_probe;") + .execute(&mut *connection) + .await + .expect("remove added object"); + sqlx::raw_sql("DROP INDEX event_envelope_kind_created_idx;") + .execute(&mut *connection) + .await + .expect("remove replacement index"); + sqlx::query(sqlx::AssertSqlSafe(original_index_sql.clone())) + .execute(&mut *connection) + .await + .expect("restore predecessor index"); + let restored = read_catalog(&mut connection) + .await + .expect("restored catalog"); + validate_catalog_delta(&changed, &restored, &migration, "down") + .expect("exact down replacement delta"); + assert_eq!( + restored + .iter() + .find(|row| row.name == REPLACED_OBJECTS[0]) + .and_then(|row| row.sql.as_deref()), + Some(original_index_sql.as_str()) + ); + } + + #[tokio::test] + async fn v4_marker_open_allows_repairing_prior_transition_high_water_drift() { + let pool = memory_pool().await; + migrate_event_store_schema_with_registry( + &pool, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + ) + .await + .expect("install v4 schema"); + + let authority_guard = schema_object_sql( + &pool, + "radroots_event_store_source_state_authority_update_guard", + ) + .await; + sqlx::query("DROP TRIGGER radroots_event_store_source_state_authority_update_guard") + .execute(&pool) + .await + .expect("temporarily remove state authority guard"); + sqlx::query( + "UPDATE radroots_event_store_source_state SET last_transition_seq = 7 WHERE singleton = 1", + ) + .execute(&pool) + .await + .expect("drift derived transition high-water"); + sqlx::query(sqlx::AssertSqlSafe(authority_guard)) + .execute(&pool) + .await + .expect("restore state authority guard"); + let mut connection = pool.acquire().await.expect("fingerprint connection"); + validate_schema_fingerprint( + &mut connection, + EVENT_STORE_MIGRATIONS, + &EVENT_STORE_MIGRATIONS[3], + ) + .await + .expect("exact v4 catalog after drift fixture"); + drop(connection); + + let target_generation = [0x91; 32]; + let mut transaction = pool + .begin_with("BEGIN IMMEDIATE") + .await + .expect("marker transaction"); + let wrong_prior = insert_test_rebuild_marker(&mut transaction, &target_generation, 0, 6) + .await + .expect_err("marker must still bind the exact prior transition high-water"); + assert!(wrong_prior.as_database_error().is_some_and(|error| { + error + .message() + .contains("exact raw and prior source authority") + })); + let wrong_floor = insert_test_rebuild_marker(&mut transaction, &target_generation, 1, 7) + .await + .expect_err("marker must bind the actual retained transition maximum"); + assert!(wrong_floor.as_database_error().is_some_and(|error| { + error + .message() + .contains("exact raw and prior source authority") + })); + let inserted = insert_test_rebuild_marker(&mut transaction, &target_generation, 0, 7) + .await + .expect("derived transition drift is repairable under v4"); + assert_eq!(inserted.rows_affected(), 1); + let marker_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM radroots_event_store_source_rebuild_marker") + .fetch_one(&mut *transaction) + .await + .expect("marker count"); + assert_eq!(marker_count, 1); + + let appended = sqlx::query( + "INSERT INTO radroots_event_store_source_generation(source_generation, generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq) SELECT target_generation, target_generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq FROM radroots_event_store_source_rebuild_marker WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect("append repair generation"); + assert_eq!(appended.rows_affected(), 1); + let rotated = sqlx::query( + "UPDATE radroots_event_store_source_state SET active_generation = ?, raw_event_count = 0, raw_tag_count = 0, raw_high_water_seq = 0, last_transition_seq = 0 WHERE singleton = 1 AND active_generation = (SELECT prior_active_generation FROM radroots_event_store_source_rebuild_marker WHERE singleton = 1) AND raw_event_count = (SELECT prior_raw_event_count FROM radroots_event_store_source_rebuild_marker WHERE singleton = 1) AND raw_tag_count = (SELECT prior_raw_tag_count FROM radroots_event_store_source_rebuild_marker WHERE singleton = 1) AND raw_high_water_seq = (SELECT prior_raw_high_water_seq FROM radroots_event_store_source_rebuild_marker WHERE singleton = 1) AND last_transition_seq = (SELECT prior_last_transition_seq FROM radroots_event_store_source_rebuild_marker WHERE singleton = 1)", + ) + .bind(target_generation.as_slice()) + .execute(&mut *transaction) + .await + .expect("rotate source state through exact marker CAS"); + assert_eq!(rotated.rows_affected(), 1); + let repaired: (Vec<u8>, i64, i64) = sqlx::query_as( + "SELECT state.active_generation, state.last_transition_seq, COALESCE(MAX(transition.transition_seq), 0) FROM radroots_event_store_source_state AS state LEFT JOIN radroots_event_store_addressable_head_transition AS transition ON transition.source_generation = state.active_generation WHERE state.singleton = 1 GROUP BY state.active_generation, state.last_transition_seq", + ) + .fetch_one(&mut *transaction) + .await + .expect("repaired transition authority"); + assert_eq!(repaired.0.as_slice(), target_generation.as_slice()); + assert_eq!(repaired.1, repaired.2); + assert_eq!(repaired.1, 0); + transaction + .rollback() + .await + .expect("rollback marker fixture"); + } + + #[tokio::test] + async fn v3_to_v4_rejects_prior_transition_drift_atomically() { + let pool = memory_pool().await; + migrate_event_store_schema_with_registry( + &pool, + &EVENT_STORE_MIGRATIONS[..3], + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + 3, + ) + .await + .expect("install managed v3 schema"); + let healthy_state: (Vec<u8>, i64, i64, i64, i64) = sqlx::query_as( + "SELECT active_generation, raw_event_count, raw_tag_count, raw_high_water_seq, last_transition_seq FROM radroots_event_store_source_state WHERE singleton = 1", + ) + .fetch_one(&pool) + .await + .expect("healthy v3 source state"); + let authority_guard = schema_object_sql( + &pool, + "radroots_event_store_source_state_authority_update_guard", + ) + .await; + let mut predecessor_trigger_sql = BTreeMap::new(); + for name in crate::migrations::EVENT_STORE_SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES { + predecessor_trigger_sql.insert(*name, schema_object_sql(&pool, name).await); + } + let ledger_before: Vec<(i64, String, String, String, String)> = sqlx::query_as( + "SELECT version, name, up_sha256, down_sha256, schema_sha256 FROM radroots_event_store_schema_migrations ORDER BY version", + ) + .fetch_all(&pool) + .await + .expect("v3 ledger before drift"); + + let mut corruption = pool + .begin_with("BEGIN IMMEDIATE") + .await + .expect("v3 corruption fixture transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_state_authority_update_guard") + .execute(&mut *corruption) + .await + .expect("temporarily remove state authority guard"); + sqlx::query( + "UPDATE radroots_event_store_source_state SET last_transition_seq = 7 WHERE singleton = 1", + ) + .execute(&mut *corruption) + .await + .expect("drift managed v3 transition high-water"); + sqlx::query(sqlx::AssertSqlSafe(authority_guard.clone())) + .execute(&mut *corruption) + .await + .expect("restore exact v3 state authority guard"); + corruption + .commit() + .await + .expect("commit corrupt managed-v3 fixture"); + let corrupt_state: (Vec<u8>, i64, i64, i64, i64) = sqlx::query_as( + "SELECT active_generation, raw_event_count, raw_tag_count, raw_high_water_seq, last_transition_seq FROM radroots_event_store_source_state WHERE singleton = 1", + ) + .fetch_one(&pool) + .await + .expect("committed corrupt v3 source state"); + assert_eq!(corrupt_state.4, 7); + assert_ne!(corrupt_state, healthy_state); + + let error = migrate_event_store_schema_with_registry( + &pool, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + ) + .await + .expect_err("v4 upgrade must not repair corrupt managed-v3 hook state"); + assert!( + matches!( + error, + RadrootsEventStoreError::MigrationHookStateDrift { + hook_id: "nip09_reconciliation_v1", + .. + } + ), + "unexpected managed-v3 drift failure: {error:?}" + ); + + assert_eq!( + sqlx::query_as::<_, (Vec<u8>, i64, i64, i64, i64)>( + "SELECT active_generation, raw_event_count, raw_tag_count, raw_high_water_seq, last_transition_seq FROM radroots_event_store_source_state WHERE singleton = 1", + ) + .fetch_one(&pool) + .await + .expect("corrupt state after rejected upgrade"), + corrupt_state + ); + let ledger_after: Vec<(i64, String, String, String, String)> = sqlx::query_as( + "SELECT version, name, up_sha256, down_sha256, schema_sha256 FROM radroots_event_store_schema_migrations ORDER BY version", + ) + .fetch_all(&pool) + .await + .expect("ledger after rejected upgrade"); + assert_eq!(ledger_after, ledger_before); + assert_eq!( + ledger_after.iter().map(|row| row.0).collect::<Vec<_>>(), + vec![1, 2, 3] + ); + let v4_objects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM main.sqlite_schema WHERE name = 'radroots_event_store_source_capacity_v1'", + ) + .fetch_one(&pool) + .await + .expect("v4 object count after failed upgrade"); + assert_eq!(v4_objects, 0); + let v4_ledger_rows: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM radroots_event_store_schema_migrations WHERE version = 4", + ) + .fetch_one(&pool) + .await + .expect("v4 ledger row count after failed upgrade"); + assert_eq!(v4_ledger_rows, 0); + for (name, sql) in &predecessor_trigger_sql { + assert_eq!(schema_object_sql(&pool, name).await, *sql); + } + + let mut repair = pool + .begin_with("BEGIN IMMEDIATE") + .await + .expect("v3 fixture repair transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_state_authority_update_guard") + .execute(&mut *repair) + .await + .expect("temporarily remove state authority guard for repair"); + sqlx::query( + "UPDATE radroots_event_store_source_state SET last_transition_seq = ? WHERE singleton = 1", + ) + .bind(healthy_state.4) + .execute(&mut *repair) + .await + .expect("repair v3 transition high-water fixture"); + sqlx::query(sqlx::AssertSqlSafe(authority_guard)) + .execute(&mut *repair) + .await + .expect("restore exact v3 state authority guard after repair"); + repair.commit().await.expect("commit v3 fixture repair"); + assert_eq!( + inspect_event_store_schema_status_with_registry( + &pool, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + ) + .await + .expect("managed v3 status after explicit fixture repair"), + RadrootsEventStoreSchemaStatus::Managed { version: 3 } + ); + } + + #[tokio::test] + async fn v4_food_reset_requires_marker_rotation_and_preserves_target_rows() { + const PROJECTION_INSERT_GUARD: &str = + "radroots_event_store_food_availability_projection_insert_guard"; + const IMAGE_INSERT_GUARD: &str = + "radroots_event_store_food_availability_image_insert_guard"; + const CURSOR_DELETE_GUARD: &str = + "radroots_event_store_food_availability_cursor_delete_guard"; + let pool = memory_pool().await; + migrate_event_store_schema_with_registry( + &pool, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + ) + .await + .expect("install v4 schema"); + let active_generation: Vec<u8> = sqlx::query_scalar( + "SELECT active_generation FROM radroots_event_store_source_state WHERE singleton = 1", + ) + .fetch_one(&pool) + .await + .expect("active generation"); + let target_generation = [0x92; 32]; + assert_ne!(active_generation.as_slice(), target_generation.as_slice()); + + let mut connection = pool.acquire().await.expect("fixture connection"); + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&mut *connection) + .await + .expect("disable fixture foreign keys"); + let mut guard_definitions = Vec::new(); + for guard in [ + PROJECTION_INSERT_GUARD, + IMAGE_INSERT_GUARD, + CURSOR_DELETE_GUARD, + ] { + let definition: String = + sqlx::query_scalar("SELECT sql FROM main.sqlite_schema WHERE name = ?") + .bind(guard) + .fetch_one(&mut *connection) + .await + .expect("fixture guard definition"); + let drop_statement = format!("DROP TRIGGER {guard}"); + sqlx::query(sqlx::AssertSqlSafe(drop_statement)) + .execute(&mut *connection) + .await + .expect("drop fixture guard"); + guard_definitions.push(definition); + } + sqlx::query("DELETE FROM radroots_event_store_food_availability_cursor") + .execute(&mut *connection) + .await + .expect("remove Food cursor fixture"); + let author = "a".repeat(64); + for (generation, event_id, event_seq, d_tag) in [ + ( + active_generation.as_slice(), + "b".repeat(64), + 1_i64, + "historical", + ), + ( + target_generation.as_slice(), + "c".repeat(64), + 2_i64, + "target", + ), + ] { + 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, ?, ?, ?, ?, 10, 'radroots.food.availability.v1', 'fixture', 'Fixture', 'Fixture summary', 10, 'Victoria, BC', '3', 'CAD', 'lb', NULL, NULL, 'active', '[]', 1)", + ) + .bind(generation) + .bind(author.as_str()) + .bind(d_tag) + .bind(event_id) + .bind(event_seq) + .execute(&mut *connection) + .await + .expect("insert Food projection fixture"); + 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 (?, ?, ?, 0, '[\"image\",\"https://media.example/fixture.webp\"]', NULL, NULL, NULL, NULL, 0, '[]')", + ) + .bind(generation) + .bind(author.as_str()) + .bind(d_tag) + .execute(&mut *connection) + .await + .expect("insert Food image fixture"); + } + for definition in guard_definitions { + sqlx::query(sqlx::AssertSqlSafe(definition)) + .execute(&mut *connection) + .await + .expect("restore fixture guard"); + } + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut *connection) + .await + .expect("restore fixture foreign keys"); + drop(connection); + + for table in [ + "radroots_event_store_food_availability_image", + "radroots_event_store_food_availability_projection", + ] { + let statement = format!("DELETE FROM {table} WHERE source_generation = ?"); + let error = sqlx::query(sqlx::AssertSqlSafe(statement)) + .bind(active_generation.as_slice()) + .execute(&pool) + .await + .expect_err("marker-free Food reset must fail"); + assert!(error.as_database_error().is_some()); + } + + let mut transaction = pool + .begin_with("BEGIN IMMEDIATE") + .await + .expect("Food reset transaction"); + insert_test_rebuild_marker(&mut transaction, &target_generation, 0, 0) + .await + .expect("open rebuild marker"); + let pre_rotation = sqlx::query( + "DELETE FROM radroots_event_store_food_availability_image WHERE source_generation = ?", + ) + .bind(active_generation.as_slice()) + .execute(&mut *transaction) + .await + .expect_err("marker alone must not authorize Food reset"); + assert!(pre_rotation.as_database_error().is_some()); + + let appended = sqlx::query( + "INSERT INTO radroots_event_store_source_generation(source_generation, generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq) SELECT target_generation, target_generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq FROM radroots_event_store_source_rebuild_marker WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect("append target generation"); + assert_eq!(appended.rows_affected(), 1); + let rotated = sqlx::query( + "UPDATE radroots_event_store_source_state SET active_generation = ?, raw_event_count = 0, raw_tag_count = 0, raw_high_water_seq = 0, last_transition_seq = 0 WHERE singleton = 1", + ) + .bind(target_generation.as_slice()) + .execute(&mut *transaction) + .await + .expect("rotate source state"); + assert_eq!(rotated.rows_affected(), 1); + + for table in [ + "radroots_event_store_food_availability_image", + "radroots_event_store_food_availability_projection", + ] { + let statement = format!("DELETE FROM {table} WHERE source_generation = ?"); + let deleted = sqlx::query(sqlx::AssertSqlSafe(statement)) + .bind(active_generation.as_slice()) + .execute(&mut *transaction) + .await + .expect("post-rotation historical Food reset"); + assert_eq!(deleted.rows_affected(), 1); + } + for table in [ + "radroots_event_store_food_availability_image", + "radroots_event_store_food_availability_projection", + ] { + let statement = format!("DELETE FROM {table} WHERE source_generation = ?"); + let error = sqlx::query(sqlx::AssertSqlSafe(statement)) + .bind(target_generation.as_slice()) + .execute(&mut *transaction) + .await + .expect_err("active target-generation Food rows must remain guarded"); + assert!(error.as_database_error().is_some()); + } + let remaining: (i64, i64) = sqlx::query_as( + "SELECT (SELECT COUNT(*) FROM radroots_event_store_food_availability_projection WHERE source_generation = ?), (SELECT COUNT(*) FROM radroots_event_store_food_availability_image WHERE source_generation = ?)", + ) + .bind(target_generation.as_slice()) + .bind(target_generation.as_slice()) + .fetch_one(&mut *transaction) + .await + .expect("target Food rows"); + assert_eq!(remaining, (1, 1)); + transaction + .rollback() + .await + .expect("rollback Food reset fixture"); + } + + #[tokio::test] + async fn v4_down_restores_exact_predecessor_trigger_sql_and_fingerprint() { + const REPLACED: &[&str] = &[ + "radroots_event_store_food_availability_image_delete_guard", + "radroots_event_store_food_availability_projection_delete_guard", + "radroots_event_store_source_rebuild_marker_insert_guard", + ]; + let pool = memory_pool().await; + migrate_event_store_schema_with_registry( + &pool, + &EVENT_STORE_MIGRATIONS[..3], + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + 3, + ) + .await + .expect("install v3 schema"); + let mut predecessor_sql = BTreeMap::new(); + for name in REPLACED { + predecessor_sql.insert(*name, schema_object_sql(&pool, name).await); + } + + migrate_event_store_schema_with_registry( + &pool, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + ) + .await + .expect("upgrade to v4"); + for name in REPLACED { + assert_ne!(schema_object_sql(&pool, name).await, predecessor_sql[*name]); + } + + rollback_event_store_schema_with_registry( + &pool, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + 3, + ) + .await + .expect("rollback v4 to v3"); + for name in REPLACED { + assert_eq!(schema_object_sql(&pool, name).await, predecessor_sql[*name]); + } + assert_eq!( + inspect_event_store_schema_status_with_registry( + &pool, + EVENT_STORE_MIGRATIONS, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, + ) + .await + .expect("restored v3 status"), + RadrootsEventStoreSchemaStatus::Managed { version: 3 } + ); + } + #[tokio::test] async fn synthetic_v2_owned_objects_are_fingerprinted_and_foreign_keys_are_checked() { let registry = synthetic_v2_registry().await; @@ -2480,6 +3591,7 @@ CREATE TABLE forgotten_v2_table ( down_sha256: leaked_sha256(DOWN), schema_sha256: ZERO_SHA256, owned_object_names: OBJECTS, + replaced_object_names: &[], owned_table_names: OBJECTS, fts5_table_names: NO_FTS5_TABLES, hook: crate::migrations::EventStoreMigrationHook::None, @@ -2520,7 +3632,7 @@ CREATE TABLE forgotten_v2_table ( SqliteConnectOptions::new() .filename(&path) .create_if_missing(true) - .busy_timeout(Duration::from_millis(100)) + .busy_timeout(Duration::ZERO) }; let first = SqlitePoolOptions::new() .max_connections(1) @@ -2540,14 +3652,9 @@ CREATE TABLE forgotten_v2_table ( .begin_with("BEGIN IMMEDIATE") .await .expect("writer transaction"); - let started = Instant::now(); migrate_event_store_schema(&second) .await .expect("read-only fast path"); - assert!( - started.elapsed() < Duration::from_secs(1), - "current-schema migration attempted to wait for a writer lock" - ); writer.rollback().await.expect("release writer"); } diff --git a/crates/event_store/src/source_maintenance_v1.rs b/crates/event_store/src/source_maintenance_v1.rs @@ -0,0 +1,1197 @@ +#![forbid(unsafe_code)] + +use crate::model::{RadrootsEventIngest, RadrootsEventStoreSourceGeneration}; +use crate::nip09::reconciliation_v1::{ + ReconciliationCapacity, ReconciliationCapacityLimits, measure_reconciliation_capacity_bounded, +}; +use crate::{ + RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1, RadrootsEventStoreError, + RadrootsEventStoreSourceCapacityResourceV1, +}; +use sqlx::{Row, SqliteConnection}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct RawSourceCapacityDeltaV1 { + raw_events: u64, + raw_tags: u64, + raw_event_bytes: u64, + raw_tag_bytes: u64, +} + +/// Persisted retained raw-source capacity sealed to one database snapshot. +/// +/// This is the constant-cost authority used by ordinary reads and writes. It +/// does not rescan raw rows; migrations and database reopen perform the full +/// raw-source recount that authenticates this seal. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct RadrootsEventStoreSourceCapacityV1 { + source_generation: RadrootsEventStoreSourceGeneration, + capacity: ReconciliationCapacity, + raw_high_water_seq: i64, + retained_generation_count: u32, + retained_generation_limit: u32, +} + +impl RadrootsEventStoreSourceCapacityV1 { + /// Returns the active source generation sealed by this snapshot. + pub const fn source_generation(&self) -> RadrootsEventStoreSourceGeneration { + self.source_generation + } + + /// Returns the retained raw event-row count. + pub const fn raw_event_count(&self) -> u64 { + self.capacity.raw_events + } + + /// Returns the retained raw tag-row count. + pub const fn raw_tag_count(&self) -> u64 { + self.capacity.raw_tags + } + + /// Returns governed UTF-8 bytes across retained raw event text fields. + pub const fn raw_event_text_bytes(&self) -> u64 { + self.capacity.raw_event_bytes + } + + /// Returns governed UTF-8 bytes across retained raw tag text fields. + pub const fn raw_tag_text_bytes(&self) -> u64 { + self.capacity.raw_tag_bytes + } + + /// Returns the greatest retained raw event sequence. + pub const fn raw_high_water_seq(&self) -> i64 { + self.raw_high_water_seq + } + + /// Returns the append-only source generations currently retained. + pub const fn retained_generation_count(&self) -> u32 { + self.retained_generation_count + } + + /// Returns the maximum append-only source generations this store retains. + pub const fn retained_generation_limit(&self) -> u32 { + self.retained_generation_limit + } +} + +pub(crate) fn raw_source_capacity_delta_v1( + ingest: &RadrootsEventIngest, + tags_json: &str, +) -> Result<RawSourceCapacityDeltaV1, RadrootsEventStoreError> { + let event = ingest.event(); + let raw_event_bytes = raw_event_row_bytes_v1( + event.id_str(), + event.author_str(), + tags_json, + event.content(), + event.sig_str(), + ingest.raw_json(), + )?; + let mut raw_tags = 0_u64; + let mut raw_tag_bytes = 0_u64; + for tag in event.tag_slices() { + let values = tag.as_slice(); + let tag_name = values.first().map(String::as_str).unwrap_or(""); + let tag_value = values.get(1).map(String::as_str); + let tag_json = serde_json::to_string(values)?; + raw_tags = checked_capacity_add( + RadrootsEventStoreSourceCapacityResourceV1::RawTags, + raw_tags, + 1, + )?; + raw_tag_bytes = checked_capacity_add( + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + raw_tag_bytes, + raw_tag_row_bytes_v1(event.id_str(), tag_name, tag_value, tag_json.as_str())?, + )?; + } + Ok(RawSourceCapacityDeltaV1 { + raw_events: 1, + raw_tags, + raw_event_bytes, + raw_tag_bytes, + }) +} + +pub(crate) async fn preflight_unique_raw_source_append_v1( + connection: &mut SqliteConnection, + delta: RawSourceCapacityDeltaV1, +) -> Result<(), RadrootsEventStoreError> { + let current = validate_source_capacity_authority_fast_v1(connection).await?; + validate_prospective_capacity(current.capacity, delta) +} + +pub(crate) async fn advance_source_capacity_after_insert_v1( + connection: &mut SqliteConnection, + delta: RawSourceCapacityDeltaV1, + inserted_seq: i64, +) -> Result<(), RadrootsEventStoreError> { + let current = read_source_capacity_v1(connection).await?; + validate_prospective_capacity(current.capacity, delta)?; + let next = ReconciliationCapacity { + raw_events: checked_capacity_add( + RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + current.capacity.raw_events, + delta.raw_events, + )?, + raw_tags: checked_capacity_add( + RadrootsEventStoreSourceCapacityResourceV1::RawTags, + current.capacity.raw_tags, + delta.raw_tags, + )?, + raw_event_bytes: checked_capacity_add( + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, + current.capacity.raw_event_bytes, + delta.raw_event_bytes, + )?, + raw_tag_bytes: checked_capacity_add( + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + current.capacity.raw_tag_bytes, + delta.raw_tag_bytes, + )?, + }; + let updated = sqlx::query( + "UPDATE radroots_event_store_source_capacity_v1 SET raw_event_count = ?, raw_tag_count = ?, raw_event_bytes = ?, raw_tag_bytes = ?, raw_high_water_seq = ? WHERE singleton = 1 AND source_generation = ? AND raw_event_count = ? AND raw_tag_count = ? AND raw_event_bytes = ? AND raw_tag_bytes = ? AND raw_high_water_seq = ? AND retained_generation_count = ? AND retained_generation_limit = ?", + ) + .bind(sqlite_capacity_value(next.raw_events, "raw_event_count")?) + .bind(sqlite_capacity_value(next.raw_tags, "raw_tag_count")?) + .bind(sqlite_capacity_value(next.raw_event_bytes, "raw_event_bytes")?) + .bind(sqlite_capacity_value(next.raw_tag_bytes, "raw_tag_bytes")?) + .bind(inserted_seq) + .bind(current.source_generation.as_bytes().as_slice()) + .bind(sqlite_capacity_value( + current.capacity.raw_events, + "raw_event_count", + )?) + .bind(sqlite_capacity_value( + current.capacity.raw_tags, + "raw_tag_count", + )?) + .bind(sqlite_capacity_value( + current.capacity.raw_event_bytes, + "raw_event_bytes", + )?) + .bind(sqlite_capacity_value( + current.capacity.raw_tag_bytes, + "raw_tag_bytes", + )?) + .bind(current.raw_high_water_seq) + .bind(i64::from(current.retained_generation_count)) + .bind(i64::from(current.retained_generation_limit)) + .execute(&mut *connection) + .await?; + if updated.rows_affected() != 1 { + return source_capacity_drift(format!( + "append authority compare-and-swap affected {} rows", + updated.rows_affected() + )); + } + validate_source_capacity_authority_fast_v1(connection) + .await + .map(|_| ()) +} + +pub(crate) async fn apply_source_maintenance_hook_v1( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsEventStoreError> { + let capacity = measure_reconciliation_capacity_bounded( + connection, + ReconciliationCapacityLimits::production(), + ) + .await?; + validate_measured_capacity(capacity)?; + validate_no_persisted_ephemeral_raw_rows_v1(connection).await?; + let row = sqlx::query( + "SELECT state.active_generation, state.raw_event_count, state.raw_tag_count, state.raw_high_water_seq, (SELECT COUNT(*) FROM (SELECT 1 FROM radroots_event_store_source_generation LIMIT 9)) AS retained_generation_count FROM radroots_event_store_source_state AS state WHERE state.singleton = 1", + ) + .fetch_one(&mut *connection) + .await?; + let source_generation = source_generation_bytes(row.try_get("active_generation")?)?; + let raw_event_count: i64 = row.try_get("raw_event_count")?; + let raw_tag_count: i64 = row.try_get("raw_tag_count")?; + let raw_high_water_seq: i64 = row.try_get("raw_high_water_seq")?; + let retained_generation_count = generation_count(row.try_get("retained_generation_count")?)?; + if retained_generation_count > RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1 { + return Err( + RadrootsEventStoreError::SourceGenerationHistoryLimitReached { + current: retained_generation_count, + limit: RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1, + }, + ); + } + if raw_event_count != sqlite_capacity_value(capacity.raw_events, "raw_event_count")? + || raw_tag_count != sqlite_capacity_value(capacity.raw_tags, "raw_tag_count")? + { + return source_capacity_drift( + "measured raw row counts disagree with active source state".to_owned(), + ); + } + let inserted = sqlx::query( + "INSERT INTO radroots_event_store_source_capacity_v1(singleton, source_generation, raw_event_count, raw_tag_count, raw_event_bytes, raw_tag_bytes, raw_high_water_seq, retained_generation_count, retained_generation_limit) VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?)", + ) + .bind(source_generation.as_slice()) + .bind(raw_event_count) + .bind(raw_tag_count) + .bind(sqlite_capacity_value( + capacity.raw_event_bytes, + "raw_event_bytes", + )?) + .bind(sqlite_capacity_value( + capacity.raw_tag_bytes, + "raw_tag_bytes", + )?) + .bind(raw_high_water_seq) + .bind(i64::from(retained_generation_count)) + .bind(i64::from( + RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1, + )) + .execute(&mut *connection) + .await?; + if inserted.rows_affected() != 1 { + return source_capacity_drift(format!( + "source capacity initialization affected {} rows", + inserted.rows_affected() + )); + } + validate_source_capacity_authority_full_v1(connection).await +} + +pub(crate) async fn validate_source_capacity_authority_fast_v1( + connection: &mut SqliteConnection, +) -> Result<RadrootsEventStoreSourceCapacityV1, RadrootsEventStoreError> { + let capacity = read_source_capacity_v1(connection).await?; + validate_measured_capacity(capacity.capacity)?; + if capacity.retained_generation_limit + != RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1 + { + return source_capacity_drift(format!( + "retained generation limit is {}, expected {}", + capacity.retained_generation_limit, + RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1 + )); + } + let row = sqlx::query( + "SELECT state.active_generation, state.raw_event_count, state.raw_tag_count, state.raw_high_water_seq, generation.generation_ordinal, (SELECT COUNT(*) FROM (SELECT 1 FROM radroots_event_store_source_generation LIMIT 9)) AS retained_generation_count 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", + ) + .fetch_one(&mut *connection) + .await?; + let active_generation = RadrootsEventStoreSourceGeneration::from_bytes( + source_generation_bytes(row.try_get("active_generation")?)?, + ); + let raw_event_count = sqlite_nonnegative_capacity( + RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + row.try_get("raw_event_count")?, + )?; + let raw_tag_count = sqlite_nonnegative_capacity( + RadrootsEventStoreSourceCapacityResourceV1::RawTags, + row.try_get("raw_tag_count")?, + )?; + let raw_high_water_seq: i64 = row.try_get("raw_high_water_seq")?; + let generation_ordinal = generation_count(row.try_get("generation_ordinal")?)?; + let retained_generation_count = generation_count(row.try_get("retained_generation_count")?)?; + if active_generation != capacity.source_generation + || raw_event_count != capacity.capacity.raw_events + || raw_tag_count != capacity.capacity.raw_tags + || raw_high_water_seq != capacity.raw_high_water_seq + || generation_ordinal != retained_generation_count + || retained_generation_count != capacity.retained_generation_count + || retained_generation_count > capacity.retained_generation_limit + { + return source_capacity_drift( + "capacity seal does not match active source state and generation history".to_owned(), + ); + } + Ok(capacity) +} + +pub(crate) async fn validate_source_capacity_authority_full_v1( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsEventStoreError> { + let persisted = validate_source_capacity_authority_fast_v1(connection).await?; + let measured = measure_reconciliation_capacity_bounded( + connection, + ReconciliationCapacityLimits::production(), + ) + .await?; + validate_measured_capacity(measured)?; + validate_no_persisted_ephemeral_raw_rows_v1(connection).await?; + if measured != persisted.capacity { + return source_capacity_drift(format!( + "persisted capacity {:?} differs from measured raw authority {measured:?}", + persisted.capacity + )); + } + Ok(()) +} + +pub(crate) async fn validate_no_persisted_ephemeral_raw_rows_v1( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsEventStoreError> { + let row = sqlx::query( + "SELECT event_id, kind FROM event_envelopes WHERE kind BETWEEN 20000 AND 29999 ORDER BY seq LIMIT 1", + ) + .fetch_optional(&mut *connection) + .await?; + if let Some(row) = row { + return Err(RadrootsEventStoreError::PersistedEphemeralRawEvent { + event_id: row.try_get("event_id")?, + kind: row.try_get("kind")?, + }); + } + Ok(()) +} + +pub(crate) async fn preflight_source_generation_append_v1( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsEventStoreError> { + let capacity_authority_exists: i64 = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM main.sqlite_schema WHERE type = 'table' AND name = 'radroots_event_store_source_capacity_v1')", + ) + .fetch_one(&mut *connection) + .await?; + if capacity_authority_exists == 0 { + return Ok(()); + } + let capacity = validate_source_capacity_authority_fast_v1(connection).await?; + validate_source_generation_append_available_v1( + capacity.retained_generation_count, + capacity.retained_generation_limit, + ) +} + +pub(crate) async fn bind_source_capacity_to_generation_v1( + connection: &mut SqliteConnection, + target_generation: RadrootsEventStoreSourceGeneration, +) -> Result<bool, RadrootsEventStoreError> { + let capacity_authority_exists: i64 = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM main.sqlite_schema WHERE type = 'table' AND name = 'radroots_event_store_source_capacity_v1')", + ) + .fetch_one(&mut *connection) + .await?; + if capacity_authority_exists == 0 { + return Ok(false); + } + let current = read_source_capacity_v1(connection).await?; + if current.source_generation == target_generation { + return source_capacity_drift( + "source rebuild target already owns the persisted capacity seal".to_owned(), + ); + } + let updated = sqlx::query( + "UPDATE radroots_event_store_source_capacity_v1 SET source_generation = ? WHERE singleton = 1 AND source_generation = ? AND raw_event_count = ? AND raw_tag_count = ? AND raw_event_bytes = ? AND raw_tag_bytes = ? AND raw_high_water_seq = ? AND retained_generation_count = ? AND retained_generation_limit = ?", + ) + .bind(target_generation.as_bytes().as_slice()) + .bind(current.source_generation.as_bytes().as_slice()) + .bind(sqlite_capacity_value( + current.capacity.raw_events, + "raw_event_count", + )?) + .bind(sqlite_capacity_value( + current.capacity.raw_tags, + "raw_tag_count", + )?) + .bind(sqlite_capacity_value( + current.capacity.raw_event_bytes, + "raw_event_bytes", + )?) + .bind(sqlite_capacity_value( + current.capacity.raw_tag_bytes, + "raw_tag_bytes", + )?) + .bind(current.raw_high_water_seq) + .bind(i64::from(current.retained_generation_count)) + .bind(i64::from(current.retained_generation_limit)) + .execute(&mut *connection) + .await?; + if updated.rows_affected() != 1 { + return source_capacity_drift(format!( + "source rebuild capacity bind affected {} rows", + updated.rows_affected() + )); + } + let rebound = validate_source_capacity_authority_fast_v1(connection).await?; + if rebound.source_generation != target_generation { + return source_capacity_drift( + "source rebuild capacity bind did not select its target generation".to_owned(), + ); + } + Ok(true) +} + +fn validate_source_generation_append_available_v1( + current: u32, + limit: u32, +) -> Result<(), RadrootsEventStoreError> { + if current >= limit { + return Err( + RadrootsEventStoreError::SourceGenerationHistoryLimitReached { current, limit }, + ); + } + Ok(()) +} + +async fn read_source_capacity_v1( + connection: &mut SqliteConnection, +) -> Result<RadrootsEventStoreSourceCapacityV1, RadrootsEventStoreError> { + let rows = sqlx::query( + "SELECT source_generation, raw_event_count, raw_tag_count, raw_event_bytes, raw_tag_bytes, raw_high_water_seq, retained_generation_count, retained_generation_limit FROM radroots_event_store_source_capacity_v1 WHERE singleton = 1", + ) + .fetch_all(&mut *connection) + .await?; + if rows.len() != 1 { + return source_capacity_drift(format!( + "expected one source capacity row, found {}", + rows.len() + )); + } + let row = &rows[0]; + Ok(RadrootsEventStoreSourceCapacityV1 { + source_generation: RadrootsEventStoreSourceGeneration::from_bytes(source_generation_bytes( + row.try_get("source_generation")?, + )?), + capacity: ReconciliationCapacity { + raw_events: sqlite_nonnegative_capacity( + RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + row.try_get("raw_event_count")?, + )?, + raw_tags: sqlite_nonnegative_capacity( + RadrootsEventStoreSourceCapacityResourceV1::RawTags, + row.try_get("raw_tag_count")?, + )?, + raw_event_bytes: sqlite_nonnegative_capacity( + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, + row.try_get("raw_event_bytes")?, + )?, + raw_tag_bytes: sqlite_nonnegative_capacity( + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + row.try_get("raw_tag_bytes")?, + )?, + }, + raw_high_water_seq: row.try_get("raw_high_water_seq")?, + retained_generation_count: generation_count(row.try_get("retained_generation_count")?)?, + retained_generation_limit: generation_count(row.try_get("retained_generation_limit")?)?, + }) +} + +fn validate_prospective_capacity( + current: ReconciliationCapacity, + delta: RawSourceCapacityDeltaV1, +) -> Result<(), RadrootsEventStoreError> { + let limits = ReconciliationCapacityLimits::production(); + for (resource, requested) in [ + ( + RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + delta.raw_events, + ), + ( + RadrootsEventStoreSourceCapacityResourceV1::RawTags, + delta.raw_tags, + ), + ( + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, + delta.raw_event_bytes, + ), + ( + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + delta.raw_tag_bytes, + ), + ] { + let current_value = current.value(resource); + let limit = limits.limit(resource); + if current_value + .checked_add(requested) + .is_none_or(|next| next > limit) + { + return Err(RadrootsEventStoreError::SourceCapacityExceeded { + resource, + current: current_value, + requested, + limit, + }); + } + } + Ok(()) +} + +fn validate_measured_capacity( + capacity: ReconciliationCapacity, +) -> Result<(), RadrootsEventStoreError> { + validate_prospective_capacity( + capacity, + RawSourceCapacityDeltaV1 { + raw_events: 0, + raw_tags: 0, + raw_event_bytes: 0, + raw_tag_bytes: 0, + }, + ) +} + +fn raw_event_row_bytes_v1( + event_id: &str, + pubkey: &str, + tags_json: &str, + content: &str, + sig: &str, + raw_json: &str, +) -> Result<u64, RadrootsEventStoreError> { + checked_text_byte_sum( + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, + [event_id, pubkey, tags_json, content, sig, raw_json], + ) +} + +fn raw_tag_row_bytes_v1( + event_id: &str, + tag_name: &str, + tag_value: Option<&str>, + tag_json: &str, +) -> Result<u64, RadrootsEventStoreError> { + let required = checked_text_byte_sum( + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + [event_id, tag_name, tag_json], + )?; + checked_capacity_add( + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + required, + u64::try_from(tag_value.map_or(0, str::len)).map_err(|_| { + RadrootsEventStoreError::SourceCapacityExceeded { + resource: RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + current: required, + requested: u64::MAX, + limit: ReconciliationCapacityLimits::production() + .limit(RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes), + } + })?, + ) +} + +fn checked_text_byte_sum<const N: usize>( + resource: RadrootsEventStoreSourceCapacityResourceV1, + values: [&str; N], +) -> Result<u64, RadrootsEventStoreError> { + values.iter().try_fold(0_u64, |current, value| { + let requested = u64::try_from(value.len()).map_err(|_| { + RadrootsEventStoreError::SourceCapacityExceeded { + resource, + current, + requested: u64::MAX, + limit: ReconciliationCapacityLimits::production().limit(resource), + } + })?; + checked_capacity_add(resource, current, requested) + }) +} + +fn checked_capacity_add( + resource: RadrootsEventStoreSourceCapacityResourceV1, + current: u64, + requested: u64, +) -> Result<u64, RadrootsEventStoreError> { + current + .checked_add(requested) + .ok_or_else(|| RadrootsEventStoreError::SourceCapacityExceeded { + resource, + current, + requested, + limit: ReconciliationCapacityLimits::production().limit(resource), + }) +} + +fn sqlite_nonnegative_capacity( + resource: RadrootsEventStoreSourceCapacityResourceV1, + value: i64, +) -> Result<u64, RadrootsEventStoreError> { + u64::try_from(value).map_err(|_| RadrootsEventStoreError::SourceCapacityStateDrift { + reason: format!("persisted {resource} is negative or outside the unsigned range: {value}"), + }) +} + +fn sqlite_capacity_value(value: u64, field: &'static str) -> Result<i64, RadrootsEventStoreError> { + i64::try_from(value).map_err(|_| RadrootsEventStoreError::SourceCapacityStateDrift { + reason: format!("{field} exceeds the SQLite integer range: {value}"), + }) +} + +fn generation_count(value: i64) -> Result<u32, RadrootsEventStoreError> { + u32::try_from(value) + .ok() + .filter(|value| *value > 0) + .ok_or_else(|| RadrootsEventStoreError::SourceCapacityStateDrift { + reason: format!("retained generation count is outside the positive u32 range: {value}"), + }) +} + +fn source_generation_bytes(value: Vec<u8>) -> Result<[u8; 32], RadrootsEventStoreError> { + value.try_into().map_err( + |value: Vec<u8>| RadrootsEventStoreError::SourceCapacityStateDrift { + reason: format!( + "source capacity generation has {} bytes instead of 32", + value.len() + ), + }, + ) +} + +fn source_capacity_drift<T>(reason: String) -> Result<T, RadrootsEventStoreError> { + Err(RadrootsEventStoreError::SourceCapacityStateDrift { reason }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::RadrootsEventStore; + use sqlx::Connection; + + fn capacity_with( + resource: RadrootsEventStoreSourceCapacityResourceV1, + value: u64, + ) -> ReconciliationCapacity { + let mut capacity = ReconciliationCapacity::default(); + match resource { + RadrootsEventStoreSourceCapacityResourceV1::RawEvents => { + capacity.raw_events = value; + } + RadrootsEventStoreSourceCapacityResourceV1::RawTags => { + capacity.raw_tags = value; + } + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes => { + capacity.raw_event_bytes = value; + } + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes => { + capacity.raw_tag_bytes = value; + } + } + capacity + } + + fn delta_with( + resource: RadrootsEventStoreSourceCapacityResourceV1, + value: u64, + ) -> RawSourceCapacityDeltaV1 { + let mut delta = RawSourceCapacityDeltaV1 { + raw_events: 0, + raw_tags: 0, + raw_event_bytes: 0, + raw_tag_bytes: 0, + }; + match resource { + RadrootsEventStoreSourceCapacityResourceV1::RawEvents => delta.raw_events = value, + RadrootsEventStoreSourceCapacityResourceV1::RawTags => delta.raw_tags = value, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes => { + delta.raw_event_bytes = value; + } + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes => { + delta.raw_tag_bytes = value; + } + } + delta + } + + #[tokio::test] + async fn rust_utf8_accounting_matches_sqlite_blob_lengths() { + let mut connection = SqliteConnection::connect("sqlite::memory:") + .await + .expect("memory SQLite"); + let event_fields = [ + "event\0id", + "publíckey", + "[[\"t\",\"野菜\u{0000}\"]]", + "café 🥕\0", + "signature", + "{\"content\":\"café 🥕\\u0000\"}", + ]; + let rust_event = raw_event_row_bytes_v1( + event_fields[0], + event_fields[1], + event_fields[2], + event_fields[3], + event_fields[4], + event_fields[5], + ) + .expect("Rust event byte count"); + let sqlite_event: i64 = sqlx::query_scalar( + "SELECT length(CAST(? AS BLOB)) + length(CAST(? AS BLOB)) + length(CAST(? AS BLOB)) + length(CAST(? AS BLOB)) + length(CAST(? AS BLOB)) + length(CAST(? AS BLOB))", + ) + .bind(event_fields[0]) + .bind(event_fields[1]) + .bind(event_fields[2]) + .bind(event_fields[3]) + .bind(event_fields[4]) + .bind(event_fields[5]) + .fetch_one(&mut connection) + .await + .expect("SQLite event byte count"); + assert_eq!(rust_event, u64::try_from(sqlite_event).expect("positive")); + + let rust_tag = raw_tag_row_bytes_v1( + event_fields[0], + "t\0", + Some("野菜🥕"), + "[\"t\\u0000\",\"野菜🥕\"]", + ) + .expect("Rust tag byte count"); + let sqlite_tag: i64 = sqlx::query_scalar( + "SELECT length(CAST(? AS BLOB)) + length(CAST(? AS BLOB)) + COALESCE(length(CAST(? AS BLOB)), 0) + length(CAST(? AS BLOB))", + ) + .bind(event_fields[0]) + .bind("t\0") + .bind("野菜🥕") + .bind("[\"t\\u0000\",\"野菜🥕\"]") + .fetch_one(&mut connection) + .await + .expect("SQLite tag byte count"); + assert_eq!(rust_tag, u64::try_from(sqlite_tag).expect("positive")); + } + + #[test] + fn every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over() { + let limits = ReconciliationCapacityLimits::production(); + for resource in [ + RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + RadrootsEventStoreSourceCapacityResourceV1::RawTags, + RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, + RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, + ] { + let limit = limits.limit(resource); + validate_prospective_capacity( + capacity_with(resource, limit - 1), + delta_with(resource, 1), + ) + .expect("exact prospective capacity boundary"); + let error = validate_prospective_capacity( + capacity_with(resource, limit), + delta_with(resource, 1), + ) + .expect_err("one-over prospective capacity boundary"); + assert!(matches!( + error, + RadrootsEventStoreError::SourceCapacityExceeded { + resource: actual_resource, + current, + requested: 1, + limit: actual_limit, + } if actual_resource == resource && current == limit && actual_limit == limit + )); + + validate_measured_capacity(capacity_with(resource, limit)) + .expect("exact measured capacity boundary"); + let error = validate_measured_capacity(capacity_with(resource, limit + 1)) + .expect_err("one-over measured capacity boundary"); + assert!(matches!( + error, + RadrootsEventStoreError::SourceCapacityExceeded { + resource: actual_resource, + current, + requested: 0, + limit: actual_limit, + } if actual_resource == resource + && current == limit + 1 + && actual_limit == limit + )); + } + } + + #[test] + fn retained_generation_nip09_logical_rows_have_an_audited_upper_bound() { + let events = crate::RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1; + let tags = crate::RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1; + + // Per generation: one generation row, at most E coordinates, E + // deletion requests, T combined event/address targets, E head states, + // E + T transitions, and one feed-integrity row. + let generation = 1_u64; + let coordinates = events; + let requests = events; + let combined_targets = tags; + let head_states = events; + let transitions = events + tags; + let feed_integrity = 1_u64; + let per_generation = generation + + coordinates + + requests + + combined_targets + + head_states + + transitions + + feed_integrity; + assert_eq!(per_generation, 4 * events + 2 * tags + 2); + assert_eq!(per_generation, 600_002); + assert_eq!( + per_generation * u64::from(RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1), + 4_800_016 + ); + + for table in [ + "radroots_event_store_source_generation", + "radroots_event_store_event_coordinate", + "radroots_event_store_nip09_request", + "radroots_event_store_nip09_event_target", + "radroots_event_store_nip09_address_target", + "radroots_event_store_addressable_head_state", + "radroots_event_store_addressable_head_transition", + "radroots_event_store_addressable_feed_integrity_v1", + ] { + assert!( + crate::migrations::EVENT_STORE_MIGRATIONS + .iter() + .any(|migration| migration.owned_table_names.contains(&table)), + "logical-bound table is absent from the governed migration catalog: {table}" + ); + } + } + + #[test] + fn generation_append_limit_returns_the_typed_current_and_limit() { + validate_source_generation_append_available_v1(7, 8) + .expect("one retained generation slot remains"); + assert!(matches!( + validate_source_generation_append_available_v1(8, 8), + Err( + RadrootsEventStoreError::SourceGenerationHistoryLimitReached { + current: 8, + limit: 8, + } + ) + )); + } + + #[tokio::test] + async fn generation_sql_backstop_allows_exact_append_and_is_conflict_safe_one_over() { + let store = RadrootsEventStore::open_memory().await.expect("open store"); + let mut transaction = store + .begin_write_transaction() + .await + .expect("maintenance fixture transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_capacity_update_guard") + .execute(&mut *transaction) + .await + .expect("remove capacity update guard in rolled-back fixture"); + sqlx::query( + "UPDATE radroots_event_store_source_capacity_v1 SET retained_generation_count = retained_generation_limit - 1 WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect("place fixture one below retained generation limit"); + sqlx::query("DROP TRIGGER radroots_event_store_source_generation_append_guard") + .execute(&mut *transaction) + .await + .expect("isolate the v4 generation-capacity backstop"); + + let exact = sqlx::query( + "INSERT INTO radroots_event_store_source_generation(source_generation, generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq) SELECT ?, generation_ordinal + 1, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq FROM radroots_event_store_source_generation ORDER BY generation_ordinal DESC LIMIT 1", + ) + .bind(vec![0xa4_u8; 32]) + .execute(&mut *transaction) + .await + .expect("exact generation boundary must append"); + assert_eq!(exact.rows_affected(), 1); + let retained_count: i64 = sqlx::query_scalar( + "SELECT retained_generation_count FROM radroots_event_store_source_capacity_v1 WHERE singleton = 1", + ) + .fetch_one(&mut *transaction) + .await + .expect("advanced retained generation count"); + assert_eq!(retained_count, 8); + + let duplicate_error = sqlx::query( + "INSERT INTO radroots_event_store_source_generation(source_generation, generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq) SELECT source_generation, generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq FROM radroots_event_store_source_generation ORDER BY generation_ordinal DESC LIMIT 1", + ) + .execute(&mut *transaction) + .await + .expect_err("duplicate generation remains a v2 conflict at the limit"); + assert!(matches!( + duplicate_error, + sqlx::Error::Database(ref database) + if database.message().contains("source generation already exists") + && !database.message().contains("generation limit reached") + )); + + let error = sqlx::query( + "INSERT INTO radroots_event_store_source_generation(source_generation, generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq) SELECT ?, generation_ordinal + 1, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq FROM radroots_event_store_source_generation ORDER BY generation_ordinal DESC LIMIT 1", + ) + .bind(vec![0xa5_u8; 32]) + .execute(&mut *transaction) + .await + .expect_err("unique generation append must hit the SQL capacity backstop"); + assert!(matches!( + error, + sqlx::Error::Database(ref database) + if database.message().contains("retained source generation limit reached") + )); + transaction + .rollback() + .await + .expect("roll back SQL backstop fixture"); + } + + #[tokio::test] + async fn marker_close_sql_backstop_rejects_each_required_seal_drift() { + const MARKER_CLOSE_ERROR: &str = "event-store rebuild marker cannot close before capacity, NIP-09, and FoodAvailability seals agree"; + + for drift in ["capacity", "nip09", "food", "fts"] { + let store = RadrootsEventStore::open_memory().await.expect("open store"); + let capacity_before = store + .source_capacity_v1() + .await + .expect("capacity before marker fixture"); + let derived_seals_before: (i64, i64, i64) = sqlx::query_as( + "SELECT integrity.last_transition_seq, integrity.transition_count, cursor.projected_row_count FROM radroots_event_store_addressable_feed_integrity_v1 AS integrity JOIN radroots_event_store_source_state AS source ON source.active_generation = integrity.source_generation JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.source_generation = source.active_generation WHERE source.singleton = 1 AND cursor.singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("derived seals before marker fixture"); + let mut transaction = store + .begin_write_transaction() + .await + .expect("marker fixture transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_rebuild_marker_insert_guard") + .execute(&mut *transaction) + .await + .expect("remove marker insert guard in rolled-back fixture"); + sqlx::query( + "INSERT INTO radroots_event_store_source_rebuild_marker(singleton, barrier_key, target_generation, target_generation_ordinal, reconciliation_version, addressable_feed_version, event_contract_registry_version, hook_id, hook_manifest_sha256, transition_floor_seq, baseline_raw_event_count, baseline_raw_tag_count, baseline_raw_high_water_seq, prior_active_generation, prior_raw_event_count, prior_raw_tag_count, prior_raw_high_water_seq, prior_last_transition_seq) SELECT 1, 1, generation.source_generation, generation.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, state.raw_event_count, state.raw_tag_count, state.raw_high_water_seq, state.active_generation, state.raw_event_count, state.raw_tag_count, state.raw_high_water_seq, state.last_transition_seq FROM radroots_event_store_source_generation AS generation JOIN radroots_event_store_source_state AS state ON state.active_generation = generation.source_generation WHERE state.singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect("install completed synthetic marker"); + match drift { + "capacity" => { + sqlx::query("DROP TRIGGER radroots_event_store_source_capacity_update_guard") + .execute(&mut *transaction) + .await + .expect("remove capacity guard in rolled-back fixture"); + sqlx::query( + "UPDATE radroots_event_store_source_capacity_v1 SET raw_event_bytes = raw_event_bytes + 1 WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect("corrupt capacity byte seal"); + } + "nip09" => { + sqlx::query( + "UPDATE radroots_event_store_addressable_feed_integrity_v1 SET last_transition_seq = last_transition_seq + 1, transition_count = transition_count + 1", + ) + .execute(&mut *transaction) + .await + .expect("corrupt NIP-09 feed-integrity seal"); + } + "food" => { + sqlx::query( + "DROP TRIGGER radroots_event_store_food_availability_cursor_update_guard", + ) + .execute(&mut *transaction) + .await + .expect("remove Food cursor guard in rolled-back fixture"); + sqlx::query( + "UPDATE radroots_event_store_food_availability_cursor SET projected_row_count = projected_row_count + 1 WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect("corrupt Food cursor/projection seal"); + } + "fts" => { + sqlx::query( + "INSERT INTO radroots_event_store_food_availability_search_fts(rowid, event_id, pubkey, d_tag, title, summary, content, location) VALUES (1, 'fixture', 'fixture', 'fixture', 'fixture', '', '', '')", + ) + .execute(&mut *transaction) + .await + .expect("corrupt FTS seal"); + } + _ => unreachable!("bounded drift fixture"), + } + + let error = sqlx::query( + "DELETE FROM radroots_event_store_source_rebuild_marker WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect_err("marker close must reject inconsistent seals"); + assert!(matches!( + error, + sqlx::Error::Database(ref database) if database.message() == MARKER_CLOSE_ERROR + )); + transaction + .rollback() + .await + .expect("roll back marker corruption fixture"); + + assert_eq!( + store + .source_capacity_v1() + .await + .expect("capacity after rollback"), + capacity_before + ); + let residue: (i64, i64) = sqlx::query_as( + "SELECT (SELECT COUNT(*) FROM radroots_event_store_source_rebuild_marker), (SELECT COUNT(*) FROM radroots_event_store_food_availability_search_fts)", + ) + .fetch_one(store.pool()) + .await + .expect("marker and FTS residue counts"); + assert_eq!(residue, (0, 0)); + let derived_seals_after: (i64, i64, i64) = sqlx::query_as( + "SELECT integrity.last_transition_seq, integrity.transition_count, cursor.projected_row_count FROM radroots_event_store_addressable_feed_integrity_v1 AS integrity JOIN radroots_event_store_source_state AS source ON source.active_generation = integrity.source_generation JOIN radroots_event_store_food_availability_cursor AS cursor ON cursor.source_generation = source.active_generation WHERE source.singleton = 1 AND cursor.singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("derived seals after marker rollback"); + assert_eq!(derived_seals_after, derived_seals_before); + } + } + + #[tokio::test] + async fn reopen_full_measure_detects_every_persisted_capacity_dimension() { + for (index, capacity_assignment, source_assignment) in [ + (0_u8, "raw_event_count = 1", Some("raw_event_count = 1")), + (1, "raw_tag_count = 1", Some("raw_tag_count = 1")), + (2, "raw_event_bytes = 1", None), + (3, "raw_tag_bytes = 1", None), + ] { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join(format!("capacity-{index}.sqlite")); + let store = RadrootsEventStore::open_file(&path) + .await + .expect("open file store"); + let capacity_guard: String = sqlx::query_scalar( + "SELECT sql FROM main.sqlite_schema WHERE type = 'trigger' AND name = 'radroots_event_store_source_capacity_update_guard'", + ) + .fetch_one(store.pool()) + .await + .expect("capacity update guard SQL"); + let source_guard: String = sqlx::query_scalar( + "SELECT sql FROM main.sqlite_schema WHERE type = 'trigger' AND name = 'radroots_event_store_source_state_authority_update_guard'", + ) + .fetch_one(store.pool()) + .await + .expect("source-state update guard SQL"); + + let mut transaction = store + .begin_write_transaction() + .await + .expect("corruption fixture transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_capacity_update_guard") + .execute(&mut *transaction) + .await + .expect("remove capacity guard"); + sqlx::query("DROP TRIGGER radroots_event_store_source_state_authority_update_guard") + .execute(&mut *transaction) + .await + .expect("remove source-state guard"); + if let Some(source_assignment) = source_assignment { + sqlx::query(sqlx::AssertSqlSafe(format!( + "UPDATE radroots_event_store_source_state SET {source_assignment} WHERE singleton = 1" + ))) + .execute(&mut *transaction) + .await + .expect("corrupt source-state count seal"); + } + sqlx::query(sqlx::AssertSqlSafe(format!( + "UPDATE radroots_event_store_source_capacity_v1 SET {capacity_assignment} WHERE singleton = 1" + ))) + .execute(&mut *transaction) + .await + .expect("corrupt persisted capacity seal"); + sqlx::raw_sql(sqlx::AssertSqlSafe(source_guard)) + .execute(&mut *transaction) + .await + .expect("restore exact source-state guard"); + sqlx::raw_sql(sqlx::AssertSqlSafe(capacity_guard)) + .execute(&mut *transaction) + .await + .expect("restore exact capacity guard"); + transaction + .commit() + .await + .expect("commit corruption fixture"); + store.pool().close().await; + drop(store); + + assert!(matches!( + RadrootsEventStore::open_file(&path).await, + Err(RadrootsEventStoreError::SourceCapacityStateDrift { .. }) + )); + } + } + + #[tokio::test] + async fn reopen_stops_at_the_first_raw_event_one_over_before_ephemeral_probe() { + let directory = tempfile::tempdir().expect("temporary directory"); + let path = directory.path().join("bounded-one-over.sqlite"); + let store = RadrootsEventStore::open_file(&path) + .await + .expect("open file store"); + let capacity_guard: String = sqlx::query_scalar( + "SELECT sql FROM main.sqlite_schema WHERE type = 'trigger' AND name = 'radroots_event_store_source_capacity_update_guard'", + ) + .fetch_one(store.pool()) + .await + .expect("capacity update guard SQL"); + let source_guard: String = sqlx::query_scalar( + "SELECT sql FROM main.sqlite_schema WHERE type = 'trigger' AND name = 'radroots_event_store_source_state_authority_update_guard'", + ) + .fetch_one(store.pool()) + .await + .expect("source-state update guard SQL"); + let mut transaction = store + .begin_write_transaction() + .await + .expect("corruption fixture transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_capacity_update_guard") + .execute(&mut *transaction) + .await + .expect("remove capacity guard"); + sqlx::query("DROP TRIGGER radroots_event_store_source_state_authority_update_guard") + .execute(&mut *transaction) + .await + .expect("remove source-state guard"); + sqlx::query( + "WITH RECURSIVE fixture(value) AS (VALUES(1) UNION ALL SELECT value + 1 FROM fixture WHERE value < 25001) INSERT INTO event_envelopes(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) SELECT value, printf('%064x', value), printf('%064x', 0), value, CASE WHEN value = 25001 THEN 20000 ELSE 1 END, '[]', '', printf('%0128x', value), '{}', 'verified', 'unsupported', NULL, CASE WHEN value = 25001 THEN 'ephemeral' ELSE 'regular' END, 0, value, value FROM fixture", + ) + .execute(&mut *transaction) + .await + .expect("install one-over raw authority with a trailing ephemeral row"); + sqlx::query( + "UPDATE radroots_event_store_source_state SET raw_event_count = 25000, raw_high_water_seq = 25001 WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect("seal source state at the accepted count"); + sqlx::query( + "UPDATE radroots_event_store_source_capacity_v1 SET raw_event_count = 25000, raw_high_water_seq = 25001 WHERE singleton = 1", + ) + .execute(&mut *transaction) + .await + .expect("seal capacity at the accepted count"); + sqlx::raw_sql(sqlx::AssertSqlSafe(source_guard)) + .execute(&mut *transaction) + .await + .expect("restore exact source-state guard"); + sqlx::raw_sql(sqlx::AssertSqlSafe(capacity_guard)) + .execute(&mut *transaction) + .await + .expect("restore exact capacity guard"); + transaction + .commit() + .await + .expect("commit corrupt one-over fixture"); + store.pool().close().await; + drop(store); + + let error = match RadrootsEventStore::open_file(&path).await { + Ok(_) => panic!("bounded reopen accepted the first row over capacity"), + Err(error) => error, + }; + assert!( + matches!( + &error, + RadrootsEventStoreError::SourceCapacityExceeded { + resource: RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + current: 25_000, + requested: 1, + limit: 25_000, + } + ), + "unexpected bounded reopen error: {error:?}" + ); + } +} diff --git a/crates/event_store/src/store.rs b/crates/event_store/src/store.rs @@ -54,12 +54,15 @@ use crate::model::{ #[cfg(test)] use crate::nip09::reconciliation_v1::ReconciliationProfile; use crate::nip09::reconciliation_v1::{active_source_generation, generation_from_blob}; -#[cfg(test)] -use crate::schema::destroy_event_store_schema_for_test; use crate::schema::{ RadrootsEventStoreSchemaStatus, inspect_event_store_schema_status, migrate_event_store_schema, rollback_event_store_schema_offline, }; +#[cfg(test)] +use crate::schema::{ + destroy_event_store_schema_for_test, + rollback_event_store_schema_offline_destructive_for_migration_test, +}; use radroots_event::event_head::v1::RadrootsEventHeadCoordinate; #[cfg(test)] use radroots_event::event_head::v1::{ @@ -190,6 +193,22 @@ impl RadrootsEventStore { Ok(generation) } + /// Returns the fast persisted raw-source capacity seal for one snapshot. + /// + /// This validates the seal against active source and generation metadata + /// without rescanning raw rows. Migration and database reopen perform the + /// full raw-source recount. + pub async fn source_capacity_v1( + &self, + ) -> Result<crate::RadrootsEventStoreSourceCapacityV1, RadrootsEventStoreError> { + let mut tx = self.pool.begin().await?; + let capacity = + crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1(&mut tx) + .await?; + tx.commit().await?; + Ok(capacity) + } + /// Begins a serialized write transaction suitable for composed event-store writes. /// /// Call this before performing any reads that will precede @@ -1168,6 +1187,7 @@ async fn configure_pool( filename: main_filename, }); } + validate_main_database_encoding(connection).await?; crate::schema::validate_event_store_temp_schema(connection).await?; } @@ -1195,6 +1215,18 @@ async fn configure_pool( Ok(()) } +async fn validate_main_database_encoding( + connection: &mut SqliteConnection, +) -> Result<(), RadrootsEventStoreError> { + let actual: String = sqlx::query_scalar("PRAGMA main.encoding") + .fetch_one(&mut *connection) + .await?; + if actual == "UTF-8" { + return Ok(()); + } + Err(RadrootsEventStoreError::SqliteMainDatabaseEncodingNotUtf8 { actual }) +} + async fn configure_file_journal_mode( connection: &mut SqliteConnection, ) -> Result<(), RadrootsEventStoreError> { @@ -1772,8 +1804,12 @@ mod tests { RadrootsTradeMutationBodyV1, RadrootsTradeMutationEnvelopeV1, canonical_jcs_value, canonical_trade_mutation_content, }; - use radroots_event::wire::{RadrootsNip01EventWire, compute_canonical_nip01_event_id}; + use radroots_event::wire::{ + DEFAULT_CONTENT_MAX_BYTES, DEFAULT_RAW_JSON_MAX_BYTES, RadrootsNip01EventWire, + compute_canonical_nip01_event_id, + }; use radroots_event_codec::food_availability::inbound::RadrootsFoodAvailabilityImageDiagnostic; + use std::sync::Arc; const FIXTURE_ALICE_SECRET_KEY_HEX: &str = "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; @@ -1803,6 +1839,17 @@ mod tests { } } + struct PanickingGeneration; + + impl crate::nip09::reconciliation_v1::SourceGenerationProvider for PanickingGeneration { + fn fill_generation( + &self, + _generation: &mut [u8; 32], + ) -> Result<(), RadrootsEventStoreError> { + panic!("generation entropy was requested after the retained-history preflight") + } + } + fn fixture_keys() -> RadrootsNostrKeys { let secret_key = RadrootsNostrSecretKey::from_hex(FIXTURE_ALICE_SECRET_KEY_HEX).expect("secret key"); @@ -2044,6 +2091,89 @@ mod tests { RadrootsSignedEvent::from_wire_verified_id(wire, raw_json).expect("signed event") } + fn raw_source_text_bytes(event: &RadrootsSignedEvent) -> (u64, u64) { + let tags = event.tags_as_vec(); + let tags_json = serde_json::to_string(&tags).expect("tags JSON"); + let event_bytes = [ + event.id_str(), + event.pubkey_str(), + tags_json.as_str(), + event.content(), + event.sig_str(), + event.raw_json(), + ] + .into_iter() + .map(str::len) + .sum::<usize>(); + let tag_bytes = tags + .iter() + .map(|tag| { + let tag_json = serde_json::to_string(tag).expect("tag JSON"); + event.id_str().len() + + tag.first().map_or(0, String::len) + + tag.get(1).map_or(0, String::len) + + tag_json.len() + }) + .sum::<usize>(); + ( + u64::try_from(event_bytes).expect("event byte count fits u64"), + u64::try_from(tag_bytes).expect("tag byte count fits u64"), + ) + } + + async fn initialize_utf16le_database(path: &Path) { + let mut connection = SqliteConnection::connect_with( + &SqliteConnectOptions::new() + .filename(path) + .create_if_missing(true), + ) + .await + .expect("UTF-16 fixture connection"); + sqlx::query("PRAGMA main.encoding = 'UTF-16le'") + .execute(&mut connection) + .await + .expect("set UTF-16LE before schema creation"); + sqlx::query("CREATE TABLE encoding_anchor (value TEXT NOT NULL)") + .execute(&mut connection) + .await + .expect("materialize UTF-16LE database"); + sqlx::query("DROP TABLE encoding_anchor") + .execute(&mut connection) + .await + .expect("return UTF-16LE database to empty catalog"); + let actual: String = sqlx::query_scalar("PRAGMA main.encoding") + .fetch_one(&mut connection) + .await + .expect("read fixture encoding"); + assert_eq!(actual, "UTF-16le"); + connection.close().await.expect("close UTF-16 fixture"); + } + + async fn assert_utf16le_database_was_not_mutated(path: &Path) { + let mut connection = + SqliteConnection::connect_with(&SqliteConnectOptions::new().filename(path)) + .await + .expect("UTF-16 verification connection"); + let encoding: String = sqlx::query_scalar("PRAGMA main.encoding") + .fetch_one(&mut connection) + .await + .expect("verification encoding"); + let journal_mode: String = sqlx::query_scalar("PRAGMA main.journal_mode") + .fetch_one(&mut connection) + .await + .expect("verification journal mode"); + let event_store_objects: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM main.sqlite_schema WHERE name = 'radroots_event_store_schema_migrations' OR name = 'event_envelopes' OR name LIKE 'radroots_event_store_%'", + ) + .fetch_one(&mut connection) + .await + .expect("verification event-store catalog"); + assert_eq!(encoding, "UTF-16le"); + assert_eq!(journal_mode, "delete"); + assert_eq!(event_store_objects, 0); + connection.close().await.expect("close UTF-16 verifier"); + } + fn synthetic_signed_event( kind: u32, created_at: u64, @@ -2236,9 +2366,9 @@ mod tests { } async fn rollback_store_to_v1(store: &RadrootsEventStore) { - rollback_event_store_schema_offline(store.pool(), 1) + rollback_event_store_schema_offline_destructive_for_migration_test(store.pool(), 1) .await - .expect("rollback to v1"); + .expect("test-only destructive rollback to v1"); assert_eq!( inspect_event_store_schema_status(store.pool()) .await @@ -2794,6 +2924,184 @@ mod tests { } #[tokio::test] + async fn current_v4_rebuild_rotates_capacity_and_food_authority_end_to_end() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let food = food_availability_event( + 210, + "v4-rebuild-carrots", + "Nantes Carrots", + "Fresh bunches", + "active", + Vec::new(), + ); + store + .ingest_event(RadrootsEventIngest::new(food.clone(), 2_100)) + .await + .expect("FoodAvailability ingest"); + let before = store + .source_capacity_v1() + .await + .expect("capacity before rebuild"); + let raw_digest = raw_authority_digest(&store).await; + let target_generation = [0x44; 32]; + + let mut transaction = store + .begin_write_transaction() + .await + .expect("rebuild transaction"); + crate::nip09::reconciliation_v1::apply_reconciliation_hook( + &mut transaction, + &FixedGeneration(target_generation), + crate::nip09::reconciliation_v1::ReconciliationCapacityLimits::production(), + ) + .await + .expect("current-v4 rebuild"); + transaction + .commit() + .await + .expect("commit current-v4 rebuild"); + + let after = store + .source_capacity_v1() + .await + .expect("capacity after rebuild"); + assert_eq!(after.source_generation().as_bytes(), &target_generation); + assert_eq!(after.raw_event_count(), before.raw_event_count()); + assert_eq!(after.raw_tag_count(), before.raw_tag_count()); + assert_eq!(after.raw_event_text_bytes(), before.raw_event_text_bytes()); + assert_eq!(after.raw_tag_text_bytes(), before.raw_tag_text_bytes()); + assert_eq!(after.raw_high_water_seq(), before.raw_high_water_seq()); + assert_eq!( + after.retained_generation_count(), + before.retained_generation_count() + 1 + ); + assert_eq!( + after.retained_generation_limit(), + before.retained_generation_limit() + ); + assert_eq!(raw_authority_digest(&store).await, raw_digest); + let marker_count: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM radroots_event_store_source_rebuild_marker") + .fetch_one(store.pool()) + .await + .expect("rebuild marker count"); + assert_eq!(marker_count, 0); + let cursor_generation: Vec<u8> = sqlx::query_scalar( + "SELECT source_generation FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("FoodAvailability cursor generation"); + assert_eq!(cursor_generation, target_generation); + let projected = store + .food_availability_v1( + &RadrootsPublicKey::parse(FIXTURE_ALICE_PUBLIC_KEY_HEX).expect("author"), + &RadrootsFoodIdentifier::parse("v4-rebuild-carrots").expect("identifier"), + ) + .await + .expect("projection lookup") + .expect("projection after rebuild"); + assert_eq!(projected.event_id().as_str(), food.id_str()); + validate_nip09_authority(&store).await; + store + .audit_food_availability_projection_v1() + .await + .expect("FoodAvailability authority after rebuild"); + } + + #[tokio::test] + async fn ninth_current_v4_rebuild_is_typed_and_preflight_atomic() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + for ordinal in 2_u8..=8 { + let mut transaction = store + .begin_write_transaction() + .await + .expect("rebuild transaction"); + crate::nip09::reconciliation_v1::apply_reconciliation_hook( + &mut transaction, + &FixedGeneration([ordinal; 32]), + crate::nip09::reconciliation_v1::ReconciliationCapacityLimits::production(), + ) + .await + .expect("rebuild through retained generation eight"); + transaction.commit().await.expect("commit rebuild"); + } + + let capacity_before = store + .source_capacity_v1() + .await + .expect("capacity at generation limit"); + assert_eq!(capacity_before.retained_generation_count(), 8); + let source_before = source_authority_snapshot(&store).await; + let raw_before = raw_authority_digest(&store).await; + let nip09_before = normalized_nip09_snapshot(&store).await; + let food_before: (Vec<u8>, i64, i64) = sqlx::query_as( + "SELECT source_generation, last_transition_seq, projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("FoodAvailability cursor at generation limit"); + let derived_before: (i64, i64, i64) = sqlx::query_as( + "SELECT (SELECT COUNT(*) FROM radroots_event_store_source_generation), (SELECT COUNT(*) FROM radroots_event_store_addressable_head_transition), (SELECT COUNT(*) FROM radroots_event_store_addressable_feed_integrity_v1)", + ) + .fetch_one(store.pool()) + .await + .expect("derived authority counts at generation limit"); + assert_eq!(derived_before.0, 8); + + let mut transaction = store + .begin_write_transaction() + .await + .expect("ninth rebuild transaction"); + let error = crate::nip09::reconciliation_v1::apply_reconciliation_hook( + &mut transaction, + &PanickingGeneration, + crate::nip09::reconciliation_v1::ReconciliationCapacityLimits::production(), + ) + .await + .expect_err("ninth rebuild must fail before entropy or mutation"); + assert!(matches!( + error, + RadrootsEventStoreError::SourceGenerationHistoryLimitReached { + current: 8, + limit: 8, + } + )); + transaction + .commit() + .await + .expect("commit mutation-free rejected rebuild transaction"); + + assert_eq!( + store + .source_capacity_v1() + .await + .expect("capacity after rejected rebuild"), + capacity_before + ); + assert_eq!(source_authority_snapshot(&store).await, source_before); + assert_eq!(raw_authority_digest(&store).await, raw_before); + assert_eq!(normalized_nip09_snapshot(&store).await, nip09_before); + let food_after: (Vec<u8>, i64, i64) = sqlx::query_as( + "SELECT source_generation, last_transition_seq, projected_row_count FROM radroots_event_store_food_availability_cursor WHERE singleton = 1", + ) + .fetch_one(store.pool()) + .await + .expect("FoodAvailability cursor after rejected rebuild"); + assert_eq!(food_after, food_before); + let derived_after: (i64, i64, i64, i64) = sqlx::query_as( + "SELECT (SELECT COUNT(*) FROM radroots_event_store_source_generation), (SELECT COUNT(*) FROM radroots_event_store_addressable_head_transition), (SELECT COUNT(*) FROM radroots_event_store_addressable_feed_integrity_v1), (SELECT COUNT(*) FROM radroots_event_store_source_rebuild_marker)", + ) + .fetch_one(store.pool()) + .await + .expect("derived authority counts after rejected rebuild"); + assert_eq!( + derived_after, + (derived_before.0, derived_before.1, derived_before.2, 0) + ); + } + + #[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( @@ -3001,7 +3309,7 @@ mod tests { } ); assert_eq!( - crate::RadrootsEventStoreReconciliationResource::RawTagBytes.as_str(), + crate::RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes.as_str(), "total retained raw-source tag row text bytes" ); let store = RadrootsEventStore::open_memory().await.expect("open"); @@ -3039,51 +3347,59 @@ mod tests { let below_limit_cases = [ ( - crate::RadrootsEventStoreReconciliationResource::RawEvents, + crate::RadrootsEventStoreSourceCapacityResourceV1::RawEvents, crate::nip09::reconciliation_v1::ReconciliationCapacityLimits { raw_events: 0, ..exact_limits }, + 0, 1, 0, ), ( - crate::RadrootsEventStoreReconciliationResource::RawTags, + crate::RadrootsEventStoreSourceCapacityResourceV1::RawTags, crate::nip09::reconciliation_v1::ReconciliationCapacityLimits { raw_tags: 0, ..exact_limits }, + 0, 1, 0, ), ( - crate::RadrootsEventStoreReconciliationResource::RawEventBytes, + crate::RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, crate::nip09::reconciliation_v1::ReconciliationCapacityLimits { raw_event_bytes: exact_limits.raw_event_bytes - 1, ..exact_limits }, + 0, exact_limits.raw_event_bytes, exact_limits.raw_event_bytes - 1, ), ( - crate::RadrootsEventStoreReconciliationResource::RawTagBytes, + crate::RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes, crate::nip09::reconciliation_v1::ReconciliationCapacityLimits { raw_tag_bytes: exact_limits.raw_tag_bytes - 1, ..exact_limits }, + 0, exact_limits.raw_tag_bytes, exact_limits.raw_tag_bytes - 1, ), ]; - for (resource, limits, expected_actual, expected_limit) in below_limit_cases { + for (resource, limits, expected_current, expected_requested, expected_limit) in + below_limit_cases + { assert!(matches!( migrate_store_with_generation_and_limits(&store, [0x67; 32], limits).await, - Err(RadrootsEventStoreError::ReconciliationCapacityExceeded { + Err(RadrootsEventStoreError::SourceCapacityExceeded { resource: actual_resource, - actual, + current, + requested, limit, }) if actual_resource == resource - && actual == expected_actual + && current == expected_current + && requested == expected_requested && limit == expected_limit )); assert_eq!( @@ -3124,6 +3440,14 @@ mod tests { .execute(store.pool()) .await .expect("oversized legacy pubkey"); + let oversized_raw_event_bytes: i64 = sqlx::query_scalar( + "SELECT COALESCE(SUM(length(CAST(event_id AS BLOB)) + length(CAST(pubkey AS BLOB)) + length(CAST(tags_json AS BLOB)) + length(CAST(content AS BLOB)) + length(CAST(sig AS BLOB)) + length(CAST(raw_json AS BLOB))), 0) FROM event_envelopes", + ) + .fetch_one(store.pool()) + .await + .expect("oversized raw event bytes"); + let oversized_raw_event_bytes = + u64::try_from(oversized_raw_event_bytes).expect("oversized raw event bytes"); let limits = crate::nip09::reconciliation_v1::ReconciliationCapacityLimits { raw_event_bytes: u64::try_from(prior_raw_event_bytes).expect("prior raw event bytes"), ..crate::nip09::reconciliation_v1::ReconciliationCapacityLimits::production() @@ -3140,11 +3464,15 @@ mod tests { ); assert!(matches!( error, - RadrootsEventStoreError::ReconciliationCapacityExceeded { - resource: crate::RadrootsEventStoreReconciliationResource::RawEventBytes, - actual, + RadrootsEventStoreError::SourceCapacityExceeded { + resource: crate::RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, + current, + requested, limit, - } if actual > limit && limit == limits.raw_event_bytes + } if current == 0 + && requested == oversized_raw_event_bytes + && requested > limit + && limit == limits.raw_event_bytes )); assert_eq!( inspect_event_store_schema_status(store.pool()) @@ -3955,6 +4283,47 @@ mod tests { } #[tokio::test] + async fn open_file_rejects_utf16_main_database_before_schema_or_journal_mutation() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("open-file-utf16.sqlite"); + initialize_utf16le_database(&path).await; + + let error = match RadrootsEventStore::open_file(&path).await { + Ok(_) => panic!("UTF-16 main database must be rejected"), + Err(error) => error, + }; + assert!(matches!( + error, + RadrootsEventStoreError::SqliteMainDatabaseEncodingNotUtf8 { actual } + if actual == "UTF-16le" + )); + assert_utf16le_database_was_not_mutated(&path).await; + } + + #[tokio::test] + async fn open_pool_rejects_utf16_main_database_before_schema_or_journal_mutation() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("open-pool-utf16.sqlite"); + initialize_utf16le_database(&path).await; + let pool = SqlitePoolOptions::new() + .max_connections(2) + .connect_with(SqliteConnectOptions::new().filename(&path)) + .await + .expect("UTF-16 pool"); + + let error = match RadrootsEventStore::open_pool(pool, true).await { + Ok(_) => panic!("UTF-16 supplied pool must be rejected"), + Err(error) => error, + }; + assert!(matches!( + error, + RadrootsEventStoreError::SqliteMainDatabaseEncodingNotUtf8 { actual } + if actual == "UTF-16le" + )); + assert_utf16le_database_was_not_mutated(&path).await; + } + + #[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") @@ -4431,7 +4800,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", let clone = store.clone(); store - .rollback_to_schema_version_and_close(1) + .rollback_to_schema_version_and_close(3) .await .expect("terminal rollback"); @@ -4443,6 +4812,68 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", } #[tokio::test] + async fn utf8_file_reopen_preserves_non_ascii_and_nul_capacity_accounting() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("utf8-capacity-reopen.sqlite"); + let store = RadrootsEventStore::open_file(&path) + .await + .expect("UTF-8 file store"); + let event = signed_event( + KIND_POST, + 10, + vec![ + vec!["t".to_owned(), "Victoria vegetables 野菜\0".to_owned()], + vec!["location".to_owned(), "Victoria, B.C., Canada".to_owned()], + ], + "Café-grown carrots 🥕 in Victoria\0", + ); + let event_id = event.id_str().to_owned(); + let expected_tag_count = + u64::try_from(event.tags_as_vec().len()).expect("tag count fits u64"); + let (expected_event_bytes, expected_tag_bytes) = raw_source_text_bytes(&event); + + store + .ingest_event(RadrootsEventIngest::new(event, 1_000)) + .await + .expect("non-ASCII and NUL ingest"); + let before_reopen = store + .source_capacity_v1() + .await + .expect("capacity before reopen"); + assert_eq!(before_reopen.raw_event_count(), 1); + assert_eq!(before_reopen.raw_tag_count(), expected_tag_count); + assert_eq!(before_reopen.raw_event_text_bytes(), expected_event_bytes); + assert_eq!(before_reopen.raw_tag_text_bytes(), expected_tag_bytes); + store.pool().close().await; + + let reopened = RadrootsEventStore::open_file(&path) + .await + .expect("reopen UTF-8 file store"); + assert_eq!( + reopened + .source_capacity_v1() + .await + .expect("capacity after reopen"), + before_reopen + ); + let stored = reopened + .raw_event(&event_id) + .await + .expect("raw event after reopen") + .expect("stored raw event after reopen"); + assert_eq!(stored.content, "Café-grown carrots 🥕 in Victoria\0"); + let tags = reopened + .tags_for_event(&event_id) + .await + .expect("stored tags after reopen"); + assert_eq!(tags.len(), 2); + assert_eq!( + tags[0].tag_value.as_deref(), + Some("Victoria vegetables 野菜\0") + ); + } + + #[tokio::test] async fn ingest_retains_raw_event_and_ignores_duplicate_rows() { let store = RadrootsEventStore::open_memory().await.expect("open"); let event = signed_event( @@ -4457,7 +4888,15 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", .ingest_event(ingest.clone()) .await .expect("first ingest"); + let capacity_after_first = store + .source_capacity_v1() + .await + .expect("capacity after first ingest"); let second = store.ingest_event(ingest).await.expect("second ingest"); + let capacity_after_duplicate = store + .source_capacity_v1() + .await + .expect("capacity after duplicate ingest"); let stored = store .raw_event(event.id_str()) .await @@ -4466,6 +4905,7 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", assert!(first.persistence.is_inserted()); assert!(second.persistence.is_duplicate()); + assert_eq!(capacity_after_duplicate, capacity_after_first); assert_eq!(first.persistence.sequence(), second.persistence.sequence()); assert_eq!(first.persistence.sequence(), Some(stored.seq)); assert_eq!( @@ -4495,6 +4935,364 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", } #[tokio::test] + async fn independent_file_pools_serialize_the_last_raw_event_byte_capacity_slot() { + let tempdir = tempfile::tempdir().expect("tempdir"); + let path = tempdir.path().join("capacity-last-slot-race.sqlite"); + let contender_a = signed_event(KIND_POST, 101, Vec::new(), "race-a"); + let contender_b = signed_event(KIND_POST, 102, Vec::new(), "race-b"); + let contender_bytes = raw_source_text_bytes(&contender_a).0; + assert_eq!(raw_source_text_bytes(&contender_b).0, contender_bytes); + + const FILLER_CREATED_AT_BASE: u32 = 1_000_000; + let filler_base = signed_event(KIND_POST, FILLER_CREATED_AT_BASE, Vec::new(), ""); + let filler_base_bytes = raw_source_text_bytes(&filler_base).0; + let filler_target = crate::RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1 + .checked_sub(contender_bytes) + .expect("one contender fits the production byte limit"); + let full_content_len = DEFAULT_CONTENT_MAX_BYTES - 4_096; + let full_content = "v".repeat(full_content_len); + let full_filler = signed_event( + KIND_POST, + FILLER_CREATED_AT_BASE, + Vec::new(), + full_content.as_str(), + ); + assert!(full_filler.raw_json().len() <= DEFAULT_RAW_JSON_MAX_BYTES); + let full_filler_bytes = raw_source_text_bytes(&full_filler).0; + let content_shape_for_target = |target: u64| { + let adjustment = target.checked_sub(filler_base_bytes)?; + let (ascii_len, append_nul) = if adjustment % 2 == 0 { + (adjustment / 2, false) + } else { + (adjustment.checked_sub(7)? / 2, true) + }; + (ascii_len <= u64::try_from(full_content_len).ok()?) + .then_some((usize::try_from(ascii_len).ok()?, append_nul)) + }; + + let mut full_filler_count = filler_target / full_filler_bytes; + let mut tail_total = filler_target % full_filler_bytes; + let tail_targets = if tail_total == 0 { + Vec::new() + } else if content_shape_for_target(tail_total).is_some() { + vec![tail_total] + } else { + full_filler_count = full_filler_count + .checked_sub(1) + .expect("filler target admits a two-event exact tail"); + tail_total += full_filler_bytes; + let lower = filler_base_bytes.max(tail_total - full_filler_bytes); + let upper = full_filler_bytes.min(tail_total - filler_base_bytes); + let first = (lower..=upper) + .take(16) + .find(|candidate| { + content_shape_for_target(*candidate).is_some() + && content_shape_for_target(tail_total - *candidate).is_some() + }) + .expect("two bounded filler events can represent the exact tail"); + vec![first, tail_total - first] + }; + + let filler_store = RadrootsEventStore::open_file(&path) + .await + .expect("filler store"); + let mut filler_transaction = filler_store + .begin_write_transaction() + .await + .expect("filler transaction"); + let mut filler_count = 0_u64; + for index in 0..full_filler_count { + let created_at = FILLER_CREATED_AT_BASE + + u32::try_from(index).expect("bounded full filler index fits u32"); + let filler = signed_event(KIND_POST, created_at, Vec::new(), full_content.as_str()); + assert_eq!(raw_source_text_bytes(&filler).0, full_filler_bytes); + filler_store + .ingest_event_in_transaction( + &mut filler_transaction, + RadrootsEventIngest::new(filler, 1_000 + i64::from(created_at)), + ) + .await + .expect("coherent full filler ingest"); + filler_count += 1; + } + for target in tail_targets { + let (ascii_len, append_nul) = + content_shape_for_target(target).expect("validated exact tail shape"); + let mut content = "v".repeat(ascii_len); + if append_nul { + content.push('\0'); + } + let created_at = FILLER_CREATED_AT_BASE + + u32::try_from(filler_count).expect("bounded tail filler index fits u32"); + let filler = signed_event(KIND_POST, created_at, Vec::new(), content.as_str()); + assert!(filler.raw_json().len() <= DEFAULT_RAW_JSON_MAX_BYTES); + assert_eq!(raw_source_text_bytes(&filler).0, target); + filler_store + .ingest_event_in_transaction( + &mut filler_transaction, + RadrootsEventIngest::new(filler, 1_000 + i64::from(created_at)), + ) + .await + .expect("coherent exact-tail filler ingest"); + filler_count += 1; + } + filler_transaction + .commit() + .await + .expect("commit coherent filler source"); + let before_race = filler_store + .source_capacity_v1() + .await + .expect("capacity before last-slot race"); + assert_eq!(before_race.raw_event_count(), filler_count); + assert_eq!(before_race.raw_event_text_bytes(), filler_target); + filler_store.pool().close().await; + + let contender_a_id = contender_a.id_str().to_owned(); + let contender_b_id = contender_b.id_str().to_owned(); + let store_a = RadrootsEventStore::open_file(&path) + .await + .expect("first independent store"); + let store_b = RadrootsEventStore::open_file(&path) + .await + .expect("second independent store"); + let barrier = Arc::new(tokio::sync::Barrier::new(3)); + let barrier_a = barrier.clone(); + let first = tokio::spawn(async move { + barrier_a.wait().await; + let result = store_a + .ingest_event(RadrootsEventIngest::new(contender_a, 1_100)) + .await; + (store_a, result) + }); + let barrier_b = barrier.clone(); + let second = tokio::spawn(async move { + barrier_b.wait().await; + let result = store_b + .ingest_event(RadrootsEventIngest::new(contender_b, 1_200)) + .await; + (store_b, result) + }); + barrier.wait().await; + let (first, second) = tokio::join!(first, second); + let (store_a, result_a) = first.expect("first contender task"); + let (store_b, result_b) = second.expect("second contender task"); + let (accepted, rejected) = match (result_a, result_b) { + (Ok(accepted), Err(rejected)) | (Err(rejected), Ok(accepted)) => (accepted, rejected), + _ => panic!("exactly one contender must consume the last capacity slot"), + }; + assert!(accepted.persistence.is_inserted()); + assert!(matches!( + rejected, + RadrootsEventStoreError::SourceCapacityExceeded { + resource: crate::RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes, + current: crate::RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1, + requested, + limit: crate::RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1, + } if requested == contender_bytes + )); + store_a.pool().close().await; + store_b.pool().close().await; + + let reopened = RadrootsEventStore::open_file(&path) + .await + .expect("clean full reopen after last-slot race"); + let after_race = reopened + .source_capacity_v1() + .await + .expect("capacity after last-slot race"); + assert_eq!(after_race.raw_event_count(), filler_count + 1); + assert_eq!( + after_race.raw_event_text_bytes(), + crate::RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1 + ); + let retained_contenders: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM event_envelopes WHERE event_id = ? OR event_id = ?", + ) + .bind(contender_a_id) + .bind(contender_b_id) + .fetch_one(reopened.pool()) + .await + .expect("retained contender count"); + assert_eq!(retained_contenders, 1); + } + + #[tokio::test] + async fn exact_capacity_boundary_allows_duplicate_observation_and_ephemeral_noop() { + let store = RadrootsEventStore::open_memory().await.expect("open"); + let retained = signed_event(KIND_POST, 20, Vec::new(), "retained at boundary"); + store + .ingest_event(RadrootsEventIngest::new(retained.clone(), 1_000)) + .await + .expect("initial durable ingest"); + + let source_guard: String = sqlx::query_scalar( + "SELECT sql FROM main.sqlite_schema WHERE type = 'trigger' AND name = 'radroots_event_store_source_state_authority_update_guard'", + ) + .fetch_one(store.pool()) + .await + .expect("source-state update guard SQL"); + let capacity_guard: String = sqlx::query_scalar( + "SELECT sql FROM main.sqlite_schema WHERE type = 'trigger' AND name = 'radroots_event_store_source_capacity_update_guard'", + ) + .fetch_one(store.pool()) + .await + .expect("capacity update guard SQL"); + let mut transaction = store + .begin_write_transaction() + .await + .expect("boundary fixture transaction"); + sqlx::query("DROP TRIGGER radroots_event_store_source_state_authority_update_guard") + .execute(&mut *transaction) + .await + .expect("remove source-state guard in rolled-back fixture"); + sqlx::query("DROP TRIGGER radroots_event_store_source_capacity_update_guard") + .execute(&mut *transaction) + .await + .expect("remove capacity guard in rolled-back fixture"); + sqlx::query( + "UPDATE radroots_event_store_source_state SET raw_event_count = ? WHERE singleton = 1", + ) + .bind( + i64::try_from(crate::RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1) + .expect("production event-count limit fits SQLite"), + ) + .execute(&mut *transaction) + .await + .expect("place source state at event-count boundary"); + sqlx::query( + "UPDATE radroots_event_store_source_capacity_v1 SET raw_event_count = ? WHERE singleton = 1", + ) + .bind( + i64::try_from(crate::RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1) + .expect("production event-count limit fits SQLite"), + ) + .execute(&mut *transaction) + .await + .expect("place capacity seal at event-count boundary"); + sqlx::raw_sql(sqlx::AssertSqlSafe(source_guard)) + .execute(&mut *transaction) + .await + .expect("restore exact source-state guard"); + sqlx::raw_sql(sqlx::AssertSqlSafe(capacity_guard)) + .execute(&mut *transaction) + .await + .expect("restore exact capacity guard"); + + let at_limit = crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1( + &mut transaction, + ) + .await + .expect("fast capacity seal at exact limit"); + assert_eq!( + at_limit.raw_event_count(), + crate::RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1 + ); + let duplicate_observation = RadrootsTransportObservation::new( + RadrootsTransportKind::Nostr, + "wss://capacity-boundary.example.test", + RadrootsTransportObservationType::Subscription, + 1_100, + ) + .expect("duplicate observation"); + let duplicate = store + .ingest_event_in_transaction( + &mut transaction, + RadrootsEventIngest::new(retained.clone(), 1_100) + .with_observation(duplicate_observation), + ) + .await + .expect("duplicate does not charge capacity at the exact boundary"); + assert!(duplicate.persistence.is_duplicate()); + let observation_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM event_transport_observation WHERE event_id = ?", + ) + .bind(retained.id_str()) + .fetch_one(&mut *transaction) + .await + .expect("duplicate observation count"); + assert_eq!(observation_count, 1); + assert_eq!( + crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1( + &mut transaction, + ) + .await + .expect("capacity after duplicate"), + at_limit + ); + + let unique = signed_event(KIND_POST, 21, Vec::new(), "one event over boundary"); + assert!(matches!( + store + .ingest_event_in_transaction( + &mut transaction, + RadrootsEventIngest::new(unique.clone(), 1_200), + ) + .await, + Err(RadrootsEventStoreError::SourceCapacityExceeded { + resource: crate::RadrootsEventStoreSourceCapacityResourceV1::RawEvents, + current: crate::RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1, + requested: 1, + limit: crate::RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1, + }) + )); + let ephemeral = signed_event(KIND_GEOCHAT, 22, Vec::new(), "live-only boundary event"); + let ephemeral_observation = RadrootsTransportObservation::new( + RadrootsTransportKind::Nostr, + "wss://ephemeral-boundary.example.test", + RadrootsTransportObservationType::Subscription, + 1_300, + ) + .expect("ephemeral observation"); + let ephemeral_receipt = store + .ingest_event_in_transaction( + &mut transaction, + RadrootsEventIngest::new(ephemeral.clone(), 1_300) + .with_observation(ephemeral_observation), + ) + .await + .expect("ephemeral event is not charged at the exact boundary"); + assert_eq!( + ephemeral_receipt.persistence, + RadrootsEventPersistence::NotPersisted + ); + let ephemeral_observation_count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM event_transport_observation WHERE event_id = ?", + ) + .bind(ephemeral.id_str()) + .fetch_one(&mut *transaction) + .await + .expect("ephemeral observation count"); + assert_eq!(ephemeral_observation_count, 0); + assert_eq!( + crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1( + &mut transaction, + ) + .await + .expect("capacity after ephemeral event"), + at_limit + ); + transaction + .rollback() + .await + .expect("roll back exact-bound fixture"); + assert!( + store + .raw_event(unique.id_str()) + .await + .expect("unique raw event after rollback") + .is_none() + ); + assert!( + store + .raw_event(ephemeral.id_str()) + .await + .expect("ephemeral raw event after rollback") + .is_none() + ); + } + + #[tokio::test] async fn duplicate_preserves_immutable_classification_and_first_raw_bytes() { let store = RadrootsEventStore::open_memory().await.expect("open"); let first_event = signed_event( @@ -4531,6 +5329,10 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", .fetch_one(store.pool()) .await .expect("before"); + let capacity_before_duplicate = store + .source_capacity_v1() + .await + .expect("capacity before alternate-encoding duplicate"); let receipt = store .ingest_event(RadrootsEventIngest::new(second_event, 1_200)) @@ -4543,8 +5345,13 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", .fetch_one(store.pool()) .await .expect("after"); + let capacity_after_duplicate = store + .source_capacity_v1() + .await + .expect("capacity after alternate-encoding duplicate"); assert!(receipt.persistence.is_duplicate()); + assert_eq!(capacity_after_duplicate, capacity_before_duplicate); assert_eq!( receipt.admission_status, RadrootsEventAdmissionStatus::Admitted @@ -4779,6 +5586,10 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", ) .await .expect("prior caller work"); + let capacity_after_prior = + crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1(&mut tx) + .await + .expect("capacity after prior caller work"); let error = store .ingest_event_in_transaction( &mut tx, @@ -4791,6 +5602,11 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", RadrootsEventStoreError::MigrationHookStateDrift { ref reason, .. } if reason.contains("post-core extensions changed protocol-owned authority") )); + let capacity_after_rollback = + crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1(&mut tx) + .await + .expect("capacity after failed nested ingest"); + assert_eq!(capacity_after_rollback, capacity_after_prior); tx.commit() .await .expect("caller may commit prior work after failed ingest"); @@ -4855,6 +5671,13 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", .expect("transition count"); assert_eq!(trade_mutation_count, 0); assert_eq!(transition_count, 0); + assert_eq!( + store + .source_capacity_v1() + .await + .expect("committed capacity after rollback"), + capacity_after_prior + ); } #[tokio::test] @@ -5306,6 +6129,10 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", async fn unsupported_verified_events_are_stored_but_not_projected() { let store = RadrootsEventStore::open_memory().await.expect("open"); let event = signed_event(999, 11, Vec::new(), "unsupported"); + let capacity_before = store + .source_capacity_v1() + .await + .expect("capacity before unsupported ingest"); let receipt = store .ingest_event(RadrootsEventIngest::new(event.clone(), 2_000)) .await @@ -5315,6 +6142,10 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", .await .expect("get") .expect("stored"); + let capacity_after_insert = store + .source_capacity_v1() + .await + .expect("capacity after unsupported ingest"); assert_eq!( receipt.admission_status, @@ -5326,6 +6157,21 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", RadrootsEventAdmissionStatus::Unsupported ); assert!(!stored.valid_stream_eligible); + assert_eq!( + capacity_after_insert.raw_event_count(), + capacity_before.raw_event_count() + 1 + ); + assert_eq!( + capacity_after_insert.raw_tag_count(), + capacity_before.raw_tag_count() + ); + assert!( + capacity_after_insert.raw_event_text_bytes() > capacity_before.raw_event_text_bytes() + ); + assert_eq!( + capacity_after_insert.raw_tag_text_bytes(), + capacity_before.raw_tag_text_bytes() + ); assert!( store .valid_event(event.id_str()) @@ -5338,7 +6184,12 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", .ingest_event(RadrootsEventIngest::new(event, 2_100)) .await .expect("duplicate"); + let capacity_after_duplicate = store + .source_capacity_v1() + .await + .expect("capacity after unsupported duplicate"); assert!(duplicate.persistence.is_duplicate()); + assert_eq!(capacity_after_duplicate, capacity_after_insert); assert_eq!( duplicate.raw_head_decision, RadrootsRawHeadDecision::NotHeadSelected @@ -5445,6 +6296,10 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", #[tokio::test] async fn ephemeral_admission_outcomes_are_never_persisted() { let store = RadrootsEventStore::open_memory().await.expect("open"); + let capacity_before = store + .source_capacity_v1() + .await + .expect("capacity before ephemeral ingests"); let admitted = signed_event(KIND_GEOCHAT, 15, Vec::new(), "hello"); let unsupported = signed_event(29_999, 16, Vec::new(), "unsupported"); let invalid = signed_event(KIND_RELAY_AUTH, 17, Vec::new(), "not-json"); @@ -5543,6 +6398,13 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", assert_eq!(status.valid_stream_events, 0); assert_eq!(status.transport_observations, 0); assert_eq!( + store + .source_capacity_v1() + .await + .expect("capacity after ephemeral ingests"), + capacity_before + ); + assert_eq!( admitted_receipt.raw_head_decision, RadrootsRawHeadDecision::NotPersisted ); @@ -5621,6 +6483,10 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", #[tokio::test] async fn ambiguous_classified_listing_shape_is_invalid_but_still_updates_the_raw_head() { let store = RadrootsEventStore::open_memory().await.expect("open"); + let capacity_before = store + .source_capacity_v1() + .await + .expect("capacity before invalid durable ingest"); let event = signed_event( KIND_CLASSIFIED_LISTING, 17, @@ -5637,6 +6503,10 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", .ingest_event(RadrootsEventIngest::new(event.clone(), 2_275)) .await .expect("ingest"); + let capacity_after = store + .source_capacity_v1() + .await + .expect("capacity after invalid durable ingest"); assert_eq!( receipt.admission_status, @@ -5648,6 +6518,16 @@ CREATE TABLE aux.event_transport_observation (event_id TEXT);", ); assert!(!receipt.valid_stream_eligible); assert_eq!(receipt.raw_head_decision, RadrootsRawHeadDecision::Applied); + assert_eq!( + capacity_after.raw_event_count(), + capacity_before.raw_event_count() + 1 + ); + assert_eq!( + capacity_after.raw_tag_count(), + capacity_before.raw_tag_count() + 3 + ); + assert!(capacity_after.raw_event_text_bytes() > capacity_before.raw_event_text_bytes()); + assert!(capacity_after.raw_tag_text_bytes() > capacity_before.raw_tag_text_bytes()); assert!( store .raw_event(event.id_str()) diff --git a/crates/event_store/src/store/protocol_reconciliation_v1.rs b/crates/event_store/src/store/protocol_reconciliation_v1.rs @@ -9,6 +9,10 @@ use crate::nip09::reconciliation_v1::{ EventAdmission, ReconciliationProfile, generation_from_blob, persist_event_coordinate_after_insert, synchronize_after_insert, validate_source_raw_authority, }; +use crate::source_maintenance_v1::{ + advance_source_capacity_after_insert_v1, preflight_unique_raw_source_append_v1, + raw_source_capacity_delta_v1, validate_source_capacity_authority_fast_v1, +}; use radroots_event::contract::registry_v7::RadrootsEventContract; use radroots_event::envelope::{RadrootsEventEnvelope, RadrootsEventKindClass}; use radroots_event::event_head::v1::{ @@ -54,8 +58,12 @@ struct ProtocolPostExtensionAuthoritySeal { baseline_raw_high_water_seq: i64, raw_event_count: i64, raw_tag_count: i64, + raw_event_bytes: u64, + raw_tag_bytes: u64, raw_high_water_seq: i64, last_transition_seq: i64, + retained_generation_count: u32, + retained_generation_limit: u32, actual_raw_high_water_seq: i64, global_transition_min_seq: Option<i64>, global_transition_max_seq: Option<i64>, @@ -71,6 +79,7 @@ pub(super) async fn ingest_event_protocol_reconciliation_v1( ) -> Result<ProtocolReconciliationV1IngestResult, RadrootsEventStoreError> { acquire_event_store_write_lock(tx).await?; let profile = validate_source_raw_authority(tx).await?; + validate_source_capacity_authority_fast_v1(tx).await?; let event = ingest.event(); let admission = EventAdmission::for_profile(profile, ingest.verified_event())?; let kind_class = event.kind_class(); @@ -95,6 +104,18 @@ pub(super) async fn ingest_event_protocol_reconciliation_v1( let tags = event.tags_as_vec(); let tags_json = serde_json::to_string(&tags)?; let event_id = event.id_str().to_owned(); + let existing_raw_event: i64 = + sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM event_envelopes WHERE event_id = ?)") + .bind(event_id.as_str()) + .fetch_one(&mut **tx) + .await?; + let capacity_delta = if existing_raw_event == 0 { + let delta = raw_source_capacity_delta_v1(ingest, tags_json.as_str())?; + preflight_unique_raw_source_append_v1(tx, delta).await?; + Some(delta) + } else { + None + }; let insert = insert_raw_event( tx, ingest, @@ -113,6 +134,12 @@ pub(super) async fn ingest_event_protocol_reconciliation_v1( .await? .decision; if inserted { + let capacity_delta = + capacity_delta.ok_or_else(|| RadrootsEventStoreError::SourceCapacityStateDrift { + reason: format!( + "unique raw event `{event_id}` was inserted after duplicate preflight" + ), + })?; synchronize_after_insert( tx, ingest, @@ -123,6 +150,7 @@ pub(super) async fn ingest_event_protocol_reconciliation_v1( &raw_head_decision, ) .await?; + advance_source_capacity_after_insert_v1(tx, capacity_delta, insert.seq).await?; } let post_extension_authority_seal = read_protocol_post_extension_authority_seal(tx).await?; @@ -149,6 +177,7 @@ pub(super) async fn ingest_event_protocol_reconciliation_v1( async fn read_protocol_post_extension_authority_seal( tx: &mut Transaction<'_, Sqlite>, ) -> Result<ProtocolPostExtensionAuthoritySeal, RadrootsEventStoreError> { + let source_capacity = validate_source_capacity_authority_fast_v1(tx).await?; let actual_raw_high_water_seq: i64 = sqlx::query_scalar("SELECT seq FROM event_envelopes ORDER BY seq DESC LIMIT 1") .fetch_optional(&mut **tx) @@ -271,8 +300,12 @@ async fn read_protocol_post_extension_authority_seal( baseline_raw_high_water_seq: source_row.try_get("baseline_raw_high_water_seq")?, raw_event_count, raw_tag_count, + raw_event_bytes: source_capacity.raw_event_text_bytes(), + raw_tag_bytes: source_capacity.raw_tag_text_bytes(), raw_high_water_seq, last_transition_seq, + retained_generation_count: source_capacity.retained_generation_count(), + retained_generation_limit: source_capacity.retained_generation_limit(), actual_raw_high_water_seq, global_transition_min_seq, global_transition_max_seq, @@ -315,8 +348,12 @@ fn protocol_post_extension_authority_matches( baseline_raw_high_water_seq: expected_baseline_raw_high_water_seq, raw_event_count: expected_raw_event_count, raw_tag_count: expected_raw_tag_count, + raw_event_bytes: expected_raw_event_bytes, + raw_tag_bytes: expected_raw_tag_bytes, raw_high_water_seq: expected_raw_high_water_seq, last_transition_seq: expected_last_transition_seq, + retained_generation_count: expected_retained_generation_count, + retained_generation_limit: expected_retained_generation_limit, actual_raw_high_water_seq: expected_actual_raw_high_water_seq, global_transition_min_seq: expected_global_transition_min_seq, global_transition_max_seq: expected_global_transition_max_seq, @@ -339,8 +376,12 @@ fn protocol_post_extension_authority_matches( baseline_raw_high_water_seq: actual_baseline_raw_high_water_seq, raw_event_count: actual_state_raw_event_count, raw_tag_count: actual_state_raw_tag_count, + raw_event_bytes: actual_raw_event_bytes, + raw_tag_bytes: actual_raw_tag_bytes, raw_high_water_seq: actual_state_raw_high_water_seq, last_transition_seq: actual_last_transition_seq, + retained_generation_count: actual_retained_generation_count, + retained_generation_limit: actual_retained_generation_limit, actual_raw_high_water_seq: actual_observed_raw_high_water_seq, global_transition_min_seq: actual_global_transition_min_seq, global_transition_max_seq: actual_global_transition_max_seq, @@ -363,8 +404,12 @@ fn protocol_post_extension_authority_matches( && expected_baseline_raw_high_water_seq == actual_baseline_raw_high_water_seq && expected_raw_event_count == actual_state_raw_event_count && expected_raw_tag_count == actual_state_raw_tag_count + && expected_raw_event_bytes == actual_raw_event_bytes + && expected_raw_tag_bytes == actual_raw_tag_bytes && expected_raw_high_water_seq == actual_state_raw_high_water_seq && expected_last_transition_seq == actual_last_transition_seq + && expected_retained_generation_count == actual_retained_generation_count + && expected_retained_generation_limit == actual_retained_generation_limit && expected_actual_raw_high_water_seq == actual_observed_raw_high_water_seq && expected_global_transition_min_seq == actual_global_transition_min_seq && expected_global_transition_max_seq == actual_global_transition_max_seq diff --git a/crates/event_store/tests/fixtures/source_maintenance.v1.json b/crates/event_store/tests/fixtures/source_maintenance.v1.json @@ -0,0 +1,408 @@ +{ + "schema_version": 1, + "contract_id": "radroots_event_store.source_maintenance_v1", + "capacity_version": 1, + "limits": { + "raw_events": 25000, + "raw_tags": 250000, + "raw_event_text_bytes": 67108864, + "raw_tag_text_bytes": 33554432, + "retained_source_generations": 8 + }, + "accounting": { + "algorithm": "sqlite_cast_blob_octet_sum_v1", + "raw_event_columns": [ + "event_id", + "pubkey", + "tags_json", + "content", + "sig", + "raw_json" + ], + "raw_tag_columns": [ + "event_id", + "tag_name", + "tag_value", + "tag_json" + ], + "nullable_raw_tag_columns": [ + "tag_value" + ] + }, + "cases": [ + { + "id": "fresh_store_zero_authority", + "execution": "direct_executor", + "authority": "source_maintenance_v1_result_vector", + "authority_path": "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "durable_unique_append_updates_all_dimensions", + "execution": "direct_executor", + "authority": "source_maintenance_v1_result_vector", + "authority_path": "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "duplicate_at_exact_boundary_is_idempotent", + "execution": "delegated_rust_test", + "authority": "exact_capacity_boundary_allows_duplicate_observation_and_ephemeral_noop", + "authority_path": "crates/event_store/src/store.rs", + "resource": "raw_events", + "boundary": "exact", + "expected_outcome": "accepted_without_capacity_delta", + "error_domain": null, + "expected_error": null + }, + { + "id": "ephemeral_consumes_no_capacity", + "execution": "direct_executor", + "authority": "source_maintenance_v1_result_vector", + "authority_path": "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted_without_capacity_delta", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_event_count_exact", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_events", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_event_count_one_over", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_events", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "raw_tag_count_exact", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_tags", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_tag_count_one_over", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_tags", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "raw_event_text_bytes_exact", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_event_text_bytes", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_event_text_bytes_one_over", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_event_text_bytes", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "raw_tag_text_bytes_exact", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_tag_text_bytes", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "raw_tag_text_bytes_one_over", + "execution": "delegated_rust_test", + "authority": "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_tag_text_bytes", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "outer_transaction_rollback_restores_capacity", + "execution": "direct_executor", + "authority": "source_maintenance_v1_result_vector", + "authority_path": "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rolled_back_without_capacity_delta", + "error_domain": null, + "expected_error": null + }, + { + "id": "failed_nested_ingest_rolls_back_savepoint_only", + "execution": "delegated_rust_test", + "authority": "borrowed_ingest_savepoint_rolls_back_post_core_authority_forge", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "failed_ingest_rolled_back_and_prior_caller_work_preserved", + "error_domain": "typed", + "expected_error": "MigrationHookStateDrift" + }, + { + "id": "v3_to_v4_under_limit_succeeds", + "execution": "delegated_rust_test", + "authority": "v3_to_v4_under_limit_backfills_exact_capacity_and_preserves_source", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "under_limit", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "v3_to_v4_prior_transition_drift_is_atomic", + "execution": "delegated_rust_test", + "authority": "v3_to_v4_rejects_prior_transition_drift_atomically", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "corrupt_managed_v3", + "expected_outcome": "rejected_before_v4_schema_ledger_or_predecessor_trigger_mutation", + "error_domain": "typed", + "expected_error": "MigrationHookStateDrift" + }, + { + "id": "v3_to_v4_one_over_is_atomic", + "execution": "delegated_rust_test", + "authority": "source_capacity_is_rechecked_for_every_rebuild_bound_migration", + "authority_path": "crates/event_store/src/schema.rs", + "resource": "raw_events", + "boundary": "one_over", + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "v3_to_v4_persisted_ephemeral_is_atomic", + "execution": "delegated_rust_test", + "authority": "v4_rejects_persisted_legacy_ephemeral_rows_atomically", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_before_mutation", + "error_domain": "typed", + "expected_error": "PersistedEphemeralRawEvent" + }, + { + "id": "reopen_rejects_incoherent_capacity_authority", + "execution": "delegated_rust_test", + "authority": "reopen_full_measure_detects_every_persisted_capacity_dimension", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_on_reopen", + "error_domain": "typed", + "expected_error": "SourceCapacityStateDrift" + }, + { + "id": "reopen_stops_at_first_raw_event_one_over", + "execution": "delegated_rust_test", + "authority": "reopen_stops_at_the_first_raw_event_one_over_before_ephemeral_probe", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "raw_events", + "boundary": "one_over", + "expected_outcome": "rejected_at_scan_bound", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + }, + { + "id": "retained_generation_rebuild_exact", + "execution": "delegated_rust_test", + "authority": "ninth_current_v4_rebuild_is_typed_and_preflight_atomic", + "authority_path": "crates/event_store/src/store.rs", + "resource": "retained_source_generations", + "boundary": "exact", + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "ninth_rebuild_is_typed_and_atomic", + "execution": "delegated_rust_test", + "authority": "ninth_current_v4_rebuild_is_typed_and_preflight_atomic", + "authority_path": "crates/event_store/src/store.rs", + "resource": "retained_source_generations", + "boundary": "one_over", + "expected_outcome": "rejected_before_entropy_or_mutation", + "error_domain": "typed", + "expected_error": "SourceGenerationHistoryLimitReached" + }, + { + "id": "retained_generation_sql_backstop_one_over", + "execution": "delegated_sql_test", + "authority": "generation_sql_backstop_allows_exact_append_and_is_conflict_safe_one_over", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": "retained_source_generations", + "boundary": "one_over", + "expected_outcome": "rejected_by_sql_backstop", + "error_domain": "sqlite_database", + "expected_error": "event-store retained source generation limit reached; replace and resync into a fresh store" + }, + { + "id": "rebuild_marker_accepts_consistent_seals", + "execution": "delegated_rust_test", + "authority": "current_v4_rebuild_rotates_capacity_and_food_authority_end_to_end", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted", + "error_domain": null, + "expected_error": null + }, + { + "id": "rebuild_marker_rejects_incoherent_seals", + "execution": "delegated_sql_test", + "authority": "marker_close_sql_backstop_rejects_each_required_seal_drift", + "authority_path": "crates/event_store/src/source_maintenance_v1.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_by_sql_backstop", + "error_domain": "sqlite_database", + "expected_error": "event-store rebuild marker cannot close before capacity, NIP-09, and FoodAvailability seals agree" + }, + { + "id": "v4_marker_repair_binds_exact_prior_and_floor", + "execution": "delegated_rust_test", + "authority": "v4_marker_open_allows_repairing_prior_transition_high_water_drift", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "managed_v4_rebuild", + "expected_outcome": "accepts_derived_high_water_repair_and_rejects_wrong_prior_or_floor", + "error_domain": "sqlite_database", + "expected_error": "exact raw and prior source authority" + }, + { + "id": "v4_food_reset_requires_target_rotation", + "execution": "delegated_rust_test", + "authority": "v4_food_reset_requires_marker_rotation_and_preserves_target_rows", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "managed_v4_rebuild", + "expected_outcome": "historical_rows_deleted_only_after_rotation_and_target_rows_preserved", + "error_domain": null, + "expected_error": null + }, + { + "id": "v4_down_restores_predecessor_triggers", + "execution": "delegated_rust_test", + "authority": "v4_down_restores_exact_predecessor_trigger_sql_and_fingerprint", + "authority_path": "crates/event_store/src/schema.rs", + "resource": null, + "boundary": "v4_to_v3", + "expected_outcome": "restored_exact_predecessor_trigger_sql_and_v3_fingerprint", + "error_domain": null, + "expected_error": null + }, + { + "id": "utf16_open_file_rejected_before_mutation", + "execution": "delegated_rust_test", + "authority": "open_file_rejects_utf16_main_database_before_schema_or_journal_mutation", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_before_schema_or_journal_mutation", + "error_domain": "typed", + "expected_error": "SqliteMainDatabaseEncodingNotUtf8" + }, + { + "id": "utf16_open_pool_rejected_before_mutation", + "execution": "delegated_rust_test", + "authority": "open_pool_rejects_utf16_main_database_before_schema_or_journal_mutation", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "rejected_before_schema_or_journal_mutation", + "error_domain": "typed", + "expected_error": "SqliteMainDatabaseEncodingNotUtf8" + }, + { + "id": "utf8_non_ascii_nul_reopen_accounting", + "execution": "delegated_rust_test", + "authority": "utf8_file_reopen_preserves_non_ascii_and_nul_capacity_accounting", + "authority_path": "crates/event_store/src/store.rs", + "resource": null, + "boundary": null, + "expected_outcome": "accepted_with_exact_capacity_after_reopen", + "error_domain": null, + "expected_error": null + }, + { + "id": "generation_destructive_rollback_rejected", + "execution": "delegated_rust_test", + "authority": "rollback_rejects_below_floor_ahead_unmanaged_and_generation_destructive_targets", + "authority_path": "crates/event_store/src/schema.rs", + "resource": "retained_source_generations", + "boundary": null, + "expected_outcome": "rejected_before_mutation_with_status_and_history_preserved", + "error_domain": "typed", + "expected_error": "RollbackWouldDiscardSourceGenerationHistory" + }, + { + "id": "generation_destructive_two_step_rollback_rejected", + "execution": "delegated_rust_test", + "authority": "rollback_cannot_bypass_generation_history_guard_through_version_three", + "authority_path": "crates/event_store/src/schema.rs", + "resource": "retained_source_generations", + "boundary": null, + "expected_outcome": "rejected_before_mutation_after_history_preserving_intermediate_rollback", + "error_domain": "typed", + "expected_error": "RollbackWouldDiscardSourceGenerationHistory" + }, + { + "id": "independent_pool_last_byte_slot_race", + "execution": "delegated_rust_test", + "authority": "independent_file_pools_serialize_the_last_raw_event_byte_capacity_slot", + "authority_path": "crates/event_store/src/store.rs", + "resource": "raw_event_text_bytes", + "boundary": "exact", + "expected_outcome": "exactly_one_accepted_one_typed_rejection_and_clean_reopen", + "error_domain": "typed", + "expected_error": "SourceCapacityExceeded" + } + ] +} diff --git a/crates/event_store/tests/source_maintenance_v1_result_vector.rs b/crates/event_store/tests/source_maintenance_v1_result_vector.rs @@ -0,0 +1,632 @@ +#![forbid(unsafe_code)] + +use nostr::{EventBuilder, Keys, Kind, SecretKey, Tag, TagKind, Timestamp}; +use radroots_event_store::{ + RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1, + RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1, + RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1, RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1, + RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1, RadrootsEventIngest, + RadrootsEventStore, +}; +use serde::Deserialize; +use std::collections::BTreeSet; + +const RESULT_VECTOR_EXECUTOR_ID: &str = + "radroots_event_store.source_maintenance_v1.result_vector_executor.v1"; +const RESULT_VECTOR_BYTES: &[u8] = + include_bytes!("../../../contracts/conformance/vectors/event_store/source_maintenance.v1.json"); +const FIXTURE_SECRET_KEY_HEX: &str = + "10c5304d6c9ae3a1a16f7860f1cc8f5e3a76225a2663b3a989a0d775919b7df5"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct SourceMaintenanceVector { + schema_version: u32, + contract_id: String, + capacity_version: u32, + limits: CapacityLimits, + accounting: CapacityAccounting, + cases: Vec<VectorCase>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CapacityLimits { + raw_events: u64, + raw_tags: u64, + raw_event_text_bytes: u64, + raw_tag_text_bytes: u64, + retained_source_generations: u32, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CapacityAccounting { + algorithm: String, + raw_event_columns: Vec<String>, + raw_tag_columns: Vec<String>, + nullable_raw_tag_columns: Vec<String>, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct VectorCase { + id: String, + execution: String, + authority: String, + authority_path: String, + resource: Option<String>, + boundary: Option<String>, + expected_outcome: String, + error_domain: Option<String>, + expected_error: Option<String>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct CapacityDelta { + raw_events: u64, + raw_tags: u64, + raw_event_text_bytes: u64, + raw_tag_text_bytes: u64, +} + +#[derive(Clone, Copy)] +struct ExpectedCase { + id: &'static str, + execution: &'static str, + authority: &'static str, + authority_path: &'static str, + error_domain: Option<&'static str>, +} + +const DIRECT_EXECUTOR: &str = "direct_executor"; +const RESULT_VECTOR_EXECUTOR_TEST: &str = "source_maintenance_v1_result_vector"; +const RESULT_VECTOR_EXECUTOR_PATH: &str = + "crates/event_store/tests/source_maintenance_v1_result_vector.rs"; + +const EXPECTED_CASES: &[ExpectedCase] = &[ + ExpectedCase { + id: "fresh_store_zero_authority", + execution: DIRECT_EXECUTOR, + authority: RESULT_VECTOR_EXECUTOR_TEST, + authority_path: RESULT_VECTOR_EXECUTOR_PATH, + error_domain: None, + }, + ExpectedCase { + id: "durable_unique_append_updates_all_dimensions", + execution: DIRECT_EXECUTOR, + authority: RESULT_VECTOR_EXECUTOR_TEST, + authority_path: RESULT_VECTOR_EXECUTOR_PATH, + error_domain: None, + }, + ExpectedCase { + id: "duplicate_at_exact_boundary_is_idempotent", + execution: "delegated_rust_test", + authority: "exact_capacity_boundary_allows_duplicate_observation_and_ephemeral_noop", + authority_path: "crates/event_store/src/store.rs", + error_domain: None, + }, + ExpectedCase { + id: "ephemeral_consumes_no_capacity", + execution: DIRECT_EXECUTOR, + authority: RESULT_VECTOR_EXECUTOR_TEST, + authority_path: RESULT_VECTOR_EXECUTOR_PATH, + error_domain: None, + }, + ExpectedCase { + id: "raw_event_count_exact", + execution: "delegated_rust_test", + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: None, + }, + ExpectedCase { + id: "raw_event_count_one_over", + execution: "delegated_rust_test", + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "raw_tag_count_exact", + execution: "delegated_rust_test", + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: None, + }, + ExpectedCase { + id: "raw_tag_count_one_over", + execution: "delegated_rust_test", + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "raw_event_text_bytes_exact", + execution: "delegated_rust_test", + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: None, + }, + ExpectedCase { + id: "raw_event_text_bytes_one_over", + execution: "delegated_rust_test", + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "raw_tag_text_bytes_exact", + execution: "delegated_rust_test", + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: None, + }, + ExpectedCase { + id: "raw_tag_text_bytes_one_over", + execution: "delegated_rust_test", + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "outer_transaction_rollback_restores_capacity", + execution: DIRECT_EXECUTOR, + authority: RESULT_VECTOR_EXECUTOR_TEST, + authority_path: RESULT_VECTOR_EXECUTOR_PATH, + error_domain: None, + }, + ExpectedCase { + id: "failed_nested_ingest_rolls_back_savepoint_only", + execution: "delegated_rust_test", + authority: "borrowed_ingest_savepoint_rolls_back_post_core_authority_forge", + authority_path: "crates/event_store/src/store.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "v3_to_v4_under_limit_succeeds", + execution: "delegated_rust_test", + authority: "v3_to_v4_under_limit_backfills_exact_capacity_and_preserves_source", + authority_path: "crates/event_store/src/schema.rs", + error_domain: None, + }, + ExpectedCase { + id: "v3_to_v4_prior_transition_drift_is_atomic", + execution: "delegated_rust_test", + authority: "v3_to_v4_rejects_prior_transition_drift_atomically", + authority_path: "crates/event_store/src/schema.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "v3_to_v4_one_over_is_atomic", + execution: "delegated_rust_test", + authority: "source_capacity_is_rechecked_for_every_rebuild_bound_migration", + authority_path: "crates/event_store/src/schema.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "v3_to_v4_persisted_ephemeral_is_atomic", + execution: "delegated_rust_test", + authority: "v4_rejects_persisted_legacy_ephemeral_rows_atomically", + authority_path: "crates/event_store/src/schema.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "reopen_rejects_incoherent_capacity_authority", + execution: "delegated_rust_test", + authority: "reopen_full_measure_detects_every_persisted_capacity_dimension", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "reopen_stops_at_first_raw_event_one_over", + execution: "delegated_rust_test", + authority: "reopen_stops_at_the_first_raw_event_one_over_before_ephemeral_probe", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "retained_generation_rebuild_exact", + execution: "delegated_rust_test", + authority: "ninth_current_v4_rebuild_is_typed_and_preflight_atomic", + authority_path: "crates/event_store/src/store.rs", + error_domain: None, + }, + ExpectedCase { + id: "ninth_rebuild_is_typed_and_atomic", + execution: "delegated_rust_test", + authority: "ninth_current_v4_rebuild_is_typed_and_preflight_atomic", + authority_path: "crates/event_store/src/store.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "retained_generation_sql_backstop_one_over", + execution: "delegated_sql_test", + authority: "generation_sql_backstop_allows_exact_append_and_is_conflict_safe_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: Some("sqlite_database"), + }, + ExpectedCase { + id: "rebuild_marker_accepts_consistent_seals", + execution: "delegated_rust_test", + authority: "current_v4_rebuild_rotates_capacity_and_food_authority_end_to_end", + authority_path: "crates/event_store/src/store.rs", + error_domain: None, + }, + ExpectedCase { + id: "rebuild_marker_rejects_incoherent_seals", + execution: "delegated_sql_test", + authority: "marker_close_sql_backstop_rejects_each_required_seal_drift", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + error_domain: Some("sqlite_database"), + }, + ExpectedCase { + id: "v4_marker_repair_binds_exact_prior_and_floor", + execution: "delegated_rust_test", + authority: "v4_marker_open_allows_repairing_prior_transition_high_water_drift", + authority_path: "crates/event_store/src/schema.rs", + error_domain: Some("sqlite_database"), + }, + ExpectedCase { + id: "v4_food_reset_requires_target_rotation", + execution: "delegated_rust_test", + authority: "v4_food_reset_requires_marker_rotation_and_preserves_target_rows", + authority_path: "crates/event_store/src/schema.rs", + error_domain: None, + }, + ExpectedCase { + id: "v4_down_restores_predecessor_triggers", + execution: "delegated_rust_test", + authority: "v4_down_restores_exact_predecessor_trigger_sql_and_fingerprint", + authority_path: "crates/event_store/src/schema.rs", + error_domain: None, + }, + ExpectedCase { + id: "utf16_open_file_rejected_before_mutation", + execution: "delegated_rust_test", + authority: "open_file_rejects_utf16_main_database_before_schema_or_journal_mutation", + authority_path: "crates/event_store/src/store.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "utf16_open_pool_rejected_before_mutation", + execution: "delegated_rust_test", + authority: "open_pool_rejects_utf16_main_database_before_schema_or_journal_mutation", + authority_path: "crates/event_store/src/store.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "utf8_non_ascii_nul_reopen_accounting", + execution: "delegated_rust_test", + authority: "utf8_file_reopen_preserves_non_ascii_and_nul_capacity_accounting", + authority_path: "crates/event_store/src/store.rs", + error_domain: None, + }, + ExpectedCase { + id: "generation_destructive_rollback_rejected", + execution: "delegated_rust_test", + authority: "rollback_rejects_below_floor_ahead_unmanaged_and_generation_destructive_targets", + authority_path: "crates/event_store/src/schema.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "generation_destructive_two_step_rollback_rejected", + execution: "delegated_rust_test", + authority: "rollback_cannot_bypass_generation_history_guard_through_version_three", + authority_path: "crates/event_store/src/schema.rs", + error_domain: Some("typed"), + }, + ExpectedCase { + id: "independent_pool_last_byte_slot_race", + execution: "delegated_rust_test", + authority: "independent_file_pools_serialize_the_last_raw_event_byte_capacity_slot", + authority_path: "crates/event_store/src/store.rs", + error_domain: Some("typed"), + }, +]; + +#[tokio::test] +async fn source_maintenance_v1_result_vector() { + assert_eq!( + RESULT_VECTOR_EXECUTOR_ID, + "radroots_event_store.source_maintenance_v1.result_vector_executor.v1" + ); + let vector: SourceMaintenanceVector = + serde_json::from_slice(RESULT_VECTOR_BYTES).expect("strict SourceMaintenance vector"); + validate_vector_header(&vector); + validate_case_inventory(&vector.cases); + let mut executed_direct_cases = BTreeSet::new(); + + let store = RadrootsEventStore::open_memory() + .await + .expect("open current in-memory event store"); + let fresh = store + .source_capacity_v1() + .await + .expect("fresh source capacity"); + assert_eq!(fresh.raw_event_count(), 0); + assert_eq!(fresh.raw_tag_count(), 0); + assert_eq!(fresh.raw_event_text_bytes(), 0); + assert_eq!(fresh.raw_tag_text_bytes(), 0); + assert_eq!(fresh.raw_high_water_seq(), 0); + assert_eq!(fresh.retained_generation_count(), 1); + assert_eq!( + fresh.retained_generation_limit(), + RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1 + ); + mark_direct_case(&mut executed_direct_cases, "fresh_store_zero_authority"); + + let durable_raw = signed_raw_json( + 1, + 1_750_000_000, + vec![vec!["t".to_owned(), "soil".to_owned()]], + "Victoria field note", + ); + let expected_delta = capacity_delta(&durable_raw); + let first = store + .ingest_event( + RadrootsEventIngest::from_raw_json(durable_raw.clone(), 1_750_000_001) + .expect("verified durable fixture"), + ) + .await + .expect("unique durable ingest"); + assert!(first.persistence.is_inserted()); + let after_first = store + .source_capacity_v1() + .await + .expect("capacity after unique durable ingest"); + assert_eq!(after_first.raw_event_count(), expected_delta.raw_events); + assert_eq!(after_first.raw_tag_count(), expected_delta.raw_tags); + assert_eq!( + after_first.raw_event_text_bytes(), + expected_delta.raw_event_text_bytes + ); + assert_eq!( + after_first.raw_tag_text_bytes(), + expected_delta.raw_tag_text_bytes + ); + assert_eq!(after_first.raw_high_water_seq(), 1); + mark_direct_case( + &mut executed_direct_cases, + "durable_unique_append_updates_all_dimensions", + ); + + let duplicate = store + .ingest_event( + RadrootsEventIngest::from_raw_json(durable_raw, 1_750_000_002) + .expect("verified duplicate fixture"), + ) + .await + .expect("duplicate durable ingest"); + assert!(duplicate.persistence.is_duplicate()); + assert_eq!( + store + .source_capacity_v1() + .await + .expect("capacity after duplicate"), + after_first + ); + + let ephemeral_raw = signed_raw_json(20_001, 1_750_000_003, Vec::new(), "relay-only"); + let ephemeral = store + .ingest_event( + RadrootsEventIngest::from_raw_json(ephemeral_raw, 1_750_000_004) + .expect("verified ephemeral fixture"), + ) + .await + .expect("ephemeral ingest outcome"); + assert!(!ephemeral.persistence.is_inserted()); + assert_eq!( + store + .source_capacity_v1() + .await + .expect("capacity after ephemeral"), + after_first + ); + mark_direct_case(&mut executed_direct_cases, "ephemeral_consumes_no_capacity"); + + let rolled_back_raw = signed_raw_json(1, 1_750_000_005, Vec::new(), "rolled back"); + let rolled_back_id = serde_json::from_str::<serde_json::Value>(&rolled_back_raw) + .expect("rolled-back JSON") + .get("id") + .and_then(serde_json::Value::as_str) + .expect("rolled-back event id") + .to_owned(); + let mut transaction = store + .begin_write_transaction() + .await + .expect("begin composed write"); + let receipt = store + .ingest_event_in_transaction( + &mut transaction, + RadrootsEventIngest::from_raw_json(rolled_back_raw, 1_750_000_006) + .expect("verified rollback fixture"), + ) + .await + .expect("nested ingest before outer rollback"); + assert!(receipt.persistence.is_inserted()); + transaction.rollback().await.expect("rollback outer write"); + assert_eq!( + store + .source_capacity_v1() + .await + .expect("capacity after outer rollback"), + after_first + ); + assert!( + store + .raw_event(&rolled_back_id) + .await + .expect("rolled-back raw lookup") + .is_none() + ); + mark_direct_case( + &mut executed_direct_cases, + "outer_transaction_rollback_restores_capacity", + ); + + assert_direct_cases_executed(&vector.cases, &executed_direct_cases); +} + +fn validate_vector_header(vector: &SourceMaintenanceVector) { + assert_eq!(vector.schema_version, 1); + assert_eq!( + vector.contract_id, + "radroots_event_store.source_maintenance_v1" + ); + assert_eq!(vector.capacity_version, 1); + assert_eq!( + vector.limits.raw_events, + RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1 + ); + assert_eq!( + vector.limits.raw_tags, + RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1 + ); + assert_eq!( + vector.limits.raw_event_text_bytes, + RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1 + ); + assert_eq!( + vector.limits.raw_tag_text_bytes, + RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1 + ); + assert_eq!( + vector.limits.retained_source_generations, + RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1 + ); + assert_eq!(vector.accounting.algorithm, "sqlite_cast_blob_octet_sum_v1"); + assert_eq!( + vector.accounting.raw_event_columns, + [ + "event_id", + "pubkey", + "tags_json", + "content", + "sig", + "raw_json" + ] + ); + assert_eq!( + vector.accounting.raw_tag_columns, + ["event_id", "tag_name", "tag_value", "tag_json"] + ); + assert_eq!(vector.accounting.nullable_raw_tag_columns, ["tag_value"]); +} + +fn validate_case_inventory(cases: &[VectorCase]) { + assert_eq!(cases.len(), EXPECTED_CASES.len()); + for (case, expected) in cases.iter().zip(EXPECTED_CASES) { + assert_eq!(case.id, expected.id); + assert_eq!(case.execution, expected.execution, "{}", case.id); + assert_eq!(case.authority, expected.authority, "{}", case.id); + assert_eq!(case.authority_path, expected.authority_path, "{}", case.id); + assert_eq!( + case.error_domain.as_deref(), + expected.error_domain, + "{}", + case.id + ); + assert!(!case.expected_outcome.is_empty(), "{}", case.id); + match case.boundary.as_deref() { + Some( + "corrupt_managed_v3" | "exact" | "managed_v4_rebuild" | "one_over" | "under_limit" + | "v4_to_v3", + ) + | None => {} + other => panic!("{}: invalid boundary {other:?}", case.id), + } + match (case.error_domain.as_deref(), case.expected_error.as_deref()) { + (None, None) => {} + (Some("typed" | "sqlite_database"), Some(error)) if !error.is_empty() => {} + other => panic!("{}: inconsistent error metadata {other:?}", case.id), + } + if case.execution == DIRECT_EXECUTOR { + assert!(case.resource.is_none(), "{}", case.id); + assert!(case.boundary.is_none(), "{}", case.id); + } + } +} + +fn mark_direct_case(executed: &mut BTreeSet<String>, id: &str) { + assert!( + executed.insert(id.to_owned()), + "direct case executed twice: {id}" + ); +} + +fn assert_direct_cases_executed(cases: &[VectorCase], executed: &BTreeSet<String>) { + let expected = cases + .iter() + .filter(|case| case.execution == DIRECT_EXECUTOR) + .map(|case| case.id.clone()) + .collect::<BTreeSet<_>>(); + assert_eq!(executed, &expected); +} + +fn signed_raw_json(kind: u16, created_at: u64, tags: Vec<Vec<String>>, content: &str) -> String { + let secret_key = SecretKey::from_hex(FIXTURE_SECRET_KEY_HEX).expect("fixture secret key"); + let keys = Keys::new(secret_key); + let tags = tags + .into_iter() + .map(|mut values| { + let name = values.remove(0); + Tag::custom(TagKind::Custom(name.into()), values) + }) + .collect::<Vec<_>>(); + let event = EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at)) + .sign_with_keys(&keys) + .expect("signed fixture event"); + serde_json::to_string(&event).expect("fixture event JSON") +} + +fn capacity_delta(raw_json: &str) -> CapacityDelta { + let event: serde_json::Value = serde_json::from_str(raw_json).expect("signed fixture JSON"); + let event_id = text_field(&event, "id"); + let pubkey = text_field(&event, "pubkey"); + let content = text_field(&event, "content"); + let sig = text_field(&event, "sig"); + let tags = event + .get("tags") + .and_then(serde_json::Value::as_array) + .expect("fixture tags"); + let tags_json = serde_json::to_string(tags).expect("canonical tags JSON"); + let raw_event_text_bytes = [event_id, pubkey, tags_json.as_str(), content, sig, raw_json] + .into_iter() + .map(str::len) + .sum::<usize>(); + let raw_tag_text_bytes = tags + .iter() + .map(|tag| { + let values = tag.as_array().expect("tag array"); + let name = values + .first() + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let value = values + .get(1) + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + event_id.len() + + name.len() + + value.len() + + serde_json::to_string(values).expect("tag JSON").len() + }) + .sum::<usize>(); + CapacityDelta { + raw_events: 1, + raw_tags: u64::try_from(tags.len()).expect("tag count fits u64"), + raw_event_text_bytes: u64::try_from(raw_event_text_bytes) + .expect("event byte count fits u64"), + raw_tag_text_bytes: u64::try_from(raw_tag_text_bytes).expect("tag byte count fits u64"), + } +} + +fn text_field<'a>(event: &'a serde_json::Value, name: &str) -> &'a str { + event + .get(name) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("fixture event missing {name}")) +} diff --git a/tools/xtask/src/contract.rs b/tools/xtask/src/contract.rs @@ -7,6 +7,7 @@ mod deletion_authority; mod food_availability_projection; mod nip09_reconciliation; mod registry_v7; +mod source_maintenance; pub(crate) use food_availability_projection::{ validate_food_availability_projection_manifest, write_food_availability_projection_manifest, @@ -17,6 +18,9 @@ pub(crate) use nip09_reconciliation::{ pub(crate) use registry_v7::{ validate_event_contract_registry_v7_inventory, write_event_contract_registry_v7_inventory, }; +pub(crate) use source_maintenance::{ + validate_source_maintenance_manifest, write_source_maintenance_manifest, +}; use crate::coverage::{CoveragePolicyFile, CoverageThresholds, read_coverage_policy}; use admission_authority::validate_admission_operation_authority; @@ -40,6 +44,14 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; +pub(crate) fn validate_artifact_contracts(workspace_root: &Path) -> Result<(), String> { + validate_event_contract_registry_v7_inventory(workspace_root)?; + validate_nip09_reconciliation_manifest(workspace_root)?; + validate_food_availability_projection_manifest(workspace_root)?; + validate_source_maintenance_manifest(workspace_root)?; + validate_knowledge_contract_manifest(workspace_root) +} + const ROOT_RELEASE_POLICY_RELATIVE: &str = "foundation/contracts/release_runtime/mounted_rust_crates/publish-policy.toml"; const CONFORMANCE_ROOT_RELATIVE: &str = "contracts/conformance"; @@ -48,9 +60,12 @@ const NIP09_RECONCILIATION_CONFORMANCE_VECTOR_RELATIVE: &str = "contracts/conformance/vectors/event_store/nip09_reconciliation.v1.json"; 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] = [ +const SOURCE_MAINTENANCE_CONFORMANCE_VECTOR_RELATIVE: &str = + "contracts/conformance/vectors/event_store/source_maintenance.v1.json"; +const SPECIALIZED_CONFORMANCE_VECTOR_RELATIVES: [&str; 3] = [ NIP09_RECONCILIATION_CONFORMANCE_VECTOR_RELATIVE, FOOD_AVAILABILITY_PROJECTION_CONFORMANCE_VECTOR_RELATIVE, + SOURCE_MAINTENANCE_CONFORMANCE_VECTOR_RELATIVE, ]; const KNOWLEDGE_MANIFEST_RELATIVE: &str = "contracts/knowledge/knowledge_event_contract_manifest.v2.json"; @@ -71,7 +86,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); 21] = [ +const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 22] = [ ( "contracts/conformance/vectors/blossom/bud11_claims.v1.json", "crates/blossom/tests/fixtures/bud11_claims.v1.json", @@ -117,6 +132,10 @@ const CONFORMANCE_VECTOR_MIRRORS: [(&str, &str); 21] = [ "crates/event_store/tests/fixtures/food_availability_projection.v1.json", ), ( + SOURCE_MAINTENANCE_CONFORMANCE_VECTOR_RELATIVE, + "crates/event_store/tests/fixtures/source_maintenance.v1.json", + ), + ( "contracts/conformance/vectors/events/operational_listing_tags_full.v1.json", "crates/event_codec/tests/fixtures/operational_listing_tags_full.v1.json", ), diff --git a/tools/xtask/src/contract/food_availability_projection.rs b/tools/xtask/src/contract/food_availability_projection.rs @@ -1,11 +1,13 @@ +#![allow(dead_code)] + use super::artifact_bundle::{ GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction, }; use super::nip09_reconciliation::{ + nip09_predecessor_production_source_paths_under_lock, 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}; @@ -75,6 +77,77 @@ 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 IMMUTABLE_MANIFEST_BYTES: &[u8] = include_bytes!( + "../../../../crates/event_store/contracts/food_availability_projection_v1.manifest.json" +); +const IMMUTABLE_MANIFEST_SCHEMA_BYTES: &[u8] = include_bytes!( + "../../../../crates/event_store/contracts/food_availability_projection_v1.manifest.schema.json" +); +const IMMUTABLE_MANIFEST_SHA256_BYTES: &[u8] = include_bytes!( + "../../../../crates/event_store/contracts/food_availability_projection_v1.manifest.sha256" +); +const IMMUTABLE_GENERATED_DESCRIPTOR_BYTES: &[u8] = include_bytes!( + "../../../../crates/event_store/src/generated/food_availability_projection_manifest.rs" +); +const IMMUTABLE_RESULT_VECTOR_BYTES: &[u8] = include_bytes!( + "../../../../contracts/conformance/vectors/event_store/food_availability_projection.v1.json" +); + +#[derive(Clone, Copy)] +struct ImmutableArtifactSpec { + relative: &'static str, + byte_length: usize, + sha256: &'static str, +} + +const IMMUTABLE_PREDECESSOR_ARTIFACTS: [ImmutableArtifactSpec; 9] = [ + ImmutableArtifactSpec { + relative: MANIFEST_RELATIVE, + byte_length: 17_455, + sha256: "33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23", + }, + ImmutableArtifactSpec { + relative: MANIFEST_SCHEMA_RELATIVE, + byte_length: 7_964, + sha256: "39171f6ef872a8d1483bc3d55049df5e0d110d9131c5adb4450b7c418f546910", + }, + ImmutableArtifactSpec { + relative: MANIFEST_SHA256_RELATIVE, + byte_length: 65, + sha256: "4ac4c79a946ccb1a11726cbafc18e2e016f08f3f6797964400dea3494c66dbc5", + }, + ImmutableArtifactSpec { + relative: GENERATED_DESCRIPTOR_RELATIVE, + byte_length: 21_437, + sha256: "90908da53ab9572f45f5916ccc2652736b7ea26ba6dd202a4f69af1e651b564b", + }, + ImmutableArtifactSpec { + relative: RESULT_VECTOR_CANONICAL_RELATIVE, + byte_length: 103_659, + sha256: "fca2b71b47736ed04ed1e908823b65b3fc3cf0366cb162128369fe328295bb63", + }, + ImmutableArtifactSpec { + relative: RESULT_VECTOR_MIRROR_RELATIVE, + byte_length: 103_659, + sha256: "fca2b71b47736ed04ed1e908823b65b3fc3cf0366cb162128369fe328295bb63", + }, + ImmutableArtifactSpec { + relative: RESULT_VECTOR_EXECUTOR_RELATIVE, + byte_length: 34_075, + sha256: "9e8e11abae7bbc7dda30eab6f0a79074ffc3761aa6b955cff58c4c62fa581aa3", + }, + ImmutableArtifactSpec { + relative: MIGRATION_UP_RELATIVE, + byte_length: 23_683, + sha256: "4e7edfb981b25f76055efc7802ec30b4034eeae9b9c0809ea4ea7c574678748a", + }, + ImmutableArtifactSpec { + relative: MIGRATION_DOWN_RELATIVE, + byte_length: 1_755, + sha256: "29d663320109d9dd0df6a00b6a53d8d988438d01f7a66960a9d4ba3482ffffb8", + }, +]; + const GOVERNED_PUBLIC_API_MODULES: &[&str] = &[ "addressable_transition_feed_v1", "current_visibility_v1", @@ -731,11 +804,7 @@ 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)?; + transaction.write(immutable_generated_artifacts())?; validate_food_availability_projection_manifest_under_lock(workspace_root) }) } @@ -748,12 +817,10 @@ pub(crate) fn validate_food_availability_projection_manifest( }) } -fn validate_food_availability_projection_manifest_under_lock( +pub(super) 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) @@ -778,16 +845,65 @@ fn validate_food_availability_projection_manifest_under_lock( )); } - let expected = expected_artifacts(workspace_root)?; - for artifact in expected { + if manifest.migration.up.sha256 != IMMUTABLE_PREDECESSOR_ARTIFACTS[7].sha256 + || manifest.migration.down.sha256 != IMMUTABLE_PREDECESSOR_ARTIFACTS[8].sha256 + || manifest.result_vector.sha256 != IMMUTABLE_PREDECESSOR_ARTIFACTS[4].sha256 + || manifest.result_vector.executor_sha256 != IMMUTABLE_PREDECESSOR_ARTIFACTS[6].sha256 + { + return Err(format!( + "{MANIFEST_RELATIVE} does not describe the immutable FoodAvailability 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: 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)?; + + for artifact in IMMUTABLE_PREDECESSOR_ARTIFACTS { let actual = read_regular_file(workspace_root, artifact.relative)?; - if actual != artifact.contents { - return Err(stale_error(artifact.relative)); + if actual.len() != artifact.byte_length || sha256_hex(&actual) != artifact.sha256 { + return Err(format!( + "immutable FoodAvailability predecessor artifact {} does not match its authenticated byte identity", + artifact.relative + )); } } Ok(()) } +fn immutable_generated_artifacts() -> Vec<GeneratedArtifact> { + 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(), + }, + GeneratedArtifact { + relative: RESULT_VECTOR_MIRROR_RELATIVE, + contents: IMMUTABLE_RESULT_VECTOR_BYTES.to_vec(), + }, + ] +} + fn expected_artifacts(workspace_root: &Path) -> Result<Vec<GeneratedArtifact>, String> { let schema = manifest_schema(); let schema_bytes = canonical_json_bytes(&schema)?; @@ -826,7 +942,7 @@ fn describe_manifest( schema_bytes: &[u8], ) -> Result<FoodAvailabilityProjectionManifest, String> { validate_source_contract(workspace_root)?; - validate_predecessor_production_source_coverage(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 @@ -921,7 +1037,10 @@ fn describe_manifest( }) } -fn validate_predecessor_production_source_coverage(workspace_root: &Path) -> Result<(), String> { +fn validate_predecessor_production_source_coverage( + workspace_root: &Path, + downstream_nip09_superseded_paths: &[&str], +) -> Result<(), String> { for path in PREDECESSOR_SUPERSEDED_SOURCE_PATHS { if !SOURCE_SPECS.iter().any(|source| source.path == *path) { return Err(format!( @@ -929,10 +1048,152 @@ fn validate_predecessor_production_source_coverage(workspace_root: &Path) -> Res )); } } - validate_nip09_predecessor_production_sources_under_lock( - workspace_root, - PREDECESSOR_SUPERSEDED_SOURCE_PATHS, - ) + let superseded_paths = PREDECESSOR_SUPERSEDED_SOURCE_PATHS + .iter() + .copied() + .chain(downstream_nip09_superseded_paths.iter().copied()) + .collect::<BTreeSet<_>>() + .into_iter() + .collect::<Vec<_>>(); + validate_nip09_predecessor_production_sources_under_lock(workspace_root, &superseded_paths) +} + +pub(super) fn validate_food_availability_projection_predecessor_production_sources_under_lock( + workspace_root: &Path, + superseded_paths: &[&str], +) -> Result<(), String> { + validate_food_availability_projection_manifest_under_lock(workspace_root)?; + let manifest_bytes = read_regular_file(workspace_root, MANIFEST_RELATIVE)?; + let manifest: FoodAvailabilityProjectionManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("parse {MANIFEST_RELATIVE}: {error}"))?; + + let (food_superseded_paths, nip09_superseded_paths) = + partition_downstream_predecessor_supersessions(workspace_root, superseded_paths)?; + validate_food_predecessor_source_inventory(&manifest, &food_superseded_paths, |spec| { + describe_source_file(workspace_root, spec) + })?; + require_predecessor_file_match( + "registry inventory", + &manifest.registry_inventory, + &descriptor_for_file(workspace_root, REGISTRY_INVENTORY_RELATIVE)?, + )?; + require_predecessor_file_match( + "FoodAvailability profile vector", + &manifest.food_profile_vector, + &descriptor_for_file(workspace_root, FOOD_PROFILE_VECTOR_RELATIVE)?, + )?; + validate_predecessor_production_source_coverage(workspace_root, &nip09_superseded_paths) +} + +fn partition_downstream_predecessor_supersessions<'a>( + workspace_root: &Path, + superseded_paths: &'a [&'a str], +) -> Result<(Vec<&'a str>, Vec<&'a str>), String> { + 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 food_paths = SOURCE_SPECS + .iter() + .map(|source| source.path) + .collect::<BTreeSet<_>>(); + let nip09_paths = nip09_predecessor_production_source_paths_under_lock(workspace_root)?; + let mut food_superseded_paths = Vec::new(); + let mut nip09_superseded_paths = Vec::new(); + for path in superseded_paths { + if food_paths.contains(path) { + food_superseded_paths.push(*path); + } + if nip09_paths.contains(*path) { + nip09_superseded_paths.push(*path); + } + if !food_paths.contains(path) && !nip09_paths.contains(*path) { + return Err(format!( + "successor supersession path `{path}` is not bound by either the FoodAvailability or NIP-09 predecessor" + )); + } + } + Ok((food_superseded_paths, nip09_superseded_paths)) +} + +fn validate_food_predecessor_source_inventory<Describe>( + manifest: &FoodAvailabilityProjectionManifest, + superseded_paths: &[&str], + mut describe: Describe, +) -> Result<(), String> +where + Describe: FnMut(SourceSpec) -> Result<SourceFileDescriptor, String>, +{ + 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 predecessor_paths = SOURCE_SPECS + .iter() + .map(|source| source.path) + .collect::<BTreeSet<_>>(); + if let Some(path) = superseded + .iter() + .find(|path| !predecessor_paths.contains(**path)) + { + return Err(format!( + "successor supersession path `{path}` is not a FoodAvailability predecessor-bound production source" + )); + } + + if manifest.source_files.len() != SOURCE_SPECS.len() { + return Err( + "immutable FoodAvailability predecessor source inventory is incomplete".to_owned(), + ); + } + for (expected, spec) in manifest.source_files.iter().zip(SOURCE_SPECS) { + if expected.role != spec.role || expected.path != spec.path { + return Err(format!( + "immutable FoodAvailability predecessor source inventory drifted at `{}`", + spec.path + )); + } + if superseded.contains(spec.path) { + continue; + } + let current = describe(*spec)?; + if current != *expected { + return Err(format!( + "unchanged FoodAvailability predecessor source authority `{}` drifted from the immutable manifest", + spec.path + )); + } + } + Ok(()) +} + +fn require_predecessor_file_match( + label: &str, + expected: &FileDescriptor, + current: &FileDescriptor, +) -> Result<(), String> { + if current != expected { + return Err(format!( + "unchanged FoodAvailability predecessor {label} `{}` drifted from the immutable manifest", + expected.path + )); + } + Ok(()) +} + +fn describe_source_file( + workspace_root: &Path, + spec: SourceSpec, +) -> Result<SourceFileDescriptor, String> { + let bytes = 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(), + }) } fn descriptor_for_file(workspace_root: &Path, relative: &str) -> Result<FileDescriptor, String> { @@ -3556,6 +3817,7 @@ fn stale_error(relative: &str) -> String { #[cfg(test)] mod tests { use super::*; + use std::fs; fn repository_root() -> std::path::PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) @@ -3565,6 +3827,228 @@ mod tests { .to_path_buf() } + fn immutable_manifest() -> FoodAvailabilityProjectionManifest { + serde_json::from_slice(IMMUTABLE_MANIFEST_BYTES) + .expect("immutable FoodAvailability manifest") + } + + fn copy_file(source_root: &Path, destination_root: &Path, relative: &str) { + let destination = destination_root.join(relative); + fs::create_dir_all(destination.parent().expect("fixture parent")) + .expect("create fixture parent"); + fs::copy(source_root.join(relative), destination).expect("copy fixture"); + } + + fn immutable_artifact_workspace() -> tempfile::TempDir { + const NIP09_ARTIFACTS: &[&str] = &[ + "crates/event_store/contracts/nip09_reconciliation_v1.manifest.json", + "crates/event_store/contracts/nip09_reconciliation_v1.manifest.schema.json", + "crates/event_store/contracts/nip09_reconciliation_v1.manifest.sha256", + "crates/event_store/src/generated/nip09_reconciliation_manifest.rs", + "contracts/conformance/vectors/event_store/nip09_reconciliation.v1.json", + "crates/event_store/tests/fixtures/nip09_reconciliation.v1.json", + "crates/event_store/src/nip09/reconciliation_v1/result_vector_executor.rs", + "crates/event_store/migrations/0001_event_store.up.sql", + "crates/event_store/migrations/0001_event_store.down.sql", + "crates/event_store/migrations/0002_nip09.up.sql", + "crates/event_store/migrations/0002_nip09.down.sql", + ]; + + let workspace = tempfile::TempDir::new().expect("workspace"); + let repository = repository_root(); + for relative in NIP09_ARTIFACTS.iter().copied().chain( + IMMUTABLE_PREDECESSOR_ARTIFACTS + .iter() + .map(|artifact| artifact.relative), + ) { + copy_file(&repository, workspace.path(), relative); + } + workspace + } + + #[test] + fn immutable_food_predecessor_artifacts_match_authenticated_identities() { + let root = repository_root(); + for artifact in IMMUTABLE_PREDECESSOR_ARTIFACTS { + let bytes = read_regular_file(&root, artifact.relative).expect("immutable artifact"); + assert_eq!(bytes.len(), artifact.byte_length, "{}", artifact.relative); + assert_eq!(sha256_hex(&bytes), artifact.sha256, "{}", artifact.relative); + } + } + + #[test] + fn legacy_writer_restores_only_generated_immutable_artifacts() { + let workspace = immutable_artifact_workspace(); + fs::write(workspace.path().join(MANIFEST_RELATIVE), b"tampered\n") + .expect("tamper manifest"); + fs::write( + workspace.path().join(RESULT_VECTOR_MIRROR_RELATIVE), + b"tampered\n", + ) + .expect("tamper result-vector mirror"); + let changed_source = workspace.path().join("crates/event_store/src/error.rs"); + fs::create_dir_all(changed_source.parent().expect("source parent")) + .expect("create source parent"); + fs::write(&changed_source, b"successor-owned source bytes\n") + .expect("write changed source"); + + write_food_availability_projection_manifest(workspace.path()) + .expect("restore immutable generated artifacts"); + assert_eq!( + fs::read(workspace.path().join(MANIFEST_RELATIVE)).expect("restored manifest"), + IMMUTABLE_MANIFEST_BYTES + ); + assert_eq!( + fs::read(workspace.path().join(RESULT_VECTOR_MIRROR_RELATIVE)) + .expect("restored result-vector mirror"), + IMMUTABLE_RESULT_VECTOR_BYTES + ); + assert_eq!( + fs::read(changed_source).expect("changed source"), + b"successor-owned source bytes\n" + ); + } + + #[test] + fn legacy_writer_cannot_rebaseline_an_authored_immutable_artifact() { + let workspace = immutable_artifact_workspace(); + fs::write( + workspace.path().join(RESULT_VECTOR_EXECUTOR_RELATIVE), + b"tampered\n", + ) + .expect("tamper executor"); + let error = write_food_availability_projection_manifest(workspace.path()) + .expect_err("immutable executor drift must fail"); + assert!( + error.contains("immutable FoodAvailability predecessor artifact") + && error.contains(RESULT_VECTOR_EXECUTOR_RELATIVE), + "{error}" + ); + } + + #[test] + fn predecessor_source_inventory_rejects_duplicate_unknown_and_unsuperseded_drift() { + let manifest = immutable_manifest(); + let descriptors = manifest.source_files.clone(); + let describe = |spec: SourceSpec| { + descriptors + .iter() + .find(|source| source.path == spec.path) + .cloned() + .ok_or_else(|| format!("missing fixture source {}", spec.path)) + }; + validate_food_predecessor_source_inventory(&manifest, &[], describe) + .expect("complete immutable predecessor inventory"); + + let path = SOURCE_SPECS[0].path; + let error = validate_food_predecessor_source_inventory(&manifest, &[path, path], |_| { + unreachable!("duplicates fail before source reads") + }) + .expect_err("duplicate supersession must fail"); + assert!(error.contains("must be unique"), "{error}"); + + let error = validate_food_predecessor_source_inventory( + &manifest, + &["crates/event_store/src/not_bound.rs"], + |_| unreachable!("unknown paths fail before source reads"), + ) + .expect_err("unknown supersession must fail"); + assert!( + error.contains("not a FoodAvailability predecessor-bound"), + "{error}" + ); + + let drift_path = SOURCE_SPECS[0].path; + let descriptors = manifest.source_files.clone(); + let error = validate_food_predecessor_source_inventory(&manifest, &[], |spec| { + let mut source = descriptors + .iter() + .find(|source| source.path == spec.path) + .cloned() + .expect("fixture source"); + if spec.path == drift_path { + source.sha256 = "00".repeat(32); + } + Ok(source) + }) + .expect_err("unsuperseded source drift must fail"); + assert!( + error.contains("unchanged FoodAvailability predecessor source authority") + && error.contains(drift_path), + "{error}" + ); + + let descriptors = manifest.source_files.clone(); + validate_food_predecessor_source_inventory(&manifest, &[drift_path], |spec| { + assert_ne!(spec.path, drift_path, "superseded source must not be read"); + descriptors + .iter() + .find(|source| source.path == spec.path) + .cloned() + .ok_or_else(|| format!("missing fixture source {}", spec.path)) + }) + .expect("an explicitly superseded source is delegated to the successor"); + } + + #[test] + fn downstream_nip09_only_supersession_is_transitively_validated() { + const SOURCE_MAINTENANCE_SUPERSEDED_PATHS: &[&str] = &[ + "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/protocol_reconciliation_v1.rs", + ]; + + let root = repository_root(); + let (food_paths, nip09_paths) = partition_downstream_predecessor_supersessions( + &root, + &[ + "crates/event_store/src/error.rs", + "crates/event_store/src/store/protocol_reconciliation_v1.rs", + ], + ) + .expect("overlapping and NIP-09-only predecessor ownership"); + assert_eq!(food_paths, ["crates/event_store/src/error.rs"]); + assert_eq!( + nip09_paths, + [ + "crates/event_store/src/error.rs", + "crates/event_store/src/store/protocol_reconciliation_v1.rs", + ] + ); + validate_food_availability_projection_predecessor_production_sources_under_lock( + &root, + SOURCE_MAINTENANCE_SUPERSEDED_PATHS, + ) + .expect("Food and transitive NIP-09 successor source coverage"); + + let mut duplicate = SOURCE_MAINTENANCE_SUPERSEDED_PATHS.to_vec(); + duplicate.push("crates/event_store/src/store/protocol_reconciliation_v1.rs"); + let error = + validate_food_availability_projection_predecessor_production_sources_under_lock( + &root, &duplicate, + ) + .expect_err("duplicate transitive supersession must fail"); + assert!(error.contains("must be unique"), "{error}"); + + let mut unknown = SOURCE_MAINTENANCE_SUPERSEDED_PATHS.to_vec(); + unknown.push("crates/event_store/src/store/not_predecessor_bound.rs"); + let error = + validate_food_availability_projection_predecessor_production_sources_under_lock( + &root, &unknown, + ) + .expect_err("unknown transitive supersession must fail"); + assert!( + error.contains("not bound by either the FoodAvailability or NIP-09 predecessor"), + "{error}" + ); + } + #[test] fn food_scope_fingerprint_is_pinned() { let mut hasher = Sha256::new(); @@ -3691,138 +4175,6 @@ mod tests { } #[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( @@ -4145,45 +4497,15 @@ mod tests { } #[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, + fn immutable_public_api_inventory_is_exact() { + let mut manifest = immutable_manifest(); + assert_eq!( + manifest.public_api, + PUBLIC_API + .iter() + .map(|name| (*name).to_owned()) + .collect::<Vec<_>>() ); - 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"); @@ -4312,10 +4634,9 @@ mod tests { #[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 schema: Value = serde_json::from_slice(IMMUTABLE_MANIFEST_SCHEMA_BYTES) + .expect("immutable manifest schema"); + let manifest = immutable_manifest(); let mut value = serde_json::to_value(manifest).expect("manifest value"); value .as_object_mut() @@ -4328,12 +4649,9 @@ mod tests { #[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); + let manifest = immutable_manifest(); + let descriptor = std::str::from_utf8(IMMUTABLE_GENERATED_DESCRIPTOR_BYTES) + .expect("UTF-8 immutable generated descriptor"); assert_eq!(manifest.migration.schema_sha256, SCHEMA_SHA256); for name in [ "FOOD_AVAILABILITY_PROJECTION_MANIFEST_SCHEMA_VERSION", @@ -4362,6 +4680,6 @@ mod tests { "{name} must use the rustfmt-stable one-line assignment" ); } - syn::parse_file(&descriptor).expect("generated descriptor parses as Rust"); + 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 @@ -17,6 +17,42 @@ const SCHEMA_VERSION: u32 = 1; const HOOK_ID: &str = "nip09_reconciliation_v1"; const MIGRATION_VERSION: u32 = 2; const MIGRATION_NAME: &str = "nip09"; +const SOURCE_MAINTENANCE_MIGRATION_VERSION: u32 = 4; +const SOURCE_MAINTENANCE_MIGRATION_NAME: &str = "source_maintenance"; +const SOURCE_MAINTENANCE_PRIVILEGED_TERMINALS: [&str; 9] = [ + "apply_source_maintenance_hook_v1", + "preflight_unique_raw_source_append_v1", + "raw_source_capacity_delta_v1", + "advance_source_capacity_after_insert_v1", + "preflight_source_generation_append_v1", + "bind_source_capacity_to_generation_v1", + "validate_source_capacity_authority_fast_v1", + "validate_source_capacity_authority_full_v1", + "validate_no_persisted_ephemeral_raw_rows_v1", +]; +const PRIVILEGED_TERMINAL_NAMES: [&str; 21] = [ + "validate_event_store_temp_schema", + "validate_main_database_encoding", + "validate_rollback_preserves_source_generation_history", + "apply_migration_up", + "apply_migration_down", + "apply_migration_hook", + "validate_migration_hook_state", + "validate_active_hook_state_fast", + "ingest_event_protocol_reconciliation_v1", + "dispatch_post_core_extensions", + "apply_post_core_extensions_v1", + "validate_protocol_post_extensions", + "apply_source_maintenance_hook_v1", + "preflight_unique_raw_source_append_v1", + "raw_source_capacity_delta_v1", + "advance_source_capacity_after_insert_v1", + "preflight_source_generation_append_v1", + "bind_source_capacity_to_generation_v1", + "validate_source_capacity_authority_fast_v1", + "validate_source_capacity_authority_full_v1", + "validate_no_persisted_ephemeral_raw_rows_v1", +]; const RECONCILIATION_VERSION: u32 = 1; const ADDRESSABLE_FEED_VERSION: u32 = 1; const EVENT_CONTRACT_REGISTRY_VERSION: u32 = 7; @@ -357,6 +393,8 @@ const SUCCESSOR_08C_EXCLUSIVE_SOURCE_PATHS: [&str; 8] = [ "crates/event_store/src/store/post_core_extensions_v2.rs", "crates/event_store/src/store/post_core_storage_v2.rs", ]; +const SUCCESSOR_08D_SOURCE_PATHS: [&str; 1] = ["crates/event_store/src/source_maintenance_v1.rs"]; +const SUCCESSOR_08D_LIB_MODULES: [&str; 1] = ["source_maintenance_v1"]; const EVENT_STORE_FIXED_PUBLIC_REEXPORTS: [&str; 40] = [ "error::RadrootsEventStoreError", "error::RadrootsEventStoreReconciliationResource", @@ -433,6 +471,17 @@ const SUCCESSOR_08C_PUBLIC_REEXPORTS: [&str; 32] = [ "model::RadrootsStoredFoodAvailabilityImageV1", "model::RadrootsStoredFoodAvailabilityV1", ]; +const SUCCESSOR_08D_RETIRED_PUBLIC_REEXPORTS: [&str; 1] = + ["error::RadrootsEventStoreReconciliationResource"]; +const SUCCESSOR_08D_PUBLIC_REEXPORTS: [&str; 7] = [ + "error::RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1", + "error::RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1", + "error::RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1", + "error::RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1", + "error::RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1", + "error::RadrootsEventStoreSourceCapacityResourceV1", + "source_maintenance_v1::RadrootsEventStoreSourceCapacityV1", +]; const POST_CORE_STORAGE_METHODS: [&str; 4] = [ "new", "quarantine_trade", @@ -2285,19 +2334,22 @@ pub(super) fn validate_nip09_reconciliation_manifest_under_lock( Ok(()) } -pub(super) fn validate_nip09_predecessor_production_sources_under_lock( +pub(super) fn nip09_predecessor_production_source_paths_under_lock( workspace_root: &Path, - superseded_paths: &[&str], -) -> Result<(), String> { +) -> Result<BTreeSet<String>, 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()); - } + Ok(nip09_predecessor_production_source_paths(&manifest) + .into_iter() + .map(str::to_owned) + .collect()) +} - let mut predecessor_paths = manifest +fn nip09_predecessor_production_source_paths( + manifest: &Nip09ReconciliationManifest, +) -> BTreeSet<&str> { + let mut paths = manifest .frozen_sources .iter() .map(|source| source.path.as_str()) @@ -2327,12 +2379,28 @@ pub(super) fn validate_nip09_predecessor_production_sources_under_lock( .map(|source| source.path.as_str()), ) .collect::<BTreeSet<_>>(); - predecessor_paths.extend([ + paths.extend([ POST_CORE_CAPABILITIES_SOURCE_RELATIVE, POST_CORE_DISPATCHER_SOURCE_RELATIVE, POST_CORE_EXTENSION_SOURCE_RELATIVE, POST_CORE_STORAGE_SOURCE_RELATIVE, ]); + paths +} + +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 predecessor_paths = nip09_predecessor_production_source_paths(&manifest); if let Some(path) = superseded .iter() .find(|path| !predecessor_paths.contains(**path)) @@ -2543,7 +2611,7 @@ fn expected_manifest(workspace_root: &Path) -> Result<Nip09ReconciliationManifes validate_governed_support_source_tree_baselines(workspace_root)?; validate_route_facade_baselines(workspace_root)?; describe_post_core_extension_boundary(workspace_root, true)?; - validate_privileged_store_authority(workspace_root)?; + validate_current_event_store_successor_authority(workspace_root)?; describe_nip09_v1_manifest(workspace_root) } @@ -2673,7 +2741,7 @@ fn describe_frozen_source_bytes( spec: FrozenSourceSpec, bytes: &[u8], ) -> Result<FrozenSourceDescriptor, String> { - let canonical = canonical_rust_ast(spec.path, &bytes, RustAstProfile::Production)?; + let canonical = canonical_rust_ast(spec.path, bytes, RustAstProfile::Production)?; let file = syn::parse_file( std::str::from_utf8(&canonical) .map_err(|error| format!("{} canonical source must be UTF-8: {error}", spec.path))?, @@ -2783,7 +2851,7 @@ fn expected_event_store_migration_compiler_inputs( )); } - fn field<'a>( + fn raw_field<'a>( relative: &str, entry: &'a syn::ExprStruct, name: &str, @@ -2798,7 +2866,15 @@ fn expected_event_store_migration_compiler_inputs( "{relative} future-compatible migration entry must contain field `{name}` exactly once" )); }; - Ok(peel_expression(&field.expr)) + Ok(&field.expr) + } + + fn field<'a>( + relative: &str, + entry: &'a syn::ExprStruct, + name: &str, + ) -> Result<&'a syn::Expr, String> { + Ok(peel_expression(raw_field(relative, entry, name)?)) } fn integer_field(relative: &str, entry: &syn::ExprStruct, name: &str) -> Result<u32, String> { @@ -2886,9 +2962,9 @@ fn expected_event_store_migration_compiler_inputs( "{relative} migration {version} name must be non-empty lowercase snake_case" )); } - if version > 2 { - let expected_authority = if version == 3 && name == "food_availability_projection" { - [ + let hookless = if version > 2 { + let expected_authority = match (version, name.as_str()) { + (3, "food_availability_projection") => [ ( "hook", "EventStoreMigrationHook::FoodAvailabilityProjectionV1", @@ -2901,14 +2977,25 @@ fn expected_event_store_migration_compiler_inputs( "event_contract_registry_version", "Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION,)", ), - ] - } else { - [ + ], + (SOURCE_MAINTENANCE_MIGRATION_VERSION, SOURCE_MAINTENANCE_MIGRATION_NAME) => [ + ("hook", "EventStoreMigrationHook::SourceMaintenanceV1"), + ( + "hook_manifest_sha256", + "Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256,)", + ), + ( + "event_contract_registry_version", + "Some(source_maintenance_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION,)", + ), + ], + _ => [ ("hook", "EventStoreMigrationHook::None"), ("hook_manifest_sha256", "None"), ("event_contract_registry_version", "None"), - ] + ], }; + let hookless = expected_authority[0].1 == "EventStoreMigrationHook::None"; for (field_name, expected) in expected_authority { let actual = compact_tokens(field(relative, entry, field_name)?); if actual != expected { @@ -2917,7 +3004,19 @@ fn expected_event_store_migration_compiler_inputs( )); } } - } + if hookless { + let replacements = + compact_tokens(raw_field(relative, entry, "replaced_object_names")?); + if replacements != "&[]" { + return Err(format!( + "{relative} hookless post-v2 migration {version} must not declare predecessor replacements without separately authenticated successor authority; found `{replacements}`" + )); + } + } + hookless + } else { + false + }; for direction in ["up", "down"] { let expected_path = format!("../migrations/{version:04}_{name}.{direction}.sql"); inputs.push(include_str_field( @@ -2926,7 +3025,7 @@ fn expected_event_store_migration_compiler_inputs( &format!("{direction}_sql"), &expected_path, )?); - if version > 3 { + if version > 2 && hookless { validate_hookless_post_v2_migration_sql_isolated( workspace_root, version, @@ -3608,6 +3707,10 @@ fn protected_v1_migration_object_names(workspace_root: &Path) -> Result<BTreeSet read_regular_file(workspace_root, EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE)?; let migrations = parse_canonical_production_rust(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, &migrations_bytes)?; + validate_event_store_migrations_import_authority( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &migrations, + )?; for name in [ "EVENT_STORE_BASELINE_OBJECT_NAMES", "EVENT_STORE_BASELINE_TABLE_NAMES", @@ -5735,6 +5838,138 @@ struct PrivilegedStoreCallSite { route: String, } +fn validate_exact_top_level_imports( + relative: &str, + file: &syn::File, + expected: &[&str], +) -> Result<(), String> { + let actual = file + .items + .iter() + .filter_map(|item| match item { + syn::Item::Use(item) => Some(compact_tokens(item)), + _ => None, + }) + .collect::<Vec<_>>(); + let expected = expected + .iter() + .map(|item| compact_source_tokens(item)) + .collect::<Vec<_>>(); + if actual != expected { + return Err(format!( + "{relative} production top-level import authority drifted: expected {expected:?}, found {actual:?}" + )); + } + Ok(()) +} + +fn validate_event_store_migrations_import_authority( + relative: &str, + file: &syn::File, +) -> Result<(), String> { + validate_exact_top_level_imports( + relative, + file, + &[ + "use crate::RadrootsEventStoreError;", + "use crate::generated::food_availability_projection_manifest as food_manifest;", + "use crate::generated::nip09_reconciliation_manifest as nip09_manifest;", + "use crate::generated::source_maintenance_manifest;", + "use sha2::{Digest, Sha256};", + "use std::collections::BTreeSet;", + ], + ) +} + +fn validate_event_store_schema_import_authority( + relative: &str, + file: &syn::File, +) -> Result<(), String> { + validate_exact_top_level_imports( + relative, + file, + &[ + "use crate::RadrootsEventStoreError;", + r#"use crate::migrations::{ + EVENT_STORE_LEDGER_CREATE_DDL, EVENT_STORE_LEDGER_DDL, EVENT_STORE_LEDGER_NAME, + EVENT_STORE_MIGRATIONS, EventStoreMigration, EventStoreMigrationHook, + RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT, RADROOTS_EVENT_STORE_SCHEMA_VERSION_MIN, + is_event_store_governed_schema_name, is_event_store_owned_table_name, + migration_for_version, sqlite_identifier_starts_with, + validate_embedded_migration_registry, validate_migration_registry, + };"#, + "use sha2::{Digest, Sha256};", + "use sqlx::{Row, Sqlite, SqliteConnection, SqlitePool, Transaction};", + "use std::collections::{BTreeMap, BTreeSet};", + r#"use crate::nip09::reconciliation_v1::{ + OsSourceGenerationProvider, ReconciliationCapacityLimits, + SourceGenerationProvider, apply_reconciliation_hook, + validate_active_hook_state_fast, validate_reconciliation_capacity, + };"#, + r#"use crate::source_maintenance_v1::{ + apply_source_maintenance_hook_v1, + validate_no_persisted_ephemeral_raw_rows_v1, + validate_source_capacity_authority_full_v1, + };"#, + r#"use crate::store::food_availability_projection_v1::{ + apply_food_availability_projection_hook_v1, + validate_food_availability_projection_hook_state_fast_v1, + };"#, + ], + ) +} + +pub(super) fn validate_current_event_store_successor_authority( + workspace_root: &Path, +) -> Result<(), String> { + validate_privileged_store_authority(workspace_root)?; + + let migrations_bytes = + read_regular_file(workspace_root, EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE)?; + let migrations = + parse_canonical_production_rust(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, &migrations_bytes)?; + validate_event_store_migrations_import_authority( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &migrations, + )?; + let expected_inputs = + expected_event_store_migration_compiler_inputs(workspace_root, &migrations)?; + validate_compiler_macro_inputs( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &migrations, + &expected_inputs, + )?; + validate_migration_registry_reachability(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, &migrations)?; + validate_manifest_validator_reachability(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, &migrations)?; + validate_source_maintenance_manifest_validator_reachability( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &migrations, + )?; + validate_event_store_schema_name_matchers(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, &migrations)?; + validate_source_maintenance_migration_bindings( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &migrations, + )?; + validate_event_store_migration_support_authority( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &migrations, + )?; + + let schema_bytes = read_regular_file(workspace_root, EVENT_STORE_SCHEMA_SOURCE_RELATIVE)?; + let schema = + parse_canonical_production_rust(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &schema_bytes)?; + validate_event_store_schema_import_authority(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &schema)?; + validate_schema_runtime_reachability(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &schema)?; + validate_schema_migration_execution_authority(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &schema)?; + validate_source_maintenance_schema_dispatch(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &schema)?; + validate_source_generation_rollback_authority(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &schema)?; + + let store_bytes = read_regular_file(workspace_root, EVENT_STORE_STORE_SOURCE_RELATIVE)?; + let store = parse_canonical_production_rust(EVENT_STORE_STORE_SOURCE_RELATIVE, &store_bytes)?; + validate_sqlite_encoding_preflight_authority(EVENT_STORE_STORE_SOURCE_RELATIVE, &store)?; + validate_source_maintenance_runtime_token_authority(workspace_root) +} + fn validate_privileged_store_authority(workspace_root: &Path) -> Result<(), String> { let lib_bytes = read_regular_file(workspace_root, EVENT_STORE_LIB_SOURCE_RELATIVE)?; let lib_source = std::str::from_utf8(&lib_bytes).map_err(|error| { @@ -5859,6 +6094,22 @@ fn validate_privileged_store_authority(workspace_root: &Path) -> Result<(), Stri EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, "super::protocol_storage_v1::stored_raw_event_from_row", ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::advance_source_capacity_after_insert_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::preflight_unique_raw_source_append_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::raw_source_capacity_delta_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1", + ), ] .into_iter() .map(|(relative, route)| (relative.to_owned(), route.to_owned())) @@ -5871,45 +6122,89 @@ fn validate_privileged_store_authority(workspace_root: &Path) -> Result<(), Stri let expected_calls = [ ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "associated:source_capacity_v1", + "crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, "free:inspect_event_store_status", "crate::schema::validate_event_store_temp_schema", ), ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "free:configure_pool", + "validate_main_database_encoding", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, "free:configure_pool", "crate::schema::validate_event_store_temp_schema", ), ( + EVENT_STORE_STORE_SOURCE_RELATIVE, "free:ingest_event_in_transaction", "crate::schema::validate_event_store_temp_schema", ), ( + EVENT_STORE_STORE_SOURCE_RELATIVE, "free:ingest_event_in_transaction", "ingest_event_protocol_reconciliation_v1", ), ( + EVENT_STORE_STORE_SOURCE_RELATIVE, "free:ingest_event_in_transaction", "PostCoreExtensionCapabilities::new", ), ( + EVENT_STORE_STORE_SOURCE_RELATIVE, "free:ingest_event_in_transaction", "dispatch_post_core_extensions", ), ( + EVENT_STORE_STORE_SOURCE_RELATIVE, "free:ingest_event_in_transaction", "validate_protocol_post_extensions", ), - ("associated:apply_v1", "PostCoreStorageV1::new"), - ("associated:apply_v1", "apply_post_core_extensions_v1"), + ( + POST_CORE_CAPABILITIES_SOURCE_RELATIVE, + "associated:apply_v1", + "PostCoreStorageV1::new", + ), + ( + POST_CORE_CAPABILITIES_SOURCE_RELATIVE, + "associated:apply_v1", + "apply_post_core_extensions_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:ingest_event_protocol_reconciliation_v1", + "validate_source_capacity_authority_fast_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:ingest_event_protocol_reconciliation_v1", + "raw_source_capacity_delta_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:ingest_event_protocol_reconciliation_v1", + "preflight_unique_raw_source_append_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:ingest_event_protocol_reconciliation_v1", + "advance_source_capacity_after_insert_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:read_protocol_post_extension_authority_seal", + "validate_source_capacity_authority_fast_v1", + ), ] .into_iter() - .enumerate() - .map(|(index, (function, route))| PrivilegedStoreCallSite { - relative: if index < 7 { - EVENT_STORE_STORE_SOURCE_RELATIVE - } else { - POST_CORE_CAPABILITIES_SOURCE_RELATIVE - } - .to_owned(), + .map(|(relative, function, route)| PrivilegedStoreCallSite { + relative: relative.to_owned(), function: function.to_owned(), route: route.to_owned(), }) @@ -5933,41 +6228,50 @@ 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(); + let mut imports = Vec::new(); for relative in source_paths { let bytes = read_regular_file(workspace_root, &relative)?; let file = parse_canonical_production_rust(&relative, &bytes)?; - let expected_macro_inputs = match relative.as_str() { - EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE => { - expected_event_store_migration_compiler_inputs(workspace_root, &file)? - } - RESULT_VECTOR_EXECUTOR_RELATIVE => [ - "include_bytes!(\"../../../tests/fixtures/nip09_reconciliation.v1.json\")", - "include_str!(\"../../../migrations/0001_event_store.up.sql\")", - "include_str!(\"../../../migrations/0002_nip09.up.sql\")", - ] - .map(str::to_owned) - .to_vec(), - _ => Vec::new(), - }; - validate_compiler_macro_inputs(&relative, &file, &expected_macro_inputs)?; - validate_event_store_module_source_graph(&relative, &file)?; - validate_event_store_trait_impl_authority(&relative, &file)?; + if !SUCCESSOR_08C_EXCLUSIVE_SOURCE_PATHS.contains(&relative.as_str()) { + let expected_macro_inputs = match relative.as_str() { + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE => { + expected_event_store_migration_compiler_inputs(workspace_root, &file)? + } + RESULT_VECTOR_EXECUTOR_RELATIVE => [ + "include_bytes!(\"../../../tests/fixtures/nip09_reconciliation.v1.json\")", + "include_str!(\"../../../migrations/0001_event_store.up.sql\")", + "include_str!(\"../../../migrations/0002_nip09.up.sql\")", + ] + .map(str::to_owned) + .to_vec(), + _ => Vec::new(), + }; + validate_compiler_macro_inputs(&relative, &file, &expected_macro_inputs)?; + validate_event_store_module_source_graph(&relative, &file)?; + validate_event_store_trait_impl_authority(&relative, &file)?; + } let mut audit = PrivilegedTerminalAudit { relative: &relative, current_function: None, direct_callee: false, + scope_depth: 0, definitions: Vec::new(), calls: Vec::new(), + imports: Vec::new(), error: None, }; syn::visit::Visit::visit_file(&mut audit, &file); - let (mut file_definitions, mut file_calls) = audit.finish()?; + let PrivilegedTerminalAuthority { + definitions: mut file_definitions, + calls: mut file_calls, + imports: mut file_imports, + } = audit.finish()?; definitions.append(&mut file_definitions); calls.append(&mut file_calls); + imports.append(&mut file_imports); } definitions.sort(); calls.sort_by(|left, right| { @@ -5977,61 +6281,182 @@ fn validate_event_store_privileged_terminal_authority(workspace_root: &Path) -> &right.route, )) }); + imports.sort(); - let mut expected_definitions = [ + let mut expected_imports = [ + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "self::post_core_extension_dispatcher::dispatch_post_core_extensions", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "self::protocol_reconciliation_v1::ingest_event_protocol_reconciliation_v1", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "self::protocol_reconciliation_v1::validate_protocol_post_extensions", + ), + ( + POST_CORE_CAPABILITIES_SOURCE_RELATIVE, + "super::post_core_extensions_v1::apply_post_core_extensions_v1", + ), ( EVENT_STORE_SCHEMA_SOURCE_RELATIVE, - "validate_event_store_temp_schema", + "crate::nip09::reconciliation_v1::validate_active_hook_state_fast", ), ( - POST_CORE_DISPATCHER_SOURCE_RELATIVE, - "dispatch_post_core_extensions", + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "crate::source_maintenance_v1::apply_source_maintenance_hook_v1", ), ( - POST_CORE_EXTENSION_SOURCE_RELATIVE, - "apply_post_core_extensions_v1", + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "crate::source_maintenance_v1::validate_no_persisted_ephemeral_raw_rows_v1", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "crate::source_maintenance_v1::validate_source_capacity_authority_full_v1", ), ( EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, - "ingest_event_protocol_reconciliation_v1", + "crate::source_maintenance_v1::advance_source_capacity_after_insert_v1", ), ( EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, - "validate_protocol_post_extensions", + "crate::source_maintenance_v1::preflight_unique_raw_source_append_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::raw_source_capacity_delta_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1", ), ] .into_iter() - .map(|(relative, name)| PrivilegedTerminalDefinition { - relative: relative.to_owned(), - name: name.to_owned(), - }) + .map(|(relative, route)| (relative.to_owned(), route.to_owned())) .collect::<Vec<_>>(); - expected_definitions.sort(); - if definitions != expected_definitions { + expected_imports.sort(); + if imports != expected_imports { return Err(format!( - "event-store privileged terminal definitions drifted: expected {expected_definitions:?}, found {definitions:?}" + "event-store SourceMaintenance privileged import authority drifted: expected {expected_imports:?}, found {imports:?}" )); } - let mut expected_calls = [ + let mut expected_definitions = [ + (EVENT_STORE_SCHEMA_SOURCE_RELATIVE, "apply_migration_down"), + (EVENT_STORE_SCHEMA_SOURCE_RELATIVE, "apply_migration_hook"), + (EVENT_STORE_SCHEMA_SOURCE_RELATIVE, "apply_migration_up"), ( - EVENT_STORE_STORE_SOURCE_RELATIVE, - "free:configure_pool", - "crate::schema::validate_event_store_temp_schema", + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "validate_event_store_temp_schema", ), ( - EVENT_STORE_STORE_SOURCE_RELATIVE, - "free:ingest_event_in_transaction", - "PostCoreExtensionCapabilities::new", + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "validate_migration_hook_state", ), ( - EVENT_STORE_STORE_SOURCE_RELATIVE, - "free:ingest_event_in_transaction", - "dispatch_post_core_extensions", + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "validate_rollback_preserves_source_generation_history", ), ( EVENT_STORE_STORE_SOURCE_RELATIVE, - "free:ingest_event_in_transaction", + "validate_main_database_encoding", + ), + ( + "crates/event_store/src/nip09/reconciliation_v1.rs", + "validate_active_hook_state_fast", + ), + ( + POST_CORE_DISPATCHER_SOURCE_RELATIVE, + "dispatch_post_core_extensions", + ), + ( + POST_CORE_EXTENSION_SOURCE_RELATIVE, + "apply_post_core_extensions_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "ingest_event_protocol_reconciliation_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "validate_protocol_post_extensions", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "advance_source_capacity_after_insert_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "apply_source_maintenance_hook_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "bind_source_capacity_to_generation_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "preflight_source_generation_append_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "preflight_unique_raw_source_append_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "raw_source_capacity_delta_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "validate_no_persisted_ephemeral_raw_rows_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "validate_source_capacity_authority_fast_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "validate_source_capacity_authority_full_v1", + ), + ] + .into_iter() + .map(|(relative, name)| PrivilegedTerminalDefinition { + relative: relative.to_owned(), + name: name.to_owned(), + }) + .collect::<Vec<_>>(); + expected_definitions.sort(); + if definitions != expected_definitions { + return Err(format!( + "event-store privileged terminal definitions drifted: expected {expected_definitions:?}, found {definitions:?}" + )); + } + + let mut expected_calls = [ + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "free:configure_pool", + "validate_main_database_encoding", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "free:configure_pool", + "crate::schema::validate_event_store_temp_schema", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "free:ingest_event_in_transaction", + "PostCoreExtensionCapabilities::new", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "free:ingest_event_in_transaction", + "dispatch_post_core_extensions", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "free:ingest_event_in_transaction", "crate::schema::validate_event_store_temp_schema", ), ( @@ -6059,6 +6484,146 @@ fn validate_event_store_privileged_terminal_authority(workspace_root: &Path) -> "associated:apply_v1", "apply_post_core_extensions_v1", ), + ( + "crates/event_store/src/nip09/reconciliation_v1.rs", + "free:apply_reconciliation_hook", + "crate::source_maintenance_v1::bind_source_capacity_to_generation_v1", + ), + ( + "crates/event_store/src/nip09/reconciliation_v1.rs", + "free:apply_reconciliation_hook", + "crate::source_maintenance_v1::preflight_source_generation_append_v1", + ), + ( + "crates/event_store/src/nip09/reconciliation_v1.rs", + "free:apply_reconciliation_hook", + "validate_active_hook_state_fast", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:apply_migration_hook", + "apply_source_maintenance_hook_v1", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:migrate_event_store_schema_with_registry_and_generation_provider", + "validate_no_persisted_ephemeral_raw_rows_v1", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:migrate_schema_on_connection", + "apply_migration_hook", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:migrate_schema_on_connection", + "apply_migration_up", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:migrate_schema_on_connection", + "apply_migration_up", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:migrate_schema_on_connection", + "validate_no_persisted_ephemeral_raw_rows_v1", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:rollback_schema_on_connection", + "apply_migration_down", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:rollback_schema_on_connection", + "validate_rollback_preserves_source_generation_history", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:validate_applied_migration_hooks", + "validate_migration_hook_state", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:validate_migration_hook_state", + "validate_active_hook_state_fast", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "free:validate_migration_hook_state", + "validate_source_capacity_authority_full_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "free:advance_source_capacity_after_insert_v1", + "validate_source_capacity_authority_fast_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "free:apply_source_maintenance_hook_v1", + "validate_no_persisted_ephemeral_raw_rows_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "free:apply_source_maintenance_hook_v1", + "validate_source_capacity_authority_full_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "free:bind_source_capacity_to_generation_v1", + "validate_source_capacity_authority_fast_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "free:preflight_source_generation_append_v1", + "validate_source_capacity_authority_fast_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "free:preflight_unique_raw_source_append_v1", + "validate_source_capacity_authority_fast_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "free:validate_source_capacity_authority_full_v1", + "validate_no_persisted_ephemeral_raw_rows_v1", + ), + ( + "crates/event_store/src/source_maintenance_v1.rs", + "free:validate_source_capacity_authority_full_v1", + "validate_source_capacity_authority_fast_v1", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "associated:source_capacity_v1", + "crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:ingest_event_protocol_reconciliation_v1", + "advance_source_capacity_after_insert_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:ingest_event_protocol_reconciliation_v1", + "preflight_unique_raw_source_append_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:ingest_event_protocol_reconciliation_v1", + "raw_source_capacity_delta_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:ingest_event_protocol_reconciliation_v1", + "validate_source_capacity_authority_fast_v1", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "free:read_protocol_post_extension_authority_seal", + "validate_source_capacity_authority_fast_v1", + ), ] .into_iter() .map(|(relative, function, route)| PrivilegedStoreCallSite { @@ -6174,7 +6739,7 @@ fn validate_event_store_trait_impl_authority( "crates/event_store/src/error.rs" => &[ ( "core::fmt::Display", - "RadrootsEventStoreReconciliationResource", + "RadrootsEventStoreSourceCapacityResourceV1", ), ("From<RadrootsTransportError>", "RadrootsEventStoreError"), ], @@ -6200,7 +6765,7 @@ fn validate_event_store_trait_impl_authority( )); } let expected_inherent_self_types: &[&str] = match relative { - "crates/event_store/src/error.rs" => &["RadrootsEventStoreReconciliationResource"], + "crates/event_store/src/error.rs" => &["RadrootsEventStoreSourceCapacityResourceV1"], EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE => &["EventStoreMigrationHook"], "crates/event_store/src/model.rs" => &[ "RadrootsTransportObservationMessage", @@ -6230,6 +6795,9 @@ fn validate_event_store_trait_impl_authority( "TransitionOrigin", ], EVENT_STORE_STORE_SOURCE_RELATIVE => &["RadrootsEventStore"], + "crates/event_store/src/source_maintenance_v1.rs" => { + &["RadrootsEventStoreSourceCapacityV1"] + } POST_CORE_CAPABILITIES_SOURCE_RELATIVE => &["PostCoreExtensionCapabilities<'borrow,'db>"], POST_CORE_STORAGE_SOURCE_RELATIVE => { &["TradeProjectionWrite<'a>", "PostCoreStorageV1<'borrow,'db>"] @@ -6273,6 +6841,32 @@ fn validate_event_store_trait_impl_authority( &food_manifest_arm.body, "Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256)", )?; + let source_id_arm = exact_associated_match_arm( + relative, + file, + "EventStoreMigrationHook", + "id", + "SourceMaintenanceV1", + )?; + validate_exact_arm_expression( + relative, + "EventStoreMigrationHook::id SourceMaintenanceV1 arm", + &source_id_arm.body, + "source_maintenance_manifest::SOURCE_MAINTENANCE_HOOK_ID", + )?; + let source_manifest_arm = exact_associated_match_arm( + relative, + file, + "EventStoreMigrationHook", + "manifest_sha256", + "SourceMaintenanceV1", + )?; + validate_exact_arm_expression( + relative, + "EventStoreMigrationHook::manifest_sha256 SourceMaintenanceV1 arm", + &source_manifest_arm.body, + "Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256)", + )?; let migration_impls = file .items @@ -6294,6 +6888,7 @@ fn validate_event_store_trait_impl_authority( }; let mut predecessor_projection = (*migration_impl).clone(); let mut removed_food_arms = 0usize; + let mut removed_source_maintenance_arms = 0usize; for item in &mut predecessor_projection.items { let syn::ImplItem::Fn(function) = item else { continue; @@ -6310,6 +6905,14 @@ fn validate_event_store_trait_impl_authority( .cloned() .collect(); removed_food_arms += before - expression.arms.len(); + let before = expression.arms.len(); + expression.arms = expression + .arms + .iter() + .filter(|arm| !syntax_contains_ident(&arm.pat, "SourceMaintenanceV1")) + .cloned() + .collect(); + removed_source_maintenance_arms += before - expression.arms.len(); } } if removed_food_arms != 2 { @@ -6317,6 +6920,11 @@ fn validate_event_store_trait_impl_authority( "{relative} successor migration hook impl must add exactly two FoodAvailabilityProjectionV1 arms; found {removed_food_arms}" )); } + if removed_source_maintenance_arms != 2 { + return Err(format!( + "{relative} authenticated SourceMaintenance migration hook impl must add exactly two SourceMaintenanceV1 arms; found {removed_source_maintenance_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 { @@ -6332,22 +6940,29 @@ struct PrivilegedTerminalAudit<'a> { relative: &'a str, current_function: Option<String>, direct_callee: bool, + scope_depth: usize, definitions: Vec<PrivilegedTerminalDefinition>, calls: Vec<PrivilegedStoreCallSite>, + imports: Vec<(String, String)>, error: Option<String>, } +struct PrivilegedTerminalAuthority { + definitions: Vec<PrivilegedTerminalDefinition>, + calls: Vec<PrivilegedStoreCallSite>, + imports: Vec<(String, String)>, +} + impl PrivilegedTerminalAudit<'_> { - fn finish( - self, - ) -> Result< - ( - Vec<PrivilegedTerminalDefinition>, - Vec<PrivilegedStoreCallSite>, - ), - String, - > { - self.error.map_or(Ok((self.definitions, self.calls)), Err) + fn finish(self) -> Result<PrivilegedTerminalAuthority, String> { + if let Some(error) = self.error { + return Err(error); + } + Ok(PrivilegedTerminalAuthority { + definitions: self.definitions, + calls: self.calls, + imports: self.imports, + }) } fn fail(&mut self, reason: impl Into<String>) { @@ -6365,6 +6980,14 @@ impl<'ast> syn::visit::Visit<'ast> for PrivilegedTerminalAudit<'_> { fn visit_item_fn(&mut self, function: &'ast syn::ItemFn) { let name = function.sig.ident.to_string(); if is_privileged_terminal(&name) { + if self.current_function.is_some() + || !is_authoritative_privileged_terminal_definition(self.relative, &name) + { + self.fail(format!( + "shadows privileged authority with function `{name}`" + )); + return; + } self.definitions.push(PrivilegedTerminalDefinition { relative: self.relative.to_owned(), name: name.clone(), @@ -6376,6 +6999,13 @@ impl<'ast> syn::visit::Visit<'ast> for PrivilegedTerminalAudit<'_> { } fn visit_impl_item_fn(&mut self, function: &'ast syn::ImplItemFn) { + if is_privileged_terminal(&function.sig.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with associated function `{}`", + function.sig.ident + )); + return; + } let previous = self .current_function .replace(format!("associated:{}", function.sig.ident)); @@ -6384,6 +7014,13 @@ impl<'ast> syn::visit::Visit<'ast> for PrivilegedTerminalAudit<'_> { } fn visit_trait_item_fn(&mut self, function: &'ast syn::TraitItemFn) { + if is_privileged_terminal(&function.sig.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with trait function `{}`", + function.sig.ident + )); + return; + } let previous = self .current_function .replace(format!("trait:{}", function.sig.ident)); @@ -6394,89 +7031,216 @@ impl<'ast> syn::visit::Visit<'ast> for PrivilegedTerminalAudit<'_> { fn visit_item_use(&mut self, item_use: &'ast syn::ItemUse) { let mut routes = Vec::new(); flatten_use_tree("", &item_use.tree, &mut routes); + if let Some(route) = routes.iter().find(|route| route.ends_with("::*")) { + self.fail(format!( + "uses unauditable glob import `{route}` in privileged source scope" + )); + return; + } if let Some(route) = routes.iter().find(|route| { let source = route .split_once(" as ") .map_or(route.as_str(), |(source, _)| source); route.contains(" as ") - && use_route_local_binding(source) + && (use_route_local_binding(source) .is_some_and(is_privileged_terminal_or_storage_type) + || use_route_local_binding(route) + .is_some_and(is_privileged_terminal_or_storage_type)) }) { self.fail(format!( "aliases or reexports privileged source terminal through `{route}`" )); return; } - syn::visit::visit_item_use(self, item_use); - } - - fn visit_expr_call(&mut self, expression: &'ast syn::ExprCall) { - if let Some(route) = direct_expression_call_route(expression) - && (route - .rsplit("::") - .next() - .is_some_and(is_privileged_terminal) - || route - .split("::") - .collect::<Vec<_>>() - .windows(2) - .any(|segments| { - segments == ["PostCoreExtensionCapabilities", "new"] - || segments == ["PostCoreStorageV1", "new"] - })) + for route in routes + .iter() + .filter(|route| use_route_local_binding(route).is_some_and(is_privileged_terminal)) { - let Some(function) = self.current_function.clone() else { + if self.scope_depth != 0 + || !is_inherited_visibility(&item_use.vis) + || !item_use.attrs.is_empty() + || !is_approved_privileged_terminal_import(self.relative, route) + { self.fail(format!( - "calls privileged terminal `{route}` outside a function" + "privileged terminal import `{route}` must be an exact private top-level approved route" )); return; - }; - self.calls.push(PrivilegedStoreCallSite { - relative: self.relative.to_owned(), - function, - route, - }); - let previous = self.direct_callee; - self.direct_callee = true; - syn::visit::Visit::visit_expr(self, expression.func.as_ref()); - self.direct_callee = previous; - for argument in &expression.args { - syn::visit::Visit::visit_expr(self, argument); } - return; + self.imports + .push((self.relative.to_owned(), route.to_owned())); } - syn::visit::visit_expr_call(self, expression); - } - - fn visit_expr_path(&mut self, expression: &'ast syn::ExprPath) { - if expression - .path - .segments - .last() - .is_some_and(|segment| is_privileged_terminal(&segment.ident.to_string())) - && !self.direct_callee - { + if let Some(route) = routes.iter().find(|route| { + use_route_local_binding(route).is_some_and(is_privileged_terminal_or_storage_type) + && (self.scope_depth != 0 || !is_inherited_visibility(&item_use.vis)) + }) { self.fail(format!( - "takes or aliases privileged terminal value `{}`", - compact_tokens(expression) + "privileged terminal import `{route}` must remain private and top-level" )); return; } - syn::visit::visit_expr_path(self, expression); + syn::visit::visit_item_use(self, item_use); } - fn visit_macro(&mut self, item: &'ast syn::Macro) { - if [ - "validate_event_store_temp_schema", - "ingest_event_protocol_reconciliation_v1", - "dispatch_post_core_extensions", - "apply_post_core_extensions_v1", - "validate_protocol_post_extensions", - "PostCoreExtensionCapabilities", - "PostCoreStorageV1", - ] - .iter() - .any(|name| syntax_contains_ident(item, name)) + fn visit_block(&mut self, block: &'ast syn::Block) { + self.scope_depth += 1; + syn::visit::visit_block(self, block); + self.scope_depth -= 1; + } + + fn visit_item_mod(&mut self, module: &'ast syn::ItemMod) { + if is_privileged_terminal(&module.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with module `{}`", + module.ident + )); + return; + } + if module.content.is_some() { + self.scope_depth += 1; + syn::visit::visit_item_mod(self, module); + self.scope_depth -= 1; + } else { + syn::visit::visit_item_mod(self, module); + } + } + + fn visit_pat_ident(&mut self, pattern: &'ast syn::PatIdent) { + if is_privileged_terminal(&pattern.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with binding `{}`", + pattern.ident + )); + return; + } + syn::visit::visit_pat_ident(self, pattern); + } + + fn visit_item_const(&mut self, item: &'ast syn::ItemConst) { + if is_privileged_terminal(&item.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with const `{}`", + item.ident + )); + return; + } + syn::visit::visit_item_const(self, item); + } + + fn visit_item_static(&mut self, item: &'ast syn::ItemStatic) { + if is_privileged_terminal(&item.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with static `{}`", + item.ident + )); + return; + } + syn::visit::visit_item_static(self, item); + } + + fn visit_item_type(&mut self, item: &'ast syn::ItemType) { + if is_privileged_terminal(&item.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with type alias `{}`", + item.ident + )); + return; + } + syn::visit::visit_item_type(self, item); + } + + fn visit_item_struct(&mut self, item: &'ast syn::ItemStruct) { + if is_privileged_terminal(&item.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with struct constructor `{}`", + item.ident + )); + return; + } + syn::visit::visit_item_struct(self, item); + } + + fn visit_item_enum(&mut self, item: &'ast syn::ItemEnum) { + if is_privileged_terminal(&item.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with enum `{}`", + item.ident + )); + return; + } + syn::visit::visit_item_enum(self, item); + } + + fn visit_item_union(&mut self, item: &'ast syn::ItemUnion) { + if is_privileged_terminal(&item.ident.to_string()) { + self.fail(format!( + "shadows privileged authority with union `{}`", + item.ident + )); + return; + } + syn::visit::visit_item_union(self, item); + } + + fn visit_expr_call(&mut self, expression: &'ast syn::ExprCall) { + if let Some(route) = direct_expression_call_route(expression) + && (route + .rsplit("::") + .next() + .is_some_and(is_privileged_terminal) + || route + .split("::") + .collect::<Vec<_>>() + .windows(2) + .any(|segments| { + segments == ["PostCoreExtensionCapabilities", "new"] + || segments == ["PostCoreStorageV1", "new"] + })) + { + let Some(function) = self.current_function.clone() else { + self.fail(format!( + "calls privileged terminal `{route}` outside a function" + )); + return; + }; + self.calls.push(PrivilegedStoreCallSite { + relative: self.relative.to_owned(), + function, + route, + }); + let previous = self.direct_callee; + self.direct_callee = true; + syn::visit::Visit::visit_expr(self, expression.func.as_ref()); + self.direct_callee = previous; + for argument in &expression.args { + syn::visit::Visit::visit_expr(self, argument); + } + return; + } + syn::visit::visit_expr_call(self, expression); + } + + fn visit_expr_path(&mut self, expression: &'ast syn::ExprPath) { + if expression + .path + .segments + .last() + .is_some_and(|segment| is_privileged_terminal(&segment.ident.to_string())) + && !self.direct_callee + { + self.fail(format!( + "takes or aliases privileged terminal value `{}`", + compact_tokens(expression) + )); + return; + } + syn::visit::visit_expr_path(self, expression); + } + + fn visit_macro(&mut self, item: &'ast syn::Macro) { + if PRIVILEGED_TERMINAL_NAMES + .iter() + .chain(["PostCoreExtensionCapabilities", "PostCoreStorageV1"].iter()) + .any(|name| syntax_contains_ident(item, name)) { self.fail(format!( "references privileged terminal through macro `{}`", @@ -6489,14 +7253,7 @@ impl<'ast> syn::visit::Visit<'ast> for PrivilegedTerminalAudit<'_> { } fn is_privileged_terminal(name: &str) -> bool { - matches!( - name, - "validate_event_store_temp_schema" - | "ingest_event_protocol_reconciliation_v1" - | "dispatch_post_core_extensions" - | "apply_post_core_extensions_v1" - | "validate_protocol_post_extensions" - ) + PRIVILEGED_TERMINAL_NAMES.contains(&name) } fn is_privileged_terminal_or_storage_type(name: &str) -> bool { @@ -6504,6 +7261,95 @@ fn is_privileged_terminal_or_storage_type(name: &str) -> bool { || matches!(name, "PostCoreExtensionCapabilities" | "PostCoreStorageV1") } +fn is_authoritative_privileged_terminal_definition(relative: &str, name: &str) -> bool { + matches!( + (relative, name), + (EVENT_STORE_SCHEMA_SOURCE_RELATIVE, "apply_migration_down") + | (EVENT_STORE_SCHEMA_SOURCE_RELATIVE, "apply_migration_hook") + | (EVENT_STORE_SCHEMA_SOURCE_RELATIVE, "apply_migration_up") + | ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "validate_event_store_temp_schema" + ) + | ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "validate_migration_hook_state" + ) + | ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "validate_rollback_preserves_source_generation_history" + ) + | ( + "crates/event_store/src/nip09/reconciliation_v1.rs", + "validate_active_hook_state_fast" + ) + | ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "validate_main_database_encoding" + ) + | ( + POST_CORE_DISPATCHER_SOURCE_RELATIVE, + "dispatch_post_core_extensions" + ) + | ( + POST_CORE_EXTENSION_SOURCE_RELATIVE, + "apply_post_core_extensions_v1" + ) + | ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "ingest_event_protocol_reconciliation_v1" + ) + | ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "validate_protocol_post_extensions" + ) + ) || (relative == "crates/event_store/src/source_maintenance_v1.rs" + && SOURCE_MAINTENANCE_PRIVILEGED_TERMINALS.contains(&name)) +} + +fn is_approved_privileged_terminal_import(relative: &str, route: &str) -> bool { + matches!( + (relative, route), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "self::post_core_extension_dispatcher::dispatch_post_core_extensions" + ) | ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "self::protocol_reconciliation_v1::ingest_event_protocol_reconciliation_v1" + ) | ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "self::protocol_reconciliation_v1::validate_protocol_post_extensions" + ) | ( + POST_CORE_CAPABILITIES_SOURCE_RELATIVE, + "super::post_core_extensions_v1::apply_post_core_extensions_v1" + ) | ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "crate::nip09::reconciliation_v1::validate_active_hook_state_fast" + ) | ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "crate::source_maintenance_v1::apply_source_maintenance_hook_v1" + ) | ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "crate::source_maintenance_v1::validate_no_persisted_ephemeral_raw_rows_v1" + ) | ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "crate::source_maintenance_v1::validate_source_capacity_authority_full_v1" + ) | ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::advance_source_capacity_after_insert_v1" + ) | ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::preflight_unique_raw_source_append_v1" + ) | ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::raw_source_capacity_delta_v1" + ) | ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1" + ) + ) +} + fn validate_event_store_lib_resolution_authority( relative: &str, file: &syn::File, @@ -6552,7 +7398,21 @@ fn validate_event_store_lib_resolution_authority( )); } } - let required_module_names = required_modules.into_iter().collect::<BTreeSet<_>>(); + let predecessor_module_names = required_modules.into_iter().collect::<BTreeSet<_>>(); + if !predecessor_module_names.is_subset( + &module_names + .iter() + .map(String::as_str) + .collect::<BTreeSet<_>>(), + ) { + return Err(format!( + "{relative} must retain every predecessor-governed private `sqlite` module; found {module_names:?}" + )); + } + let required_module_names = predecessor_module_names + .into_iter() + .chain(SUCCESSOR_08D_LIB_MODULES) + .collect::<BTreeSet<_>>(); if module_names .iter() .map(String::as_str) @@ -6560,7 +7420,7 @@ fn validate_event_store_lib_resolution_authority( != required_module_names { return Err(format!( - "{relative} must contain exactly the seven governed private `sqlite` modules; found {module_names:?}" + "{relative} must contain exactly the predecessor modules plus the authenticated SourceMaintenance private `sqlite` module; found {module_names:?}" )); } @@ -6615,8 +7475,11 @@ fn validate_event_store_lib_resolution_authority( "{relative} reexports duplicate local binding `{binding}`" )); } - if !EVENT_STORE_FIXED_PUBLIC_REEXPORTS.contains(&route.as_str()) + let inherited_current = EVENT_STORE_FIXED_PUBLIC_REEXPORTS.contains(&route.as_str()) + && !SUCCESSOR_08D_RETIRED_PUBLIC_REEXPORTS.contains(&route.as_str()); + if !inherited_current && !SUCCESSOR_08C_PUBLIC_REEXPORTS.contains(&route.as_str()) + && !SUCCESSOR_08D_PUBLIC_REEXPORTS.contains(&route.as_str()) { return Err(format!( "{relative} public export inventory is closed for this contract version; found unsupported reexport `{route}`" @@ -6627,7 +7490,9 @@ fn validate_event_store_lib_resolution_authority( } let expected_uses = EVENT_STORE_FIXED_PUBLIC_REEXPORTS .into_iter() + .filter(|route| !SUCCESSOR_08D_RETIRED_PUBLIC_REEXPORTS.contains(route)) .chain(SUCCESSOR_08C_PUBLIC_REEXPORTS) + .chain(SUCCESSOR_08D_PUBLIC_REEXPORTS) .map(str::to_owned) .collect::<BTreeSet<_>>(); let actual_use_set = actual_uses.iter().cloned().collect::<BTreeSet<_>>(); @@ -6784,8 +7649,12 @@ fn is_privileged_store_import_route(route: &str) -> bool { ] .iter() .any(|segment| source_route.split("::").any(|actual| actual == *segment)) - || use_route_local_binding(source_route).is_some_and(is_governed_store_import_binding) - || use_route_local_binding(route).is_some_and(is_governed_store_import_binding) + || use_route_local_binding(source_route).is_some_and(|binding| { + is_governed_store_import_binding(binding) || is_privileged_terminal(binding) + }) + || use_route_local_binding(route).is_some_and(|binding| { + is_governed_store_import_binding(binding) || is_privileged_terminal(binding) + }) || route.ends_with("::*") } @@ -6821,14 +7690,7 @@ fn is_governed_store_import_binding(binding: &str) -> bool { } fn is_privileged_store_value_binding(binding: &str) -> bool { - matches!( - binding, - "dispatch_post_core_extensions" - | "apply_post_core_extensions_v1" - | "ingest_event_protocol_reconciliation_v1" - | "validate_event_store_temp_schema" - | "validate_protocol_post_extensions" - ) + is_privileged_terminal(binding) } struct PrivilegedStoreReferenceAudit<'a> { @@ -7215,6 +8077,9 @@ fn is_authoritative_privileged_store_definition(relative: &str, name: &str) -> b matches!( (relative, name), ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "validate_main_database_encoding" + ) | ( POST_CORE_DISPATCHER_SOURCE_RELATIVE, "dispatch_post_core_extensions" ) | ( @@ -7265,35 +8130,24 @@ fn is_allowed_privileged_store_attribute(attribute: &syn::Attribute) -> bool { } fn is_privileged_store_call_route(route: &str) -> bool { - matches!( - route.rsplit("::").next(), - Some( - "ingest_event_protocol_reconciliation_v1" - | "dispatch_post_core_extensions" - | "apply_post_core_extensions_v1" - | "validate_protocol_post_extensions" - | "validate_event_store_temp_schema" - ) - ) || route - .split("::") - .collect::<Vec<_>>() - .windows(2) - .any(|segments| { - segments == ["PostCoreExtensionCapabilities", "new"] - || segments == ["PostCoreStorageV1", "new"] - }) + route + .rsplit("::") + .next() + .is_some_and(is_privileged_terminal) + || route + .split("::") + .collect::<Vec<_>>() + .windows(2) + .any(|segments| { + segments == ["PostCoreExtensionCapabilities", "new"] + || segments == ["PostCoreStorageV1", "new"] + }) } fn expression_contains_privileged_store_authority(node: &impl ToTokens) -> bool { - [ - "ingest_event_protocol_reconciliation_v1", - "dispatch_post_core_extensions", - "apply_post_core_extensions_v1", - "validate_protocol_post_extensions", - "validate_event_store_temp_schema", - ] - .iter() - .any(|ident| syntax_contains_ident(node, ident)) + PRIVILEGED_TERMINAL_NAMES + .iter() + .any(|ident| syntax_contains_ident(node, ident)) || (["PostCoreExtensionCapabilities", "PostCoreStorageV1"] .iter() .any(|name| syntax_contains_ident(node, name)) @@ -10984,9 +11838,10 @@ fn validate_migration_registry_reachability( 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 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 food_manifest_guard = "if registry.iter().any(|migration|{migration.hook==EventStoreMigrationHook::FoodAvailabilityProjectionV1}){validate_generated_food_availability_projection_manifest_descriptor()?;}"; + let source_maintenance_manifest_guard = "if registry.iter().any(|migration|migration.hook==EventStoreMigrationHook::SourceMaintenanceV1){validate_generated_source_maintenance_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() == 10 + let valid = statements.len() == 12 && statements.first().is_some_and(|statement| { compact_tokens(statement) == compact_source_tokens(ledger_guard) }) @@ -10994,41 +11849,50 @@ fn validate_migration_registry_reachability( 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) + compact_tokens(statement) == compact_source_tokens(food_manifest_guard) }) && statements.get(3).is_some_and(|statement| { + compact_tokens(statement) == compact_source_tokens(source_maintenance_manifest_guard) + }) + && statements.get(4).is_some_and(|statement| { compact_tokens(statement) == compact_source_tokens(range_guard) }) && matches!( - statements.get(4), + statements.get(5), Some(syn::Stmt::Local(local)) if local_pattern_ident(&local.pat).as_deref() == Some("expected_version") ) && matches!( - statements.get(5), + statements.get(6), Some(syn::Stmt::Local(local)) if local_pattern_ident(&local.pat).as_deref() == Some("owned_object_names") ) && matches!( - statements.get(6), + statements.get(7), Some(syn::Stmt::Local(local)) if local_pattern_ident(&local.pat).as_deref() == Some("owned_table_names") ) && matches!( - statements.get(7), + statements.get(8), + Some(syn::Stmt::Local(local)) + if local_pattern_ident(&local.pat).as_deref() == Some("migration_hook_ids") + ) + && matches!( + statements.get(9), Some(syn::Stmt::Expr(syn::Expr::ForLoop(_), _)) ) && matches!( - statements.get(8), + statements.get(10), Some(syn::Stmt::Expr(syn::Expr::If(_), _)) ) && statements - .get(9) + .get(11) .and_then(direct_statement_expression) .is_some_and(|expression| compact_tokens(expression) == "Ok(())"); if !valid { return Err(format!( - "{relative} `validate_migration_registry` authoritative top-level statement skeleton drifted" + "{relative} `validate_migration_registry` authoritative top-level statement skeleton drifted: found {:?}", + statements.iter().map(compact_tokens).collect::<Vec<_>>() )); } Ok(()) @@ -11097,9 +11961,852 @@ fn validate_manifest_validator_reachability( Ok(()) } -fn validate_schema_runtime_reachability<'a>( +fn validate_source_maintenance_manifest_validator_reachability( relative: &str, - file: &'a syn::File, + file: &syn::File, +) -> Result<(), String> { + const EXPECTED_TOKEN_SHA256: &str = + "711c977666d6a7e3ce3c1759e6ca7a9811bab9690bffda8994b605b8f6c539a2"; + + let function = exact_top_level_function( + relative, + file, + "validate_generated_source_maintenance_manifest_descriptor", + )?; + let statements = &function.block.stmts; + let expected_locals = [ + (1, "bytes"), + (5, "manifest"), + (6, "expected_numbers"), + (7, "expected_strings"), + (8, "numbers_match"), + (9, "strings_match"), + (10, "string_array_matches"), + ]; + let valid = statements.len() == 14 + && matches!(statements.first(), Some(syn::Stmt::Item(syn::Item::Use(_)))) + && expected_locals.iter().all(|(index, name)| { + matches!( + statements.get(*index), + Some(syn::Stmt::Local(local)) + if local_pattern_ident(&local.pat).as_deref() == Some(*name) + ) + }) + && matches!( + statements.get(2), + Some(syn::Stmt::Expr(syn::Expr::If(_), _)) + ) + && statements + .get(3) + .and_then(direct_statement_expression) + .and_then(|expression| direct_try_function_call(expression, "validate_sha256_literal")) + .is_some() + && matches!( + statements.get(4), + Some(syn::Stmt::Expr(syn::Expr::If(_), _)) + ) + && matches!( + statements.get(11), + Some(syn::Stmt::Expr(syn::Expr::If(_), _)) + ) + && matches!( + statements.get(12), + Some(syn::Stmt::Expr(syn::Expr::ForLoop(_), _)) + ) + && statements + .get(13) + .and_then(direct_statement_expression) + .is_some_and(|expression| compact_tokens(expression) == "Ok(())"); + if !valid { + return Err(format!( + "{relative} generated SourceMaintenance manifest validator authoritative statement skeleton drifted" + )); + } + + for (binding, accessor) in [("numbers_match", "as_u64"), ("strings_match", "as_str")] { + let expression = statements + .iter() + .find_map(|statement| match statement { + syn::Stmt::Local(local) + if local_pattern_ident(&local.pat).as_deref() == Some(binding) => + { + local.init.as_ref().map(|init| init.expr.as_ref()) + } + _ => None, + }) + .expect("validated descriptor local"); + validate_manifest_pointer_check(relative, binding, expression, accessor)?; + } + let array_matcher = statements + .get(10) + .and_then(direct_statement_expression) + .ok_or_else(|| format!("{relative} SourceMaintenance array matcher is missing"))?; + use syn::visit::Visit; + let mut array_routes = RustCallRouteCollector { routes: Vec::new() }; + array_routes.visit_expr(array_matcher); + for route in [ + "method:pointer", + "method:as_array", + "method:is_some_and", + "method:zip", + ] { + if !array_routes + .routes + .iter() + .any(|candidate| candidate == route) + { + return Err(format!( + "{relative} SourceMaintenance array matcher is missing semantic route `{route}`" + )); + } + } + let digest_loop = match statements.get(12) { + Some(syn::Stmt::Expr(syn::Expr::ForLoop(expression), _)) => expression, + _ => unreachable!("validated descriptor loop"), + }; + if digest_loop.body.stmts.len() != 1 + || digest_loop + .body + .stmts + .first() + .and_then(direct_statement_expression) + .and_then(|expression| direct_try_function_call(expression, "validate_sha256_literal")) + .is_none() + { + return Err(format!( + "{relative} SourceMaintenance descriptor digest loop must directly propagate validate_sha256_literal" + )); + } + + let actual_sha256 = sha256_hex(compact_tokens(function).as_bytes()); + if actual_sha256 != EXPECTED_TOKEN_SHA256 { + return Err(format!( + "{relative} generated SourceMaintenance manifest validator exact token authority drifted: expected {EXPECTED_TOKEN_SHA256}, found {actual_sha256}" + )); + } + Ok(()) +} + +fn validate_source_maintenance_migration_bindings( + relative: &str, + file: &syn::File, +) -> Result<(), String> { + let entry = exact_const_struct_array_element( + relative, + file, + "EVENT_STORE_MIGRATIONS", + "version", + u64::from(SOURCE_MAINTENANCE_MIGRATION_VERSION), + )?; + let expected_entry = r#"EventStoreMigration { + version: 4, + name: "source_maintenance", + up_sql: include_str!("../migrations/0004_source_maintenance.up.sql"), + down_sql: include_str!("../migrations/0004_source_maintenance.down.sql"), + up_len: source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_UP_BYTE_LENGTH, + down_len: source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_DOWN_BYTE_LENGTH, + up_sha256: source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_UP_SHA256, + down_sha256: source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_DOWN_SHA256, + schema_sha256: source_maintenance_manifest::SOURCE_MAINTENANCE_SCHEMA_SHA256, + owned_object_names: EVENT_STORE_SOURCE_MAINTENANCE_OBJECT_NAMES, + replaced_object_names: EVENT_STORE_SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES, + owned_table_names: EVENT_STORE_SOURCE_MAINTENANCE_TABLE_NAMES, + fts5_table_names: &[], + hook: EventStoreMigrationHook::SourceMaintenanceV1, + hook_manifest_sha256: Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256,), + event_contract_registry_version: Some( + source_maintenance_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION, + ), + }"#; + let actual_entry = compact_tokens(entry); + let expected_entry = compact_source_tokens(expected_entry); + if actual_entry != expected_entry { + return Err(format!( + "{relative} SourceMaintenance v4 migration entry authority drifted: expected `{expected_entry}`, found `{actual_entry}`" + )); + } + + let loop_expression = exact_direct_for_loop( + relative, + file, + "validate_migration_registry", + "(index,migration)", + "registry.iter().enumerate()", + )?; + let arm = exact_direct_loop_match_arm( + relative, + "validate_migration_registry", + loop_expression, + &[ + "migration.hook", + "migration.hook_manifest_sha256", + "migration.event_contract_registry_version", + ], + "SourceMaintenanceV1", + )?; + let expected_pattern = r#"(EventStoreMigrationHook::None, None, None) + | ( + 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,), + ) + | ( + EventStoreMigrationHook::SourceMaintenanceV1, + Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256), + Some(source_maintenance_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION,), + )"#; + if compact_tokens(&arm.pat) != compact_source_tokens(expected_pattern) + || arm.guard.is_some() + || compact_tokens(&arm.body) != "{}" + { + return Err(format!( + "{relative} SourceMaintenance registry tuple authority drifted: expected pattern `{}`, found pattern `{}`, guard {:?}, body `{}`", + compact_source_tokens(expected_pattern), + compact_tokens(&arm.pat), + arm.guard + .as_ref() + .map(|(_, expression)| compact_tokens(expression)), + compact_tokens(&arm.body), + )); + } + Ok(()) +} + +fn validate_event_store_migration_support_authority( + relative: &str, + file: &syn::File, +) -> Result<(), String> { + const EXPECTED: [(&str, &str); 9] = [ + ( + "EVENT_STORE_LEDGER_DDL", + "adb8845fa244f2d4503fd52eeea9488c214da9375727a0e77905233ad3d5b701", + ), + ( + "EVENT_STORE_LEDGER_CREATE_DDL", + "ecaced87b78196cc220fb2c785a7ee2db047bf08a27876066758f79a48ea8648", + ), + ( + "EVENT_STORE_BASELINE_FTS5_TABLE_NAMES", + "4ab01dfd843eb33e82fae3d9503000f9c0292ce4e6f61f18aeee33ebc15360d3", + ), + ( + "EventStoreMigration", + "3552624482aa3c698ebcc88e5d3497e35d30d3646ea0605d2c192acc21006ee2", + ), + ( + "EVENT_STORE_MIGRATIONS[version=1]", + "0f763874f3fb73f2a41701ec623bae3629464243d70de797d842cdde45ab847e", + ), + ( + "migration_for_version", + "896f4fd8a67ba6dc17262f117fd74b0df74ee75f3cf0066bfddd90fb58d84a96", + ), + ( + "validate_embedded_migration_input", + "526a7971d0736588d66cf95cca4063ebd3a4497c6f0c2c7ba96b39d09ee07259", + ), + ( + "validate_migration_registry", + "e6cf2795b0308a51ef5958ce91f41877fb9c73f8f4c7008c90b1e5e70b37364a", + ), + ( + "validate_generated_nip09_manifest_descriptor", + "44d44c3c35a8ea923d9fce80afea4a9db35e9225172090c831fd151bd2c5d4a1", + ), + ]; + + let tokens = [ + compact_tokens(exact_executor_const( + file, + relative, + "EVENT_STORE_LEDGER_DDL", + )?), + compact_tokens(exact_executor_const( + file, + relative, + "EVENT_STORE_LEDGER_CREATE_DDL", + )?), + compact_tokens(exact_executor_const( + file, + relative, + "EVENT_STORE_BASELINE_FTS5_TABLE_NAMES", + )?), + compact_tokens(exact_top_level_struct( + relative, + file, + "EventStoreMigration", + )?), + compact_tokens(exact_const_struct_array_element( + relative, + file, + "EVENT_STORE_MIGRATIONS", + "version", + 1, + )?), + compact_tokens(exact_top_level_function( + relative, + file, + "migration_for_version", + )?), + compact_tokens(exact_top_level_function( + relative, + file, + "validate_embedded_migration_input", + )?), + compact_tokens(exact_top_level_function( + relative, + file, + "validate_migration_registry", + )?), + compact_tokens(exact_top_level_function( + relative, + file, + "validate_generated_nip09_manifest_descriptor", + )?), + ]; + let drift = EXPECTED + .iter() + .zip(tokens) + .filter_map(|((label, expected), tokens)| { + let actual = sha256_hex(tokens.as_bytes()); + (actual != *expected).then(|| format!("{label}={actual} (expected {expected})")) + }) + .collect::<Vec<_>>(); + if !drift.is_empty() { + return Err(format!( + "{relative} active migration support token authority drifted: {}", + drift.join(", ") + )); + } + Ok(()) +} + +fn validate_source_maintenance_schema_dispatch( + relative: &str, + file: &syn::File, +) -> Result<(), String> { + for (function, called) in [ + ("apply_migration_hook", "apply_source_maintenance_hook_v1"), + ( + "validate_migration_hook_state", + "validate_source_capacity_authority_full_v1", + ), + ] { + let arm = exact_tail_match_arm( + relative, + file, + function, + "migration.hook", + "SourceMaintenanceV1", + )?; + validate_direct_arm_awaited_call( + relative, + &format!("`{function}` SourceMaintenanceV1 arm"), + &arm.body, + called, + )?; + if arm.guard.is_some() { + return Err(format!( + "{relative} `{function}` SourceMaintenanceV1 arm must remain unguarded" + )); + } + } + Ok(()) +} + +fn validate_schema_migration_execution_authority( + relative: &str, + file: &syn::File, +) -> Result<(), String> { + for (name, expected) in [ + ( + "apply_migration_up", + r#"async fn apply_migration_up( + connection: &mut SqliteConnection, + registry: &[EventStoreMigration], + migration: &EventStoreMigration, + ) -> Result<(), RadrootsEventStoreError> { + let before = read_catalog(connection).await?; + sqlx::raw_sql(migration.up_sql) + .execute(&mut *connection) + .await?; + let after = read_catalog(connection).await?; + validate_catalog_delta(&before, &after, migration, "up")?; + validate_schema_fingerprint(connection, registry, migration).await + }"#, + ), + ( + "apply_migration_down", + r#"async fn apply_migration_down( + connection: &mut SqliteConnection, + migration: &EventStoreMigration, + ) -> Result<(), RadrootsEventStoreError> { + let before = read_catalog(connection).await?; + sqlx::raw_sql(migration.down_sql) + .execute(&mut *connection) + .await?; + let after = read_catalog(connection).await?; + validate_catalog_delta(&before, &after, migration, "down") + }"#, + ), + ( + "validate_catalog_delta", + r#"fn validate_catalog_delta( + before: &[CatalogRow], + after: &[CatalogRow], + migration: &EventStoreMigration, + direction: &'static str, + ) -> Result<(), RadrootsEventStoreError> { + let before = before + .iter() + .map(|row| (row.name.as_str(), row)) + .collect::<BTreeMap<_, _>>(); + let after = after + .iter() + .map(|row| (row.name.as_str(), row)) + .collect::<BTreeMap<_, _>>(); + let added = after + .keys() + .filter(|name| !before.contains_key(**name)) + .copied() + .collect::<BTreeSet<_>>(); + let removed = before + .keys() + .filter(|name| !after.contains_key(**name)) + .copied() + .collect::<BTreeSet<_>>(); + let changed = before + .iter() + .filter_map(|(name, row)| { + after + .get(name) + .filter(|after_row| *after_row != row) + .map(|_| *name) + }) + .collect::<BTreeSet<_>>(); + let expected = migration + .owned_object_names + .iter() + .copied() + .collect::<BTreeSet<_>>(); + let expected_changed = migration + .replaced_object_names + .iter() + .copied() + .collect::<BTreeSet<_>>(); + + let valid = match direction { + "up" => added == expected && removed.is_empty() && changed == expected_changed, + "down" => removed == expected && added.is_empty() && changed == expected_changed, + _ => false, + }; + if !valid { + return Err(RadrootsEventStoreError::MigrationCatalogDeltaMismatch { + version: migration.version, + direction, + reason: format!( + "expected {} objects {expected:?} and changed replacement objects {expected_changed:?}; added {added:?}, removed {removed:?}, changed {changed:?}", + if direction == "up" { + "added" + } else { + "removed" + } + ), + }); + } + Ok(()) + }"#, + ), + ( + "apply_migration_hook", + r#"async fn apply_migration_hook( + connection: &mut SqliteConnection, + migration: &EventStoreMigration, + generation_provider: &dyn SourceGenerationProvider, + reconciliation_limits: ReconciliationCapacityLimits, + ) -> Result<(), RadrootsEventStoreError> { + match migration.hook { + EventStoreMigrationHook::None => Ok(()), + EventStoreMigrationHook::Nip09ReconciliationV1 => { + apply_reconciliation_hook( + connection, + generation_provider, + reconciliation_limits, + ).await + } + EventStoreMigrationHook::FoodAvailabilityProjectionV1 => { + apply_food_availability_projection_hook_v1(connection).await + } + EventStoreMigrationHook::SourceMaintenanceV1 => { + apply_source_maintenance_hook_v1(connection).await + } + } + }"#, + ), + ( + "validate_migration_hook_state", + r#"async fn validate_migration_hook_state( + connection: &mut SqliteConnection, + migration: &EventStoreMigration, + ) -> Result<(), RadrootsEventStoreError> { + match migration.hook { + EventStoreMigrationHook::None => Ok(()), + EventStoreMigrationHook::Nip09ReconciliationV1 => { + validate_active_hook_state_fast(connection).await + } + EventStoreMigrationHook::FoodAvailabilityProjectionV1 => { + validate_food_availability_projection_hook_state_fast_v1(connection).await + } + EventStoreMigrationHook::SourceMaintenanceV1 => { + validate_source_capacity_authority_full_v1(connection).await + } + } + }"#, + ), + ] { + let actual = exact_top_level_function(relative, file, name)?; + let expected_file = parse_canonical_production_rust( + &format!("authoritative {relative}:{name}"), + expected.as_bytes(), + )?; + let expected = exact_top_level_function(relative, &expected_file, name)?; + if compact_tokens(actual) != compact_tokens(expected) { + return Err(format!( + "{relative} authoritative schema migration execution function `{name}` signature or control flow drifted" + )); + } + } + Ok(()) +} + +fn validate_sqlite_encoding_preflight_authority( + relative: &str, + file: &syn::File, +) -> Result<(), String> { + let configure_pool = exact_top_level_function(relative, file, "configure_pool")?; + let expected_configure_pool = r#" + async fn configure_pool( + pool: &SqlitePool, + file_backed: bool, + ) -> Result<(), RadrootsEventStoreError> { + let max_connections = pool.options().get_max_connections(); + let existing_options = pool.connect_options(); + if !file_backed && max_connections != 1 { + return Err(RadrootsEventStoreError::UnsafeInMemoryPoolConnectionCount { + actual: max_connections, + }); + } + + let mut connections = Vec::with_capacity(max_connections as usize); + for _ in 0..max_connections { + connections.push(pool.acquire().await?); + } + for connection in &mut connections { + let main_filename = main_database_filename(connection).await?; + let database_is_memory = main_filename.is_empty(); + if file_backed == database_is_memory { + return Err(RadrootsEventStoreError::SqlitePoolBackingMismatch { + file_backed, + filename: main_filename, + }); + } + validate_main_database_encoding(connection).await?; + crate::schema::validate_event_store_temp_schema(connection).await?; + } + + let mut connect_options = existing_options + .as_ref() + .clone() + .foreign_keys(true) + .busy_timeout(Duration::from_millis(5_000)); + if file_backed { + connect_options = connect_options.journal_mode(SqliteJournalMode::Wal); + } + pool.set_connect_options(connect_options); + + for connection in &mut connections { + sqlx::query("PRAGMA foreign_keys = ON") + .execute(&mut **connection) + .await?; + sqlx::query("PRAGMA busy_timeout = 5000") + .execute(&mut **connection) + .await?; + if file_backed { + configure_file_journal_mode(connection).await?; + } + } + Ok(()) + } + "#; + if compact_tokens(configure_pool) != compact_source_tokens(expected_configure_pool) { + return Err(format!( + "{relative} `configure_pool` must validate every main database as UTF-8 after backing classification and before TEMP-schema, connection-option, PRAGMA, or journal mutation" + )); + } + + let validator = exact_top_level_function(relative, file, "validate_main_database_encoding")?; + let expected_validator = r#" + async fn validate_main_database_encoding( + connection: &mut SqliteConnection, + ) -> Result<(), RadrootsEventStoreError> { + let actual: String = sqlx::query_scalar("PRAGMA main.encoding") + .fetch_one(&mut *connection) + .await?; + if actual == "UTF-8" { + return Ok(()); + } + Err(RadrootsEventStoreError::SqliteMainDatabaseEncodingNotUtf8 { actual, }) + } + "#; + if compact_tokens(validator) != compact_source_tokens(expected_validator) { + return Err(format!( + "{relative} `validate_main_database_encoding` UTF-8 query or typed failure authority drifted: expected `{}`, found `{}`", + compact_source_tokens(expected_validator), + compact_tokens(validator), + )); + } + Ok(()) +} + +fn validate_source_generation_rollback_authority( + relative: &str, + file: &syn::File, +) -> Result<(), String> { + let policies = file + .items + .iter() + .filter_map(|item| match item { + syn::Item::Enum(item) if item.ident == "SourceGenerationHistoryRollbackPolicy" => { + Some(item) + } + _ => None, + }) + .collect::<Vec<_>>(); + let [policy] = policies.as_slice() else { + return Err(format!( + "{relative} must define exactly one production `SourceGenerationHistoryRollbackPolicy`; found {}", + policies.len() + )); + }; + let expected_policy = r#" + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum SourceGenerationHistoryRollbackPolicy { + Preserve, + } + "#; + if compact_tokens(policy) != compact_source_tokens(expected_policy) { + return Err(format!( + "{relative} production `SourceGenerationHistoryRollbackPolicy` must contain only `Preserve`" + )); + } + + let wrapper = + exact_top_level_function(relative, file, "rollback_event_store_schema_with_registry")?; + let expected_wrapper = r#" + async fn rollback_event_store_schema_with_registry( + pool: &SqlitePool, + registry: &[EventStoreMigration], + minimum: u32, + supported_current: u32, + target: u32, + ) -> Result<(), RadrootsEventStoreError> { + rollback_event_store_schema_with_registry_inner( + pool, + registry, + minimum, + supported_current, + target, + SourceGenerationHistoryRollbackPolicy::Preserve, + ) + .await + } + "#; + if compact_tokens(wrapper) != compact_source_tokens(expected_wrapper) { + return Err(format!( + "{relative} production rollback registry wrapper must directly select and await the `Preserve` policy" + )); + } + + let rollback = exact_top_level_function(relative, file, "rollback_schema_on_connection")?; + let expected_signature = compact_source_tokens( + r#"async fn rollback_schema_on_connection( + connection: &mut SqliteConnection, + registry: &[EventStoreMigration], + supported_current: u32, + target: u32, + source_generation_history_policy: SourceGenerationHistoryRollbackPolicy, + ) -> Result<(), RadrootsEventStoreError>"#, + ); + if !rollback.attrs.is_empty() + || !matches!(rollback.vis, syn::Visibility::Inherited) + || compact_tokens(&rollback.sig) != expected_signature + || rollback.block.stmts.len() != 6 + { + return Err(format!( + "{relative} `rollback_schema_on_connection` signature or six-statement authority skeleton drifted" + )); + } + for (index, expected) in [ + r#"let RadrootsEventStoreSchemaStatus::Managed { + version: current_version + } = inspect_schema_on_connection(connection, registry, supported_current,).await? + else { + return Err(RadrootsEventStoreError::RollbackUnmanaged); + };"#, + r#"if target > current_version { + return Err(RadrootsEventStoreError::RollbackAhead { + current: current_version, + target, + }); + }"#, + r#"if source_generation_history_policy == SourceGenerationHistoryRollbackPolicy::Preserve { + validate_rollback_preserves_source_generation_history( + registry, + current_version, + target, + )?; + }"#, + ] + .into_iter() + .enumerate() + { + if compact_tokens(&rollback.block.stmts[index]) != compact_source_tokens(expected) { + return Err(format!( + "{relative} `rollback_schema_on_connection` authoritative preflight statement {} drifted: expected `{}`, found `{}`", + index + 1, + compact_source_tokens(expected), + compact_tokens(&rollback.block.stmts[index]), + )); + } + } + if !matches!( + rollback.block.stmts.get(3), + Some(syn::Stmt::Expr(syn::Expr::ForLoop(loop_expression), _)) + if compact_tokens(&loop_expression.pat) == "version" + && compact_tokens(&loop_expression.expr) + == "((target+1)..=current_version).rev()" + ) { + return Err(format!( + "{relative} source-generation rollback guard must remain immediately before the direct down-migration loop" + )); + } + + let validator = exact_top_level_function( + relative, + file, + "validate_rollback_preserves_source_generation_history", + )?; + let expected_validator = r#" + fn validate_rollback_preserves_source_generation_history( + registry: &[EventStoreMigration], + current: u32, + target: u32, + ) -> Result<(), RadrootsEventStoreError> { + let Some(floor) = registry + .iter() + .find(|migration| { + migration.hook == EventStoreMigrationHook::Nip09ReconciliationV1 + }) + .map(|migration| migration.version) + else { + return Ok(()); + }; + if current < floor || target >= floor { + return Ok(()); + } + + Err(RadrootsEventStoreError::RollbackWouldDiscardSourceGenerationHistory { + current, + target, + floor, + }) + } + "#; + if compact_tokens(validator) != compact_source_tokens(expected_validator) { + return Err(format!( + "{relative} source-generation rollback floor derivation or typed rejection authority drifted: expected `{}`, found `{}`", + compact_source_tokens(expected_validator), + compact_tokens(validator), + )); + } + Ok(()) +} + +fn validate_source_maintenance_runtime_token_authority( + workspace_root: &Path, +) -> Result<(), String> { + const SOURCE_RUNTIME_AST_SHA256: &str = + "181576a5de365cf664b8a87091c30b0389ce0be90e7d1cc16fd7170342f6c2bc"; + const FUNCTION_SPECS: [(&str, &str, &str); 4] = [ + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "ingest_event_protocol_reconciliation_v1", + "f8d26e1d4e1a362c7335f1ba58ad6f1bac2f119162b15ca1067391756149d1e3", + ), + ( + EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE, + "read_protocol_post_extension_authority_seal", + "490e59d21fb84f3321c593ffb67a4d1ada1e5cc8373ed41e2c6834114f2a6ef9", + ), + ( + "crates/event_store/src/nip09/reconciliation_v1.rs", + "apply_reconciliation_hook", + "41a0bc1f4e529528f9bc13be28b4a31305156124282c1c7e955ed2e4a56e86d2", + ), + ( + EVENT_STORE_STORE_SOURCE_RELATIVE, + "associated:RadrootsEventStore::source_capacity_v1", + "176c41b212e8d9d4ae3a53cf61dfade6d34b802f5bb649b18f66ffc769d5bade", + ), + ]; + + let source_relative = "crates/event_store/src/source_maintenance_v1.rs"; + let source_bytes = read_regular_file(workspace_root, source_relative)?; + let canonical_source = + canonical_rust_ast(source_relative, &source_bytes, RustAstProfile::Production)?; + let mut drift = Vec::new(); + let source_sha256 = sha256_hex(&canonical_source); + if source_sha256 != SOURCE_RUNTIME_AST_SHA256 { + drift.push(format!( + "{source_relative}={source_sha256} (expected {SOURCE_RUNTIME_AST_SHA256})" + )); + } + + for (relative, function, expected_sha256) in FUNCTION_SPECS { + let bytes = read_regular_file(workspace_root, relative)?; + let file = parse_canonical_production_rust(relative, &bytes)?; + let tokens = if let Some(method) = function.strip_prefix("associated:") { + let (owner, method) = method.split_once("::").ok_or_else(|| { + format!("invalid associated SourceMaintenance witness `{function}`") + })?; + compact_tokens(exact_associated_function(relative, &file, owner, method)?) + } else { + compact_tokens(exact_top_level_function(relative, &file, function)?) + }; + let actual_sha256 = sha256_hex(tokens.as_bytes()); + if actual_sha256 != expected_sha256 { + drift.push(format!( + "{relative}:{function}={actual_sha256} (expected {expected_sha256})" + )); + } + } + if !drift.is_empty() { + return Err(format!( + "current SourceMaintenance runtime exact token authority drifted: {}", + drift.join(", ") + )); + } + Ok(()) +} + +fn validate_schema_runtime_reachability<'a>( + relative: &str, + file: &'a syn::File, ) -> Result<Vec<&'a syn::ItemFn>, String> { const EXPECTED: [(&str, &str); 9] = [ ( @@ -11204,6 +12911,11 @@ fn validate_schema_runtime_reachability<'a>( &mut connection, reconciliation_limits ).await?; + if has_pending_source_maintenance_hook(&status, registry) { + validate_no_persisted_ephemeral_raw_rows_v1( + &mut connection + ).await?; + } } let mut transaction = pool.begin_with("BEGIN IMMEDIATE").await?; let result = migrate_schema_on_connection( @@ -11285,7 +12997,7 @@ fn validate_schema_runtime_reachability<'a>( ), ]; - EXPECTED + let functions = EXPECTED .iter() .map(|(name, expected)| { let function = exact_top_level_function(relative, file, name)?; @@ -11298,7 +13010,120 @@ fn validate_schema_runtime_reachability<'a>( } Ok(function) }) - .collect() + .collect::<Result<Vec<_>, String>>()?; + let migrate = exact_top_level_function(relative, file, "migrate_schema_on_connection")?; + 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") + ) + }) + .ok_or_else(|| { + format!( + "{relative} authoritative schema runtime is missing the direct `current_version` initializer" + ) + })?; + let expected_current_version = parse_canonical_production_rust( + "authoritative migrate_schema_on_connection current_version initializer", + br#"fn expected() { + let current_version = match status { + RadrootsEventStoreSchemaStatus::Uninitialized => { + apply_migration_up(connection, registry, &registry[0]).await?; + create_ledger(connection).await?; + insert_ledger_row(connection, &registry[0]).await?; + registry[0].version + } + RadrootsEventStoreSchemaStatus::UnledgeredBaseline => { + create_ledger(connection).await?; + insert_ledger_row(connection, &registry[0]).await?; + registry[0].version + } + RadrootsEventStoreSchemaStatus::Managed { version } + if version == supported_current => + { + return Ok(()); + } + RadrootsEventStoreSchemaStatus::Managed { version } => version, + }; + }"#, + )?; + let expected_current_version = + exact_top_level_function(relative, &expected_current_version, "expected")? + .block + .stmts + .first() + .expect("authoritative current-version initializer"); + if compact_tokens(current_version) != compact_tokens(expected_current_version) { + return Err(format!( + "{relative} authoritative schema runtime `migrate_schema_on_connection` current-version control flow drifted" + )); + } + validate_migration_hook_loop_reachability(relative, file)?; + Ok(functions) +} + +fn validate_migration_hook_loop_reachability( + relative: &str, + file: &syn::File, +) -> Result<(), String> { + let apply_loop = exact_direct_for_loop( + relative, + file, + "migrate_schema_on_connection", + "migration", + "registry.iter().filter(|migration|migration.version>current_version)", + )?; + let source_preflight = r#"if matches!( + migration.hook, + EventStoreMigrationHook::Nip09ReconciliationV1 + | EventStoreMigrationHook::FoodAvailabilityProjectionV1 + | EventStoreMigrationHook::SourceMaintenanceV1 + ) { + validate_reconciliation_capacity(connection, reconciliation_limits).await?; + if migration.hook == EventStoreMigrationHook::SourceMaintenanceV1 { + validate_no_persisted_ephemeral_raw_rows_v1(connection).await?; + } + }"#; + if apply_loop.body.stmts.first().is_none_or(|statement| { + compact_tokens(statement) != compact_source_tokens(source_preflight) + }) { + return Err(format!( + "{relative} `migrate_schema_on_connection` SourceMaintenance preflight or error propagation drifted" + )); + } + for called in [ + "apply_migration_up", + "apply_migration_hook", + "validate_applied_migration_hooks", + "insert_ledger_row", + ] { + exact_direct_loop_awaited_call( + relative, + "migrate_schema_on_connection", + apply_loop, + called, + )?; + } + + let validate_loop = exact_direct_for_loop( + relative, + file, + "validate_applied_migration_hooks", + "migration", + "registry.iter().filter(|migration|migration.version<=current)", + )?; + exact_direct_loop_awaited_call( + relative, + "validate_applied_migration_hooks", + validate_loop, + "validate_migration_hook_state", + )?; + Ok(()) } fn validate_no_diverging_control_flow( @@ -11739,7 +13564,7 @@ fn exact_direct_for_loop<'a>( )); }; let (expected_statement_count, expected_index) = match function { - "validate_migration_registry" => (9, 6), + "validate_migration_registry" => (12, 9), "migrate_schema_on_connection" => (5, 2), "validate_applied_migration_hooks" => (2, 0), _ => { @@ -11772,9 +13597,9 @@ fn exact_direct_loop_match_arm<'a>( variant: &str, ) -> Result<&'a syn::Arm, String> { if function == "validate_migration_registry" - && (loop_expression.body.stmts.len() != 14 + && (loop_expression.body.stmts.len() != 20 || !matches!( - loop_expression.body.stmts.get(12), + loop_expression.body.stmts.get(18), Some(syn::Stmt::Expr(syn::Expr::Match(_), _)) )) { @@ -11849,9 +13674,12 @@ fn exact_direct_loop_awaited_call<'a>( loop_expression: &'a syn::ExprForLoop, called: &str, ) -> Result<&'a syn::ExprAwait, String> { - let (expected_statement_count, expected_index) = match function { - "migrate_schema_on_connection" => (5, 2), - "validate_applied_migration_hooks" => (1, 0), + let (expected_statement_count, expected_index) = match (function, called) { + ("migrate_schema_on_connection", "apply_migration_up") => (5, 1), + ("migrate_schema_on_connection", "apply_migration_hook") => (5, 2), + ("migrate_schema_on_connection", "validate_applied_migration_hooks") => (5, 3), + ("migrate_schema_on_connection", "insert_ledger_row") => (5, 4), + ("validate_applied_migration_hooks", "validate_migration_hook_state") => (1, 0), _ => { return Err(format!( "{relative} `{function}` is not an approved awaited-loop witness" @@ -15257,6 +17085,8 @@ mod tests { MIGRATION_V1_DOWN_RELATIVE, MIGRATION_UP_RELATIVE, MIGRATION_DOWN_RELATIVE, + "crates/event_store/migrations/0004_source_maintenance.up.sql", + "crates/event_store/migrations/0004_source_maintenance.down.sql", REGISTRY_INVENTORY_RELATIVE, "contracts/event_store/event_contract_registry_v7.inventory.sha256", RESULT_VECTOR_CANONICAL_RELATIVE, @@ -15266,6 +17096,13 @@ mod tests { MANIFEST_SCHEMA_RELATIVE, MANIFEST_SHA256_RELATIVE, GENERATED_DESCRIPTOR_RELATIVE, + "contracts/conformance/vectors/event_store/source_maintenance.v1.json", + "crates/event_store/tests/fixtures/source_maintenance.v1.json", + "crates/event_store/tests/source_maintenance_v1_result_vector.rs", + "crates/event_store/contracts/source_maintenance_v1.manifest.json", + "crates/event_store/contracts/source_maintenance_v1.manifest.schema.json", + "crates/event_store/contracts/source_maintenance_v1.manifest.sha256", + "crates/event_store/src/generated/source_maintenance_manifest.rs", ]; for dependency in SEMANTIC_DEPENDENCY_SPECS { paths.push(dependency.canonical_path); @@ -15276,6 +17113,8 @@ 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.extend(SUCCESSOR_08D_SOURCE_PATHS); + paths.extend(super::super::source_maintenance::source_contract_fixture_source_paths()); paths.sort_unstable(); paths.dedup(); paths @@ -15580,7 +17419,7 @@ route!(r#hex); "#[cfg(any())]\nuse self::post_core_extension_dispatcher::dispatch_post_core_extensions;", 1, ), - "privileged cross-module import routes drifted", + "SourceMaintenance privileged import authority drifted", ), ( "extra associated core bypass call", @@ -15791,7 +17630,8 @@ route!(r#hex); .expect_err("privileged sibling source must fail"); assert!( error.contains("privileged cross-module import routes drifted") - || error.contains("source inventory is closed"), + || error.contains("source inventory is closed") + || error.contains("privileged terminal import"), "{error}" ); @@ -15980,8 +17820,92 @@ route!(r#hex); error.contains(EVENT_STORE_LIB_SOURCE_RELATIVE), "unexpected ancestor error: {error}" ); - fs::write(&lib_path, &lib_original).expect("restore event-store lib source"); + fs::write(&lib_path, &lib_original).expect("restore event-store lib source"); + } + + let food_path = workspace + .path() + .join("crates/event_store/src/store/food_availability_projection_v1.rs"); + let food_original = fs::read_to_string(&food_path).expect("08C Food store source"); + let food_mutations = [ + ( + "08C direct SourceMaintenance terminal call", + format!( + "{food_original}\nasync fn source_terminal_bypass(connection: &mut sqlx::SqliteConnection) {{\n let _ = crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1(connection).await;\n}}\n" + ), + ), + ( + "08C SourceMaintenance alias import", + format!( + "{food_original}\nuse crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1 as bypass;\n" + ), + ), + ( + "08C SourceMaintenance glob import", + format!("{food_original}\nuse crate::source_maintenance_v1::*;\n"), + ), + ( + "08C SourceMaintenance macro reference", + format!( + "{food_original}\nfn source_terminal_macro_bypass() {{ stringify!(validate_source_capacity_authority_fast_v1); }}\n" + ), + ), + ( + "08C SourceMaintenance associated-function shadow", + format!( + "{food_original}\nstruct SourceTerminalShadow;\nimpl SourceTerminalShadow {{ fn validate_source_capacity_authority_fast_v1() {{}} }}\n" + ), + ), + ( + "08C SourceMaintenance trait-function shadow", + format!( + "{food_original}\ntrait SourceTerminalShadow {{ fn preflight_unique_raw_source_append_v1(); }}\n" + ), + ), + ]; + for (label, mutation) in food_mutations { + fs::write(&food_path, mutation).expect("write 08C terminal bypass"); + let error = validate_privileged_store_authority(workspace.path()) + .expect_err("08C SourceMaintenance terminal bypass must fail"); + assert!( + error.contains("privileged terminal") + || error.contains("privileged authority") + || error.contains("glob import"), + "{label} produced unexpected error: {error}" + ); + fs::write(&food_path, &food_original).expect("restore 08C Food source"); } + + let protocol_path = workspace + .path() + .join(EVENT_STORE_PROTOCOL_RECONCILIATION_SOURCE_RELATIVE); + let protocol_original = + fs::read_to_string(&protocol_path).expect("protocol reconciliation source"); + let mut protocol_shadow = + syn::parse_file(&protocol_original).expect("protocol reconciliation AST"); + let ingest = protocol_shadow + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) + if function.sig.ident == "ingest_event_protocol_reconciliation_v1" => + { + Some(function) + } + _ => None, + }) + .expect("approved SourceMaintenance caller"); + ingest.block.stmts.insert( + 0, + syn::parse_str("let validate_source_capacity_authority_fast_v1 = || ();") + .expect("terminal shadow binding"), + ); + fs::write(&protocol_path, prettyplease::unparse(&protocol_shadow)) + .expect("write approved-caller terminal shadow"); + let error = validate_privileged_store_authority(workspace.path()) + .expect_err("approved-caller terminal shadow must fail"); + assert!(error.contains("shadows privileged authority"), "{error}"); + fs::write(&protocol_path, protocol_original).expect("restore protocol source"); } #[test] @@ -16078,7 +18002,7 @@ route!(r#hex); #[test] fn migration_reachability_requires_guard_order_and_error_propagation() { let source = repository_source(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE); - let mut file = parse_canonical_production_rust( + let file = parse_canonical_production_rust( EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, source.as_bytes(), ) @@ -16087,6 +18011,16 @@ route!(r#hex); .expect("authoritative registry reachability"); validate_manifest_validator_reachability(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, &file) .expect("authoritative manifest-validator reachability"); + validate_source_maintenance_manifest_validator_reachability( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &file, + ) + .expect("authoritative SourceMaintenance descriptor reachability"); + validate_source_maintenance_migration_bindings( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &file, + ) + .expect("authoritative SourceMaintenance migration bindings"); let mut early_return_file = file.clone(); let early_return_registry = early_return_file @@ -16112,7 +18046,8 @@ route!(r#hex); "an early success return before the generated-manifest guard must fail" ); - let registry = file + let mut reordered_predecessor_guards = file.clone(); + let registry = reordered_predecessor_guards .items .iter_mut() .find_map(|item| match item { @@ -16126,10 +18061,76 @@ route!(r#hex); assert!( validate_migration_registry_reachability( EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, - &file, + &reordered_predecessor_guards, + ) + .is_err(), + "reordering the predecessor manifest guards must fail" + ); + + let mut missing_source_guard = file.clone(); + let registry = missing_source_guard + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) if function.sig.ident == "validate_migration_registry" => { + Some(function) + } + _ => None, + }) + .expect("registry validator"); + registry.block.stmts.remove(3); + assert!( + validate_migration_registry_reachability( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &missing_source_guard, + ) + .is_err(), + "removing the SourceMaintenance descriptor guard must fail" + ); + + let mut reordered_source_guard = file.clone(); + let registry = reordered_source_guard + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) if function.sig.ident == "validate_migration_registry" => { + Some(function) + } + _ => None, + }) + .expect("registry validator"); + registry.block.stmts.swap(3, 4); + assert!( + validate_migration_registry_reachability( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &reordered_source_guard, + ) + .is_err(), + "moving the SourceMaintenance guard behind the range guard must fail" + ); + + let mut discarded_source_guard_result = file.clone(); + let registry = discarded_source_guard_result + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) if function.sig.ident == "validate_migration_registry" => { + Some(function) + } + _ => None, + }) + .expect("registry validator"); + let syn::Stmt::Expr(syn::Expr::If(source_guard), _) = &mut registry.block.stmts[3] else { + panic!("SourceMaintenance manifest guard"); + }; + strip_outer_try(&mut source_guard.then_branch.stmts[0]); + assert!( + validate_migration_registry_reachability( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &discarded_source_guard_result, ) .is_err(), - "moving the manifest guard behind the range guard must fail" + "discarding the SourceMaintenance descriptor validator Result must fail" ); let mut file = parse_canonical_production_rust( @@ -16186,44 +18187,44 @@ route!(r#hex); .is_err(), "discarding manifest SHA validation must fail" ); + + let mut source_descriptor_bypass = parse_canonical_production_rust( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + source.as_bytes(), + ) + .expect("migration AST"); + let descriptor = source_descriptor_bypass + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) + if function.sig.ident + == "validate_generated_source_maintenance_manifest_descriptor" => + { + Some(function) + } + _ => None, + }) + .expect("SourceMaintenance descriptor validator"); + descriptor.block = syn::parse_str("{ Ok(()) }").expect("no-op descriptor body"); + assert!( + validate_source_maintenance_manifest_validator_reachability( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &source_descriptor_bypass, + ) + .is_err(), + "a no-op SourceMaintenance descriptor body must fail while its registry call remains" + ); } #[test] fn migration_hook_loops_require_awaited_question_mark_propagation() { let source = repository_source(EVENT_STORE_SCHEMA_SOURCE_RELATIVE); - let validate = |file: &syn::File| -> Result<(), String> { - let apply_loop = exact_direct_for_loop( - EVENT_STORE_SCHEMA_SOURCE_RELATIVE, - file, - "migrate_schema_on_connection", - "migration", - "registry.iter().filter(|migration|migration.version>current_version)", - )?; - exact_direct_loop_awaited_call( - EVENT_STORE_SCHEMA_SOURCE_RELATIVE, - "migrate_schema_on_connection", - apply_loop, - "apply_migration_hook", - )?; - let validate_loop = exact_direct_for_loop( - EVENT_STORE_SCHEMA_SOURCE_RELATIVE, - file, - "validate_applied_migration_hooks", - "migration", - "registry.iter().filter(|migration|migration.version<=current)", - )?; - exact_direct_loop_awaited_call( - EVENT_STORE_SCHEMA_SOURCE_RELATIVE, - "validate_applied_migration_hooks", - validate_loop, - "validate_migration_hook_state", - )?; - Ok(()) - }; let file = parse_canonical_production_rust(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, source.as_bytes()) .expect("schema AST"); - validate(&file).expect("authoritative hook propagation"); + validate_migration_hook_loop_reachability(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &file) + .expect("authoritative hook propagation"); for (function_name, loop_statement_index) in [ ("migrate_schema_on_connection", 2usize), @@ -16255,7 +18256,11 @@ route!(r#hex); .expect("hook loop"); strip_outer_try(&mut loop_expression.body.stmts[loop_statement_index]); assert!( - validate(&file).is_err(), + validate_migration_hook_loop_reachability( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + &file, + ) + .is_err(), "discarding `{function_name}` hook Result must fail" ); } @@ -16299,39 +18304,319 @@ route!(r#hex); ); } - for (label, mutation) in [ - ( - "TEMP LIKE wildcard filter", - source.replacen( - "SELECT type, name, tbl_name FROM temp.sqlite_schema ORDER BY type, name, tbl_name", - "SELECT type, name, tbl_name FROM temp.sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name, tbl_name", - 1, - ), - ), - ( - "main catalog LIKE wildcard filter", - source.replacen( - "SELECT type, name, tbl_name, sql FROM main.sqlite_schema", - "SELECT type, name, tbl_name, sql FROM main.sqlite_schema WHERE name NOT LIKE 'sqlite_%'", - 1, - ), - ), - ] { - assert_ne!(mutation, source, "{label} fixture must mutate"); - let file = parse_canonical_production_rust( + let source_preflight = r#" if has_pending_source_maintenance_hook(&status, registry) { + validate_no_persisted_ephemeral_raw_rows_v1(&mut connection).await?; + } +"#; + let begin_immediate = + " let mut transaction = pool.begin_with(\"BEGIN IMMEDIATE\").await?;\n"; + let preflight_mutations = [ + ( + "removed SourceMaintenance persisted-ephemeral preflight", + source.replacen(source_preflight, "", 1), + ), + ( + "discarded SourceMaintenance persisted-ephemeral preflight Result", + source.replacen( + "validate_no_persisted_ephemeral_raw_rows_v1(&mut connection).await?;", + "validate_no_persisted_ephemeral_raw_rows_v1(&mut connection).await;", + 1, + ), + ), + ( + "SourceMaintenance preflight moved after BEGIN IMMEDIATE", + source.replacen(source_preflight, "", 1).replacen( + begin_immediate, + &format!("{begin_immediate}{source_preflight}"), + 1, + ), + ), + ( + "unguarded SourceMaintenance persisted-ephemeral preflight", + source.replacen( + source_preflight, + " validate_no_persisted_ephemeral_raw_rows_v1(&mut connection).await?;\n", + 1, + ), + ), + ]; + for (label, mutation) in preflight_mutations { + assert_ne!(mutation, source, "{label} fixture must mutate"); + let file = parse_canonical_production_rust( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + mutation.as_bytes(), + ) + .expect("mutated SourceMaintenance preflight AST"); + assert!( + validate_schema_runtime_reachability(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &file) + .is_err(), + "{label} must fail closed" + ); + } + + for (label, mutation) in [ + ( + "TEMP LIKE wildcard filter", + source.replacen( + "SELECT type, name, tbl_name FROM temp.sqlite_schema ORDER BY type, name, tbl_name", + "SELECT type, name, tbl_name FROM temp.sqlite_schema WHERE name NOT LIKE 'sqlite_%' ORDER BY type, name, tbl_name", + 1, + ), + ), + ( + "main catalog LIKE wildcard filter", + source.replacen( + "SELECT type, name, tbl_name, sql FROM main.sqlite_schema", + "SELECT type, name, tbl_name, sql FROM main.sqlite_schema WHERE name NOT LIKE 'sqlite_%'", + 1, + ), + ), + ] { + assert_ne!(mutation, source, "{label} fixture must mutate"); + let file = parse_canonical_production_rust( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + mutation.as_bytes(), + ) + .expect("mutated schema AST"); + let error = + validate_schema_runtime_reachability(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &file) + .err() + .expect("LIKE wildcard catalog filter must fail"); + assert!( + error.contains("authoritative schema runtime"), + "{label} produced unexpected error: {error}" + ); + } + } + + #[test] + fn sqlite_encoding_preflight_rejects_ordering_and_propagation_bypasses() { + let source = repository_source(EVENT_STORE_STORE_SOURCE_RELATIVE); + let baseline = + parse_canonical_production_rust(EVENT_STORE_STORE_SOURCE_RELATIVE, source.as_bytes()) + .expect("store AST"); + validate_sqlite_encoding_preflight_authority(EVENT_STORE_STORE_SOURCE_RELATIVE, &baseline) + .expect("authoritative SQLite encoding preflight"); + + for mutation in ["remove", "discard", "after_temp", "before_backing"] { + let mut file = baseline.clone(); + let configure_pool = file + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) if function.sig.ident == "configure_pool" => { + Some(function) + } + _ => None, + }) + .expect("configure_pool"); + let syn::Stmt::Expr(syn::Expr::ForLoop(preflight), _) = + &mut configure_pool.block.stmts[5] + else { + panic!("encoding preflight loop"); + }; + match mutation { + "remove" => { + preflight.body.stmts.remove(3); + } + "discard" => strip_outer_try(&mut preflight.body.stmts[3]), + "after_temp" => preflight.body.stmts.swap(3, 4), + "before_backing" => preflight.body.stmts.swap(2, 3), + _ => unreachable!(), + } + assert!( + validate_sqlite_encoding_preflight_authority( + EVENT_STORE_STORE_SOURCE_RELATIVE, + &file, + ) + .is_err(), + "SQLite encoding `{mutation}` bypass must fail closed" + ); + } + + let mut no_op = baseline; + let validator = no_op + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) + if function.sig.ident == "validate_main_database_encoding" => + { + Some(function) + } + _ => None, + }) + .expect("encoding validator"); + validator.block = syn::parse_str("{ Ok(()) }").expect("no-op encoding validator"); + assert!( + validate_sqlite_encoding_preflight_authority( + EVENT_STORE_STORE_SOURCE_RELATIVE, + &no_op, + ) + .is_err(), + "a no-op encoding validator must fail closed" + ); + } + + #[test] + fn source_generation_rollback_guard_rejects_policy_and_ordering_bypasses() { + let source = repository_source(EVENT_STORE_SCHEMA_SOURCE_RELATIVE); + let baseline = + parse_canonical_production_rust(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, source.as_bytes()) + .expect("schema AST"); + validate_source_generation_rollback_authority( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + &baseline, + ) + .expect("authoritative source-generation rollback guard"); + + let mut wrapper_bypass = baseline.clone(); + let wrapper = wrapper_bypass + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) + if function.sig.ident == "rollback_event_store_schema_with_registry" => + { + Some(function) + } + _ => None, + }) + .expect("production rollback wrapper"); + wrapper.block = syn::parse_str("{ Ok(()) }").expect("rollback wrapper bypass"); + assert!( + validate_source_generation_rollback_authority( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + &wrapper_bypass, + ) + .is_err(), + "a production rollback wrapper that omits `Preserve` must fail closed" + ); + + for mutation in ["remove", "discard", "after_down_loop"] { + let mut file = baseline.clone(); + let rollback = file + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) + if function.sig.ident == "rollback_schema_on_connection" => + { + Some(function) + } + _ => None, + }) + .expect("rollback implementation"); + match mutation { + "remove" => { + rollback.block.stmts.remove(2); + } + "discard" => { + let syn::Stmt::Expr(syn::Expr::If(guard), _) = &mut rollback.block.stmts[2] + else { + panic!("source-generation rollback guard"); + }; + strip_outer_try(&mut guard.then_branch.stmts[0]); + } + "after_down_loop" => rollback.block.stmts.swap(2, 3), + _ => unreachable!(), + } + assert!( + validate_source_generation_rollback_authority( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + &file, + ) + .is_err(), + "source-generation rollback `{mutation}` bypass must fail closed" + ); + } + + let mut policy_bypass = baseline.clone(); + let policy = policy_bypass + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Enum(item) if item.ident == "SourceGenerationHistoryRollbackPolicy" => { + Some(item) + } + _ => None, + }) + .expect("rollback policy enum"); + policy + .variants + .push(syn::parse_str("AllowDestructive").expect("production bypass variant")); + assert!( + validate_source_generation_rollback_authority( EVENT_STORE_SCHEMA_SOURCE_RELATIVE, - mutation.as_bytes(), + &policy_bypass, ) - .expect("mutated schema AST"); - let error = - validate_schema_runtime_reachability(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &file) - .err() - .expect("LIKE wildcard catalog filter must fail"); - assert!( - error.contains("authoritative schema runtime"), - "{label} produced unexpected error: {error}" - ); - } + .is_err(), + "a second production rollback policy must fail closed" + ); + + let mut no_op = baseline; + let validator = no_op + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) + if function.sig.ident + == "validate_rollback_preserves_source_generation_history" => + { + Some(function) + } + _ => None, + }) + .expect("rollback floor validator"); + validator.block = syn::parse_str("{ Ok(()) }").expect("no-op rollback validator"); + assert!( + validate_source_generation_rollback_authority( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + &no_op, + ) + .is_err(), + "a no-op rollback floor validator must fail closed" + ); + } + + #[test] + fn standalone_source_contract_rejects_marker_preserving_schema_early_return() { + let workspace = synthetic_workspace(); + super::super::source_maintenance::validate_schema_capacity_authority(workspace.path()) + .expect("baseline SourceMaintenance marker layer"); + + let schema_path = workspace.path().join(EVENT_STORE_SCHEMA_SOURCE_RELATIVE); + let source = fs::read_to_string(&schema_path).expect("schema source"); + let mut mutation = syn::parse_file(&source).expect("schema AST"); + let outer = mutation + .items + .iter_mut() + .find_map(|item| match item { + syn::Item::Fn(function) + if function.sig.ident + == "migrate_event_store_schema_with_registry_and_generation_provider" => + { + Some(function) + } + _ => None, + }) + .expect("outer migration route"); + outer.block.stmts.insert( + 0, + syn::parse_str("if std::hint::black_box(false) { return Ok(()); }") + .expect("marker-preserving early return"), + ); + fs::write(&schema_path, prettyplease::unparse(&mutation)) + .expect("write marker-preserving schema bypass"); + + super::super::source_maintenance::validate_schema_capacity_authority(workspace.path()) + .expect("marker-only layer intentionally preserves all ordered witnesses"); + let error = super::super::source_maintenance::validate_source_contract(workspace.path()) + .expect_err("standalone SourceMaintenance authority must reject the early return"); + assert!( + error.contains("authoritative schema runtime"), + "unexpected active governance error: {error}" + ); } #[test] @@ -16425,6 +18710,15 @@ route!(r#hex); let import_rebind = format!( "{import_rebind}\nasync fn validate_active_hook_state_fast(\n connection: &mut SqliteConnection,\n) -> Result<(), RadrootsEventStoreError> {{\n sqlx::query(\"DELETE FROM event_envelopes\").execute(&mut *connection).await?;\n Ok(())\n}}\n" ); + let catalog_delta_bypass = source.replacen( + "changed == expected_changed", + "changed.is_subset(&expected_changed)", + 1, + ); + assert_ne!( + catalog_delta_bypass, source, + "catalog-delta bypass fixture must mutate" + ); for (label, mutation) in [ ( @@ -16440,19 +18734,57 @@ route!(r#hex); prettyplease::unparse(&call_path_bypass), ), ("import-rebound hook validator", import_rebind), + ("widened replacement catalog delta", catalog_delta_bypass), ] { fs::write(&schema_path, mutation).expect("write schema authority mutation"); + let mutated_bytes = fs::read(&schema_path).expect("mutated schema bytes"); assert_ne!( - sha256_hex(&fs::read(&schema_path).expect("mutated schema bytes")), + sha256_hex(&mutated_bytes), baseline_sha256, "{label} must rotate the successor's exact schema source descriptor" ); + let mutated = + parse_canonical_production_rust(EVENT_STORE_SCHEMA_SOURCE_RELATIVE, &mutated_bytes) + .expect("mutated schema authority AST"); + let (structural_error, expected_error) = match label { + "malicious hookless migration arm" + | "bypassed migration SQL application" + | "widened replacement catalog delta" => ( + validate_schema_migration_execution_authority( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + &mutated, + ) + .expect_err("migration execution mutation must fail closed"), + "authoritative schema migration execution", + ), + "migration call-path early return" => ( + validate_schema_runtime_reachability( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + &mutated, + ) + .err() + .expect("migration call-path mutation must fail closed"), + "authoritative schema runtime", + ), + "import-rebound hook validator" => ( + validate_privileged_store_authority(workspace.path()) + .expect_err("hook-validator rebind must fail closed"), + "privileged", + ), + _ => unreachable!(), + }; + assert!( + structural_error.contains(expected_error), + "unexpected {label} structural error: {structural_error}" + ); + let active_error = + super::super::source_maintenance::validate_source_contract(workspace.path()) + .expect_err("standalone SourceMaintenance contract must reject schema bypass"); + assert!( + active_error.contains(expected_error), + "unexpected active {label} error: {active_error}" + ); 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, @@ -16487,6 +18819,78 @@ route!(r#hex); } #[test] + fn successor_import_authority_rejects_direct_external_rebindings() { + let workspace = synthetic_workspace(); + for (relative, label, needle, replacement) in [ + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "NIP-09 apply and validation routes", + "use crate::nip09::reconciliation_v1::{", + "use arbitrary_external::{", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "Food apply and validation routes", + "use crate::store::food_availability_projection_v1::{", + "use arbitrary_external::{", + ), + ( + EVENT_STORE_SCHEMA_SOURCE_RELATIVE, + "migration helper routes", + "use crate::migrations::{", + "use arbitrary_external::{", + ), + ( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + "NIP-09 generated manifest route", + "use crate::generated::nip09_reconciliation_manifest as nip09_manifest;", + "use arbitrary_external::nip09_reconciliation_manifest as nip09_manifest;", + ), + ( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + "Food generated manifest route", + "use crate::generated::food_availability_projection_manifest as food_manifest;", + "use arbitrary_external::food_availability_projection_manifest as food_manifest;", + ), + ( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + "SourceMaintenance generated manifest route", + "use crate::generated::source_maintenance_manifest;", + "use arbitrary_external::source_maintenance_manifest;", + ), + ] { + let path = workspace.path().join(relative); + let source = fs::read_to_string(&path).expect("successor import authority source"); + let mutation = source.replacen(needle, replacement, 1); + assert_ne!(mutation, source, "{label} fixture must mutate"); + fs::write(&path, &mutation).expect("write external import rebind"); + let mutation = parse_canonical_production_rust(relative, mutation.as_bytes()) + .expect("external import rebind AST"); + let structural_error = if relative == EVENT_STORE_SCHEMA_SOURCE_RELATIVE { + validate_event_store_schema_import_authority(relative, &mutation) + } else { + validate_event_store_migrations_import_authority(relative, &mutation) + } + .expect_err("external import rebind must fail exact route authority"); + assert!( + structural_error.contains("production top-level import authority"), + "unexpected {label} structural error: {structural_error}" + ); + let active_error = + super::super::source_maintenance::validate_source_contract(workspace.path()) + .expect_err( + "standalone SourceMaintenance contract must reject external rebind", + ); + assert!( + active_error.contains("production top-level import authority") + || active_error.contains("privileged terminal import"), + "unexpected active {label} error: {active_error}" + ); + fs::write(&path, source).expect("restore successor import authority source"); + } + } + + #[test] fn schema_name_matcher_witness_rejects_case_and_prefix_regressions() { let source = repository_source(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE); let file = parse_canonical_production_rust( @@ -17165,11 +19569,81 @@ route!(r#hex); .expect("current successor registry reachability"); validate_manifest_validator_reachability(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, &baseline) .expect("immutable predecessor descriptor reachability"); + validate_source_maintenance_manifest_validator_reachability( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &baseline, + ) + .expect("SourceMaintenance descriptor reachability"); + validate_source_maintenance_migration_bindings( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &baseline, + ) + .expect("SourceMaintenance v4 binding authority"); + validate_event_store_migrations_import_authority( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &baseline, + ) + .expect("migration import authority"); + validate_event_store_migration_support_authority( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &baseline, + ) + .expect("active migration support authority"); expected_event_store_migration_compiler_inputs(workspace.path(), &baseline) .expect("current versioned migration compiler inputs"); for (label, needle, replacement) in [ ( + "SourceMaintenance v4 version", + " version: 4,\n name: \"source_maintenance\",", + " version: 5,\n name: \"source_maintenance\",", + ), + ( + "SourceMaintenance v4 hook", + " hook: EventStoreMigrationHook::SourceMaintenanceV1,", + " hook: EventStoreMigrationHook::None,", + ), + ( + "SourceMaintenance v4 manifest hash", + " hook_manifest_sha256: Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256),", + " hook_manifest_sha256: Some(food_manifest::FOOD_AVAILABILITY_PROJECTION_MANIFEST_SHA256),", + ), + ( + "SourceMaintenance v4 registry version", + " source_maintenance_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION,", + " food_manifest::FOOD_AVAILABILITY_PROJECTION_EVENT_CONTRACT_REGISTRY_VERSION,", + ), + ( + "SourceMaintenance v4 replacement inventory binding", + " replaced_object_names: EVENT_STORE_SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES,", + " replaced_object_names: &[],", + ), + ] { + let mutation = source.replacen(needle, replacement, 1); + assert_ne!(mutation, source, "{label} fixture must mutate"); + fs::write(&migrations_path, &mutation).expect("write v4 authority mutation"); + let mutation = parse_canonical_production_rust( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + mutation.as_bytes(), + ) + .expect("mutated SourceMaintenance v4 AST"); + assert!( + expected_event_store_migration_compiler_inputs(workspace.path(), &mutation) + .is_err() + || validate_source_maintenance_migration_bindings( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &mutation, + ) + .is_err(), + "{label} drift must fail closed" + ); + super::super::source_maintenance::validate_source_contract(workspace.path()) + .expect_err("standalone SourceMaintenance contract must reject v4 binding drift"); + fs::write(&migrations_path, &source).expect("restore migration authority source"); + } + + for (label, needle, replacement) in [ + ( "ledger DDL", ") STRICT, WITHOUT ROWID\";", ") STRICT, WITHOUT ROWID /* authority mutation */\";", @@ -17209,6 +19683,26 @@ route!(r#hex); " let mut expected_version = minimum;", " let mut expected_version = { return Ok(()); minimum };", ), + ( + "duplicate hook reuse guard", + " if !migration_hook_ids.insert(migration.hook.id()) {", + " if false && !migration_hook_ids.insert(migration.hook.id()) {", + ), + ( + "canonical hook migration binding", + " if migration.version != canonical_version || migration.name != canonical_name {", + " if false && (migration.version != canonical_version || migration.name != canonical_name) {", + ), + ( + "exact predecessor replacement ownership", + " if prior_owners.len() != 1 {", + " if prior_owners.is_empty() {", + ), + ( + "predecessor table replacement prohibition", + " if prior_owner.owned_table_names.contains(object_name)\n || prior_owner.fts5_table_names.contains(object_name)", + " if prior_owner.owned_table_names.contains(object_name)\n && prior_owner.fts5_table_names.contains(object_name)", + ), ] { let mutation = source.replacen(needle, replacement, 1); assert_ne!(mutation, source, "{label} fixture must mutate"); @@ -17218,38 +19712,51 @@ route!(r#hex); baseline_sha256, "{label} must rotate the successor's exact migration source descriptor" ); + let mutation = parse_canonical_production_rust( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &fs::read(&migrations_path).expect("mutated migration bytes"), + ) + .expect("mutated migration support AST"); + let structural_error = match validate_event_store_migration_support_authority( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &mutation, + ) { + Ok(()) => panic!("{label} active migration support mutation must fail closed"), + Err(error) => error, + }; + assert!( + structural_error.contains("active migration support token authority"), + "unexpected {label} structural error: {structural_error}" + ); + let active_error = + super::super::source_maintenance::validate_source_contract(workspace.path()) + .expect_err( + "standalone SourceMaintenance contract must reject migration support drift", + ); + assert!( + active_error.contains("active migration support token authority"), + "unexpected active {label} error: {active_error}" + ); fs::write(&migrations_path, &source).expect("restore migration authority source"); } - let mut mutation = syn::parse_file(&source).expect("migration authority AST"); - let validator = mutation - .items - .iter_mut() - .find_map(|item| match item { - syn::Item::Fn(function) - if function.sig.ident == "validate_generated_nip09_manifest_descriptor" => - { - Some(function) - } - _ => None, - }) - .expect("generated-manifest validator"); - let initializer = validator - .block - .stmts - .iter_mut() - .find_map(|statement| match statement { - syn::Stmt::Local(local) - if local_pattern_ident(&local.pat).as_deref() == Some("up_byte_length") => - { - local.init.as_mut() - } - _ => None, - }) - .expect("up-byte-length initializer"); - *initializer.expr = - syn::parse_str("{ return Ok(()); 0_u64 }").expect("early-return initializer"); - let mutation = prettyplease::unparse(&mutation); + let mutation = source.replacen( + r#" let up_byte_length = u64::try_from( + nip09_manifest::NIP09_RECONCILIATION_MIGRATION_UP_BYTE_LENGTH, + ) + .map_err(|_| RadrootsEventStoreError::MigrationRegistryDefect { + reason: "generated NIP-09 migration up byte length is out of range".to_owned(), + })?;"#, + r#" let up_byte_length = { + return Ok(()); + 0_u64 + };"#, + 1, + ); + assert_ne!( + mutation, source, + "generated-manifest validator fixture must mutate only its initializer" + ); fs::write(&migrations_path, &mutation) .expect("write manifest-validator early-return mutation"); assert_ne!( @@ -17290,10 +19797,79 @@ route!(r#hex); .is_err(), "the structural divergence audit must reject an early return" ); + let structural_error = validate_event_store_migration_support_authority( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + &mutation, + ) + .expect_err("predecessor descriptor bypass must fail active support authority"); + assert!(structural_error.contains("active migration support token authority")); + let active_error = + super::super::source_maintenance::validate_source_contract(workspace.path()) + .expect_err("standalone SourceMaintenance contract must reject predecessor bypass"); + assert!( + active_error.contains("active migration support token authority"), + "unexpected active predecessor descriptor error: {active_error}" + ); fs::write(&migrations_path, &source).expect("restore migration authority source"); } #[test] + fn source_replacement_inventory_and_sql_restoration_are_active_authority() { + let workspace = synthetic_workspace(); + let migrations_path = workspace + .path() + .join(EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE); + let migrations = fs::read_to_string(&migrations_path).expect("migration registry source"); + let replacement_mutation = migrations.replacen( + " \"radroots_event_store_food_availability_image_delete_guard\",\n", + "", + 1, + ); + assert_ne!( + replacement_mutation, migrations, + "replacement inventory fixture must mutate" + ); + fs::write(&migrations_path, replacement_mutation) + .expect("write replacement inventory mutation"); + let error = super::super::source_maintenance::validate_source_contract(workspace.path()) + .expect_err("replacement inventory drift must fail active SourceMaintenance authority"); + assert!( + error.contains("migration catalog differs"), + "unexpected replacement inventory error: {error}" + ); + fs::write(&migrations_path, migrations).expect("restore migration registry source"); + + for (relative, label, needle, replacement) in [ + ( + "crates/event_store/migrations/0004_source_maintenance.up.sql", + "widened managed-v4 marker predicate", + " AND NEW.prior_last_transition_seq = state.last_transition_seq\n", + " AND NEW.prior_last_transition_seq >= state.last_transition_seq\n", + ), + ( + "crates/event_store/migrations/0004_source_maintenance.down.sql", + "omitted exact v3 marker restoration predicate", + " AND NEW.transition_floor_seq = state.last_transition_seq\n", + "", + ), + ] { + let path = workspace.path().join(relative); + let source = fs::read_to_string(&path).expect("replacement SQL source"); + let mutation = source.replacen(needle, replacement, 1); + assert_ne!(mutation, source, "{label} fixture must mutate"); + fs::write(&path, mutation).expect("write replacement SQL mutation"); + let error = + super::super::source_maintenance::validate_source_contract(workspace.path()) + .expect_err("replacement SQL drift must fail exact migration identity"); + assert!( + error.contains("reviewed v4 identity"), + "unexpected {label} error: {error}" + ); + fs::write(&path, source).expect("restore replacement SQL source"); + } + } + + #[test] fn hookless_future_migration_does_not_churn_nip09_v1_artifacts() { let workspace = synthetic_workspace(); let bundle_paths = [ @@ -17314,14 +19890,14 @@ route!(r#hex); fs::write( workspace .path() - .join("crates/event_store/migrations/0004_future_probe.up.sql"), + .join("crates/event_store/migrations/0005_future_probe.up.sql"), up_sql, ) .expect("write future up migration"); fs::write( workspace .path() - .join("crates/event_store/migrations/0004_future_probe.down.sql"), + .join("crates/event_store/migrations/0005_future_probe.down.sql"), down_sql, ) .expect("write future down migration"); @@ -17331,24 +19907,25 @@ route!(r#hex); .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 = 3;", "pub const RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT: u32 = 4;", + "pub const RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT: u32 = 5;", 1, ); let registry_tail = " },\n];\n\npub(crate) fn migration_for_version"; let future_entry = format!( r#" }}, EventStoreMigration {{ - version: 4, + version: 5, name: "future_probe", - up_sql: include_str!("../migrations/0004_future_probe.up.sql"), - down_sql: include_str!("../migrations/0004_future_probe.down.sql"), + up_sql: include_str!("../migrations/0005_future_probe.up.sql"), + down_sql: include_str!("../migrations/0005_future_probe.down.sql"), up_len: {}, down_len: {}, up_sha256: "{}", down_sha256: "{}", schema_sha256: "0000000000000000000000000000000000000000000000000000000000000000", owned_object_names: &["radroots_event_store_future_probe"], + replaced_object_names: &[], owned_table_names: &["radroots_event_store_future_probe"], fts5_table_names: &[], hook: EventStoreMigrationHook::None, @@ -17365,10 +19942,11 @@ pub(crate) fn migration_for_version"#, ); let migrations = migrations.replacen(registry_tail, &future_entry, 1); assert!( - migrations.contains("version: 4"), + migrations.contains("version: 5") + && migrations.contains("../migrations/0005_future_probe.up.sql"), "future migration fixture must extend the registry" ); - fs::write(&migrations_path, migrations).expect("write future migration registry"); + fs::write(&migrations_path, &migrations).expect("write future migration registry"); let registry = parse_canonical_production_rust( EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, @@ -17378,6 +19956,60 @@ pub(crate) fn migration_for_version"#, expected_event_store_migration_compiler_inputs(workspace.path(), &registry) .expect("versioned successor and isolated future compiler inputs"); + for (field, needle, replacement) in [ + ( + "hook_manifest_sha256", + "hook_manifest_sha256: None", + "hook_manifest_sha256: Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256)", + ), + ( + "event_contract_registry_version", + "event_contract_registry_version: None", + "event_contract_registry_version: Some(source_maintenance_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION)", + ), + ] { + let mut invalid = migrations.clone(); + let index = invalid + .rfind(needle) + .expect("future hookless field must be the final matching field"); + invalid.replace_range(index..index + needle.len(), replacement); + let invalid = parse_canonical_production_rust( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + invalid.as_bytes(), + ) + .expect("invalid future hook authority AST"); + let error = expected_event_store_migration_compiler_inputs(workspace.path(), &invalid) + .expect_err("hookless v5 migration must reject non-None authority"); + assert!( + error.contains(field) && error.contains("versioned hook authority"), + "unexpected v5 `{field}` error: {error}" + ); + } + + let mut invalid_replacements = migrations.clone(); + let needle = "replaced_object_names: &[]"; + let index = invalid_replacements + .rfind(needle) + .expect("future hookless replacement field must be the final matching field"); + invalid_replacements.replace_range( + index..index + needle.len(), + "replaced_object_names: &[\"radroots_event_store_food_availability_projection_delete_guard\"]", + ); + let invalid_replacements = parse_canonical_production_rust( + EVENT_STORE_MIGRATIONS_SOURCE_RELATIVE, + invalid_replacements.as_bytes(), + ) + .expect("invalid future replacement authority AST"); + let error = + expected_event_store_migration_compiler_inputs(workspace.path(), &invalid_replacements) + .expect_err("hookless v5 migration must reject predecessor replacements"); + assert!( + error.contains("hookless post-v2 migration 5") + && error.contains("predecessor replacements") + && error.contains("separately authenticated successor authority"), + "unexpected v5 replacement authority error: {error}" + ); + for (relative, before) in bundle_paths.into_iter().zip(before) { let after = read_regular_file(workspace.path(), relative).expect("future artifact"); assert_eq!( @@ -17388,7 +20020,7 @@ pub(crate) fn migration_for_version"#, } let future_up_path = workspace .path() - .join("crates/event_store/migrations/0004_future_probe.up.sql"); + .join("crates/event_store/migrations/0005_future_probe.up.sql"); for malicious_sql in [ "INSERT INTO event_envelopes(raw_json) VALUES ('{}');\n", "INSERT INTO 'event_envelopes'(raw_json) VALUES ('{}');\n", @@ -17431,9 +20063,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(), - 4, + 5, "up", - "crates/event_store/migrations/0004_future_probe.up.sql", + "crates/event_store/migrations/0005_future_probe.up.sql", ) .expect_err("hookless future migration must not couple to v1 authority"); assert!( diff --git a/tools/xtask/src/contract/source_maintenance.rs b/tools/xtask/src/contract/source_maintenance.rs @@ -0,0 +1,4284 @@ +#![allow(dead_code)] + +use super::artifact_bundle::{ + GeneratedArtifact, read_regular_file, with_artifact_bundle_transaction, +}; +use super::food_availability_projection::{ + validate_food_availability_projection_manifest_under_lock, + validate_food_availability_projection_predecessor_production_sources_under_lock, +}; +use super::nip09_reconciliation::validate_current_event_store_successor_authority; +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::{Expr, Item, UseTree}; + +const SCHEMA_VERSION: u32 = 1; +const CONTRACT_ID: &str = "radroots_event_store.source_maintenance_v1"; +const HOOK_ID: &str = "source_maintenance_v1"; +const MIGRATION_VERSION: u32 = 4; +const MIGRATION_NAME: &str = "source_maintenance"; +const CAPACITY_VERSION: u32 = 1; +const EVENT_CONTRACT_REGISTRY_VERSION: u32 = 7; +const CAPACITY_AUTHORITY_ID: &str = "radroots_event_store_source_capacity_v1"; +const ACCOUNTING_ALGORITHM: &str = "sqlite_cast_blob_octet_sum_v1"; +const REOPEN_VALIDATION_MODE: &str = "bounded_full_raw_recount_v1"; +const GENERATION_HISTORY_VALIDATION: &str = "bounded_count_plus_active_ordinal_v1"; +const RAW_EVENT_COUNT_LIMIT: u64 = 25_000; +const RAW_TAG_COUNT_LIMIT: u64 = 250_000; +const RAW_EVENT_TEXT_BYTES_LIMIT: u64 = 67_108_864; +const RAW_TAG_TEXT_BYTES_LIMIT: u64 = 33_554_432; +const RETAINED_SOURCE_GENERATION_LIMIT: u32 = 8; +const RAW_EVENT_REJECTION_SCAN_BOUND: u64 = RAW_EVENT_COUNT_LIMIT + 1; +const RAW_TAG_REJECTION_SCAN_BOUND: u64 = RAW_TAG_COUNT_LIMIT + 1; +const RETAINED_GENERATION_REJECTION_SCAN_BOUND: u32 = RETAINED_SOURCE_GENERATION_LIMIT + 1; +const SCHEMA_SHA256: &str = "d526d96ea02be12b4b0aed99e97cfdde17c4474ace67111506a7b900ee78b186"; +const HASH_ALGORITHM: &str = "sha256_bytes_v1"; +const WRITE_COMMAND: &str = "cargo xtask contract source-maintenance-manifest --write"; + +const PREDECESSOR_HOOK_ID: &str = "food_availability_projection_v1"; +const PREDECESSOR_MANIFEST_RELATIVE: &str = + "crates/event_store/contracts/food_availability_projection_v1.manifest.json"; +const PREDECESSOR_MANIFEST_BYTE_LENGTH: usize = 17_455; +const PREDECESSOR_MANIFEST_SHA256: &str = + "33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23"; +const NIP09_HOOK_ID: &str = "nip09_reconciliation_v1"; +const NIP09_MANIFEST_SHA256: &str = + "74af832420ffbaa9805e89df3c0b34f126a443e1598f757e3372f407f9003b77"; +const FOOD_SCOPE_FINGERPRINT_SHA256: &str = + "8b63c5ddc48a2cc7db69295238b96d5f814dba50427c80b4d0079f061e6d3de0"; +const ACTIVE_GENERATION_AUTHORITY: &str = "radroots_event_store_source_state"; +const MARKER_CLOSE_AUTHORITY: &str = "radroots_event_store_source_capacity_marker_close_guard"; + +const MANIFEST_RELATIVE: &str = "crates/event_store/contracts/source_maintenance_v1.manifest.json"; +const MANIFEST_SCHEMA_RELATIVE: &str = + "crates/event_store/contracts/source_maintenance_v1.manifest.schema.json"; +const MANIFEST_SHA256_RELATIVE: &str = + "crates/event_store/contracts/source_maintenance_v1.manifest.sha256"; +const GENERATED_DESCRIPTOR_RELATIVE: &str = + "crates/event_store/src/generated/source_maintenance_manifest.rs"; +const MIGRATIONS_SOURCE_RELATIVE: &str = "crates/event_store/src/migrations.rs"; +const MIGRATION_UP_RELATIVE: &str = "crates/event_store/migrations/0004_source_maintenance.up.sql"; +const MIGRATION_DOWN_RELATIVE: &str = + "crates/event_store/migrations/0004_source_maintenance.down.sql"; +const RESULT_VECTOR_CANONICAL_RELATIVE: &str = + "contracts/conformance/vectors/event_store/source_maintenance.v1.json"; +const RESULT_VECTOR_MIRROR_RELATIVE: &str = + "crates/event_store/tests/fixtures/source_maintenance.v1.json"; +const RESULT_VECTOR_EXECUTOR_RELATIVE: &str = + "crates/event_store/tests/source_maintenance_v1_result_vector.rs"; +const RESULT_VECTOR_EXECUTOR_ID: &str = + "radroots_event_store.source_maintenance_v1.result_vector_executor.v1"; +const RESULT_VECTOR_EXECUTOR_TEST: &str = "source_maintenance_v1_result_vector"; +const CONTRACT_COMMAND_SOURCE_RELATIVE: &str = "tools/xtask/src/contract.rs"; +const XTASK_MAIN_SOURCE_RELATIVE: &str = "tools/xtask/src/main.rs"; +const XTASK_MAIN_FULL_AST_SHA256: &str = + "b48c71c7f40f45c89bd7c83935d48eac3a1a367c8f73f62262e8ee14404616b4"; + +const RAW_EVENT_COLUMNS: &[&str] = &[ + "event_id", + "pubkey", + "tags_json", + "content", + "sig", + "raw_json", +]; +const RAW_TAG_COLUMNS: &[&str] = &["event_id", "tag_name", "tag_value", "tag_json"]; +const NULLABLE_RAW_TAG_COLUMNS: &[&str] = &["tag_value"]; + +const EXPECTED_CATALOG_OBJECTS: &[&str] = &[ + "radroots_event_store_source_capacity_delete_guard", + "radroots_event_store_source_capacity_insert_guard", + "radroots_event_store_source_capacity_marker_close_guard", + "radroots_event_store_source_capacity_update_guard", + "radroots_event_store_source_capacity_v1", + "radroots_event_store_source_generation_capacity_advance", + "radroots_event_store_source_generation_capacity_guard", +]; +const EXPECTED_CATALOG_TABLES: &[&str] = &["radroots_event_store_source_capacity_v1"]; +const EXPECTED_REPLACED_CATALOG_OBJECTS: &[&str] = &[ + "radroots_event_store_food_availability_image_delete_guard", + "radroots_event_store_food_availability_projection_delete_guard", + "radroots_event_store_source_rebuild_marker_insert_guard", +]; + +const INHERITED_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", +]; + +const ADDED_PUBLIC_API: &[&str] = &[ + "RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1", + "RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1", + "RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1", + "RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1", + "RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1", + "RadrootsEventStoreSourceCapacityResourceV1", + "RadrootsEventStoreSourceCapacityV1", +]; + +const PUBLIC_METHODS: &[&str] = &[ + "RadrootsEventStore::source_capacity_v1", + "RadrootsEventStoreSourceCapacityResourceV1::as_str", + "RadrootsEventStoreSourceCapacityV1::source_generation", + "RadrootsEventStoreSourceCapacityV1::raw_event_count", + "RadrootsEventStoreSourceCapacityV1::raw_tag_count", + "RadrootsEventStoreSourceCapacityV1::raw_event_text_bytes", + "RadrootsEventStoreSourceCapacityV1::raw_tag_text_bytes", + "RadrootsEventStoreSourceCapacityV1::raw_high_water_seq", + "RadrootsEventStoreSourceCapacityV1::retained_generation_count", + "RadrootsEventStoreSourceCapacityV1::retained_generation_limit", +]; +const ERROR_VARIANTS: &[&str] = &[ + "SourceCapacityExceeded", + "SourceGenerationHistoryLimitReached", + "PersistedEphemeralRawEvent", + "SourceCapacityStateDrift", + "SqliteMainDatabaseEncodingNotUtf8", + "RollbackWouldDiscardSourceGenerationHistory", +]; +const REMOVED_PUBLIC_API: &[&str] = &[ + "RadrootsEventStoreReconciliationResource", + "RadrootsEventStoreError::ReconciliationCapacityExceeded", +]; +const BREAKING_PUBLIC_API_REPLACEMENTS: &[(&str, &str)] = &[ + ( + "RadrootsEventStoreReconciliationResource", + "RadrootsEventStoreSourceCapacityResourceV1", + ), + ( + "RadrootsEventStoreError::ReconciliationCapacityExceeded", + "RadrootsEventStoreError::SourceCapacityExceeded", + ), +]; + +const GOVERNED_MODEL_MODULES: &[&str] = &[ + "addressable_transition_feed_v1", + "current_visibility_v1", + "food_availability_projection_v1", +]; + +const ENTRY_POINTS: &[(&str, &str)] = &[ + ( + "migration_registry", + "radroots_event_store::migrations::EVENT_STORE_MIGRATIONS[3]", + ), + ( + "migration_apply_hook", + "radroots_event_store::schema::apply_migration_hook", + ), + ( + "migration_validation_hook", + "radroots_event_store::schema::validate_migration_hook_state", + ), + ( + "capacity_query", + "radroots_event_store::RadrootsEventStore::source_capacity_v1", + ), + ( + "raw_append_preflight", + "radroots_event_store::source_maintenance_v1::preflight_unique_raw_source_append_v1", + ), + ( + "raw_append_advance", + "radroots_event_store::source_maintenance_v1::advance_source_capacity_after_insert_v1", + ), + ( + "generation_append_preflight", + "radroots_event_store::source_maintenance_v1::preflight_source_generation_append_v1", + ), + ( + "generation_rebuild_bind", + "radroots_event_store::source_maintenance_v1::bind_source_capacity_to_generation_v1", + ), + ( + "sqlite_encoding_preflight", + "radroots_event_store::store::validate_main_database_encoding", + ), + ( + "source_generation_history_rollback_guard", + "radroots_event_store::schema::validate_rollback_preserves_source_generation_history", + ), + ("result_vector_executor", RESULT_VECTOR_EXECUTOR_TEST), +]; + +#[derive(Clone, Copy)] +struct SourceSpec { + role: &'static str, + path: &'static str, +} + +const SOURCE_SPECS: &[SourceSpec] = &[ + SourceSpec { + role: "event_store_error_and_limits", + 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: "predecessor_model_public_surface", + path: "crates/event_store/src/model.rs", + }, + SourceSpec { + role: "source_generation_rebuild_authority", + path: "crates/event_store/src/nip09/reconciliation_v1.rs", + }, + SourceSpec { + role: "schema_migration_and_reopen_authority", + path: "crates/event_store/src/schema.rs", + }, + SourceSpec { + role: "public_store_and_transaction_authority", + path: "crates/event_store/src/store.rs", + }, + SourceSpec { + role: "raw_ingest_capacity_authority", + path: "crates/event_store/src/store/protocol_reconciliation_v1.rs", + }, + SourceSpec { + role: "source_maintenance_runtime", + path: "crates/event_store/src/source_maintenance_v1.rs", + }, + SourceSpec { + role: "artifact_transaction_authority", + path: "tools/xtask/src/contract/artifact_bundle.rs", + }, + SourceSpec { + role: "predecessor_successor_governance", + path: "tools/xtask/src/contract/food_availability_projection.rs", + }, + SourceSpec { + role: "transitive_predecessor_membership_governance", + path: "tools/xtask/src/contract/nip09_reconciliation.rs", + }, + SourceSpec { + role: "source_maintenance_governance", + path: "tools/xtask/src/contract/source_maintenance.rs", + }, + SourceSpec { + role: "contract_command_authority", + path: "tools/xtask/src/contract.rs", + }, + SourceSpec { + role: "xtask_dispatch_and_release_preflight", + path: "tools/xtask/src/main.rs", + }, +]; + +#[cfg(test)] +pub(super) fn source_contract_fixture_source_paths() -> Vec<&'static str> { + SOURCE_SPECS.iter().map(|source| source.path).collect() +} + +const PREDECESSOR_SUPERSEDED_SOURCE_PATHS: &[&str] = &[ + "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/protocol_reconciliation_v1.rs", +]; + +const GENERATED_ARTIFACT_PATHS: &[&str] = &[ + MANIFEST_RELATIVE, + MANIFEST_SCHEMA_RELATIVE, + MANIFEST_SHA256_RELATIVE, + GENERATED_DESCRIPTOR_RELATIVE, + RESULT_VECTOR_MIRROR_RELATIVE, +]; + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct SourceMaintenanceManifest { + schema_version: u32, + contract_id: String, + hook_id: String, + manifest_schema: FileDescriptor, + predecessor: PredecessorDescriptor, + migration: MigrationDescriptor, + source_maintenance: SourceMaintenanceDescriptor, + entry_points: Vec<EntryPointDescriptor>, + source_files: Vec<SourceFileDescriptor>, + public_api: PublicApiDescriptor, + 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>, + replaced_objects: Vec<String>, + tables: Vec<String>, + fts5_tables: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct SourceMaintenanceDescriptor { + version: u32, + event_contract_registry_version: u32, + capacity_authority_id: String, + accounting: AccountingDescriptor, + limits: LimitDescriptor, + reopen_validation: ReopenValidationDescriptor, + rebuild_seal: RebuildSealDescriptor, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct AccountingDescriptor { + algorithm: String, + raw_event_columns: Vec<String>, + raw_tag_columns: Vec<String>, + nullable_raw_tag_columns: Vec<String>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct LimitDescriptor { + raw_events: u64, + raw_tags: u64, + raw_event_text_bytes: u64, + raw_tag_text_bytes: u64, + retained_source_generations: u32, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct ReopenValidationDescriptor { + mode: String, + raw_event_rejection_scan_bound: u64, + raw_tag_rejection_scan_bound: u64, + generation_history_validation: String, + retained_generation_rejection_scan_bound: u32, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct RebuildSealDescriptor { + nip09_hook_id: String, + nip09_manifest_sha256: String, + food_hook_id: String, + food_manifest_sha256: String, + food_scope_fingerprint_sha256: String, + active_generation_authority: String, + marker_close_authority: 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 PublicApiDescriptor { + inherited_predecessor_symbols: Vec<String>, + added_symbols: Vec<String>, + methods: Vec<String>, + error_variants: Vec<String>, + removed_symbols: Vec<String>, + breaking_replacements: Vec<PublicApiReplacementDescriptor>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct PublicApiReplacementDescriptor { + removed: String, + replacement: 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(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct SourceMaintenanceVector { + schema_version: u32, + contract_id: String, + capacity_version: u32, + limits: LimitDescriptor, + accounting: AccountingDescriptor, + cases: Vec<VectorCase>, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields)] +struct VectorCase { + id: String, + execution: String, + authority: String, + authority_path: String, + resource: Option<String>, + boundary: Option<String>, + expected_outcome: String, + error_domain: Option<String>, + expected_error: Option<String>, +} + +pub(crate) fn write_source_maintenance_manifest(workspace_root: &Path) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |transaction| { + let artifacts = expected_artifacts(workspace_root)?; + transaction.write(artifacts)?; + validate_source_maintenance_manifest_under_lock(workspace_root) + }) +} + +pub(crate) fn validate_source_maintenance_manifest(workspace_root: &Path) -> Result<(), String> { + with_artifact_bundle_transaction(workspace_root, |_| { + validate_source_maintenance_manifest_under_lock(workspace_root) + }) +} + +pub(super) fn validate_source_maintenance_manifest_under_lock( + workspace_root: &Path, +) -> Result<(), String> { + 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)); + } + } + + 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: SourceMaintenanceManifest = serde_json::from_value(manifest_value.clone()) + .map_err(|error| format!("parse typed {MANIFEST_RELATIVE}: {error}"))?; + validate_canonical_json(MANIFEST_RELATIVE, &manifest_bytes, &manifest)?; + validate_manifest_shape(&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)?; + + let digest = read_regular_file(workspace_root, MANIFEST_SHA256_RELATIVE)?; + validate_digest_sidecar(MANIFEST_SHA256_RELATIVE, &digest)?; + if digest != format!("{}\n", sha256_hex(&manifest_bytes)).as_bytes() { + return Err(format!( + "{MANIFEST_SHA256_RELATIVE} must match the checked-in manifest bytes" + )); + } + + 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: SourceMaintenanceVector = 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(workspace_root, &vector)?; + 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<SourceMaintenanceManifest, String> { + validate_food_availability_projection_manifest_under_lock(workspace_root)?; + 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" + )); + } + validate_predecessor_public_api(&predecessor_bytes)?; + + let vector_bytes = read_regular_file(workspace_root, RESULT_VECTOR_CANONICAL_RELATIVE)?; + let vector: SourceMaintenanceVector = 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(workspace_root, &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 migration_up = descriptor_for_file(workspace_root, MIGRATION_UP_RELATIVE)?; + let migration_down = descriptor_for_file(workspace_root, MIGRATION_DOWN_RELATIVE)?; + validate_migration_identity(&migration_up, &migration_down)?; + + 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(SourceMaintenanceManifest { + 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: migration_up, + down: migration_down, + schema_sha256: SCHEMA_SHA256.to_owned(), + catalog, + }, + source_maintenance: SourceMaintenanceDescriptor { + version: CAPACITY_VERSION, + event_contract_registry_version: EVENT_CONTRACT_REGISTRY_VERSION, + capacity_authority_id: CAPACITY_AUTHORITY_ID.to_owned(), + accounting: AccountingDescriptor { + algorithm: ACCOUNTING_ALGORITHM.to_owned(), + raw_event_columns: owned(RAW_EVENT_COLUMNS), + raw_tag_columns: owned(RAW_TAG_COLUMNS), + nullable_raw_tag_columns: owned(NULLABLE_RAW_TAG_COLUMNS), + }, + limits: expected_limits(), + reopen_validation: ReopenValidationDescriptor { + mode: REOPEN_VALIDATION_MODE.to_owned(), + raw_event_rejection_scan_bound: RAW_EVENT_REJECTION_SCAN_BOUND, + raw_tag_rejection_scan_bound: RAW_TAG_REJECTION_SCAN_BOUND, + generation_history_validation: GENERATION_HISTORY_VALIDATION.to_owned(), + retained_generation_rejection_scan_bound: RETAINED_GENERATION_REJECTION_SCAN_BOUND, + }, + rebuild_seal: RebuildSealDescriptor { + nip09_hook_id: NIP09_HOOK_ID.to_owned(), + nip09_manifest_sha256: NIP09_MANIFEST_SHA256.to_owned(), + food_hook_id: PREDECESSOR_HOOK_ID.to_owned(), + food_manifest_sha256: PREDECESSOR_MANIFEST_SHA256.to_owned(), + food_scope_fingerprint_sha256: FOOD_SCOPE_FINGERPRINT_SHA256.to_owned(), + active_generation_authority: ACTIVE_GENERATION_AUTHORITY.to_owned(), + marker_close_authority: MARKER_CLOSE_AUTHORITY.to_owned(), + }, + }, + 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: expected_public_api(), + 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 expected_limits() -> LimitDescriptor { + LimitDescriptor { + raw_events: RAW_EVENT_COUNT_LIMIT, + raw_tags: RAW_TAG_COUNT_LIMIT, + raw_event_text_bytes: RAW_EVENT_TEXT_BYTES_LIMIT, + raw_tag_text_bytes: RAW_TAG_TEXT_BYTES_LIMIT, + retained_source_generations: RETAINED_SOURCE_GENERATION_LIMIT, + } +} + +fn expected_public_api() -> PublicApiDescriptor { + PublicApiDescriptor { + inherited_predecessor_symbols: owned(INHERITED_PUBLIC_API), + added_symbols: owned(ADDED_PUBLIC_API), + methods: owned(PUBLIC_METHODS), + error_variants: owned(ERROR_VARIANTS), + removed_symbols: owned(REMOVED_PUBLIC_API), + breaking_replacements: BREAKING_PUBLIC_API_REPLACEMENTS + .iter() + .map(|(removed, replacement)| PublicApiReplacementDescriptor { + removed: (*removed).to_owned(), + replacement: (*replacement).to_owned(), + }) + .collect(), + } +} + +fn owned(values: &[&str]) -> Vec<String> { + values.iter().map(|value| (*value).to_owned()).collect() +} + +fn validate_predecessor_production_source_coverage(workspace_root: &Path) -> Result<(), String> { + let source_paths = SOURCE_SPECS + .iter() + .map(|source| source.path) + .collect::<Vec<_>>(); + let unique_source_paths = source_paths.iter().copied().collect::<BTreeSet<_>>(); + if unique_source_paths.len() != source_paths.len() { + return Err("SourceMaintenance SOURCE_SPECS paths must be unique".to_owned()); + } + for path in PREDECESSOR_SUPERSEDED_SOURCE_PATHS { + let count = source_paths + .iter() + .filter(|candidate| **candidate == *path) + .count(); + if count != 1 { + return Err(format!( + "SourceMaintenance successor must current-byte-bind superseded predecessor path `{path}` exactly once; found {count}" + )); + } + } + let superseded = PREDECESSOR_SUPERSEDED_SOURCE_PATHS + .iter() + .copied() + .collect::<BTreeSet<_>>(); + if superseded.len() != PREDECESSOR_SUPERSEDED_SOURCE_PATHS.len() { + return Err("SourceMaintenance predecessor supersession paths must be unique".to_owned()); + } + validate_food_availability_projection_predecessor_production_sources_under_lock( + workspace_root, + PREDECESSOR_SUPERSEDED_SOURCE_PATHS, + ) +} + +fn validate_predecessor_public_api(predecessor_bytes: &[u8]) -> Result<(), String> { + let predecessor: Value = serde_json::from_slice(predecessor_bytes) + .map_err(|error| format!("parse {PREDECESSOR_MANIFEST_RELATIVE}: {error}"))?; + let actual = predecessor + .pointer("/public_api") + .and_then(Value::as_array) + .ok_or_else(|| format!("{PREDECESSOR_MANIFEST_RELATIVE} has no public_api array"))? + .iter() + .map(|value| { + value.as_str().map(str::to_owned).ok_or_else(|| { + format!("{PREDECESSOR_MANIFEST_RELATIVE} public_api values must be strings") + }) + }) + .collect::<Result<Vec<_>, String>>()?; + if actual != owned(INHERITED_PUBLIC_API) { + return Err( + "SourceMaintenance inherited public API must exactly equal the immutable FoodAvailability public API" + .to_owned(), + ); + } + Ok(()) +} + +fn descriptor_for_file(workspace_root: &Path, relative: &str) -> Result<FileDescriptor, String> { + descriptor_for_bytes(relative, &read_regular_file(workspace_root, relative)?) +} + +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!("{MIGRATIONS_SOURCE_RELATIVE} must be UTF-8: {error}"))?; + let syntax = syn::parse_file(source) + .map_err(|error| format!("parse {MIGRATIONS_SOURCE_RELATIVE}: {error}"))?; + Ok(CatalogDescriptor { + objects: extract_string_array_const( + &syntax, + "EVENT_STORE_SOURCE_MAINTENANCE_OBJECT_NAMES", + )?, + replaced_objects: extract_string_array_const( + &syntax, + "EVENT_STORE_SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES", + )?, + tables: extract_string_array_const(&syntax, "EVENT_STORE_SOURCE_MAINTENANCE_TABLE_NAMES")?, + fts5_tables: Vec::new(), + }) +} + +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()), + _ => None, + }) + .ok_or_else(|| format!("{MIGRATIONS_SOURCE_RELATIVE} must define `{name}`"))?; + let Expr::Array(array) = strip_expression_wrappers(expression) else { + return Err(format!( + "{MIGRATIONS_SOURCE_RELATIVE} `{name}` must be a literal array" + )); + }; + array + .elems + .iter() + .map(|element| match strip_expression_wrappers(element) { + Expr::Lit(syn::ExprLit { + lit: syn::Lit::Str(value), + .. + }) => Ok(value.value()), + _ => Err(format!( + "{MIGRATIONS_SOURCE_RELATIVE} `{name}` values must be string literals" + )), + }) + .collect() +} + +fn strip_expression_wrappers(mut expression: &Expr) -> &Expr { + loop { + match expression { + Expr::Reference(reference) => expression = &reference.expr, + Expr::Group(group) => expression = &group.expr, + Expr::Paren(paren) => expression = &paren.expr, + _ => return expression, + } + } +} + +fn validate_catalog(catalog: &CatalogDescriptor) -> Result<(), String> { + if catalog.objects != owned(EXPECTED_CATALOG_OBJECTS) + || catalog.replaced_objects != owned(EXPECTED_REPLACED_CATALOG_OBJECTS) + || catalog.tables != owned(EXPECTED_CATALOG_TABLES) + || !catalog.fts5_tables.is_empty() + { + return Err(format!( + "SourceMaintenance migration catalog differs: expected objects {:?}, replacements {:?}, tables {:?}, no FTS5; found {catalog:?}", + EXPECTED_CATALOG_OBJECTS, EXPECTED_REPLACED_CATALOG_OBJECTS, EXPECTED_CATALOG_TABLES, + )); + } + validate_unique( + "SourceMaintenance catalog objects", + catalog.objects.iter().map(String::as_str), + )?; + validate_unique( + "SourceMaintenance replaced catalog objects", + catalog.replaced_objects.iter().map(String::as_str), + )?; + validate_unique( + "SourceMaintenance catalog tables", + catalog.tables.iter().map(String::as_str), + )?; + if catalog + .replaced_objects + .iter() + .any(|name| catalog.objects.contains(name) || catalog.tables.contains(name)) + { + return Err( + "SourceMaintenance replaced catalog objects must be disjoint from newly owned objects and tables" + .to_owned(), + ); + } + Ok(()) +} + +fn validate_migration_identity(up: &FileDescriptor, down: &FileDescriptor) -> Result<(), String> { + const UP_BYTE_LENGTH: u64 = 19_841; + const UP_SHA256: &str = "425dc799f392b87f265a6fb81f89c4a1c7a5db8391ab0380970708cb9c66704d"; + const DOWN_BYTE_LENGTH: u64 = 5_172; + const DOWN_SHA256: &str = "fe44fd53c51545c08ea479b385e6781079dab70fc63da2a3c205d727a00ce860"; + if up.path != MIGRATION_UP_RELATIVE + || up.byte_length != UP_BYTE_LENGTH + || up.sha256 != UP_SHA256 + || down.path != MIGRATION_DOWN_RELATIVE + || down.byte_length != DOWN_BYTE_LENGTH + || down.sha256 != DOWN_SHA256 + { + return Err( + "SourceMaintenance migration bytes do not match the reviewed v4 identity".to_owned(), + ); + } + Ok(()) +} + +pub(super) fn validate_source_contract(workspace_root: &Path) -> Result<(), String> { + validate_source_inventory()?; + let migration_up = descriptor_for_file(workspace_root, MIGRATION_UP_RELATIVE)?; + let migration_down = descriptor_for_file(workspace_root, MIGRATION_DOWN_RELATIVE)?; + validate_migration_identity(&migration_up, &migration_down)?; + validate_public_api_authority(workspace_root)?; + validate_error_and_limit_authority(workspace_root)?; + validate_migration_registry_authority(workspace_root)?; + validate_capacity_runtime_authority(workspace_root)?; + validate_ingest_capacity_authority(workspace_root)?; + validate_schema_capacity_authority(workspace_root)?; + validate_generation_rebuild_authority(workspace_root)?; + validate_sql_capacity_authority(workspace_root)?; + validate_contract_command_reachability_authority(workspace_root)?; + validate_current_event_store_successor_authority(workspace_root) +} + +fn validate_contract_command_reachability_authority(workspace_root: &Path) -> Result<(), String> { + let contract = rust_source(workspace_root, CONTRACT_COMMAND_SOURCE_RELATIVE)?; + let main = rust_source(workspace_root, XTASK_MAIN_SOURCE_RELATIVE)?; + validate_contract_command_reachability_sources(&contract, &main) +} + +fn validate_contract_command_reachability_sources( + contract_source: &str, + main_source: &str, +) -> Result<(), String> { + let contract = syn::parse_file(contract_source) + .map_err(|error| format!("parse {CONTRACT_COMMAND_SOURCE_RELATIVE}: {error}"))?; + let main = syn::parse_file(main_source) + .map_err(|error| format!("parse {XTASK_MAIN_SOURCE_RELATIVE}: {error}"))?; + let main_ast_sha256 = sha256_hex(compact_tokens(&main).as_bytes()); + if main_ast_sha256 != XTASK_MAIN_FULL_AST_SHA256 { + return Err(format!( + "{XTASK_MAIN_SOURCE_RELATIVE} full dispatch AST authority drifted: expected {XTASK_MAIN_FULL_AST_SHA256}, found {main_ast_sha256}" + )); + } + for (relative, file, name, expected) in [ + ( + CONTRACT_COMMAND_SOURCE_RELATIVE, + &contract, + "validate_artifact_contracts", + r#"pub(crate) fn validate_artifact_contracts( + workspace_root: &Path + ) -> Result<(), String> { + validate_event_contract_registry_v7_inventory(workspace_root)?; + validate_nip09_reconciliation_manifest(workspace_root)?; + validate_food_availability_projection_manifest(workspace_root)?; + validate_source_maintenance_manifest(workspace_root)?; + validate_knowledge_contract_manifest(workspace_root) + }"#, + ), + ( + XTASK_MAIN_SOURCE_RELATIVE, + &main, + "validate_contract", + r#"fn validate_contract() -> Result<(), String> { + radroots_protocol_contract_v1::validate_protocol_contract_v1() + .map_err(|error| error.to_string())?; + let root = workspace_root(); + dto_roots::check(&root)?; + contract::load_contract_bundle(&root) + .and_then(|bundle| contract::validate_contract_bundle(&bundle)) + .and_then(|_| contract::validate_canonical_event_boundary(&root)) + .and_then(|_| contract::validate_artifact_contracts(&root)) + }"#, + ), + ( + XTASK_MAIN_SOURCE_RELATIVE, + &main, + "release_preflight_at", + r#"fn release_preflight_at(root: &Path) -> Result<(), String> { + dto_roots::check(root)?; + contract::validate_artifact_contracts(root)?; + contract::validate_release_preflight(root) + }"#, + ), + ] { + let actual = compact_tokens(exact_top_level_function(file, name)?); + let expected_file = syn::parse_file(expected) + .map_err(|error| format!("parse authoritative `{name}` function: {error}"))?; + let expected = compact_tokens(exact_top_level_function(&expected_file, name)?); + if actual != expected { + return Err(format!( + "{relative} `{name}` SourceMaintenance validation call-path authority drifted: expected `{expected}`, found `{actual}`" + )); + } + } + Ok(()) +} + +fn validate_source_inventory() -> Result<(), String> { + validate_unique( + "SourceMaintenance source roles", + SOURCE_SPECS.iter().map(|spec| spec.role), + )?; + validate_unique( + "SourceMaintenance source paths", + SOURCE_SPECS.iter().map(|spec| spec.path), + )?; + let source_paths = SOURCE_SPECS + .iter() + .map(|spec| spec.path) + .collect::<BTreeSet<_>>(); + for path in GENERATED_ARTIFACT_PATHS { + if source_paths.contains(path) { + return Err(format!( + "SourceMaintenance generated artifact `{path}` must not participate in its own source hash graph" + )); + } + } + if source_paths.contains(RESULT_VECTOR_MIRROR_RELATIVE) + || source_paths.contains(MANIFEST_RELATIVE) + || source_paths.contains(MANIFEST_SCHEMA_RELATIVE) + { + return Err("SourceMaintenance source inventory contains a self-hashed output".to_owned()); + } + Ok(()) +} + +fn validate_error_and_limit_authority(workspace_root: &Path) -> Result<(), String> { + let source = rust_source(workspace_root, "crates/event_store/src/error.rs")?; + validate_error_and_limit_source(&source) +} + +fn validate_error_and_limit_source(source: &str) -> Result<(), String> { + let syntax = syn::parse_file(source) + .map_err(|error| format!("parse crates/event_store/src/error.rs: {error}"))?; + let public_items = top_level_public_item_names(&syntax); + for symbol in &ADDED_PUBLIC_API[..6] { + if !public_items.contains(*symbol) { + return Err(format!( + "crates/event_store/src/error.rs must publicly define `{symbol}`" + )); + } + } + validate_source_capacity_resource_authority(&syntax)?; + validate_source_capacity_limit_authority(&syntax)?; + let error_enum = exact_top_level_enum(&syntax, "RadrootsEventStoreError")?; + if error_enum + .attrs + .iter() + .any(|attribute| attribute.path().is_ident("cfg") || attribute.path().is_ident("cfg_attr")) + { + return Err( + "top-level enum `RadrootsEventStoreError` must not have conditional attributes" + .to_owned(), + ); + } + let variants = error_enum + .variants + .iter() + .map(|variant| variant.ident.to_string()) + .collect::<BTreeSet<_>>(); + for variant in ERROR_VARIANTS { + if !variants.contains(*variant) { + return Err(format!( + "RadrootsEventStoreError must define SourceMaintenance variant `{variant}`" + )); + } + } + if variants.contains("ReconciliationCapacityExceeded") { + return Err( + "removed error variant `RadrootsEventStoreError::ReconciliationCapacityExceeded` must remain absent" + .to_owned(), + ); + } + for (name, expected) in [ + ( + "SourceCapacityExceeded", + r#"#[error( + "event-store retained source {resource} capacity exceeded: current {current}, requested additional {requested}, limit {limit}; durable append refused, retain a bounded source set in a new disposable cache" + )] + SourceCapacityExceeded { + resource: RadrootsEventStoreSourceCapacityResourceV1, + current: u64, + requested: u64, + limit: u64, + }"#, + ), + ( + "SourceGenerationHistoryLimitReached", + r#"#[error( + "event-store retained source generation limit reached: current {current}, limit {limit}; replace and resync into a fresh store" + )] + SourceGenerationHistoryLimitReached { current: u32, limit: u32 }"#, + ), + ( + "PersistedEphemeralRawEvent", + r#"#[error( + "event-store retained source contains ephemeral event `{event_id}` of kind {kind}; ephemeral events must be discarded" + )] + PersistedEphemeralRawEvent { event_id: String, kind: i64 }"#, + ), + ( + "SourceCapacityStateDrift", + r#"#[error("event-store retained source capacity authority is inconsistent: {reason}")] + SourceCapacityStateDrift { reason: String }"#, + ), + ( + "SqliteMainDatabaseEncodingNotUtf8", + r#"#[error( + "event-store SQLite main database must use UTF-8 encoding; reported `{actual}`" + )] + SqliteMainDatabaseEncodingNotUtf8 { actual: String }"#, + ), + ( + "RollbackWouldDiscardSourceGenerationHistory", + r#"#[error( + "event-store rollback from version {current} to {target} would discard retained source-generation history; minimum retained-history schema version is {floor}" + )] + RollbackWouldDiscardSourceGenerationHistory { + current: u32, + target: u32, + floor: u32, + }"#, + ), + ] { + let actual = error_enum + .variants + .iter() + .find(|variant| variant.ident == name) + .ok_or_else(|| format!("RadrootsEventStoreError must define `{name}`"))?; + let mut actual = actual.clone(); + actual + .attrs + .retain(|attribute| !attribute.path().is_ident("doc")); + let expected = syn::parse_str::<syn::ItemEnum>(&format!("enum Expected {{ {expected} }}")) + .map_err(|error| format!("parse authoritative `{name}` variant: {error}"))?; + let expected = expected + .variants + .first() + .expect("authoritative error enum contains one variant"); + if compact_tokens(&actual) != compact_tokens(expected) { + return Err(format!( + "RadrootsEventStoreError::{name} typed fields or display contract drifted" + )); + } + } + Ok(()) +} + +fn validate_source_capacity_limit_authority(file: &syn::File) -> Result<(), String> { + for (name, expected) in [ + ( + "RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1", + "pub const RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1: u64 = 25_000;", + ), + ( + "RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1", + "pub const RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1: u64 = 250_000;", + ), + ( + "RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1", + "pub const RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1: u64 = 64 * 1024 * 1024;", + ), + ( + "RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1", + "pub const RADROOTS_EVENT_STORE_RAW_TAG_TEXT_BYTES_LIMIT_V1: u64 = 32 * 1024 * 1024;", + ), + ( + "RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1", + "pub const RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1: u32 = 8;", + ), + ] { + let matches = file + .items + .iter() + .filter_map(|item| match item { + Item::Const(item) if item.ident == name => Some(item), + _ => None, + }) + .collect::<Vec<_>>(); + let [actual] = matches.as_slice() else { + return Err(format!( + "event-store capacity limit authority must define top-level `{name}` exactly once; found {}", + matches.len() + )); + }; + let mut actual = (*actual).clone(); + actual + .attrs + .retain(|attribute| !attribute.path().is_ident("doc")); + let expected = syn::parse_str::<syn::ItemConst>(expected) + .map_err(|error| format!("parse authoritative capacity limit `{name}`: {error}"))?; + if compact_tokens(&actual) != compact_tokens(&expected) { + return Err(format!( + "event-store capacity limit authority `{name}` visibility, attributes, type, or value drifted" + )); + } + } + Ok(()) +} + +fn validate_source_capacity_resource_authority(file: &syn::File) -> Result<(), String> { + let resources = file + .items + .iter() + .filter_map(|item| match item { + Item::Enum(item) if item.ident == "RadrootsEventStoreSourceCapacityResourceV1" => { + Some(item) + } + _ => None, + }) + .collect::<Vec<_>>(); + let [resource] = resources.as_slice() else { + return Err(format!( + "crates/event_store/src/error.rs must define `RadrootsEventStoreSourceCapacityResourceV1` exactly once; found {}", + resources.len() + )); + }; + let mut resource = (*resource).clone(); + resource + .attrs + .retain(|attribute| !attribute.path().is_ident("doc")); + for variant in &mut resource.variants { + variant + .attrs + .retain(|attribute| !attribute.path().is_ident("doc")); + } + let expected = syn::parse_str::<syn::ItemEnum>( + r#"#[derive(Clone, Copy, Debug, PartialEq, Eq)] + #[non_exhaustive] + pub enum RadrootsEventStoreSourceCapacityResourceV1 { + RawEvents, + RawTags, + RawEventBytes, + RawTagBytes, + }"#, + ) + .map_err(|error| format!("parse source-capacity resource authority: {error}"))?; + if compact_tokens(&resource) != compact_tokens(&expected) { + return Err( + "RadrootsEventStoreSourceCapacityResourceV1 variants, visibility, or attributes drifted" + .to_owned(), + ); + } + + let inherent = exact_top_level_impl(file, None, "RadrootsEventStoreSourceCapacityResourceV1")?; + let mut inherent = inherent.clone(); + strip_doc_attributes_from_impl(&mut inherent); + let expected = syn::parse_str::<syn::ItemImpl>( + r#"impl RadrootsEventStoreSourceCapacityResourceV1 { + pub const fn as_str(self) -> &'static str { + match self { + Self::RawEvents => "raw event count", + Self::RawTags => "raw tag count", + Self::RawEventBytes => "total retained raw-source event row text bytes", + Self::RawTagBytes => "total retained raw-source tag row text bytes", + } + } + }"#, + ) + .map_err(|error| format!("parse source-capacity label authority: {error}"))?; + if compact_tokens(&inherent) != compact_tokens(&expected) { + return Err( + "RadrootsEventStoreSourceCapacityResourceV1::as_str label authority drifted".to_owned(), + ); + } + + let display = exact_top_level_impl( + file, + Some("core::fmt::Display"), + "RadrootsEventStoreSourceCapacityResourceV1", + )?; + let mut display = display.clone(); + strip_doc_attributes_from_impl(&mut display); + let expected = syn::parse_str::<syn::ItemImpl>( + r#"impl core::fmt::Display for RadrootsEventStoreSourceCapacityResourceV1 { + fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + formatter.write_str(self.as_str()) + } + }"#, + ) + .map_err(|error| format!("parse source-capacity Display authority: {error}"))?; + if compact_tokens(&display) != compact_tokens(&expected) { + return Err( + "RadrootsEventStoreSourceCapacityResourceV1 Display authority drifted".to_owned(), + ); + } + Ok(()) +} + +fn validate_source_capacity_snapshot_authority(file: &syn::File) -> Result<(), String> { + let snapshots = file + .items + .iter() + .filter_map(|item| match item { + Item::Struct(item) if item.ident == "RadrootsEventStoreSourceCapacityV1" => Some(item), + _ => None, + }) + .collect::<Vec<_>>(); + let [snapshot] = snapshots.as_slice() else { + return Err(format!( + "crates/event_store/src/source_maintenance_v1.rs must define `RadrootsEventStoreSourceCapacityV1` exactly once; found {}", + snapshots.len() + )); + }; + let mut snapshot = (*snapshot).clone(); + snapshot + .attrs + .retain(|attribute| !attribute.path().is_ident("doc")); + for field in &mut snapshot.fields { + field + .attrs + .retain(|attribute| !attribute.path().is_ident("doc")); + } + let expected = syn::parse_str::<syn::ItemStruct>( + r#"#[derive(Clone, Copy, Debug, PartialEq, Eq)] + pub struct RadrootsEventStoreSourceCapacityV1 { + source_generation: RadrootsEventStoreSourceGeneration, + capacity: ReconciliationCapacity, + raw_high_water_seq: i64, + retained_generation_count: u32, + retained_generation_limit: u32, + }"#, + ) + .map_err(|error| format!("parse source-capacity snapshot authority: {error}"))?; + if compact_tokens(&snapshot) != compact_tokens(&expected) { + return Err( + "RadrootsEventStoreSourceCapacityV1 derives, visibility, or private field authority drifted" + .to_owned(), + ); + } + + let inherent = exact_top_level_impl(file, None, "RadrootsEventStoreSourceCapacityV1")?; + let mut inherent = inherent.clone(); + strip_doc_attributes_from_impl(&mut inherent); + let expected = syn::parse_str::<syn::ItemImpl>( + r#"impl RadrootsEventStoreSourceCapacityV1 { + pub const fn source_generation(&self) -> RadrootsEventStoreSourceGeneration { + self.source_generation + } + + pub const fn raw_event_count(&self) -> u64 { + self.capacity.raw_events + } + + pub const fn raw_tag_count(&self) -> u64 { + self.capacity.raw_tags + } + + pub const fn raw_event_text_bytes(&self) -> u64 { + self.capacity.raw_event_bytes + } + + pub const fn raw_tag_text_bytes(&self) -> u64 { + self.capacity.raw_tag_bytes + } + + pub const fn raw_high_water_seq(&self) -> i64 { + self.raw_high_water_seq + } + + pub const fn retained_generation_count(&self) -> u32 { + self.retained_generation_count + } + + pub const fn retained_generation_limit(&self) -> u32 { + self.retained_generation_limit + } + }"#, + ) + .map_err(|error| format!("parse source-capacity snapshot accessor authority: {error}"))?; + if compact_tokens(&inherent) != compact_tokens(&expected) { + return Err( + "RadrootsEventStoreSourceCapacityV1 public accessor authority drifted".to_owned(), + ); + } + Ok(()) +} + +fn exact_top_level_impl<'a>( + file: &'a syn::File, + trait_path: Option<&str>, + self_type: &str, +) -> Result<&'a syn::ItemImpl, String> { + let matches = file + .items + .iter() + .filter_map(|item| match item { + Item::Impl(item) + if compact_tokens(item.self_ty.as_ref()) == self_type + && item + .trait_ + .as_ref() + .map(|(_, path, _)| compact_tokens(path)) + .as_deref() + == trait_path => + { + Some(item) + } + _ => None, + }) + .collect::<Vec<_>>(); + let [item] = matches.as_slice() else { + return Err(format!( + "governed Rust source must define impl `{}` for `{self_type}` exactly once; found {}", + trait_path.unwrap_or("inherent"), + matches.len() + )); + }; + Ok(item) +} + +fn strip_doc_attributes_from_impl(item: &mut syn::ItemImpl) { + item.attrs + .retain(|attribute| !attribute.path().is_ident("doc")); + for member in &mut item.items { + if let syn::ImplItem::Fn(function) = member { + function + .attrs + .retain(|attribute| !attribute.path().is_ident("doc")); + } + } +} + +fn validate_migration_registry_authority(workspace_root: &Path) -> Result<(), String> { + let source = rust_source(workspace_root, MIGRATIONS_SOURCE_RELATIVE)?; + let compact = compact_rust(&source, MIGRATIONS_SOURCE_RELATIVE)?; + for marker in [ + "pubconstRADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT:u32=4", + "SourceMaintenanceV1", + "version:4", + "name:\"source_maintenance\"", + "up_len:source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_UP_BYTE_LENGTH", + "down_len:source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_DOWN_BYTE_LENGTH", + "up_sha256:source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_UP_SHA256", + "down_sha256:source_maintenance_manifest::SOURCE_MAINTENANCE_MIGRATION_DOWN_SHA256", + "schema_sha256:source_maintenance_manifest::SOURCE_MAINTENANCE_SCHEMA_SHA256", + "replaced_object_names:EVENT_STORE_SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES", + "hook_manifest_sha256:Some(source_maintenance_manifest::SOURCE_MAINTENANCE_MANIFEST_SHA256)", + "event_contract_registry_version:Some(source_maintenance_manifest::SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION,)", + ] { + require_marker("SourceMaintenance migration registry", &compact, marker)?; + } + let catalog = catalog_from_migration_source(source.as_bytes())?; + validate_catalog(&catalog) +} + +fn validate_capacity_runtime_authority(workspace_root: &Path) -> Result<(), String> { + let relative = "crates/event_store/src/source_maintenance_v1.rs"; + let source = rust_source(workspace_root, relative)?; + let syntax = syn::parse_file(&source).map_err(|error| format!("parse {relative}: {error}"))?; + validate_source_capacity_snapshot_authority(&syntax)?; + + let full = exact_free_function_tokens(&syntax, "validate_source_capacity_authority_full_v1")?; + require_ordered_markers( + "full SourceMaintenance reopen authority", + &full, + &[ + "validate_source_capacity_authority_fast_v1(connection).await?", + "measure_reconciliation_capacity_bounded(connection,ReconciliationCapacityLimits::production(),).await?", + "validate_measured_capacity(measured)?", + "validate_no_persisted_ephemeral_raw_rows_v1(connection).await?", + "ifmeasured!=persisted.capacity", + ], + )?; + + let fast = exact_free_function_tokens(&syntax, "validate_source_capacity_authority_fast_v1")?; + require_marker( + "bounded generation-history validation", + &fast, + "(SELECTCOUNT(*)FROM(SELECT1FROMradroots_event_store_source_generationLIMIT9))ASretained_generation_count", + )?; + require_marker( + "active generation ordinal validation", + &fast, + "generation.generation_ordinal", + )?; + if fast.contains("fetch_all") || fast.contains("Vec<") { + return Err( + "fast SourceMaintenance validation must not materialize generation history".to_owned(), + ); + } + + for function in [ + "raw_source_capacity_delta_v1", + "preflight_unique_raw_source_append_v1", + "advance_source_capacity_after_insert_v1", + "apply_source_maintenance_hook_v1", + "validate_source_capacity_authority_fast_v1", + "validate_source_capacity_authority_full_v1", + "validate_no_persisted_ephemeral_raw_rows_v1", + "preflight_source_generation_append_v1", + "bind_source_capacity_to_generation_v1", + ] { + exact_free_function_tokens(&syntax, function)?; + } + Ok(()) +} + +fn validate_ingest_capacity_authority(workspace_root: &Path) -> Result<(), String> { + let relative = "crates/event_store/src/store/protocol_reconciliation_v1.rs"; + let source = rust_source(workspace_root, relative)?; + let syntax = syn::parse_file(&source).map_err(|error| format!("parse {relative}: {error}"))?; + let ingest = exact_free_function_tokens(&syntax, "ingest_event_protocol_reconciliation_v1")?; + require_ordered_markers( + "SourceMaintenance ingest authority", + &ingest, + &[ + "acquire_event_store_write_lock(tx).await?", + "validate_source_raw_authority(tx).await?", + "validate_source_capacity_authority_fast_v1(tx).await?", + "ifkind_class==RadrootsEventKindClass::Ephemeral", + "SELECTEXISTS(SELECT1FROMevent_envelopesWHEREevent_id=?)", + "raw_source_capacity_delta_v1(ingest,tags_json.as_str())?", + "preflight_unique_raw_source_append_v1(tx,delta).await?", + "insert_raw_event", + "synchronize_after_insert", + "advance_source_capacity_after_insert_v1(tx,capacity_delta,insert.seq).await?", + "read_protocol_post_extension_authority_seal(tx).await?", + ], + ) +} + +pub(super) fn validate_schema_capacity_authority(workspace_root: &Path) -> Result<(), String> { + let relative = "crates/event_store/src/schema.rs"; + let source = rust_source(workspace_root, relative)?; + let syntax = syn::parse_file(&source).map_err(|error| format!("parse {relative}: {error}"))?; + let outer = exact_free_function_tokens( + &syntax, + "migrate_event_store_schema_with_registry_and_generation_provider", + )?; + require_ordered_markers( + "SourceMaintenance outer migration preflight", + &outer, + &[ + "inspect_event_store_schema_status_with_registry", + "ifhas_pending_source_capacity_hook", + "validate_event_store_temp_schema_with_registry", + "validate_reconciliation_capacity", + "ifhas_pending_source_maintenance_hook", + "validate_no_persisted_ephemeral_raw_rows_v1", + "begin_with(\"BEGINIMMEDIATE\")", + ], + )?; + let inner = exact_free_function_tokens(&syntax, "migrate_schema_on_connection")?; + require_ordered_markers( + "SourceMaintenance in-transaction migration recheck", + &inner, + &[ + "EventStoreMigrationHook::SourceMaintenanceV1", + "validate_reconciliation_capacity(connection,reconciliation_limits).await?", + "validate_no_persisted_ephemeral_raw_rows_v1(connection).await?", + "apply_migration_up(connection,registry,migration).await?", + "apply_migration_hook", + "validate_applied_migration_hooks", + "insert_ledger_row", + ], + )?; + let inspect = exact_free_function_tokens(&syntax, "inspect_schema_on_connection")?; + require_ordered_markers( + "managed-store reopen validation", + &inspect, + &[ + "validate_history_against_registry", + "ifactual_schema_sha256!=expected.schema_sha256", + "validate_applied_migration_hooks(connection,registry,current).await?", + "RadrootsEventStoreSchemaStatus::Managed", + ], + )?; + let hook = exact_free_function_tokens(&syntax, "validate_migration_hook_state")?; + require_ordered_markers( + "SourceMaintenance hook validation dispatch", + &hook, + &[ + "EventStoreMigrationHook::SourceMaintenanceV1", + "validate_source_capacity_authority_full_v1(connection).await", + ], + ) +} + +fn validate_generation_rebuild_authority(workspace_root: &Path) -> Result<(), String> { + let relative = "crates/event_store/src/nip09/reconciliation_v1.rs"; + let source = rust_source(workspace_root, relative)?; + let syntax = syn::parse_file(&source).map_err(|error| format!("parse {relative}: {error}"))?; + let rebuild = exact_free_function_tokens(&syntax, "apply_reconciliation_hook")?; + require_ordered_markers( + "SourceMaintenance source-generation rebuild authority", + &rebuild, + &[ + "preflight_source_generation_append_v1(connection).await?", + "open_source_rebuild_marker", + "append_source_generation", + "bind_source_capacity_to_generation_v1", + "apply_food_availability_projection_hook_v1", + "close_source_rebuild_marker", + "validate_sqlite_integrity_after_rebuild", + "validate_active_hook_state_fast", + ], + )?; + + let measure = exact_free_function_tokens(&syntax, "measure_reconciliation_capacity_bounded")?; + require_ordered_markers( + "SourceMaintenance bounded raw-source recount", + &measure, + &[ + "bounded_capacity_page_len(capacity.raw_events,limits.raw_events)", + ".bind(page_size)", + "ifrow_count<page_len", + "bounded_capacity_page_len(capacity.raw_tags,limits.raw_tags)", + ".bind(page_size)", + "ifrow_count<page_len", + ], + )?; + let page_len = exact_free_function_tokens(&syntax, "bounded_capacity_page_len")?; + require_ordered_markers( + "SourceMaintenance rejection-probe page bound", + &page_len, + &[ + "limit.saturating_sub(current)", + ".saturating_add(1)", + ".min(RECONCILIATION_SNAPSHOT_BATCH_COUNT)", + "i64::try_from(page_count).unwrap_or(RECONCILIATION_SNAPSHOT_BATCH_SIZE)", + "usize::try_from(page_count).unwrap_or(RECONCILIATION_SNAPSHOT_BATCH_LEN)", + ], + ) +} + +fn validate_sql_capacity_authority(workspace_root: &Path) -> Result<(), String> { + let sql = read_regular_file(workspace_root, MIGRATION_UP_RELATIVE)?; + let sql = std::str::from_utf8(&sql) + .map_err(|error| format!("{MIGRATION_UP_RELATIVE} must be UTF-8 SQL: {error}"))?; + for marker in [ + "DROP TRIGGER radroots_event_store_source_rebuild_marker_insert_guard", + "CREATE TRIGGER radroots_event_store_source_rebuild_marker_insert_guard", + "DROP TRIGGER radroots_event_store_food_availability_projection_delete_guard", + "CREATE TRIGGER radroots_event_store_food_availability_projection_delete_guard", + "DROP TRIGGER radroots_event_store_food_availability_image_delete_guard", + "CREATE TRIGGER radroots_event_store_food_availability_image_delete_guard", + "source.active_generation = marker.target_generation", + "OLD.source_generation != source.active_generation", + "raw_event_count >= 0 AND raw_event_count <= 25000", + "raw_tag_count >= 0 AND raw_tag_count <= 250000", + "raw_event_bytes >= 0 AND raw_event_bytes <= 67108864", + "raw_tag_bytes >= 0 AND raw_tag_bytes <= 33554432", + "retained_generation_count >= 1 AND retained_generation_count <= 8", + "retained_generation_limit = 8", + "CREATE TRIGGER radroots_event_store_source_generation_capacity_guard", + "retained_generation_count >= retained_generation_limit", + "CREATE TRIGGER radroots_event_store_source_generation_capacity_advance", + "CREATE TRIGGER radroots_event_store_source_capacity_marker_close_guard", + "33b93a3c87ce428e8aa6f5e92643c77203d9aa006c53ce96f3562fe6d68ffd23", + "8B63C5DDC48A2CC7DB69295238B96D5F814DBA50427C80B4D0079F061E6D3DE0", + "capacity.retained_generation_count = (\n SELECT COUNT(*)\n FROM (\n SELECT 1\n FROM radroots_event_store_source_generation\n LIMIT 9\n )\n )", + "generation.generation_ordinal = capacity.retained_generation_count", + ] { + require_marker("SourceMaintenance SQL capacity authority", sql, marker)?; + } + if sql.contains("AND NEW.transition_floor_seq = state.last_transition_seq") { + return Err( + "SourceMaintenance v4 marker replacement must derive the transition floor from retained transitions rather than stale source-state high-water" + .to_owned(), + ); + } + + let down = read_regular_file(workspace_root, MIGRATION_DOWN_RELATIVE)?; + let down = std::str::from_utf8(&down) + .map_err(|error| format!("{MIGRATION_DOWN_RELATIVE} must be UTF-8 SQL: {error}"))?; + for marker in [ + "DROP TRIGGER radroots_event_store_food_availability_image_delete_guard", + "CREATE TRIGGER radroots_event_store_food_availability_image_delete_guard", + "DROP TRIGGER radroots_event_store_food_availability_projection_delete_guard", + "CREATE TRIGGER radroots_event_store_food_availability_projection_delete_guard", + "DROP TRIGGER radroots_event_store_source_rebuild_marker_insert_guard", + "CREATE TRIGGER radroots_event_store_source_rebuild_marker_insert_guard", + "AND NEW.transition_floor_seq = state.last_transition_seq", + "event-store FoodAvailability image delete is not backed by a pending retraction", + "event-store FoodAvailability projection delete is not backed by a pending retraction", + ] { + require_marker( + "SourceMaintenance exact v3 SQL restoration authority", + down, + marker, + )?; + } + 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_source = rust_source(workspace_root, "crates/event_store/src/model.rs")?; + let lib_source = rust_source(workspace_root, "crates/event_store/src/lib.rs")?; + let error_source = rust_source(workspace_root, "crates/event_store/src/error.rs")?; + let maintenance_source = rust_source( + workspace_root, + "crates/event_store/src/source_maintenance_v1.rs", + )?; + let store_source = rust_source(workspace_root, "crates/event_store/src/store.rs")?; + validate_public_api_sources( + &model_source, + &lib_source, + &error_source, + &maintenance_source, + &store_source, + ) +} + +fn validate_public_api_sources( + model_source: &str, + lib_source: &str, + error_source: &str, + maintenance_source: &str, + store_source: &str, +) -> Result<(), String> { + let model = syn::parse_file(model_source) + .map_err(|error| format!("parse crates/event_store/src/model.rs: {error}"))?; + let lib = syn::parse_file(lib_source) + .map_err(|error| format!("parse crates/event_store/src/lib.rs: {error}"))?; + let error = syn::parse_file(error_source) + .map_err(|error| format!("parse crates/event_store/src/error.rs: {error}"))?; + let maintenance = syn::parse_file(maintenance_source).map_err(|error| { + format!("parse crates/event_store/src/source_maintenance_v1.rs: {error}") + })?; + let store = syn::parse_file(store_source) + .map_err(|error| format!("parse crates/event_store/src/store.rs: {error}"))?; + + let governed_modules = GOVERNED_MODEL_MODULES + .iter() + .copied() + .collect::<BTreeSet<_>>(); + let mut model_exports = BTreeSet::new(); + let mut represented_modules = BTreeSet::new(); + for route in collect_top_level_public_use_routes(&model) { + let Some(module) = route.segments.first().map(String::as_str) else { + continue; + }; + if !governed_modules.contains(module) { + continue; + } + if route.absolute || route.renamed || route.glob || route.segments.len() != 2 { + return Err(format!( + "model predecessor export `{}` must be a direct, non-renamed public re-export", + route.segments.join("::") + )); + } + represented_modules.insert(module.to_owned()); + if !model_exports.insert(route.exported_name.clone()) { + return Err(format!( + "model predecessor symbol `{}` is exported more than once", + route.exported_name + )); + } + } + let expected_modules = GOVERNED_MODEL_MODULES + .iter() + .map(|value| (*value).to_owned()) + .collect::<BTreeSet<_>>(); + let expected_inherited = INHERITED_PUBLIC_API + .iter() + .map(|value| (*value).to_owned()) + .collect::<BTreeSet<_>>(); + if represented_modules != expected_modules || model_exports != expected_inherited { + return Err(format!( + "model inherited FoodAvailability export authority differs: modules={represented_modules:?}, symbols={model_exports:?}" + )); + } + + let sqlite_cfg = "#[cfg(feature=\"sqlite\")]"; + let routes = collect_top_level_public_use_routes(&lib); + if routes + .iter() + .any(|route| route.exported_name == "RadrootsEventStoreReconciliationResource") + { + return Err( + "removed public symbol `RadrootsEventStoreReconciliationResource` must remain absent from the crate root" + .to_owned(), + ); + } + let mut expected_root_routes = BTreeMap::new(); + for symbol in INHERITED_PUBLIC_API { + expected_root_routes.insert((*symbol).to_owned(), "model"); + } + for symbol in &ADDED_PUBLIC_API[..6] { + expected_root_routes.insert((*symbol).to_owned(), "error"); + } + expected_root_routes.insert( + "RadrootsEventStoreSourceCapacityV1".to_owned(), + "source_maintenance_v1", + ); + for (symbol, expected_module) in expected_root_routes { + let matches = routes + .iter() + .filter(|route| route.exported_name == symbol) + .collect::<Vec<_>>(); + if matches.len() != 1 { + return Err(format!( + "crate root must export governed symbol `{symbol}` exactly once; found {}", + matches.len() + )); + } + let route = matches[0]; + if route.attributes.as_slice() != [sqlite_cfg] + || route.absolute + || route.renamed + || route.glob + || route.segments.len() != 2 + || route.segments[0] != expected_module + || route.segments[1] != symbol + { + return Err(format!( + "crate-root governed export `{symbol}` must be direct, non-renamed, sqlite-gated, and sourced from `{expected_module}`" + )); + } + } + + let error_public = top_level_public_item_names(&error); + if error_public.contains("RadrootsEventStoreReconciliationResource") { + return Err( + "removed public symbol `RadrootsEventStoreReconciliationResource` must remain absent from the error module" + .to_owned(), + ); + } + for symbol in &ADDED_PUBLIC_API[..6] { + if !error_public.contains(*symbol) { + return Err(format!("error module does not publicly define `{symbol}`")); + } + } + validate_source_capacity_snapshot_authority(&maintenance)?; + + let methods = associated_method_tokens(&store, "RadrootsEventStore", "source_capacity_v1")?; + if methods.len() != 1 { + return Err(format!( + "RadrootsEventStore must define source_capacity_v1 exactly once; found {}", + methods.len() + )); + } + let expected = syn::parse_str::<syn::ImplItemFn>( + r#"pub async fn source_capacity_v1( + &self, + ) -> Result<crate::RadrootsEventStoreSourceCapacityV1, RadrootsEventStoreError> { + let mut tx = self.pool.begin().await?; + let capacity = + crate::source_maintenance_v1::validate_source_capacity_authority_fast_v1( + &mut tx + ).await?; + tx.commit().await?; + Ok(capacity) + }"#, + ) + .map_err(|error| format!("parse authoritative source_capacity_v1 method: {error}"))?; + let expected = compact_tokens(&expected); + if methods[0] != expected { + return Err(format!( + "public capacity query signature or four-statement transaction authority drifted: expected `{expected}`, found `{}`", + methods[0] + )); + } + 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: &UseTree, + prefix: &mut Vec<String>, + absolute: bool, + attributes: &[String], + routes: &mut Vec<PublicUseRoute>, +) { + match tree { + UseTree::Path(path) => { + prefix.push(path.ident.to_string()); + flatten_public_use_tree(&path.tree, prefix, absolute, attributes, routes); + prefix.pop(); + } + UseTree::Name(name) => { + let mut segments = prefix.clone(); + segments.push(name.ident.to_string()); + routes.push(PublicUseRoute { + exported_name: name.ident.to_string(), + segments, + renamed: false, + glob: false, + absolute, + attributes: attributes.to_vec(), + }); + } + UseTree::Rename(rename) => { + let mut segments = prefix.clone(); + segments.push(rename.ident.to_string()); + routes.push(PublicUseRoute { + exported_name: rename.rename.to_string(), + segments, + renamed: true, + glob: false, + absolute, + attributes: attributes.to_vec(), + }); + } + UseTree::Glob(_) => routes.push(PublicUseRoute { + exported_name: "*".to_owned(), + segments: prefix.clone(), + renamed: false, + glob: true, + absolute, + attributes: attributes.to_vec(), + }), + UseTree::Group(group) => { + for item in &group.items { + flatten_public_use_tree(item, prefix, absolute, attributes, routes); + } + } + } +} + +fn top_level_public_item_names(file: &syn::File) -> BTreeSet<String> { + file.items + .iter() + .filter_map(|item| match item { + Item::Const(item) if matches!(item.vis, syn::Visibility::Public(_)) => { + Some(item.ident.to_string()) + } + Item::Enum(item) if matches!(item.vis, syn::Visibility::Public(_)) => { + Some(item.ident.to_string()) + } + Item::Fn(item) if matches!(item.vis, syn::Visibility::Public(_)) => { + Some(item.sig.ident.to_string()) + } + Item::Struct(item) if matches!(item.vis, syn::Visibility::Public(_)) => { + Some(item.ident.to_string()) + } + Item::Type(item) if matches!(item.vis, syn::Visibility::Public(_)) => { + Some(item.ident.to_string()) + } + _ => None, + }) + .collect() +} + +fn exact_top_level_enum<'a>(file: &'a syn::File, name: &str) -> Result<&'a syn::ItemEnum, String> { + let matches = file + .items + .iter() + .filter_map(|item| match item { + Item::Enum(item) if item.ident == name => Some(item), + _ => None, + }) + .collect::<Vec<_>>(); + let [item] = matches.as_slice() else { + return Err(format!( + "Rust source must define top-level enum `{name}` exactly once; found {}", + matches.len() + )); + }; + Ok(item) +} + +fn associated_method_tokens( + file: &syn::File, + owner: &str, + method: &str, +) -> Result<Vec<String>, String> { + let mut matches = Vec::new(); + for item in &file.items { + let Item::Impl(item_impl) = item else { + continue; + }; + if compact_tokens(item_impl.self_ty.as_ref()) != owner { + continue; + } + for item in &item_impl.items { + let syn::ImplItem::Fn(function) = item else { + continue; + }; + if function.sig.ident == method { + let mut function = function.clone(); + function.attrs.clear(); + matches.push(compact_tokens(&function)); + } + } + } + Ok(matches) +} + +struct FreeFunctionCollector<'name, 'ast> { + name: &'name str, + matches: Vec<&'ast syn::ItemFn>, +} + +impl<'ast> syn::visit::Visit<'ast> for FreeFunctionCollector<'_, 'ast> { + fn visit_item_fn(&mut self, function: &'ast syn::ItemFn) { + if function.sig.ident == self.name { + self.matches.push(function); + } + syn::visit::visit_item_fn(self, function); + } +} + +fn exact_free_function<'a>(file: &'a syn::File, name: &str) -> Result<&'a syn::ItemFn, String> { + use syn::visit::Visit; + let mut collector = FreeFunctionCollector { + name, + matches: Vec::new(), + }; + collector.visit_file(file); + if collector.matches.len() != 1 { + return Err(format!( + "governed Rust source must define free function `{name}` exactly once; found {}", + collector.matches.len() + )); + } + Ok(collector.matches.pop().expect("one function")) +} + +fn exact_free_function_tokens(file: &syn::File, name: &str) -> Result<String, String> { + Ok(compact_tokens(exact_free_function(file, name)?)) +} + +fn exact_top_level_function<'a>( + file: &'a syn::File, + name: &str, +) -> Result<&'a syn::ItemFn, String> { + let matches = file + .items + .iter() + .filter_map(|item| match item { + Item::Fn(function) if function.sig.ident == name => Some(function), + _ => None, + }) + .collect::<Vec<_>>(); + let [function] = matches.as_slice() else { + return Err(format!( + "governed Rust source must define top-level function `{name}` exactly once; found {}", + matches.len() + )); + }; + Ok(function) +} + +fn rust_source(workspace_root: &Path, relative: &str) -> Result<String, String> { + let bytes = read_regular_file(workspace_root, relative)?; + std::str::from_utf8(&bytes) + .map(str::to_owned) + .map_err(|error| format!("{relative} must be UTF-8 Rust: {error}")) +} + +fn compact_rust(source: &str, relative: &str) -> Result<String, String> { + let syntax = syn::parse_file(source).map_err(|error| format!("parse {relative}: {error}"))?; + Ok(compact_tokens(&syntax)) +} + +fn compact_tokens(tokens: &impl ToTokens) -> String { + tokens.to_token_stream().to_string().replace(' ', "") +} + +fn require_marker(label: &str, source: &str, marker: &str) -> Result<(), String> { + if !source.contains(marker) { + return Err(format!("{label} is missing exact witness `{marker}`")); + } + Ok(()) +} + +fn require_ordered_markers(label: &str, source: &str, markers: &[&str]) -> Result<(), String> { + let mut offset = 0; + for marker in markers { + let Some(found) = source[offset..].find(marker) else { + return Err(format!( + "{label} is missing ordered witness `{marker}` after byte {offset}" + )); + }; + offset += found + marker.len(); + } + Ok(()) +} + +fn validate_manifest_shape(manifest: &SourceMaintenanceManifest) -> Result<(), String> { + if manifest.schema_version != SCHEMA_VERSION + || manifest.contract_id != CONTRACT_ID + || manifest.hook_id != HOOK_ID + || manifest.manifest_schema.path != MANIFEST_SCHEMA_RELATIVE + || manifest.predecessor.hook_id != PREDECESSOR_HOOK_ID + || manifest.predecessor.manifest.path != PREDECESSOR_MANIFEST_RELATIVE + || manifest.predecessor.manifest.byte_length + != u64::try_from(PREDECESSOR_MANIFEST_BYTE_LENGTH) + .map_err(|_| "predecessor length does not fit u64".to_owned())? + || manifest.predecessor.manifest.sha256 != PREDECESSOR_MANIFEST_SHA256 + || manifest.migration.version != MIGRATION_VERSION + || manifest.migration.name != MIGRATION_NAME + || manifest.migration.up.path != MIGRATION_UP_RELATIVE + || manifest.migration.down.path != MIGRATION_DOWN_RELATIVE + || manifest.migration.schema_sha256 != SCHEMA_SHA256 + || manifest.source_maintenance + != (SourceMaintenanceDescriptor { + version: CAPACITY_VERSION, + event_contract_registry_version: EVENT_CONTRACT_REGISTRY_VERSION, + capacity_authority_id: CAPACITY_AUTHORITY_ID.to_owned(), + accounting: AccountingDescriptor { + algorithm: ACCOUNTING_ALGORITHM.to_owned(), + raw_event_columns: owned(RAW_EVENT_COLUMNS), + raw_tag_columns: owned(RAW_TAG_COLUMNS), + nullable_raw_tag_columns: owned(NULLABLE_RAW_TAG_COLUMNS), + }, + limits: expected_limits(), + reopen_validation: ReopenValidationDescriptor { + mode: REOPEN_VALIDATION_MODE.to_owned(), + raw_event_rejection_scan_bound: RAW_EVENT_REJECTION_SCAN_BOUND, + raw_tag_rejection_scan_bound: RAW_TAG_REJECTION_SCAN_BOUND, + generation_history_validation: GENERATION_HISTORY_VALIDATION.to_owned(), + retained_generation_rejection_scan_bound: + RETAINED_GENERATION_REJECTION_SCAN_BOUND, + }, + rebuild_seal: RebuildSealDescriptor { + nip09_hook_id: NIP09_HOOK_ID.to_owned(), + nip09_manifest_sha256: NIP09_MANIFEST_SHA256.to_owned(), + food_hook_id: PREDECESSOR_HOOK_ID.to_owned(), + food_manifest_sha256: PREDECESSOR_MANIFEST_SHA256.to_owned(), + food_scope_fingerprint_sha256: FOOD_SCOPE_FINGERPRINT_SHA256.to_owned(), + active_generation_authority: ACTIVE_GENERATION_AUTHORITY.to_owned(), + marker_close_authority: MARKER_CLOSE_AUTHORITY.to_owned(), + }, + }) + || manifest.public_api != expected_public_api() + { + return Err(format!( + "{MANIFEST_RELATIVE} has inconsistent SourceMaintenance identity or semantics" + )); + } + validate_catalog(&manifest.migration.catalog)?; + validate_migration_identity(&manifest.migration.up, &manifest.migration.down)?; + + let expected_entry_points = ENTRY_POINTS + .iter() + .map(|(role, rust_path)| EntryPointDescriptor { + role: (*role).to_owned(), + rust_path: (*rust_path).to_owned(), + }) + .collect::<Vec<_>>(); + if manifest.entry_points != expected_entry_points { + return Err(format!( + "{MANIFEST_RELATIVE} entry-point inventory is not exact" + )); + } + let expected_source_identity = SOURCE_SPECS + .iter() + .map(|spec| (spec.role, spec.path)) + .collect::<Vec<_>>(); + let actual_source_identity = manifest + .source_files + .iter() + .map(|source| (source.role.as_str(), source.path.as_str())) + .collect::<Vec<_>>(); + if actual_source_identity != expected_source_identity { + return Err(format!( + "{MANIFEST_RELATIVE} source-file inventory is not exact" + )); + } + validate_unique( + "SourceMaintenance manifest source roles", + manifest + .source_files + .iter() + .map(|source| source.role.as_str()), + )?; + validate_unique( + "SourceMaintenance manifest source paths", + manifest + .source_files + .iter() + .map(|source| source.path.as_str()), + )?; + for source in &manifest.source_files { + if GENERATED_ARTIFACT_PATHS.contains(&source.path.as_str()) { + return Err(format!( + "{MANIFEST_RELATIVE} recursively hashes generated artifact `{}`", + source.path + )); + } + validate_sha256(source.path.as_str(), source.sha256.as_str())?; + if source.hash_algorithm != HASH_ALGORITHM || source.byte_length == 0 { + return Err(format!( + "{MANIFEST_RELATIVE} source descriptor `{}` is invalid", + source.path + )); + } + } + for descriptor in [ + &manifest.manifest_schema, + &manifest.predecessor.manifest, + &manifest.migration.up, + &manifest.migration.down, + ] { + validate_sha256(descriptor.path.as_str(), descriptor.sha256.as_str())?; + if descriptor.hash_algorithm != HASH_ALGORITHM || descriptor.byte_length == 0 { + return Err(format!( + "{MANIFEST_RELATIVE} file descriptor `{}` is invalid", + descriptor.path + )); + } + } + validate_sha256( + "migration schema", + manifest.migration.schema_sha256.as_str(), + )?; + validate_sha256("result vector", manifest.result_vector.sha256.as_str())?; + validate_sha256( + "result-vector executor", + manifest.result_vector.executor_sha256.as_str(), + )?; + if manifest.result_vector.canonical_path != RESULT_VECTOR_CANONICAL_RELATIVE + || manifest.result_vector.mirror_path != RESULT_VECTOR_MIRROR_RELATIVE + || manifest.result_vector.hash_algorithm != HASH_ALGORITHM + || manifest.result_vector.executor_id != RESULT_VECTOR_EXECUTOR_ID + || manifest.result_vector.executor_path != RESULT_VECTOR_EXECUTOR_RELATIVE + || manifest.result_vector.executor_test != RESULT_VECTOR_EXECUTOR_TEST + || manifest.result_vector.executor_hash_algorithm != HASH_ALGORITHM + || manifest.result_vector.byte_length == 0 + || manifest.result_vector.executor_byte_length == 0 + { + return Err(format!( + "{MANIFEST_RELATIVE} result-vector descriptor is invalid" + )); + } + Ok(()) +} + +fn generated_descriptor( + manifest: &SourceMaintenanceManifest, + manifest_bytes: &[u8], + manifest_sha256: &str, +) -> String { + let manifest_json = std::str::from_utf8(manifest_bytes).expect("canonical manifest is UTF-8"); + let manifest_literal = format!("{manifest_json:?}"); + format!( + "// @generated by `cargo xtask contract source-maintenance-manifest --write`; do not edit.\n\ +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_JSON: &str = {manifest_literal};\n\ +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_BYTE_LENGTH: usize = {};\n\ +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SHA256: &str =\n \"{manifest_sha256}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_MANIFEST_SCHEMA_VERSION: u32 = {SCHEMA_VERSION};\n\ +pub(crate) const SOURCE_MAINTENANCE_CONTRACT_ID: &str =\n \"{CONTRACT_ID}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_HOOK_ID: &str = \"{HOOK_ID}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_VERSION: u32 = {MIGRATION_VERSION};\n\ +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_NAME: &str = \"{MIGRATION_NAME}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_UP_BYTE_LENGTH: usize = {};\n\ +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_UP_SHA256: &str =\n \"{}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_DOWN_BYTE_LENGTH: usize = {};\n\ +pub(crate) const SOURCE_MAINTENANCE_MIGRATION_DOWN_SHA256: &str =\n \"{}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_SCHEMA_SHA256: &str =\n \"{SCHEMA_SHA256}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_EVENT_CONTRACT_REGISTRY_VERSION: u32 = {EVENT_CONTRACT_REGISTRY_VERSION};\n\ +pub(crate) const SOURCE_MAINTENANCE_CAPACITY_VERSION: u32 = {CAPACITY_VERSION};\n\ +pub(crate) const SOURCE_MAINTENANCE_CAPACITY_AUTHORITY_ID: &str =\n \"{CAPACITY_AUTHORITY_ID}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_ACCOUNTING_ALGORITHM: &str = \"{ACCOUNTING_ALGORITHM}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_RAW_EVENT_COLUMNS: &[&str] = &{};\n\ +pub(crate) const SOURCE_MAINTENANCE_RAW_TAG_COLUMNS: &[&str] =\n &{};\n\ +pub(crate) const SOURCE_MAINTENANCE_NULLABLE_RAW_TAG_COLUMNS: &[&str] = &{};\n\ +pub(crate) const SOURCE_MAINTENANCE_REPLACED_OBJECT_NAMES: &[&str] = &{};\n\ +pub(crate) const SOURCE_MAINTENANCE_RAW_EVENT_COUNT_LIMIT: u64 = {RAW_EVENT_COUNT_LIMIT};\n\ +pub(crate) const SOURCE_MAINTENANCE_RAW_TAG_COUNT_LIMIT: u64 = {RAW_TAG_COUNT_LIMIT};\n\ +pub(crate) const SOURCE_MAINTENANCE_RAW_EVENT_TEXT_BYTES_LIMIT: u64 = {RAW_EVENT_TEXT_BYTES_LIMIT};\n\ +pub(crate) const SOURCE_MAINTENANCE_RAW_TAG_TEXT_BYTES_LIMIT: u64 = {RAW_TAG_TEXT_BYTES_LIMIT};\n\ +pub(crate) const SOURCE_MAINTENANCE_RETAINED_SOURCE_GENERATION_LIMIT: u32 = {RETAINED_SOURCE_GENERATION_LIMIT};\n\ +pub(crate) const SOURCE_MAINTENANCE_PREDECESSOR_HOOK_ID: &str = \"{PREDECESSOR_HOOK_ID}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_PREDECESSOR_MANIFEST_SHA256: &str =\n \"{PREDECESSOR_MANIFEST_SHA256}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_RESULT_VECTOR_SHA256: &str =\n \"{}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_RESULT_VECTOR_EXECUTOR_ID: &str =\n \"{RESULT_VECTOR_EXECUTOR_ID}\";\n\ +pub(crate) const SOURCE_MAINTENANCE_RESULT_VECTOR_EXECUTOR_SHA256: &str =\n \"{}\";\n", + manifest_bytes.len(), + usize::try_from(manifest.migration.up.byte_length).expect("up length fits usize"), + manifest.migration.up.sha256, + usize::try_from(manifest.migration.down.byte_length).expect("down length fits usize"), + manifest.migration.down.sha256, + rust_multiline_string_slice(RAW_EVENT_COLUMNS), + rust_string_slice(RAW_TAG_COLUMNS), + rust_string_slice(NULLABLE_RAW_TAG_COLUMNS), + rust_multiline_string_slice(EXPECTED_REPLACED_CATALOG_OBJECTS), + manifest.result_vector.sha256, + manifest.result_vector.executor_sha256, + ) +} + +fn rust_string_slice(values: &[&str]) -> String { + let values = values + .iter() + .map(|value| format!("{value:?}")) + .collect::<Vec<_>>() + .join(", "); + format!("[{values}]") +} + +fn rust_multiline_string_slice(values: &[&str]) -> String { + let values = values + .iter() + .map(|value| format!(" {value:?},")) + .collect::<Vec<_>>() + .join("\n"); + format!("[\n{values}\n]") +} + +fn manifest_schema() -> Value { + let path_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": {"type": "string", "pattern": path_pattern}, + "byte_length": {"type": "integer", "minimum": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "hash_algorithm": {"const": HASH_ALGORITHM} + }, + "additionalProperties": false + }); + let string_array = json!({ + "type": "array", + "items": {"type": "string", "minLength": 1} + }); + json!({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://radroots.org/contracts/event-store/source-maintenance-v1-manifest.schema.json", + "title": "Radroots event-store SourceMaintenance v1 manifest", + "type": "object", + "required": [ + "schema_version", "contract_id", "hook_id", "manifest_schema", "predecessor", + "migration", "source_maintenance", "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", "replaced_objects", "tables", "fts5_tables"], + "properties": { + "objects": string_array.clone(), + "replaced_objects": string_array.clone(), + "tables": string_array.clone(), + "fts5_tables": string_array.clone() + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "source_maintenance": { + "type": "object", + "required": [ + "version", "event_contract_registry_version", "capacity_authority_id", + "accounting", "limits", "reopen_validation", "rebuild_seal" + ], + "properties": { + "version": {"const": CAPACITY_VERSION}, + "event_contract_registry_version": {"const": EVENT_CONTRACT_REGISTRY_VERSION}, + "capacity_authority_id": {"const": CAPACITY_AUTHORITY_ID}, + "accounting": {"$ref": "#/$defs/accounting"}, + "limits": {"$ref": "#/$defs/limits"}, + "reopen_validation": { + "type": "object", + "required": [ + "mode", "raw_event_rejection_scan_bound", + "raw_tag_rejection_scan_bound", "generation_history_validation", + "retained_generation_rejection_scan_bound" + ], + "properties": { + "mode": {"const": REOPEN_VALIDATION_MODE}, + "raw_event_rejection_scan_bound": { + "const": RAW_EVENT_REJECTION_SCAN_BOUND + }, + "raw_tag_rejection_scan_bound": { + "const": RAW_TAG_REJECTION_SCAN_BOUND + }, + "generation_history_validation": {"const": GENERATION_HISTORY_VALIDATION}, + "retained_generation_rejection_scan_bound": { + "const": RETAINED_GENERATION_REJECTION_SCAN_BOUND + } + }, + "additionalProperties": false + }, + "rebuild_seal": { + "type": "object", + "required": [ + "nip09_hook_id", "nip09_manifest_sha256", "food_hook_id", + "food_manifest_sha256", "food_scope_fingerprint_sha256", + "active_generation_authority", "marker_close_authority" + ], + "properties": { + "nip09_hook_id": {"const": NIP09_HOOK_ID}, + "nip09_manifest_sha256": {"const": NIP09_MANIFEST_SHA256}, + "food_hook_id": {"const": PREDECESSOR_HOOK_ID}, + "food_manifest_sha256": {"const": PREDECESSOR_MANIFEST_SHA256}, + "food_scope_fingerprint_sha256": {"const": FOOD_SCOPE_FINGERPRINT_SHA256}, + "active_generation_authority": {"const": ACTIVE_GENERATION_AUTHORITY}, + "marker_close_authority": {"const": MARKER_CLOSE_AUTHORITY} + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "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": { + "type": "object", + "required": [ + "inherited_predecessor_symbols", "added_symbols", "methods", "error_variants", + "removed_symbols", "breaking_replacements" + ], + "properties": { + "inherited_predecessor_symbols": string_array.clone(), + "added_symbols": string_array.clone(), + "methods": string_array.clone(), + "error_variants": string_array.clone(), + "removed_symbols": string_array.clone(), + "breaking_replacements": { + "type": "array", + "items": { + "type": "object", + "required": ["removed", "replacement"], + "properties": { + "removed": {"type": "string", "minLength": 1}, + "replacement": {"type": "string", "minLength": 1} + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + }, + "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": path_pattern}, + "byte_length": {"type": "integer", "minimum": 1}, + "sha256": {"type": "string", "pattern": "^[0-9a-f]{64}$"}, + "hash_algorithm": {"const": HASH_ALGORITHM} + }, + "additionalProperties": false + }, + "accounting": { + "type": "object", + "required": [ + "algorithm", "raw_event_columns", "raw_tag_columns", "nullable_raw_tag_columns" + ], + "properties": { + "algorithm": {"const": ACCOUNTING_ALGORITHM}, + "raw_event_columns": string_array.clone(), + "raw_tag_columns": string_array.clone(), + "nullable_raw_tag_columns": string_array.clone() + }, + "additionalProperties": false + }, + "limits": { + "type": "object", + "required": [ + "raw_events", "raw_tags", "raw_event_text_bytes", "raw_tag_text_bytes", + "retained_source_generations" + ], + "properties": { + "raw_events": {"const": RAW_EVENT_COUNT_LIMIT}, + "raw_tags": {"const": RAW_TAG_COUNT_LIMIT}, + "raw_event_text_bytes": {"const": RAW_EVENT_TEXT_BYTES_LIMIT}, + "raw_tag_text_bytes": {"const": RAW_TAG_TEXT_BYTES_LIMIT}, + "retained_source_generations": {"const": RETAINED_SOURCE_GENERATION_LIMIT} + }, + "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> { + let canonical = canonical_json_bytes(value)?; + if actual != canonical { + return Err(format!( + "{relative} must use canonical pretty JSON with one trailing newline" + )); + } + 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 !seen.insert(value) { + return Err(format!("{label} contains duplicate `{value}`")); + } + } + Ok(()) +} + +fn validate_digest_sidecar(relative: &str, bytes: &[u8]) -> Result<(), String> { + let value = + std::str::from_utf8(bytes).map_err(|error| format!("{relative} must be UTF-8: {error}"))?; + let Some(digest) = value.strip_suffix('\n') else { + return Err(format!("{relative} must end in exactly one newline")); + }; + if digest.contains('\n') || digest.contains('\r') { + return Err(format!("{relative} must contain one SHA-256 digest line")); + } + validate_sha256(relative, digest) +} + +fn validate_sha256(label: &str, value: &str) -> Result<(), String> { + if value.len() != 64 + || !value + .as_bytes() + .iter() + .all(|byte| byte.is_ascii_digit() || matches!(byte, b'a'..=b'f')) + { + return Err(format!("{label} must be a lowercase 64-hex SHA-256 digest")); + } + 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}`") +} + +#[derive(Clone, Copy)] +struct ExpectedVectorCase { + id: &'static str, + execution: &'static str, + authority: &'static str, + authority_path: &'static str, + resource: Option<&'static str>, + boundary: Option<&'static str>, + expected_outcome: &'static str, + error_domain: Option<&'static str>, + expected_error: Option<&'static str>, +} + +const DIRECT_EXECUTOR: &str = "direct_executor"; +const DELEGATED_RUST_TEST: &str = "delegated_rust_test"; +const DELEGATED_SQL_TEST: &str = "delegated_sql_test"; + +const EXPECTED_VECTOR_CASES: &[ExpectedVectorCase] = &[ + ExpectedVectorCase { + id: "fresh_store_zero_authority", + execution: DIRECT_EXECUTOR, + authority: RESULT_VECTOR_EXECUTOR_TEST, + authority_path: RESULT_VECTOR_EXECUTOR_RELATIVE, + resource: None, + boundary: None, + expected_outcome: "accepted", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "durable_unique_append_updates_all_dimensions", + execution: DIRECT_EXECUTOR, + authority: RESULT_VECTOR_EXECUTOR_TEST, + authority_path: RESULT_VECTOR_EXECUTOR_RELATIVE, + resource: None, + boundary: None, + expected_outcome: "accepted", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "duplicate_at_exact_boundary_is_idempotent", + execution: DELEGATED_RUST_TEST, + authority: "exact_capacity_boundary_allows_duplicate_observation_and_ephemeral_noop", + authority_path: "crates/event_store/src/store.rs", + resource: Some("raw_events"), + boundary: Some("exact"), + expected_outcome: "accepted_without_capacity_delta", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "ephemeral_consumes_no_capacity", + execution: DIRECT_EXECUTOR, + authority: RESULT_VECTOR_EXECUTOR_TEST, + authority_path: RESULT_VECTOR_EXECUTOR_RELATIVE, + resource: None, + boundary: None, + expected_outcome: "accepted_without_capacity_delta", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "raw_event_count_exact", + execution: DELEGATED_RUST_TEST, + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("raw_events"), + boundary: Some("exact"), + expected_outcome: "accepted", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "raw_event_count_one_over", + execution: DELEGATED_RUST_TEST, + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("raw_events"), + boundary: Some("one_over"), + expected_outcome: "rejected_before_mutation", + error_domain: Some("typed"), + expected_error: Some("SourceCapacityExceeded"), + }, + ExpectedVectorCase { + id: "raw_tag_count_exact", + execution: DELEGATED_RUST_TEST, + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("raw_tags"), + boundary: Some("exact"), + expected_outcome: "accepted", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "raw_tag_count_one_over", + execution: DELEGATED_RUST_TEST, + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("raw_tags"), + boundary: Some("one_over"), + expected_outcome: "rejected_before_mutation", + error_domain: Some("typed"), + expected_error: Some("SourceCapacityExceeded"), + }, + ExpectedVectorCase { + id: "raw_event_text_bytes_exact", + execution: DELEGATED_RUST_TEST, + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("raw_event_text_bytes"), + boundary: Some("exact"), + expected_outcome: "accepted", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "raw_event_text_bytes_one_over", + execution: DELEGATED_RUST_TEST, + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("raw_event_text_bytes"), + boundary: Some("one_over"), + expected_outcome: "rejected_before_mutation", + error_domain: Some("typed"), + expected_error: Some("SourceCapacityExceeded"), + }, + ExpectedVectorCase { + id: "raw_tag_text_bytes_exact", + execution: DELEGATED_RUST_TEST, + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("raw_tag_text_bytes"), + boundary: Some("exact"), + expected_outcome: "accepted", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "raw_tag_text_bytes_one_over", + execution: DELEGATED_RUST_TEST, + authority: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("raw_tag_text_bytes"), + boundary: Some("one_over"), + expected_outcome: "rejected_before_mutation", + error_domain: Some("typed"), + expected_error: Some("SourceCapacityExceeded"), + }, + ExpectedVectorCase { + id: "outer_transaction_rollback_restores_capacity", + execution: DIRECT_EXECUTOR, + authority: RESULT_VECTOR_EXECUTOR_TEST, + authority_path: RESULT_VECTOR_EXECUTOR_RELATIVE, + resource: None, + boundary: None, + expected_outcome: "rolled_back_without_capacity_delta", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "failed_nested_ingest_rolls_back_savepoint_only", + execution: DELEGATED_RUST_TEST, + authority: "borrowed_ingest_savepoint_rolls_back_post_core_authority_forge", + authority_path: "crates/event_store/src/store.rs", + resource: None, + boundary: None, + expected_outcome: "failed_ingest_rolled_back_and_prior_caller_work_preserved", + error_domain: Some("typed"), + expected_error: Some("MigrationHookStateDrift"), + }, + ExpectedVectorCase { + id: "v3_to_v4_under_limit_succeeds", + execution: DELEGATED_RUST_TEST, + authority: "v3_to_v4_under_limit_backfills_exact_capacity_and_preserves_source", + authority_path: "crates/event_store/src/schema.rs", + resource: None, + boundary: Some("under_limit"), + expected_outcome: "accepted", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "v3_to_v4_prior_transition_drift_is_atomic", + execution: DELEGATED_RUST_TEST, + authority: "v3_to_v4_rejects_prior_transition_drift_atomically", + authority_path: "crates/event_store/src/schema.rs", + resource: None, + boundary: Some("corrupt_managed_v3"), + expected_outcome: "rejected_before_v4_schema_ledger_or_predecessor_trigger_mutation", + error_domain: Some("typed"), + expected_error: Some("MigrationHookStateDrift"), + }, + ExpectedVectorCase { + id: "v3_to_v4_one_over_is_atomic", + execution: DELEGATED_RUST_TEST, + authority: "source_capacity_is_rechecked_for_every_rebuild_bound_migration", + authority_path: "crates/event_store/src/schema.rs", + resource: Some("raw_events"), + boundary: Some("one_over"), + expected_outcome: "rejected_before_mutation", + error_domain: Some("typed"), + expected_error: Some("SourceCapacityExceeded"), + }, + ExpectedVectorCase { + id: "v3_to_v4_persisted_ephemeral_is_atomic", + execution: DELEGATED_RUST_TEST, + authority: "v4_rejects_persisted_legacy_ephemeral_rows_atomically", + authority_path: "crates/event_store/src/schema.rs", + resource: None, + boundary: None, + expected_outcome: "rejected_before_mutation", + error_domain: Some("typed"), + expected_error: Some("PersistedEphemeralRawEvent"), + }, + ExpectedVectorCase { + id: "reopen_rejects_incoherent_capacity_authority", + execution: DELEGATED_RUST_TEST, + authority: "reopen_full_measure_detects_every_persisted_capacity_dimension", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: None, + boundary: None, + expected_outcome: "rejected_on_reopen", + error_domain: Some("typed"), + expected_error: Some("SourceCapacityStateDrift"), + }, + ExpectedVectorCase { + id: "reopen_stops_at_first_raw_event_one_over", + execution: DELEGATED_RUST_TEST, + authority: "reopen_stops_at_the_first_raw_event_one_over_before_ephemeral_probe", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("raw_events"), + boundary: Some("one_over"), + expected_outcome: "rejected_at_scan_bound", + error_domain: Some("typed"), + expected_error: Some("SourceCapacityExceeded"), + }, + ExpectedVectorCase { + id: "retained_generation_rebuild_exact", + execution: DELEGATED_RUST_TEST, + authority: "ninth_current_v4_rebuild_is_typed_and_preflight_atomic", + authority_path: "crates/event_store/src/store.rs", + resource: Some("retained_source_generations"), + boundary: Some("exact"), + expected_outcome: "accepted", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "ninth_rebuild_is_typed_and_atomic", + execution: DELEGATED_RUST_TEST, + authority: "ninth_current_v4_rebuild_is_typed_and_preflight_atomic", + authority_path: "crates/event_store/src/store.rs", + resource: Some("retained_source_generations"), + boundary: Some("one_over"), + expected_outcome: "rejected_before_entropy_or_mutation", + error_domain: Some("typed"), + expected_error: Some("SourceGenerationHistoryLimitReached"), + }, + ExpectedVectorCase { + id: "retained_generation_sql_backstop_one_over", + execution: DELEGATED_SQL_TEST, + authority: "generation_sql_backstop_allows_exact_append_and_is_conflict_safe_one_over", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: Some("retained_source_generations"), + boundary: Some("one_over"), + expected_outcome: "rejected_by_sql_backstop", + error_domain: Some("sqlite_database"), + expected_error: Some( + "event-store retained source generation limit reached; replace and resync into a fresh store", + ), + }, + ExpectedVectorCase { + id: "rebuild_marker_accepts_consistent_seals", + execution: DELEGATED_RUST_TEST, + authority: "current_v4_rebuild_rotates_capacity_and_food_authority_end_to_end", + authority_path: "crates/event_store/src/store.rs", + resource: None, + boundary: None, + expected_outcome: "accepted", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "rebuild_marker_rejects_incoherent_seals", + execution: DELEGATED_SQL_TEST, + authority: "marker_close_sql_backstop_rejects_each_required_seal_drift", + authority_path: "crates/event_store/src/source_maintenance_v1.rs", + resource: None, + boundary: None, + expected_outcome: "rejected_by_sql_backstop", + error_domain: Some("sqlite_database"), + expected_error: Some( + "event-store rebuild marker cannot close before capacity, NIP-09, and FoodAvailability seals agree", + ), + }, + ExpectedVectorCase { + id: "v4_marker_repair_binds_exact_prior_and_floor", + execution: DELEGATED_RUST_TEST, + authority: "v4_marker_open_allows_repairing_prior_transition_high_water_drift", + authority_path: "crates/event_store/src/schema.rs", + resource: None, + boundary: Some("managed_v4_rebuild"), + expected_outcome: "accepts_derived_high_water_repair_and_rejects_wrong_prior_or_floor", + error_domain: Some("sqlite_database"), + expected_error: Some("exact raw and prior source authority"), + }, + ExpectedVectorCase { + id: "v4_food_reset_requires_target_rotation", + execution: DELEGATED_RUST_TEST, + authority: "v4_food_reset_requires_marker_rotation_and_preserves_target_rows", + authority_path: "crates/event_store/src/schema.rs", + resource: None, + boundary: Some("managed_v4_rebuild"), + expected_outcome: "historical_rows_deleted_only_after_rotation_and_target_rows_preserved", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "v4_down_restores_predecessor_triggers", + execution: DELEGATED_RUST_TEST, + authority: "v4_down_restores_exact_predecessor_trigger_sql_and_fingerprint", + authority_path: "crates/event_store/src/schema.rs", + resource: None, + boundary: Some("v4_to_v3"), + expected_outcome: "restored_exact_predecessor_trigger_sql_and_v3_fingerprint", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "utf16_open_file_rejected_before_mutation", + execution: DELEGATED_RUST_TEST, + authority: "open_file_rejects_utf16_main_database_before_schema_or_journal_mutation", + authority_path: "crates/event_store/src/store.rs", + resource: None, + boundary: None, + expected_outcome: "rejected_before_schema_or_journal_mutation", + error_domain: Some("typed"), + expected_error: Some("SqliteMainDatabaseEncodingNotUtf8"), + }, + ExpectedVectorCase { + id: "utf16_open_pool_rejected_before_mutation", + execution: DELEGATED_RUST_TEST, + authority: "open_pool_rejects_utf16_main_database_before_schema_or_journal_mutation", + authority_path: "crates/event_store/src/store.rs", + resource: None, + boundary: None, + expected_outcome: "rejected_before_schema_or_journal_mutation", + error_domain: Some("typed"), + expected_error: Some("SqliteMainDatabaseEncodingNotUtf8"), + }, + ExpectedVectorCase { + id: "utf8_non_ascii_nul_reopen_accounting", + execution: DELEGATED_RUST_TEST, + authority: "utf8_file_reopen_preserves_non_ascii_and_nul_capacity_accounting", + authority_path: "crates/event_store/src/store.rs", + resource: None, + boundary: None, + expected_outcome: "accepted_with_exact_capacity_after_reopen", + error_domain: None, + expected_error: None, + }, + ExpectedVectorCase { + id: "generation_destructive_rollback_rejected", + execution: DELEGATED_RUST_TEST, + authority: "rollback_rejects_below_floor_ahead_unmanaged_and_generation_destructive_targets", + authority_path: "crates/event_store/src/schema.rs", + resource: Some("retained_source_generations"), + boundary: None, + expected_outcome: "rejected_before_mutation_with_status_and_history_preserved", + error_domain: Some("typed"), + expected_error: Some("RollbackWouldDiscardSourceGenerationHistory"), + }, + ExpectedVectorCase { + id: "generation_destructive_two_step_rollback_rejected", + execution: DELEGATED_RUST_TEST, + authority: "rollback_cannot_bypass_generation_history_guard_through_version_three", + authority_path: "crates/event_store/src/schema.rs", + resource: Some("retained_source_generations"), + boundary: None, + expected_outcome: "rejected_before_mutation_after_history_preserving_intermediate_rollback", + error_domain: Some("typed"), + expected_error: Some("RollbackWouldDiscardSourceGenerationHistory"), + }, + ExpectedVectorCase { + id: "independent_pool_last_byte_slot_race", + execution: DELEGATED_RUST_TEST, + authority: "independent_file_pools_serialize_the_last_raw_event_byte_capacity_slot", + authority_path: "crates/event_store/src/store.rs", + resource: Some("raw_event_text_bytes"), + boundary: Some("exact"), + expected_outcome: "exactly_one_accepted_one_typed_rejection_and_clean_reopen", + error_domain: Some("typed"), + expected_error: Some("SourceCapacityExceeded"), + }, +]; + +fn validate_result_vector( + workspace_root: &Path, + vector: &SourceMaintenanceVector, +) -> Result<(), String> { + if vector.schema_version != SCHEMA_VERSION + || vector.contract_id != CONTRACT_ID + || vector.capacity_version != CAPACITY_VERSION + || vector.limits != expected_limits() + || vector.accounting + != (AccountingDescriptor { + algorithm: ACCOUNTING_ALGORITHM.to_owned(), + raw_event_columns: owned(RAW_EVENT_COLUMNS), + raw_tag_columns: owned(RAW_TAG_COLUMNS), + nullable_raw_tag_columns: owned(NULLABLE_RAW_TAG_COLUMNS), + }) + { + return Err(format!( + "{RESULT_VECTOR_CANONICAL_RELATIVE} header, limits, or accounting authority is invalid" + )); + } + if vector.cases.len() != EXPECTED_VECTOR_CASES.len() { + return Err(format!( + "{RESULT_VECTOR_CANONICAL_RELATIVE} must contain exactly {} cases; found {}", + EXPECTED_VECTOR_CASES.len(), + vector.cases.len() + )); + } + validate_unique( + "SourceMaintenance result-vector case IDs", + vector.cases.iter().map(|case| case.id.as_str()), + )?; + for (actual, expected) in vector.cases.iter().zip(EXPECTED_VECTOR_CASES) { + if actual.id != expected.id + || actual.execution != expected.execution + || actual.authority != expected.authority + || actual.authority_path != expected.authority_path + || actual.resource.as_deref() != expected.resource + || actual.boundary.as_deref() != expected.boundary + || actual.expected_outcome != expected.expected_outcome + || actual.error_domain.as_deref() != expected.error_domain + || actual.expected_error.as_deref() != expected.expected_error + { + return Err(format!( + "{RESULT_VECTOR_CANONICAL_RELATIVE} case `{}` does not match its exact governed expectation", + expected.id + )); + } + match ( + actual.error_domain.as_deref(), + actual.expected_error.as_deref(), + ) { + (None, None) => {} + (Some("typed" | "sqlite_database"), Some(error)) if !error.is_empty() => {} + _ => { + return Err(format!( + "{RESULT_VECTOR_CANONICAL_RELATIVE} case `{}` has inconsistent error domain", + actual.id + )); + } + } + } + validate_delegated_test_authorities(workspace_root, vector) +} + +#[derive(Clone, Copy)] +struct DelegatedAuthoritySpec { + path: &'static str, + test: &'static str, + ordered_markers: &'static [&'static str], +} + +const EXECUTABLE_AUTHORITY_AST_SHA256: &str = + "19c405fc91468997aa53f08ebbaed82c526bf4810b2175ad9034d95dc95b8597"; +const BOUND_AUTHORITY_SOURCE_AST_SHA256: &str = + "ba44c729ebba14e658ec7b48f7ba395fd4e8fb3d597d306df5cfa7010a4f9bac"; + +#[derive(Clone, Debug, Serialize)] +struct ExecutableAuthorityIdentity { + path: String, + test: String, + tokens: String, +} + +#[derive(Clone, Debug, Serialize)] +struct BoundAuthoritySourceIdentity { + path: String, + tokens: String, +} + +const DELEGATED_AUTHORITIES: &[DelegatedAuthoritySpec] = &[ + DelegatedAuthoritySpec { + path: "crates/event_store/src/store.rs", + test: "exact_capacity_boundary_allows_duplicate_observation_and_ephemeral_noop", + ordered_markers: &[ + "RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1", + "validate_source_capacity_authority_fast_v1", + "duplicate.persistence.is_duplicate()", + "SourceCapacityExceeded", + "RadrootsEventPersistence::NotPersisted", + "assert_eq!(ephemeral_observation_count,0)", + "transaction.rollback().await", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/store.rs", + test: "borrowed_ingest_savepoint_rolls_back_post_core_authority_forge", + ordered_markers: &[ + "priorcallerwork", + "capacity_after_prior", + "expect_err(\"post-corerawauthoritymutationmustfail\")", + "MigrationHookStateDrift", + "capacity_after_rollback,capacity_after_prior", + "callermaycommitpriorworkafterfailedingest", + "raw_event(prior_event.id_str())", + "raw_event(trigger_event.id_str())", + "raw_event(forged_event.id_str())", + "trade_mutation_count,0", + "transition_count,0", + "capacity_after_prior", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/source_maintenance_v1.rs", + test: "every_prospective_capacity_dimension_accepts_exact_and_rejects_one_over", + ordered_markers: &[ + "RadrootsEventStoreSourceCapacityResourceV1::RawEvents", + "RadrootsEventStoreSourceCapacityResourceV1::RawTags", + "RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes", + "RadrootsEventStoreSourceCapacityResourceV1::RawTagBytes", + "capacity_with(resource,limit-1)", + "delta_with(resource,1)", + "capacity_with(resource,limit)", + "SourceCapacityExceeded", + "requested:1", + "capacity_with(resource,limit+1)", + "requested:0", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/schema.rs", + test: "v3_to_v4_under_limit_backfills_exact_capacity_and_preserves_source", + ordered_markers: &[ + "&EVENT_STORE_MIGRATIONS[..3]", + "event_envelopes", + "active_generation", + "RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT", + "Managed{version:4}", + "radroots_event_store_source_capacity_v1", + "assert_eq!(capacity,(generation_before,1,0,event_bytes,0,1,8))", + "preserved", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/schema.rs", + test: "v3_to_v4_rejects_prior_transition_drift_atomically", + ordered_markers: &[ + "&EVENT_STORE_MIGRATIONS[..3]", + "predecessor_trigger_sql", + "corruption.commit().await", + "expect_err(\"v4upgrademustnotrepaircorruptmanaged-v3hookstate\")", + "MigrationHookStateDrift{hook_id:\"nip09_reconciliation_v1\"", + "ledger_after,ledger_before", + "v4_objects,0", + "v4_ledger_rows,0", + "schema_object_sql(&pool,name).await,*sql", + "Managed{version:3}", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/schema.rs", + test: "source_capacity_is_rechecked_for_every_rebuild_bound_migration", + ordered_markers: &[ + "raw_events:0", + "UnledgeredBaseline", + "&EVENT_STORE_MIGRATIONS[..3]", + "expect_err(\"v4capacityexcessmustfail\")", + "SourceCapacityExceeded", + "Managed{version:3}", + "radroots_event_store_source_capacity_v1", + "assert_eq!(v4_object_count,0)", + "assert_eq!(v4_ledger_count,0)", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/schema.rs", + test: "v4_rejects_persisted_legacy_ephemeral_rows_atomically", + ordered_markers: &[ + "&EVENT_STORE_MIGRATIONS[..3]", + "kind,tags_json,content", + "20000", + "begin_with(\"BEGINIMMEDIATE\")", + "expect_err(\"persistedephemeralsourcemustrejectv4\")", + "PersistedEphemeralRawEvent", + "Managed{version:3}", + "radroots_event_store_source_capacity_v1", + "assert_eq!(v4_object_count,0)", + "assert_eq!(v4_ledger_count,0)", + "assert_eq!(raw_count,1)", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/source_maintenance_v1.rs", + test: "reopen_full_measure_detects_every_persisted_capacity_dimension", + ordered_markers: &[ + "raw_event_count=1", + "raw_tag_count=1", + "raw_event_bytes=1", + "raw_tag_bytes=1", + "RadrootsEventStore::open_file(&path).await", + "SourceCapacityStateDrift", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/source_maintenance_v1.rs", + test: "reopen_stops_at_the_first_raw_event_one_over_before_ephemeral_probe", + ordered_markers: &[ + "value<25001", + "CASEWHENvalue=25001THEN20000ELSE1END", + "RadrootsEventStore::open_file(&path).await", + "SourceCapacityExceeded", + "current:25_000", + "requested:1", + "limit:25_000", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/store.rs", + test: "ninth_current_v4_rebuild_is_typed_and_preflight_atomic", + ordered_markers: &[ + "forordinalin2_u8..=8", + "assert_eq!(capacity_before.retained_generation_count(),8)", + "PanickingGeneration", + "expect_err(\"ninthrebuildmustfailbeforeentropyormutation\")", + "SourceGenerationHistoryLimitReached{current:8,limit:8,}", + "assert_eq!(source_authority_snapshot(&store).await,source_before)", + "assert_eq!(raw_authority_digest(&store).await,raw_before)", + "assert_eq!(normalized_nip09_snapshot(&store).await,nip09_before)", + "assert_eq!(food_after,food_before)", + "assert_eq!(derived_after,(derived_before.0,derived_before.1,derived_before.2,0))", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/source_maintenance_v1.rs", + test: "generation_sql_backstop_allows_exact_append_and_is_conflict_safe_one_over", + ordered_markers: &[ + "retained_generation_count=retained_generation_limit-1", + "exactgenerationboundarymustappend", + "assert_eq!(retained_count,8)", + "sourcegenerationalreadyexists", + "uniquegenerationappendmusthittheSQLcapacitybackstop", + "sqlx::Error::Database", + "retainedsourcegenerationlimitreached", + "transaction.rollback().await", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/store.rs", + test: "current_v4_rebuild_rotates_capacity_and_food_authority_end_to_end", + ordered_markers: &[ + "apply_reconciliation_hook", + "after.retained_generation_count()", + "before.retained_generation_count()+1", + "assert_eq!(marker_count,0)", + "audit_food_availability_projection_v1", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/source_maintenance_v1.rs", + test: "marker_close_sql_backstop_rejects_each_required_seal_drift", + ordered_markers: &[ + "rebuildmarkercannotclosebeforecapacity,NIP-09,andFoodAvailabilitysealsagree", + "fordriftin[\"capacity\",\"nip09\",\"food\",\"fts\"]", + "radroots_event_store_source_capacity_update_guard", + "raw_event_bytes=raw_event_bytes+1", + "radroots_event_store_addressable_feed_integrity_v1", + "last_transition_seq=last_transition_seq+1", + "radroots_event_store_food_availability_cursor_update_guard", + "projected_row_count=projected_row_count+1", + "radroots_event_store_food_availability_search_fts", + "DELETEFROMradroots_event_store_source_rebuild_marker", + "sqlx::Error::Database", + "database.message()==MARKER_CLOSE_ERROR", + "transaction.rollback().await", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/schema.rs", + test: "v4_marker_open_allows_repairing_prior_transition_high_water_drift", + ordered_markers: &[ + "last_transition_seq=7", + "validate_schema_fingerprint", + "wrong_prior", + "wrong_floor", + "expect(\"derivedtransitiondriftisrepairableunderv4\")", + "repaired.0.as_slice(),target_generation.as_slice()", + "repaired.1,repaired.2", + "repaired.1,0", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/schema.rs", + test: "v4_food_reset_requires_marker_rotation_and_preserves_target_rows", + ordered_markers: &[ + "expect_err(\"marker-freeFoodresetmustfail\")", + "expect_err(\"markeralonemustnotauthorizeFoodreset\")", + "expect(\"rotatesourcestate\")", + "expect(\"post-rotationhistoricalFoodreset\")", + "expect_err(\"activetarget-generationFoodrowsmustremainguarded\")", + "remaining,(1,1)", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/schema.rs", + test: "v4_down_restores_exact_predecessor_trigger_sql_and_fingerprint", + ordered_markers: &[ + "&EVENT_STORE_MIGRATIONS[..3]", + "predecessor_sql", + "assert_ne!(schema_object_sql(&pool,name).await,predecessor_sql[*name])", + "rollback_event_store_schema_with_registry", + "assert_eq!(schema_object_sql(&pool,name).await,predecessor_sql[*name])", + "Managed{version:3}", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/store.rs", + test: "open_file_rejects_utf16_main_database_before_schema_or_journal_mutation", + ordered_markers: &[ + "initialize_utf16le_database(&path).await", + "RadrootsEventStore::open_file(&path).await", + "SqliteMainDatabaseEncodingNotUtf8{actual}", + "actual==\"UTF-16le\"", + "assert_utf16le_database_was_not_mutated(&path).await", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/store.rs", + test: "open_pool_rejects_utf16_main_database_before_schema_or_journal_mutation", + ordered_markers: &[ + "initialize_utf16le_database(&path).await", + "max_connections(2)", + "RadrootsEventStore::open_pool(pool,true).await", + "SqliteMainDatabaseEncodingNotUtf8{actual}", + "actual==\"UTF-16le\"", + "assert_utf16le_database_was_not_mutated(&path).await", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/store.rs", + test: "utf8_file_reopen_preserves_non_ascii_and_nul_capacity_accounting", + ordered_markers: &[ + "letexpected_tag_count=", + "raw_source_text_bytes(&event)", + "expect(\"non-ASCIIandNULingest\")", + "before_reopen.raw_event_count(),1", + "before_reopen.raw_tag_count(),expected_tag_count", + "before_reopen.raw_event_text_bytes(),expected_event_bytes", + "before_reopen.raw_tag_text_bytes(),expected_tag_bytes", + "store.pool().close().await", + "RadrootsEventStore::open_file(&path).await", + "source_capacity_v1()", + "before_reopen", + "raw_event(&event_id)", + "tags_for_event(&event_id)", + "assert_eq!(tags.len(),2)", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/schema.rs", + test: "rollback_rejects_below_floor_ahead_unmanaged_and_generation_destructive_targets", + ordered_markers: &[ + "lethistory_before:Vec<(Vec<u8>,i64)>=", + "rollback_event_store_schema_offline(&managed,1).await", + "RollbackWouldDiscardSourceGenerationHistory{current:RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT,target:1,floor:2,}", + "inspect_event_store_schema_status(&managed).await", + "Managed{version:RADROOTS_EVENT_STORE_SCHEMA_VERSION_CURRENT,}", + "source-generationhistoryafterrejectedrollback", + "history_before", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/schema.rs", + test: "rollback_cannot_bypass_generation_history_guard_through_version_three", + ordered_markers: &[ + "rollback_event_store_schema_offline(&managed,3).await", + "lethistory_before:Vec<(Vec<u8>,i64)>=", + "rollback_event_store_schema_offline(&managed,1).await", + "RollbackWouldDiscardSourceGenerationHistory{current:3,target:1,floor:2,}", + "inspect_event_store_schema_status(&managed).await", + "Managed{version:3}", + "v3source-generationhistoryafterrejectedbypass", + "history_before", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/store.rs", + test: "independent_file_pools_serialize_the_last_raw_event_byte_capacity_slot", + ordered_markers: &[ + "RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1", + "before_race.raw_event_text_bytes(),filler_target", + "Barrier::new(3)", + "tokio::spawn", + "tokio::spawn", + "tokio::join!", + "(Ok(accepted),Err(rejected))|(Err(rejected),Ok(accepted))", + "accepted.persistence.is_inserted()", + "SourceCapacityExceeded{resource:crate::RadrootsEventStoreSourceCapacityResourceV1::RawEventBytes", + "requested==contender_bytes", + "cleanfullreopenafterlast-slotrace", + "after_race.raw_event_count(),filler_count+1", + "after_race.raw_event_text_bytes(),crate::RADROOTS_EVENT_STORE_RAW_EVENT_TEXT_BYTES_LIMIT_V1", + "assert_eq!(retained_contenders,1)", + ], + }, +]; + +const MANDATORY_BOUND_AUTHORITIES: &[DelegatedAuthoritySpec] = &[ + DelegatedAuthoritySpec { + path: "crates/event_store/src/nip09/reconciliation_v1.rs", + test: "bounded_capacity_page_len_caps_gross_source_probe_at_one_over", + ordered_markers: &[ + "forlimitin[25_000_u64,250_000_u64]", + "bounded_capacity_page_len(current,limit)", + "(1..=RECONCILIATION_SNAPSHOT_BATCH_LEN).contains(&fetched_len)", + "assert_eq!(fetched,limit+1)", + "bounded_capacity_page_len(24_576,25_000),(425,425)", + "bounded_capacity_page_len(249_856,250_000),(145,145)", + "bounded_capacity_page_len(25_000,25_000),(1,1)", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/source_maintenance_v1.rs", + test: "generation_append_limit_returns_the_typed_current_and_limit", + ordered_markers: &[ + "validate_source_generation_append_available_v1(7,8)", + "validate_source_generation_append_available_v1(8,8)", + "SourceGenerationHistoryLimitReached{current:8,limit:8,}", + ], + }, + DelegatedAuthoritySpec { + path: "crates/event_store/src/source_maintenance_v1.rs", + test: "retained_generation_nip09_logical_rows_have_an_audited_upper_bound", + ordered_markers: &[ + "RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1", + "RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1", + "letper_generation=", + "4*events+2*tags+2", + "RADROOTS_EVENT_STORE_RETAINED_SOURCE_GENERATION_LIMIT_V1", + "4_800_016", + "EVENT_STORE_MIGRATIONS", + ], + }, +]; + +const MANDATORY_BOUND_HELPER_AUTHORITIES: &[DelegatedAuthoritySpec] = &[DelegatedAuthoritySpec { + path: "crates/event_store/src/store.rs", + test: "assert_utf16le_database_was_not_mutated", + ordered_markers: &[ + "PRAGMAmain.encoding", + "PRAGMAmain.journal_mode", + "SELECTCOUNT(*)FROMmain.sqlite_schemaWHEREname='radroots_event_store_schema_migrations'ORname='event_envelopes'ORnameLIKE'radroots_event_store_%'", + "assert_eq!(encoding,\"UTF-16le\")", + "assert_eq!(journal_mode,\"delete\")", + "assert_eq!(event_store_objects,0)", + ], +}]; + +fn validate_delegated_test_authorities( + workspace_root: &Path, + vector: &SourceMaintenanceVector, +) -> Result<(), String> { + let delegated = vector + .cases + .iter() + .filter(|case| case.execution != DIRECT_EXECUTOR) + .map(|case| (case.authority_path.as_str(), case.authority.as_str())) + .collect::<BTreeSet<_>>(); + let expected = DELEGATED_AUTHORITIES + .iter() + .map(|spec| (spec.path, spec.test)) + .collect::<BTreeSet<_>>(); + if delegated != expected { + return Err(format!( + "SourceMaintenance delegated-test inventory differs: expected {expected:?}, found {delegated:?}" + )); + } + let mut executable_identities = Vec::new(); + for spec in DELEGATED_AUTHORITIES + .iter() + .chain(MANDATORY_BOUND_AUTHORITIES) + { + executable_identities.push(ExecutableAuthorityIdentity { + path: spec.path.to_owned(), + test: spec.test.to_owned(), + tokens: validate_delegated_authority(workspace_root, *spec)?, + }); + } + for spec in MANDATORY_BOUND_HELPER_AUTHORITIES { + executable_identities.push(ExecutableAuthorityIdentity { + path: spec.path.to_owned(), + test: spec.test.to_owned(), + tokens: validate_bound_function_authority(workspace_root, *spec)?, + }); + } + + let executor_source = rust_source(workspace_root, RESULT_VECTOR_EXECUTOR_RELATIVE)?; + let executor = syn::parse_file(&executor_source) + .map_err(|error| format!("parse {RESULT_VECTOR_EXECUTOR_RELATIVE}: {error}"))?; + let executor = exact_free_function(&executor, RESULT_VECTOR_EXECUTOR_TEST)?; + validate_executable_test_authority( + RESULT_VECTOR_EXECUTOR_RELATIVE, + RESULT_VECTOR_EXECUTOR_TEST, + executor, + )?; + let executor = compact_tokens(executor); + for case in vector + .cases + .iter() + .filter(|case| case.execution == DIRECT_EXECUTOR) + { + require_marker( + "SourceMaintenance direct result-vector executor", + &executor, + case.id.as_str(), + )?; + } + require_marker( + "SourceMaintenance direct result-vector executor", + &executor, + "assert_direct_cases_executed", + )?; + executable_identities.push(ExecutableAuthorityIdentity { + path: RESULT_VECTOR_EXECUTOR_RELATIVE.to_owned(), + test: RESULT_VECTOR_EXECUTOR_TEST.to_owned(), + tokens: executor, + }); + validate_executable_authority_identities(&executable_identities)?; + let source_identities = bound_authority_source_identities(workspace_root)?; + validate_bound_authority_source_identities(&source_identities)?; + Ok(()) +} + +fn validate_delegated_authority( + workspace_root: &Path, + spec: DelegatedAuthoritySpec, +) -> Result<String, String> { + let source = rust_source(workspace_root, spec.path)?; + let syntax = syn::parse_file(&source) + .map_err(|error| format!("parse delegated authority {}: {error}", spec.path))?; + let function = exact_free_function(&syntax, spec.test)?; + validate_executable_test_authority(spec.path, spec.test, function)?; + let function = compact_tokens(function); + require_ordered_markers( + &format!("delegated authority {}::{}", spec.path, spec.test), + &function, + spec.ordered_markers, + )?; + Ok(function) +} + +fn validate_executable_authority_identities( + identities: &[ExecutableAuthorityIdentity], +) -> Result<(), String> { + let bytes = canonical_json_bytes(&identities)?; + let actual = sha256_hex(&bytes); + if actual != EXECUTABLE_AUTHORITY_AST_SHA256 { + return Err(format!( + "SourceMaintenance executable direct/delegated authority AST identity drifted: expected {EXECUTABLE_AUTHORITY_AST_SHA256}, found {actual}" + )); + } + Ok(()) +} + +fn bound_authority_source_identities( + workspace_root: &Path, +) -> Result<Vec<BoundAuthoritySourceIdentity>, String> { + let paths = DELEGATED_AUTHORITIES + .iter() + .chain(MANDATORY_BOUND_AUTHORITIES) + .chain(MANDATORY_BOUND_HELPER_AUTHORITIES) + .map(|spec| spec.path) + .chain([RESULT_VECTOR_EXECUTOR_RELATIVE]) + .collect::<BTreeSet<_>>(); + paths + .into_iter() + .map(|path| { + let source = rust_source(workspace_root, path)?; + let file = syn::parse_file(&source) + .map_err(|error| format!("parse bound authority source {path}: {error}"))?; + Ok(BoundAuthoritySourceIdentity { + path: path.to_owned(), + tokens: compact_tokens(&file), + }) + }) + .collect() +} + +fn validate_bound_authority_source_identities( + identities: &[BoundAuthoritySourceIdentity], +) -> Result<(), String> { + let bytes = canonical_json_bytes(&identities)?; + let actual = sha256_hex(&bytes); + if actual != BOUND_AUTHORITY_SOURCE_AST_SHA256 { + return Err(format!( + "SourceMaintenance bound executor/test-module/helper source AST identity drifted: expected {BOUND_AUTHORITY_SOURCE_AST_SHA256}, found {actual}" + )); + } + Ok(()) +} + +#[derive(Default)] +struct EarlyReturnAudit { + count: usize, +} + +impl<'ast> syn::visit::Visit<'ast> for EarlyReturnAudit { + fn visit_expr_return(&mut self, expression: &'ast syn::ExprReturn) { + self.count += 1; + syn::visit::visit_expr_return(self, expression); + } +} + +fn validate_executable_test_authority( + relative: &str, + name: &str, + function: &syn::ItemFn, +) -> Result<(), String> { + let expected_attribute = if function.sig.asyncness.is_some() { + "#[tokio::test]" + } else { + "#[test]" + }; + let attributes = function + .attrs + .iter() + .map(compact_tokens) + .collect::<Vec<_>>(); + if attributes != [expected_attribute] { + return Err(format!( + "executable test authority {relative}::{name} must have exactly `{expected_attribute}` and no disabling or conditional attributes; found {attributes:?}" + )); + } + if !matches!(function.vis, syn::Visibility::Inherited) + || function.sig.constness.is_some() + || function.sig.unsafety.is_some() + || function.sig.abi.is_some() + || !function.sig.inputs.is_empty() + || !function.sig.generics.params.is_empty() + || function.sig.generics.where_clause.is_some() + || !matches!(function.sig.output, syn::ReturnType::Default) + || function.sig.variadic.is_some() + { + return Err(format!( + "executable test authority {relative}::{name} must remain a private zero-argument test with no generic, ABI, unsafe, variadic, or return-type escape" + )); + } + + use syn::visit::Visit; + let mut returns = EarlyReturnAudit::default(); + returns.visit_block(&function.block); + if returns.count != 0 { + return Err(format!( + "executable test authority {relative}::{name} must not contain early return control flow; found {} return expression(s)", + returns.count + )); + } + Ok(()) +} + +fn validate_bound_function_authority( + workspace_root: &Path, + spec: DelegatedAuthoritySpec, +) -> Result<String, String> { + let source = rust_source(workspace_root, spec.path)?; + let syntax = syn::parse_file(&source) + .map_err(|error| format!("parse bound authority {}: {error}", spec.path))?; + let function = exact_free_function_tokens(&syntax, spec.test)?; + require_ordered_markers( + &format!("bound authority {}::{}", spec.path, spec.test), + &function, + spec.ordered_markers, + )?; + Ok(function) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .and_then(Path::parent) + .expect("xtask lives under tools/ in the workspace") + .to_path_buf() + } + + fn public_api_sources(root: &Path) -> (String, String, String, String, String) { + ( + rust_source(root, "crates/event_store/src/model.rs").expect("model source"), + rust_source(root, "crates/event_store/src/lib.rs").expect("lib source"), + rust_source(root, "crates/event_store/src/error.rs").expect("error source"), + rust_source(root, "crates/event_store/src/source_maintenance_v1.rs") + .expect("maintenance source"), + rust_source(root, "crates/event_store/src/store.rs").expect("store source"), + ) + } + + #[test] + fn source_inventory_excludes_outputs_and_exactly_supersedes_predecessors() { + let root = workspace_root(); + validate_source_inventory().expect("source inventory"); + validate_predecessor_production_source_coverage(&root) + .expect("exact predecessor supersession"); + + let source_paths = SOURCE_SPECS + .iter() + .map(|spec| spec.path) + .collect::<BTreeSet<_>>(); + for generated in GENERATED_ARTIFACT_PATHS { + assert!(!source_paths.contains(generated)); + } + assert_eq!( + PREDECESSOR_SUPERSEDED_SOURCE_PATHS + .iter() + .copied() + .collect::<BTreeSet<_>>() + .len(), + PREDECESSOR_SUPERSEDED_SOURCE_PATHS.len() + ); + } + + #[test] + fn public_surface_rejects_renames_and_retired_api_reintroduction() { + let root = workspace_root(); + let (model, lib, error, maintenance, store) = public_api_sources(&root); + validate_public_api_sources(&model, &lib, &error, &maintenance, &store) + .expect("current public API"); + + let renamed_model = model.replacen( + "RadrootsAddressableTransitionCauseV1,", + "RadrootsAddressableTransitionCauseV1 as RenamedCause,", + 1, + ); + let renamed_error = + validate_public_api_sources(&renamed_model, &lib, &error, &maintenance, &store) + .expect_err("renamed inherited symbol must fail"); + assert!(renamed_error.contains("direct, non-renamed")); + + let retired_type = + format!("{error}\npub struct RadrootsEventStoreReconciliationResource;\n"); + let retired_type_error = + validate_public_api_sources(&model, &lib, &retired_type, &maintenance, &store) + .expect_err("retired public type must fail"); + assert!(retired_type_error.contains("must remain absent")); + + let retired_variant = error.replacen( + "pub enum RadrootsEventStoreError {", + "pub enum RadrootsEventStoreError {\n ReconciliationCapacityExceeded,", + 1, + ); + let retired_variant_error = + validate_error_and_limit_source(&retired_variant).expect_err("retired variant"); + assert!(retired_variant_error.contains("must remain absent")); + } + + #[test] + fn manifest_schema_rejects_unknown_fields() { + let root = workspace_root(); + let schema = manifest_schema(); + let manifest_bytes = + read_regular_file(&root, MANIFEST_RELATIVE).expect("generated manifest bytes"); + let mut manifest: Value = + serde_json::from_slice(&manifest_bytes).expect("generated manifest JSON"); + validate_manifest_json_schema(&schema, &manifest).expect("current manifest schema"); + + manifest + .pointer_mut("/source_maintenance/reopen_validation") + .and_then(Value::as_object_mut) + .expect("reopen validation object") + .insert("unbounded_scan".to_owned(), Value::Bool(true)); + let error = validate_manifest_json_schema(&schema, &manifest) + .expect_err("unknown reopen field must fail"); + assert!(error.contains("violates")); + } + + #[test] + fn generated_bundle_render_is_rerunnable_without_byte_drift() { + let root = workspace_root(); + let before = expected_artifacts(&root) + .expect("first in-memory generated bundle render") + .into_iter() + .map(|artifact| (artifact.relative, artifact.contents)) + .collect::<Vec<_>>(); + let after = expected_artifacts(&root) + .expect("second in-memory generated bundle render") + .into_iter() + .map(|artifact| (artifact.relative, artifact.contents)) + .collect::<Vec<_>>(); + assert_eq!(before, after); + } + + fn current_executable_authority_identities(root: &Path) -> Vec<ExecutableAuthorityIdentity> { + let mut identities = DELEGATED_AUTHORITIES + .iter() + .chain(MANDATORY_BOUND_AUTHORITIES) + .map(|spec| ExecutableAuthorityIdentity { + path: spec.path.to_owned(), + test: spec.test.to_owned(), + tokens: validate_delegated_authority(root, *spec) + .expect("current delegated executable authority"), + }) + .collect::<Vec<_>>(); + identities.extend(MANDATORY_BOUND_HELPER_AUTHORITIES.iter().map(|spec| { + ExecutableAuthorityIdentity { + path: spec.path.to_owned(), + test: spec.test.to_owned(), + tokens: validate_bound_function_authority(root, *spec) + .expect("current bound helper authority"), + } + })); + let source = rust_source(root, RESULT_VECTOR_EXECUTOR_RELATIVE) + .expect("direct result-vector executor source"); + let file = syn::parse_file(&source).expect("direct result-vector executor AST"); + let function = exact_free_function(&file, RESULT_VECTOR_EXECUTOR_TEST) + .expect("direct result-vector executor function"); + validate_executable_test_authority( + RESULT_VECTOR_EXECUTOR_RELATIVE, + RESULT_VECTOR_EXECUTOR_TEST, + function, + ) + .expect("current direct executable authority"); + identities.push(ExecutableAuthorityIdentity { + path: RESULT_VECTOR_EXECUTOR_RELATIVE.to_owned(), + test: RESULT_VECTOR_EXECUTOR_TEST.to_owned(), + tokens: compact_tokens(function), + }); + identities + } + + fn assert_bound_source_mutation_rejected( + baseline: &[BoundAuthoritySourceIdentity], + path: &str, + source: &str, + label: &str, + ) { + let mut identities = baseline.to_vec(); + let identity = identities + .iter_mut() + .find(|identity| identity.path == path) + .unwrap_or_else(|| panic!("missing bound source identity for {path}")); + let file = syn::parse_file(source) + .unwrap_or_else(|error| panic!("parse {label} mutation for {path}: {error}")); + let tokens = compact_tokens(&file); + assert_ne!(tokens, identity.tokens, "{label} fixture must mutate"); + identity.tokens = tokens; + let error = validate_bound_authority_source_identities(&identities) + .expect_err("bound authority source-context drift must fail closed"); + assert!( + error.contains("bound executor/test-module/helper source AST identity drifted"), + "unexpected {label} error: {error}" + ); + } + + #[test] + fn bound_executor_and_test_module_sources_are_exact_ast_authority() { + let root = workspace_root(); + let baseline = + bound_authority_source_identities(&root).expect("current bound source identities"); + validate_bound_authority_source_identities(&baseline) + .expect("current bound source aggregate identity"); + + let executor = rust_source(&root, RESULT_VECTOR_EXECUTOR_RELATIVE) + .expect("direct result-vector executor source"); + let crate_disabled = executor.replacen( + "#![forbid(unsafe_code)]", + "#![forbid(unsafe_code)]\n#![cfg(any())]", + 1, + ); + assert_bound_source_mutation_rejected( + &baseline, + RESULT_VECTOR_EXECUTOR_RELATIVE, + &crate_disabled, + "crate-disabled direct executor", + ); + + let delegated_path = "crates/event_store/src/source_maintenance_v1.rs"; + let delegated = + rust_source(&root, delegated_path).expect("delegated authority module source"); + let module_disabled = delegated.replacen( + "#[cfg(test)]\nmod tests {", + "#[cfg(all(test, any()))]\nmod tests {", + 1, + ); + assert_bound_source_mutation_rejected( + &baseline, + delegated_path, + &module_disabled, + "disabled delegated test module", + ); + + let macro_shadowed = executor.replacen( + "#![forbid(unsafe_code)]", + "#![forbid(unsafe_code)]\nmacro_rules! assert_eq { ($($tokens:tt)*) => {}; }", + 1, + ); + assert_bound_source_mutation_rejected( + &baseline, + RESULT_VECTOR_EXECUTOR_RELATIVE, + &macro_shadowed, + "shadowing direct-executor assertion macro", + ); + } + + #[test] + fn executable_authority_rejects_disabled_missing_and_unreachable_tests() { + let root = workspace_root(); + let source = rust_source(&root, RESULT_VECTOR_EXECUTOR_RELATIVE) + .expect("direct result-vector executor source"); + for (label, mutation) in [ + ( + "ignored direct executor", + source.replacen("#[tokio::test]", "#[ignore]\n#[tokio::test]", 1), + ), + ( + "missing direct executor attribute", + source.replacen("#[tokio::test]\n", "", 1), + ), + ( + "early-returning direct executor", + source.replacen( + "async fn source_maintenance_v1_result_vector() {", + "async fn source_maintenance_v1_result_vector() {\n return;", + 1, + ), + ), + ] { + assert_ne!(mutation, source, "{label} fixture must mutate"); + let file = syn::parse_file(&mutation).expect("mutated direct executor AST"); + let function = exact_free_function(&file, RESULT_VECTOR_EXECUTOR_TEST) + .expect("mutated direct executor function"); + let error = validate_executable_test_authority( + RESULT_VECTOR_EXECUTOR_RELATIVE, + RESULT_VECTOR_EXECUTOR_TEST, + function, + ) + .expect_err("disabled or early-returning direct executor must fail closed"); + assert!( + error.contains("executable test authority"), + "unexpected {label} error: {error}" + ); + } + + let mut identities = current_executable_authority_identities(&root); + validate_executable_authority_identities(&identities) + .expect("current aggregate executable identity"); + let direct = identities + .last_mut() + .expect("direct executable identity is terminal"); + let file = syn::parse_file(&source).expect("direct executor AST"); + let function = exact_free_function(&file, RESULT_VECTOR_EXECUTOR_TEST) + .expect("direct executor function"); + let mut function = function.clone(); + let body = function.block.clone(); + *function.block = syn::parse_quote!({ if false #body; }); + direct.tokens = compact_tokens(&function); + let error = validate_executable_authority_identities(&identities) + .expect_err("non-returning unreachable test body must fail exact AST authority"); + assert!(error.contains("executable direct/delegated authority AST identity drifted")); + + let mut identities = current_executable_authority_identities(&root); + let helper_spec = MANDATORY_BOUND_HELPER_AUTHORITIES[0]; + let helper_source = rust_source(&root, helper_spec.path).expect("bound helper source"); + let helper_file = syn::parse_file(&helper_source).expect("bound helper AST"); + let helper = exact_free_function(&helper_file, helper_spec.test).expect("bound helper"); + let mut bypass = helper.clone(); + let body = bypass.block.clone(); + *bypass.block = syn::parse_quote!({ if false #body; }); + let identity = identities + .iter_mut() + .find(|identity| identity.path == helper_spec.path && identity.test == helper_spec.test) + .expect("bound helper identity"); + identity.tokens = compact_tokens(&bypass); + validate_executable_authority_identities(&identities) + .expect_err("unreachable bound helper body must fail exact AST authority"); + } + + #[test] + fn command_and_release_validation_reachability_is_exact() { + let root = workspace_root(); + let contract = + rust_source(&root, CONTRACT_COMMAND_SOURCE_RELATIVE).expect("contract command source"); + let main = rust_source(&root, XTASK_MAIN_SOURCE_RELATIVE).expect("xtask main source"); + validate_contract_command_reachability_sources(&contract, &main) + .expect("current aggregate and release validation reachability"); + + let mutations = [ + ( + "aggregate removal", + contract.replacen( + " validate_source_maintenance_manifest(workspace_root)?;\n", + "", + 1, + ), + main.clone(), + ), + ( + "aggregate discarded result", + contract.replacen( + " validate_source_maintenance_manifest(workspace_root)?;", + " let _ = validate_source_maintenance_manifest(workspace_root);", + 1, + ), + main.clone(), + ), + ( + "aggregate reordering", + contract.replacen( + " validate_food_availability_projection_manifest(workspace_root)?;\n validate_source_maintenance_manifest(workspace_root)?;", + " validate_source_maintenance_manifest(workspace_root)?;\n validate_food_availability_projection_manifest(workspace_root)?;", + 1, + ), + main.clone(), + ), + ( + "contract validation removal", + contract.clone(), + main.replacen( + " .and_then(|_| contract::validate_artifact_contracts(&root))", + " .map(|_| ())", + 1, + ), + ), + ( + "contract validate dispatch bypass", + contract.clone(), + main.replacen( + " Some(\"validate\") => validate_contract(),", + " Some(\"validate\") => Ok(()),", + 1, + ), + ), + ( + "release preflight dispatch bypass", + contract.clone(), + main.replacen( + " Some(\"preflight\") => release_preflight(),", + " Some(\"preflight\") => Ok(()),", + 1, + ), + ), + ( + "release validation removal", + contract.clone(), + main.replacen(" contract::validate_artifact_contracts(root)?;\n", "", 1), + ), + ( + "release validation discarded result", + contract.clone(), + main.replacen( + " contract::validate_artifact_contracts(root)?;", + " let _ = contract::validate_artifact_contracts(root);", + 1, + ), + ), + ( + "release validation reordering", + contract.clone(), + main.replacen( + " dto_roots::check(root)?;\n contract::validate_artifact_contracts(root)?;", + " contract::validate_artifact_contracts(root)?;\n dto_roots::check(root)?;", + 1, + ), + ), + ]; + for (label, contract_mutation, main_mutation) in mutations { + assert!( + contract_mutation != contract || main_mutation != main, + "{label} fixture must mutate" + ); + let error = + validate_contract_command_reachability_sources(&contract_mutation, &main_mutation) + .expect_err("validation reachability drift must fail closed"); + assert!( + error.contains("validation call-path authority drifted") + || error.contains("full dispatch AST authority drifted"), + "unexpected {label} error: {error}" + ); + } + } + + #[test] + fn capacity_snapshot_struct_and_public_accessors_are_exact_authority() { + let root = workspace_root(); + let source = rust_source(&root, "crates/event_store/src/source_maintenance_v1.rs") + .expect("source-capacity snapshot authority"); + let baseline = syn::parse_file(&source).expect("source-capacity snapshot AST"); + validate_source_capacity_snapshot_authority(&baseline) + .expect("current source-capacity snapshot authority"); + + for (label, needle, replacement) in [ + ( + "snapshot derive", + "#[derive(Clone, Copy, Debug, PartialEq, Eq)]\npub struct RadrootsEventStoreSourceCapacityV1", + "#[derive(Clone, Debug, PartialEq, Eq)]\npub struct RadrootsEventStoreSourceCapacityV1", + ), + ( + "private field type", + " raw_high_water_seq: i64,\n retained_generation_count: u32,", + " raw_high_water_seq: u64,\n retained_generation_count: u32,", + ), + ( + "public accessor signature", + " pub const fn raw_event_count(&self) -> u64 {", + " pub fn raw_event_count(&mut self) -> u64 {", + ), + ( + "public accessor body", + " pub const fn raw_event_count(&self) -> u64 {\n self.capacity.raw_events\n }", + " pub const fn raw_event_count(&self) -> u64 {\n self.capacity.raw_tags\n }", + ), + ] { + let mutation = source.replacen(needle, replacement, 1); + assert_ne!(mutation, source, "{label} fixture must mutate"); + let file = syn::parse_file(&mutation).expect("mutated source-capacity snapshot AST"); + let error = validate_source_capacity_snapshot_authority(&file) + .expect_err("source-capacity snapshot drift must fail closed"); + assert!( + error.contains("RadrootsEventStoreSourceCapacityV1"), + "unexpected {label} error: {error}" + ); + } + } + + #[test] + fn capacity_resource_and_all_typed_errors_are_exact_authority() { + let root = workspace_root(); + let source = rust_source(&root, "crates/event_store/src/error.rs") + .expect("event-store error authority"); + validate_error_and_limit_source(&source).expect("current capacity/error authority"); + for (label, needle, replacement) in [ + ( + "conditional duplicate capacity constant", + "pub const RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1: u64 = 25_000;", + "#[cfg(any())]\npub const RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1: u64 = 25_000;\n#[cfg(not(any()))]\npub const RADROOTS_EVENT_STORE_RAW_EVENT_COUNT_LIMIT_V1: u64 = 1;", + ), + ( + "conditional duplicate error enum", + "#[derive(Debug, thiserror::Error)]\npub enum RadrootsEventStoreError {", + "#[cfg(any())]\npub enum RadrootsEventStoreError {}\n\n#[derive(Debug, thiserror::Error)]\npub enum RadrootsEventStoreError {", + ), + ( + "capacity constant value", + "pub const RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1: u64 = 250_000;", + "pub const RADROOTS_EVENT_STORE_RAW_TAG_COUNT_LIMIT_V1: u64 = 249_999;", + ), + ("resource non-exhaustive", "#[non_exhaustive]", ""), + ("resource variant", " RawTagBytes,", " TagBytes,"), + ("resource label", "\"raw tag count\"", "\"raw tags\""), + ( + "capacity requested type", + " requested: u64,\n limit: u64,", + " requested: u32,\n limit: u64,", + ), + ( + "generation history type", + " SourceGenerationHistoryLimitReached { current: u32, limit: u32 },", + " SourceGenerationHistoryLimitReached { current: u64, limit: u32 },", + ), + ( + "ephemeral kind type", + " PersistedEphemeralRawEvent { event_id: String, kind: i64 },", + " PersistedEphemeralRawEvent { event_id: String, kind: u64 },", + ), + ( + "capacity drift reason type", + " SourceCapacityStateDrift { reason: String },", + " SourceCapacityStateDrift { reason: &'static str },", + ), + ( + "UTF-8 diagnostic type", + " SqliteMainDatabaseEncodingNotUtf8 { actual: String },", + " SqliteMainDatabaseEncodingNotUtf8 { actual: &'static str },", + ), + ( + "rollback floor type", + " floor: u32,\n },\n #[error(\"event-store rollback requires a managed schema\")]", + " floor: u64,\n },\n #[error(\"event-store rollback requires a managed schema\")]", + ), + ( + "capacity error display", + "requested additional {requested}", + "requested {requested}", + ), + ] { + let mutation = source.replacen(needle, replacement, 1); + assert_ne!(mutation, source, "{label} fixture must mutate"); + let error = validate_error_and_limit_source(&mutation) + .expect_err("typed capacity/error authority drift must fail closed"); + assert!(!error.is_empty(), "{label} must return a diagnostic"); + } + } +} diff --git a/tools/xtask/src/main.rs b/tools/xtask/src/main.rs @@ -20,6 +20,7 @@ fn usage() { 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 source-maintenance-manifest [--write]"); eprintln!(" cargo xtask contract knowledge-manifest [--write]"); eprintln!(" cargo xtask dto-roots --check|--write"); eprintln!(" cargo xtask release preflight"); @@ -64,10 +65,7 @@ fn validate_contract() -> Result<(), String> { contract::load_contract_bundle(&root) .and_then(|bundle| contract::validate_contract_bundle(&bundle)) .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)) + .and_then(|_| contract::validate_artifact_contracts(&root)) } #[cfg_attr(coverage_nightly, coverage(off))] @@ -78,6 +76,7 @@ fn release_preflight() -> Result<(), String> { fn release_preflight_at(root: &Path) -> Result<(), String> { dto_roots::check(root)?; + contract::validate_artifact_contracts(root)?; contract::validate_release_preflight(root) } @@ -120,6 +119,15 @@ fn run_contract(args: &[String]) -> Result<(), String> { .to_string(), ), }, + Some("source-maintenance-manifest") => match &args[1..] { + [] => contract::validate_source_maintenance_manifest(&workspace_root()), + [flag] if flag == "--write" => { + contract::write_source_maintenance_manifest(&workspace_root()) + } + _ => Err( + "source-maintenance-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()) @@ -234,6 +242,12 @@ mod tests { ]) .expect_err("invalid FoodAvailability projection manifest mode"); assert!(invalid_food.contains("exactly --write")); + let invalid_source_maintenance = run_contract(&[ + "source-maintenance-manifest".to_string(), + "--invalid".to_string(), + ]) + .expect_err("invalid SourceMaintenance manifest mode"); + assert!(invalid_source_maintenance.contains("exactly --write")); let unknown_root = run(&["unknown".to_string()]).expect_err("unknown command"); assert!(unknown_root.contains("unknown command")); @@ -331,6 +345,8 @@ mod tests { .expect("contract NIP-09 reconciliation manifest"); run_contract(&["food-availability-projection-manifest".to_string()]) .expect("contract FoodAvailability projection manifest"); + run_contract(&["source-maintenance-manifest".to_string()]) + .expect("contract SourceMaintenance manifest"); run_contract(&["knowledge-manifest".to_string()]).expect("contract knowledge manifest"); } }