From 9c4469248a7a769d9a22818d9ef9c7b30f70b9f4 Mon Sep 17 00:00:00 2001 From: SinuS Von SifriduS Date: Wed, 2 Sep 2026 16:07:57 +0200 Subject: [PATCH] v0.3.7-pre.007 --- Cargo.toml | 4 +- crates/ksp-app-backfill-desk/Cargo.toml | 4 +- .../ksp-app-backfill-desk/frontend/main.html | 83 ++++- .../ksp-app-backfill-desk/frontend/ts/main.ts | 259 ++++++++++++++- crates/ksp-app-backfill-desk/src/app_state.rs | 69 +++- .../src/backfill_request.rs | 313 ++++++++++++++++++ crates/ksp-app-backfill-desk/src/constants.rs | 12 +- .../ksp-app-backfill-desk/src/dto_backfill.rs | 196 +++++++++++ .../ksp-app-backfill-desk/src/dto_common.rs | 12 +- crates/ksp-app-backfill-desk/src/errors.rs | 7 +- crates/ksp-app-backfill-desk/src/lib.rs | 36 +- crates/ksp-app-backfill-desk/src/tauri.rs | 22 +- .../src/transport_runtime.rs | 10 +- .../tests/desktop_contract.rs | 51 ++- .../tests/desktop_security.rs | 81 ++++- .../unit_tests/backfill_request.rs | 198 +++++++++++ .../unit_tests/dto_backfill.rs | 31 ++ .../unit_tests/dto_common.rs | 11 +- deltas/0.3.7/pre.007.md | 138 ++++++++ docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md | 6 +- docs/validation/024-V0_3_7_BACKFILL_DESK.md | 42 ++- 21 files changed, 1518 insertions(+), 67 deletions(-) create mode 100644 crates/ksp-app-backfill-desk/src/backfill_request.rs create mode 100644 crates/ksp-app-backfill-desk/src/dto_backfill.rs create mode 100644 crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs create mode 100644 crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs create mode 100644 deltas/0.3.7/pre.007.md diff --git a/Cargo.toml b/Cargo.toml index c2017b2..cb59462 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ # file: Cargo.toml -# version: 424 +# version: 425 [workspace] resolver = "3" members = ["crates/ksp-app-backfill-desk", "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-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-store-api", "crates/ksp-store-lib", "crates/ksp-store-postgres-lib", "crates/ksp-wallet-lib"] [workspace.package] -version = "0.3.7-pre.6.fix.1" +version = "0.3.7-pre.7" edition = "2024" license = "MIT" repository = "https://git.sasedev.com/Sasedev/khadhroony-solana-project" diff --git a/crates/ksp-app-backfill-desk/Cargo.toml b/crates/ksp-app-backfill-desk/Cargo.toml index a13c2c9..56f0730 100644 --- a/crates/ksp-app-backfill-desk/Cargo.toml +++ b/crates/ksp-app-backfill-desk/Cargo.toml @@ -1,5 +1,5 @@ # file: crates/ksp-app-backfill-desk/Cargo.toml -# version: 3 +# version: 4 [package] name = "ksp-app-backfill-desk" @@ -26,6 +26,8 @@ chrono = { workspace = true, features = ["std", "now"] } fs2.workspace = true ksp-config-lib = { path = "../ksp-config-lib" } ksp-core-lib = { path = "../ksp-core-lib" } +ksp-job-api = { path = "../ksp-job-api" } +ksp-job-backfill-lib = { path = "../ksp-job-backfill-lib" } ksp-logging-lib = { path = "../ksp-logging-lib" } ksp-onchain-transport-lib = { path = "../ksp-onchain-transport-lib" } ksp-store-lib = { path = "../ksp-store-lib" } diff --git a/crates/ksp-app-backfill-desk/frontend/main.html b/crates/ksp-app-backfill-desk/frontend/main.html index e13b2bb..a7ccb6a 100644 --- a/crates/ksp-app-backfill-desk/frontend/main.html +++ b/crates/ksp-app-backfill-desk/frontend/main.html @@ -1,5 +1,5 @@ - + @@ -47,12 +47,12 @@

Backfill

-

Scaffold desktop prêt. Les contrôles Backfill seront branchés dans les tranches runtime suivantes.

+

Mainnet par défaut, transport HTTP sélectionnable et mapping de campagne validé côté Rust avant tout démarrage de job.

-
+
Transport HTTP
@@ -71,12 +71,77 @@
-
-
- -
-

Campagne Backfill

-

Les scopes, commitments et le bouton Start seront branchés dans la tranche suivante. Le transport reste HTTP uniquement en v0.3.7.

