From 6d2b1401aaf427dc70809daaa8232c351dde0ad1 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Sat, 29 Aug 2026 18:42:37 +0200 Subject: [PATCH] v0.3.2-pre.004 --- .env.example | 6 +- Cargo.toml | 4 +- config/examples/std.store.example.json | 29 ++ config/schemas/std.store.schema.json | 158 +++++++++++ config/std.store.json | 29 ++ crates/ksp-app-config-desk/tauri.conf.json | 2 + .../tests/desktop_contract.rs | 9 +- crates/ksp-app-solprices-desk/tauri.conf.json | 2 + .../tests/desktop_contract.rs | 8 +- crates/ksp-app-wallet-desk/tauri.conf.json | 2 + .../tests/desktop_contract.rs | 9 +- .../tests/release_compliance.rs | 4 +- crates/ksp-config-lib/Cargo.toml | 3 +- crates/ksp-config-lib/src/lib.rs | 15 +- crates/ksp-config-lib/src/registry.rs | 28 +- crates/ksp-config-lib/src/store.rs | 264 ++++++++++++++++++ crates/ksp-config-lib/tests/ownership.rs | 45 ++- crates/ksp-config-lib/tests/public_api.rs | 36 ++- crates/ksp-config-lib/unit_tests/registry.rs | 49 +++- crates/ksp-config-lib/unit_tests/store.rs | 163 +++++++++++ deltas/0.3.2/pre.004.md | 108 +++++++ ...3-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md | 10 +- .../019-V0_3_2_STORE_POSTGRES_FOUNDATION.md | 32 ++- 23 files changed, 958 insertions(+), 57 deletions(-) create mode 100644 config/examples/std.store.example.json create mode 100644 config/schemas/std.store.schema.json create mode 100644 config/std.store.json create mode 100644 crates/ksp-config-lib/src/store.rs create mode 100644 crates/ksp-config-lib/unit_tests/store.rs create mode 100644 deltas/0.3.2/pre.004.md diff --git a/.env.example b/.env.example index 1c7f241..bdcdffe 100644 --- a/.env.example +++ b/.env.example @@ -1,10 +1,14 @@ # file: .env.example -# version: 10 +# version: 11 # KSP Logging root directory. Used by config/std.logging.json for relative log output paths. # The current Config document fallback is "logs" when neither the process environment nor .env defines this variable. KSP_LOGS_DIRECTORY=logs +# PostgreSQL connection URI used by Config std.store profiles. +# Keep credentials only in the process environment or local .env; the committed fallback remains secret-classified and is redacted in safe projections. +# KSP_SECRET_STORE_POSTGRES_URI=postgresql://user:password@localhost/ksp + # KSP Wallet root directory. Used by config/std.wallet.json before an optional profile subdirectory is appended. # The committed Wallet document falls back to "wallets" when neither the process environment nor .env defines this variable. KSP_WALLETS_DIRECTORY=wallets diff --git a/Cargo.toml b/Cargo.toml index 0747fc7..d4b3165 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 334 +# version: 335 [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.2-pre.3.fix.1" +version = "0.3.2-pre.4" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/config/examples/std.store.example.json b/config/examples/std.store.example.json new file mode 100644 index 0000000..6bc56ac --- /dev/null +++ b/config/examples/std.store.example.json @@ -0,0 +1,29 @@ +{ + "format_version": 1, + "default_profile": "postgres_default", + "profiles": [ + { + "profile_id": "postgres_default", + "backend": "postgres", + "postgres": { + "connection_uri": "${KSP_SECRET_STORE_POSTGRES_URI:-postgresql://localhost/ksp}", + "pool": { + "max_connections": 8, + "connect_timeout_ms": 10000, + "wait_timeout_ms": 5000, + "create_timeout_ms": 10000, + "recycle_timeout_ms": 5000 + }, + "tls": { + "mode": "verify_full" + }, + "bootstrap": { + "auto_migrate": true, + "migration_timeout_ms": 30000, + "migration_lock_timeout_ms": 10000 + }, + "shutdown_timeout_ms": 5000 + } + } + ] +} diff --git a/config/schemas/std.store.schema.json b/config/schemas/std.store.schema.json new file mode 100644 index 0000000..6def4e8 --- /dev/null +++ b/config/schemas/std.store.schema.json @@ -0,0 +1,158 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:ksp:schema:std.store:v1", + "title": "KSP standard Store configuration", + "type": "object", + "additionalProperties": false, + "required": [ + "format_version", + "default_profile", + "profiles" + ], + "properties": { + "format_version": { + "const": 1 + }, + "default_profile": { + "$ref": "#/$defs/profileId" + }, + "profiles": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/$defs/profile" + } + } + }, + "$defs": { + "profileId": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9._-]*$" + }, + "duration100To60000": { + "type": "integer", + "minimum": 100, + "maximum": 60000 + }, + "profile": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile_id", + "backend", + "postgres" + ], + "properties": { + "profile_id": { + "$ref": "#/$defs/profileId" + }, + "backend": { + "const": "postgres" + }, + "postgres": { + "$ref": "#/$defs/postgres" + } + } + }, + "postgres": { + "type": "object", + "additionalProperties": false, + "required": [ + "connection_uri", + "pool", + "tls", + "bootstrap", + "shutdown_timeout_ms" + ], + "properties": { + "connection_uri": { + "type": "string", + "minLength": 1 + }, + "pool": { + "$ref": "#/$defs/pool" + }, + "tls": { + "$ref": "#/$defs/tls" + }, + "bootstrap": { + "$ref": "#/$defs/bootstrap" + }, + "shutdown_timeout_ms": { + "type": "integer", + "minimum": 100, + "maximum": 30000 + } + } + }, + "pool": { + "type": "object", + "additionalProperties": false, + "required": [ + "max_connections", + "connect_timeout_ms", + "wait_timeout_ms", + "create_timeout_ms", + "recycle_timeout_ms" + ], + "properties": { + "max_connections": { + "type": "integer", + "minimum": 1, + "maximum": 64 + }, + "connect_timeout_ms": { + "$ref": "#/$defs/duration100To60000" + }, + "wait_timeout_ms": { + "$ref": "#/$defs/duration100To60000" + }, + "create_timeout_ms": { + "$ref": "#/$defs/duration100To60000" + }, + "recycle_timeout_ms": { + "$ref": "#/$defs/duration100To60000" + } + } + }, + "tls": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode" + ], + "properties": { + "mode": { + "enum": [ + "disabled", + "verify_full" + ] + } + } + }, + "bootstrap": { + "type": "object", + "additionalProperties": false, + "required": [ + "auto_migrate", + "migration_timeout_ms", + "migration_lock_timeout_ms" + ], + "properties": { + "auto_migrate": { + "type": "boolean" + }, + "migration_timeout_ms": { + "type": "integer", + "minimum": 1000, + "maximum": 300000 + }, + "migration_lock_timeout_ms": { + "type": "integer", + "minimum": 100, + "maximum": 120000 + } + } + } + } +} diff --git a/config/std.store.json b/config/std.store.json new file mode 100644 index 0000000..6bc56ac --- /dev/null +++ b/config/std.store.json @@ -0,0 +1,29 @@ +{ + "format_version": 1, + "default_profile": "postgres_default", + "profiles": [ + { + "profile_id": "postgres_default", + "backend": "postgres", + "postgres": { + "connection_uri": "${KSP_SECRET_STORE_POSTGRES_URI:-postgresql://localhost/ksp}", + "pool": { + "max_connections": 8, + "connect_timeout_ms": 10000, + "wait_timeout_ms": 5000, + "create_timeout_ms": 10000, + "recycle_timeout_ms": 5000 + }, + "tls": { + "mode": "verify_full" + }, + "bootstrap": { + "auto_migrate": true, + "migration_timeout_ms": 30000, + "migration_lock_timeout_ms": 10000 + }, + "shutdown_timeout_ms": 5000 + } + } + ] +} diff --git a/crates/ksp-app-config-desk/tauri.conf.json b/crates/ksp-app-config-desk/tauri.conf.json index f89328a..d733515 100644 --- a/crates/ksp-app-config-desk/tauri.conf.json +++ b/crates/ksp-app-config-desk/tauri.conf.json @@ -56,11 +56,13 @@ "../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json", "../../config/std.logging.json": "config/std.logging.json", "../../config/std.offchain_transport.json": "config/std.offchain_transport.json", + "../../config/std.store.json": "config/std.store.json", "../../config/std.transport.json": "config/std.transport.json", "../../config/std.wallet.json": "config/std.wallet.json", "../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json", "../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json", "../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json", + "../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json", "../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json", "../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json" } diff --git a/crates/ksp-app-config-desk/tests/desktop_contract.rs b/crates/ksp-app-config-desk/tests/desktop_contract.rs index 8b87dc1..253dc48 100644 --- a/crates/ksp-app-config-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-config-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-config-desk/tests/desktop_contract.rs -// version: 10 +// version: 11 //! Desktop build/shell contract audits for Config Desk. @@ -110,7 +110,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writab let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object); assert!(resources.is_some(), "packaged Config resources map must exist"); if let std::option::Option::Some(resources) = resources { - assert_eq!(resources.len(), 11); + assert_eq!(resources.len(), 13); assert_eq!( resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"), @@ -124,6 +124,11 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_activates_shared_writab resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"), ); + assert_eq!(resources.get("../../config/std.store.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.store.json"),); + assert_eq!( + resources.get("../../config/schemas/std.store.schema.json").and_then(serde_json::Value::as_str), + std::option::Option::Some("config/schemas/std.store.schema.json"), + ); assert_eq!( resources.get("../../config/schemas/std.wallet.schema.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/schemas/std.wallet.schema.json"), diff --git a/crates/ksp-app-solprices-desk/tauri.conf.json b/crates/ksp-app-solprices-desk/tauri.conf.json index 26eb675..ffe398e 100644 --- a/crates/ksp-app-solprices-desk/tauri.conf.json +++ b/crates/ksp-app-solprices-desk/tauri.conf.json @@ -56,11 +56,13 @@ "../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json", "../../config/std.logging.json": "config/std.logging.json", "../../config/std.offchain_transport.json": "config/std.offchain_transport.json", + "../../config/std.store.json": "config/std.store.json", "../../config/std.transport.json": "config/std.transport.json", "../../config/std.wallet.json": "config/std.wallet.json", "../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json", "../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json", "../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json", + "../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json", "../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json", "../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json" } diff --git a/crates/ksp-app-solprices-desk/tests/desktop_contract.rs b/crates/ksp-app-solprices-desk/tests/desktop_contract.rs index 4c539c4..bf30b86 100644 --- a/crates/ksp-app-solprices-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-solprices-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-solprices-desk/tests/desktop_contract.rs -// version: 9 +// version: 10 //! Desktop scaffold, shared-template and Config packaging contract audits for SOL Prices Desk `0.2.12`. @@ -85,19 +85,21 @@ fn pre_002_package_is_mixed_lib_bin_and_frontend_is_scaffold_only() { } #[test] -fn pre_003_packaging_contains_current_eleven_config_resources() { +fn pre_004_packaging_contains_current_thirteen_config_resources() { let root = app_root(); let tauri = read_json(root.join("tauri.conf.json").as_path()); let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object); assert!(resources.is_some()); if let std::option::Option::Some(resources) = resources { - assert_eq!(resources.len(), 11); + assert_eq!(resources.len(), 13); assert_eq!( resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"), ); assert!(resources.contains_key("../../config/std.offchain_transport.json")); assert!(resources.contains_key("../../config/schemas/std.offchain_transport.schema.json")); + assert!(resources.contains_key("../../config/std.store.json")); + assert!(resources.contains_key("../../config/schemas/std.store.schema.json")); } let tauri_source = read_text(root.join("src/tauri.rs").as_path()); assert!(tauri_source.contains("ksp_config_lib::prepare_packaged_runtime")); diff --git a/crates/ksp-app-wallet-desk/tauri.conf.json b/crates/ksp-app-wallet-desk/tauri.conf.json index d6c8d5b..50b3477 100644 --- a/crates/ksp-app-wallet-desk/tauri.conf.json +++ b/crates/ksp-app-wallet-desk/tauri.conf.json @@ -56,11 +56,13 @@ "../../config/composite.ksp-app-wallet-desk.json": "config/composite.ksp-app-wallet-desk.json", "../../config/std.logging.json": "config/std.logging.json", "../../config/std.offchain_transport.json": "config/std.offchain_transport.json", + "../../config/std.store.json": "config/std.store.json", "../../config/std.transport.json": "config/std.transport.json", "../../config/std.wallet.json": "config/std.wallet.json", "../../config/schemas/composite.schema.json": "config/schemas/composite.schema.json", "../../config/schemas/std.logging.schema.json": "config/schemas/std.logging.schema.json", "../../config/schemas/std.offchain_transport.schema.json": "config/schemas/std.offchain_transport.schema.json", + "../../config/schemas/std.store.schema.json": "config/schemas/std.store.schema.json", "../../config/schemas/std.transport.schema.json": "config/schemas/std.transport.schema.json", "../../config/schemas/std.wallet.schema.json": "config/schemas/std.wallet.schema.json" } diff --git a/crates/ksp-app-wallet-desk/tests/desktop_contract.rs b/crates/ksp-app-wallet-desk/tests/desktop_contract.rs index 35cd12f..231c16a 100644 --- a/crates/ksp-app-wallet-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-wallet-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/tests/desktop_contract.rs -// version: 30 +// version: 31 //! Desktop build, shell and Config-status contract audits for Wallet Desk. @@ -432,7 +432,7 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi let resources = tauri.pointer("/bundle/resources").and_then(serde_json::Value::as_object); assert!(resources.is_some(), "packaged Wallet Desk Config resources map must exist"); if let std::option::Option::Some(resources) = resources { - assert_eq!(resources.len(), 11); + assert_eq!(resources.len(), 13); assert_eq!( resources.get("../../config/composite.ksp-app-solprices-desk.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/composite.ksp-app-solprices-desk.json"), @@ -449,6 +449,11 @@ fn pre_018_packaged_runtime_bundles_config_resources_and_keeps_wallet_desk_versi resources.get("../../config/std.offchain_transport.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.offchain_transport.json"), ); + assert_eq!(resources.get("../../config/std.store.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/std.store.json"),); + assert_eq!( + resources.get("../../config/schemas/std.store.schema.json").and_then(serde_json::Value::as_str), + std::option::Option::Some("config/schemas/std.store.schema.json"), + ); assert_eq!( resources.get("../../config/schemas/std.offchain_transport.schema.json").and_then(serde_json::Value::as_str), std::option::Option::Some("config/schemas/std.offchain_transport.schema.json"), diff --git a/crates/ksp-app-wallet-desk/tests/release_compliance.rs b/crates/ksp-app-wallet-desk/tests/release_compliance.rs index 58c47b3..fe92280 100644 --- a/crates/ksp-app-wallet-desk/tests/release_compliance.rs +++ b/crates/ksp-app-wallet-desk/tests/release_compliance.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-wallet-desk/tests/release_compliance.rs -// version: 9 +// version: 10 //! Release-wide deterministic compliance canaries for Wallet Desk. @@ -199,7 +199,7 @@ fn packaged_resources_include_only_registered_config_sources_and_schemas() { std::option::Option::Some(value) => value, std::option::Option::None => return, }; - assert_eq!(resources.len(), 11); + assert_eq!(resources.len(), 13); for (source, destination) in resources { let destination = destination.as_str(); assert!(destination.is_some(), "resource destination must be textual"); diff --git a/crates/ksp-config-lib/Cargo.toml b/crates/ksp-config-lib/Cargo.toml index 991db1f..c956662 100644 --- a/crates/ksp-config-lib/Cargo.toml +++ b/crates/ksp-config-lib/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-config-lib/Cargo.toml -# version: 9 +# version: 10 [package] name = "ksp-config-lib" @@ -14,6 +14,7 @@ ksp-core-lib = { path = "../ksp-core-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" } ksp-offchain-transport-lib = { path = "../ksp-offchain-transport-lib" } ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" } +ksp-store-lib = { path = "../ksp-store-lib", default-features = false } serde = { workspace = true, features = ["derive"] } serde_json.workspace = true diff --git a/crates/ksp-config-lib/src/lib.rs b/crates/ksp-config-lib/src/lib.rs index 2608417..fe68215 100644 --- a/crates/ksp-config-lib/src/lib.rs +++ b/crates/ksp-config-lib/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/lib.rs -// version: 20 +// version: 21 #![warn(missing_docs)] #![deny(unreachable_pub)] @@ -9,7 +9,7 @@ //! //! The `0.1.3` surface owns bootstrap roots, the logical file registry, JSON/JSON Schema validation, standard-document profiles, generic composites and //! KSP/KSPB environment resolution through process + `.env` + fallback precedence. Resolved values preserve real/safe representations, sensitivity and -//! provenance. Standard Logging, on-chain Transport (HTTP/WebSocket/Yellowstone gRPC) and Wallet documents map explicitly to their runtime consumer +//! provenance. Standard Logging, on-chain Transport (HTTP/WebSocket/Yellowstone gRPC), Store and Wallet documents map explicitly to their runtime consumer //! contracts, while the management surface provides typed Logging mutation, safe environment reports, explicit privileged reveal calls and atomic //! JSON/`.env` persistence. @@ -27,6 +27,7 @@ mod persistence; mod profile; mod registry; mod sensitivity; +mod store; mod transport; mod wallet; @@ -164,6 +165,10 @@ pub use self::registry::DEFAULT_STD_LOGGING_SCHEMA_FILENAME; pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME; /// Default physical filename for the standard Off-chain Transport JSON Schema document. pub use self::registry::DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME; +/// Default physical filename for the standard Store configuration document. +pub use self::registry::DEFAULT_STD_STORE_FILENAME; +/// Default physical filename for the standard Store JSON Schema document. +pub use self::registry::DEFAULT_STD_STORE_SCHEMA_FILENAME; /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document. pub use self::registry::DEFAULT_STD_TRANSPORT_FILENAME; /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. @@ -182,6 +187,8 @@ pub use self::registry::FILE_ID_SCHEMA_COMPOSITE; pub use self::registry::FILE_ID_SCHEMA_STD_LOGGING; /// Logical file identifier for the standard Off-chain Transport JSON Schema document. pub use self::registry::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT; +/// Logical file identifier for the standard Store JSON Schema document. +pub use self::registry::FILE_ID_SCHEMA_STD_STORE; /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. pub use self::registry::FILE_ID_SCHEMA_STD_TRANSPORT; /// Logical file identifier for the standard Wallet JSON Schema document. @@ -190,6 +197,8 @@ pub use self::registry::FILE_ID_SCHEMA_STD_WALLET; pub use self::registry::FILE_ID_STD_LOGGING; /// Logical file identifier for the standard Off-chain Transport configuration document. pub use self::registry::FILE_ID_STD_OFFCHAIN_TRANSPORT; +/// Logical file identifier for the standard Store configuration document. +pub use self::registry::FILE_ID_STD_STORE; /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document. pub use self::registry::FILE_ID_STD_TRANSPORT; /// Logical file identifier for the standard Wallet configuration document. @@ -204,6 +213,8 @@ pub use self::sensitivity::REDACTED_CONFIG_VALUE; pub use self::sensitivity::ResolvedConfigJson; /// One resolved Config string preserving real/safe representations and provenance. pub use self::sensitivity::ResolvedConfigText; +/// Effective standard Store configuration mapped to backend-neutral Store settings. +pub use self::store::ResolvedStoreConfig; /// Effective standard Transport configuration mapped to HTTP plus optional WebSocket and Yellowstone gRPC runtime settings. pub use self::transport::ResolvedTransportConfig; /// Effective standard Wallet configuration resolved to validated filesystem roots. diff --git a/crates/ksp-config-lib/src/registry.rs b/crates/ksp-config-lib/src/registry.rs index 4f63ce8..3eda52b 100644 --- a/crates/ksp-config-lib/src/registry.rs +++ b/crates/ksp-config-lib/src/registry.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/src/registry.rs -// version: 12 +// version: 13 /// Bootstrap argument used to replace a known Config filename mapping. pub const ARG_FILE_MAP: &str = "--filemap"; @@ -17,6 +17,10 @@ pub const DEFAULT_STD_LOGGING_SCHEMA_FILENAME: &str = "std.logging.schema.json"; pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_FILENAME: &str = "std.offchain_transport.json"; /// Default physical filename for the standard Off-chain Transport JSON Schema document. pub const DEFAULT_STD_OFFCHAIN_TRANSPORT_SCHEMA_FILENAME: &str = "std.offchain_transport.schema.json"; +/// Default physical filename for the standard Store configuration document. +pub const DEFAULT_STD_STORE_FILENAME: &str = "std.store.json"; +/// Default physical filename for the standard Store JSON Schema document. +pub const DEFAULT_STD_STORE_SCHEMA_FILENAME: &str = "std.store.schema.json"; /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document. pub const DEFAULT_STD_TRANSPORT_FILENAME: &str = "std.transport.json"; /// Default physical filename for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. @@ -35,6 +39,8 @@ pub const FILE_ID_SCHEMA_COMPOSITE: &str = "schema.composite"; pub const FILE_ID_SCHEMA_STD_LOGGING: &str = "schema.std.logging"; /// Logical file identifier for the standard Off-chain Transport JSON Schema document. pub const FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT: &str = "schema.std.offchain_transport"; +/// Logical file identifier for the standard Store JSON Schema document. +pub const FILE_ID_SCHEMA_STD_STORE: &str = "schema.std.store"; /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport JSON Schema document. pub const FILE_ID_SCHEMA_STD_TRANSPORT: &str = "schema.std.transport"; /// Logical file identifier for the standard Wallet JSON Schema document. @@ -43,6 +49,8 @@ pub const FILE_ID_SCHEMA_STD_WALLET: &str = "schema.std.wallet"; pub const FILE_ID_STD_LOGGING: &str = "cfg.std.logging"; /// Logical file identifier for the standard Off-chain Transport configuration document. pub const FILE_ID_STD_OFFCHAIN_TRANSPORT: &str = "cfg.std.offchain_transport"; +/// Logical file identifier for the standard Store configuration document. +pub const FILE_ID_STD_STORE: &str = "cfg.std.store"; /// Logical file identifier for the standard HTTP + WebSocket + Yellowstone gRPC Transport configuration document. pub const FILE_ID_STD_TRANSPORT: &str = "cfg.std.transport"; /// Logical file identifier for the standard Wallet configuration document. @@ -214,6 +222,22 @@ impl ConfigFileRegistry { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; + let store = ConfigFileDescriptor::new( + FILE_ID_STD_STORE, + ConfigFileKind::Config, + DEFAULT_STD_STORE_FILENAME, + std::option::Option::Some(FILE_ID_SCHEMA_STD_STORE), + ); + let store = match store { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let store_schema = + ConfigFileDescriptor::new(FILE_ID_SCHEMA_STD_STORE, ConfigFileKind::Schema, DEFAULT_STD_STORE_SCHEMA_FILENAME, std::option::Option::None); + let store_schema = match store_schema { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; let transport = ConfigFileDescriptor::new( FILE_ID_STD_TRANSPORT, ConfigFileKind::Config, @@ -254,6 +278,8 @@ impl ConfigFileRegistry { logging_schema, offchain_transport, offchain_transport_schema, + store, + store_schema, transport, transport_schema, wallet, diff --git a/crates/ksp-config-lib/src/store.rs b/crates/ksp-config-lib/src/store.rs new file mode 100644 index 0000000..880d49c --- /dev/null +++ b/crates/ksp-config-lib/src/store.rs @@ -0,0 +1,264 @@ +// file: crates/ksp-config-lib/src/store.rs +// version: 1 + +/// Effective standard Store configuration mapped to `ksp_store_lib::StoreSettings`. +pub struct ResolvedStoreConfig { + effective: crate::ResolvedConfigJson, + file_id: crate::ConfigFileId, + profile_id: String, + selection_source: crate::ConfigProfileSelectionSource, + settings: ksp_store_lib::StoreSettings, + source_path: std::path::PathBuf, +} + +impl ResolvedStoreConfig { + /// Returns the detailed environment-resolved Config view. + #[must_use] + pub const fn effective(&self) -> &crate::ResolvedConfigJson { + return &self.effective; + } + + /// Returns the logical Config file identifier used by this runtime configuration. + #[must_use] + pub const fn file_id(&self) -> &crate::ConfigFileId { + return &self.file_id; + } + + /// Returns the selected standard Store profile identifier. + #[must_use] + pub fn profile_id(&self) -> &str { + return self.profile_id.as_str(); + } + + /// Returns the source that selected the standard Store profile. + #[must_use] + pub const fn selection_source(&self) -> crate::ConfigProfileSelectionSource { + return self.selection_source; + } + + /// Borrows the backend-neutral Store settings without exposing the connection URI. + #[must_use] + pub const fn settings(&self) -> &ksp_store_lib::StoreSettings { + return &self.settings; + } + + /// Consumes the resolved Config and returns the Store-owned runtime settings. + #[must_use] + pub fn into_settings(self) -> ksp_store_lib::StoreSettings { + return self.settings; + } + + /// Returns the physical source Config document path. + #[must_use] + pub fn source_path(&self) -> &std::path::Path { + return self.source_path.as_path(); + } +} + +impl std::fmt::Debug for ResolvedStoreConfig { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + return formatter + .debug_struct("ResolvedStoreConfig") + .field("effective", &self.effective) + .field("file_id", &self.file_id) + .field("profile_id", &self.profile_id) + .field("selection_source", &self.selection_source) + .field("settings", &self.settings) + .field("source_path", &self.source_path) + .finish(); + } +} + +impl crate::ConfigDocumentEngine { + /// Loads `std.store`, resolves one profile/environment and maps it to backend-neutral Store settings. + pub fn load_resolved_store_config( + &self, + requested_profile: std::option::Option<&str>, + environment: &crate::ConfigEnvironment, + ) -> ksp_core_lib::Result { + let file_id = crate::ConfigFileId::new(crate::FILE_ID_STD_STORE); + let file_id = match file_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let profile = self.load_resolved_profile(&file_id, requested_profile); + let profile = match profile { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return resolve_store_profile(&profile, environment); + } + + /// Maps an already resolved `cfg.std.store` profile while preserving its selection provenance. + pub fn resolve_store_config_profile( + &self, + profile: &crate::ResolvedConfigProfile, + environment: &crate::ConfigEnvironment, + ) -> ksp_core_lib::Result { + if profile.file_id().as_str() != crate::FILE_ID_STD_STORE { + return std::result::Result::Err(effective_error(profile, "resolved Config profile does not reference the standard Store document")); + } + let descriptor = self.registry().descriptor(profile.file_id()); + if let std::result::Result::Err(error) = descriptor { + return std::result::Result::Err(error); + } + return resolve_store_profile(profile, environment); + } +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectiveStoreSource { + backend: String, + format_version: u32, + postgres: EffectivePostgresSource, + profile_id: String, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectivePostgresSource { + bootstrap: EffectivePostgresBootstrapSource, + connection_uri: String, + pool: EffectivePostgresPoolSource, + shutdown_timeout_ms: u64, + tls: EffectivePostgresTlsSource, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectivePostgresPoolSource { + connect_timeout_ms: u64, + create_timeout_ms: u64, + max_connections: u32, + recycle_timeout_ms: u64, + wait_timeout_ms: u64, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectivePostgresTlsSource { + mode: String, +} + +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct EffectivePostgresBootstrapSource { + auto_migrate: bool, + migration_lock_timeout_ms: u64, + migration_timeout_ms: u64, +} + +fn resolve_store_profile(profile: &crate::ResolvedConfigProfile, environment: &crate::ConfigEnvironment) -> ksp_core_lib::Result { + ksp_logging_lib::trace!(target: crate::TRACING_TARGET, profile_id = profile.profile_id(), "mapping standard Store Config profile"); + let effective = profile.resolve_effective_environment_detailed(environment); + let effective = match effective { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let provenance = validate_connection_uri_provenance(&effective, profile); + if let std::result::Result::Err(error) = provenance { + return std::result::Result::Err(error); + } + let source = serde_json::from_value::(effective.value().clone()); + let source = match source { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + effective_error(profile, "effective Store Config cannot be decoded into the runtime adapter contract").with_source(error), + ); + }, + }; + if source.format_version != 1 { + return std::result::Result::Err(effective_error(profile, "effective Store format_version is unsupported")); + } + if source.profile_id != profile.profile_id() { + return std::result::Result::Err(effective_error(profile, "effective Store profile_id does not match the selected profile")); + } + if source.backend != "postgres" { + return std::result::Result::Err(effective_error(profile, "effective Store backend is unsupported").with_context("backend", source.backend)); + } + let tls_mode = match source.postgres.tls.mode.as_str() { + "disabled" => ksp_store_lib::PostgresTlsMode::Disabled, + "verify_full" => ksp_store_lib::PostgresTlsMode::VerifyFull, + _ => return std::result::Result::Err(effective_error(profile, "effective Store PostgreSQL TLS mode is unsupported")), + }; + let pool = ksp_store_lib::PostgresPoolSettings::new( + source.postgres.pool.max_connections, + std::time::Duration::from_millis(source.postgres.pool.connect_timeout_ms), + std::time::Duration::from_millis(source.postgres.pool.wait_timeout_ms), + std::time::Duration::from_millis(source.postgres.pool.create_timeout_ms), + std::time::Duration::from_millis(source.postgres.pool.recycle_timeout_ms), + ); + let bootstrap = ksp_store_lib::PostgresBootstrapSettings::new( + source.postgres.bootstrap.auto_migrate, + std::time::Duration::from_millis(source.postgres.bootstrap.migration_timeout_ms), + std::time::Duration::from_millis(source.postgres.bootstrap.migration_lock_timeout_ms), + ); + let postgres = ksp_store_lib::PostgresStoreSettings::new(source.postgres.connection_uri, pool, tls_mode, bootstrap); + let settings = ksp_store_lib::StoreSettings::new( + ksp_store_lib::StoreBackendSettings::Postgres(postgres), + std::time::Duration::from_millis(source.postgres.shutdown_timeout_ms), + ); + if let std::result::Result::Err(error) = settings.validate() { + return std::result::Result::Err(store_contract_error(profile, &error)); + } + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + profile_id = profile.profile_id(), + backend = settings.backend_kind().code(), + "mapped standard Store Config to Store settings" + ); + return std::result::Result::Ok(ResolvedStoreConfig { + effective, + file_id: profile.file_id().clone(), + profile_id: profile.profile_id().to_owned(), + selection_source: profile.selection_source(), + settings, + source_path: profile.path().to_path_buf(), + }); +} + +fn validate_connection_uri_provenance(effective: &crate::ResolvedConfigJson, profile: &crate::ResolvedConfigProfile) -> ksp_core_lib::Result<()> { + let provenance = match effective.provenance_at("/postgres/connection_uri") { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI provenance is unavailable")), + }; + let mut has_secret_environment = false; + for item in provenance { + let variable_name = match item.variable_name() { + std::option::Option::Some(value) => value, + std::option::Option::None => continue, + }; + let sensitivity = crate::ConfigSensitivity::from_variable_name(variable_name); + let sensitivity = match sensitivity { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if !sensitivity.is_secret() { + return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI may reference only secret environment variables")); + } + has_secret_environment = true; + } + if !has_secret_environment { + return std::result::Result::Err(effective_error(profile, "Store PostgreSQL connection URI requires secret environment provenance")); + } + return std::result::Result::Ok(()); +} + +fn store_contract_error(profile: &crate::ResolvedConfigProfile, error: &ksp_core_lib::Error) -> ksp_core_lib::Error { + return effective_error(profile, "effective Store settings fail the Store runtime contract") + .with_context("store_error_domain", error.code().domain()) + .with_context("store_error_code", error.code().code()); +} + +fn effective_error(profile: &crate::ResolvedConfigProfile, reason: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID, "effective Config cannot be mapped to the requested runtime contract") + .with_context("file_id", profile.file_id().as_str()) + .with_context("profile_id", profile.profile_id()) + .with_context("reason", reason); +} + +#[cfg(test)] +#[path = "../unit_tests/store.rs"] +mod tests; diff --git a/crates/ksp-config-lib/tests/ownership.rs b/crates/ksp-config-lib/tests/ownership.rs index 56356c7..54cc213 100644 --- a/crates/ksp-config-lib/tests/ownership.rs +++ b/crates/ksp-config-lib/tests/ownership.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/tests/ownership.rs -// version: 8 +// version: 9 //! Workspace ownership audits for KSP application configuration boundaries. @@ -280,6 +280,49 @@ fn workspace_crates_do_not_hardcode_config_managed_physical_files() { } } +#[test] +fn store_config_adapter_does_not_force_backend_feature_or_reverse_dependency() { + let root = workspace_root(); + let config_manifest_path = root.join("crates/ksp-config-lib/Cargo.toml"); + let config_manifest = std::fs::read_to_string(config_manifest_path.as_path()); + assert!(config_manifest.is_ok(), "unable to read {}", config_manifest_path.display()); + if let std::result::Result::Ok(config_manifest) = config_manifest { + assert!( + config_manifest.contains("ksp-store-lib = { path = \"../ksp-store-lib\", default-features = false }"), + "Config -> Store dependency must not force a physical backend feature" + ); + } + for crate_name in ["ksp-store-lib", "ksp-store-postgres-lib"] { + let manifest_path = root.join("crates").join(crate_name).join("Cargo.toml"); + let manifest = std::fs::read_to_string(manifest_path.as_path()); + assert!(manifest.is_ok(), "unable to read {}", manifest_path.display()); + if let std::result::Result::Ok(manifest) = manifest { + assert!(!manifest.contains("ksp-config-lib"), "{} must not depend back on Config", manifest_path.display()); + } + } +} + +#[test] +fn store_runtime_sources_do_not_bypass_config_for_environment_or_libpq_files() { + let root = workspace_root(); + for crate_name in ["ksp-store-lib", "ksp-store-postgres-lib"] { + let source_root = root.join("crates").join(crate_name).join("src"); + let mut rust_files = std::vec::Vec::new(); + collect_rust_files(source_root.as_path(), &mut rust_files); + for rust_file in rust_files { + let source = std::fs::read_to_string(rust_file.as_path()); + assert!(source.is_ok(), "unable to read {}", rust_file.display()); + let source = match source { + std::result::Result::Ok(value) => non_comment_source(value.as_str()), + std::result::Result::Err(_) => continue, + }; + for forbidden in ["std::env", "dotenv", "KSP_", "KSPB_", "\"PG", ".pgpass"] { + assert!(!source.contains(forbidden), "{} bypasses Config through forbidden Store environment/libpq token {forbidden}", rust_file.display()); + } + } + } +} + #[test] fn environment_name_scanner_ignores_namespace_labels_but_keeps_concrete_names() { let source = r#" diff --git a/crates/ksp-config-lib/tests/public_api.rs b/crates/ksp-config-lib/tests/public_api.rs index fded8d6..d69d1c5 100644 --- a/crates/ksp-config-lib/tests/public_api.rs +++ b/crates/ksp-config-lib/tests/public_api.rs @@ -1,8 +1,8 @@ // file: crates/ksp-config-lib/tests/public_api.rs -// version: 25 +// version: 26 //! Integration tests for the public `ksp-config-lib` bootstrap, registry, JSON/profile/composite, environment-resolution, sensitivity, -//! Logging/Transport adapters and management contracts. +//! Logging/Transport/Store adapters and management contracts. #[test] fn bootstrap_contract_is_available_from_crate_root() { @@ -80,19 +80,21 @@ fn registry_descriptor_inventory_is_available_from_crate_root() { assert!(registry.is_ok(), "public registry should remain constructible: {registry:?}"); if let std::result::Result::Ok(registry) = registry { let descriptors: std::vec::Vec<&ksp_config_lib::ConfigFileDescriptor> = registry.descriptors().collect(); - assert_eq!(descriptors.len(), 11); + assert_eq!(descriptors.len(), 13); assert_eq!(descriptors[0].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK); assert_eq!(descriptors[1].file_id().as_str(), ksp_config_lib::FILE_ID_COMPOSITE_KSP_APP_WALLET_DESK); assert_eq!(descriptors[2].file_id().as_str(), ksp_config_lib::FILE_ID_STD_LOGGING); assert_eq!(descriptors[3].file_id().as_str(), ksp_config_lib::FILE_ID_STD_OFFCHAIN_TRANSPORT); - assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT); - assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET); - assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE); - assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING); - assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT); - assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT); - assert_eq!(descriptors[10].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET); - let schema_file_id = descriptors[5].schema_file_id(); + assert_eq!(descriptors[4].file_id().as_str(), ksp_config_lib::FILE_ID_STD_STORE); + assert_eq!(descriptors[5].file_id().as_str(), ksp_config_lib::FILE_ID_STD_TRANSPORT); + assert_eq!(descriptors[6].file_id().as_str(), ksp_config_lib::FILE_ID_STD_WALLET); + assert_eq!(descriptors[7].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_COMPOSITE); + assert_eq!(descriptors[8].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_LOGGING); + assert_eq!(descriptors[9].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT); + assert_eq!(descriptors[10].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_STORE); + assert_eq!(descriptors[11].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_TRANSPORT); + assert_eq!(descriptors[12].file_id().as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET); + let schema_file_id = descriptors[6].schema_file_id(); assert!(schema_file_id.is_some(), "public Wallet descriptor should preserve schema association"); if let std::option::Option::Some(schema_file_id) = schema_file_id { assert_eq!(schema_file_id.as_str(), ksp_config_lib::FILE_ID_SCHEMA_STD_WALLET); @@ -211,6 +213,18 @@ fn logging_adapter_contract_is_available_from_crate_root() { assert!(std::mem::size_of::() > 0); } +#[test] +fn store_adapter_contract_is_available_from_crate_root() { + let adapter = ksp_config_lib::ConfigDocumentEngine::load_resolved_store_config; + let composite_adapter = ksp_config_lib::ConfigDocumentEngine::resolve_store_config_profile; + let _ = (adapter, composite_adapter); + assert_eq!(ksp_config_lib::FILE_ID_STD_STORE, "cfg.std.store"); + assert_eq!(ksp_config_lib::FILE_ID_SCHEMA_STD_STORE, "schema.std.store"); + assert_eq!(ksp_config_lib::DEFAULT_STD_STORE_FILENAME, "std.store.json"); + assert_eq!(ksp_config_lib::DEFAULT_STD_STORE_SCHEMA_FILENAME, "std.store.schema.json"); + assert!(std::mem::size_of::() > 0); +} + #[test] fn management_contracts_are_available_from_crate_root() { let workspace = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); diff --git a/crates/ksp-config-lib/unit_tests/registry.rs b/crates/ksp-config-lib/unit_tests/registry.rs index 77ef4f7..ec1ac66 100644 --- a/crates/ksp-config-lib/unit_tests/registry.rs +++ b/crates/ksp-config-lib/unit_tests/registry.rs @@ -1,5 +1,5 @@ // file: crates/ksp-config-lib/unit_tests/registry.rs -// version: 10 +// version: 11 #[test] fn descriptors_expose_complete_registry_in_deterministic_file_id_order() { @@ -7,7 +7,7 @@ fn descriptors_expose_complete_registry_in_deterministic_file_id_order() { assert!(registry.is_ok(), "default registry should be valid: {registry:?}"); if let std::result::Result::Ok(registry) = registry { let descriptors: std::vec::Vec<&crate::ConfigFileDescriptor> = registry.descriptors().collect(); - assert_eq!(descriptors.len(), 11); + assert_eq!(descriptors.len(), 13); assert_eq!(descriptors[0].file_id().as_str(), crate::FILE_ID_COMPOSITE_KSP_APP_SOLPRICES_DESK); assert_eq!(descriptors[0].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_SOLPRICES_DESK_FILENAME)); assert_eq!(descriptors[0].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_COMPOSITE)); @@ -15,17 +15,19 @@ fn descriptors_expose_complete_registry_in_deterministic_file_id_order() { assert_eq!(descriptors[1].filename(), std::path::Path::new(crate::DEFAULT_COMPOSITE_KSP_APP_WALLET_DESK_FILENAME)); assert_eq!(descriptors[2].file_id().as_str(), crate::FILE_ID_STD_LOGGING); assert_eq!(descriptors[3].file_id().as_str(), crate::FILE_ID_STD_OFFCHAIN_TRANSPORT); - assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT); - assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_STD_WALLET); - assert_eq!(descriptors[5].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME)); - assert_eq!(descriptors[5].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET)); - assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE); - assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING); - assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT); - assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT); - assert_eq!(descriptors[10].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET); - assert!(descriptors[0..6].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config)); - assert!(descriptors[6..11].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema)); + assert_eq!(descriptors[4].file_id().as_str(), crate::FILE_ID_STD_STORE); + assert_eq!(descriptors[5].file_id().as_str(), crate::FILE_ID_STD_TRANSPORT); + assert_eq!(descriptors[6].file_id().as_str(), crate::FILE_ID_STD_WALLET); + assert_eq!(descriptors[6].filename(), std::path::Path::new(crate::DEFAULT_STD_WALLET_FILENAME)); + assert_eq!(descriptors[6].schema_file_id().map(crate::ConfigFileId::as_str), std::option::Option::Some(crate::FILE_ID_SCHEMA_STD_WALLET)); + assert_eq!(descriptors[7].file_id().as_str(), crate::FILE_ID_SCHEMA_COMPOSITE); + assert_eq!(descriptors[8].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_LOGGING); + assert_eq!(descriptors[9].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_OFFCHAIN_TRANSPORT); + assert_eq!(descriptors[10].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_STORE); + assert_eq!(descriptors[11].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_TRANSPORT); + assert_eq!(descriptors[12].file_id().as_str(), crate::FILE_ID_SCHEMA_STD_WALLET); + assert!(descriptors[0..7].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Config)); + assert!(descriptors[7..13].iter().all(|descriptor| return descriptor.kind() == crate::ConfigFileKind::Schema)); } } @@ -114,6 +116,27 @@ fn defaults_register_offchain_transport_document_and_schema_with_distinct_roots( } } +#[test] +fn defaults_register_store_document_and_schema_with_distinct_roots() { + let registry = crate::ConfigFileRegistry::defaults(); + assert!(registry.is_ok()); + if let std::result::Result::Ok(registry) = registry { + let config_id = crate::ConfigFileId::new(crate::FILE_ID_STD_STORE); + let schema_id = crate::ConfigFileId::new(crate::FILE_ID_SCHEMA_STD_STORE); + if let (std::result::Result::Ok(config_id), std::result::Result::Ok(schema_id)) = (config_id, schema_id) { + let config = registry.descriptor(&config_id); + let schema = registry.descriptor(&schema_id); + if let (std::result::Result::Ok(config), std::result::Result::Ok(schema)) = (config, schema) { + assert_eq!(config.kind(), crate::ConfigFileKind::Config); + assert_eq!(config.filename(), std::path::Path::new(crate::DEFAULT_STD_STORE_FILENAME)); + assert_eq!(config.schema_file_id(), std::option::Option::Some(&schema_id)); + assert_eq!(schema.kind(), crate::ConfigFileKind::Schema); + assert_eq!(schema.filename(), std::path::Path::new(crate::DEFAULT_STD_STORE_SCHEMA_FILENAME)); + } + } + } +} + #[test] fn defaults_register_transport_document_and_schema_with_distinct_roots() { let registry = crate::ConfigFileRegistry::defaults(); diff --git a/crates/ksp-config-lib/unit_tests/store.rs b/crates/ksp-config-lib/unit_tests/store.rs new file mode 100644 index 0000000..ce0da6f --- /dev/null +++ b/crates/ksp-config-lib/unit_tests/store.rs @@ -0,0 +1,163 @@ +// file: crates/ksp-config-lib/unit_tests/store.rs +// version: 1 + +#[test] +fn committed_store_profile_maps_exact_runtime_settings_and_secret_fallback() { + let engine = committed_engine(); + let engine = match engine { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new()); + let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment); + assert!(resolved.is_ok(), "committed Store profile should map without opening PostgreSQL: {resolved:?}"); + if let std::result::Result::Ok(resolved) = resolved { + assert_eq!(resolved.file_id().as_str(), crate::FILE_ID_STD_STORE); + assert_eq!(resolved.profile_id(), "postgres_default"); + assert_eq!(resolved.selection_source(), crate::ConfigProfileSelectionSource::DefaultProfile); + assert_eq!(resolved.settings().backend_kind(), ksp_store_lib::StoreBackendKind::Postgres); + assert_eq!(resolved.settings().shutdown_timeout(), std::time::Duration::from_millis(5_000)); + let postgres = match resolved.settings().backend() { + ksp_store_lib::StoreBackendSettings::Postgres(postgres) => std::option::Option::Some(postgres), + _ => std::option::Option::None, + }; + assert!(postgres.is_some(), "pre.004 fixture should map to the PostgreSQL Store backend"); + if let std::option::Option::Some(postgres) = postgres { + assert_eq!(postgres.pool().max_connections(), 8); + assert_eq!(postgres.pool().connect_timeout(), std::time::Duration::from_millis(10_000)); + assert_eq!(postgres.pool().wait_timeout(), std::time::Duration::from_millis(5_000)); + assert_eq!(postgres.pool().create_timeout(), std::time::Duration::from_millis(10_000)); + assert_eq!(postgres.pool().recycle_timeout(), std::time::Duration::from_millis(5_000)); + assert_eq!(postgres.tls_mode(), ksp_store_lib::PostgresTlsMode::VerifyFull); + assert!(postgres.bootstrap().auto_migrate()); + assert_eq!(postgres.bootstrap().migration_timeout(), std::time::Duration::from_millis(30_000)); + assert_eq!(postgres.bootstrap().migration_lock_timeout(), std::time::Duration::from_millis(10_000)); + } + assert!(resolved.effective().sensitivity().is_secret()); + let safe = resolved.effective().safe_value().to_string(); + assert!(!safe.contains("postgresql://localhost/ksp")); + assert!(safe.contains(crate::REDACTED_CONFIG_VALUE)); + let debug = format!("{resolved:?}"); + assert!(!debug.contains("postgresql://localhost/ksp")); + } +} + +#[test] +fn process_store_uri_wins_and_remains_redacted_in_safe_views() { + let engine = committed_engine(); + let engine = match engine { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let canary = "postgresql://secret-user:secret-pass@db.example/ksp"; + let mut process = std::collections::BTreeMap::::new(); + process.insert("KSP_SECRET_STORE_POSTGRES_URI".to_owned(), canary.to_owned()); + let environment = crate::ConfigEnvironment::from_maps(process, std::collections::BTreeMap::new()); + let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment); + assert!(resolved.is_ok(), "secret process Store URI should map: {resolved:?}"); + if let std::result::Result::Ok(resolved) = resolved { + assert_eq!(resolved.effective().value().pointer("/postgres/connection_uri").and_then(serde_json::Value::as_str), std::option::Option::Some(canary)); + assert!(!resolved.effective().safe_value().to_string().contains(canary)); + assert!(!format!("{resolved:?}").contains(canary)); + let provenance = resolved.effective().provenance_at("/postgres/connection_uri"); + assert!(provenance.is_some()); + if let std::option::Option::Some(provenance) = provenance { + assert!(provenance.iter().any(|item| return item.environment_source() == std::option::Option::Some(crate::ConfigEnvironmentSource::Process))); + } + } +} + +#[test] +fn literal_or_nonsecret_store_uri_is_rejected_by_effective_adapter() { + for value in ["postgresql://literal.invalid/ksp", "${KSP_PUBLIC_STORE_POSTGRES_URI:-postgresql://public.invalid/ksp}"] { + let fixture = tempfile::tempdir(); + assert!(fixture.is_ok()); + let fixture = match fixture { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let source = committed_document_value(); + let mut source = match source { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let profiles = source.get_mut("profiles").and_then(serde_json::Value::as_array_mut); + if let std::option::Option::Some(profiles) = profiles { + if let std::option::Option::Some(profile) = profiles.first_mut() { + profile["postgres"]["connection_uri"] = serde_json::Value::String(value.to_owned()); + } + } + let engine = fixture_engine_with_document(fixture.path(), &source); + assert!(engine.is_ok()); + let engine = match engine { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let environment = crate::ConfigEnvironment::from_maps(std::collections::BTreeMap::new(), std::collections::BTreeMap::new()); + let resolved = engine.load_resolved_store_config(std::option::Option::None, &environment); + assert!(resolved.is_err(), "Store URI without secret provenance must be rejected"); + if let std::result::Result::Err(error) = resolved { + assert_eq!(error.code(), crate::ERROR_CODE_EFFECTIVE_CONFIG_INVALID); + assert!(!format!("{error:?}").contains("literal.invalid")); + assert!(!format!("{error:?}").contains("public.invalid")); + } + } +} + +fn committed_engine() -> ksp_core_lib::Result { + let workspace = workspace_root(); + let bootstrap = crate::ConfigBootstrapOptions::from_paths(workspace.join("config"), workspace.join("config/schemas")); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let registry = crate::ConfigFileRegistry::defaults(); + let registry = match registry { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry)); +} + +fn fixture_engine_with_document(root: &std::path::Path, document: &serde_json::Value) -> ksp_core_lib::Result { + let config_root = root.join("config"); + if let std::result::Result::Err(error) = std::fs::create_dir_all(config_root.as_path()) { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Config root cannot be created").with_source(error), + ); + } + let bytes = serde_json::to_vec_pretty(document); + let bytes = match bytes { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_SYNTAX_INVALID, "test Store Config cannot be encoded").with_source(error), + ); + }, + }; + let path = config_root.join(crate::DEFAULT_STD_STORE_FILENAME); + if let std::result::Result::Err(error) = std::fs::write(path.as_path(), bytes) { + return std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_JSON_FILE_READ_FAILED, "test Store Config cannot be written").with_source(error), + ); + } + let bootstrap = crate::ConfigBootstrapOptions::from_paths(config_root, workspace_root().join("config/schemas")); + let bootstrap = match bootstrap { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let registry = crate::ConfigFileRegistry::defaults(); + let registry = match registry { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(crate::ConfigDocumentEngine::new(bootstrap, registry)); +} + +fn committed_document_value() -> std::result::Result { + return serde_json::from_str(include_str!("../../../config/std.store.json")); +} + +fn workspace_root() -> std::path::PathBuf { + return std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); +} diff --git a/deltas/0.3.2/pre.004.md b/deltas/0.3.2/pre.004.md new file mode 100644 index 0000000..1def4d5 --- /dev/null +++ b/deltas/0.3.2/pre.004.md @@ -0,0 +1,108 @@ + + + +# Delta `0.3.2-pre.004` — Config `std.store` + +## 1. Base requise + +Base exacte : + +```text +0.3.2-pre.003-fix.001 +workspace.package.version = 0.3.2-pre.3.fix.1 +``` + +Le gate opérateur fourni le 29 août 2026 est entièrement vert : audits Rust/Markdown, `cargo check`, Clippy, tests Store avec et sans features par défaut, backend et compilation `--no-default-features` passent. + +## 2. Objet + +Matérialiser exclusivement la frontière Config de la fondation Store : + +```text +config/std.store.json +config/schemas/std.store.schema.json +config/examples/std.store.example.json +registry cfg.std.store / schema.std.store +KSP_SECRET_STORE_POSTGRES_URI +adapter Config -> ksp_store_lib::StoreSettings +provenance/sensitivity/redaction +resources desktop strictement nécessaires +``` + +Aucune connexion, pool physique, TLS connector, migration ou SQL n'est ajouté. + +## 3. Graphe de dépendance + +`ksp-config-lib` ajoute : + +```text +ksp-config-lib -> ksp-store-lib (default-features = false) +``` + +Config peut ainsi construire les types publics de settings sans forcer `postgres`. `ksp-store-lib` et `ksp-store-postgres-lib` restent interdits de dépendance vers Config. + +## 4. Document V1 + +Le profil `postgres_default` sélectionne `backend = postgres` et fournit exactement les groupes `connection_uri`, `pool`, `tls`, `bootstrap` et `shutdown_timeout_ms` décidés en `pre.001/pre.003`. Les bornes JSON Schema reflètent les bornes runtime Store ; la validation runtime reste l'autorité finale. + +Le `connection_uri` committed utilise : + +```text +${KSP_SECRET_STORE_POSTGRES_URI:-postgresql://localhost/ksp} +``` + +Le fallback reste classé `Secret`, donc la safe projection et `Debug` ne rendent jamais l'URI. L'adapter rejette aussi un URI literal ou provenant d'un namespace non secret, même si le JSON Schema l'accepte syntaxiquement. + +## 5. Adapter + +`ResolvedStoreConfig` possède la Config effective et un `StoreSettings`. Il expose un emprunt sûr et `into_settings(self)` pour transférer les settings au runtime. Aucun getter de l'URI n'est ajouté. + +Mapping : + +```text +postgres -> StoreBackendSettings::Postgres +disabled -> PostgresTlsMode::Disabled +verify_full -> PostgresTlsMode::VerifyFull +ms -> Duration +``` + +Les erreurs du contrat Store sont projetées par domaine/code seulement, sans recopier de message ou de contexte arbitraire. + +## 6. Packaging + +`prepare_packaged_runtime` parcourt tout le registry Config. Les trois applications desktop qui utilisent cette préparation doivent donc embarquer les deux nouvelles resources runtime/schema. Leur inventaire passe de 11 à 13 resources. Aucun écran Store n'est ajouté. + +## 7. Réconciliation du plan + +Le prompt de démarrage et les conventions Config utilisent `std.store.schema.json` / `std.store.example.json`. Deux mentions `store.schema.json` / `store.example.json` du plan `pre.001` étaient incohérentes ; elles sont corrigées au moment de la matérialisation, sans changement de contrat. + +## 8. Version + +```text +workspace.package.version = 0.3.2-pre.4 +``` + +## 9. Gate opérateur requis + +```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-config-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 test -p ksp-app-config-desk +cargo test -p ksp-app-solprices-desk +cargo test -p ksp-app-wallet-desk +cargo tree -p ksp-config-lib --edges normal +cargo tree -p ksp-store-lib -e features +``` + +Les builds Tauri sont recommandés dans cette tranche car les resources packagées ont réellement changé. + +## 10. Suite + +Si le gate est vert, `pre.005` ouvre la connexion PostgreSQL réelle, Deadpool et Rustls. diff --git a/docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md b/docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md index 827a6d0..0f36d0c 100644 --- a/docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md +++ b/docs/plans/023-V0_3_2_STORE_POSTGRES_FOUNDATION_PLAN.md @@ -1,5 +1,5 @@ - + # Plan `0.3.2` — Store/PostgreSQL runtime foundation @@ -593,8 +593,8 @@ Seul `ksp-config-lib` possède : ```text config/std.store.json -config/schemas/store.schema.json -config/examples/store.example.json +config/schemas/std.store.schema.json +config/examples/std.store.example.json registry descriptors placeholder interpolation .env / process environment @@ -692,7 +692,7 @@ ks-store/src/postgres/** ks-store/migrations/postgres/** ks-config/src/store.rs config/store.config.json -config/schemas/store.schema.json +config/schemas/std.store.schema.json docs/architecture/STORAGE_ARCHITECTURE.md docs/guides/POSTGRES_STORAGE.md ``` @@ -937,7 +937,7 @@ Le dernier comportement est volontairement transitoire : `pre.003` ne retourne j ### `pre.004` — Config `std.store` -Document/schema/example/registry/adaptor, `.env.example`, provenance/sensitivity/redaction, packaging resources strictement nécessaires. Aucun env dans Store/backend. +Document/schema/example/registry/adaptor, `.env.example`, provenance/sensitivity/redaction, packaging resources strictement nécessaires. Aucun env dans Store/backend. `pre.004` matérialise les noms canoniques `std.store.json`, `std.store.schema.json` et `std.store.example.json`, conformément au prompt de démarrage et aux conventions Config existantes. ### `pre.005` — PostgreSQL connection + deadpool + Rustls diff --git a/docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md b/docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md index b41e82f..d8fc63e 100644 --- a/docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md +++ b/docs/validation/019-V0_3_2_STORE_POSTGRES_FOUNDATION.md @@ -1,5 +1,5 @@ - + # Validation `0.3.2` — Store/PostgreSQL runtime foundation @@ -72,6 +72,8 @@ Le gate opérateur de `pre.002`, fourni le 29 août 2026, confirme également le Le gate opérateur de `pre.002-fix.001`, fourni le 29 août 2026, est entièrement vert et sans warning : audits Rust/Markdown, workspace check/Clippy, tests des deux crates et compilation `ksp-store-lib --no-default-features` passent. Cette base est l'entrée effective de `pre.003`. +Le gate opérateur de `pre.003-fix.001`, fourni le 29 août 2026, est entièrement vert : audits Rust/Markdown, workspace check/Clippy, tests `ksp-store-lib` avec et sans feature par défaut, tests backend et compilation `--no-default-features` passent. Cette base est l'entrée effective de `pre.004`. + ## 3. Frontières Cargo ### V32-DEP-001 — Façade -> API @@ -154,7 +156,7 @@ Critère : `StoreSettings` est constructible sans `ksp-config-lib`, sans env et Matérialisé par `pre.003` : `StoreSettings`, `StoreBackendSettings`, `PostgresStoreSettings`, pool/bootstrap/TLS typés, sans dépendance Config/serde/env. -Statut : `TODO gate opérateur pre.003`. +Statut : `PASS pre.003-fix.001 opérateur`. ### V32-API-002 — Backend connu non compilé @@ -162,7 +164,7 @@ Critère : `Postgres` reste un backend connu sans feature et `Store::open` écho Matérialisé par `pre.003` : le test `feature_mismatch` appelle réellement `Store::open` sous `--no-default-features` et exige `store.backend_not_compiled`. -Statut : `TODO gate opérateur pre.003 / TODO pre.009 final`. +Statut : `PASS pre.003-fix.001 opérateur / TODO pre.009 final`. ### V32-API-003 — Aucun type backend physique public @@ -183,7 +185,7 @@ Critère : un consumer de `ksp-store-lib` accède aux contrats Store API utiles `pre.003` réexporte explicitement les 60 symboles crate-root acquis de `ksp-store-api` depuis `ksp-store-lib`, sans glob et sans réexport backend. -Statut : `TODO gate opérateur pre.003 / TODO pre.009 exact exports`. +Statut : `PASS pre.003-fix.001 opérateur / TODO pre.009 exact exports`. ### V32-API-005 — Lifecycle @@ -199,7 +201,7 @@ Drop best-effort seulement `pre.003` fixe les signatures `Store::open(settings).await` et `Store::close(self).await` et garde `Store` opaque/non constructible par un consumer. Aucun succès d'ouverture n'est simulé avant la connexion réelle : le backend compilé s'arrête avec `store.backend_open_failed` et le contexte sûr `runtime_foundation_pending`. -Statut : `TODO gate opérateur pre.003` pour les signatures et le staging ; `TODO pre.007` pour le shutdown physique borné. +Statut : `PASS pre.003-fix.001 opérateur` pour les signatures et le staging ; `TODO pre.007` pour le shutdown physique borné. ## 5. Config ownership @@ -215,7 +217,9 @@ profiles typés backend postgres explicite ``` -Statut : `TODO pre.004`. +Matérialisé par `pre.004` avec `cfg.std.store` / `schema.std.store`, V1 profilée, backend PostgreSQL typé et example canonique. + +Statut : `TODO gate opérateur pre.004`. ### V32-CONFIG-002 — Secrets/provenance @@ -228,7 +232,9 @@ provenance sans valeur dotenv inventory à jour ``` -Statut : `TODO pre.004`. +Matérialisé par `pre.004` : `KSP_SECRET_STORE_POSTGRES_URI` est inventorié, le fallback reste `Secret`, la safe projection est redacted et la provenance n'embarque aucune valeur. + +Statut : `TODO gate opérateur pre.004`. ### V32-CONFIG-003 — No-env Store/backend @@ -243,13 +249,17 @@ PG* .pgpass ``` -Statut : `TODO pre.004/pre.009`. +`pre.004` renforce aussi le canari d'ownership avec les nouveaux filenames Store ; Store/backend restent sans dépendance Config et sans lecture KSP/KSPB. + +Statut : `TODO gate opérateur pre.004 / TODO pre.009`. ### V32-CONFIG-004 — Adapter Config -> Store Critère : `ksp-config-lib` seul transforme un profil résolu en `StoreSettings` et ne transmet aucun secret dans diagnostics. -Statut : `TODO pre.004`. +Matérialisé par `pre.004` : seul `ksp-config-lib` dépend de `ksp-store-lib` avec `default-features = false` et construit `StoreSettings` sans forcer la feature backend. + +Statut : `TODO gate opérateur pre.004`. ## 6. Pool et lifecycle PostgreSQL @@ -497,13 +507,13 @@ 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`. -Statut : `TODO gate opérateur pre.003 / TODO pre.005 / TODO pre.009`. +Statut : `PASS pre.003-fix.001 opérateur` pour les bornes backend-neutral / `TODO pre.005 / TODO pre.009` pour les timeouts physiques. ### V32-SEC-004 — Feature mismatch avant I/O `pre.003` matérialise un canari d'intégration compilé avec et sans `postgres`. Sans feature, `Store::open` retourne le code stable `store.backend_not_compiled` avant tout chemin backend physique. -Statut : `TODO gate opérateur pre.003 / TODO pre.009`. +Statut : `PASS pre.003-fix.001 opérateur / TODO pre.009`. ### V32-SEC-005 — Server error sanitization