From 31ae38853d2ea71304cf2144a482173f1e2a2f1b Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 16 Sep 2026 10:58:29 +0200 Subject: [PATCH] 0.3.15-pre.012 --- Cargo.toml | 4 +- .../README.md | 6 +- .../USAGE.md | 6 +- .../frontend/ts/frontend_log.ts | 10 +- .../src/app_state.rs | 39 ++-- .../src/dto_route.rs | 36 +++- .../src/errors.rs | 14 +- .../src/frontend_logging.rs | 72 +++++-- .../src/lib.rs | 10 +- .../src/route_runtime.rs | 167 ++++++++++++++--- .../src/route_start.rs | 6 +- .../src/tauri.rs | 63 ++++++- .../tests/desktop_contract.rs | 35 +++- .../tests/desktop_security.rs | 30 ++- .../tests/release_completeness.rs | 26 ++- .../unit_tests/dto_route.rs | 33 +++- .../unit_tests/frontend_logging.rs | 25 ++- .../unit_tests/route_runtime.rs | 69 ++++++- deltas/0.3.15/pre.012.md | 177 ++++++++++++++++++ ...0_3_15_RAW_TRANSACTION_INGEST_DESK_PLAN.md | 12 +- ...032-V0_3_15_RAW_TRANSACTION_INGEST_DESK.md | 54 +++++- 21 files changed, 815 insertions(+), 79 deletions(-) create mode 100644 deltas/0.3.15/pre.012.md diff --git a/Cargo.toml b/Cargo.toml index f3d025b..bf57feb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 621 +# version: 622 [workspace] resolver = "3" members = ["crates/ksp-app-backfill-desk", "crates/ksp-app-config-desk", "crates/ksp-app-raw-transaction-ingest-desk", "crates/ksp-app-solprices-desk", "crates/ksp-app-store-desk", "crates/ksp-app-wallet-desk", "crates/ksp-config-lib", "crates/ksp-core-lib", "crates/ksp-interface-lib", "crates/ksp-job-api", "crates/ksp-job-backfill-lib", "crates/ksp-logging-lib", "crates/ksp-offchain-transport-lib", "crates/ksp-onchain-transport-lib", "crates/ksp-program-api", "crates/ksp-raw-transaction-lib", "crates/ksp-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib", "crates/ksp-worker-api", "crates/ksp-worker-raw-transaction-ingest-lib"] [workspace.package] -version = "0.3.15-pre.11.fix.1" +version = "0.3.15-pre.12" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-app-raw-transaction-ingest-desk/README.md b/crates/ksp-app-raw-transaction-ingest-desk/README.md index 2167a8f..3cd33bd 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/README.md +++ b/crates/ksp-app-raw-transaction-ingest-desk/README.md @@ -1,5 +1,5 @@ - + # `ksp-app-raw-transaction-ingest-desk` @@ -31,11 +31,15 @@ Au Start, le backend revalide une sélection logique par `profile_id + route_id Le runtime accepte désormais plusieurs routes simultanées sur un même réseau. Chaque route possède son Worker indépendant ; deux Starts de la même identité logique `profile_id + route_id` sont refusés, et un Start d'un autre réseau est refusé tant que le Store partagé reste ouvert. Le premier Worker ouvre et valide le Store, les suivants réutilisent le même `Arc`, puis la dernière route terminale déclenche seule la fermeture explicite du Store. Un fault d'une route ne stoppe pas les Workers siblings. Stop est ciblé par route et reste idempotent lorsqu'un terminal sûr a déjà été retenu. +Les races de contrôle sont fermées côté backend : un Stop qui rencontre une route encore `Starting` marque cette réservation pour arrêt coopératif et attend son cleanup au lieu de la considérer inactive. Dès qu'une fermeture de l'application commence, tout nouveau Start est refusé, les routes actives reçoivent Stop et les Starts déjà réservés sont marqués avant activation. La fenêtre principale ne quitte le processus qu'après cleanup borné des Workers et du Store partagé, ou avec un code d'échec après expiration de la borne. + Les profils applicatifs sont réseau-centriques (`devnet`, `mainnet`, `testnet`). Un profil peut agréger plusieurs profils Transport du même réseau : le provider reste une source de capability et ne devient jamais une identité réseau ni un choix de profil applicatif. Une route explicitement liée à un provider n’est projetée pour un réseau que si le composite déclare une source Transport de ce provider. Ainsi, la route Helius Transaction existe sur Devnet/Mainnet mais n’est pas présentée sur Testnet tant qu’Helius ne fournit pas de source Testnet configurée. Le frontend ne reçoit aucune URL, credential, URI Store, metadata secrète, source key, handle runtime ou payload `RawTransaction`. +Les requêtes IPC Start/Stop sont shape-strict : les champs inconnus sont rejetés et `profile_id` est borné/validé avant toute reconstruction. Le bridge de logging frontend applique également des bornes strictes et refuse les caractères de contrôle avant émission dans la façade Logging. + ## Routes V1 ```text diff --git a/crates/ksp-app-raw-transaction-ingest-desk/USAGE.md b/crates/ksp-app-raw-transaction-ingest-desk/USAGE.md index 46e703b..746c8f2 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/USAGE.md +++ b/crates/ksp-app-raw-transaction-ingest-desk/USAGE.md @@ -1,5 +1,5 @@ - + # Utilisation de `ksp-app-raw-transaction-ingest-desk` @@ -36,12 +36,16 @@ Une route provider-specific dont aucune source n’est déclarée pour le résea Les URLs, tokens, metadata secrètes, handles Transport/Store/Worker, URI Store, source keys et payloads RAW restent backend-only. +Les commandes Start/Stop refusent les champs IPC inconnus et les identifiants logiques non bornés ou contenant des caractères de contrôle. Le logging frontend est lui aussi borné avant l'IPC ; une entrée hostile ne doit jamais être recopiée dans les diagnostics backend. + ### Start/Stop multi-route Pour une route `Configured`, choisir `confirmed` ou `finalized` puis utiliser `Start`. Le backend revalide la génération d'inventaire et la composition Config, ouvre le Store correspondant lorsqu'aucun Store compatible n'est encore actif, exige son état `Ready`, construit le Worker exact puis installe son handle avant de renvoyer l'accusé runtime. Plusieurs routes peuvent être actives simultanément lorsqu'elles appartiennent au même réseau logique. Chaque route conserve son Worker indépendant et son Stop ciblé. Le Store est partagé entre ces Workers ; le dernier Worker terminal déclenche la fermeture explicite du Store. Une route `Faulted` ne force pas l'arrêt des autres routes. Un Start d'un autre réseau est refusé tant que le Store partagé du réseau courant reste ouvert. +Un Stop peut être demandé même si le Start ciblé n'a pas encore fini d'ouvrir le Store ou d'installer le Worker : la réservation est alors marquée pour arrêt et le backend attend sa résolution. Lors de la fermeture de la fenêtre principale, le Desk ferme d'abord l'admission Start, demande l'arrêt de toutes les routes actives ou encore en démarrage et attend un cleanup borné avant de quitter. Il n'est donc pas nécessaire d'arrêter manuellement chaque route avant de fermer l'application. + Le backend expose désormais un flux latest-value par route via l'événement Tauri `ksp-raw-ingest-route-status`. Chaque projection contient uniquement l'identité logique sûre de la route, lifecycle/health/activity, compteurs admission/persistence, backpressure, reconnect/replay, continuité, gaps et repair. La commande `get_route_monitoring` permet une resynchronisation explicite des routes actives et des derniers terminaux retenus dans la session runtime courante. Les séquences, slots et compteurs `u64` sont transmis en texte décimal pour préserver leur exactitude côté JavaScript. Le bouton `Resync monitoring` recharge explicitement les latest values backend ; les événements `ksp-raw-ingest-route-status` actualisent ensuite les cartes sans polling frontend. Le relais backend coalesce les changements à une cadence maximale d'une émission toutes les `500 ms` par route, et les changements steady-state mettent à jour la carte en place sans recréer les boutons Start/Stop. Une carte disposant d'un snapshot montre un résumé sûr (`health`, `activity`, persisted, sources actives, gaps) et le bouton `Supervision` ouvre le détail pipeline/persistence, sources, reconnect/replay, continuité et repair. Les derniers terminaux de la session restent consultables jusqu'à remplacement par un nouveau Start ou changement de session réseau. Sur Mainnet, `yellowstone-hydrated` utilise un abonnement Yellowstone Block comme signal de temps réel puis reconcile chaque slot via `getBlock` Full/Base64. Cette stratégie évite l'hydration `getTransaction` unitaire par transaction. Le profil PublicNode peut exposer plusieurs endpoints HTTP sous le même rôle logique ; Transport distribue alors les requêtes selon ses règles de priorité, disponibilité, concurrence et cooldown. La conversion directe du payload Yellowstone vers le RAW canonique reste réservée à une évolution ultérieure tant que la parité complète du meta n'est pas prouvée. diff --git a/crates/ksp-app-raw-transaction-ingest-desk/frontend/ts/frontend_log.ts b/crates/ksp-app-raw-transaction-ingest-desk/frontend/ts/frontend_log.ts index be27f34..b5b8dd1 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/frontend/ts/frontend_log.ts +++ b/crates/ksp-app-raw-transaction-ingest-desk/frontend/ts/frontend_log.ts @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/frontend/ts/frontend_log.ts -// version: 1 +// version: 2 import { invoke } from "@tauri-apps/api/core"; import type { FrontendLogPayloadDto } from "./bindings/ksp_app_raw_transaction_ingest_desk/frontend_logging/FrontendLogPayloadDto.ts"; @@ -9,6 +9,8 @@ export type FrontendLogTargetId = "frontend" | "main" | "splash"; type ConsoleMethod = (...items: unknown[]) => void; +const FRONTEND_LOG_MESSAGE_MAX_CODE_UNITS = 1024; + const originalConsole = { trace: console.trace.bind(console), debug: console.debug.bind(console), @@ -37,11 +39,15 @@ function formatMessage(items: unknown[]): string { return items.map(item => stringifyItem(item)).join(" "); } +function boundMessage(message: string): string { + return message.length <= FRONTEND_LOG_MESSAGE_MAX_CODE_UNITS ? message : `${message.slice(0, FRONTEND_LOG_MESSAGE_MAX_CODE_UNITS)}…`; +} + async function sendFrontendLog(level: FrontendLogLevel, targetId: FrontendLogTargetId, message: string): Promise { const payload: FrontendLogPayloadDto = { level, targetId, - message, + message: boundMessage(message), }; await invoke("emit_frontend_log", { payload }); } diff --git a/crates/ksp-app-raw-transaction-ingest-desk/src/app_state.rs b/crates/ksp-app-raw-transaction-ingest-desk/src/app_state.rs index 778f955..55ce620 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/src/app_state.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/src/app_state.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/src/app_state.rs -// version: 7 +// version: 8 //! Shared backend state owned by the Raw Transaction Ingest Desk Tauri application. @@ -117,23 +117,26 @@ impl crate::AppState { /// Starts one independent route Worker after repeating complete Start-time revalidation, sharing Store only on an exact network match. pub(crate) async fn start_route(&self, request: &crate::RawIngestRouteStartRequestDto) -> ksp_core_lib::Result { - let generation = { - let generation = self.inventory_generation.lock(); - match generation { - std::result::Result::Ok(value) => *value, - std::result::Result::Err(_) => { - return std::result::Result::Err(ksp_core_lib::Error::new( - crate::ERROR_CODE_APP_STATE_LOCK_FAILED, - "Raw Transaction Ingest Desk route inventory generation lock is poisoned", - )); - }, - } + let admission = self.route_runtime.ensure_start_admission_open(); + if let std::result::Result::Err(error) = admission { + return std::result::Result::Err(error); + } + let generation = self.inventory_generation.lock(); + let generation = match generation { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_APP_STATE_LOCK_FAILED, + "Raw Transaction Ingest Desk route inventory generation lock is poisoned", + )); + }, }; - let prepared = crate::prepare_route_start(&self.config_management, generation, request); + let prepared = crate::prepare_route_start(&self.config_management, *generation, request); let prepared = match prepared { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; + std::mem::drop(generation); return crate::start_route_runtime(std::sync::Arc::clone(&self.route_runtime), prepared).await; } @@ -147,6 +150,16 @@ impl crate::AppState { return self.route_runtime.stop_and_wait(request).await; } + /// Closes route Start admission once and requests cooperative Stop for all routes during application shutdown. + pub(crate) fn begin_shutdown(&self) -> ksp_core_lib::Result { + return self.route_runtime.begin_shutdown(); + } + + /// Waits for bounded cleanup of all route Workers and the shared Store during application shutdown. + pub(crate) async fn shutdown_routes(&self) -> ksp_core_lib::Result<()> { + return self.route_runtime.shutdown_and_wait().await; + } + /// Builds the safe scaffold status exposed by the shell. pub(crate) fn shell_status(&self) -> ksp_core_lib::Result { let document_count = self.config_management.engine().registry().descriptors().count(); diff --git a/crates/ksp-app-raw-transaction-ingest-desk/src/dto_route.rs b/crates/ksp-app-raw-transaction-ingest-desk/src/dto_route.rs index 66039de..1190ed0 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/src/dto_route.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/src/dto_route.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/src/dto_route.rs -// version: 6 +// version: 7 //! Safe route-identity, Config-composability and lifecycle DTOs for Raw Transaction Ingest Desk. @@ -99,7 +99,7 @@ impl crate::RawIngestCommitment { /// Safe request used to revalidate and reconstruct one future route Start without accepting physical resources from the frontend. #[derive(Clone, Debug, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_raw_transaction_ingest_desk/dto_route/RawIngestRouteStartRequestDto.ts")] pub(crate) struct RawIngestRouteStartRequestDto { /// Confirmed/finalized commitment selected for the future Worker. @@ -114,7 +114,7 @@ pub(crate) struct RawIngestRouteStartRequestDto { /// Safe targeted Stop request containing only the logical Worker route identity. #[derive(Clone, Debug, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_raw_transaction_ingest_desk/dto_route/RawIngestRouteStopRequestDto.ts")] pub(crate) struct RawIngestRouteStopRequestDto { /// Safe logical network-profile identifier that owns the Worker route. @@ -123,6 +123,36 @@ pub(crate) struct RawIngestRouteStopRequestDto { pub(crate) route_id: crate::RawIngestRouteId, } +const RAW_INGEST_PROFILE_ID_MAX_BYTES: usize = 256; + +impl crate::RawIngestRouteStartRequestDto { + /// Validates the only caller-controlled free-form logical identifier before Config or runtime reconstruction. + pub(crate) fn validate_logical_identity(&self) -> ksp_core_lib::Result<()> { + return validate_profile_id(self.profile_id.as_str()); + } +} + +impl crate::RawIngestRouteStopRequestDto { + /// Validates the only caller-controlled free-form logical identifier before runtime lookup. + pub(crate) fn validate_logical_identity(&self) -> ksp_core_lib::Result<()> { + return validate_profile_id(self.profile_id.as_str()); + } +} + +fn validate_profile_id(profile_id: &str) -> ksp_core_lib::Result<()> { + if profile_id.is_empty() + || profile_id.len() > RAW_INGEST_PROFILE_ID_MAX_BYTES + || profile_id.trim() != profile_id + || profile_id.chars().any(char::is_control) + { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_ROUTE_REQUEST_INVALID, + "Raw Transaction Ingest Desk route request contains an invalid logical profile identifier", + )); + } + return std::result::Result::Ok(()); +} + /// Safe acknowledgement that exact Start-time Transport resources were reconstructed and validated without Worker launch. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] diff --git a/crates/ksp-app-raw-transaction-ingest-desk/src/errors.rs b/crates/ksp-app-raw-transaction-ingest-desk/src/errors.rs index a69160f..d3f3203 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/src/errors.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/src/errors.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/src/errors.rs -// version: 5 +// version: 6 //! Stable Raw Transaction Ingest Desk application error codes. @@ -17,6 +17,9 @@ pub(crate) const ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID: ksp_core_lib::ErrorCode /// Frontend logging requested a target outside the application whitelist. pub(crate) const ERROR_CODE_FRONTEND_LOG_TARGET_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("raw_transaction_ingest_desk", "frontend_log_target_invalid"); +/// Frontend logging payload exceeds the bounded technical IPC contract. +pub(crate) const ERROR_CODE_FRONTEND_LOG_PAYLOAD_INVALID: ksp_core_lib::ErrorCode = + ksp_core_lib::ErrorCode::new("raw_transaction_ingest_desk", "frontend_log_payload_invalid"); /// Raw Transaction Ingest Desk could not install managed Logging or its safe fallback. pub(crate) const ERROR_CODE_LOGGING_BOOTSTRAP_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("raw_transaction_ingest_desk", "logging_bootstrap_failed"); @@ -46,6 +49,15 @@ pub(crate) const ERROR_CODE_ROUTE_RUNTIME_STORE_SHUTDOWN_FAILED: ksp_core_lib::E /// The multi-route runtime reservation/handle state changed unexpectedly. pub(crate) const ERROR_CODE_ROUTE_RUNTIME_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("raw_transaction_ingest_desk", "route_runtime_state_invalid"); +/// Application shutdown has closed admission for new route Worker starts. +pub(crate) const ERROR_CODE_ROUTE_RUNTIME_SHUTTING_DOWN: ksp_core_lib::ErrorCode = + ksp_core_lib::ErrorCode::new("raw_transaction_ingest_desk", "route_runtime_shutting_down"); +/// Application shutdown could not join all route Workers and shared Store cleanup within the bounded deadline. +pub(crate) const ERROR_CODE_ROUTE_RUNTIME_SHUTDOWN_FAILED: ksp_core_lib::ErrorCode = + ksp_core_lib::ErrorCode::new("raw_transaction_ingest_desk", "route_runtime_shutdown_failed"); +/// A Start/Stop IPC request contains an invalid logical identifier or unsupported shape. +pub(crate) const ERROR_CODE_ROUTE_REQUEST_INVALID: ksp_core_lib::ErrorCode = + ksp_core_lib::ErrorCode::new("raw_transaction_ingest_desk", "route_request_invalid"); /// Start request references an inventory generation that is no longer current. pub(crate) const ERROR_CODE_ROUTE_START_STALE_INVENTORY: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("raw_transaction_ingest_desk", "stale_inventory"); diff --git a/crates/ksp-app-raw-transaction-ingest-desk/src/frontend_logging.rs b/crates/ksp-app-raw-transaction-ingest-desk/src/frontend_logging.rs index d8c7c9c..d6b33af 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/src/frontend_logging.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/src/frontend_logging.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/src/frontend_logging.rs -// version: 1 +// version: 2 //! KSP-owned bridge for technical log events emitted by Raw Transaction Ingest Desk frontend scripts. @@ -7,7 +7,7 @@ use ts_rs::TS; // rust-rules: trait-import /// Log payload sent by Raw Transaction Ingest Desk frontend scripts. #[derive(Clone, Debug, serde::Deserialize, TS)] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_raw_transaction_ingest_desk/frontend_logging/FrontendLogPayloadDto.ts")] pub(crate) struct FrontendLogPayloadDto { /// Lowercase KSP log level. @@ -34,8 +34,15 @@ enum FrontendLogTarget { Splash, } +const FRONTEND_LOG_MESSAGE_MAX_BYTES: usize = 8192; +const FRONTEND_LOG_SELECTOR_MAX_BYTES: usize = 16; + /// Emits one validated frontend event through the KSP Logging facade. pub(crate) fn emit_frontend_log_event(payload: FrontendLogPayloadDto) -> ksp_core_lib::Result<()> { + let shape = validate_payload_shape(&payload); + if let std::result::Result::Err(error) = shape { + return std::result::Result::Err(error); + } let level = parse_level(payload.level.as_str()); let level = match level { std::result::Result::Ok(value) => value, @@ -50,26 +57,55 @@ pub(crate) fn emit_frontend_log_event(payload: FrontendLogPayloadDto) -> ksp_cor return std::result::Result::Ok(()); } +fn validate_payload_shape(payload: &FrontendLogPayloadDto) -> ksp_core_lib::Result<()> { + if payload.level.len() > FRONTEND_LOG_SELECTOR_MAX_BYTES + || payload.target_id.len() > FRONTEND_LOG_SELECTOR_MAX_BYTES + || payload.message.len() > FRONTEND_LOG_MESSAGE_MAX_BYTES + || payload.message.chars().any(char::is_control) + { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_FRONTEND_LOG_PAYLOAD_INVALID, + "Frontend log payload exceeds the bounded technical IPC contract", + )); + } + return std::result::Result::Ok(()); +} + fn parse_level(level: &str) -> ksp_core_lib::Result { - return match level.trim().to_ascii_lowercase().as_str() { - "trace" => std::result::Result::Ok(FrontendLogLevel::Trace), - "debug" => std::result::Result::Ok(FrontendLogLevel::Debug), - "info" => std::result::Result::Ok(FrontendLogLevel::Info), - "warn" => std::result::Result::Ok(FrontendLogLevel::Warn), - "error" => std::result::Result::Ok(FrontendLogLevel::Error), - _ => std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID, "Frontend log level is not supported")), - }; + let level = level.trim(); + if level.eq_ignore_ascii_case("trace") { + return std::result::Result::Ok(FrontendLogLevel::Trace); + } + if level.eq_ignore_ascii_case("debug") { + return std::result::Result::Ok(FrontendLogLevel::Debug); + } + if level.eq_ignore_ascii_case("info") { + return std::result::Result::Ok(FrontendLogLevel::Info); + } + if level.eq_ignore_ascii_case("warn") { + return std::result::Result::Ok(FrontendLogLevel::Warn); + } + if level.eq_ignore_ascii_case("error") { + return std::result::Result::Ok(FrontendLogLevel::Error); + } + return std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID, "Frontend log level is not supported")); } fn parse_target(target_id: &str) -> ksp_core_lib::Result { - return match target_id.trim().to_ascii_lowercase().as_str() { - "frontend" => std::result::Result::Ok(FrontendLogTarget::Frontend), - "main" => std::result::Result::Ok(FrontendLogTarget::Main), - "splash" => std::result::Result::Ok(FrontendLogTarget::Splash), - _ => { - std::result::Result::Err(ksp_core_lib::Error::new(crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID, "Frontend log target identifier is not supported")) - }, - }; + let target_id = target_id.trim(); + if target_id.eq_ignore_ascii_case("frontend") { + return std::result::Result::Ok(FrontendLogTarget::Frontend); + } + if target_id.eq_ignore_ascii_case("main") { + return std::result::Result::Ok(FrontendLogTarget::Main); + } + if target_id.eq_ignore_ascii_case("splash") { + return std::result::Result::Ok(FrontendLogTarget::Splash); + } + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID, + "Frontend log target identifier is not supported", + )); } fn emit_validated_frontend_log(level: FrontendLogLevel, target: FrontendLogTarget, message: &str) { diff --git a/crates/ksp-app-raw-transaction-ingest-desk/src/lib.rs b/crates/ksp-app-raw-transaction-ingest-desk/src/lib.rs index df08e8a..6c04392 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/src/lib.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/src/lib.rs -// version: 7 +// version: 8 //! Tauri desktop application scaffold for composing and supervising KSP live RAW transaction ingest routes. @@ -113,18 +113,26 @@ pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED; pub(crate) use self::errors::ERROR_CODE_CONFIG_COMPOSITE_INVALID; /// Frontend logging requested an unsupported level. pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_LEVEL_INVALID; +/// Frontend logging payload exceeds the bounded technical IPC contract. +pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_PAYLOAD_INVALID; /// Frontend logging requested an unsupported target. pub(crate) use self::errors::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID; /// Managed and fallback Logging initialization failed. pub(crate) use self::errors::ERROR_CODE_LOGGING_BOOTSTRAP_FAILED; /// Config-only route inventory cannot be projected safely. pub(crate) use self::errors::ERROR_CODE_ROUTE_INVENTORY_INVALID; +/// Start/Stop IPC request contains an invalid logical shape. +pub(crate) use self::errors::ERROR_CODE_ROUTE_REQUEST_INVALID; /// A Worker runtime already owns the same logical route in the shared application runtime. pub(crate) use self::errors::ERROR_CODE_ROUTE_RUNTIME_ACTIVE; /// Selected route cannot share the currently open Store because its network differs. pub(crate) use self::errors::ERROR_CODE_ROUTE_RUNTIME_NETWORK_MISMATCH; /// No selected route Worker runtime is currently active. pub(crate) use self::errors::ERROR_CODE_ROUTE_RUNTIME_NOT_ACTIVE; +/// Application shutdown could not complete all bounded route/Store cleanup. +pub(crate) use self::errors::ERROR_CODE_ROUTE_RUNTIME_SHUTDOWN_FAILED; +/// Application shutdown has closed admission for new route Worker starts. +pub(crate) use self::errors::ERROR_CODE_ROUTE_RUNTIME_SHUTTING_DOWN; /// Store or Worker startup failed after Start-time route revalidation. pub(crate) use self::errors::ERROR_CODE_ROUTE_RUNTIME_START_FAILED; /// The multi-route runtime reservation or handle state changed unexpectedly. diff --git a/crates/ksp-app-raw-transaction-ingest-desk/src/route_runtime.rs b/crates/ksp-app-raw-transaction-ingest-desk/src/route_runtime.rs index 95de722..6d539c2 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/src/route_runtime.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/src/route_runtime.rs @@ -1,9 +1,10 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/src/route_runtime.rs -// version: 6 +// version: 7 //! Multi-route Store and independent Worker lifecycle owned by Raw Transaction Ingest Desk. -const ROUTE_STOP_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); +const ROUTE_SHUTDOWN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); +const ROUTE_STOP_CLEANUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15); const STORE_RECLAIM_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5); const STORE_RECLAIM_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(1); @@ -13,6 +14,7 @@ struct RouteRuntimeInner { network: std::option::Option, next_sequence: u64, routes: std::vec::Vec, + shutting_down: bool, store: std::option::Option>, store_closing_token: std::option::Option, store_opening_token: std::option::Option, @@ -34,6 +36,7 @@ impl crate::RouteRuntimeState { network: std::option::Option::None, next_sequence: 0, routes: std::vec::Vec::new(), + shutting_down: false, store: std::option::Option::None, store_closing_token: std::option::Option::None, store_opening_token: std::option::Option::None, @@ -41,12 +44,34 @@ impl crate::RouteRuntimeState { }; } + /// Rejects new route Start work after application shutdown has closed admission. + pub(crate) fn ensure_start_admission_open(&self) -> ksp_core_lib::Result<()> { + let inner = self.inner.lock(); + let inner = match inner { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(runtime_lock_error()), + }; + if inner.shutting_down { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_ROUTE_RUNTIME_SHUTTING_DOWN, + "Raw Transaction Ingest Desk cannot start a route while application shutdown is in progress", + )); + } + return std::result::Result::Ok(()); + } + fn reserve(&self, prepared: &crate::PreparedRouteStart) -> ksp_core_lib::Result { let inner = self.inner.lock(); let mut inner = match inner { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(runtime_lock_error()), }; + if inner.shutting_down { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_ROUTE_RUNTIME_SHUTTING_DOWN, + "Raw Transaction Ingest Desk cannot start a route while application shutdown is in progress", + )); + } if inner.store_closing_token.is_some() { return std::result::Result::Err(ksp_core_lib::Error::new( crate::ERROR_CODE_ROUTE_RUNTIME_ACTIVE, @@ -111,7 +136,7 @@ impl crate::RouteRuntimeState { if needs_store_open { inner.store_opening_token = std::option::Option::Some(sequence); } - inner.routes.push(RouteRuntimeSlot::Starting { identity: identity.clone(), token: sequence }); + inner.routes.push(RouteRuntimeSlot::Starting { identity: identity.clone(), stop_requested: false, token: sequence }); return std::result::Result::Ok(RouteRuntimeReservation { identity, needs_store_open, token: sequence, worker_id }); } @@ -173,7 +198,19 @@ impl crate::RouteRuntimeState { )); }, }; - let state = project_worker_state(handle.snapshot_source().current().worker_snapshot().state()); + let stop_requested = match &inner.routes[position] { + RouteRuntimeSlot::Starting { stop_requested, .. } => *stop_requested, + RouteRuntimeSlot::Active { .. } => false, + }; + let should_stop = stop_requested || inner.shutting_down; + if should_stop { + let _accepted = handle.request_stop(); + } + let state = if should_stop { + crate::RawIngestRouteState::Stopping + } else { + project_worker_state(handle.snapshot_source().current().worker_snapshot().state()) + }; inner.routes[position] = RouteRuntimeSlot::Active { handle, identity: reservation.identity.clone(), token: reservation.token }; return std::result::Result::Ok(reservation.identity.dto(state)); } @@ -184,7 +221,12 @@ impl crate::RouteRuntimeState { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(runtime_lock_error()), }; + let cancelled_terminal = inner.routes.iter().find(|slot| return slot.token() == token).and_then(RouteRuntimeSlot::cancelled_start_terminal); inner.routes.retain(|slot| return slot.token() != token); + if let std::option::Option::Some(terminal) = cancelled_terminal { + inner.last_terminals.retain(|previous| return previous.profile_id != terminal.profile_id || previous.route_id != terminal.route_id); + inner.last_terminals.push(terminal); + } if inner.store_opening_token == std::option::Option::Some(token) { inner.store_opening_token = std::option::Option::None; } @@ -272,8 +314,71 @@ impl crate::RouteRuntimeState { return std::result::Result::Ok(values); } + /// Closes route Start admission exactly once and requests cooperative Stop for all active or still-starting routes. + pub(crate) fn begin_shutdown(&self) -> ksp_core_lib::Result { + let inner = self.inner.lock(); + let mut inner = match inner { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(runtime_lock_error()), + }; + if inner.shutting_down { + return std::result::Result::Ok(false); + } + inner.shutting_down = true; + for slot in &mut inner.routes { + match slot { + RouteRuntimeSlot::Starting { stop_requested, .. } => *stop_requested = true, + RouteRuntimeSlot::Active { handle, .. } => { + let _accepted = handle.request_stop(); + }, + } + } + return std::result::Result::Ok(true); + } + + /// Waits until all owned route Workers and the shared Store have completed bounded application-shutdown cleanup. + pub(crate) async fn shutdown_and_wait(&self) -> ksp_core_lib::Result<()> { + let begin = self.begin_shutdown(); + if let std::result::Result::Err(error) = begin { + return std::result::Result::Err(error); + } + let started = std::time::Instant::now(); + loop { + let complete = self.shutdown_complete(); + let complete = match complete { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if complete { + return std::result::Result::Ok(()); + } + if started.elapsed() >= ROUTE_SHUTDOWN_TIMEOUT { + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_ROUTE_RUNTIME_SHUTDOWN_FAILED, + "Raw Transaction Ingest Desk application shutdown did not release all route Workers and shared Store state before the bounded deadline", + )); + } + tokio::time::sleep(STORE_RECLAIM_POLL_INTERVAL).await; + } + } + + fn shutdown_complete(&self) -> ksp_core_lib::Result { + let inner = self.inner.lock(); + let inner = match inner { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return std::result::Result::Err(runtime_lock_error()), + }; + return std::result::Result::Ok( + inner.routes.is_empty() && inner.store.is_none() && inner.store_opening_token.is_none() && inner.store_closing_token.is_none(), + ); + } + /// Requests cooperative Stop for one exact logical route and waits for its Worker cleanup. pub(crate) async fn stop_and_wait(&self, request: &crate::RawIngestRouteStopRequestDto) -> ksp_core_lib::Result { + let identity = request.validate_logical_identity(); + if let std::result::Result::Err(error) = identity { + return std::result::Result::Err(error); + } let target = self.stop_target(request); let target = match target { std::result::Result::Ok(value) => value, @@ -281,6 +386,7 @@ impl crate::RouteRuntimeState { }; let (handle, identity, token) = match target { RouteStopTarget::Active { handle, identity, token } => (handle, identity, token), + RouteStopTarget::Starting { identity, token } => return self.wait_route_cleanup(token, &identity).await, RouteStopTarget::Terminal(value) => return std::result::Result::Ok(value), RouteStopTarget::TerminalClosing => return self.wait_terminal_store_cleanup(request).await, }; @@ -296,33 +402,30 @@ impl crate::RouteRuntimeState { fn stop_target(&self, request: &crate::RawIngestRouteStopRequestDto) -> ksp_core_lib::Result { let inner = self.inner.lock(); - let inner = match inner { + let mut inner = match inner { std::result::Result::Ok(value) => value, std::result::Result::Err(_) => return std::result::Result::Err(runtime_lock_error()), }; - let slot = inner.routes.iter().find(|slot| return slot.matches_identity(request.profile_id.as_str(), request.route_id)); - return match slot { - std::option::Option::Some(RouteRuntimeSlot::Active { handle, identity, token }) => { - std::result::Result::Ok(RouteStopTarget::Active { handle: handle.clone(), identity: identity.clone(), token: *token }) - }, - std::option::Option::Some(RouteRuntimeSlot::Starting { .. }) => std::result::Result::Err(ksp_core_lib::Error::new( + let position = inner.routes.iter().position(|slot| return slot.matches_identity(request.profile_id.as_str(), request.route_id)); + if let std::option::Option::Some(position) = position { + return match &mut inner.routes[position] { + RouteRuntimeSlot::Active { handle, identity, token } => { + std::result::Result::Ok(RouteStopTarget::Active { handle: handle.clone(), identity: identity.clone(), token: *token }) + }, + RouteRuntimeSlot::Starting { identity, stop_requested, token } => { + *stop_requested = true; + std::result::Result::Ok(RouteStopTarget::Starting { identity: identity.clone(), token: *token }) + }, + }; + } + let terminal = inner.last_terminals.iter().find(|terminal| return terminal.profile_id == request.profile_id && terminal.route_id == request.route_id); + return match terminal { + std::option::Option::Some(value) if inner.store_closing_token.is_none() => std::result::Result::Ok(RouteStopTarget::Terminal(value.clone())), + std::option::Option::Some(_) => std::result::Result::Ok(RouteStopTarget::TerminalClosing), + std::option::Option::None => std::result::Result::Err(ksp_core_lib::Error::new( crate::ERROR_CODE_ROUTE_RUNTIME_NOT_ACTIVE, - "Raw Transaction Ingest Desk route Worker is still starting and cannot be stopped yet", + "Raw Transaction Ingest Desk has no active Worker for the selected logical route", )), - std::option::Option::None => { - let terminal = - inner.last_terminals.iter().find(|terminal| return terminal.profile_id == request.profile_id && terminal.route_id == request.route_id); - match terminal { - std::option::Option::Some(value) if inner.store_closing_token.is_none() => { - std::result::Result::Ok(RouteStopTarget::Terminal(value.clone())) - }, - std::option::Option::Some(_) => std::result::Result::Ok(RouteStopTarget::TerminalClosing), - std::option::Option::None => std::result::Result::Err(ksp_core_lib::Error::new( - crate::ERROR_CODE_ROUTE_RUNTIME_NOT_ACTIVE, - "Raw Transaction Ingest Desk has no active Worker for the selected logical route", - )), - } - }, }; } @@ -668,6 +771,10 @@ enum RouteStopTarget { identity: RouteRuntimeIdentity, token: u64, }, + Starting { + identity: RouteRuntimeIdentity, + token: u64, + }, Terminal(crate::RawIngestRouteRuntimeDto), TerminalClosing, } @@ -682,6 +789,7 @@ struct RouteRuntimeReservation { enum RouteRuntimeSlot { Starting { identity: RouteRuntimeIdentity, + stop_requested: bool, token: u64, }, Active { @@ -706,6 +814,13 @@ impl RouteRuntimeSlot { }; } + fn cancelled_start_terminal(&self) -> std::option::Option { + return match self { + Self::Starting { identity, stop_requested: true, .. } => std::option::Option::Some(identity.dto(crate::RawIngestRouteState::Stopped)), + Self::Starting { stop_requested: false, .. } | Self::Active { .. } => std::option::Option::None, + }; + } + fn token(&self) -> u64 { return match self { Self::Starting { token, .. } | Self::Active { token, .. } => *token, diff --git a/crates/ksp-app-raw-transaction-ingest-desk/src/route_start.rs b/crates/ksp-app-raw-transaction-ingest-desk/src/route_start.rs index 1938bc1..a382e99 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/src/route_start.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/src/route_start.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/src/route_start.rs -// version: 4 +// version: 5 //! Start-time Config revalidation and exact Transport/Store resource reconstruction for route validation and runtime launch. @@ -27,6 +27,10 @@ pub(crate) fn prepare_route_start( current_generation: u32, request: &crate::RawIngestRouteStartRequestDto, ) -> ksp_core_lib::Result { + let identity = request.validate_logical_identity(); + if let std::result::Result::Err(error) = identity { + return std::result::Result::Err(error); + } if current_generation == 0 || request.inventory_generation != current_generation { return std::result::Result::Err(ksp_core_lib::Error::new( crate::ERROR_CODE_ROUTE_START_STALE_INVENTORY, diff --git a/crates/ksp-app-raw-transaction-ingest-desk/src/tauri.rs b/crates/ksp-app-raw-transaction-ingest-desk/src/tauri.rs index 4eaa22e..2cb5791 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/src/tauri.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/src/tauri.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/src/tauri.rs -// version: 8 +// version: 9 //! Tauri runtime assembly for the KSP Raw Transaction Ingest desktop application. @@ -23,6 +23,7 @@ pub fn run(arguments: &[std::ffi::OsString]) -> ksp_core_lib::Result<()> { builder = builder.manage(app_state); builder = builder.plugin(tauri_plugin_tracing::Builder::new().build::()); builder = configure_commands(builder); + builder = configure_window_events(builder); builder = builder.setup(|app| { let splash = crate::require_splash_window(app); if let std::result::Result::Err(error) = splash { @@ -90,6 +91,66 @@ fn configure_commands(builder: tauri::Builder) -> tauri::Builder) -> tauri::Builder { + return builder.on_window_event(|window, event| { + if window.label() != "main" { + return; + } + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + api.prevent_close(); + let app_handle = window.app_handle().clone(); + let state = app_handle.state::(); + let begin = state.begin_shutdown(); + let begin = match begin { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => { + ksp_logging_lib::warn!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_WINDOWS, + error_domain = error.code().domain(), + error_code = error.code().code(), + "Raw Transaction Ingest Desk could not enter graceful shutdown; exiting after safe error projection" + ); + app_handle.exit(1); + return; + }, + }; + if !begin { + return; + } + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_WINDOWS, + "Raw Transaction Ingest Desk main-window close requested; route Start admission closed and cooperative shutdown requested" + ); + tauri::async_runtime::spawn(async move { + let state = app_handle.state::(); + let shutdown = state.shutdown_routes().await; + match shutdown { + std::result::Result::Ok(()) => { + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_WINDOWS, + "Raw Transaction Ingest Desk route and shared Store shutdown completed; exiting application" + ); + app_handle.exit(0); + }, + std::result::Result::Err(error) => { + ksp_logging_lib::warn!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_WINDOWS, + error_domain = error.code().domain(), + error_code = error.code().code(), + "Raw Transaction Ingest Desk bounded route shutdown failed; exiting application after bounded cleanup attempt" + ); + app_handle.exit(1); + }, + } + }); + } + }); +} + fn project_command_error(command: &'static str, domain: &'static str, error: &ksp_core_lib::Error) -> crate::CommandErrorDto { ksp_logging_lib::warn!( target: crate::TRACING_TARGET, diff --git a/crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_contract.rs b/crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_contract.rs index 6babc8e..9847776 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_contract.rs -// version: 9 +// version: 10 //! Desktop scaffold contract canaries for Raw Transaction Ingest Desk. @@ -274,3 +274,36 @@ fn pre_011_fix_001_monitoring_bridge_is_bounded_and_steady_updates_preserve_rout assert!(main.contains(required), "missing pre.011-fix.001 stable-control marker {required}"); } } + +#[test] +fn pre_012_window_shutdown_and_start_stop_races_are_bounded_backend_owned() { + let tauri = read_text(app_root().join("src/tauri.rs").as_path()); + for required in [ + "configure_window_events(builder)", + "tauri::WindowEvent::CloseRequested", + "api.prevent_close()", + "state.begin_shutdown()", + "state.shutdown_routes().await", + "app_handle.exit(0)", + "app_handle.exit(1)", + ] { + assert!(tauri.contains(required), "missing pre.012 bounded shutdown marker {required}"); + } + let runtime = read_text(app_root().join("src/route_runtime.rs").as_path()); + for required in [ + "shutting_down: bool", + "stop_requested: bool", + "ROUTE_SHUTDOWN_TIMEOUT", + "ensure_start_admission_open", + "shutdown_and_wait", + "RouteStopTarget::Starting", + "*stop_requested = true", + "handle.request_stop()", + ] { + assert!(runtime.contains(required), "missing pre.012 route-race marker {required}"); + } + let app_state = read_text(app_root().join("src/app_state.rs").as_path()); + assert!(app_state.contains("let generation = self.inventory_generation.lock()")); + assert!(app_state.contains("std::mem::drop(generation)")); + assert!(app_state.contains("self.route_runtime.ensure_start_admission_open()")); +} diff --git a/crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_security.rs b/crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_security.rs index 7bb1171..6a915c6 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_security.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_security.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_security.rs -// version: 8 +// version: 9 //! Desktop security canaries for the Raw Transaction Ingest Desk scaffold. @@ -195,3 +195,31 @@ fn pre_011_frontend_monitoring_tracing_is_source_neutral_and_does_not_log_raw_ma assert!(!main.contains("frontendTrace(\"main\", \"Raw Transaction Ingest Desk route monitoring latest value applied\", status")); assert!(!main.contains("frontendDebug(\"main\", \"Raw Transaction Ingest Desk route monitoring resynchronization completed\", statuses")); } + +#[test] +fn pre_012_hostile_ipc_is_shape_strict_bounded_and_safely_projected() { + let dto = read_text(app_root().join("src/dto_route.rs").as_path()); + assert!(dto.matches("deny_unknown_fields").count() >= 2); + assert!(dto.contains("RAW_INGEST_PROFILE_ID_MAX_BYTES: usize = 256")); + assert!(dto.contains("validate_logical_identity")); + assert!(dto.contains("ERROR_CODE_ROUTE_REQUEST_INVALID")); + let frontend_logging = read_text(app_root().join("src/frontend_logging.rs").as_path()); + for required in [ + "deny_unknown_fields", + "FRONTEND_LOG_MESSAGE_MAX_BYTES: usize = 8192", + "FRONTEND_LOG_SELECTOR_MAX_BYTES: usize = 16", + "payload.message.chars().any(char::is_control)", + "ERROR_CODE_FRONTEND_LOG_PAYLOAD_INVALID", + "eq_ignore_ascii_case", + ] { + assert!(frontend_logging.contains(required), "missing pre.012 frontend IPC guard {required}"); + } + assert!(!frontend_logging.contains("to_ascii_lowercase()")); + let frontend = read_text(app_root().join("frontend/ts/frontend_log.ts").as_path()); + assert!(frontend.contains("FRONTEND_LOG_MESSAGE_MAX_CODE_UNITS = 1024")); + assert!(frontend.contains("boundMessage(message)")); + let tauri = read_text(app_root().join("src/tauri.rs").as_path()); + assert!(tauri.contains("error_domain = error.code().domain()")); + assert!(tauri.contains("error_code = error.code().code()")); + assert!(!tauri.contains("error = ?error")); +} diff --git a/crates/ksp-app-raw-transaction-ingest-desk/tests/release_completeness.rs b/crates/ksp-app-raw-transaction-ingest-desk/tests/release_completeness.rs index d63868b..4c9d9d8 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/tests/release_completeness.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/tests/release_completeness.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/tests/release_completeness.rs -// version: 11 +// version: 12 //! Release-completeness canaries for Raw Transaction Ingest Desk Start-resource reconstruction. @@ -200,3 +200,27 @@ fn pre_011_fix_001_preserves_latest_value_contract_without_backend_module_growth assert!(main.contains("renderRouteMonitoringCard")); assert!(main.contains("renderSelectedProfile")); } + +#[test] +fn pre_012_race_shutdown_and_ipc_hardening_adds_no_production_module_or_lower_layer_growth() { + let lib = read_text(app_root().join("src/lib.rs").as_path()); + assert_eq!(lib.matches("mod ").count(), 16); + for required in [ + "ERROR_CODE_FRONTEND_LOG_PAYLOAD_INVALID", + "ERROR_CODE_ROUTE_REQUEST_INVALID", + "ERROR_CODE_ROUTE_RUNTIME_SHUTTING_DOWN", + "ERROR_CODE_ROUTE_RUNTIME_SHUTDOWN_FAILED", + ] { + assert!(lib.contains(required), "missing pre.012 crate-root error export {required}"); + } + let runtime = read_text(app_root().join("src/route_runtime.rs").as_path()); + assert!(runtime.contains("stop_requested: bool")); + assert!(runtime.contains("RouteStopTarget::Starting")); + assert!(runtime.contains("cancelled_start_terminal")); + assert!(runtime.contains("inner.routes.is_empty()")); + let route_start = read_text(app_root().join("src/route_start.rs").as_path()); + assert!(route_start.contains("build_route_inventory_with_environment")); + assert!(route_start.contains("request.validate_logical_identity()")); + let worker_manifest = read_text(app_root().join("../ksp-worker-raw-transaction-ingest-lib/Cargo.toml").as_path()); + assert!(!worker_manifest.contains("ksp-app-raw-transaction-ingest-desk")); +} diff --git a/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/dto_route.rs b/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/dto_route.rs index 54bc65d..7bb0e5a 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/dto_route.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/dto_route.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/unit_tests/dto_route.rs -// version: 4 +// version: 5 #[test] fn pre_005_route_ids_are_exact_and_stably_ordered() { @@ -91,3 +91,34 @@ fn pre_008_runtime_acknowledgement_serializes_only_safe_logical_identity_and_sta } } } + +#[test] +fn pre_012_route_requests_reject_unknown_fields_and_bound_free_form_profile_identity() { + let start = serde_json::from_value::(serde_json::json!({ + "commitment": "confirmed", + "inventoryGeneration": 1, + "profileId": "mainnet", + "routeId": "http-block-polling", + "endpointUrl": "https://forbidden.invalid" + })); + assert!(start.is_err()); + let stop = serde_json::from_value::(serde_json::json!({ + "profileId": "mainnet", + "routeId": "http-block-polling", + "workerId": "forbidden" + })); + assert!(stop.is_err()); + let oversized = crate::RawIngestRouteStartRequestDto { + commitment: crate::RawIngestCommitment::Confirmed, + inventory_generation: 1, + profile_id: "x".repeat(super::RAW_INGEST_PROFILE_ID_MAX_BYTES + 1), + route_id: crate::RawIngestRouteId::HttpBlockPolling, + }; + let oversized = oversized.validate_logical_identity(); + assert!(oversized.is_err()); + if let std::result::Result::Err(error) = oversized { + assert_eq!(error.code(), crate::ERROR_CODE_ROUTE_REQUEST_INVALID); + } + let control = crate::RawIngestRouteStopRequestDto { profile_id: "mainnet\n".to_owned(), route_id: crate::RawIngestRouteId::HttpBlockPolling }; + assert!(control.validate_logical_identity().is_err()); +} diff --git a/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/frontend_logging.rs b/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/frontend_logging.rs index a19ea46..0be009c 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/frontend_logging.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/frontend_logging.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/unit_tests/frontend_logging.rs -// version: 1 +// version: 2 #[test] fn frontend_log_levels_are_explicit_and_case_insensitive() { @@ -38,3 +38,26 @@ fn arbitrary_frontend_log_target_is_rejected() { }; assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_TARGET_INVALID); } + +#[test] +fn pre_012_frontend_log_ipc_rejects_unknown_fields_and_unbounded_or_control_messages() { + let unknown = serde_json::from_value::(serde_json::json!({ + "level": "info", + "targetId": "main", + "message": "safe", + "endpointUrl": "https://forbidden.invalid" + })); + assert!(unknown.is_err()); + let oversized = crate::FrontendLogPayloadDto { + level: "info".to_owned(), + target_id: "main".to_owned(), + message: "x".repeat(super::FRONTEND_LOG_MESSAGE_MAX_BYTES + 1), + }; + let result = super::validate_payload_shape(&oversized); + assert!(result.is_err()); + if let std::result::Result::Err(error) = result { + assert_eq!(error.code(), crate::ERROR_CODE_FRONTEND_LOG_PAYLOAD_INVALID); + } + let nul = crate::FrontendLogPayloadDto { level: "info".to_owned(), target_id: "main".to_owned(), message: "safe\0hostile".to_owned() }; + assert!(super::validate_payload_shape(&nul).is_err()); +} diff --git a/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/route_runtime.rs b/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/route_runtime.rs index 7583e65..1a5c4f4 100644 --- a/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/route_runtime.rs +++ b/crates/ksp-app-raw-transaction-ingest-desk/unit_tests/route_runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-raw-transaction-ingest-desk/unit_tests/route_runtime.rs -// version: 3 +// version: 4 #[test] fn pre_009_multi_route_runtime_starts_without_workers_or_shared_store_material() { @@ -63,3 +63,70 @@ fn pre_009_late_targeted_stop_waits_while_final_shared_store_is_closing() { let target = state.stop_target(&request); assert!(matches!(target, std::result::Result::Ok(super::RouteStopTarget::TerminalClosing))); } + +#[test] +fn pre_012_stop_racing_start_marks_the_reservation_for_cooperative_stop() { + let state = crate::RouteRuntimeState::new(); + let identity = super::RouteRuntimeIdentity { + commitment: crate::RawIngestCommitment::Confirmed, + inventory_generation: 12, + network: "mainnet".to_owned(), + profile_id: "mainnet".to_owned(), + route_id: crate::RawIngestRouteId::YellowstoneHydrated, + }; + let inner = state.inner.lock(); + assert!(inner.is_ok()); + if let std::result::Result::Ok(mut inner) = inner { + inner.network = std::option::Option::Some("mainnet".to_owned()); + inner.routes.push(super::RouteRuntimeSlot::Starting { identity: identity.clone(), stop_requested: false, token: 12 }); + } + let request = crate::RawIngestRouteStopRequestDto { profile_id: "mainnet".to_owned(), route_id: crate::RawIngestRouteId::YellowstoneHydrated }; + let target = state.stop_target(&request); + assert!(matches!(target, std::result::Result::Ok(super::RouteStopTarget::Starting { token: 12, .. }))); + let inner = state.inner.lock(); + assert!(inner.is_ok()); + if let std::result::Result::Ok(inner) = inner { + assert!(matches!(inner.routes.first(), std::option::Option::Some(super::RouteRuntimeSlot::Starting { stop_requested: true, .. }))); + } + let rollback = state.rollback(12); + assert!(matches!(rollback, std::result::Result::Ok(std::option::Option::None))); + let inner = state.inner.lock(); + assert!(inner.is_ok()); + if let std::result::Result::Ok(inner) = inner { + assert!(inner.routes.is_empty()); + assert!(matches!(inner.last_terminals.first(), std::option::Option::Some(value) if value.state == crate::RawIngestRouteState::Stopped)); + } +} + +#[test] +fn pre_012_application_shutdown_is_one_shot_and_marks_starting_routes_before_activation() { + let state = crate::RouteRuntimeState::new(); + let identity = super::RouteRuntimeIdentity { + commitment: crate::RawIngestCommitment::Finalized, + inventory_generation: 13, + network: "mainnet".to_owned(), + profile_id: "mainnet".to_owned(), + route_id: crate::RawIngestRouteId::HttpBlockPolling, + }; + let inner = state.inner.lock(); + assert!(inner.is_ok()); + if let std::result::Result::Ok(mut inner) = inner { + inner.network = std::option::Option::Some("mainnet".to_owned()); + inner.routes.push(super::RouteRuntimeSlot::Starting { identity, stop_requested: false, token: 13 }); + } + let first = state.begin_shutdown(); + assert!(matches!(first, std::result::Result::Ok(true))); + let second = state.begin_shutdown(); + assert!(matches!(second, std::result::Result::Ok(false))); + let admission = state.ensure_start_admission_open(); + assert!(admission.is_err()); + if let std::result::Result::Err(error) = admission { + assert_eq!(error.code(), crate::ERROR_CODE_ROUTE_RUNTIME_SHUTTING_DOWN); + } + let inner = state.inner.lock(); + assert!(inner.is_ok()); + if let std::result::Result::Ok(inner) = inner { + assert!(inner.shutting_down); + assert!(matches!(inner.routes.first(), std::option::Option::Some(super::RouteRuntimeSlot::Starting { stop_requested: true, .. }))); + } +} diff --git a/deltas/0.3.15/pre.012.md b/deltas/0.3.15/pre.012.md new file mode 100644 index 0000000..f8102d6 --- /dev/null +++ b/deltas/0.3.15/pre.012.md @@ -0,0 +1,177 @@ + + + +# Delta `0.3.15-pre.012` + +## Base + +```text +base archive : ksp-general-0.3.15-pre.011-fix.001.zip +base SHA-256 : 5da2d7bf343d20354b941ef9158004c3dabf521251bb346ef3aaf20586342a04 +base version : 0.3.15-pre.11.fix.1 +``` + +Le gate opérateur du 16 septembre 2026 confirme la base : audits Rust/Markdown, `cargo check --workspace`, Clippy strict, tests Raw Transaction Ingest Desk/Worker et workspace all-targets/all-features passent. Le live Mainnet valide aussi plusieurs cycles Yellowstone + HTTP Block Polling simultanés avec Stop ciblé dans les deux ordres et fermeture du Store seulement après la dernière route. + +## Objectif + +Fermer les races Start/Stop, stale inventory, shutdown applicatif et les surfaces IPC hostiles sans modifier Config, Transport, Store, Worker, Common RAW ni les stratégies d'acquisition. + +## Race Start / Stop + +Une route réservée mais pas encore activée possède maintenant un marqueur `stop_requested`. + +```text +Stop sur Active -> request_stop Worker puis attente terminal/cleanup +Stop sur Starting -> stop_requested=true puis attente cleanup +Start active après Stop -> request_stop avant acknowledgement +Start échoue avant activation -> rollback retient terminal Stopped +``` + +La route n'est donc plus faussement déclarée `not_active` pendant sa fenêtre de démarrage. Le timeout de cleanup Desk est porté à `15 s`, au-dessus du drain Worker borné, afin de ne pas transformer un arrêt coopératif encore valide en erreur Desk prématurée. + +## Shutdown applicatif + +`RouteRuntimeState` possède désormais un état `shutting_down` one-shot. Le `CloseRequested` de la fenêtre principale est intercepté : + +```text +prevent_close +begin_shutdown +fermeture admission Start +marquage des Starting +request_stop de tous les Active +attente bornée routes + Store +exit(0) si cleanup complet +exit(1) si échec/timeout sûr +``` + +Un second événement de fermeture pendant ce cycle ne lance pas une seconde séquence concurrente. L'admission shutdown est vérifiée avant la préparation Start puis atomiquement lors de la réservation runtime, ce qui ferme la race entre un Start déjà en vol et la fermeture de l'application. + +## Stale inventory + +Le Start conserve le verrou de génération pendant toute la préparation synchrone qui revalide l'inventaire et reconstruit Config/Transport. Le verrou est explicitement libéré avant le premier `await` d'ouverture Store. Un refresh d'inventaire ne peut donc pas intercaler une nouvelle génération entre le contrôle de génération et cette reconstruction. + +## Sécurité IPC + +Les requêtes Start/Stop sont maintenant `deny_unknown_fields` et valident `profile_id` avant reconstruction : + +```text +non vide +<= 256 octets +aucun trim implicite +aucun caractère de contrôle +``` + +Le bridge frontend Logging est également shape-strict et borné : + +```text +level / target <= 16 octets +message <= 8192 octets +aucun caractère de contrôle +aucune allocation lowercase du selector hostile +``` + +Le frontend borne le message technique à `1024` code units avant IPC, ce qui laisse une marge sûre pour l'encodage UTF-8. Les erreurs Tauri restent projetées uniquement par `domain/code` ; les payloads hostiles ne sont jamais journalisés. + +## Canaris + +Ajouts/renforcements : + +```text +pre_012_route_requests_reject_unknown_fields_and_bound_free_form_profile_identity +pre_012_frontend_log_ipc_rejects_unknown_fields_and_unbounded_or_control_messages +pre_012_stop_racing_start_marks_the_reservation_for_cooperative_stop +pre_012_application_shutdown_is_one_shot_and_marks_starting_routes_before_activation +pre_012_window_shutdown_and_start_stop_races_are_bounded_backend_owned +pre_012_hostile_ipc_is_shape_strict_bounded_and_safely_projected +pre_012_race_shutdown_and_ipc_hardening_adds_no_production_module_or_lower_layer_growth +``` + +Le canari de race Start/Stop vérifie aussi que le rollback d'une réservation déjà stoppée retient un terminal logique `Stopped`. + +## Frontières inchangées + +Aucune modification de : + +```text +ksp-config-lib +ksp-onchain-transport-lib +ksp-store-lib / backend Store +ksp-worker-raw-transaction-ingest-lib +ksp-raw-transaction-lib +pipeline RAW / replay / repair +``` + +Le module Rust de production du Desk reste à `16` modules et aucun nouvel edge lower-layer n'est introduit. + +## Version + +```text +workspace.package.version : 0.3.15-pre.12 +root Cargo header counter : 622 +``` + +## Validation d'assemblage + +Exécuté dans l'environnement d'assemblage : + +```text +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.15 +canaris statiques pre.012 de surface/race/IPC +TypeScript auxiliary no-emit du frontend_log avec bindings temporaires +``` + +Cargo/rustfmt ne sont pas disponibles dans cet environnement ; aucun gate Cargo de `pre.012` n'est déclaré PASS ici. + +Le gate opérateur attendu reste : + +```bash +cargo fmt --all +cargo fmt --all -- --check +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.15 +cargo check --workspace +cargo clippy --workspace --all-targets --all-features -- -D warnings +cargo test -p ksp-app-raw-transaction-ingest-desk --all-targets --all-features +cargo test -p ksp-worker-raw-transaction-ingest-lib --all-targets --all-features +cargo test --workspace --all-targets --all-features +(cd crates/ksp-app-raw-transaction-ingest-desk && cargo tauri dev) +``` + +Aucun `npm run build` manuel n'est demandé. + +## Inventaire exact du delta + +Ajout : + +```text +deltas/0.3.15/pre.012.md +``` + +Modifications : + +```text +Cargo.toml +crates/ksp-app-raw-transaction-ingest-desk/README.md +crates/ksp-app-raw-transaction-ingest-desk/USAGE.md +crates/ksp-app-raw-transaction-ingest-desk/frontend/ts/frontend_log.ts +crates/ksp-app-raw-transaction-ingest-desk/src/app_state.rs +crates/ksp-app-raw-transaction-ingest-desk/src/dto_route.rs +crates/ksp-app-raw-transaction-ingest-desk/src/errors.rs +crates/ksp-app-raw-transaction-ingest-desk/src/frontend_logging.rs +crates/ksp-app-raw-transaction-ingest-desk/src/lib.rs +crates/ksp-app-raw-transaction-ingest-desk/src/route_runtime.rs +crates/ksp-app-raw-transaction-ingest-desk/src/route_start.rs +crates/ksp-app-raw-transaction-ingest-desk/src/tauri.rs +crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_contract.rs +crates/ksp-app-raw-transaction-ingest-desk/tests/desktop_security.rs +crates/ksp-app-raw-transaction-ingest-desk/tests/release_completeness.rs +crates/ksp-app-raw-transaction-ingest-desk/unit_tests/dto_route.rs +crates/ksp-app-raw-transaction-ingest-desk/unit_tests/frontend_logging.rs +crates/ksp-app-raw-transaction-ingest-desk/unit_tests/route_runtime.rs +docs/plans/036-V0_3_15_RAW_TRANSACTION_INGEST_DESK_PLAN.md +docs/validation/032-V0_3_15_RAW_TRANSACTION_INGEST_DESK.md +``` + +Suppressions : aucune. diff --git a/docs/plans/036-V0_3_15_RAW_TRANSACTION_INGEST_DESK_PLAN.md b/docs/plans/036-V0_3_15_RAW_TRANSACTION_INGEST_DESK_PLAN.md index 5992274..8c60cfa 100644 --- a/docs/plans/036-V0_3_15_RAW_TRANSACTION_INGEST_DESK_PLAN.md +++ b/docs/plans/036-V0_3_15_RAW_TRANSACTION_INGEST_DESK_PLAN.md @@ -1,5 +1,5 @@ - + # Plan v0.3.15 — Raw Transaction Ingest Desk @@ -835,7 +835,15 @@ Budget cible : une tranche frontend. ### `pre.012` — races / shutdown / sécurité IPC -Durcir stale inventory, Start/Stop concurrents, fermeture application, joins bornés, erreurs sûres, surfaces IPC et scénarios hostiles. +**État : implémenté.** Le Desk ferme désormais explicitement l'admission de nouveaux Start dès qu'une fermeture de la fenêtre principale est engagée. La fermeture Tauri est interceptée une seule fois, demande un Stop coopératif à toutes les routes actives, marque aussi les réservations encore en `Starting`, puis attend de façon bornée la disparition de tous les Workers et du Store partagé avant de quitter le processus. Un second événement de fermeture ne lance pas un second shutdown concurrent. + +La race Start/Stop est désormais définie : un Stop ciblé reçu pendant `Starting` marque la réservation `stop_requested` au lieu de répondre `not_active`. Si le Worker est ensuite activé, son Stop est demandé avant publication de l'acknowledgement ; si le Start échoue avant activation, le rollback conserve un terminal logique `Stopped`, ce qui permet au Stop concurrent de terminer sans inventer de Worker. Les délais de cleanup Desk sont alignés sur les drains Worker bornés afin de ne pas signaler prématurément un échec pendant un arrêt coopératif valide. + +La revalidation Start conserve la génération d'inventaire sous verrou pendant toute la reconstruction synchrone Config/Transport, puis libère explicitement ce verrou avant le premier `await`. L'admission shutdown est vérifiée avant la préparation et de nouveau atomiquement lors de la réservation runtime ; un Start déjà en vol ne peut donc pas franchir silencieusement une fermeture applicative. + +Les DTO Start/Stop refusent maintenant les champs JSON inconnus et bornent le seul identifiant libre `profile_id`. Le bridge de logging frontend refuse également les champs inconnus, borne niveau/target/message avant parsing et rejette les caractères de contrôle ; le frontend borne lui-même le message technique avant IPC avec une marge sûre vis-à-vis de l'encodage UTF-8. Les erreurs Tauri continuent à journaliser uniquement `domain/code`, jamais le payload hostile ni une ressource physique. + +Aucun module de production supplémentaire, aucune dépendance lower-layer et aucune seconde pipeline ne sont introduits. Les comportements Config, Transport, Store, Worker et Common RAW restent inchangés. Budget cible : une tranche hardening. diff --git a/docs/validation/032-V0_3_15_RAW_TRANSACTION_INGEST_DESK.md b/docs/validation/032-V0_3_15_RAW_TRANSACTION_INGEST_DESK.md index dd52ee1..ff57005 100644 --- a/docs/validation/032-V0_3_15_RAW_TRANSACTION_INGEST_DESK.md +++ b/docs/validation/032-V0_3_15_RAW_TRANSACTION_INGEST_DESK.md @@ -1,5 +1,5 @@ - + # Validation v0.3.15 — Raw Transaction Ingest Desk @@ -743,3 +743,55 @@ pre_011_frontend_supervision_closes_planned_ui_without_backend_module_growth ``` Aucune modification Config, Transport, Store, Worker ou Common RAW n'est introduite par `pre.011`. Le module Rust de production du Desk reste exactement celui de `pre.010`; la tranche suivante `pre.012` reste dédiée aux races/shutdown/sécurité IPC. + +### `pre.012` — races Start/Stop, shutdown applicatif et sécurité IPC + +Base : `0.3.15-pre.011-fix.001`, validée par le gate opérateur du 16 septembre 2026. Les audits, `cargo check --workspace`, Clippy strict, les suites Desk/Worker/workspace et le scénario live multi-route passent ; Yellowstone et HTTP Block Polling restent manipulables simultanément sous charge et le Store partagé ferme seulement après la dernière route. + +Périmètre `pre.012` : hardening du contrôle Desk uniquement, sans modification des stratégies d'acquisition. + +```text +shutdown application + CloseRequested main window intercepté et empêché temporairement + admission de nouveaux Start fermée une seule fois + Stop coopératif demandé à tous les Workers actifs + réservations Starting marquées stop_requested + attente bornée routes + Store avant app_handle.exit + second CloseRequested n'engendre aucun second shutdown + +race Start / Stop + Stop sur Starting => marque stop_requested puis attend le cleanup + activation ultérieure => request_stop avant acknowledgement + échec avant activation => rollback retient un terminal logique Stopped + cleanup Desk borné et compatible avec le drain Worker borné + +stale inventory / Start + génération sous verrou pendant la préparation synchrone + reconstruction complète Config/Transport à chaque Start + verrou libéré explicitement avant le premier await + admission shutdown revérifiée atomiquement pendant reserve + +IPC hostile + StartRequest / StopRequest : deny_unknown_fields + profile_id : non vide, <= 256 octets, sans trim implicite ni contrôle + FrontendLogPayload : deny_unknown_fields + level / target <= 16 octets + message <= 8192 octets et sans caractères de contrôle + frontend : message technique borné à 1024 code units avant IPC + erreurs Tauri : projection domain/code uniquement +``` + +Canaris ajoutés/étendus : + +```text +pre_012_route_requests_reject_unknown_fields_and_bound_free_form_profile_identity +pre_012_frontend_log_ipc_rejects_unknown_fields_and_unbounded_or_control_messages +pre_012_stop_racing_start_marks_the_reservation_for_cooperative_stop +pre_012_application_shutdown_is_one_shot_and_marks_starting_routes_before_activation +pre_012_window_shutdown_and_start_stop_races_are_bounded_backend_owned +pre_012_hostile_ipc_is_shape_strict_bounded_and_safely_projected +pre_012_race_shutdown_and_ipc_hardening_adds_no_production_module_or_lower_layer_growth +``` + +Non-claims : `pre.012` ne modifie ni Config, ni Transport, ni Store, ni `ksp-worker-raw-transaction-ingest-lib`, ni Common RAW. Il n'ajoute aucune orchestration Backfill, aucun scheduler, aucune route et aucune nouvelle donnée frontend. `pre.013` reste propriétaire de la fermeture completeness/security cross-layer. +