+
+
+
Campagne Backfill
+
+
+
+
+ + +
+
+ + +
+
+ + +
+ + +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
Chaîne décimale validée côté Rust pour préserver la précision u64.
+
+
+
+ + Cette tranche ne démarre aucun job. +
+
+ +
diff --git a/crates/ksp-app-backfill-desk/frontend/ts/main.ts b/crates/ksp-app-backfill-desk/frontend/ts/main.ts index a3850eb..fccb445 100644 --- a/crates/ksp-app-backfill-desk/frontend/ts/main.ts +++ b/crates/ksp-app-backfill-desk/frontend/ts/main.ts @@ -1,10 +1,12 @@ // file: crates/ksp-app-backfill-desk/frontend/ts/main.ts -// version: 2 +// version: 3 import "bootstrap"; import ResizeObserver from "resize-observer-polyfill"; import "simplebar"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import type { BackfillRequestPreviewDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestPreviewDto.ts"; +import type { BackfillStartRequestDto } from "./bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartRequestDto.ts"; import type { BackfillDeskOptionsDto } from "./bindings/ksp_app_backfill_desk/dto_common/BackfillDeskOptionsDto.ts"; import type { BackfillHttpRouteOptionDto } from "./bindings/ksp_app_backfill_desk/dto_common/BackfillHttpRouteOptionDto.ts"; import type { ShellStatusDto } from "./bindings/ksp_app_backfill_desk/dto_common/ShellStatusDto.ts"; @@ -21,6 +23,19 @@ const viewTitles: Record = { diagnostics: "Diagnostics", }; +const commitmentLabels: Record = { + confirmed: "confirmed", + finalized: "finalized", +}; + +const scopeLabels: Record = { + latest_address: "Adresse — historique le plus récent", + before_address: "Adresse — avant une signature", + after_address: "Adresse — après une signature", + explicit_signatures: "Signatures explicites", +}; + + function isViewId(value: string | undefined): value is ViewId { return value === "backfill" || value === "diagnostics"; } @@ -79,6 +94,245 @@ function bindNavigation(): void { frontendTrace("main", "Backfill Desk navigation handlers installed"); } +function populateCodeSelect(selectId: string, values: string[], labels: Record): void { + const select = document.querySelector(`#${selectId}`); + if (!select) { + return; + } + const previous = select.value; + select.replaceChildren(); + for (const value of values) { + const option = document.createElement("option"); + option.value = value; + option.textContent = labels[value] ?? value; + select.append(option); + } + select.disabled = values.length === 0; + if (values.includes(previous)) { + select.value = previous; + } +} + +function applyNumberInputContract(inputId: string, maximum: number, defaultValue: number): void { + const input = document.querySelector(`#${inputId}`); + if (!input) { + return; + } + input.min = "1"; + input.max = maximum.toString(); + if (input.value === "") { + input.value = defaultValue.toString(); + } +} + +function renderCampaignContract(options: BackfillDeskOptionsDto): void { + populateCodeSelect("backfillScopeKind", options.scopeKinds, scopeLabels); + populateCodeSelect("backfillCommitment", options.commitments, commitmentLabels); + applyNumberInputContract("backfillPageSize", options.limits.maxPageSize, options.limits.defaultPageSize); + applyNumberInputContract("backfillMaxPages", options.limits.maxPages, options.limits.defaultMaxPages); + applyNumberInputContract("backfillMaxCandidates", options.limits.maxCandidates, options.limits.defaultMaxCandidates); + applyNumberInputContract("backfillHydrationConcurrency", options.limits.maxHydrationConcurrency, options.limits.defaultHydrationConcurrency); + const signatureHelp = document.querySelector("#backfillExplicitSignaturesHelp"); + if (signatureHelp) { + signatureHelp.textContent = `Une signature par ligne, ${options.limits.minSignatureTextBytes}–${options.limits.maxSignatureTextBytes} octets encodés ; maximum ${options.limits.maxCandidates}.`; + } + const validateButton = document.querySelector("#validateBackfillRequest"); + if (validateButton) { + validateButton.disabled = !options.compositionReady || options.httpRoutes.length === 0; + } + updateCampaignScopeFields(); + frontendTrace("main", "Backfill Desk campaign contract rendered", { + commitmentCount: options.commitments.length, + compositionReady: options.compositionReady, + scopeCount: options.scopeKinds.length, + }); +} + +function currentScopeKind(): string { + const select = document.querySelector("#backfillScopeKind"); + return select?.value ?? ""; +} + +function updateCampaignScopeFields(): void { + const scopeKind = currentScopeKind(); + const addressGroup = document.querySelector("#backfillAddressGroup"); + const anchorGroup = document.querySelector("#backfillAnchorGroup"); + const signaturesGroup = document.querySelector("#backfillExplicitSignaturesGroup"); + const minContextSlotGroup = document.querySelector("#backfillMinContextSlotGroup"); + const addressScope = scopeKind === "latest_address" || scopeKind === "before_address" || scopeKind === "after_address"; + const anchoredScope = scopeKind === "before_address" || scopeKind === "after_address"; + const explicitScope = scopeKind === "explicit_signatures"; + if (addressGroup) { + addressGroup.hidden = !addressScope; + } + if (anchorGroup) { + anchorGroup.hidden = !anchoredScope; + } + if (signaturesGroup) { + signaturesGroup.hidden = !explicitScope; + } + if (minContextSlotGroup) { + minContextSlotGroup.hidden = explicitScope; + } + frontendTrace("main", "Backfill Desk campaign scope fields updated", { scopeKind: scopeKind || "none" }); +} + +function invalidateCampaignValidation(source: "input" | "scope" | "route"): void { + const status = document.querySelector("#backfillRequestValidation"); + if (status) { + status.hidden = true; + status.textContent = ""; + status.classList.remove("alert-success", "alert-danger"); + status.classList.add("alert-secondary"); + } + const summary = document.querySelector("#backfillRequestPreview"); + if (summary) { + summary.hidden = true; + } + frontendTrace("main", "Backfill Desk campaign validation invalidated", { source }); +} + +function numericInputValue(inputId: string): number { + const input = document.querySelector(`#${inputId}`); + if (!input || !Number.isSafeInteger(input.valueAsNumber) || input.valueAsNumber < 0) { + return 0; + } + return input.valueAsNumber; +} + +function rawOptionalInput(inputId: string, enabled: boolean): string | null { + if (!enabled) { + return null; + } + const input = document.querySelector(`#${inputId}`); + if (!input || input.value === "") { + return null; + } + return input.value; +} + +function explicitSignatureLines(): string[] { + const textarea = document.querySelector("#backfillExplicitSignatures"); + if (!textarea || currentScopeKind() !== "explicit_signatures") { + return []; + } + return textarea.value.split(/\r?\n/).filter(line => line.length > 0); +} + +function buildBackfillStartRequest(): BackfillStartRequestDto { + const scopeKind = currentScopeKind(); + const role = document.querySelector("#backfillHttpRoute")?.value ?? ""; + const commitment = document.querySelector("#backfillCommitment")?.value ?? ""; + const addressScope = scopeKind === "latest_address" || scopeKind === "before_address" || scopeKind === "after_address"; + const anchoredScope = scopeKind === "before_address" || scopeKind === "after_address"; + const minContextSlotEnabled = scopeKind !== "explicit_signatures"; + return { + address: rawOptionalInput("backfillAddress", addressScope), + anchorSignature: rawOptionalInput("backfillAnchorSignature", anchoredScope), + commitment, + explicitSignatures: explicitSignatureLines(), + hydrationConcurrency: numericInputValue("backfillHydrationConcurrency"), + httpRole: role, + maxCandidates: numericInputValue("backfillMaxCandidates"), + maxPages: numericInputValue("backfillMaxPages"), + minContextSlot: rawOptionalInput("backfillMinContextSlot", minContextSlotEnabled), + pageSize: numericInputValue("backfillPageSize"), + scopeKind, + }; +} + +function renderRequestPreview(preview: BackfillRequestPreviewDto): void { + const values: Record = { + validatedNetwork: preview.network, + validatedHttpRole: preview.httpRole, + validatedCommitment: preview.commitment, + validatedScopeKind: preview.scopeKind, + validatedExplicitSignatureCount: preview.explicitSignatureCount.toString(), + validatedBounds: `${preview.pageSize} / ${preview.maxPages} / ${preview.maxCandidates} / ${preview.hydrationConcurrency}`, + validatedMinContextSlot: preview.minContextSlotPresent ? "présent" : "absent", + }; + for (const [id, value] of Object.entries(values)) { + const element = document.querySelector(`#${id}`); + if (element) { + element.textContent = value; + } + } + const summary = document.querySelector("#backfillRequestPreview"); + if (summary) { + summary.hidden = false; + } + const status = document.querySelector("#backfillRequestValidation"); + if (status) { + status.hidden = false; + status.classList.remove("alert-secondary", "alert-danger"); + status.classList.add("alert-success"); + status.textContent = "Requête valide. Aucun job n'a été démarré."; + } + frontendTrace("main", "Backfill Desk validated request preview rendered", { + commitment: preview.commitment, + httpRole: preview.httpRole, + scopeKind: preview.scopeKind, + }); +} + +function renderRequestValidationError(caughtError: unknown): void { + const status = document.querySelector("#backfillRequestValidation"); + if (!status) { + return; + } + let message = "Requête refusée par le backend."; + if (typeof caughtError === "object" && caughtError !== null) { + const candidate = caughtError as { code?: unknown; domain?: unknown; message?: unknown }; + if (typeof candidate.domain === "string" && typeof candidate.code === "string" && typeof candidate.message === "string") { + message = `${candidate.domain}/${candidate.code}: ${candidate.message}`; + } + } + status.hidden = false; + status.classList.remove("alert-secondary", "alert-success"); + status.classList.add("alert-danger"); + status.textContent = message; +} + +async function validateBackfillRequest(): Promise { + const request = buildBackfillStartRequest(); + frontendDebug("main", "Backfill Desk campaign validation requested", { + commitment: request.commitment, + httpRole: request.httpRole, + scopeKind: request.scopeKind, + }); + try { + const preview = await invokeKsp("main", "backfill_validate_request", { request }); + renderRequestPreview(preview); + frontendDebug("main", "Backfill Desk campaign validation completed", { + commitment: preview.commitment, + httpRole: preview.httpRole, + scopeKind: preview.scopeKind, + }); + } catch (caughtError) { + renderRequestValidationError(caughtError); + frontendWarn("main", "Backfill Desk campaign validation failed"); + } +} + +function bindCampaignForm(): void { + const scope = document.querySelector("#backfillScopeKind"); + if (scope) { + scope.addEventListener("change", () => { + frontendDebug("main", "Backfill Desk campaign scope changed", { scopeKind: scope.value || "none" }); + updateCampaignScopeFields(); + invalidateCampaignValidation("scope"); + }); + } + const form = document.querySelector("#backfillCampaignForm"); + if (form) { + form.addEventListener("input", () => invalidateCampaignValidation("input")); + form.addEventListener("submit", event => { + event.preventDefault(); + void validateBackfillRequest(); + }); + } + frontendTrace("main", "Backfill Desk campaign form handlers installed"); +} function routeLabel(route: BackfillHttpRouteOptionDto): string { if (route.pooled) { @@ -88,6 +342,7 @@ function routeLabel(route: BackfillHttpRouteOptionDto): string { } function renderBackfillOptions(options: BackfillDeskOptionsDto): void { + renderCampaignContract(options); const select = document.querySelector("#backfillHttpRoute"); if (select) { const previous = select.value; @@ -143,6 +398,7 @@ function bindBackfillRouteSelection(): void { } select.addEventListener("change", () => { frontendDebug("main", "Backfill Desk HTTP route selection changed", { role: select.value || "none" }); + invalidateCampaignValidation("route"); }); frontendTrace("main", "Backfill Desk HTTP route selection handler installed"); } @@ -202,6 +458,7 @@ async function initializeMain(): Promise { bindNavigation(); bindRuntimeStatusRefresh(); bindBackfillRouteSelection(); + bindCampaignForm(); activateView("backfill", "startup"); try { await Promise.all([loadRuntimeStatus("startup"), loadBackfillOptions("startup")]); diff --git a/crates/ksp-app-backfill-desk/src/app_state.rs b/crates/ksp-app-backfill-desk/src/app_state.rs index 4d356a7..ee1d589 100644 --- a/crates/ksp-app-backfill-desk/src/app_state.rs +++ b/crates/ksp-app-backfill-desk/src/app_state.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/src/app_state.rs -// version: 4 +// version: 5 //! Shared backend state owned by the Backfill Desk Tauri application. @@ -121,7 +121,7 @@ impl crate::AppState { application_version: env!("CARGO_PKG_VERSION").to_owned(), config_document_count: document_count, fallback_logging_active: runtime.fallback_active, - shell_phase: "pre.006-mainnet-http-routing".to_owned(), + shell_phase: "pre.007-request-mapping".to_owned(), startup_diagnostic: runtime.startup_diagnostic.clone(), }); } @@ -131,17 +131,27 @@ impl crate::AppState { let runtime = self.transport_runtime.as_ref(); let options = match runtime { std::option::Option::Some(value) => value.options(), - std::option::Option::None => std::result::Result::Ok(crate::BackfillDeskOptionsDto { - http_routes: std::vec::Vec::new(), - composition_ready: false, - configured_networks: std::vec::Vec::new(), - network_coherent: false, - store_diagnostic: std::option::Option::None, - store_network: std::option::Option::None, - store_ready: false, - transport_diagnostic: self.transport_startup_diagnostic.clone(), - transport_ready: false, - }), + std::option::Option::None => { + let limits = crate::backfill_request_limits(); + let limits = match limits { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + std::result::Result::Ok(crate::BackfillDeskOptionsDto { + commitments: crate::backfill_commitment_codes(), + composition_ready: false, + configured_networks: std::vec::Vec::new(), + http_routes: std::vec::Vec::new(), + limits, + network_coherent: false, + scope_kinds: crate::backfill_scope_kind_codes(), + store_diagnostic: std::option::Option::None, + store_network: std::option::Option::None, + store_ready: false, + transport_diagnostic: self.transport_startup_diagnostic.clone(), + transport_ready: false, + }) + }, }; let mut options = match options { std::result::Result::Ok(value) => value, @@ -151,6 +161,39 @@ impl crate::AppState { return std::result::Result::Ok(options); } + /// Validates and maps one frontend campaign request without starting a Backfill Job. + pub(crate) fn validate_backfill_request(&self, request: crate::BackfillStartRequestDto) -> ksp_core_lib::Result { + let options = self.backfill_options(); + let options = match options { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let job_id = ksp_job_api::JobId::new("backfill-desk-validation"); + let job_id = match job_id { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let mapped = crate::map_backfill_request(request, &options, job_id); + let mapped = match mapped { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let preview = crate::project_backfill_request(&mapped); + let preview = match preview { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + ksp_logging_lib::debug!( + target: crate::TRACING_TARGET, + domain = crate::TRACING_DOMAIN_REQUEST, + commitment = preview.commitment.as_str(), + http_role = preview.http_role.as_str(), + scope_kind = preview.scope_kind.as_str(), + "validated Backfill Desk campaign request without starting a Job" + ); + return std::result::Result::Ok(preview); + } + /// Marks graceful application shutdown as started and reports whether this caller won the one-shot transition. pub(crate) fn begin_shutdown(&self) -> bool { return self.shutdown_started.compare_exchange(false, true, std::sync::atomic::Ordering::AcqRel, std::sync::atomic::Ordering::Acquire).is_ok(); diff --git a/crates/ksp-app-backfill-desk/src/backfill_request.rs b/crates/ksp-app-backfill-desk/src/backfill_request.rs new file mode 100644 index 0000000..c8023b8 --- /dev/null +++ b/crates/ksp-app-backfill-desk/src/backfill_request.rs @@ -0,0 +1,313 @@ +// file: crates/ksp-app-backfill-desk/src/backfill_request.rs +// version: 1 + +//! Strict application mapping from frontend campaign DTOs to the KSP Backfill request contract. + +/// Maps one app-owned campaign request to a validated KSP Backfill request without starting a Job. +pub(crate) fn map_backfill_request( + input: crate::BackfillStartRequestDto, + options: &crate::BackfillDeskOptionsDto, + job_id: ksp_job_api::JobId, +) -> ksp_core_lib::Result { + let composition = validate_composition(options); + if let std::result::Result::Err(error) = composition { + return std::result::Result::Err(error); + } + let network = request_network(options); + let network = match network { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let role = request_role(input.http_role.as_str(), options); + let role = match role { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let commitment = request_commitment(input.commitment.as_str()); + let commitment = match commitment { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let min_context_slot = parse_min_context_slot(input.min_context_slot.as_deref()); + let min_context_slot = match min_context_slot { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let scope = request_scope(&input); + let scope = match scope { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let page_size = usize::try_from(input.page_size); + let page_size = match page_size { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("page_size", error)), + }; + let max_pages = usize::try_from(input.max_pages); + let max_pages = match max_pages { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_pages", error)), + }; + let max_candidates = usize::try_from(input.max_candidates); + let max_candidates = match max_candidates { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_candidates", error)), + }; + let hydration_concurrency = usize::try_from(input.hydration_concurrency); + let hydration_concurrency = match hydration_concurrency { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("hydration_concurrency", error)), + }; + return ksp_job_backfill_lib::BackfillRequest::new( + job_id, + network, + role, + commitment, + scope, + page_size, + max_pages, + max_candidates, + hydration_concurrency, + min_context_slot, + ); +} + +/// Builds a safe request preview from one already validated KSP Backfill request. +pub(crate) fn project_backfill_request(request: &ksp_job_backfill_lib::BackfillRequest) -> ksp_core_lib::Result { + let explicit_signature_count = match request.scope().signatures() { + std::option::Option::Some(signatures) => u32::try_from(signatures.len()), + std::option::Option::None => std::result::Result::Ok(0), + }; + let explicit_signature_count = match explicit_signature_count { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("explicit_signature_count", error)), + }; + let hydration_concurrency = u32::try_from(request.hydration_concurrency()); + let hydration_concurrency = match hydration_concurrency { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("hydration_concurrency", error)), + }; + let max_candidates = u32::try_from(request.max_candidates()); + let max_candidates = match max_candidates { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_candidates", error)), + }; + let max_pages = u32::try_from(request.max_pages()); + let max_pages = match max_pages { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("max_pages", error)), + }; + let page_size = u32::try_from(request.page_size()); + let page_size = match page_size { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(request_conversion_error("page_size", error)), + }; + return std::result::Result::Ok(crate::BackfillRequestPreviewDto { + address_present: request.scope().address().is_some(), + anchor_present: request.scope().anchor().is_some(), + commitment: request.commitment().code().to_owned(), + explicit_signature_count, + hydration_concurrency, + http_role: request.role().as_str().to_owned(), + max_candidates, + max_pages, + min_context_slot_present: request.min_context_slot().is_some(), + network: request.network().as_str().to_owned(), + page_size, + scope_kind: request.scope().kind().code().to_owned(), + }); +} + +fn parse_min_context_slot(value: std::option::Option<&str>) -> ksp_core_lib::Result> { + let value = match value { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Ok(std::option::Option::None), + }; + if value.is_empty() || value.trim() != value { + return std::result::Result::Err(request_field_error("min_context_slot")); + } + let parsed = value.parse::(); + return match parsed { + std::result::Result::Ok(value) => std::result::Result::Ok(std::option::Option::Some(value)), + std::result::Result::Err(error) => std::result::Result::Err(request_field_error("min_context_slot").with_source(error)), + }; +} + +fn parse_pubkey(value: &str, field: &'static str) -> ksp_core_lib::Result { + if value.is_empty() || value.trim() != value { + return std::result::Result::Err(request_field_error(field)); + } + let parsed = value.parse::(); + return match parsed { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(request_field_error(field).with_source(error)), + }; +} + +fn parse_signature(value: &str, field: &'static str) -> ksp_core_lib::Result { + if value.is_empty() || value.trim() != value { + return std::result::Result::Err(request_field_error(field)); + } + let parsed = ksp_job_backfill_lib::BackfillSignature::new(value.to_owned()); + return match parsed { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(request_field_error(field).with_source(error)), + }; +} + +fn request_commitment(value: &str) -> ksp_core_lib::Result { + return match value { + "confirmed" => std::result::Result::Ok(ksp_job_backfill_lib::BackfillCommitment::Confirmed), + "finalized" => std::result::Result::Ok(ksp_job_backfill_lib::BackfillCommitment::Finalized), + _ => std::result::Result::Err(request_field_error("commitment")), + }; +} + +fn request_conversion_error(field: &'static str, source: impl std::error::Error + Send + Sync + 'static) -> ksp_core_lib::Error { + return request_field_error(field).with_source(source); +} + +fn request_field_error(field: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_BACKFILL_REQUEST_INVALID, "Backfill Desk campaign request is invalid").with_context("field", field); +} + +fn request_network(options: &crate::BackfillDeskOptionsDto) -> ksp_core_lib::Result { + let network = options.store_network.as_deref(); + let network = match network { + std::option::Option::Some(value) => value, + std::option::Option::None => return std::result::Result::Err(request_field_error("network")), + }; + let parsed = ksp_store_lib::RawNetworkId::new(network.to_owned()); + return match parsed { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(request_field_error("network").with_source(error)), + }; +} + +fn request_role(value: &str, options: &crate::BackfillDeskOptionsDto) -> ksp_core_lib::Result { + let admitted = options.http_routes.iter().any(|route| return route.role == value); + if !admitted || value.is_empty() || value.trim() != value { + return std::result::Result::Err(request_field_error("http_role")); + } + return std::result::Result::Ok(ksp_onchain_transport_lib::HttpRoleName::new(value.to_owned())); +} + +fn request_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { + return match input.scope_kind.as_str() { + "latest_address" => latest_address_scope(input), + "before_address" => before_address_scope(input), + "after_address" => after_address_scope(input), + "explicit_signatures" => explicit_signatures_scope(input), + _ => std::result::Result::Err(request_field_error("scope_kind")), + }; +} + +fn latest_address_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { + let shape = require_no_anchor_or_signatures(input); + if let std::result::Result::Err(error) = shape { + return std::result::Result::Err(error); + } + let address = required_address(input.address.as_deref()); + let address = match address { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(ksp_job_backfill_lib::BackfillScope::latest_address(address)); +} + +fn before_address_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { + let shape = require_no_explicit_signatures(input); + if let std::result::Result::Err(error) = shape { + return std::result::Result::Err(error); + } + let address = required_address(input.address.as_deref()); + let address = match address { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let anchor = required_anchor(input.anchor_signature.as_deref()); + let anchor = match anchor { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(ksp_job_backfill_lib::BackfillScope::before_address(address, anchor)); +} + +fn after_address_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { + let shape = require_no_explicit_signatures(input); + if let std::result::Result::Err(error) = shape { + return std::result::Result::Err(error); + } + let address = required_address(input.address.as_deref()); + let address = match address { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let anchor = required_anchor(input.anchor_signature.as_deref()); + let anchor = match anchor { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + return std::result::Result::Ok(ksp_job_backfill_lib::BackfillScope::after_address(address, anchor)); +} + +fn explicit_signatures_scope(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result { + if input.address.is_some() || input.anchor_signature.is_some() || input.min_context_slot.is_some() { + return std::result::Result::Err(request_field_error("scope")); + } + if input.explicit_signatures.is_empty() || input.explicit_signatures.len() > ksp_job_backfill_lib::MAX_BACKFILL_CANDIDATES { + return std::result::Result::Err(request_field_error("explicit_signatures")); + } + let mut signatures = std::vec::Vec::with_capacity(input.explicit_signatures.len()); + for value in &input.explicit_signatures { + let signature = parse_signature(value.as_str(), "explicit_signatures"); + let signature = match signature { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + signatures.push(signature); + } + return ksp_job_backfill_lib::BackfillScope::explicit_signatures(signatures); +} + +fn require_no_anchor_or_signatures(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<()> { + if input.anchor_signature.is_some() || !input.explicit_signatures.is_empty() { + return std::result::Result::Err(request_field_error("scope")); + } + return std::result::Result::Ok(()); +} + +fn require_no_explicit_signatures(input: &crate::BackfillStartRequestDto) -> ksp_core_lib::Result<()> { + if !input.explicit_signatures.is_empty() { + return std::result::Result::Err(request_field_error("explicit_signatures")); + } + return std::result::Result::Ok(()); +} + +fn required_address(value: std::option::Option<&str>) -> ksp_core_lib::Result { + return match value { + std::option::Option::Some(value) => parse_pubkey(value, "address"), + std::option::Option::None => std::result::Result::Err(request_field_error("address")), + }; +} + +fn required_anchor(value: std::option::Option<&str>) -> ksp_core_lib::Result { + return match value { + std::option::Option::Some(value) => parse_signature(value, "anchor_signature"), + std::option::Option::None => std::result::Result::Err(request_field_error("anchor_signature")), + }; +} + +fn validate_composition(options: &crate::BackfillDeskOptionsDto) -> ksp_core_lib::Result<()> { + if options.composition_ready && options.transport_ready && options.store_ready && options.network_coherent { + return std::result::Result::Ok(()); + } + return std::result::Result::Err(ksp_core_lib::Error::new( + crate::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY, + "Backfill Desk cannot admit a campaign before Transport and Store composition is ready", + )); +} + +#[cfg(test)] +#[path = "../unit_tests/backfill_request.rs"] +mod tests; diff --git a/crates/ksp-app-backfill-desk/src/constants.rs b/crates/ksp-app-backfill-desk/src/constants.rs index 8f9f9a5..c22aa1c 100644 --- a/crates/ksp-app-backfill-desk/src/constants.rs +++ b/crates/ksp-app-backfill-desk/src/constants.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/src/constants.rs -// version: 4 +// version: 5 //! Application-owned tracing targets and domains. @@ -9,10 +9,20 @@ pub(crate) const COMPOSITE_COMPONENT_ID_LOGGING: &str = "logging"; pub(crate) const COMPOSITE_COMPONENT_ID_STORE: &str = "store"; /// Composite component identifier for Transport. pub(crate) const COMPOSITE_COMPONENT_ID_TRANSPORT: &str = "transport"; +/// Initial hydration concurrency proposed by the Desk for a new campaign. +pub(crate) const DEFAULT_BACKFILL_HYDRATION_CONCURRENCY: u32 = 4; +/// Initial candidate cap proposed by the Desk for a new campaign. +pub(crate) const DEFAULT_BACKFILL_MAX_CANDIDATES: u32 = 1_000; +/// Initial discovery-page cap proposed by the Desk for a new campaign. +pub(crate) const DEFAULT_BACKFILL_MAX_PAGES: u32 = 10; +/// Initial signature page size proposed by the Desk for a new campaign. +pub(crate) const DEFAULT_BACKFILL_PAGE_SIZE: u32 = 100; /// Structured domain used while bootstrapping Config and Logging. pub(crate) const TRACING_DOMAIN_BOOTSTRAP: &str = "backfill.bootstrap"; /// Structured domain used by technical frontend events. pub(crate) const TRACING_DOMAIN_FRONTEND: &str = "frontend"; +/// Structured domain used by Backfill campaign admission and request mapping. +pub(crate) const TRACING_DOMAIN_REQUEST: &str = "backfill.request"; /// Structured domain used by the Backfill Desk shell. pub(crate) const TRACING_DOMAIN_SHELL: &str = "backfill.shell"; /// Structured domain used by Store readiness and shutdown operations. diff --git a/crates/ksp-app-backfill-desk/src/dto_backfill.rs b/crates/ksp-app-backfill-desk/src/dto_backfill.rs new file mode 100644 index 0000000..86d4729 --- /dev/null +++ b/crates/ksp-app-backfill-desk/src/dto_backfill.rs @@ -0,0 +1,196 @@ +// file: crates/ksp-app-backfill-desk/src/dto_backfill.rs +// version: 1 + +//! Application-owned Backfill campaign DTOs and backend-derived request limits. + +use ts_rs::TS; // rust-rules: trait-import + +/// Backend-derived limits and application defaults rendered by the Backfill campaign form. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestLimitsDto.ts")] +pub(crate) struct BackfillRequestLimitsDto { + /// Initial hydration concurrency proposed by this Desk while remaining below the Job-owned maximum. + pub(crate) default_hydration_concurrency: u32, + /// Initial candidate cap proposed by this Desk while remaining below the Job-owned maximum. + pub(crate) default_max_candidates: u32, + /// Initial discovery-page cap proposed by this Desk while remaining below the Job-owned maximum. + pub(crate) default_max_pages: u32, + /// Initial signature page size proposed by this Desk while remaining below the Job-owned maximum. + pub(crate) default_page_size: u32, + /// Maximum hydration concurrency owned by `ksp-job-backfill-lib`. + pub(crate) max_hydration_concurrency: u32, + /// Maximum candidate count owned by `ksp-job-backfill-lib`. + pub(crate) max_candidates: u32, + /// Maximum discovery-page count owned by `ksp-job-backfill-lib`. + pub(crate) max_pages: u32, + /// Maximum signature page size owned by `ksp-job-backfill-lib`. + pub(crate) max_page_size: u32, + /// Maximum encoded transaction-signature text length owned by `ksp-job-backfill-lib`. + pub(crate) max_signature_text_bytes: u32, + /// Minimum encoded transaction-signature text length owned by `ksp-job-backfill-lib`. + pub(crate) min_signature_text_bytes: u32, +} + +/// App-owned request received from the Backfill campaign form before a Job is started. +/// +/// Network and physical endpoint information are intentionally absent. The backend derives the +/// network from the configured Store and maps `http_role` only after checking the current safe +/// Transport inventory. +#[derive(serde::Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillStartRequestDto.ts")] +pub(crate) struct BackfillStartRequestDto { + /// Optional address text used only by address-based scopes. + pub(crate) address: std::option::Option, + /// Optional exclusive anchor signature used only by Before/After scopes. + pub(crate) anchor_signature: std::option::Option, + /// Stable Backfill commitment code. + pub(crate) commitment: String, + /// Explicit signature texts used only by the explicit-signatures scope. + pub(crate) explicit_signatures: std::vec::Vec, + /// Maximum number of concurrent candidate hydrations. + pub(crate) hydration_concurrency: u32, + /// Logical HTTP role selected from `BackfillDeskOptionsDto::http_routes`. + pub(crate) http_role: String, + /// Maximum number of candidates admitted by the campaign. + pub(crate) max_candidates: u32, + /// Maximum number of address-discovery pages admitted by the campaign. + pub(crate) max_pages: u32, + /// Optional minimum context slot encoded as decimal text to avoid JavaScript integer precision loss. + pub(crate) min_context_slot: std::option::Option, + /// Signature page size used by address discovery. + pub(crate) page_size: u32, + /// Stable Backfill scope kind code. + pub(crate) scope_kind: String, +} + +/// Safe projection proving that one app request mapped to the KSP Backfill contract. +/// +/// Address and signature values are intentionally reduced to presence/count metadata. This DTO is +/// suitable for request validation feedback before the runtime Start slice exists. +#[derive(Clone, Debug, serde::Serialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_backfill/BackfillRequestPreviewDto.ts")] +pub(crate) struct BackfillRequestPreviewDto { + /// Whether an address participates in the validated scope. + pub(crate) address_present: bool, + /// Whether an exclusive anchor participates in the validated scope. + pub(crate) anchor_present: bool, + /// Stable validated commitment code. + pub(crate) commitment: String, + /// Number of explicit signatures after Backfill-owned stable deduplication. + pub(crate) explicit_signature_count: u32, + /// Validated hydration concurrency. + pub(crate) hydration_concurrency: u32, + /// Validated logical HTTP role. + pub(crate) http_role: String, + /// Validated maximum candidate count. + pub(crate) max_candidates: u32, + /// Validated maximum discovery-page count. + pub(crate) max_pages: u32, + /// Whether a minimum context slot participates in the validated request. + pub(crate) min_context_slot_present: bool, + /// Backend-derived logical Store network. + pub(crate) network: String, + /// Validated signature page size. + pub(crate) page_size: u32, + /// Stable validated scope kind code. + pub(crate) scope_kind: String, +} + +/// Returns the exact commitment codes currently admitted by the Backfill runtime. +#[must_use] +pub(crate) fn backfill_commitment_codes() -> std::vec::Vec { + return vec![ksp_job_backfill_lib::BackfillCommitment::Finalized.code().to_owned(), ksp_job_backfill_lib::BackfillCommitment::Confirmed.code().to_owned()]; +} + +/// Returns the exact HTTP scope codes currently admitted by the Backfill runtime. +#[must_use] +pub(crate) fn backfill_scope_kind_codes() -> std::vec::Vec { + return vec![ + ksp_job_backfill_lib::BackfillScopeKind::LatestAddress.code().to_owned(), + ksp_job_backfill_lib::BackfillScopeKind::BeforeAddress.code().to_owned(), + ksp_job_backfill_lib::BackfillScopeKind::AfterAddress.code().to_owned(), + ksp_job_backfill_lib::BackfillScopeKind::ExplicitSignatures.code().to_owned(), + ]; +} + +/// Builds frontend-safe request limits directly from the public Backfill constants. +pub(crate) fn backfill_request_limits() -> ksp_core_lib::Result { + let max_hydration_concurrency = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_HYDRATION_CONCURRENCY, "max_hydration_concurrency"); + let max_hydration_concurrency = match max_hydration_concurrency { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let max_candidates = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_CANDIDATES, "max_candidates"); + let max_candidates = match max_candidates { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let max_pages = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_PAGES, "max_pages"); + let max_pages = match max_pages { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let max_page_size = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_PAGE_SIZE, "max_page_size"); + let max_page_size = match max_page_size { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let max_signature_text_bytes = usize_to_u32(ksp_job_backfill_lib::MAX_BACKFILL_SIGNATURE_TEXT_BYTES, "max_signature_text_bytes"); + let max_signature_text_bytes = match max_signature_text_bytes { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + let min_signature_text_bytes = usize_to_u32(ksp_job_backfill_lib::MIN_BACKFILL_SIGNATURE_TEXT_BYTES, "min_signature_text_bytes"); + let min_signature_text_bytes = match min_signature_text_bytes { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; + if crate::DEFAULT_BACKFILL_HYDRATION_CONCURRENCY == 0 || crate::DEFAULT_BACKFILL_HYDRATION_CONCURRENCY > max_hydration_concurrency { + return std::result::Result::Err(limit_contract_error("default_hydration_concurrency")); + } + if crate::DEFAULT_BACKFILL_MAX_CANDIDATES == 0 || crate::DEFAULT_BACKFILL_MAX_CANDIDATES > max_candidates { + return std::result::Result::Err(limit_contract_error("default_max_candidates")); + } + if crate::DEFAULT_BACKFILL_MAX_PAGES == 0 || crate::DEFAULT_BACKFILL_MAX_PAGES > max_pages { + return std::result::Result::Err(limit_contract_error("default_max_pages")); + } + if crate::DEFAULT_BACKFILL_PAGE_SIZE == 0 || crate::DEFAULT_BACKFILL_PAGE_SIZE > max_page_size { + return std::result::Result::Err(limit_contract_error("default_page_size")); + } + return std::result::Result::Ok(BackfillRequestLimitsDto { + default_hydration_concurrency: crate::DEFAULT_BACKFILL_HYDRATION_CONCURRENCY, + default_max_candidates: crate::DEFAULT_BACKFILL_MAX_CANDIDATES, + default_max_pages: crate::DEFAULT_BACKFILL_MAX_PAGES, + default_page_size: crate::DEFAULT_BACKFILL_PAGE_SIZE, + max_hydration_concurrency, + max_candidates, + max_pages, + max_page_size, + max_signature_text_bytes, + min_signature_text_bytes, + }); +} + +fn limit_contract_error(field: &'static str) -> ksp_core_lib::Error { + return ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Backfill Desk request-limit projection is internally inconsistent") + .with_context("field", field); +} + +fn usize_to_u32(value: usize, field: &'static str) -> ksp_core_lib::Result { + let converted = u32::try_from(value); + return match converted { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err( + ksp_core_lib::Error::new(crate::ERROR_CODE_APP_STATE_INVALID, "Backfill Desk cannot project a Job-owned bound to the frontend") + .with_context("field", field) + .with_source(error), + ), + }; +} + +#[cfg(test)] +#[path = "../unit_tests/dto_backfill.rs"] +mod tests; diff --git a/crates/ksp-app-backfill-desk/src/dto_common.rs b/crates/ksp-app-backfill-desk/src/dto_common.rs index 03d4b47..980d2b9 100644 --- a/crates/ksp-app-backfill-desk/src/dto_common.rs +++ b/crates/ksp-app-backfill-desk/src/dto_common.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/src/dto_common.rs -// version: 4 +// version: 5 //! Common Tauri DTOs shared by the Backfill Desk shell. @@ -21,15 +21,19 @@ pub(crate) struct BackfillHttpRouteOptionDto { pub(crate) role: String, } -/// Safe Transport-and-Store readiness subset of the Backfill Desk options contract. +/// Safe Backfill Desk options contract combining readiness, HTTP routes and Job-owned campaign bounds. /// -/// Campaign scopes, commitments and backend-owned bounds are added in the dedicated request/DTO slice. Endpoint URLs and credentials are intentionally absent. +/// Endpoint URLs, credentials and physical routing details are intentionally absent. #[derive(Clone, Debug, serde::Serialize, TS)] #[serde(rename_all = "camelCase")] #[ts(export, export_to = "../frontend/ts/bindings/ksp_app_backfill_desk/dto_common/BackfillDeskOptionsDto.ts")] pub(crate) struct BackfillDeskOptionsDto { + /// Stable commitment codes currently admitted by `ksp-job-backfill-lib`. + pub(crate) commitments: std::vec::Vec, /// Selectable logical HTTP routes that support both RPC methods required by the Backfill runtime. pub(crate) http_routes: std::vec::Vec, + /// Backend-owned maxima plus application defaults used by the campaign form. + pub(crate) limits: crate::BackfillRequestLimitsDto, /// Whether the Transport and Store readiness gates jointly permit later Backfill composition. pub(crate) composition_ready: bool, /// Distinct enabled HTTP cluster labels selected by the active Transport profile. @@ -44,6 +48,8 @@ pub(crate) struct BackfillDeskOptionsDto { pub(crate) store_ready: bool, /// Safe startup diagnostic when Transport configuration could not be resolved or constructed. pub(crate) transport_diagnostic: std::option::Option, + /// Stable HTTP scope codes currently admitted by `ksp-job-backfill-lib`. + pub(crate) scope_kinds: std::vec::Vec, /// Whether Transport currently has one coherent network and at least one compatible available role. pub(crate) transport_ready: bool, } diff --git a/crates/ksp-app-backfill-desk/src/errors.rs b/crates/ksp-app-backfill-desk/src/errors.rs index d386eff..71f7c08 100644 --- a/crates/ksp-app-backfill-desk/src/errors.rs +++ b/crates/ksp-app-backfill-desk/src/errors.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/src/errors.rs -// version: 5 +// version: 6 //! Application-local error codes for Backfill Desk composition and desktop runtime surfaces. @@ -7,6 +7,11 @@ pub(crate) const ERROR_CODE_APP_STATE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "app_state_invalid"); /// Shared Backfill Desk runtime state cannot be locked safely. pub(crate) const ERROR_CODE_APP_STATE_LOCK_FAILED: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "app_state_lock_failed"); +/// Backfill Desk cannot admit a campaign before Transport and Store composition is ready. +pub(crate) const ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY: ksp_core_lib::ErrorCode = + ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_composition_not_ready"); +/// Backfill Desk received a campaign field or logical route that cannot map to the Backfill contract. +pub(crate) const ERROR_CODE_BACKFILL_REQUEST_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "backfill_request_invalid"); /// Backfill Desk composite configuration is missing or references an unexpected document. pub(crate) const ERROR_CODE_CONFIG_COMPOSITE_INVALID: ksp_core_lib::ErrorCode = ksp_core_lib::ErrorCode::new("backfill_desk", "config_composite_invalid"); /// Frontend logging requested an unsupported level. diff --git a/crates/ksp-app-backfill-desk/src/lib.rs b/crates/ksp-app-backfill-desk/src/lib.rs index 84f76ae..3e6aeea 100644 --- a/crates/ksp-app-backfill-desk/src/lib.rs +++ b/crates/ksp-app-backfill-desk/src/lib.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/src/lib.rs -// version: 5 +// version: 6 //! Tauri desktop application scaffold for controlling and inspecting KSP RAW backfill jobs. @@ -8,8 +8,10 @@ #![warn(missing_docs)] mod app_state; +mod backfill_request; mod bootstrap; mod constants; +mod dto_backfill; mod dto_common; mod errors; mod frontend_logging; @@ -26,6 +28,10 @@ pub use self::tauri::run; /// Shared Backfill Desk application state managed by Tauri. pub(crate) use self::app_state::AppState; +/// Maps one app-owned campaign DTO to the validated KSP Backfill request contract. +pub(crate) use self::backfill_request::map_backfill_request; +/// Projects one validated Backfill request without returning address or signature values. +pub(crate) use self::backfill_request::project_backfill_request; /// Crate-internal Logging startup state shared by the application state. pub(crate) use self::bootstrap::LoggingStartup; /// Builds the Config management facade from the common KSP CLI bootstrap contract. @@ -42,10 +48,20 @@ pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_LOGGING; pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_STORE; /// Composite component identifier for Transport. pub(crate) use self::constants::COMPOSITE_COMPONENT_ID_TRANSPORT; +/// Initial hydration concurrency proposed by the Backfill campaign form. +pub(crate) use self::constants::DEFAULT_BACKFILL_HYDRATION_CONCURRENCY; +/// Initial candidate cap proposed by the Backfill campaign form. +pub(crate) use self::constants::DEFAULT_BACKFILL_MAX_CANDIDATES; +/// Initial discovery-page cap proposed by the Backfill campaign form. +pub(crate) use self::constants::DEFAULT_BACKFILL_MAX_PAGES; +/// Initial signature page size proposed by the Backfill campaign form. +pub(crate) use self::constants::DEFAULT_BACKFILL_PAGE_SIZE; /// Structured domain used while bootstrapping Config and Logging. pub(crate) use self::constants::TRACING_DOMAIN_BOOTSTRAP; /// Structured domain used by technical frontend events. pub(crate) use self::constants::TRACING_DOMAIN_FRONTEND; +/// Structured domain used by Backfill campaign admission and request mapping. +pub(crate) use self::constants::TRACING_DOMAIN_REQUEST; /// Structured domain used by the Backfill Desk shell. pub(crate) use self::constants::TRACING_DOMAIN_SHELL; /// Structured domain used by Store readiness and shutdown operations. @@ -62,7 +78,19 @@ pub(crate) use self::constants::TRACING_TARGET_FRONTEND; pub(crate) use self::constants::TRACING_TARGET_FRONTEND_MAIN; /// Owning target for splash-window frontend events. pub(crate) use self::constants::TRACING_TARGET_FRONTEND_SPLASH; -/// Safe Transport-and-Store readiness subset of the Backfill Desk options contract. +/// Backend-derived request bounds and Desk defaults for the campaign form. +pub(crate) use self::dto_backfill::BackfillRequestLimitsDto; +/// Safe request preview produced after strict backend mapping. +pub(crate) use self::dto_backfill::BackfillRequestPreviewDto; +/// App-owned campaign request received from the frontend. +pub(crate) use self::dto_backfill::BackfillStartRequestDto; +/// Returns commitment codes admitted by the current Backfill contract. +pub(crate) use self::dto_backfill::backfill_commitment_codes; +/// Builds frontend-safe campaign limits from Job-owned public constants. +pub(crate) use self::dto_backfill::backfill_request_limits; +/// Returns scope kind codes admitted by the current Backfill contract. +pub(crate) use self::dto_backfill::backfill_scope_kind_codes; +/// Safe Backfill Desk options contract combining readiness and campaign metadata. pub(crate) use self::dto_common::BackfillDeskOptionsDto; /// Safe logical HTTP route exposed for operator selection. pub(crate) use self::dto_common::BackfillHttpRouteOptionDto; @@ -74,6 +102,10 @@ pub(crate) use self::dto_common::ShellStatusDto; pub(crate) use self::errors::ERROR_CODE_APP_STATE_INVALID; /// Shared Backfill Desk runtime state cannot be locked safely. pub(crate) use self::errors::ERROR_CODE_APP_STATE_LOCK_FAILED; +/// Backfill Desk cannot admit a campaign before Transport and Store composition is ready. +pub(crate) use self::errors::ERROR_CODE_BACKFILL_COMPOSITION_NOT_READY; +/// Backfill Desk received a campaign field that cannot map to the Backfill contract. +pub(crate) use self::errors::ERROR_CODE_BACKFILL_REQUEST_INVALID; /// Backfill Desk composite configuration is missing or references an unexpected document. pub(crate) use self::errors::ERROR_CODE_CONFIG_COMPOSITE_INVALID; /// Frontend logging requested an unsupported level. diff --git a/crates/ksp-app-backfill-desk/src/tauri.rs b/crates/ksp-app-backfill-desk/src/tauri.rs index eae1b32..30747d7 100644 --- a/crates/ksp-app-backfill-desk/src/tauri.rs +++ b/crates/ksp-app-backfill-desk/src/tauri.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/src/tauri.rs -// version: 4 +// version: 5 //! Tauri runtime assembly for the KSP Backfill desktop application. @@ -75,7 +75,13 @@ fn configure_plugins(builder: tauri::Builder) -> tauri::Builder) -> tauri::Builder { - return builder.invoke_handler(tauri::generate_handler![backfill_options, emit_frontend_log, get_runtime_status, splash_frontend_ready]); + return builder.invoke_handler(tauri::generate_handler![ + backfill_options, + backfill_validate_request, + emit_frontend_log, + get_runtime_status, + splash_frontend_ready + ]); } fn configure_setup(builder: tauri::Builder) -> tauri::Builder { @@ -158,6 +164,18 @@ fn backfill_options(state: tauri::State<'_, crate::AppState>) -> std::result::Re }; } +#[tauri::command] +fn backfill_validate_request( + request: crate::BackfillStartRequestDto, + state: tauri::State<'_, crate::AppState>, +) -> std::result::Result { + let result = state.validate_backfill_request(request); + return match result { + std::result::Result::Ok(value) => std::result::Result::Ok(value), + std::result::Result::Err(error) => std::result::Result::Err(project_command_error("backfill_validate_request", crate::TRACING_DOMAIN_REQUEST, &error)), + }; +} + #[tauri::command] fn emit_frontend_log(payload: crate::FrontendLogPayloadDto) -> std::result::Result<(), crate::CommandErrorDto> { let result = crate::emit_frontend_log_event(payload); diff --git a/crates/ksp-app-backfill-desk/src/transport_runtime.rs b/crates/ksp-app-backfill-desk/src/transport_runtime.rs index 95dd0bd..9dbe3da 100644 --- a/crates/ksp-app-backfill-desk/src/transport_runtime.rs +++ b/crates/ksp-app-backfill-desk/src/transport_runtime.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/src/transport_runtime.rs -// version: 3 +// version: 4 //! Composite-selected HTTP Transport readiness and route inventory owned by Backfill Desk. @@ -35,12 +35,20 @@ impl TransportRuntime { std::result::Result::Ok(value) => value, std::result::Result::Err(error) => return std::result::Result::Err(error), }; + let limits = crate::backfill_request_limits(); + let limits = match limits { + std::result::Result::Ok(value) => value, + std::result::Result::Err(error) => return std::result::Result::Err(error), + }; let transport_ready = configured_networks.len() == 1 && !http_routes.is_empty() && snapshot.available_endpoint_count() > 0; return std::result::Result::Ok(crate::BackfillDeskOptionsDto { + commitments: crate::backfill_commitment_codes(), composition_ready: false, configured_networks, http_routes, + limits, network_coherent: false, + scope_kinds: crate::backfill_scope_kind_codes(), store_diagnostic: std::option::Option::None, store_network: std::option::Option::None, store_ready: false, diff --git a/crates/ksp-app-backfill-desk/tests/desktop_contract.rs b/crates/ksp-app-backfill-desk/tests/desktop_contract.rs index d73f558..470caad 100644 --- a/crates/ksp-app-backfill-desk/tests/desktop_contract.rs +++ b/crates/ksp-app-backfill-desk/tests/desktop_contract.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/tests/desktop_contract.rs -// version: 6 +// version: 7 //! Structural desktop contract checks for the Backfill Desk scaffold. @@ -240,8 +240,6 @@ fn pre_006_mainnet_default_and_http_route_selector_are_explicit_without_start_ru for required in ["mainnet_backfill_pool", "backfill_pool", "backfill_publicnode", "backfill_solana_public", "solana-rpc.publicnode.com"] { assert!(transport.contains(required), "missing Backfill HTTP route marker {required}"); } - let app_state = read_text(root.join("src/app_state.rs").as_path()); - assert!(app_state.contains("pre.006-mainnet-http-routing")); let frontend = read_text(root.join("frontend/ts/main.ts").as_path()); assert!(frontend.contains("Backfill Desk HTTP route selection changed")); assert!(frontend.contains("backfill_options")); @@ -251,3 +249,50 @@ fn pre_006_mainnet_default_and_http_route_selector_are_explicit_without_start_ru assert!(!frontend.contains(forbidden), "pre.006 frontend opens deferred runtime marker {forbidden}"); } } + +#[test] +fn pre_007_campaign_dto_and_backend_mapping_exist_without_start_runtime() { + let root = app_root(); + let manifest = read_text(root.join("Cargo.toml").as_path()); + assert!(manifest.contains("ksp-job-api = { path = \"../ksp-job-api\" }")); + assert!(manifest.contains("ksp-job-backfill-lib = { path = \"../ksp-job-backfill-lib\" }")); + let dto = read_text(root.join("src/dto_backfill.rs").as_path()); + for required in [ + "BackfillStartRequestDto", + "BackfillRequestPreviewDto", + "MAX_BACKFILL_CANDIDATES", + "MAX_BACKFILL_HYDRATION_CONCURRENCY", + "MAX_BACKFILL_PAGES", + "MAX_BACKFILL_PAGE_SIZE", + ] { + assert!(dto.contains(required), "missing pre.007 DTO/request bound marker {required}"); + } + let mapping = read_text(root.join("src/backfill_request.rs").as_path()); + for required in ["BackfillRequest::new", "HttpRoleName::new", "latest_address", "before_address", "after_address", "explicit_signatures"] { + assert!(mapping.contains(required), "missing strict Backfill request mapping marker {required}"); + } + let tauri = read_text(root.join("src/tauri.rs").as_path()); + assert!(tauri.contains("backfill_validate_request")); + let html = read_text(root.join("frontend/main.html").as_path()); + for required in [ + "backfillCampaignForm", + "backfillScopeKind", + "backfillCommitment", + "backfillAddress", + "backfillAnchorSignature", + "backfillExplicitSignatures", + "backfillMinContextSlot", + "validateBackfillRequest", + "Valider la requête", + ] { + assert!(html.contains(required), "missing campaign form marker {required}"); + } + let frontend = read_text(root.join("frontend/ts/main.ts").as_path()); + assert!(frontend.contains("backfill_validate_request")); + assert!(frontend.contains("Backfill Desk campaign validation requested")); + assert!(frontend.contains("Aucun job n'a été démarré")); + for forbidden in ["backfill_start", "BackfillJobRuntime", "BackfillJobHandle"] { + assert!(!frontend.contains(forbidden), "pre.007 frontend opens deferred Start/runtime marker {forbidden}"); + assert!(!tauri.contains(forbidden), "pre.007 Tauri surface opens deferred Start/runtime marker {forbidden}"); + } +} diff --git a/crates/ksp-app-backfill-desk/tests/desktop_security.rs b/crates/ksp-app-backfill-desk/tests/desktop_security.rs index 1e2d279..0c1d700 100644 --- a/crates/ksp-app-backfill-desk/tests/desktop_security.rs +++ b/crates/ksp-app-backfill-desk/tests/desktop_security.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/tests/desktop_security.rs -// version: 6 +// version: 7 //! Security and dependency-boundary checks for the Backfill Desk scaffold. @@ -43,7 +43,7 @@ fn read_text(path: &std::path::Path) -> String { } #[test] -fn capability_surface_remains_core_plus_tracing_while_transport_and_store_are_backend_only() { +fn capability_surface_remains_core_plus_tracing_while_backfill_runtime_stays_backend_only() { let root = app_root(); let capability = read_text(root.join("capabilities/default.json").as_path()); assert!(capability.contains("\"core:default\"")); @@ -53,10 +53,12 @@ fn capability_surface_remains_core_plus_tracing_while_transport_and_store_are_ba } let manifest = read_text(root.join("Cargo.toml").as_path()); assert!(manifest.contains("tauri-plugin-tracing.workspace = true")); + assert!(manifest.contains("ksp-job-api = { path = \"../ksp-job-api\" }")); + assert!(manifest.contains("ksp-job-backfill-lib = { path = \"../ksp-job-backfill-lib\" }")); assert!(manifest.contains("ksp-onchain-transport-lib = { path = \"../ksp-onchain-transport-lib\" }")); assert!(manifest.contains("ksp-store-lib = { path = \"../ksp-store-lib\" }")); - for forbidden in ["ksp-job-backfill-lib", "ksp-store-api", "ksp-store-postgres-lib", "reqwest", "tokio-postgres"] { - assert!(!manifest.contains(forbidden), "current Backfill Desk opens a forbidden dependency: {forbidden}"); + for forbidden in ["ksp-store-api", "ksp-store-postgres-lib", "reqwest", "tokio-postgres", "tonic", "yellowstone-grpc"] { + assert!(!manifest.contains(forbidden), "current Backfill Desk opens a forbidden direct dependency: {forbidden}"); } } @@ -89,7 +91,7 @@ fn pre_002_tauri_commands_remain_centralized() { assert_eq!(count, 0, "{} declares a Tauri command outside tauri.rs", path.display()); } } - assert_eq!(command_count, 4); + assert_eq!(command_count, 5); } #[test] @@ -109,21 +111,23 @@ fn pre_002_frontend_instrumentation_avoids_business_or_secret_payloads() { assert!(main.contains(required)); } } - for forbidden in ["JSON.stringify(status)", "signature", "address", "apiKey", "authorization", "database_url"] { - assert!(!main.contains(forbidden)); + for forbidden in [ + "JSON.stringify(status)", + "apiKey", + "authorization", + "database_url", + "campaign validation requested\", {\n address", + "campaign validation requested\", {\n anchorSignature", + "campaign validation requested\", {\n explicitSignatures", + "campaign validation requested\", {\n minContextSlot", + ] { + assert!(!main.contains(forbidden), "frontend tracing includes forbidden request payload marker {forbidden}"); } } #[test] -fn pre_005_opens_store_facade_without_backend_or_job_dependencies() { +fn pre_005_store_runtime_remains_backend_neutral_after_job_mapping_opens() { let root = app_root(); - let manifest = read_text(root.join("Cargo.toml").as_path()); - assert!(manifest.contains("ksp-config-lib = { path = \"../ksp-config-lib\" }")); - assert!(manifest.contains("ksp-onchain-transport-lib = { path = \"../ksp-onchain-transport-lib\" }")); - assert!(manifest.contains("ksp-store-lib = { path = \"../ksp-store-lib\" }")); - for forbidden in ["ksp-job-backfill-lib", "ksp-store-api", "ksp-store-postgres-lib", "reqwest", "tokio-postgres"] { - assert!(!manifest.contains(forbidden), "pre.005 opens a forbidden direct dependency: {forbidden}"); - } let bootstrap = read_text(root.join("src/bootstrap.rs").as_path()); assert!(bootstrap.contains("LogFilterLevel::Trace")); assert!(!bootstrap.contains("HttpTransportPool")); @@ -132,8 +136,8 @@ fn pre_005_opens_store_facade_without_backend_or_job_dependencies() { assert!(store.contains("ksp_store_lib::Store::open")); assert!(store.contains("store.health().await")); assert!(store.contains("store.close().await")); - for forbidden in ["connection_uri", "postgres", "database_url", "provider()", "endpoint_url"] { - assert!(!store.contains(forbidden), "Store runtime leaks or depends on forbidden physical metadata marker {forbidden}"); + for forbidden in ["ksp_job_", "BackfillRequest", "connection_uri", "postgres", "database_url", "provider()", "endpoint_url"] { + assert!(!store.contains(forbidden), "Store runtime leaks or absorbs another responsibility: {forbidden}"); } let dto = read_text(root.join("src/dto_common.rs").as_path()); assert!(dto.contains("BackfillDeskOptionsDto")); @@ -157,3 +161,46 @@ fn pre_006_frontend_receives_only_safe_http_route_metadata_and_no_endpoint_mater assert!(!frontend.contains(forbidden), "frontend embeds forbidden provider material marker {forbidden}"); } } + +#[test] +fn pre_007_request_mapping_keeps_network_endpoint_and_payload_secrets_backend_owned() { + let root = app_root(); + let dto = read_text(root.join("src/dto_backfill.rs").as_path()); + let request_start = dto.find("pub(crate) struct BackfillStartRequestDto"); + let preview_start = dto.find("pub(crate) struct BackfillRequestPreviewDto"); + assert!(request_start.is_some()); + assert!(preview_start.is_some()); + let request_source = match (request_start, preview_start) { + (std::option::Option::Some(start), std::option::Option::Some(end)) if start < end => &dto[start..end], + _ => "", + }; + for forbidden in [ + "pub(crate) network:", + "pub(crate) provider", + "pub(crate) endpoint", + "pub(crate) url", + "pub(crate) job_id", + "pub(crate) credential", + "pub(crate) token", + ] { + assert!(!request_source.contains(forbidden), "frontend request DTO owns forbidden field marker {forbidden}"); + } + let mapping = read_text(root.join("src/backfill_request.rs").as_path()); + assert!(mapping.contains("options.store_network.as_deref()")); + assert!(mapping.contains("options.http_routes.iter().any")); + assert!(mapping.contains("BackfillRequest::new")); + assert!(!mapping.contains("HttpTransportPool::new")); + assert!(!mapping.contains("Store::open")); + let state = read_text(root.join("src/app_state.rs").as_path()); + assert!(state.contains("backfill-desk-validation")); + assert!(state.contains("validated Backfill Desk campaign request without starting a Job")); + for forbidden in [ + "preview.address", + "preview.anchor_signature", + "preview.explicit_signatures", + "request.address.as_str()", + "request.anchor_signature.as_str()", + ] { + assert!(!state.contains(forbidden), "request validation logging includes forbidden payload marker {forbidden}"); + } +} diff --git a/crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs b/crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs new file mode 100644 index 0000000..3c8c08f --- /dev/null +++ b/crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs @@ -0,0 +1,198 @@ +// file: crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs +// version: 1 + +fn ready_options() -> crate::BackfillDeskOptionsDto { + let limits = crate::backfill_request_limits(); + assert!(limits.is_ok()); + let limits = match limits { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => crate::BackfillRequestLimitsDto { + default_hydration_concurrency: 1, + default_max_candidates: 1, + default_max_pages: 1, + default_page_size: 1, + max_hydration_concurrency: 1, + max_candidates: 1, + max_pages: 1, + max_page_size: 1, + max_signature_text_bytes: 88, + min_signature_text_bytes: 64, + }, + }; + return crate::BackfillDeskOptionsDto { + commitments: crate::backfill_commitment_codes(), + composition_ready: true, + configured_networks: vec!["mainnet-beta".to_owned()], + http_routes: vec![ + crate::BackfillHttpRouteOptionDto { + pooled: true, + providers: vec!["publicnode".to_owned(), "solana-public".to_owned()], + role: "backfill_pool".to_owned(), + }, + crate::BackfillHttpRouteOptionDto { pooled: false, providers: vec!["publicnode".to_owned()], role: "backfill_publicnode".to_owned() }, + ], + limits, + network_coherent: true, + scope_kinds: crate::backfill_scope_kind_codes(), + store_diagnostic: std::option::Option::None, + store_network: std::option::Option::Some("mainnet-beta".to_owned()), + store_ready: true, + transport_diagnostic: std::option::Option::None, + transport_ready: true, + }; +} + +fn request(scope_kind: &str) -> crate::BackfillStartRequestDto { + return crate::BackfillStartRequestDto { + address: std::option::Option::Some("11111111111111111111111111111111".to_owned()), + anchor_signature: std::option::Option::None, + commitment: "finalized".to_owned(), + explicit_signatures: std::vec::Vec::new(), + hydration_concurrency: 4, + http_role: "backfill_pool".to_owned(), + max_candidates: 100, + max_pages: 10, + min_context_slot: std::option::Option::Some("123456".to_owned()), + page_size: 100, + scope_kind: scope_kind.to_owned(), + }; +} + +fn job_id() -> std::option::Option { + let job_id = ksp_job_api::JobId::new("request-mapping-test"); + assert!(job_id.is_ok()); + return match job_id { + std::result::Result::Ok(value) => std::option::Option::Some(value), + std::result::Result::Err(_) => std::option::Option::None, + }; +} + +#[test] +fn latest_address_maps_to_backend_network_role_commitment_and_bounds() { + let job_id = job_id(); + let job_id = match job_id { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let mapped = crate::map_backfill_request(request("latest_address"), &ready_options(), job_id); + assert!(mapped.is_ok()); + let mapped = match mapped { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + assert_eq!(mapped.network().as_str(), "mainnet-beta"); + assert_eq!(mapped.role().as_str(), "backfill_pool"); + assert_eq!(mapped.commitment(), ksp_job_backfill_lib::BackfillCommitment::Finalized); + assert_eq!(mapped.scope().kind(), ksp_job_backfill_lib::BackfillScopeKind::LatestAddress); + assert_eq!(mapped.page_size(), 100); + assert_eq!(mapped.max_pages(), 10); + assert_eq!(mapped.max_candidates(), 100); + assert_eq!(mapped.hydration_concurrency(), 4); + assert_eq!(mapped.min_context_slot(), std::option::Option::Some(123456)); +} + +#[test] +fn before_and_after_scopes_require_an_exact_anchor_shape() { + for scope_kind in ["before_address", "after_address"] { + let job_id = job_id(); + let job_id = match job_id { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let mut input = request(scope_kind); + input.anchor_signature = std::option::Option::Some("1".repeat(64)); + let mapped = crate::map_backfill_request(input, &ready_options(), job_id); + assert!(mapped.is_ok(), "scope {scope_kind} should map with a bounded anchor"); + } + let job_id = job_id(); + let job_id = match job_id { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let missing_anchor = crate::map_backfill_request(request("before_address"), &ready_options(), job_id); + assert!(missing_anchor.is_err()); +} + +#[test] +fn explicit_signatures_reject_address_context_and_deduplicate_in_job_contract() { + let job_id = job_id(); + let job_id = match job_id { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let mut input = request("explicit_signatures"); + input.address = std::option::Option::None; + input.min_context_slot = std::option::Option::None; + input.explicit_signatures = vec!["1".repeat(64), "1".repeat(64), "2".repeat(64)]; + let mapped = crate::map_backfill_request(input, &ready_options(), job_id); + assert!(mapped.is_ok()); + let mapped = match mapped { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let signatures = mapped.scope().signatures(); + assert!(signatures.is_some()); + if let std::option::Option::Some(signatures) = signatures { + assert_eq!(signatures.len(), 2); + } + let mut hostile = request("explicit_signatures"); + hostile.address = std::option::Option::None; + hostile.min_context_slot = std::option::Option::None; + hostile.explicit_signatures = vec![" 1".to_owned()]; + let hostile_job_id = job_id(); + let hostile_job_id = match hostile_job_id { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + assert!(crate::map_backfill_request(hostile, &ready_options(), hostile_job_id).is_err()); +} + +#[test] +fn route_inventory_and_composition_are_revalidated_before_mapping() { + let first_job_id = job_id(); + let first_job_id = match first_job_id { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let mut unknown_route = request("latest_address"); + unknown_route.http_role = "provider-from-frontend".to_owned(); + let rejected = crate::map_backfill_request(unknown_route, &ready_options(), first_job_id); + assert!(rejected.is_err()); + let mut unavailable = ready_options(); + unavailable.composition_ready = false; + let second_job_id = job_id(); + let second_job_id = match second_job_id { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + assert!(crate::map_backfill_request(request("latest_address"), &unavailable, second_job_id).is_err()); +} + +#[test] +fn request_preview_never_contains_address_or_signature_values() { + let job_id = job_id(); + let job_id = match job_id { + std::option::Option::Some(value) => value, + std::option::Option::None => return, + }; + let mapped = crate::map_backfill_request(request("latest_address"), &ready_options(), job_id); + assert!(mapped.is_ok()); + let mapped = match mapped { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let preview = crate::project_backfill_request(&mapped); + assert!(preview.is_ok()); + let preview = match preview { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + let serialized = serde_json::to_string(&preview); + assert!(serialized.is_ok()); + if let std::result::Result::Ok(serialized) = serialized { + assert!(!serialized.contains("11111111111111111111111111111111")); + assert!(!serialized.contains(&"1".repeat(64))); + assert!(serialized.contains("mainnet-beta")); + assert!(serialized.contains("backfill_pool")); + } +} diff --git a/crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs b/crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs new file mode 100644 index 0000000..4a4d4c5 --- /dev/null +++ b/crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs @@ -0,0 +1,31 @@ +// file: crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs +// version: 1 + +#[test] +fn request_limits_are_derived_from_job_owned_public_bounds() { + let limits = crate::backfill_request_limits(); + assert!(limits.is_ok()); + let limits = match limits { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; + assert_eq!(usize::try_from(limits.max_page_size), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_PAGE_SIZE)); + assert_eq!(usize::try_from(limits.max_pages), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_PAGES)); + assert_eq!(usize::try_from(limits.max_candidates), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_CANDIDATES)); + assert_eq!(usize::try_from(limits.max_hydration_concurrency), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_HYDRATION_CONCURRENCY)); + assert_eq!(usize::try_from(limits.min_signature_text_bytes), std::result::Result::Ok(ksp_job_backfill_lib::MIN_BACKFILL_SIGNATURE_TEXT_BYTES)); + assert_eq!(usize::try_from(limits.max_signature_text_bytes), std::result::Result::Ok(ksp_job_backfill_lib::MAX_BACKFILL_SIGNATURE_TEXT_BYTES)); + assert!(limits.default_page_size <= limits.max_page_size); + assert!(limits.default_max_pages <= limits.max_pages); + assert!(limits.default_max_candidates <= limits.max_candidates); + assert!(limits.default_hydration_concurrency <= limits.max_hydration_concurrency); +} + +#[test] +fn request_options_expose_exact_current_commitments_and_http_scopes() { + assert_eq!(crate::backfill_commitment_codes(), vec!["finalized".to_owned(), "confirmed".to_owned()]); + assert_eq!( + crate::backfill_scope_kind_codes(), + vec!["latest_address".to_owned(), "before_address".to_owned(), "after_address".to_owned(), "explicit_signatures".to_owned()] + ); +} diff --git a/crates/ksp-app-backfill-desk/unit_tests/dto_common.rs b/crates/ksp-app-backfill-desk/unit_tests/dto_common.rs index 2f4ee9f..822efcd 100644 --- a/crates/ksp-app-backfill-desk/unit_tests/dto_common.rs +++ b/crates/ksp-app-backfill-desk/unit_tests/dto_common.rs @@ -1,5 +1,5 @@ // file: crates/ksp-app-backfill-desk/unit_tests/dto_common.rs -// version: 4 +// version: 5 #[test] fn command_error_projection_excludes_arbitrary_context_and_source_values() { @@ -15,11 +15,20 @@ fn command_error_projection_excludes_arbitrary_context_and_source_values() { #[test] fn transport_options_projection_contains_only_safe_transport_metadata() { + let limits = crate::backfill_request_limits(); + assert!(limits.is_ok()); + let limits = match limits { + std::result::Result::Ok(value) => value, + std::result::Result::Err(_) => return, + }; let dto = crate::BackfillDeskOptionsDto { + commitments: crate::backfill_commitment_codes(), http_routes: vec![crate::BackfillHttpRouteOptionDto { pooled: false, providers: vec!["solana-public".to_owned()], role: "default".to_owned() }], + limits, composition_ready: true, configured_networks: vec!["devnet".to_owned()], network_coherent: true, + scope_kinds: crate::backfill_scope_kind_codes(), store_diagnostic: std::option::Option::None, store_network: std::option::Option::Some("devnet".to_owned()), store_ready: true, diff --git a/deltas/0.3.7/pre.007.md b/deltas/0.3.7/pre.007.md new file mode 100644 index 0000000..5a1a370 --- /dev/null +++ b/deltas/0.3.7/pre.007.md @@ -0,0 +1,138 @@ + + + +# Delta `0.3.7-pre.007` — DTO et mapping de requête Backfill + +## 1. Base requise + +```text +0.3.7-pre.006-fix.001 +workspace.package.version = 0.3.7-pre.6.fix.1 +``` + +## 2. Objectif + +Ouvrir le contrat Job Backfill dans la Desk uniquement pour construire, valider et prévisualiser une requête de campagne HTTP, sans créer ni démarrer de `BackfillJobRuntime`. + +## 3. Contrat applicatif + +Backfill Desk dépend désormais directement de `ksp-job-api` et `ksp-job-backfill-lib`. Le frontend reçoit les quatre scopes HTTP V1, les deux commitments, les maxima publics Job et des defaults applicatifs bornés. + +`BackfillStartRequestDto` reste possédé par l'application et ne contient ni réseau, provider, endpoint, URL, credential, checkpoint ou JobId. Le réseau est dérivé du Store composé ; `http_role` est revalidé contre l'inventaire sûr `BackfillDeskOptionsDto::http_routes` avant construction du contrat Job. + +`min_context_slot` traverse IPC sous forme de texte décimal et est parsé en `u64` côté Rust afin d'éviter toute perte de précision JavaScript. + +## 4. Mapping autoritaire + +Le mapping Rust : + +- valide la readiness Transport + Store et leur cohérence réseau ; +- mappe explicitement `finalized` / `confirmed` ; +- mappe explicitement `latest_address`, `before_address`, `after_address` et `explicit_signatures` ; +- parse l'adresse et les signatures dans les types propriétaires ; +- convertit les bornes `u32` vers `usize` ; +- délègue la validation finale à `ksp-job-backfill-lib::BackfillRequest::new` ; +- n'ouvre aucun runtime Job. + +La commande Tauri `backfill_validate_request` utilise exactement ce mapping avec un JobId interne réservé à la validation, puis renvoie une `BackfillRequestPreviewDto` sûre. La preview expose réseau/rôle/commitment/scope/bornes, mais réduit adresse, ancre, `min_context_slot` et signatures à des booléens ou comptages. + +## 5. Frontend + +Le formulaire Backfill expose : + +- route HTTP logique ; +- scope ; +- commitment ; +- adresse et ancre selon le scope ; +- signatures explicites ; +- page size ; +- max pages ; +- max candidates ; +- hydration concurrency ; +- `min_context_slot`. + +Le bouton reste **Valider la requête**. Aucun Start, Cancel, Resume ou checkpoint n'est ouvert dans cette tranche. Les logs frontend ne contiennent aucune adresse, signature, ancre ou valeur de `min_context_slot`. + +## 6. Version + +```text +workspace.package.version = 0.3.7-pre.7 +label = 0.3.7-pre.007 +``` + +## 7. Fichiers ajoutés + +```text +crates/ksp-app-backfill-desk/src/backfill_request.rs +crates/ksp-app-backfill-desk/src/dto_backfill.rs +crates/ksp-app-backfill-desk/unit_tests/backfill_request.rs +crates/ksp-app-backfill-desk/unit_tests/dto_backfill.rs +deltas/0.3.7/pre.007.md +``` + +## 8. Fichiers modifiés + +```text +Cargo.toml +crates/ksp-app-backfill-desk/Cargo.toml +crates/ksp-app-backfill-desk/frontend/main.html +crates/ksp-app-backfill-desk/frontend/ts/main.ts +crates/ksp-app-backfill-desk/src/app_state.rs +crates/ksp-app-backfill-desk/src/constants.rs +crates/ksp-app-backfill-desk/src/dto_common.rs +crates/ksp-app-backfill-desk/src/errors.rs +crates/ksp-app-backfill-desk/src/lib.rs +crates/ksp-app-backfill-desk/src/tauri.rs +crates/ksp-app-backfill-desk/src/transport_runtime.rs +crates/ksp-app-backfill-desk/tests/desktop_contract.rs +crates/ksp-app-backfill-desk/tests/desktop_security.rs +crates/ksp-app-backfill-desk/unit_tests/dto_common.rs +docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md +docs/validation/024-V0_3_7_BACKFILL_DESK.md +``` + +## 9. Fichiers supprimés + +Aucun. + +## 10. Gate précédent acquis + +Le replay opérateur de `0.3.7-pre.006-fix.001` est propre : audits Rust/Markdown, `cargo check --workspace`, Clippy, `ksp-config-lib`, Backfill Desk et arbres Cargo passent sans anomalie rapportée. + +## 11. Validations exécutées 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.7 +``` + +Contrôles ciblés : + +- syntaxe TypeScript par transpilation locale ; +- cohérence des headers/version des fichiers modifiés ; +- présence des quatre scopes et deux commitments à partir des contrats Job publics ; +- absence de réseau/provider/URL/endpoint/JobId dans le DTO frontend ; +- absence de `BackfillJobRuntime`, Start, Cancel, Resume et checkpoint dans le runtime de cette tranche ; +- absence de valeur d'adresse/signature/ancre/`min_context_slot` dans la preview et les logs frontend ; +- dépendances directes limitées aux façades/API prévues ; +- contrôle différentiel et reconstruction par application du delta. + +## 12. Validations non exécutées dans l'environnement d'assemblage + +`cargo` et `rustfmt` ne sont pas disponibles dans l'environnement d'assemblage. Gate opérateur obligatoire avant `pre.008` : + +```text +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.7 +cargo check --workspace +cargo clippy --workspace --all-targets +cargo test -p ksp-job-backfill-lib +cargo test -p ksp-app-backfill-desk +cargo tree -p ksp-app-backfill-desk --edges normal +cargo tree -p ksp-app-backfill-desk -e features +``` + +## 13. Suite + +`pre.008` ouvrira le runtime et Start : état single-active-run, construction `BackfillJobRuntime`, admission atomique, spawn non bloquant et handle installé avant exécution. Le choix HTTP pool/provider et le mapping `BackfillRequest` de `pre.007` restent les entrées autoritaires. diff --git a/docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md b/docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md index 79852cb..ea2789f 100644 --- a/docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md +++ b/docs/plans/028-V0_3_7_BACKFILL_DESK_PLAN.md @@ -1,5 +1,5 @@ - + # Plan v0.3.7 — Backfill Desk @@ -259,7 +259,9 @@ Faire de `mainnet` le profil composite par défaut, conserver `supertrace` penda ### pre.007 — DTO/request mapping -Ajouter `ksp-job-backfill-lib` et compléter les DTO TS-RS app-owned : quatre scopes HTTP existants, commitments, min context slot, bornes backend-owned, signatures explicites hostiles et rôle HTTP sélectionné. Mapper strictement vers `BackfillRequest`/`HttpRoleName` côté Rust sans Start effectif et refuser toute route qui n'appartient pas à l'inventaire courant. +Ajouter `ksp-job-api` et `ksp-job-backfill-lib` comme dépendances backend directes de la Desk, sans ouvrir encore `BackfillJobRuntime`. Les DTO TS-RS app-owned exposent les quatre scopes HTTP existants, les commitments, `min_context_slot` sous forme de texte décimal, les bornes publiques Job et des defaults applicatifs bornés (`page_size=100`, `max_pages=10`, `max_candidates=1000`, `hydration_concurrency=4`). Le request DTO ne contient ni réseau, ni provider, ni endpoint, ni URL, ni JobId. + +Le mapping Rust dérive le réseau du Store configuré, revalide `http_role` contre `BackfillDeskOptionsDto::http_routes`, parse adresse/signatures et délègue l'autorité finale des bornes/cohérences à `BackfillRequest::new`. Une commande `backfill_validate_request` utilise exactement ce mapping pour produire une projection sûre réduite à présence/comptage et paramètres non sensibles ; elle ne lance aucun Job. Le frontend matérialise le formulaire de campagne et le bouton **Valider la requête** ; adresse, ancre, signatures explicites et `min_context_slot` ne sont jamais journalisés. ### pre.008 — runtime + Start diff --git a/docs/validation/024-V0_3_7_BACKFILL_DESK.md b/docs/validation/024-V0_3_7_BACKFILL_DESK.md index 37327de..2b4f0ec 100644 --- a/docs/validation/024-V0_3_7_BACKFILL_DESK.md +++ b/docs/validation/024-V0_3_7_BACKFILL_DESK.md @@ -1,5 +1,5 @@ - + # Validation v0.3.7 — Backfill Desk @@ -61,10 +61,10 @@ Le journal opérateur fourni rapporte `cargo clean`, `cargo fmt`, audits Rust/Ma - [X] Composite dédié `config/composite.ksp-app-backfill-desk.json` prévu. - [X] Profils `devnet`, `mainnet`, `testnet` prévus avec couples Transport/Store de même réseau. -- [X] Default prévu : `devnet`. +- [X] Default final : `mainnet` depuis `pre.006`; Devnet/Testnet restent disponibles par reconfiguration du composite. - [X] Aucun `std.job_backfill.json` en V1. - [X] Les rôles sont dérivés de Transport et validés pour `getSignaturesForAddress` + `getTransaction`. -- [X] Aucun rôle `history_backfill` n'est hardcodé ; la base standard actuelle expose `default`. +- [X] Aucun rôle `history_backfill` n'est hardcodé ; `pre.006` expose les rôles logiques `backfill_pool`, `backfill_solana_public` et `backfill_publicnode` depuis Transport. - [X] Une incohérence réseau est rejetée avant Start. - [X] Le Desk ouvre Store via `ksp-store-lib`, jamais via backend direct. @@ -97,8 +97,8 @@ Le journal opérateur fourni rapporte `cargo clean`, `cargo fmt`, audits Rust/Ma - [X] Splash/main + tracing conformes. - [X] Composite Backfill Desk packagé et valide. - [X] Transport readiness et role inventory sûrs. -- [ ] Store readiness et cohérence réseau. -- [ ] Quatre scopes + commitments + bornes mappés. +- [X] Store readiness et cohérence réseau. +- [X] Quatre scopes + commitments + bornes mappés. - [ ] Single active run + Start. - [ ] Snapshot latest-value bridge. - [ ] Cancel + races terminales. @@ -207,7 +207,7 @@ cargo test -p ksp-app-backfill-desk FAIL — canari Ca Backfill Desk dépend désormais directement de `ksp-onchain-transport-lib` au niveau backend uniquement. `initialize_transport()` résout le profil Transport déjà sélectionné par le composite, construit `HttpTransportPool`, dérive les clusters HTTP activés et déduplique les rôles logiques à partir des snapshots sûrs. Chaque rôle candidat doit réussir `select_for_method()` pour `getSignaturesForAddress` **et** `getTransaction` avant d'être exposé comme compatible. -Le sous-contrat `BackfillDeskOptionsDto` expose uniquement : réseaux configurés, rôles compatibles, readiness Transport et diagnostic applicatif sûr. Il ne contient ni provider, URL, endpoint physique, token, checkpoint, payload RAW ou objet Transport. La commande Tauri `backfill_options` est centralisée dans `tauri.rs`. Les scopes, commitments et bornes restent hors de cette tranche et seront ajoutés en `pre.006`. +Le sous-contrat `BackfillDeskOptionsDto` expose uniquement : réseaux configurés, rôles compatibles, readiness Transport et diagnostic applicatif sûr. Il ne contient ni provider, URL, endpoint physique, token, checkpoint, payload RAW ou objet Transport. La commande Tauri `backfill_options` est centralisée dans `tauri.rs`. Les scopes, commitments et bornes restent hors de cette tranche et seront ajoutés en `pre.007` après la stabilisation du routage HTTP de `pre.006`. Une seule network distincte et au moins un rôle compatible sont requis pour `transport_ready = true`. Une erreur Config/Transport n'empêche pas le shell de démarrer : elle produit une options projection non-ready avec `CommandErrorDto`, tandis que le pool n'est pas conservé. La cohérence avec le réseau Store reste explicitement réservée à `pre.005`. @@ -287,7 +287,7 @@ Le support Yellowstone gRPC de `ksp-job-backfill-lib`/Backfill Desk est retiré - [X] le frontend charge/rend le sélecteur et trace son changement ; - [X] aucun Start ni dépendance `ksp-job-backfill-lib` n'est ouvert par la Desk ; - [X] audits statiques Rust/Markdown, JSON Schema et syntaxe TypeScript propres dans l'environnement d'assemblage ; -- [ ] replay opérateur `cargo fmt/check/clippy/test` de `pre.006` à exécuter. +- [X] replay opérateur `cargo fmt/check/clippy/test` de `pre.006` exécuté ; un fix Clippy strictement local a été requis puis traité. ## Correctif `pre.006-fix.001` @@ -301,4 +301,30 @@ Le correctif reste strictement dans la responsabilité de `pre.006` : il ajoute - [X] aucun contenu fonctionnel de `pre.006` n'est modifié ; - [X] version Cargo synchronisée en `0.3.7-pre.6.fix.1` conformément à `VER-ID-007` et `VER-ID-010` ; - [X] audits statiques Rust/Markdown rejoués dans l'environnement d'assemblage ; -- [ ] replay opérateur `cargo fmt/check/clippy/test` du fix à exécuter. +- [X] replay opérateur `cargo fmt/check/clippy/test` du fix propre : audits, check, Clippy, Config et toutes les suites Backfill Desk passent. + +## 15. `pre.007` — DTO et mapping de requête + +Le replay opérateur de `pre.006-fix.001` est acquis : audits Rust/Markdown propres, `cargo check --workspace` et Clippy propres, `ksp-config-lib` intégralement vert, puis Backfill Desk vert sur ses 21 tests unitaires, 5 tests de composition, 10 desktop contract, 6 desktop security et 1 public API. Les arbres Cargo ont également été exécutés sans anomalie rapportée. + +`pre.007` ouvre `ksp-job-api` et `ksp-job-backfill-lib` uniquement pour construire et valider le contrat de requête. `BackfillStartRequestDto` reste app-owned et ne transporte ni network, provider, endpoint, URL, credential, checkpoint ou JobId. Le backend dérive le réseau depuis le Store déjà composé et refuse tout `http_role` absent de l'inventaire sûr courant. + +Le formulaire expose les quatre scopes HTTP V1 (`latest_address`, `before_address`, `after_address`, `explicit_signatures`), `finalized`/`confirmed`, les quatre bornes numériques et `min_context_slot`. Ce dernier traverse l'IPC comme texte décimal afin d'éviter toute perte de précision JavaScript avant parsing `u64` côté Rust. Les maxima proviennent exclusivement des constantes publiques de `ksp-job-backfill-lib`; les defaults de Desk restent strictement inférieurs ou égaux à ces maxima. + +La commande `backfill_validate_request` construit un JobId interne uniquement pour satisfaire le contrat `BackfillRequest`, applique le même mapping qui sera réutilisé par Start, puis renvoie une `BackfillRequestPreviewDto` sûre. La preview contient le réseau, le rôle logique, commitment/scope, bornes et seulement présence/comptage pour les valeurs sensibles. Aucun Job n'est créé ni lancé dans cette tranche. + +### Gate statique local `pre.007` + +- [X] dépendances directes `ksp-job-api` et `ksp-job-backfill-lib` ajoutées à Backfill Desk ; +- [X] aucune dépendance directe backend Store, `reqwest`, `tonic` ou Yellowstone ajoutée ; +- [X] request DTO sans network/provider/endpoint/URL/JobId ; +- [X] réseau dérivé du Store configuré et rôle HTTP revalidé contre l'inventaire courant ; +- [X] quatre scopes et deux commitments mappés explicitement ; +- [X] `min_context_slot` transporté comme texte puis parsé en `u64` côté Rust ; +- [X] bornes maxima dérivées des constantes publiques Job et defaults applicatifs vérifiés ; +- [X] preview sûre sans adresse ni signature ; +- [X] formulaire frontend + validation backend disponibles, sans bouton Start ni `BackfillJobRuntime` ; +- [X] instrumentation frontend limitée aux codes/rôle/scope et métadonnées de contrôle ; +- [X] capabilities Tauri inchangées : `core:default + tracing:default` ; +- [ ] `cargo fmt/check/clippy/test` de `pre.007` à rejouer par l'opérateur ; `cargo`/`rustfmt` restent absents du sandbox. +