From 50d41427975d2f60874f609fea4fb3bae79f0ea3 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sun, 30 Aug 2026 22:17:47 +0200 Subject: [PATCH] v0.3.4-pre.005 --- Cargo.toml | 4 +- crates/ksp-store-postgres-lib/src/lib.rs | 8 +- .../ksp-store-postgres-lib/src/raw_account.rs | 270 ++++++++++++++- crates/ksp-store-postgres-lib/src/runtime.rs | 11 +- .../tests/dependency_boundary.rs | 32 +- .../tests/public_api.rs | 8 +- .../unit_tests/raw_account.rs | 105 +++++- deltas/0.3.4/pre.005.md | 308 ++++++++++++++++++ ...-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md | 14 +- .../021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md | 37 ++- 10 files changed, 766 insertions(+), 31 deletions(-) create mode 100644 deltas/0.3.4/pre.005.md diff --git a/Cargo.toml b/Cargo.toml index c41bcb2..f706936 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 370 +# version: 371 [workspace] resolver = "3" members = ["crates/ksp-app-config-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.3.4-pre.4.fix.1" +version = "0.3.4-pre.5" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-store-postgres-lib/src/lib.rs b/crates/ksp-store-postgres-lib/src/lib.rs index 049991d..de0117e 100644 --- a/crates/ksp-store-postgres-lib/src/lib.rs +++ b/crates/ksp-store-postgres-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/src/lib.rs -// version: 17 +// version: 18 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -27,7 +27,9 @@ //! navigation index, external-schema compatibility and the bounded prerelease //! checksum transition from the provisional `pre.002` schema. `0.3.4-pre.004` //! adds backend-private RAW account state/observation read mapping and hostile-row -//! guards while keeping all account writes, pagination and trait implementations closed. +//! guards. `0.3.4-pre.005` adds atomic account state+observation acquisition writes +//! with exact idempotence/conflict classification while pagination, additional-observation +//! writes and all four account trait implementations remain closed. //! //! This crate depends on `ksp-store-api` and never on `ksp-store-lib`. The //! common facade consumes only this crate's narrow backend bridge and never @@ -71,6 +73,8 @@ pub(crate) use self::migration::current_migration_version; pub(crate) use self::raw_account::get_raw_account_observation; /// Private RAW account state reader consumed by the physical backend runtime. pub(crate) use self::raw_account::get_raw_account_state; +/// Private atomic RAW account acquisition writer consumed by the physical backend runtime. +pub(crate) use self::raw_account::persist_raw_account_acquisition; /// Private RAW transaction cursor decoder consumed by the physical RAW module. pub(crate) use self::raw_transaction::cursor::decode_raw_transaction_cursor; /// Private RAW transaction cursor encoder consumed by the physical RAW module. diff --git a/crates/ksp-store-postgres-lib/src/raw_account.rs b/crates/ksp-store-postgres-lib/src/raw_account.rs index ad5c8f7..66709f2 100644 --- a/crates/ksp-store-postgres-lib/src/raw_account.rs +++ b/crates/ksp-store-postgres-lib/src/raw_account.rs @@ -1,8 +1,12 @@ // file: crates/ksp-store-postgres-lib/src/raw_account.rs -// version: 2 +// version: 3 const GET_ACCOUNT_OBSERVATION_SQL: &str = "SELECT observation_key, account_pubkey, account_slot::text AS account_slot_text, account_state_hash, provider, protocol, acquisition_method, origin, received_at_unix_millis, capture_session_id, commitment, endpoint_id, filter_id, observed_at_unix_millis, source_payload_hash, source_payload_size_bytes, is_startup, transaction_signature, write_version::text AS write_version_text FROM ksp_raw_account_observations WHERE observation_key = $1"; const GET_ACCOUNT_STATE_SQL: &str = "SELECT pubkey, slot::text AS slot_text, state_hash, lamports::text AS lamports_text, owner, executable, rent_epoch::text AS rent_epoch_text, data FROM ksp_raw_account_states WHERE pubkey = $1 AND slot = $2::TEXT::NUMERIC AND state_hash = $3"; +const INSERT_ACCOUNT_OBSERVATION_SQL: &str = "INSERT INTO ksp_raw_account_observations (observation_key, account_pubkey, account_slot, account_state_hash, provider, protocol, acquisition_method, origin, received_at_unix_millis, capture_session_id, commitment, endpoint_id, filter_id, observed_at_unix_millis, source_payload_hash, source_payload_size_bytes, is_startup, transaction_signature, write_version) VALUES ($1, $2, $3::TEXT::NUMERIC, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19::TEXT::NUMERIC) ON CONFLICT (observation_key) DO NOTHING RETURNING observation_key"; +const INSERT_ACCOUNT_STATE_SQL: &str = "INSERT INTO ksp_raw_account_states (pubkey, slot, state_hash, lamports, owner, executable, rent_epoch, data) VALUES ($1, $2::TEXT::NUMERIC, $3, $4::TEXT::NUMERIC, $5, $6, $7::TEXT::NUMERIC, $8) ON CONFLICT (pubkey, slot, state_hash) DO NOTHING RETURNING pubkey"; +const LOCK_ACCOUNT_OBSERVATION_SQL: &str = "SELECT observation_key, account_pubkey, account_slot::text AS account_slot_text, account_state_hash, provider, protocol, acquisition_method, origin, received_at_unix_millis, capture_session_id, commitment, endpoint_id, filter_id, observed_at_unix_millis, source_payload_hash, source_payload_size_bytes, is_startup, transaction_signature, write_version::text AS write_version_text FROM ksp_raw_account_observations WHERE observation_key = $1 FOR UPDATE"; +const LOCK_ACCOUNT_STATE_SQL: &str = "SELECT pubkey, slot::text AS slot_text, state_hash, lamports::text AS lamports_text, owner, executable, rent_epoch::text AS rent_epoch_text, data FROM ksp_raw_account_states WHERE pubkey = $1 AND slot = $2::TEXT::NUMERIC AND state_hash = $3 FOR UPDATE"; struct RawAccountObservationDbRow { account_pubkey: std::vec::Vec, @@ -103,7 +107,10 @@ pub(crate) async fn get_raw_account_observation( let rows = match rows_result { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => { - return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::ReadFailed, "raw_account_observation_query")); + return std::result::Result::Err(crate::PostgresBackendError::new( + crate::PostgresBackendErrorKind::ReadFailed, + "raw_account_observation_query", + )); }, }; if rows.is_empty() { @@ -130,6 +137,58 @@ pub(crate) async fn get_raw_account_observation( return std::result::Result::Ok(std::option::Option::Some(decoded)); } +/// Persists one canonical RAW account state and its acquisition observation atomically. +pub(crate) async fn persist_raw_account_acquisition( + pool: &deadpool_postgres::Pool, + network: &ksp_store_api::RawNetworkId, + state: ksp_store_api::RawAccountState, + observation: ksp_store_api::RawAccountObservation, +) -> std::result::Result { + let input_result = ensure_acquisition_inputs(network, &state, &observation); + if let std::result::Result::Err(error) = input_result { + return std::result::Result::Err(error); + } + let client_result = pool.get().await; + let mut client = match client_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(crate::map_pool_error(error)), + }; + let sql_transaction_result = client.transaction().await; + let sql_transaction = match sql_transaction_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(write_failed("raw_account_acquisition_begin")), + }; + let insert_result = insert_account_state(&sql_transaction, &state).await; + let inserted = match insert_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let entity_outcome = if inserted { + ksp_store_api::RawEntityWriteOutcome::Inserted + } else { + let locked_result = load_locked_account_state(&sql_transaction, network, state.reference()).await; + let locked = match locked_result { + std::result::Result::Ok(std::option::Option::Some(value)) => value, + std::result::Result::Ok(std::option::Option::None) => return std::result::Result::Err(data_invalid("raw_account_acquisition_conflict_missing")), + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if !raw_account_states_equal(&locked, &state) { + return std::result::Result::Err(conflict("raw_account_acquisition_content_conflict")); + } + ksp_store_api::RawEntityWriteOutcome::AlreadyPresent + }; + let observation_result = persist_account_observation_row(&sql_transaction, network, &observation).await; + let observation_outcome = match observation_result { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let commit_result = sql_transaction.commit().await; + if commit_result.is_err() { + return std::result::Result::Err(write_failed("raw_account_acquisition_commit")); + } + return std::result::Result::Ok(ksp_store_api::RawAcquisitionWriteOutcome::new(entity_outcome, observation_outcome)); +} + fn raw_account_observation_db_row(row: &tokio_postgres::Row) -> std::result::Result { let account_pubkey: std::vec::Vec = match row.try_get("account_pubkey") { std::result::Result::Ok(value) => value, @@ -432,6 +491,209 @@ fn decode_raw_account_state_row( }; } +async fn insert_account_state( + sql_transaction: &deadpool_postgres::Transaction<'_>, + state: &ksp_store_api::RawAccountState, +) -> std::result::Result { + let reference = state.reference(); + let pubkey_bytes: &[u8] = reference.pubkey().as_ref(); + let slot_text = reference.slot().to_string(); + let state_hash = reference.state_hash(); + let state_hash_bytes: &[u8] = state_hash.as_bytes(); + let lamports_text = state.lamports().to_string(); + let owner_bytes: &[u8] = state.owner().as_ref(); + let executable = state.executable(); + let rent_epoch_text = state.rent_epoch().to_string(); + let data = state.data(); + let row_result = sql_transaction + .query_opt( + INSERT_ACCOUNT_STATE_SQL, + &[ + &pubkey_bytes, + &slot_text.as_str(), + &state_hash_bytes, + &lamports_text.as_str(), + &owner_bytes, + &executable, + &rent_epoch_text.as_str(), + &data, + ], + ) + .await; + return match row_result { + std::result::Result::Ok(std::option::Option::Some(_)) => std::result::Result::Ok(true), + std::result::Result::Ok(std::option::Option::None) => std::result::Result::Ok(false), + std::result::Result::Err(_) => std::result::Result::Err(write_failed("raw_account_acquisition_insert_state")), + }; +} + +async fn load_locked_account_state( + sql_transaction: &deadpool_postgres::Transaction<'_>, + network: &ksp_store_api::RawNetworkId, + reference: &ksp_store_api::RawAccountStateReference, +) -> std::result::Result, crate::PostgresBackendError> { + let pubkey_bytes: &[u8] = reference.pubkey().as_ref(); + let slot_text = reference.slot().to_string(); + let state_hash = reference.state_hash(); + let state_hash_bytes: &[u8] = state_hash.as_bytes(); + let row_result = sql_transaction.query_opt(LOCK_ACCOUNT_STATE_SQL, &[&pubkey_bytes, &slot_text.as_str(), &state_hash_bytes]).await; + let row = match row_result { + std::result::Result::Ok(std::option::Option::Some(value)) => value, + std::result::Result::Ok(std::option::Option::None) => return std::result::Result::Ok(std::option::Option::None), + std::result::Result::Err(_) => return std::result::Result::Err(write_failed("raw_account_acquisition_lock_state")), + }; + let physical = match raw_account_state_db_row(&row) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return match decode_raw_account_state_row(network, physical) { + std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)), + std::result::Result::Err(error) => std::result::Result::Err(error), + }; +} + +async fn persist_account_observation_row( + sql_transaction: &deadpool_postgres::Transaction<'_>, + network: &ksp_store_api::RawNetworkId, + observation: &ksp_store_api::RawAccountObservation, +) -> std::result::Result { + let provenance = observation.provenance(); + let observation_key = observation.observation_key(); + let observation_key_bytes: &[u8] = observation_key.as_bytes(); + let account = observation.account(); + let account_pubkey_bytes: &[u8] = account.pubkey().as_ref(); + let account_slot_text = account.slot().to_string(); + let account_state_hash = account.state_hash(); + let account_state_hash_bytes: &[u8] = account_state_hash.as_bytes(); + let origin = match encode_origin(provenance.origin()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let received_at = match i64::try_from(provenance.received_at().unix_millis()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_observation_received_at_encode")), + }; + let observed_at = match provenance.observed_at() { + std::option::Option::Some(value) => match i64::try_from(value.unix_millis()) { + std::result::Result::Ok(decoded) => std::option::Option::Some(decoded), + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_observation_observed_at_encode")), + }, + std::option::Option::None => std::option::Option::None, + }; + let source_payload_size = match provenance.source_payload_size_bytes() { + std::option::Option::Some(value) => match i64::try_from(value) { + std::result::Result::Ok(decoded) => std::option::Option::Some(decoded), + std::result::Result::Err(_) => return std::result::Result::Err(data_invalid("raw_account_observation_source_size_encode")), + }, + std::option::Option::None => std::option::Option::None, + }; + let capture_session_id = provenance.capture_session_id().map(|value| return value.as_str()); + let commitment = provenance.commitment().map(|value| return value.as_str()); + let endpoint_id = provenance.endpoint_id().map(|value| return value.as_str()); + let filter_id = provenance.filter_id().map(|value| return value.as_str()); + let source_payload_hash = provenance.source_payload_hash(); + let source_payload_hash_bytes: std::option::Option<&[u8]> = source_payload_hash.as_ref().map(|value| return &value.as_bytes()[..]); + let is_startup = observation.is_startup(); + let transaction_signature = observation.transaction_signature(); + let transaction_signature_bytes: std::option::Option<&[u8]> = transaction_signature.as_ref().map(|value| return &value.as_bytes()[..]); + let write_version_text = observation.write_version().map(|value| return value.to_string()); + let row_result = sql_transaction + .query_opt( + INSERT_ACCOUNT_OBSERVATION_SQL, + &[ + &observation_key_bytes, + &account_pubkey_bytes, + &account_slot_text.as_str(), + &account_state_hash_bytes, + &provenance.provider().as_str(), + &provenance.protocol().as_str(), + &provenance.acquisition_method().as_str(), + &origin, + &received_at, + &capture_session_id, + &commitment, + &endpoint_id, + &filter_id, + &observed_at, + &source_payload_hash_bytes, + &source_payload_size, + &is_startup, + &transaction_signature_bytes, + &write_version_text, + ], + ) + .await; + let inserted = match row_result { + std::result::Result::Ok(std::option::Option::Some(_)) => true, + std::result::Result::Ok(std::option::Option::None) => false, + std::result::Result::Err(_) => return std::result::Result::Err(write_failed("raw_account_observation_insert")), + }; + if inserted { + return std::result::Result::Ok(ksp_store_api::RawObservationWriteOutcome::Inserted); + } + let existing_result = sql_transaction.query_opt(LOCK_ACCOUNT_OBSERVATION_SQL, &[&observation_key_bytes]).await; + let existing_row = match existing_result { + std::result::Result::Ok(std::option::Option::Some(value)) => value, + std::result::Result::Ok(std::option::Option::None) => return std::result::Result::Err(data_invalid("raw_account_observation_conflict_missing")), + std::result::Result::Err(_) => return std::result::Result::Err(write_failed("raw_account_observation_conflict_query")), + }; + let physical = match raw_account_observation_db_row(&existing_row) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let stored = match decode_raw_account_observation_row(network, physical) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if stored.eq(observation) { + return std::result::Result::Ok(ksp_store_api::RawObservationWriteOutcome::AlreadyPresent); + } + return std::result::Result::Err(conflict("raw_account_observation_content_conflict")); +} + +fn ensure_acquisition_inputs( + network: &ksp_store_api::RawNetworkId, + state: &ksp_store_api::RawAccountState, + observation: &ksp_store_api::RawAccountObservation, +) -> std::result::Result<(), crate::PostgresBackendError> { + let state_network_result = ensure_network(network, state.reference(), "raw_account_acquisition_state_network"); + if let std::result::Result::Err(error) = state_network_result { + return std::result::Result::Err(error); + } + let observation_network_result = ensure_network(network, observation.account(), "raw_account_acquisition_observation_network"); + if let std::result::Result::Err(error) = observation_network_result { + return std::result::Result::Err(error); + } + if observation.account() != state.reference() { + return std::result::Result::Err(conflict("raw_account_acquisition_reference_mismatch")); + } + return std::result::Result::Ok(()); +} + +fn raw_account_states_equal(left: &ksp_store_api::RawAccountState, right: &ksp_store_api::RawAccountState) -> bool { + return left.reference() == right.reference() + && left.lamports() == right.lamports() + && left.owner() == right.owner() + && left.executable() == right.executable() + && left.rent_epoch() == right.rent_epoch() + && left.data() == right.data(); +} + +fn encode_origin(origin: ksp_store_api::RawAcquisitionOrigin) -> std::result::Result<&'static str, crate::PostgresBackendError> { + return match origin { + ksp_store_api::RawAcquisitionOrigin::Backfill => std::result::Result::Ok("backfill"), + ksp_store_api::RawAcquisitionOrigin::Import => std::result::Result::Ok("import"), + ksp_store_api::RawAcquisitionOrigin::Live => std::result::Result::Ok("live"), + ksp_store_api::RawAcquisitionOrigin::Repair => std::result::Result::Ok("repair"), + ksp_store_api::RawAcquisitionOrigin::Replay => std::result::Result::Ok("replay"), + _ => std::result::Result::Err(data_invalid("raw_account_observation_origin_encode")), + }; +} + +fn conflict(phase: &'static str) -> crate::PostgresBackendError { + return crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::Conflict, phase); +} + fn decode_origin(value: &str) -> std::result::Result { return match value { "backfill" => std::result::Result::Ok(ksp_store_api::RawAcquisitionOrigin::Backfill), @@ -497,6 +759,10 @@ fn data_invalid(phase: &'static str) -> crate::PostgresBackendError { return crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::DataInvalid, phase); } +fn write_failed(phase: &'static str) -> crate::PostgresBackendError { + return crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::WriteFailed, phase); +} + #[cfg(test)] #[path = "../unit_tests/raw_account.rs"] mod tests; diff --git a/crates/ksp-store-postgres-lib/src/runtime.rs b/crates/ksp-store-postgres-lib/src/runtime.rs index 88021e2..af78617 100644 --- a/crates/ksp-store-postgres-lib/src/runtime.rs +++ b/crates/ksp-store-postgres-lib/src/runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/src/runtime.rs -// version: 11 +// version: 12 const APPLICATION_NAME: &str = "ksp-store"; const MAX_CONNECTION_URI_BYTES: usize = 4_096; @@ -348,6 +348,15 @@ impl PostgresBackend { return crate::get_raw_account_observation(&self.pool, &self.network, observation_key).await; } + /// Persists one complete RAW account state and its acquisition observation atomically. + pub async fn persist_raw_account_acquisition( + &self, + state: ksp_store_api::RawAccountState, + observation: ksp_store_api::RawAccountObservation, + ) -> std::result::Result { + return crate::persist_raw_account_acquisition(&self.pool, &self.network, state, observation).await; + } + /// Reads one canonical RAW transaction without exposing physical PostgreSQL row types. pub async fn get_raw_transaction( &self, diff --git a/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs b/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs index 263fc6b..ca2b8c6 100644 --- a/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs +++ b/crates/ksp-store-postgres-lib/tests/dependency_boundary.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/tests/dependency_boundary.rs -// version: 18 +// version: 19 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -152,7 +152,7 @@ fn pre_003_v002_schema_is_complete_without_account_trait_or_write_dispatch() { } #[test] -fn pre_004_raw_account_read_sql_and_mapping_remain_backend_private_and_read_only() { +fn pre_005_raw_account_acquisition_is_atomic_idempotent_and_keeps_later_scope_closed() { let crate_root = include_str!("../src/lib.rs"); let raw = include_str!("../src/raw_account.rs"); let runtime = include_str!("../src/runtime.rs"); @@ -161,19 +161,27 @@ fn pre_004_raw_account_read_sql_and_mapping_remain_backend_private_and_read_only for required in [ "GET_ACCOUNT_STATE_SQL", "GET_ACCOUNT_OBSERVATION_SQL", - "slot::text AS slot_text", - "lamports::text AS lamports_text", - "rent_epoch::text AS rent_epoch_text", - "write_version::text AS write_version_text", + "INSERT_ACCOUNT_STATE_SQL", + "INSERT_ACCOUNT_OBSERVATION_SQL", + "LOCK_ACCOUNT_STATE_SQL", + "LOCK_ACCOUNT_OBSERVATION_SQL", + "ON CONFLICT (pubkey, slot, state_hash) DO NOTHING", + "ON CONFLICT (observation_key) DO NOTHING", + "FOR UPDATE", + "persist_raw_account_acquisition", + "raw_account_states_equal", "RawAccountState::try_new", "RawAccountObservation::new", + "PostgresBackendErrorKind::Conflict", "PostgresBackendErrorKind::DataInvalid", "PostgresBackendErrorKind::WrongNetwork", + "PostgresBackendErrorKind::WriteFailed", ] { - assert!(raw.contains(required), "missing private RAW account read mapping contract: {required}"); + assert!(raw.contains(required), "missing private RAW account acquisition contract: {required}"); } - for forbidden in ["INSERT INTO", "UPDATE ", "DELETE FROM", "ON CONFLICT", "FOR UPDATE", " OFFSET ", "list_raw_account_states"] { - assert!(!raw.contains(forbidden), "pre.004 account module contains write/pagination material: {forbidden}"); + assert!(runtime.contains("pub async fn persist_raw_account_acquisition")); + for forbidden in ["UPDATE ", "DELETE FROM", "ON CONFLICT DO UPDATE", " OFFSET ", "list_raw_account_states", "record_raw_account_observation"] { + assert!(!raw.contains(forbidden), "pre.005 account module contains later/destructive scope: {forbidden}"); } for forbidden in [ "impl ksp_store_api::RawAccountStateRead for PostgresBackend", @@ -181,10 +189,10 @@ fn pre_004_raw_account_read_sql_and_mapping_remain_backend_private_and_read_only "impl ksp_store_api::RawAccountObservationRead for PostgresBackend", "impl ksp_store_api::RawAccountObservationWrite for PostgresBackend", ] { - assert!(!runtime.contains(forbidden), "pre.004 opened RawAccount trait scope prematurely: {forbidden}"); + assert!(!runtime.contains(forbidden), "pre.005 opened RawAccount trait scope prematurely: {forbidden}"); } - for forbidden in ["std::env", "dotenv", "ksp_store_lib", "ksp_config_lib"] { - assert!(!raw.contains(forbidden), "RAW account module contains forbidden ownership material: {forbidden}"); + for forbidden in ["std::env", "dotenv", "ksp_store_lib", "ksp_config_lib", "sqlx::", "SELECT *"] { + assert!(!raw.contains(forbidden), "RAW account module contains forbidden ownership/query material: {forbidden}"); } return; } diff --git a/crates/ksp-store-postgres-lib/tests/public_api.rs b/crates/ksp-store-postgres-lib/tests/public_api.rs index 2e28c8f..9d14032 100644 --- a/crates/ksp-store-postgres-lib/tests/public_api.rs +++ b/crates/ksp-store-postgres-lib/tests/public_api.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/tests/public_api.rs -// version: 10 +// version: 11 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -82,6 +82,12 @@ fn pre_004_raw_account_read_bridge_uses_only_backend_independent_models() { return; } +#[test] +fn pre_005_raw_account_acquisition_bridge_uses_only_backend_independent_models_and_outcomes() { + let _acquisition = ksp_store_postgres_lib::PostgresBackend::persist_raw_account_acquisition; + return; +} + #[test] fn pre_004_raw_read_bridge_uses_only_backend_independent_models() { let _get = ksp_store_postgres_lib::PostgresBackend::get_raw_transaction; diff --git a/crates/ksp-store-postgres-lib/unit_tests/raw_account.rs b/crates/ksp-store-postgres-lib/unit_tests/raw_account.rs index 01215ac..b4e4885 100644 --- a/crates/ksp-store-postgres-lib/unit_tests/raw_account.rs +++ b/crates/ksp-store-postgres-lib/unit_tests/raw_account.rs @@ -1,5 +1,5 @@ // file: crates/ksp-store-postgres-lib/unit_tests/raw_account.rs -// version: 1 +// version: 2 fn network() -> ksp_store_api::RawNetworkId { return match ksp_store_api::RawNetworkId::new("devnet") { @@ -219,3 +219,106 @@ fn pre_004_account_wrong_network_guard_is_pre_io_and_static() { assert_eq!(error.phase(), "raw_account_state_network"); return; } + +#[test] +fn pre_005_account_acquisition_input_guard_requires_network_and_exact_reference() { + let backend = network(); + let valid_state = match super::decode_raw_account_state_row(&backend, state_row()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account state rejected: {error:?}"), + }; + let valid_observation = match super::decode_raw_account_observation_row(&backend, observation_row()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account observation rejected: {error:?}"), + }; + assert!(super::ensure_acquisition_inputs(&backend, &valid_state, &valid_observation).is_ok()); + let foreign = match ksp_store_api::RawNetworkId::new("mainnet-beta") { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid foreign network rejected: {error:?}"), + }; + let foreign_state = match super::decode_raw_account_state_row(&foreign, state_row()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid foreign account state rejected: {error:?}"), + }; + let foreign_observation = match super::decode_raw_account_observation_row(&foreign, observation_row()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid foreign account observation rejected: {error:?}"), + }; + let error = super::ensure_acquisition_inputs(&backend, &foreign_state, &foreign_observation).err(); + assert_eq!(error.map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::WrongNetwork)); + let mut mismatched_row = observation_row(); + mismatched_row.account_state_hash = vec![9; 32]; + let mismatched_observation = match super::decode_raw_account_observation_row(&backend, mismatched_row) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid mismatched observation model rejected: {error:?}"), + }; + let error = super::ensure_acquisition_inputs(&backend, &valid_state, &mismatched_observation).err(); + assert_eq!(error.map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::Conflict)); + return; +} + +#[test] +fn pre_005_account_state_idempotence_compares_complete_content_not_state_hash_only() { + let backend = network(); + let first = match super::decode_raw_account_state_row(&backend, state_row()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account state rejected: {error:?}"), + }; + let identical = match super::decode_raw_account_state_row(&backend, state_row()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid identical account state rejected: {error:?}"), + }; + assert!(super::raw_account_states_equal(&first, &identical)); + let mut divergent_row = state_row(); + divergent_row.lamports_text = (u64::MAX - 1).to_string(); + let divergent = match super::decode_raw_account_state_row(&backend, divergent_row) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid divergent account state rejected: {error:?}"), + }; + assert_eq!(first.reference(), divergent.reference()); + assert!(!super::raw_account_states_equal(&first, &divergent)); + return; +} + +#[test] +fn pre_005_account_observation_idempotence_compares_optional_metadata_exactly() { + let backend = network(); + let first = match super::decode_raw_account_observation_row(&backend, observation_row()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account observation rejected: {error:?}"), + }; + let identical = match super::decode_raw_account_observation_row(&backend, observation_row()) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid identical account observation rejected: {error:?}"), + }; + assert_eq!(first, identical); + let mut divergent_row = observation_row(); + divergent_row.write_version_text = std::option::Option::Some((u64::MAX - 1).to_string()); + let divergent = match super::decode_raw_account_observation_row(&backend, divergent_row) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid divergent account observation rejected: {error:?}"), + }; + assert_eq!(first.observation_key(), divergent.observation_key()); + assert_eq!(first.account(), divergent.account()); + assert_ne!(first, divergent); + return; +} + +#[test] +fn pre_005_account_origin_encoding_is_exact_and_static() { + let cases = [ + (ksp_store_api::RawAcquisitionOrigin::Backfill, "backfill"), + (ksp_store_api::RawAcquisitionOrigin::Import, "import"), + (ksp_store_api::RawAcquisitionOrigin::Live, "live"), + (ksp_store_api::RawAcquisitionOrigin::Repair, "repair"), + (ksp_store_api::RawAcquisitionOrigin::Replay, "replay"), + ]; + for (origin, expected) in cases { + let encoded = match super::encode_origin(origin) { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => panic!("valid account origin rejected: {error:?}"), + }; + assert_eq!(encoded, expected); + } + return; +} diff --git a/deltas/0.3.4/pre.005.md b/deltas/0.3.4/pre.005.md new file mode 100644 index 0000000..a3427ab --- /dev/null +++ b/deltas/0.3.4/pre.005.md @@ -0,0 +1,308 @@ + + + +# Delta `0.3.4-pre.005` — acquisition atomique et idempotence `RawAccountState` + +## 1. Base requise + +```text +0.3.4-pre.4.fix.1 +``` + +Le gate opérateur fourni le 2026-08-30 pour `pre.004-fix.001` est entièrement vert : + +```text +cargo fmt --all PASS +audit Rust général / exports / workspace PASS +audit Markdown PASS — 239 tables / 141 files +cargo check --workspace PASS +cargo clippy --workspace --all-targets PASS +cargo test -p ksp-store-api PASS +cargo test -p ksp-store-lib PASS +cargo test -p ksp-store-postgres-lib PASS — 52 unit tests + canaris, live ignored +cargo test -p ksp-config-lib PASS — 128 unit tests + ownership/public API +cargo check -p ksp-store-lib --no-default-features PASS +``` + +Le mapping/read account de `pre.004` est donc considéré acquis avant l'ouverture des writes d'acquisition. + +## 2. Objectif + +Implémenter uniquement l'écriture atomique du couple : + +```text +RawAccountState ++ +RawAccountObservation +``` + +avec les invariants figés par `pre.001` : + +```text +guards réseau/référence avant I/O +state insert idempotent +collision state -> FOR UPDATE + comparaison exacte +observation insert idempotente +collision observation -> FOR UPDATE + comparaison exacte +commit unique +aucun overwrite silencieux +``` + +L'observation supplémentaire sans retransmettre les bytes account reste réservée à `pre.006`. + +## 3. Version + +Le workspace passe à : + +```text +0.3.4-pre.5 +``` + +Aucune crate ne redéfinit localement la version héritée. + +## 4. Préconditions avant I/O + +`persist_raw_account_acquisition` valide avant `pool.get()` : + +```text +state.reference.network == backend.network +observation.account.network == backend.network +observation.account == state.reference +``` + +Un mauvais réseau devient `WrongNetwork`. Une référence observation/state divergente devient `Conflict`. Aucune connexion PostgreSQL n'est consommée avant ces gardes. + +## 5. Insertion canonique du state + +Le state utilise : + +```text +INSERT INTO ksp_raw_account_states (...) +ON CONFLICT (pubkey, slot, state_hash) DO NOTHING +RETURNING pubkey +``` + +Les domaines `u64` restent encodés sans narrowing via chaînes décimales et cast PostgreSQL `TEXT::NUMERIC` : + +```text +slot +lamports +rent_epoch +``` + +Les pubkeys/hash restent en bytes fixes et `data` est persistée sans base64 ni copie structurale. + +Si l'INSERT gagne : + +```text +RawEntityWriteOutcome::Inserted +``` + +Si l'identité existe déjà, le backend charge la ligne canonique sous : + +```text +FOR UPDATE +``` + +et compare exactement : + +```text +reference +lamports +owner +executable +rent_epoch +data +``` + +Le `state_hash` ne suffit donc jamais à déclarer l'idempotence. Contenu identique -> `AlreadyPresent`; contenu divergent -> `Conflict`. + +## 6. Insertion idempotente de l'observation + +L'observation est encodée avec toute la provenance et toutes les metadata optionnelles : + +```text +observation_key +account pubkey/slot/state_hash +provider +protocol +acquisition_method +origin +received_at +capture_session_id +commitment +endpoint_id +filter_id +observed_at +source_payload_hash +source_payload_size_bytes +is_startup +transaction_signature +write_version +``` + +Le SQL utilise : + +```text +ON CONFLICT (observation_key) DO NOTHING +``` + +En cas de collision, la ligne existante est relue `FOR UPDATE`, décodée avec le codec hostile-row de `pre.004`, puis comparée au `RawAccountObservation` entrant complet. Identique -> `AlreadyPresent`; divergent -> `Conflict`. + +Aucun champ optionnel n'est ignoré dans l'idempotence. + +## 7. Atomicité + +State et observation vivent dans la même transaction PostgreSQL : + +```text +BEGIN +state insert/compare +observation insert/compare +COMMIT +``` + +Si un nouveau state a été inséré mais que l'observation collide avec un contenu divergent, le chemin retourne une erreur avant commit. Le drop de la transaction annule alors l'insertion du state : aucune moitié d'acquisition durable n'est admise. + +Aucun pré-check `has_*`, aucun `ON CONFLICT DO UPDATE`, aucun `UPDATE` et aucun `DELETE` account n'est introduit. + +## 8. Surface backend + +`PostgresBackend` expose le bridge étroit : + +```text +persist_raw_account_acquisition( + RawAccountState, + RawAccountObservation, +) -> RawAcquisitionWriteOutcome +``` + +Le bridge reste backend-agnostic et n'expose aucun type PostgreSQL. + +Cette méthode n'implémente pas encore `RawAccountStateWrite`. Les quatre traits account restent volontairement absents jusqu'à `pre.008` afin d'ouvrir la conformance physique 10/10 en une seule tranche. + +## 9. Tests et canaries + +Les tests unitaires ajoutés couvrent : + +```text +guard réseau state +référence observation/state exacte +state identique -> comparaison vraie +même référence/hash + lamports divergent -> comparaison fausse +observation identique -> égalité exacte +même clé/référence + write_version divergent -> non-identique +encodage exact des cinq RawAcquisitionOrigin +``` + +Le canari `dependency_boundary` exige maintenant : + +```text +INSERT_ACCOUNT_STATE_SQL +INSERT_ACCOUNT_OBSERVATION_SQL +LOCK_ACCOUNT_STATE_SQL +LOCK_ACCOUNT_OBSERVATION_SQL +ON CONFLICT ... DO NOTHING +FOR UPDATE +persist_raw_account_acquisition +``` + +et continue d'interdire : + +```text +UPDATE / DELETE account +ON CONFLICT DO UPDATE +record_raw_account_observation +list_raw_account_states +OFFSET +les quatre impl RawAccount* +``` + +`public_api.rs` vérifie le nouveau bridge backend-agnostic. + +## 10. Scope explicitement reporté + +```text +record_raw_account_observation pre.006 +races/cancellation ciblées pre.006 +list_raw_account_states pre.007 +cursor KSPA pre.007 +4 impl RawAccount* PostgresBackend pre.008 +4 dispatch Store pre.008 +live PostgreSQL account pre.009 +``` + +## 11. Migrations + +Aucune ressource sous `migrations/` n'est modifiée. + +Checksums conservés : + +```text +V000 = d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450 +V001 = 31488cda2f08f3f46c4cdbdbb6c18c243662fada02eac4487040c8735d72cc51 +V002 = ff21605ed45f7ab4c0f92bbb692700b4118a9488b04d50a31d259ac59bdb550e +``` + +## 12. Fichiers modifiés + +```text +Cargo.toml +crates/ksp-store-postgres-lib/src/lib.rs +crates/ksp-store-postgres-lib/src/raw_account.rs +crates/ksp-store-postgres-lib/src/runtime.rs +crates/ksp-store-postgres-lib/tests/dependency_boundary.rs +crates/ksp-store-postgres-lib/tests/public_api.rs +crates/ksp-store-postgres-lib/unit_tests/raw_account.rs +docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md +docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md +``` + +## 13. Fichier ajouté + +```text +deltas/0.3.4/pre.005.md +``` + +## 14. Fichiers supprimés + +```text +aucun +``` + +## 15. Validations exécutées dans l'environnement d'assemblage + +```text +python3 scripts/audit_rust_workspace_rules.py + General Rust rule audit: clean + Rust export completeness audit: 0 candidate(s) + KSP workspace Rust rule audit: clean + +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.3.4 + PASS +``` + +Les contrôles différentiels doivent également confirmer l'identité byte-à-byte de V000/V001/V002 et l'absence de surface `pre.006+`. + +## 16. Validations non exécutées dans l'environnement d'assemblage + +`cargo`, `rustc` et `rustfmt` ne sont pas disponibles dans l'environnement d'assemblage. Aucun résultat Cargo post-`pre.005` n'est revendiqué ici. + +Gate opérateur à rejouer : + +```bash +cargo fmt --all +python3 scripts/audit_rust_workspace_rules.py +python3 scripts/audit_markdown_tables.py README.md RULES.md ROADMAP.md CHANGELOG.md docs prompts crates deltas/0.3.4 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-store-api +cargo test -p ksp-store-lib +cargo test -p ksp-store-postgres-lib +cargo test -p ksp-config-lib +cargo check -p ksp-store-lib --no-default-features +``` + +## 17. Verdict + +`pre.005` ouvre uniquement l'acquisition atomique `RawAccountState + RawAccountObservation`. L'observation supplémentaire, les races/cancellations dédiées, la pagination et les capabilities account restent fermées conformément au sizing souple de `0.3.4`. diff --git a/docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md b/docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md index f29be93..bb25118 100644 --- a/docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md +++ b/docs/plans/025-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.3.4` — Store/PostgreSQL `RawAccountState` + complétude RAW @@ -617,17 +617,21 @@ La tranche reste strictement read-only : aucun `INSERT`, `UPDATE`, `DELETE`, `ON #### `pre.004-fix.001` — conformité Clippy du mapping `Row` -**Statut : réalisé ; gate opérateur complet à rejouer.** +**Statut : réalisé ; gate opérateur complet PASS.** Le gate opérateur de `pre.004` confirme `cargo check --workspace`, les suites ciblées `ksp-store-api`, `ksp-store-lib`, `ksp-store-postgres-lib` (52 tests), `ksp-config-lib` et `ksp-store-lib --no-default-features`, tandis que `cargo clippy --workspace --all-targets` rejette les 27 extractions `Row::try_get(...).map_err(...)?` du nouveau module. Chaque occurrence produit conjointement `clippy::question-mark-used` et `clippy::implicit-return`, conformément aux interdictions `RUST-ERR-002/003`. Le même gate signale aussi un `crate_root` inutilisé dans le canari `pre_003_v002_schema_is_complete_without_account_trait_or_write_dispatch`. -Le correctif déplie les extractions physiques en `match` typés avec retour `DataInvalid` explicite et supprime uniquement la variable de test inutilisée. Aucun SELECT, phase d'erreur, type physique, invariant de mapping, signature de bridge, test fonctionnel, migration ou checksum n'est modifié. La version Cargo devient `0.3.4-pre.4.fix.1` conformément à `VER-ID-007/010`, car deux fichiers Rust sont modifiés. +Le correctif déplie les extractions physiques en `match` typés avec retour `DataInvalid` explicite et supprime uniquement la variable de test inutilisée. Aucun SELECT, phase d'erreur, type physique, invariant de mapping, signature de bridge, test fonctionnel, migration ou checksum n'est modifié. La version Cargo devient `0.3.4-pre.4.fix.1` conformément à `VER-ID-007/010`, car deux fichiers Rust sont modifiés. Le gate opérateur complet du correctif est vert : audits Rust/Markdown, workspace check, Clippy all-targets, suites Store API/façade/PostgreSQL/Config et `--no-default-features` passent. ### `pre.005` — Acquisition atomique et idempotence -**Statut : planifié.** +**Statut : réalisé ; gate opérateur complet à rejouer.** -Budget cible : **15-20 min**. Implémenter l'acquisition atomique state+observation, l'idempotence exacte et la classification des conflits. +Budget cible : **15-20 min**. L'acquisition account est désormais une transaction PostgreSQL unique : guard réseau et identité exacte avant I/O, `INSERT ... ON CONFLICT DO NOTHING` du state, verrouillage `FOR UPDATE` et comparaison exacte de `lamports/owner/executable/rent_epoch/data` en cas de collision, puis insertion idempotente de l'observation avec comparaison exacte de la référence, de toute la provenance et des metadata optionnelles. Une divergence retourne `Conflict` sans overwrite. + +Si le state vient d'être inséré mais que l'observation collide avec un contenu divergent, le retour d'erreur abandonne la transaction et ne laisse donc aucun state orphelin durable. Le bridge étroit `PostgresBackend::persist_raw_account_acquisition` est ouvert sans implémenter `RawAccountStateWrite`; les quatre traits account restent fermés jusqu'à `pre.008`. + +`record_raw_account_observation`, `list_raw_account_states`, le cursor `KSPA`, tout `UPDATE`/`DELETE` account et tout `ON CONFLICT DO UPDATE` restent absents. `pre.006` conserve la responsabilité de l'observation supplémentaire et des scénarios de concurrence/cancellation ciblés. ### `pre.006` — Observation supplémentaire et concurrence unitaire diff --git a/docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md b/docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md index 7ed14e1..983ac5e 100644 --- a/docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md +++ b/docs/validation/021-V0_3_4_STORE_POSTGRES_RAW_ACCOUNT.md @@ -1,5 +1,5 @@ - + # Validation `0.3.4` — Store/PostgreSQL `RawAccountState` + complétude RAW @@ -274,8 +274,8 @@ Le test live account devra être opt-in/ignored, URI stdin, sans environnement n | pre.001 | audit, kbot3, threat model, V002 design, sizing, plan/validation | DONE | | pre.002 | V002 registry + deux tables + PK/FK de base, sans repository | PASS | | pre.003 | contraintes complètes, index, schema compatibility, checksum V002 | PASS | -| pre.004 | mapping privé state/observation + get reads + hostile rows | RECHECK | -| pre.005 | acquisition atomique state+observation + idempotence/conflict | PLANNED | +| pre.004 | mapping privé state/observation + get reads + hostile rows | PASS | +| pre.005 | acquisition atomique state+observation + idempotence/conflict | RECHECK | | pre.006 | observation supplémentaire + races/cancellation unitaires | PLANNED | | pre.007 | list RawAccountStateQuery + keyset cursor V1 account | PLANNED | | pre.008 | 4 impl backend + 4 dispatch Store + conformance 10/10 | PLANNED | @@ -399,7 +399,7 @@ Gate opérateur de `pre.003-fix.001` du 2026-08-30 : **PASS complet** — audits ## 21. Verdict `pre.004` -Mapping PostgreSQL privé account : **tests opérateur PASS ; Clippy `pre.004` FAIL ; `pre.004-fix.001` appliqué statiquement, gate complet à rejouer**. +Mapping PostgreSQL privé account : **PASS final via `pre.004-fix.001`**. Le gate initial `pre.004` avait échoué uniquement sur Clippy ; le gate complet du correctif est désormais vert. Surface matérialisée : @@ -447,5 +447,32 @@ Invariants du fix : - aucun write, cursor, trait account ou dispatch Store ouvert ; - `workspace.package.version = 0.3.4-pre.4.fix.1`. -Validations exécutées dans l'environnement d'assemblage du fix : audit Rust général/export/workspace **PASS**, audit Markdown **PASS**, absence de `?` dans `src/raw_account.rs` confirmée. Le gate opérateur complet doit rejouer `cargo fmt`, audits, check, Clippy et suites ciblées après application du delta. +Validations exécutées dans l'environnement d'assemblage du fix : audit Rust général/export/workspace **PASS**, audit Markdown **PASS**, absence de `?` dans `src/raw_account.rs` confirmée. Gate opérateur du 2026-08-30 : **PASS complet** — audits Rust/Markdown, workspace check, Clippy all-targets, Store API, Store façade, backend PostgreSQL (52 tests), Config et `--no-default-features` sont verts. + +## 22. Verdict `pre.005` + +Acquisition atomique account : **PASS statique ; gate opérateur complet à rejouer**. + +Surface ajoutée : + +```text +PostgresBackend::persist_raw_account_acquisition +INSERT state ON CONFLICT DO NOTHING +SELECT state FOR UPDATE sur collision +comparaison exacte du state canonique complet +INSERT observation ON CONFLICT DO NOTHING +SELECT observation FOR UPDATE sur collision +comparaison exacte référence + provenance + metadata optionnelles +COMMIT unique state + observation +``` + +Les préconditions `state.reference.network == backend.network`, `observation.account.network == backend.network` et `observation.account == state.reference` sont évaluées avant `pool.get()`. Une divergence de référence ou de contenu est classée `Conflict`; un échec physique d'écriture reste `WriteFailed`, sans texte PostgreSQL conservé. + +L'idempotence state ne fait aucune confiance exclusive à `state_hash` : un même `(pubkey, slot, state_hash)` n'est `AlreadyPresent` que si `lamports`, `owner`, `executable`, `rent_epoch` et `data` sont identiques. L'idempotence observation compare le modèle `RawAccountObservation` complet, y compris `is_startup`, `transaction_signature`, `write_version` et toute la provenance. + +La transaction commune garantit qu'une collision divergente sur l'observation après insertion d'un nouveau state ne peut pas laisser ce state durable isolément. Aucun `has_*`, `ON CONFLICT DO UPDATE`, `UPDATE` ou `DELETE` account n'est introduit. + +Canaries `pre.005` : guard réseau/référence, comparaison state complète, comparaison observation complète, encodage origin fermé, SQL `DO NOTHING + FOR UPDATE`, bridge backend-agnostic et absence des quatre implémentations `RawAccount*`. + +Reste explicitement hors tranche : `record_raw_account_observation` (`pre.006`), concurrence/cancellation ciblées (`pre.006`), pagination/cursor (`pre.007`) et implémentations/dispatch 10/10 (`pre.008`). Les migrations V000/V001/V002 restent inchangées et le checksum V002 final demeure `ff21605ed45f7ab4c0f92bbb692700b4118a9488b04d50a31d259ac59bdb550e`.