v0.3.2-pre.006

This commit is contained in:
2026-08-29 19:53:00 +02:00
parent c1dbaad88d
commit c4f56d9e85
18 changed files with 674 additions and 49 deletions

View File

@@ -1,12 +1,12 @@
# file: Cargo.toml # file: Cargo.toml
# version: 339 # version: 340
[workspace] [workspace]
resolver = "3" 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"] 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] [workspace.package]
version = "0.3.2-pre.5.fix.1" version = "0.3.2-pre.6"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project"
@@ -14,7 +14,7 @@ authors = ["SinuS von SifriduS <sinus@sasedev.net>"]
publish = false publish = false
[workspace.dependencies] [workspace.dependencies]
argon2 = { version = "^0.5", default-features = false } argon2 = { version = "^0.6", default-features = false }
base64 = { version = "^0.23" } base64 = { version = "^0.23" }
chacha20poly1305 = { version = "^0.11", default-features = false } chacha20poly1305 = { version = "^0.11", default-features = false }
chrono = { version = "^0.4", default-features = false } chrono = { version = "^0.4", default-features = false }
@@ -25,12 +25,13 @@ fs2 = { version = "^0.4" }
futures-util = { version = "^0.3", default-features = false } futures-util = { version = "^0.3", default-features = false }
getrandom = { version = "^0.4", default-features = false } getrandom = { version = "^0.4", default-features = false }
http = { version = "^1.5", default-features = false } http = { version = "^1.5", default-features = false }
jsonschema = { version = "^0.51", default-features = false } jsonschema = { version = "^0.52", default-features = false }
reqwest = { version = "^0.13", default-features = false } reqwest = { version = "^0.13", default-features = false }
rustls = { version = "^0.23", default-features = false } rustls = { version = "^0.23", default-features = false }
rustls-native-certs = { version = "^0.8", default-features = false } rustls-native-certs = { version = "^0.8", default-features = false }
serde = { version = "^1.0" } serde = { version = "^1.0" }
serde_json = { version = "^1.0" } serde_json = { version = "^1.0" }
sha2 = { version = "^0.11", default-features = false }
solana-keypair = { version = "^3.1", default-features = false } solana-keypair = { version = "^3.1", default-features = false }
solana-pubkey = { version = "^4.3", default-features = false } solana-pubkey = { version = "^4.3", default-features = false }
tauri = { version = "^2.11" } tauri = { version = "^2.11" }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-lib/src/error.rs // file: crates/ksp-store-lib/src/error.rs
// version: 2 // version: 3
/// Error code reserved for operations attempted after a Store backend has entered its closed state. /// Error code reserved for operations attempted after a Store backend has entered its closed state.
pub const ERROR_CODE_BACKEND_CLOSED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "backend_closed"); pub const ERROR_CODE_BACKEND_CLOSED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "backend_closed");
@@ -11,8 +11,14 @@ pub const ERROR_CODE_BACKEND_OPEN_FAILED: ksp_store_api::ErrorCode = ksp_store_a
pub const ERROR_CODE_POSTGRES_CONFIG_INVALID: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_config_invalid"); pub const ERROR_CODE_POSTGRES_CONFIG_INVALID: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_config_invalid");
/// Error code used when PostgreSQL physical connection establishment fails without exposing remote or credential details. /// Error code used when PostgreSQL physical connection establishment fails without exposing remote or credential details.
pub const ERROR_CODE_POSTGRES_CONNECT_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_connect_failed"); pub const ERROR_CODE_POSTGRES_CONNECT_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_connect_failed");
/// Error code used when PostgreSQL migration/bootstrap execution fails without exposing server text or SQL.
pub const ERROR_CODE_POSTGRES_MIGRATION_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_migration_failed");
/// Error code used when persisted PostgreSQL migration history diverges from the embedded immutable KSP history.
pub const ERROR_CODE_POSTGRES_MIGRATION_MISMATCH: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_migration_mismatch");
/// Error code used when a bounded PostgreSQL pool wait, create or recycle operation reaches its deadline. /// Error code used when a bounded PostgreSQL pool wait, create or recycle operation reaches its deadline.
pub const ERROR_CODE_POSTGRES_POOL_TIMEOUT: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_pool_timeout"); pub const ERROR_CODE_POSTGRES_POOL_TIMEOUT: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_pool_timeout");
/// Error code used when PostgreSQL history contains a migration newer than this Store runtime understands.
pub const ERROR_CODE_POSTGRES_SCHEMA_NEWER: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_schema_newer");
/// Error code used when verified PostgreSQL TLS setup or negotiation cannot be completed safely. /// Error code used when verified PostgreSQL TLS setup or negotiation cannot be completed safely.
pub const ERROR_CODE_POSTGRES_TLS_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_tls_failed"); pub const ERROR_CODE_POSTGRES_TLS_FAILED: ksp_store_api::ErrorCode = ksp_store_api::ErrorCode::new("store", "postgres_tls_failed");
/// Error code used when backend-neutral Store settings violate runtime bounds or invariants. /// Error code used when backend-neutral Store settings violate runtime bounds or invariants.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-lib/src/lib.rs // file: crates/ksp-store-lib/src/lib.rs
// version: 4 // version: 5
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -7,9 +7,9 @@
//! Common backend-neutral Store runtime facade for KSP. //! Common backend-neutral Store runtime facade for KSP.
//! //!
//! `0.3.2-pre.005` materializes the first physical PostgreSQL runtime path: //! `0.3.2-pre.006` materializes the physical PostgreSQL runtime plus its private
//! one bounded Deadpool pool, one startup connection proof and explicit TLS //! migration/bootstrap foundation: bounded pool, explicit TLS, versioned SHA-256
//! policy. SQL migrations remain private future slices of this release. //! history and no business persistence schema.
//! //!
//! The default `postgres` feature compiles the official PostgreSQL backend as //! The default `postgres` feature compiles the official PostgreSQL backend as
//! an optional implementation dependency. No backend implementation type is //! an optional implementation dependency. No backend implementation type is
@@ -30,8 +30,14 @@ pub use self::error::ERROR_CODE_BACKEND_OPEN_FAILED;
pub use self::error::ERROR_CODE_POSTGRES_CONFIG_INVALID; pub use self::error::ERROR_CODE_POSTGRES_CONFIG_INVALID;
/// Error code used when PostgreSQL physical connection establishment fails. /// Error code used when PostgreSQL physical connection establishment fails.
pub use self::error::ERROR_CODE_POSTGRES_CONNECT_FAILED; pub use self::error::ERROR_CODE_POSTGRES_CONNECT_FAILED;
/// Error code used when PostgreSQL migration/bootstrap execution fails safely.
pub use self::error::ERROR_CODE_POSTGRES_MIGRATION_FAILED;
/// Error code used when PostgreSQL migration history diverges from the embedded immutable KSP history.
pub use self::error::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH;
/// Error code used when a bounded PostgreSQL pool operation reaches its deadline. /// Error code used when a bounded PostgreSQL pool operation reaches its deadline.
pub use self::error::ERROR_CODE_POSTGRES_POOL_TIMEOUT; pub use self::error::ERROR_CODE_POSTGRES_POOL_TIMEOUT;
/// Error code used when PostgreSQL schema history is newer than this Store runtime.
pub use self::error::ERROR_CODE_POSTGRES_SCHEMA_NEWER;
/// Error code used when PostgreSQL verified TLS setup or negotiation fails. /// Error code used when PostgreSQL verified TLS setup or negotiation fails.
pub use self::error::ERROR_CODE_POSTGRES_TLS_FAILED; pub use self::error::ERROR_CODE_POSTGRES_TLS_FAILED;
/// Error code used when Store settings violate backend-neutral bounds or invariants. /// Error code used when Store settings violate backend-neutral bounds or invariants.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-lib/src/store.rs // file: crates/ksp-store-lib/src/store.rs
// version: 2 // version: 3
/// Opaque common Store runtime facade. /// Opaque common Store runtime facade.
/// ///
@@ -90,6 +90,7 @@ async fn open_postgres(
shutdown_timeout: std::time::Duration, shutdown_timeout: std::time::Duration,
settings: &crate::PostgresStoreSettings, settings: &crate::PostgresStoreSettings,
) -> ksp_store_api::Result<Store> { ) -> ksp_store_api::Result<Store> {
let bootstrap = settings.bootstrap();
let pool = settings.pool(); let pool = settings.pool();
let tls_mode = match settings.tls_mode() { let tls_mode = match settings.tls_mode() {
crate::PostgresTlsMode::Disabled => ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled, crate::PostgresTlsMode::Disabled => ksp_store_postgres_lib::PostgresBackendTlsMode::Disabled,
@@ -104,6 +105,9 @@ async fn open_postgres(
pool.create_timeout(), pool.create_timeout(),
pool.recycle_timeout(), pool.recycle_timeout(),
tls_mode, tls_mode,
bootstrap.auto_migrate(),
bootstrap.migration_timeout(),
bootstrap.migration_lock_timeout(),
); );
let opened = ksp_store_postgres_lib::PostgresBackend::open(backend_settings).await; let opened = ksp_store_postgres_lib::PostgresBackend::open(backend_settings).await;
return match opened { return match opened {
@@ -136,6 +140,9 @@ fn map_postgres_error(error: ksp_store_postgres_lib::PostgresBackendError, backe
ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid => crate::ERROR_CODE_POSTGRES_CONFIG_INVALID, ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid => crate::ERROR_CODE_POSTGRES_CONFIG_INVALID,
ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed => crate::ERROR_CODE_POSTGRES_CONNECT_FAILED, ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed => crate::ERROR_CODE_POSTGRES_CONNECT_FAILED,
ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout => crate::ERROR_CODE_POSTGRES_POOL_TIMEOUT, ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout => crate::ERROR_CODE_POSTGRES_POOL_TIMEOUT,
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed => crate::ERROR_CODE_POSTGRES_MIGRATION_FAILED,
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch => crate::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH,
ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer => crate::ERROR_CODE_POSTGRES_SCHEMA_NEWER,
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout => crate::ERROR_CODE_SHUTDOWN_TIMEOUT, ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout => crate::ERROR_CODE_SHUTDOWN_TIMEOUT,
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed => crate::ERROR_CODE_POSTGRES_TLS_FAILED, ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed => crate::ERROR_CODE_POSTGRES_TLS_FAILED,
_ => crate::ERROR_CODE_BACKEND_OPEN_FAILED, _ => crate::ERROR_CODE_BACKEND_OPEN_FAILED,

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-lib/tests/public_api.rs // file: crates/ksp-store-lib/tests/public_api.rs
// version: 3 // version: 4
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -37,6 +37,9 @@ fn pre_005_common_and_postgres_error_codes_are_stable_and_store_owned() {
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONFIG_INVALID.code(), "postgres_config_invalid"); assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONFIG_INVALID.code(), "postgres_config_invalid");
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONNECT_FAILED.code(), "postgres_connect_failed"); assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_CONNECT_FAILED.code(), "postgres_connect_failed");
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_POOL_TIMEOUT.code(), "postgres_pool_timeout"); assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_POOL_TIMEOUT.code(), "postgres_pool_timeout");
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_MIGRATION_FAILED.code(), "postgres_migration_failed");
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_MIGRATION_MISMATCH.code(), "postgres_migration_mismatch");
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_SCHEMA_NEWER.code(), "postgres_schema_newer");
assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_TLS_FAILED.code(), "postgres_tls_failed"); assert_eq!(ksp_store_lib::ERROR_CODE_POSTGRES_TLS_FAILED.code(), "postgres_tls_failed");
assert_eq!(ksp_store_lib::ERROR_CODE_BACKEND_CLOSED.code(), "backend_closed"); assert_eq!(ksp_store_lib::ERROR_CODE_BACKEND_CLOSED.code(), "backend_closed");
assert_eq!(ksp_store_lib::ERROR_CODE_SHUTDOWN_TIMEOUT.code(), "shutdown_timeout"); assert_eq!(ksp_store_lib::ERROR_CODE_SHUTDOWN_TIMEOUT.code(), "shutdown_timeout");

View File

@@ -1,5 +1,5 @@
# file: crates/ksp-store-postgres-lib/Cargo.toml # file: crates/ksp-store-postgres-lib/Cargo.toml
# version: 2 # version: 3
[package] [package]
name = "ksp-store-postgres-lib" name = "ksp-store-postgres-lib"
@@ -13,6 +13,7 @@ ksp-logging-lib = { path = "../ksp-logging-lib" }
ksp-store-api = { path = "../ksp-store-api" } ksp-store-api = { path = "../ksp-store-api" }
rustls = { workspace = true, features = ["aws_lc_rs", "std", "tls12"] } rustls = { workspace = true, features = ["aws_lc_rs", "std", "tls12"] }
rustls-native-certs.workspace = true rustls-native-certs.workspace = true
sha2.workspace = true
tokio = { workspace = true, features = ["rt", "time"] } tokio = { workspace = true, features = ["rt", "time"] }
tokio-postgres = { workspace = true, features = ["runtime"] } tokio-postgres = { workspace = true, features = ["runtime"] }
tokio-postgres-rustls = { workspace = true, features = ["aws-lc-rs"] } tokio-postgres-rustls = { workspace = true, features = ["aws-lc-rs"] }

View File

@@ -0,0 +1,6 @@
CREATE TABLE ksp_store_schema_migrations (
version BIGINT PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TIMESTAMPTZ NOT NULL
);

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/src/error.rs // file: crates/ksp-store-postgres-lib/src/error.rs
// version: 1 // version: 2
/// Safe backend-local classification used by the Store facade for stable error mapping. /// Safe backend-local classification used by the Store facade for stable error mapping.
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
@@ -11,6 +11,12 @@ pub enum PostgresBackendErrorKind {
ConnectFailed, ConnectFailed,
/// A bounded pool wait, create or recycle operation reached its deadline. /// A bounded pool wait, create or recycle operation reached its deadline.
PoolTimeout, PoolTimeout,
/// PostgreSQL migration/bootstrap execution failed without exposing server text or SQL.
MigrationFailed,
/// Applied PostgreSQL migration history diverges from the embedded immutable KSP history.
MigrationMismatch,
/// The database schema history contains a migration newer than this runtime understands.
SchemaNewer,
/// Explicit backend shutdown did not drain inside the supplied deadline. /// Explicit backend shutdown did not drain inside the supplied deadline.
ShutdownTimeout, ShutdownTimeout,
/// Verified TLS configuration or negotiation could not be established. /// Verified TLS configuration or negotiation could not be established.

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/src/lib.rs // file: crates/ksp-store-postgres-lib/src/lib.rs
// version: 3 // version: 4
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -7,9 +7,9 @@
//! Official PostgreSQL backend implementation for KSP Store. //! Official PostgreSQL backend implementation for KSP Store.
//! //!
//! `0.3.2-pre.005` owns the physical `tokio-postgres` connection, bounded //! `0.3.2-pre.006` owns the physical `tokio-postgres` connection, bounded
//! Deadpool pool and explicit Rustls TLS policy. SQL migrations and business //! Deadpool pool, explicit Rustls TLS policy and private KSP migration/bootstrap
//! persistence remain absent until their dedicated prereleases. //! engine. Business persistence remains absent from this foundation release.
//! //!
//! This crate depends on `ksp-store-api` and never on `ksp-store-lib`. The //! 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 //! common facade consumes only this crate's narrow backend bridge and never
@@ -17,6 +17,7 @@
mod constants; mod constants;
mod error; mod error;
mod migration;
mod runtime; mod runtime;
/// Safe backend-local error returned to the common Store facade. /// Safe backend-local error returned to the common Store facade.
@@ -32,5 +33,7 @@ pub use self::runtime::PostgresBackendTlsMode;
/// Crate-owned tracing target for PostgreSQL backend behavior. /// Crate-owned tracing target for PostgreSQL backend behavior.
pub(crate) use self::constants::TRACING_TARGET; pub(crate) use self::constants::TRACING_TARGET;
/// Private migration/bootstrap runner consumed by the physical backend runtime.
pub(crate) use self::migration::bootstrap;
const _: &str = crate::TRACING_TARGET; const _: &str = crate::TRACING_TARGET;

View File

@@ -0,0 +1,312 @@
// file: crates/ksp-store-postgres-lib/src/migration.rs
// version: 1
use sha2::Digest; // rust-rules: trait-import
const ADVISORY_LOCK_KEY: i64 = 0x4b53_5053_544f_5245;
const BOOTSTRAP_MIGRATION_NAME: &str = "bootstrap";
const BOOTSTRAP_MIGRATION_SQL: &str = include_str!("../migrations/V000__bootstrap.sql");
const BOOTSTRAP_MIGRATION_VERSION: i64 = 0;
const HEX_LOWER: &[u8; 16] = b"0123456789abcdef";
const HISTORY_INSERT_SQL: &str = "INSERT INTO ksp_store_schema_migrations (version, name, checksum, applied_at) VALUES ($1, $2, $3, CURRENT_TIMESTAMP)";
const HISTORY_LOAD_SQL: &str = "SELECT version, name, checksum FROM ksp_store_schema_migrations ORDER BY version";
const LOCK_POLL_INTERVAL_MS: u64 = 25;
const METADATA_EXISTS_SQL: &str = r#"SELECT EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_schema = current_schema()
AND table_name = 'ksp_store_schema_migrations'
AND table_type = 'BASE TABLE'
)"#;
const METADATA_PRIMARY_KEY_SQL: &str = r#"SELECT COUNT(*)::BIGINT,
COUNT(*) FILTER (WHERE kcu.column_name = 'version')::BIGINT
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_catalog = kcu.constraint_catalog
AND tc.constraint_schema = kcu.constraint_schema
AND tc.constraint_name = kcu.constraint_name
WHERE tc.table_schema = current_schema()
AND tc.table_name = 'ksp_store_schema_migrations'
AND tc.constraint_type = 'PRIMARY KEY'"#;
const METADATA_SHAPE_SQL: &str = r#"SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_schema = current_schema()
AND table_name = 'ksp_store_schema_migrations'
ORDER BY ordinal_position"#;
const SET_STATEMENT_TIMEOUT_SQL: &str = "SELECT set_config('statement_timeout', $1, true)";
struct AppliedMigration {
checksum: std::string::String,
name: std::string::String,
version: i64,
}
/// Runs the private bounded PostgreSQL schema bootstrap on one dedicated pooled client.
pub(crate) async fn bootstrap(
client: &mut deadpool_postgres::Client,
auto_migrate: bool,
migration_timeout: std::time::Duration,
migration_lock_timeout: std::time::Duration,
) -> std::result::Result<(), crate::PostgresBackendError> {
let bounded = tokio::time::timeout(migration_timeout, bootstrap_inner(client, auto_migrate, migration_timeout, migration_lock_timeout)).await;
return match bounded {
std::result::Result::Ok(result) => result,
std::result::Result::Err(_) => {
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_timeout"))
},
};
}
async fn bootstrap_inner(
client: &mut deadpool_postgres::Client,
auto_migrate: bool,
migration_timeout: std::time::Duration,
migration_lock_timeout: std::time::Duration,
) -> std::result::Result<(), crate::PostgresBackendError> {
let transaction_result = client.transaction().await;
let transaction = match transaction_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_begin"));
},
};
let lock_result = acquire_advisory_lock(&transaction, migration_lock_timeout).await;
if let std::result::Result::Err(error) = lock_result {
return std::result::Result::Err(error);
}
let timeout_result = set_statement_timeout(&transaction, migration_timeout).await;
if let std::result::Result::Err(error) = timeout_result {
return std::result::Result::Err(error);
}
let exists_result = metadata_exists(&transaction).await;
let metadata_exists = match exists_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let checksum = bootstrap_checksum();
if !metadata_exists {
if !auto_migrate {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_pending"));
}
let create_result = transaction.batch_execute(BOOTSTRAP_MIGRATION_SQL).await;
if create_result.is_err() {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_create"));
}
let shape_result = verify_metadata_shape(&transaction).await;
if let std::result::Result::Err(error) = shape_result {
return std::result::Result::Err(error);
}
let insert_result = transaction.execute(HISTORY_INSERT_SQL, &[&BOOTSTRAP_MIGRATION_VERSION, &BOOTSTRAP_MIGRATION_NAME, &checksum]).await;
match insert_result {
std::result::Result::Ok(1) => {},
std::result::Result::Ok(_) | std::result::Result::Err(_) => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_insert"));
},
}
} else {
let shape_result = verify_metadata_shape(&transaction).await;
if let std::result::Result::Err(error) = shape_result {
return std::result::Result::Err(error);
}
let history_result = load_history(&transaction).await;
let history = match history_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(error),
};
let validation_result = validate_history(history.as_slice(), checksum.as_str());
if let std::result::Result::Err(error) = validation_result {
return std::result::Result::Err(error);
}
}
let commit_result = transaction.commit().await;
return match commit_result {
std::result::Result::Ok(()) => std::result::Result::Ok(()),
std::result::Result::Err(_) => {
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_commit"))
},
};
}
async fn acquire_advisory_lock(
transaction: &deadpool_postgres::Transaction<'_>,
timeout: std::time::Duration,
) -> std::result::Result<(), crate::PostgresBackendError> {
let started = tokio::time::Instant::now();
let deadline = match started.checked_add(timeout) {
std::option::Option::Some(value) => value,
std::option::Option::None => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock_timeout"));
},
};
loop {
let row_result = transaction.query_one("SELECT pg_try_advisory_xact_lock($1)", &[&ADVISORY_LOCK_KEY]).await;
let row = match row_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock"));
},
};
let acquired_result = row.try_get::<usize, bool>(0);
let acquired = match acquired_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock_decode"));
},
};
if acquired {
return std::result::Result::Ok(());
}
let now = tokio::time::Instant::now();
if now >= deadline {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "migration_lock_timeout"));
}
let candidate = now + std::time::Duration::from_millis(LOCK_POLL_INTERVAL_MS);
let wake = if candidate < deadline { candidate } else { deadline };
tokio::time::sleep_until(wake).await;
}
}
async fn set_statement_timeout(
transaction: &deadpool_postgres::Transaction<'_>,
timeout: std::time::Duration,
) -> std::result::Result<(), crate::PostgresBackendError> {
let timeout_value = format!("{}ms", timeout.as_millis());
let result = transaction.query_one(SET_STATEMENT_TIMEOUT_SQL, &[&timeout_value]).await;
return match result {
std::result::Result::Ok(_) => std::result::Result::Ok(()),
std::result::Result::Err(_) => {
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "statement_timeout"))
},
};
}
async fn metadata_exists(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<bool, crate::PostgresBackendError> {
let result = transaction.query_one(METADATA_EXISTS_SQL, &[]).await;
let row = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_probe"));
},
};
return match row.try_get::<usize, bool>(0) {
std::result::Result::Ok(value) => std::result::Result::Ok(value),
std::result::Result::Err(_) => {
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_probe_decode"))
},
};
}
async fn verify_metadata_shape(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<(), crate::PostgresBackendError> {
let result = transaction.query(METADATA_SHAPE_SQL, &[]).await;
let rows = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_shape"));
},
};
let expected = [("version", "bigint", "NO"), ("name", "text", "NO"), ("checksum", "text", "NO"), ("applied_at", "timestamp with time zone", "NO")];
let mut found = [false; 4];
for row in rows {
let column_result = row.try_get::<usize, std::string::String>(0);
let data_type_result = row.try_get::<usize, std::string::String>(1);
let nullable_result = row.try_get::<usize, std::string::String>(2);
let (column, data_type, nullable) = match (column_result, data_type_result, nullable_result) {
(std::result::Result::Ok(column), std::result::Result::Ok(data_type), std::result::Result::Ok(nullable)) => (column, data_type, nullable),
_ => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_shape_decode"));
},
};
for (index, expected_row) in expected.iter().enumerate() {
if column == expected_row.0 {
if found[index] || data_type != expected_row.1 || nullable != expected_row.2 {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_shape"));
}
found[index] = true;
break;
}
}
}
for required in found {
if !required {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_shape"));
}
}
let key_result = transaction.query_one(METADATA_PRIMARY_KEY_SQL, &[]).await;
let key_row = match key_result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_primary_key"));
},
};
let key_count = key_row.try_get::<usize, i64>(0);
let version_count = key_row.try_get::<usize, i64>(1);
return match (key_count, version_count) {
(std::result::Result::Ok(1), std::result::Result::Ok(1)) => std::result::Result::Ok(()),
(std::result::Result::Ok(_), std::result::Result::Ok(_)) => {
std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "metadata_primary_key"))
},
_ => std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "metadata_primary_key_decode")),
};
}
async fn load_history(transaction: &deadpool_postgres::Transaction<'_>) -> std::result::Result<std::vec::Vec<AppliedMigration>, crate::PostgresBackendError> {
let result = transaction.query(HISTORY_LOAD_SQL, &[]).await;
let rows = match result {
std::result::Result::Ok(value) => value,
std::result::Result::Err(_) => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_load"));
},
};
let mut history = std::vec::Vec::with_capacity(rows.len());
for row in rows {
let version_result = row.try_get::<usize, i64>(0);
let name_result = row.try_get::<usize, std::string::String>(1);
let checksum_result = row.try_get::<usize, std::string::String>(2);
match (version_result, name_result, checksum_result) {
(std::result::Result::Ok(version), std::result::Result::Ok(name), std::result::Result::Ok(checksum)) => {
history.push(AppliedMigration { checksum, name, version });
},
_ => {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationFailed, "history_decode"));
},
}
}
return std::result::Result::Ok(history);
}
fn validate_history(history: &[AppliedMigration], expected_checksum: &str) -> std::result::Result<(), crate::PostgresBackendError> {
let mut sentinel_found = false;
for applied in history {
if applied.version > BOOTSTRAP_MIGRATION_VERSION {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::SchemaNewer, "history_newer"));
}
if applied.version < BOOTSTRAP_MIGRATION_VERSION {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_unknown"));
}
if applied.name != BOOTSTRAP_MIGRATION_NAME || applied.checksum != expected_checksum {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_diverged"));
}
sentinel_found = true;
}
if !sentinel_found {
return std::result::Result::Err(crate::PostgresBackendError::new(crate::PostgresBackendErrorKind::MigrationMismatch, "history_missing"));
}
return std::result::Result::Ok(());
}
fn bootstrap_checksum() -> std::string::String {
let mut hasher = sha2::Sha256::new();
hasher.update(BOOTSTRAP_MIGRATION_SQL.as_bytes());
let digest = hasher.finalize();
let bytes = digest.as_slice();
let mut encoded = std::string::String::with_capacity(bytes.len() * 2);
for byte in bytes {
let value = *byte;
encoded.push(char::from(HEX_LOWER[(value >> 4) as usize]));
encoded.push(char::from(HEX_LOWER[(value & 0x0f) as usize]));
}
return encoded;
}
#[cfg(test)]
#[path = "../unit_tests/migration.rs"]
mod tests;

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/src/runtime.rs // file: crates/ksp-store-postgres-lib/src/runtime.rs
// version: 1 // version: 2
const APPLICATION_NAME: &str = "ksp-store"; const APPLICATION_NAME: &str = "ksp-store";
const MAX_CONNECTION_URI_BYTES: usize = 4_096; const MAX_CONNECTION_URI_BYTES: usize = 4_096;
@@ -31,7 +31,10 @@ pub struct PostgresBackendSettings {
connect_timeout: std::time::Duration, connect_timeout: std::time::Duration,
connection_uri: std::string::String, connection_uri: std::string::String,
create_timeout: std::time::Duration, create_timeout: std::time::Duration,
auto_migrate: bool,
max_connections: u32, max_connections: u32,
migration_lock_timeout: std::time::Duration,
migration_timeout: std::time::Duration,
network: ksp_store_api::RawNetworkId, network: ksp_store_api::RawNetworkId,
recycle_timeout: std::time::Duration, recycle_timeout: std::time::Duration,
tls_mode: PostgresBackendTlsMode, tls_mode: PostgresBackendTlsMode,
@@ -50,12 +53,18 @@ impl PostgresBackendSettings {
create_timeout: std::time::Duration, create_timeout: std::time::Duration,
recycle_timeout: std::time::Duration, recycle_timeout: std::time::Duration,
tls_mode: PostgresBackendTlsMode, tls_mode: PostgresBackendTlsMode,
auto_migrate: bool,
migration_timeout: std::time::Duration,
migration_lock_timeout: std::time::Duration,
) -> Self { ) -> Self {
return Self { return Self {
auto_migrate,
connect_timeout, connect_timeout,
connection_uri: connection_uri.into(), connection_uri: connection_uri.into(),
create_timeout, create_timeout,
max_connections, max_connections,
migration_lock_timeout,
migration_timeout,
network, network,
recycle_timeout, recycle_timeout,
tls_mode, tls_mode,
@@ -82,7 +91,10 @@ impl std::fmt::Debug for PostgresBackendSettings {
.debug_struct("PostgresBackendSettings") .debug_struct("PostgresBackendSettings")
.field("network", &self.network) .field("network", &self.network)
.field("connection_uri", &"<redacted>") .field("connection_uri", &"<redacted>")
.field("auto_migrate", &self.auto_migrate)
.field("max_connections", &self.max_connections) .field("max_connections", &self.max_connections)
.field("migration_timeout", &self.migration_timeout)
.field("migration_lock_timeout", &self.migration_lock_timeout)
.field("connect_timeout", &self.connect_timeout) .field("connect_timeout", &self.connect_timeout)
.field("wait_timeout", &self.wait_timeout) .field("wait_timeout", &self.wait_timeout)
.field("create_timeout", &self.create_timeout) .field("create_timeout", &self.create_timeout)
@@ -129,16 +141,27 @@ impl PostgresBackend {
std::result::Result::Err(error) => return std::result::Result::Err(error), std::result::Result::Err(error) => return std::result::Result::Err(error),
}; };
let probe = pool.get().await; let probe = pool.get().await;
match probe { let mut client = match probe {
std::result::Result::Ok(client) => drop(client), std::result::Result::Ok(value) => value,
std::result::Result::Err(error) => return std::result::Result::Err(map_pool_error(error)), std::result::Result::Err(error) => return std::result::Result::Err(map_pool_error(error)),
} };
ksp_logging_lib::debug!( ksp_logging_lib::debug!(
target: crate::TRACING_TARGET, target: crate::TRACING_TARGET,
network = settings.network().as_str(), network = settings.network().as_str(),
tls_mode = settings.tls_mode().code(), tls_mode = settings.tls_mode().code(),
"PostgreSQL Store backend established initial physical connection" "PostgreSQL Store backend established initial physical connection"
); );
let bootstrap_result = crate::bootstrap(&mut client, settings.auto_migrate, settings.migration_timeout, settings.migration_lock_timeout).await;
if let std::result::Result::Err(error) = bootstrap_result {
return std::result::Result::Err(error);
}
drop(client);
ksp_logging_lib::debug!(
target: crate::TRACING_TARGET,
network = settings.network().as_str(),
auto_migrate = settings.auto_migrate,
"PostgreSQL Store migration/bootstrap foundation verified"
);
return std::result::Result::Ok(Self { network: settings.network, pool }); return std::result::Result::Ok(Self { network: settings.network, pool });
} }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/tests/dependency_boundary.rs // file: crates/ksp-store-postgres-lib/tests/dependency_boundary.rs
// version: 3 // version: 4
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -10,12 +10,22 @@
#[test] #[test]
fn pre_005_backend_owns_exact_physical_runtime_dependencies_without_reverse_facade_edge() { fn pre_005_backend_owns_exact_physical_runtime_dependencies_without_reverse_facade_edge() {
let manifest = include_str!("../Cargo.toml"); let manifest = include_str!("../Cargo.toml");
for required in ["deadpool-postgres", "ksp-logging-lib", "ksp-store-api", "rustls", "rustls-native-certs", "tokio-postgres", "tokio-postgres-rustls"] { for required in
["deadpool-postgres", "ksp-logging-lib", "ksp-store-api", "rustls", "rustls-native-certs", "sha2", "tokio-postgres", "tokio-postgres-rustls"]
{
assert!(manifest.contains(required), "missing PostgreSQL backend dependency: {required}"); assert!(manifest.contains(required), "missing PostgreSQL backend dependency: {required}");
} }
for forbidden in ["ksp-store-lib", "ksp-config-lib", "ksp-materializer", "ksp-program", "ksp-onchain-transport-lib", "ksp-offchain-transport-lib", "sqlx"] { for forbidden in ["ksp-store-lib", "ksp-config-lib", "ksp-materializer", "ksp-program", "ksp-onchain-transport-lib", "ksp-offchain-transport-lib", "sqlx"] {
assert!(!manifest.contains(forbidden), "forbidden PostgreSQL backend dependency detected: {forbidden}"); assert!(!manifest.contains(forbidden), "forbidden PostgreSQL backend dependency detected: {forbidden}");
} }
let migration = include_str!("../src/migration.rs");
let bootstrap_sql = include_str!("../migrations/V000__bootstrap.sql");
assert!(migration.contains("include_str!(\"../migrations/V000__bootstrap.sql\")"));
assert!(bootstrap_sql.contains("ksp_store_schema_migrations"));
for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] {
assert!(!migration.contains(forbidden), "business migration implementation leaked into foundation: {forbidden}");
assert!(!bootstrap_sql.contains(forbidden), "business schema leaked into foundation SQL: {forbidden}");
}
return; return;
} }
@@ -23,6 +33,7 @@ fn pre_005_backend_owns_exact_physical_runtime_dependencies_without_reverse_faca
fn pre_005_backend_keeps_environment_sql_migrations_and_physical_types_private() { fn pre_005_backend_keeps_environment_sql_migrations_and_physical_types_private() {
let crate_root = include_str!("../src/lib.rs"); let crate_root = include_str!("../src/lib.rs");
assert!(crate_root.contains("mod error;")); assert!(crate_root.contains("mod error;"));
assert!(crate_root.contains("mod migration;"));
assert!(crate_root.contains("mod runtime;")); assert!(crate_root.contains("mod runtime;"));
assert!(crate_root.contains("const _: &str = crate::TRACING_TARGET;")); assert!(crate_root.contains("const _: &str = crate::TRACING_TARGET;"));
for forbidden in [ for forbidden in [
@@ -52,7 +63,6 @@ fn pre_005_backend_keeps_environment_sql_migrations_and_physical_types_private()
"UPDATE ", "UPDATE ",
"DELETE FROM", "DELETE FROM",
"SELECT ", "SELECT ",
"mod migration",
] { ] {
assert!(!runtime.contains(forbidden), "forbidden PostgreSQL backend ownership/scope content detected: {forbidden}"); assert!(!runtime.contains(forbidden), "forbidden PostgreSQL backend ownership/scope content detected: {forbidden}");
} }

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/tests/public_api.rs // file: crates/ksp-store-postgres-lib/tests/public_api.rs
// version: 1 // version: 2
#![warn(missing_docs)] #![warn(missing_docs)]
#![deny(unreachable_pub)] #![deny(unreachable_pub)]
@@ -22,6 +22,9 @@ fn pre_005_backend_bridge_is_constructible_without_io() {
std::time::Duration::from_secs(10), std::time::Duration::from_secs(10),
std::time::Duration::from_secs(5), std::time::Duration::from_secs(5),
ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull, ksp_store_postgres_lib::PostgresBackendTlsMode::VerifyFull,
true,
std::time::Duration::from_secs(30),
std::time::Duration::from_secs(10),
); );
assert_eq!(settings.network().as_str(), "devnet"); assert_eq!(settings.network().as_str(), "devnet");
assert_eq!(settings.tls_mode().code(), "verify_full"); assert_eq!(settings.tls_mode().code(), "verify_full");
@@ -36,9 +39,12 @@ fn pre_005_backend_error_projection_is_safe_and_static() {
ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid, ksp_store_postgres_lib::PostgresBackendErrorKind::ConfigInvalid,
ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed, ksp_store_postgres_lib::PostgresBackendErrorKind::ConnectFailed,
ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout, ksp_store_postgres_lib::PostgresBackendErrorKind::PoolTimeout,
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationFailed,
ksp_store_postgres_lib::PostgresBackendErrorKind::MigrationMismatch,
ksp_store_postgres_lib::PostgresBackendErrorKind::SchemaNewer,
ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout, ksp_store_postgres_lib::PostgresBackendErrorKind::ShutdownTimeout,
ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed, ksp_store_postgres_lib::PostgresBackendErrorKind::TlsFailed,
]; ];
assert_eq!(kinds.len(), 5); assert_eq!(kinds.len(), 8);
return; return;
} }

View File

@@ -0,0 +1,50 @@
// file: crates/ksp-store-postgres-lib/unit_tests/migration.rs
// version: 1
fn applied(version: i64, name: &str, checksum: &str) -> super::AppliedMigration {
return super::AppliedMigration { checksum: checksum.to_owned(), name: name.to_owned(), version };
}
#[test]
fn bootstrap_migration_is_static_metadata_only_and_checksum_is_stable_sha256() {
assert_eq!(super::BOOTSTRAP_MIGRATION_VERSION, 0);
assert_eq!(super::BOOTSTRAP_MIGRATION_NAME, "bootstrap");
assert!(super::BOOTSTRAP_MIGRATION_SQL.contains("CREATE TABLE ksp_store_schema_migrations"));
for forbidden in ["RawTransaction", "RawAccountState", "raw_transaction", "raw_account", "CORE", "DECODE", "SPECIALIZED"] {
assert!(!super::BOOTSTRAP_MIGRATION_SQL.contains(forbidden), "business schema leaked into bootstrap SQL: {forbidden}");
}
let checksum = super::bootstrap_checksum();
assert_eq!(checksum.len(), 64);
assert_eq!(checksum, "d29068b8c13b9dc0cc9ef6aaadd0fa12d41e0fe4c56541a1118c4bfc846a1450");
return;
}
#[test]
fn matching_sentinel_history_is_accepted() {
let checksum = super::bootstrap_checksum();
let history = [applied(0, "bootstrap", checksum.as_str())];
assert!(super::validate_history(&history, checksum.as_str()).is_ok());
return;
}
#[test]
fn divergent_or_missing_sentinel_is_terminal_mismatch() {
let checksum = super::bootstrap_checksum();
let wrong_name = [applied(0, "changed", checksum.as_str())];
let wrong_checksum = [applied(0, "bootstrap", "00")];
let missing: [super::AppliedMigration; 0] = [];
for history in [&wrong_name[..], &wrong_checksum[..], &missing[..]] {
let result = super::validate_history(history, checksum.as_str());
assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::MigrationMismatch));
}
return;
}
#[test]
fn newer_history_is_rejected_without_down_migration() {
let checksum = super::bootstrap_checksum();
let history = [applied(0, "bootstrap", checksum.as_str()), applied(1, "future", "future-checksum")];
let result = super::validate_history(&history, checksum.as_str());
assert_eq!(result.err().map(|value| return value.kind()), std::option::Option::Some(crate::PostgresBackendErrorKind::SchemaNewer));
return;
}

View File

@@ -1,5 +1,5 @@
// file: crates/ksp-store-postgres-lib/unit_tests/runtime.rs // file: crates/ksp-store-postgres-lib/unit_tests/runtime.rs
// version: 2 // version: 3
fn network() -> ksp_store_api::RawNetworkId { fn network() -> ksp_store_api::RawNetworkId {
return match ksp_store_api::RawNetworkId::new("devnet") { return match ksp_store_api::RawNetworkId::new("devnet") {
@@ -18,6 +18,9 @@ fn settings(connection_uri: &str, tls_mode: crate::PostgresBackendTlsMode) -> cr
std::time::Duration::from_secs(10), std::time::Duration::from_secs(10),
std::time::Duration::from_secs(5), std::time::Duration::from_secs(5),
tls_mode, tls_mode,
true,
std::time::Duration::from_secs(30),
std::time::Duration::from_secs(10),
); );
} }

143
deltas/0.3.2/pre.006.md Normal file
View File

@@ -0,0 +1,143 @@
<!-- file: deltas/0.3.2/pre.006.md -->
<!-- version: 1 -->
# Delta `0.3.2-pre.006` — migration/bootstrap PostgreSQL foundation
## 1. Base
Base exacte : `0.3.2-pre.005-fix.001`.
Le gate opérateur du 29 août 2026 est entièrement vert : audits Rust/Markdown, workspace check/Clippy, tests `ksp-store-postgres-lib`, tests `ksp-store-lib` avec et sans feature PostgreSQL et compilation `--no-default-features` passent. Les versions physiques réellement résolues observées sont `tokio-postgres 0.7.18`, `deadpool-postgres 0.14.2` et `tokio-postgres-rustls 0.14.0`.
## 2. Objet
Matérialiser le moteur privé de migration/bootstrap de la fondation PostgreSQL, sans aucune persistence métier :
```text
ksp_store_schema_migrations uniquement
sentinel version 0
nom bootstrap immuable
SHA-256 du SQL exact
advisory transaction lock borné
statement timeout transaction-local
transaction unique
validation metadata/history
newer-runtime guard
rollback du run courant
```
## 3. Dépendance SHA-256
`sha2 ^0.11` devient une dépendance workspace explicite et une dépendance directe de `ksp-store-postgres-lib`, car le backend calcule lui-même le checksum persistant.
Le digest SHA-256 est encodé explicitement en 64 caractères hex minuscules ; aucune dépendance de codec hex supplémentaire n'est ajoutée.
## 4. Migration bootstrap embarquée
La seule ressource SQL de production ajoutée est :
```text
crates/ksp-store-postgres-lib/migrations/V000__bootstrap.sql
```
Elle crée uniquement :
```text
ksp_store_schema_migrations
```
avec les champs minimaux :
```text
version BIGINT PRIMARY KEY
name TEXT NOT NULL
checksum TEXT NOT NULL
applied_at TIMESTAMPTZ NOT NULL
```
Aucune table/index RAW, CORE, DECODE ou SPECIALIZED n'est introduit.
## 5. Algorithme runtime
Après la première acquisition physique déjà introduite en `pre.005`, le backend conserve ce client dédié et exécute :
```text
begin transaction
pg_try_advisory_xact_lock($1) en polling borné
set_config('statement_timeout', $1, true)
probe metadata
si absente et auto_migrate=false -> migration_pending sans DDL
si absente et auto_migrate=true -> V000 + sentinel dans la même transaction
si présente -> vérification forme minimale + PK version
load history ordered
missing/divergence -> migration mismatch
version > 0 -> schema newer
commit unique
```
L'ensemble du bootstrap est aussi borné par `migration_timeout`. Une sortie avant `commit()` droppe la transaction ; le rollback PostgreSQL implicite protège DDL et history du run courant.
Les colonnes obligatoires de metadata sont vérifiées sans interdire des colonnes supplémentaires futures. Cela permet à un ancien runtime de lire une history plus récente et de produire `schema_newer` plutôt qu'un faux mismatch de forme.
## 6. Erreurs façade
Le bridge backend ajoute :
```text
MigrationFailed
MigrationMismatch
SchemaNewer
```
La façade mappe vers :
```text
store.postgres_migration_failed
store.postgres_migration_mismatch
store.postgres_schema_newer
```
Aucun texte serveur, SQL, URI ou credential n'est propagé.
## 7. Tests/canaris
Les tests unitaires sans serveur couvrent :
```text
checksum exact/stable du V000
absence de schéma métier dans le SQL
sentinel valide
sentinel missing
nom/checksum divergent
history plus récente
bridge backend étendu
frontière dépendances + sha2 direct
```
La concurrence réelle, l'idempotence réelle et le rollback injecté restent au test PostgreSQL opt-in de `pre.008`.
## 8. Version
```text
workspace.package.version = 0.3.2-pre.6
```
## 9. Gate opérateur
```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.2
cargo check --workspace
cargo clippy --workspace --all-targets
cargo test -p ksp-store-postgres-lib
cargo test -p ksp-store-lib
cargo test -p ksp-store-lib --no-default-features
cargo check -p ksp-store-lib --no-default-features
cargo tree -p ksp-store-postgres-lib --edges normal
cargo tree -p ksp-store-lib --edges normal
```
## 10. Suite
Après gate propre, `pre.007` ferme la composition end-to-end et ajoute la projection health/readiness sûre, sans ouvrir encore les tables RAW métier.

View File

@@ -1,5 +1,5 @@
<!-- file: docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md --> <!-- file: docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md -->
<!-- version: 7 --> <!-- version: 8 -->
# Plan `0.3.2` — Store/PostgreSQL runtime foundation # Plan `0.3.2` — Store/PostgreSQL runtime foundation
@@ -942,7 +942,7 @@ Matérialiser `StoreSettings`, `StoreBackendSettings`, `PostgresStoreSettings`,
**Statut : matérialisé par `0.3.2-pre.003`, gate opérateur requis.** **Statut : matérialisé par `0.3.2-pre.003`, gate opérateur requis.**
La tranche fixe : La tranche fixe initialement :
```text ```text
73 exports crate-root de ksp-store-lib : 60 reexports ksp-store-api + 13 éléments runtime Store 73 exports crate-root de ksp-store-lib : 60 reexports ksp-store-api + 13 éléments runtime Store
@@ -980,9 +980,34 @@ ksp-store-postgres-lib
Après parsing, la policy KSP réécrit `application_name`, `connect_timeout`, `sslnegotiation` et `sslmode`; les `options=` serveur sont rejetées. `Disabled` force le plaintext, `VerifyFull` force TLS + roots système + identité serveur et interdit un target `hostaddr` sans `host` ainsi que les sockets Unix, sur lesquels PostgreSQL ne négocie pas TLS. Le pool applique `max_size` et deadlines `wait/create/recycle`, utilise le recycling `Verified`, et `Store::open` ne réussit qu'après une première acquisition physique. Les erreurs externes ne sont jamais conservées comme source/texte public. `Store::close` ferme et draine sous deadline; `Drop` reste best-effort. Aucun SQL KSP, migration ou table métier n'est introduit. Après parsing, la policy KSP réécrit `application_name`, `connect_timeout`, `sslnegotiation` et `sslmode`; les `options=` serveur sont rejetées. `Disabled` force le plaintext, `VerifyFull` force TLS + roots système + identité serveur et interdit un target `hostaddr` sans `host` ainsi que les sockets Unix, sur lesquels PostgreSQL ne négocie pas TLS. Le pool applique `max_size` et deadlines `wait/create/recycle`, utilise le recycling `Verified`, et `Store::open` ne réussit qu'après une première acquisition physique. Les erreurs externes ne sont jamais conservées comme source/texte public. `Store::close` ferme et draine sous deadline; `Drop` reste best-effort. Aucun SQL KSP, migration ou table métier n'est introduit.
Le gate opérateur de `pre.005-fix.001` est vert : audits, workspace check/Clippy, tests backend, façade avec et sans feature PostgreSQL et compilation `--no-default-features` passent. Les versions résolues observées sont `tokio-postgres 0.7.18`, `deadpool-postgres 0.14.2` et `tokio-postgres-rustls 0.14.0`.
### `pre.006` — Migration/bootstrap foundation ### `pre.006` — Migration/bootstrap foundation
Créer history metadata, sentinel, SHA-256, advisory lock borné, transaction, version/checksum/newer/missing handling et rollback. Aucun schéma RAW. Statut : matérialisé par `0.3.2-pre.006`, gate Cargo opérateur à exécuter.
La tranche introduit un moteur privé `ksp-store-postgres-lib` sans crate de migration externe :
```text
sha2 0.11.x explicite
V000__bootstrap.sql embarqué par include_str!
ksp_store_schema_migrations uniquement
version 0 / nom bootstrap / SHA-256 exact du SQL
transaction unique
pg_try_advisory_xact_lock($1) KSP fixe sous deadline
statement_timeout transaction-local via set_config(..., true)
forme metadata minimale vérifiée
history divergente/missing -> migration mismatch
version appliquée > 0 -> schema newer
aucun down migration
aucun schéma RAW
```
`auto_migrate = false` ne crée jamais la metadata absente : l'ouverture échoue avec un état `migration_pending` sûr. Si la metadata existe, elle est toujours vérifiée. Une table valide mais sans sentinel n'est jamais réparée implicitement : elle est considérée divergente. Les colonnes metadata obligatoires sont vérifiées sans interdire d'éventuelles colonnes supplémentaires futures, afin qu'un ancien runtime puisse encore lire l'historique puis classer correctement une version plus récente comme `schema_newer`.
Le bootstrap version `0` est spécial : lorsque la metadata est absente et l'auto-migration autorisée, le DDL embarqué est exécuté puis le sentinel est inséré dans la même transaction. Toute erreur ou expiration fait abandonner la transaction ; le rollback PostgreSQL implicite au drop protège DDL + history du run courant. Les preuves concurrentes et rollback injecté sur serveur réel restent à `pre.008`.
Avec les quatre codes PostgreSQL ajoutés en `pre.005` puis les trois codes migration de `pre.006`, la façade atteint désormais 80 exports crate-root : 60 réexports `ksp-store-api` et 20 éléments runtime Store.
### `pre.007` — Composition end-to-end + health ### `pre.007` — Composition end-to-end + health

View File

@@ -1,5 +1,5 @@
<!-- file: docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md --> <!-- file: docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md -->
<!-- version: 7 --> <!-- version: 8 -->
# Validation `0.3.2` — Store/PostgreSQL runtime foundation # Validation `0.3.2` — Store/PostgreSQL runtime foundation
@@ -60,7 +60,7 @@ Référence du gate :
```text ```text
PostgreSQL 18.6 PostgreSQL 18.6
tokio-postgres 0.7.18 tokio-postgres 0.7.18
deadpool-postgres 0.14.1 deadpool-postgres 0.14.2
tokio-postgres-rustls 0.14.0 tokio-postgres-rustls 0.14.0
sha2 0.11.0 sha2 0.11.0
refinery 0.9.2 audité/rejeté refinery 0.9.2 audité/rejeté
@@ -76,6 +76,8 @@ Le gate opérateur de `pre.003-fix.001`, fourni le 29 août 2026, est entièreme
Le gate opérateur de `pre.004-fix.001`, après correction manuelle des commentaires `.env.example`, est entièrement vert : audits Rust/Markdown, workspace check/Clippy, 126 tests `ksp-config-lib`, ownership, `ksp-store-lib` avec et sans feature PostgreSQL, compilation `--no-default-features` et 63 tests Config Desk passent. Cette base est l'entrée effective de `pre.005`. Le gate opérateur de `pre.004-fix.001`, après correction manuelle des commentaires `.env.example`, est entièrement vert : audits Rust/Markdown, workspace check/Clippy, 126 tests `ksp-config-lib`, ownership, `ksp-store-lib` avec et sans feature PostgreSQL, compilation `--no-default-features` et 63 tests Config Desk passent. Cette base est l'entrée effective de `pre.005`.
Le gate opérateur de `pre.005-fix.001`, fourni le 29 août 2026, est entièrement vert : audits Rust/Markdown, workspace check/Clippy, 5 tests runtime backend, canaris de dépendances/API, façade avec et sans feature PostgreSQL et compilation `--no-default-features` passent. Cette base est l'entrée effective de `pre.006`.
## 3. Frontières Cargo ## 3. Frontières Cargo
### V32-DEP-001 — Façade -> API ### V32-DEP-001 — Façade -> API
@@ -138,7 +140,7 @@ consumer ordinaire -> ksp-store-postgres-lib
Matérialisé par `pre.005` : les types physiques nécessaires sont publics uniquement dans la crate backend pour la frontière inter-crates et ne sont jamais réexportés par `ksp-store-lib`; `Pool/Client/Row/Statement` restent absents de sa crate-root. Matérialisé par `pre.005` : les types physiques nécessaires sont publics uniquement dans la crate backend pour la frontière inter-crates et ne sont jamais réexportés par `ksp-store-lib`; `Pool/Client/Row/Statement` restent absents de sa crate-root.
Statut : `TODO gate opérateur pre.005 / TODO pre.009`. Statut : `PASS pre.005-fix.001 opérateur / TODO pre.009`.
### V32-DEP-006 — `ksp-store-api` non régressé ### V32-DEP-006 — `ksp-store-api` non régressé
@@ -205,7 +207,7 @@ Drop best-effort seulement
`pre.003` fixe les signatures `Store::open(settings).await` et `Store::close(self).await`. `pre.005` remplace le stop de staging PostgreSQL par l'ouverture physique : un succès exige `pool.get().await` après construction du pool, puis `close(self)` ferme et draine ce pool sous la deadline configurée. Aucun pool/client n'est exposé par la façade. `pre.003` fixe les signatures `Store::open(settings).await` et `Store::close(self).await`. `pre.005` remplace le stop de staging PostgreSQL par l'ouverture physique : un succès exige `pool.get().await` après construction du pool, puis `close(self)` ferme et draine ce pool sous la deadline configurée. Aucun pool/client n'est exposé par la façade.
Statut : `PASS pre.003-fix.001 opérateur` pour les signatures / `TODO gate opérateur pre.005` pour l'ouverture et le shutdown physiques / `TODO pre.007` pour la composition health. Statut : `PASS pre.003-fix.001 opérateur` pour les signatures / `PASS pre.005-fix.001 opérateur` pour l'ouverture et le shutdown physiques / `TODO pre.007` pour la composition health.
## 5. Config ownership ## 5. Config ownership
@@ -298,7 +300,7 @@ aucune valeur pathologique
Matérialisé par `pre.005` : `deadpool-postgres` possède un pool `max_size` explicite et applique les deadlines `wait/create/recycle`; `tokio-postgres::Config` reçoit aussi le `connect_timeout` typé après parsing de l'URI. Matérialisé par `pre.005` : `deadpool-postgres` possède un pool `max_size` explicite et applique les deadlines `wait/create/recycle`; `tokio-postgres::Config` reçoit aussi le `connect_timeout` typé après parsing de l'URI.
Statut : `TODO gate opérateur pre.005`. Statut : `PASS pre.005-fix.001 opérateur`.
### V32-POOL-002 — Connection task ownership ### V32-POOL-002 — Connection task ownership
@@ -306,7 +308,7 @@ Critère : chaque connection future tokio-postgres est pilotée par le manager r
`pre.005` délègue la création/recycle des connexions au `Manager` Deadpool retenu ; aucun `tokio::spawn` KSP n'est introduit dans Store. La preuve de non-régression détaillée reste au hardening. `pre.005` délègue la création/recycle des connexions au `Manager` Deadpool retenu ; aucun `tokio::spawn` KSP n'est introduit dans Store. La preuve de non-régression détaillée reste au hardening.
Statut : `TODO gate opérateur pre.005 / TODO pre.009`. Statut : `PASS pre.005-fix.001 opérateur / TODO pre.009`.
### V32-POOL-003 — Open failure safe ### V32-POOL-003 — Open failure safe
@@ -314,7 +316,7 @@ Critère : DNS/connect/auth/server errors ne copient ni URI ni texte remote arbi
Matérialisé par `pre.005` : parsing/connexion/pool/TLS sont ramenés à une classification backend locale sans conserver le texte des erreurs externes ni l'URI. La façade mappe vers des codes `store.postgres_*` stables avec contexte statique sûr. Matérialisé par `pre.005` : parsing/connexion/pool/TLS sont ramenés à une classification backend locale sans conserver le texte des erreurs externes ni l'URI. La façade mappe vers des codes `store.postgres_*` stables avec contexte statique sûr.
Statut : `TODO gate opérateur pre.005`. Statut : `PASS pre.005-fix.001 opérateur`.
### V32-POOL-004 — Close ### V32-POOL-004 — Close
@@ -329,7 +331,7 @@ aucune tâche volontairement laissée orpheline
`pre.005` matérialise déjà `Pool::close()` et un drain borné par `shutdown_timeout`; `Drop` ne fait qu'un `close()` best-effort. La preuve end-to-end avec backend réel reste réservée à `pre.007/pre.008`. `pre.005` matérialise déjà `Pool::close()` et un drain borné par `shutdown_timeout`; `Drop` ne fait qu'un `close()` best-effort. La preuve end-to-end avec backend réel reste réservée à `pre.007/pre.008`.
Statut : `TODO gate opérateur pre.005 / TODO pre.007/pre.008`. Statut : `PASS pre.005-fix.001 opérateur / TODO pre.007/pre.008`.
## 7. TLS ## 7. TLS
@@ -344,7 +346,7 @@ VerifyFull
Matérialisé par `pre.005` : la surface physique accepte exactement `Disabled` et `VerifyFull`; l'URI parsée est ensuite normalisée vers `SslMode::Disable` ou `SslMode::Require`, donc elle ne peut pas modifier la policy typée. Matérialisé par `pre.005` : la surface physique accepte exactement `Disabled` et `VerifyFull`; l'URI parsée est ensuite normalisée vers `SslMode::Disable` ou `SslMode::Require`, donc elle ne peut pas modifier la policy typée.
Statut : `TODO gate opérateur pre.005`. Statut : `PASS pre.005-fix.001 opérateur`.
### V32-TLS-002 — VerifyFull ### V32-TLS-002 — VerifyFull
@@ -360,7 +362,7 @@ aucun fallback plaintext
Matérialisé par `pre.005` : `VerifyFull` construit un `rustls::RootCertStore` à partir des roots système, utilise explicitement le provider AWS-LC, requiert un host TCP pour l'identité serveur, rejette `hostaddr` seul et les sockets Unix, et ne permet aucun fallback plaintext. Les erreurs de roots/certificats sont réduites à des compteurs sûrs. Matérialisé par `pre.005` : `VerifyFull` construit un `rustls::RootCertStore` à partir des roots système, utilise explicitement le provider AWS-LC, requiert un host TCP pour l'identité serveur, rejette `hostaddr` seul et les sockets Unix, et ne permet aucun fallback plaintext. Les erreurs de roots/certificats sont réduites à des compteurs sûrs.
Statut : `TODO gate opérateur pre.005`. Statut : `PASS pre.005-fix.001 opérateur`.
### V32-TLS-003 — Pas de fichier TLS implicite ### V32-TLS-003 — Pas de fichier TLS implicite
@@ -380,7 +382,9 @@ ksp_store_schema_migrations
et aucune table métier RAW/CORE/DECODE/SPECIALIZED. et aucune table métier RAW/CORE/DECODE/SPECIALIZED.
Statut : `TODO pre.006`. `pre.006` embarque exactement `migrations/V000__bootstrap.sql`, dont le seul DDL de production crée `ksp_store_schema_migrations`. Le canari source interdit les identifiants métier RAW/CORE/DECODE/SPECIALIZED dans le moteur et la ressource SQL.
Statut : `TODO gate opérateur pre.006 / TODO pre.008 live`.
### V32-MIG-002 — Version/checksum ### V32-MIG-002 — Version/checksum
@@ -394,25 +398,33 @@ sentinel bootstrap version 0
mismatch historique terminal mismatch historique terminal
``` ```
Statut : `TODO pre.006`. `pre.006` calcule explicitement SHA-256 sur les octets exacts du SQL embarqué puis encode les 32 octets en 64 caractères hex minuscules. Le sentinel `(0, bootstrap, checksum)` est inséré dans la même transaction que la création metadata. Les tests unitaires figent le checksum du SQL committed et couvrent sentinel valide, missing, nom/checksum divergents et historique plus récent.
Statut : `TODO gate opérateur pre.006 / TODO pre.008 live`.
### V32-MIG-003 — Concurrence ### V32-MIG-003 — Concurrence
Critère : deux runners concurrents sont sérialisés par advisory transaction lock avec attente bornée. Critère : deux runners concurrents sont sérialisés par advisory transaction lock avec attente bornée.
Statut : `TODO pre.006/pre.008`. `pre.006` utilise une clé KSP fixe et `pg_try_advisory_xact_lock($1)` dans une boucle bornée par `migration_lock_timeout`, avec polling de 25 ms maximum. Aucun lock bloquant illimité n'est utilisé.
Statut : `TODO gate opérateur pre.006 / TODO pre.008 concurrence réelle`.
### V32-MIG-004 — Atomicité/recovery ### V32-MIG-004 — Atomicité/recovery
Critère : échec d'une migration du run courant rollback DDL + history de ce run ; un rerun depuis état précédent reste sûr. Critère : échec d'une migration du run courant rollback DDL + history de ce run ; un rerun depuis état précédent reste sûr.
Statut : `TODO pre.006/pre.008`. `pre.006` place lock, metadata DDL, sentinel et validation dans une transaction unique ; toute sortie d'erreur avant `commit()` droppe la transaction et PostgreSQL rollback le run courant. Un timeout externe borne également l'ensemble du bootstrap. L'injection d'échec et la preuve physique du rollback restent au smoke réel.
Statut : `TODO gate opérateur pre.006 / TODO pre.008 rollback réel`.
### V32-MIG-005 — Newer runtime guard ### V32-MIG-005 — Newer runtime guard
Critère : migration appliquée inconnue/supérieure à la liste embarquée produit `STORE_POSTGRES_SCHEMA_NEWER`, sans down migration. Critère : migration appliquée inconnue/supérieure à la liste embarquée produit `STORE_POSTGRES_SCHEMA_NEWER`, sans down migration.
Statut : `TODO pre.006`. `pre.006` connaît uniquement la version `0`; toute history `> 0` est classée `SchemaNewer` par le backend puis `store.postgres_schema_newer` par la façade. Aucun chemin de down migration n'existe.
Statut : `TODO gate opérateur pre.006`.
### V32-MIG-006 — SQL injection ### V32-MIG-006 — SQL injection
@@ -424,7 +436,9 @@ values paramétrées
aucun identifier physique user-configurable en 0.3.2 aucun identifier physique user-configurable en 0.3.2
``` ```
Statut : `TODO pre.006/pre.009`. `pre.006` garde le DDL versionné sous `include_str!` et toutes les values variables des requêtes de contrôle (`lock key`, `statement_timeout`, history values) passent par paramètres. Les noms physiques sont des constantes KSP, jamais des settings.
Statut : `TODO gate opérateur pre.006 / TODO pre.009 hardening`.
### V32-MIG-007 — No business capability ### V32-MIG-007 — No business capability
@@ -539,9 +553,9 @@ Statut : `TODO pre.009`.
Cas : zéro, inversion, dépassement bornes pour pool/connect/migration/close. Cas : zéro, inversion, dépassement bornes pour pool/connect/migration/close.
Les bornes backend-neutral décidées en `pre.001` sont matérialisées et couvertes par tests unitaires en `pre.003`. Le parsing PostgreSQL et les timeouts physiques restent à `pre.005`. Les bornes backend-neutral décidées en `pre.001` sont matérialisées et couvertes par tests unitaires en `pre.003`. `pre.005` applique physiquement `connect/wait/create/recycle/shutdown`; `pre.006` applique physiquement `migration_timeout` et `migration_lock_timeout`.
Statut : `PASS pre.003-fix.001 opérateur` pour les bornes backend-neutral / `TODO pre.005 / TODO pre.009` pour les timeouts physiques. Statut : `PASS pre.003-fix.001 opérateur` pour les bornes backend-neutral / `PASS pre.005-fix.001 opérateur` pour pool/connect/shutdown / `TODO gate opérateur pre.006` pour migration / `TODO pre.009` hardening.
### V32-SEC-004 — Feature mismatch avant I/O ### V32-SEC-004 — Feature mismatch avant I/O
@@ -553,7 +567,7 @@ Statut : `PASS pre.003-fix.001 opérateur / TODO pre.009`.
Un serveur/test double qui renvoie un message contenant un canary ne doit pas le faire traverser l'erreur publique. Un serveur/test double qui renvoie un message contenant un canary ne doit pas le faire traverser l'erreur publique.
Statut : `TODO pre.005/pre.009`. Statut : bridge de sanitization matérialisé en `pre.005`; `TODO pre.009` pour le canary serveur hostile.
## 12. Gates Rust/workspace ## 12. Gates Rust/workspace