diff --git a/.nido/local-ci.toml b/.nido/local-ci.toml index 5d563075..04da9b6f 100644 --- a/.nido/local-ci.toml +++ b/.nido/local-ci.toml @@ -1,9 +1,26 @@ # ParkHub Rust local CI entrypoints for Nido-first tabs. # -# `nido ci run --gate legal-docs` is the cheap author-time evidence gate. +# `nido ci run --gate recommendation-contract` is the cheap author-time +# cross-language recommendation fixture and policy gate. +# `nido ci run --gate legal-docs` keeps legal-readiness evidence current. # `nido ci run --gate pr` delegates to the repository's existing SHA-keyed # fop/Nido local CI attestation script and must pass before pushing/releasing. +[[gates]] +name = "recommendation-contract" + +[[gates.steps]] +name = "recommendation-contract" +command = "scripts/check-recommendation-contract.sh" +timeout_secs = 90 +allow_failure = false + +[[gates.steps]] +name = "working-tree-whitespace" +command = "git diff --check" +timeout_secs = 60 +allow_failure = false + [[gates]] name = "legal-docs" @@ -19,6 +36,12 @@ command = "scripts/tests/test-legal-openapi-contract.sh" timeout_secs = 60 allow_failure = false +[[gates.steps]] +name = "recommendation-contract" +command = "scripts/check-recommendation-contract.sh" +timeout_secs = 90 +allow_failure = false + [[gates.steps]] name = "working-tree-whitespace" command = "git diff --check" diff --git a/deny.toml b/deny.toml index 99616279..a9a23db7 100644 --- a/deny.toml +++ b/deny.toml @@ -14,6 +14,7 @@ ignore = [ "RUSTSEC-2024-0420", # gtk-rs GTK3 unmaintained (gtk3-macros) "RUSTSEC-2024-0436", # paste unmaintained "RUSTSEC-2024-0370", # proc-macro-error unmaintained + "RUSTSEC-2026-0173", # proc-macro-error2 unmaintained (transitive via validator_derive -> validator 0.20; no fixed version) "RUSTSEC-2023-0071", # rsa Marvin Attack (transitive dep) "RUSTSEC-2025-0057", # fxhash unmaintained (transitive via selectors) "RUSTSEC-2023-0019", # kuchiki unmaintained (transitive via printpdf) diff --git a/docs/deployment-readiness-record.md b/docs/deployment-readiness-record.md index c55ef943..f57f351b 100644 --- a/docs/deployment-readiness-record.md +++ b/docs/deployment-readiness-record.md @@ -43,6 +43,12 @@ Attach the JSON output or copy the fields below. Treat the catalog as reference-only; attorney review, citation verification, deployment-specific configuration review, human signoff, and final legal judgment remain required. +For the current `exact_cover_v1` release candidate, a captured catalog summary +lives at +`docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md`. Replace +that evidence with a fresh capture when the release candidate SHA, deployment +target, or legal catalog revision changes. + | Field | Captured value | | --- | --- | | Capture command | | diff --git a/docs/recommendation-engine-contract.md b/docs/recommendation-engine-contract.md index 2fa00d83..bac84bdf 100644 --- a/docs/recommendation-engine-contract.md +++ b/docs/recommendation-engine-contract.md @@ -16,6 +16,14 @@ adapter algorithm for the external fop-pipeline service and must fall back to `weighted_v1` on every missing endpoint, timeout, non-2xx response, invalid response, or unknown slot ID. +`exact_cover_v1` is a separate batch-allocation strategy for operational +scheduling workflows: recurring reservations, tenant quotas, EV/fleet/zone +constraints, accessible-space facility constraints, and maintenance-window +exclusions. It is not the default quick-booking scorer and must not replace +`weighted_v1` for ordinary single-slot recommendations. It reports solved vs +fallback status, selected option IDs, covered constraints, search-node count, +and the same legal boundary used by served recommendations. + Default weights: | Key | Default | Meaning | @@ -55,6 +63,18 @@ Changing `weighted_v1` semantics is not allowed. Any ML or tenant-specific strategy must be introduced as a new algorithm version and must pass parity fixtures against `weighted_v1` before rollout. +Changing `exact_cover_v1` semantics must be done through new shared fixtures. +The first parity fixture is +`docs/recommendation-engine-fixtures/exact_cover_v1.batch_basic.json`; Rust and +PHP must keep the selected option IDs and covered constraints identical. + +`exact_cover_v1` is deterministic by contract. The same normalized input must +return the same selected option IDs, covered constraints, fallback status, and +search-node count in Rust and PHP. Candidate ordering must not use randomness, +wall-clock time, database iteration order, or locale-sensitive sorting. Equal +weights are ordered by stable option ID. No-solution cases must return an +explicit fallback status rather than a partial allocation. + ## Config Boundary The Rust module registry exposes a JSON Schema for `recommendations` through the @@ -64,6 +84,16 @@ legacy-safe defaults. The `explain` and `profile_safe_mode` settings are reserved, fail-closed fields: attempts to set them to `false` are rejected by schema and ignored by runtime loading. +Batch allocation has its own config surface under the same module: +`allocation_strategy` (`weighted_v1` or `exact_cover_v1`), +`exact_cover_max_options` (1..256), and `exact_cover_max_search_nodes` +(1..10000). The quick-booking endpoint does not switch to exact-cover when this +is enabled; `exact_cover_v1` is exposed through the admin-only +`POST /api/v1/recommendations/allocation/exact-cover` utility for batch or +recurring scheduling inputs. The endpoint returns `allocation_trace_id` so an +operator can connect a solver result to the immutable audit trace without +logging raw request payloads in customer-facing surfaces. + `fop_pipeline_v1` uses the fop-pipeline JSON/HTTP boundary: `POST {pipeline_endpoint}/pipeline/{pipeline_name}/run`. ParkHub sends the candidate slots, weights, `profile_safe_mode`, explanation requirement, and @@ -90,15 +120,34 @@ audit events. Acceptance metrics remain `null` with `acceptance_metric_source=not_tracked` until explicit accept/reject events exist, so the endpoint does not infer acceptance from unrelated booking state. +Every served `exact_cover_v1` allocation must also preserve an immutable +allocation trace. At minimum the trace stores request ID, solver name and +version, SHA-256 config hash, constraint set hash, candidate set hash, +selected option IDs, rejected candidate IDs, tie-break inputs, actor or service +principal, tenant ID, timestamp, fallback status, and retention/deletion class. +These traces are operational evidence and may be personal data if they can be +linked to a person or vehicle, so export and erasure handling must be designed +before customer rollout. + ## Compliance Boundary This is engineering compliance, not legal advice. For German/EU/international use, the recommendation surface must keep: - data minimization: no sensitive categories, location history beyond parking - usage, or unrelated profile attributes in the score inputs; + usage, or unrelated profile attributes in the score inputs; recommendation + and allocation inputs/outputs must use pseudonymous IDs only and must not + include names, emails, license plates, IP addresses, or free-text personal + data; - explainability: every score must keep a reason or badge that can be audited; - operator control: weight changes must be authenticated, audited, and reversible; +- hard constraints: accessible-space, EV/fleet, tenant quota, reserved-inventory, + and maintenance-window requirements that represent user-declared needs, legal + obligations, or operator policy must be modeled as eligibility constraints, + not as soft scoring bonuses that weights can override; +- config governance: fairness/accessibility/eligibility parameters need explicit + min/max bounds, per-tenant defaults, role-based update permission, change + reason, rollback evidence, and a legal-review flag before sensitive changes; - security evidence: SBOM/provenance/vulnerability handling remains part of the ParkHub CI/CD baseline before business rollout; - legal review: public ToS/privacy/profiling wording must go through `fop legal` @@ -140,10 +189,8 @@ Relevant current public references: ## Legal Review Packet -`fop legal` can draft the supporting documents, and -`fop legal catalog --json` can provide the current review catalog provenance and -safety flags, but generated text and catalog entries are not shipping approval. -Treat the commands below as review inputs only: +`fop legal` can draft the supporting documents, but the generated text is not a +shipping approval. Treat the commands below as review inputs only: ```bash NO_COLOR=true fop legal privacy "ParkHub" diff --git a/docs/recommendation-engine-fixtures/exact_cover_v1.batch_basic.json b/docs/recommendation-engine-fixtures/exact_cover_v1.batch_basic.json new file mode 100644 index 00000000..2b28efa9 --- /dev/null +++ b/docs/recommendation-engine-fixtures/exact_cover_v1.batch_basic.json @@ -0,0 +1,45 @@ +{ + "schema_version": "parkhub.recommendation.fixture.v1", + "algorithm": "exact_cover_v1", + "workflow": "batch_allocation", + "profile_safe_mode": true, + "required_constraints": [ + "accessible", + "ev", + "tenant:alpha", + "tenant:beta" + ], + "options": [ + { + "id": "slot-a", + "covers": ["tenant:alpha", "ev"], + "weight": 90 + }, + { + "id": "slot-b", + "covers": ["tenant:beta", "accessible"], + "weight": 80 + }, + { + "id": "slot-c", + "covers": ["tenant:beta"], + "weight": 70 + } + ], + "expected": { + "status": "solved", + "selected_option_ids": ["slot-a", "slot-b"], + "covered_constraints": [ + "accessible", + "ev", + "tenant:alpha", + "tenant:beta" + ] + }, + "legal_boundary": { + "legal_review_required": true, + "attorney_review_status": "required_before_customer_wording", + "execution_allowed": false, + "disclaimer": "Exact-cover allocation is operational scheduling support; attorney review, citation verification, client authorization, and final legal judgment remain required before customer-facing legal or profiling claims ship." + } +} diff --git a/docs/recommendation-engine-fixtures/exact_cover_v1.empty.json b/docs/recommendation-engine-fixtures/exact_cover_v1.empty.json new file mode 100644 index 00000000..f67978c3 --- /dev/null +++ b/docs/recommendation-engine-fixtures/exact_cover_v1.empty.json @@ -0,0 +1,25 @@ +{ + "schema_version": "parkhub.recommendation.fixture.v1", + "algorithm": "exact_cover_v1", + "workflow": "batch_allocation", + "profile_safe_mode": true, + "required_constraints": [], + "options": [ + { + "id": "slot-a", + "covers": ["tenant:alpha"], + "weight": 90 + } + ], + "expected": { + "status": "solved", + "selected_option_ids": [], + "covered_constraints": [] + }, + "legal_boundary": { + "legal_review_required": true, + "attorney_review_status": "required_before_customer_wording", + "execution_allowed": false, + "disclaimer": "No constraints means no exact-cover allocation is required; attorney review, citation verification, client authorization, and final legal judgment remain required before customer-facing legal or profiling claims ship." + } +} diff --git a/docs/recommendation-engine-fixtures/exact_cover_v1.fairness_tiebreak.json b/docs/recommendation-engine-fixtures/exact_cover_v1.fairness_tiebreak.json new file mode 100644 index 00000000..54dad9c3 --- /dev/null +++ b/docs/recommendation-engine-fixtures/exact_cover_v1.fairness_tiebreak.json @@ -0,0 +1,35 @@ +{ + "schema_version": "parkhub.recommendation.fixture.v1", + "algorithm": "exact_cover_v1", + "workflow": "batch_allocation", + "profile_safe_mode": true, + "required_constraints": ["tenant:alpha"], + "options": [ + { + "id": "slot-b", + "covers": ["tenant:alpha"], + "weight": 80 + }, + { + "id": "slot-a", + "covers": ["tenant:alpha"], + "weight": 80 + }, + { + "id": "slot-c", + "covers": ["tenant:alpha"], + "weight": 70 + } + ], + "expected": { + "status": "solved", + "selected_option_ids": ["slot-a"], + "covered_constraints": ["tenant:alpha"] + }, + "legal_boundary": { + "legal_review_required": true, + "attorney_review_status": "required_before_customer_wording", + "execution_allowed": false, + "disclaimer": "Equal-weight candidates use stable option-id ordering as the deterministic fairness tie-break; attorney review, citation verification, client authorization, and final legal judgment remain required before customer-facing legal or profiling claims ship." + } +} diff --git a/docs/recommendation-engine-fixtures/exact_cover_v1.no_solution.json b/docs/recommendation-engine-fixtures/exact_cover_v1.no_solution.json new file mode 100644 index 00000000..8c72489b --- /dev/null +++ b/docs/recommendation-engine-fixtures/exact_cover_v1.no_solution.json @@ -0,0 +1,25 @@ +{ + "schema_version": "parkhub.recommendation.fixture.v1", + "algorithm": "exact_cover_v1", + "workflow": "batch_allocation", + "profile_safe_mode": true, + "required_constraints": ["tenant:alpha", "maintenance:open"], + "options": [ + { + "id": "slot-a", + "covers": ["tenant:alpha"], + "weight": 90 + } + ], + "expected": { + "status": "fallback_no_solution", + "selected_option_ids": [], + "covered_constraints": [] + }, + "legal_boundary": { + "legal_review_required": true, + "attorney_review_status": "required_before_customer_wording", + "execution_allowed": false, + "disclaimer": "Missing maintenance-window coverage must fail closed and let the caller use a safe fallback; attorney review, citation verification, client authorization, and final legal judgment remain required before customer-facing legal or profiling claims ship." + } +} diff --git a/docs/release-checklist.md b/docs/release-checklist.md index 23c93671..fdd86f8f 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -42,6 +42,10 @@ Use this before tagging a ParkHub release from this repo. - Capture the current legal catalog `source_revision`, `generated_at`, `requires_attorney_review`, `requires_human_signoff`, `execution_allowed`, and `safety_boundary` values in the deployment readiness record before release. +- For the current `exact_cover_v1` release candidate, review + `docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md` and + replace it with a fresh capture if the head SHA, legal catalog revision, or + deployment target changes before release. ## Quality bar diff --git a/docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md b/docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md new file mode 100644 index 00000000..5350d615 --- /dev/null +++ b/docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md @@ -0,0 +1,40 @@ +# 2026-05-21 Exact-Cover Nido/fop Legal Catalog Evidence + +This release-candidate evidence was captured for the ParkHub Rust +`exact_cover_v1` allocation work before release or customer-facing claims. + +## Candidate + +| Field | Value | +| --- | --- | +| Repository | `nash87/parkhub-rust` | +| Pull request | `#663` | +| Head SHA | Verify against the current PR head before release. | +| Local Nido PR gate report | `.fop/reports/local-ci-pr-.json` must exist and pass for the current PR head. | +| Candidate feature SHA | `e8bbb475d638469d4d9a0421ef3d7a7c4d61d3fa` | + +## Capture + +| Field | Value | +| --- | --- | +| Capture command | `NO_COLOR=true fop legal catalog --json` | +| Captured by / date | Codex on 2026-05-21 | +| Catalog id / source / version | `anthropic-claude-for-legal` / `claude-for-legal` / `2026-05-15-review` | +| Catalog `source_revision` | `9cecd91` | +| Catalog `generated_at` | `2026-05-21T21:32:11.995244005Z` | +| Catalog `requires_attorney_review` | `true` | +| Catalog `requires_human_signoff` | `true` | +| Catalog `execution_allowed` | `false` | +| Installed Nido legal entrypoint | Not exposed by the installed Nido CLI; use `fop legal catalog --json` until `nido legal` exists. | +| Catalog `safety_boundary` | Reference catalog only. Claude for Legal plugins can help draft, triage, and monitor legal work, but attorney review, citation verification, client authorization, and final legal judgment remain required. fop exposes install and deploy commands as text only. | + +## Release Interpretation + +- `exact_cover_v1` remains operational scheduling support only. +- This catalog output is reference-only evidence, not legal advice. +- Public ToS, privacy, profiling, accessibility, or compliance wording still + requires attorney review, citation verification, deployment-specific configuration review, + and final human signoff. +- The release remains blocked for production/customer-facing legal claims until + the deployment readiness record is completed for the actual operator, + processors, jurisdictions, enabled modules, and hosting model. diff --git a/osv-scanner.toml b/osv-scanner.toml index 5ada2759..d7d6d963 100644 --- a/osv-scanner.toml +++ b/osv-scanner.toml @@ -96,3 +96,7 @@ reason = "glib gtk-rs unchecked-callback PSK leak; not used by parkhub-server (T [[IgnoredVulns]] id = "GHSA-cq8v-f236-94qc" reason = "rand 0.7.3 transitive; bumped where direct, locked elsewhere" + +[[IgnoredVulns]] +id = "RUSTSEC-2026-0173" +reason = "proc-macro-error2 unmaintained (transitive via validator_derive -> validator 0.20); no fixed version published" diff --git a/parkhub-server/src/api/mod.rs b/parkhub-server/src/api/mod.rs index 3d1a5346..adb865b7 100644 --- a/parkhub-server/src/api/mod.rs +++ b/parkhub-server/src/api/mod.rs @@ -168,6 +168,8 @@ pub mod rate_dashboard; #[cfg(feature = "mod-rbac")] pub mod rbac; #[cfg(feature = "mod-recommendations")] +pub mod recommendation_allocation; +#[cfg(feature = "mod-recommendations")] pub mod recommendations; #[cfg(feature = "mod-recurring")] pub mod recurring; @@ -291,6 +293,8 @@ use notification_center::{ #[cfg(feature = "mod-notifications")] use notifications::{list_notifications, mark_all_notifications_read, mark_notification_read}; #[cfg(feature = "mod-recommendations")] +use recommendation_allocation::solve_exact_cover_allocation; +#[cfg(feature = "mod-recommendations")] use recommendations::{get_recommendation_stats, get_recommendations}; #[cfg(feature = "mod-recurring")] use recurring::{ @@ -1397,6 +1401,10 @@ fn booking_protected_routes() -> Router { { router = router .route("/api/v1/bookings/recommendations", get(get_recommendations)) + .route( + "/api/v1/recommendations/allocation/exact-cover", + post(solve_exact_cover_allocation), + ) .route( "/api/v1/recommendations/stats", get(get_recommendation_stats), diff --git a/parkhub-server/src/api/modules/schemas.rs b/parkhub-server/src/api/modules/schemas.rs index 168dda79..31af574a 100644 --- a/parkhub-server/src/api/modules/schemas.rs +++ b/parkhub-server/src/api/modules/schemas.rs @@ -172,6 +172,23 @@ pub(super) const MOD_RECOMMENDATIONS_SCHEMA: &str = r#"{ "const": true, "description": "Fail-closed guardrail. weighted_v1 fallback stays mandatory until fop_pipeline_v1 is production-certified." }, + "allocation_strategy": { + "type": "string", + "enum": ["weighted_v1", "exact_cover_v1"], + "description": "Batch-allocation strategy. weighted_v1 keeps quick booking as the default; exact_cover_v1 is admin-only operational scheduling support for recurring/batch constraints." + }, + "exact_cover_max_options": { + "type": "integer", + "minimum": 1, + "maximum": 256, + "description": "Maximum candidate options accepted by exact_cover_v1 before failing closed with fallback_input_limited." + }, + "exact_cover_max_search_nodes": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + "description": "Maximum Algorithm X search nodes before exact_cover_v1 fails closed with fallback_search_limited." + }, "weight_frequency": { "type": "number", "minimum": 0, @@ -237,6 +254,9 @@ pub(super) const MOD_RECOMMENDATIONS_SCHEMA: &str = r#"{ "pipeline_name", "pipeline_timeout_ms", "pipeline_fallback_enabled", + "allocation_strategy", + "exact_cover_max_options", + "exact_cover_max_search_nodes", "weight_frequency", "weight_preferred_lot", "weight_availability", diff --git a/parkhub-server/src/api/recommendation_allocation.rs b/parkhub-server/src/api/recommendation_allocation.rs new file mode 100644 index 00000000..11ab944e --- /dev/null +++ b/parkhub-server/src/api/recommendation_allocation.rs @@ -0,0 +1,787 @@ +//! Allocation strategy primitives for recommendation workflows. +//! +//! `weighted_v1` stays the default for quick single-slot recommendations. This +//! module provides the small, deterministic `exact_cover_v1` core for later +//! batch/recurring allocation workflows where every required constraint must be +//! covered exactly once. + +use axum::{Extension, Json, extract::State, http::StatusCode}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use uuid::Uuid; + +use crate::db::Database; +use parkhub_common::ApiResponse; +use parkhub_common::models::UserRole; + +use super::{ + AuthUser, SharedState, + recommendations::{RecommendationAllocationConfig, RecommendationEngineConfig}, +}; + +const DEFAULT_MAX_OPTIONS: usize = 256; +const DEFAULT_MAX_SEARCH_NODES: usize = 10_000; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExactCoverOption { + pub id: String, + pub covers: Vec, + pub weight: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct ExactCoverLimits { + pub max_options: usize, + pub max_search_nodes: usize, +} + +impl Default for ExactCoverLimits { + fn default() -> Self { + Self { + max_options: DEFAULT_MAX_OPTIONS, + max_search_nodes: DEFAULT_MAX_SEARCH_NODES, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ExactCoverResult { + pub strategy: &'static str, + pub status: ExactCoverStatus, + pub selected_option_ids: Vec, + pub covered_constraints: Vec, + pub search_nodes: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ExactCoverStatus { + Solved, + FallbackNoSolution, + FallbackInputLimited, + FallbackSearchLimited, +} + +#[derive(Debug, Deserialize)] +pub struct ExactCoverAllocationRequest { + pub required_constraints: Vec, + pub options: Vec, + pub limits: Option, +} + +#[derive(Debug, Serialize)] +pub struct ExactCoverAllocationResponse { + pub allocation_trace_id: Uuid, + pub result: ExactCoverResult, + pub legal_boundary: ExactCoverLegalBoundary, +} + +#[derive(Debug, Serialize)] +pub struct ExactCoverLegalBoundary { + pub legal_review_required: bool, + pub attorney_review_status: &'static str, + pub execution_allowed: bool, + pub disclaimer: &'static str, +} + +#[derive(Debug, Clone)] +struct NormalizedOption { + id: String, + covers: BTreeSet, + weight: i64, +} + +#[derive(Debug)] +struct SearchState { + nodes: usize, + max_nodes: usize, + limited: bool, +} + +/// Solve an exact-cover allocation with deterministic Algorithm X backtracking. +/// +/// Non-required option constraints are ignored. Ties are stable: higher weight +/// wins first, then lower option id. Callers must still decide whether to fall +/// back to `weighted_v1`; this core reports only the allocation result. +pub fn solve_exact_cover_v1( + required_constraints: &[String], + options: &[ExactCoverOption], + limits: ExactCoverLimits, +) -> ExactCoverResult { + let required = normalize_constraints(required_constraints); + if required.is_empty() { + return ExactCoverResult { + strategy: "exact_cover_v1", + status: ExactCoverStatus::Solved, + selected_option_ids: Vec::new(), + covered_constraints: Vec::new(), + search_nodes: 0, + }; + } + + if options.len() > limits.max_options { + return fallback(ExactCoverStatus::FallbackInputLimited, 0); + } + + let normalized = normalize_options(options, &required); + let mut state = SearchState { + nodes: 0, + max_nodes: limits.max_search_nodes, + limited: false, + }; + let mut selected = Vec::new(); + + let solution = search_exact_cover(&required, &required, &normalized, &mut selected, &mut state); + match solution { + Some(indices) => { + let mut selected_option_ids = indices + .iter() + .map(|idx| normalized[*idx].id.clone()) + .collect::>(); + selected_option_ids.sort(); + ExactCoverResult { + strategy: "exact_cover_v1", + status: ExactCoverStatus::Solved, + selected_option_ids, + covered_constraints: required.into_iter().collect(), + search_nodes: state.nodes, + } + } + None if state.limited => fallback(ExactCoverStatus::FallbackSearchLimited, state.nodes), + None => fallback(ExactCoverStatus::FallbackNoSolution, state.nodes), + } +} + +/// Admin-only exact-cover allocation utility for batch/recurring workflows. +/// +/// This intentionally lives outside the quick-booking recommendation endpoint: +/// `weighted_v1` remains the default scorer for ordinary single-slot requests. +pub async fn solve_exact_cover_allocation( + State(state): State, + Extension(auth_user): Extension, + Json(request): Json, +) -> (StatusCode, Json>) { + let db = database_from_shared_state(&state).await; + if let Err((status, msg)) = check_admin_db(&db, &auth_user).await { + return (status, Json(ApiResponse::error("FORBIDDEN", msg))); + } + let engine = RecommendationEngineConfig::load(&db).await; + + let limits = effective_limits(request.limits, &engine.allocation); + let result = solve_exact_cover_v1(&request.required_constraints, &request.options, limits); + let allocation_trace_id = Uuid::new_v4(); + + let audit_result = audit_exact_cover_allocation( + &db, + allocation_trace_id, + &auth_user, + &request, + limits, + &result, + ) + .await; + if let Err(err) = audit_result { + tracing::error!( + %allocation_trace_id, + error = ?err, + "failed to persist exact-cover allocation audit trace" + ); + return audit_persist_failure_response(); + } + + ( + StatusCode::OK, + Json(ApiResponse::success(ExactCoverAllocationResponse { + allocation_trace_id, + result, + legal_boundary: ExactCoverLegalBoundary { + legal_review_required: true, + attorney_review_status: "required_before_customer_wording", + execution_allowed: false, + disclaimer: "exact_cover_v1 is operational scheduling support; attorney review, citation verification, client authorization, and final legal judgment remain required before customer-facing legal or profiling claims ship.", + }, + })), + ) +} + +/// Compliance fail-closed response when the immutable audit trace cannot be +/// persisted. A served `exact_cover_v1` allocation MUST NOT report success +/// (with an `allocation_trace_id`) unless its audit record was durably written, +/// otherwise operators are left with a trace id that has no audit evidence. +fn audit_persist_failure_response() -> (StatusCode, Json>) +{ + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiResponse::error( + "AUDIT_TRACE_PERSIST_FAILED", + "Failed to persist exact-cover allocation audit trace", + )), + ) +} + +async fn database_from_shared_state(state: &SharedState) -> Database { + state.read().await.db.clone() +} + +async fn check_admin_db( + db: &Database, + auth_user: &AuthUser, +) -> Result<(), (StatusCode, &'static str)> { + match db.get_user(&auth_user.user_id.to_string()).await { + Ok(Some(user)) if matches!(user.role, UserRole::Admin | UserRole::SuperAdmin) => Ok(()), + _ => Err((StatusCode::FORBIDDEN, "Admin access required")), + } +} + +fn effective_limits( + request_limits: Option, + allocation: &RecommendationAllocationConfig, +) -> ExactCoverLimits { + let configured = ExactCoverLimits { + max_options: allocation.exact_cover_max_options, + max_search_nodes: allocation.exact_cover_max_search_nodes, + } + .bounded(DEFAULT_MAX_OPTIONS, DEFAULT_MAX_SEARCH_NODES); + + request_limits + .unwrap_or(configured) + .bounded(configured.max_options, configured.max_search_nodes) +} + +impl ExactCoverLimits { + fn bounded(self, max_options: usize, max_search_nodes: usize) -> Self { + Self { + max_options: self.max_options.clamp(1, max_options), + max_search_nodes: self.max_search_nodes.clamp(1, max_search_nodes), + } + } +} + +async fn audit_exact_cover_allocation( + db: &Database, + trace_id: Uuid, + auth_user: &AuthUser, + request: &ExactCoverAllocationRequest, + limits: ExactCoverLimits, + result: &ExactCoverResult, +) -> anyhow::Result<()> { + let tenant_id = resolve_tenant_id_db(db, auth_user.user_id).await; + let selected = result + .selected_option_ids + .iter() + .cloned() + .collect::>(); + let rejected_candidate_ids = request + .options + .iter() + .filter_map(|option| { + let id = option.id.trim(); + (!id.is_empty() && !selected.contains(id)).then(|| id.to_string()) + }) + .collect::>(); + + let details = serde_json::json!({ + "request_id": trace_id, + "solver_name": "exact_cover_v1", + "solver_version": 1, + "config_hash": exact_cover_config_hash(limits), + "constraint_set_hash": exact_cover_constraint_hash(&request.required_constraints), + "candidate_set_hash": exact_cover_candidate_hash(&request.options), + "selected_option_ids": &result.selected_option_ids, + "rejected_candidate_ids": rejected_candidate_ids, + "covered_constraints": &result.covered_constraints, + "search_nodes": result.search_nodes, + "tie_break_inputs": { + "candidate_order": "weight_desc_then_option_id_asc", + "constraint_order": "fewest_candidates_then_constraint_asc", + "max_options": limits.max_options, + "max_search_nodes": limits.max_search_nodes, + }, + "actor": { + "user_id": auth_user.user_id, + "api_key_id": auth_user.api_key_id, + }, + "tenant_id": tenant_id, + "fallback_status": status_name(result.status), + "retention_deletion_class": "operational_evidence_personal_data_possible", + "legal_boundary": { + "legal_review_required": true, + "attorney_review_status": "required_before_customer_wording", + "execution_allowed": false + } + }); + + let entry = crate::db::AuditLogEntry { + id: trace_id, + timestamp: Utc::now(), + event_type: "ExactCoverAllocationServed".to_string(), + user_id: Some(auth_user.user_id), + username: None, + details: Some(details.to_string()), + target_type: Some("recommendation_allocation".to_string()), + target_id: Some(trace_id.to_string()), + ip_address: None, + }; + + db.save_audit_log(&entry).await +} + +async fn resolve_tenant_id_db(db: &Database, user_id: Uuid) -> Option { + db.get_user(&user_id.to_string()) + .await + .ok() + .flatten() + .and_then(|user| user.tenant_id) +} + +fn exact_cover_config_hash(limits: ExactCoverLimits) -> String { + hash_json(&serde_json::json!({ + "strategy": "exact_cover_v1", + "max_options": limits.max_options, + "max_search_nodes": limits.max_search_nodes, + })) +} + +fn status_name(status: ExactCoverStatus) -> &'static str { + match status { + ExactCoverStatus::Solved => "solved", + ExactCoverStatus::FallbackNoSolution => "fallback_no_solution", + ExactCoverStatus::FallbackInputLimited => "fallback_input_limited", + ExactCoverStatus::FallbackSearchLimited => "fallback_search_limited", + } +} + +fn exact_cover_constraint_hash(required_constraints: &[String]) -> String { + hash_json(&serde_json::json!( + normalize_constraints(required_constraints) + .into_iter() + .collect::>() + )) +} + +fn exact_cover_candidate_hash(options: &[ExactCoverOption]) -> String { + let mut normalized = options + .iter() + .filter_map(|option| { + let id = option.id.trim(); + (!id.is_empty()).then(|| { + serde_json::json!({ + "id": id, + "covers": normalize_constraints(&option.covers).into_iter().collect::>(), + "weight": option.weight, + }) + }) + }) + .collect::>(); + normalized.sort_by(|a, b| { + let left = a + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + let right = b + .get("id") + .and_then(serde_json::Value::as_str) + .unwrap_or(""); + left.cmp(right) + }); + hash_json(&serde_json::json!(normalized)) +} + +fn hash_json(value: &serde_json::Value) -> String { + let payload = serde_json::to_vec(value).unwrap_or_default(); + let digest = Sha256::digest(&payload); + digest.iter().fold(String::new(), |mut output, byte| { + use std::fmt::Write as _; + let _ = write!(&mut output, "{byte:02x}"); + output + }) +} + +fn fallback(status: ExactCoverStatus, search_nodes: usize) -> ExactCoverResult { + ExactCoverResult { + strategy: "exact_cover_v1", + status, + selected_option_ids: Vec::new(), + covered_constraints: Vec::new(), + search_nodes, + } +} + +fn normalize_constraints(values: &[String]) -> BTreeSet { + values + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) + .collect() +} + +fn normalize_options( + options: &[ExactCoverOption], + required: &BTreeSet, +) -> Vec { + let mut normalized = options + .iter() + .filter_map(|option| { + let covers = normalize_constraints(&option.covers) + .intersection(required) + .cloned() + .collect::>(); + (!option.id.trim().is_empty() && !covers.is_empty()).then(|| NormalizedOption { + id: option.id.trim().to_string(), + covers, + weight: option.weight, + }) + }) + .collect::>(); + + normalized.sort_by(|a, b| b.weight.cmp(&a.weight).then_with(|| a.id.cmp(&b.id))); + normalized +} + +fn search_exact_cover( + required: &BTreeSet, + uncovered: &BTreeSet, + options: &[NormalizedOption], + selected: &mut Vec, + state: &mut SearchState, +) -> Option> { + if state.nodes >= state.max_nodes { + state.limited = true; + return None; + } + state.nodes += 1; + + if uncovered.is_empty() { + return Some(selected.clone()); + } + + let covered = required + .difference(uncovered) + .cloned() + .collect::>(); + let (constraint, candidates) = choose_next_constraint(uncovered, &covered, options)?; + + if candidates.is_empty() { + return None; + } + + for option_idx in candidates { + let option = &options[option_idx]; + if !option.covers.contains(constraint) || !option.covers.is_disjoint(&covered) { + continue; + } + + selected.push(option_idx); + let next_uncovered = uncovered + .difference(&option.covers) + .cloned() + .collect::>(); + if let Some(solution) = + search_exact_cover(required, &next_uncovered, options, selected, state) + { + return Some(solution); + } + selected.pop(); + + if state.limited { + return None; + } + } + + None +} + +fn choose_next_constraint<'a>( + uncovered: &'a BTreeSet, + covered: &BTreeSet, + options: &[NormalizedOption], +) -> Option<(&'a String, Vec)> { + uncovered + .iter() + .map(|constraint| { + let candidates = options + .iter() + .enumerate() + .filter(|(_, option)| { + option.covers.contains(constraint) && option.covers.is_disjoint(covered) + }) + .map(|(idx, _)| idx) + .collect::>(); + (constraint, candidates) + }) + .min_by(|(left_constraint, left), (right_constraint, right)| { + left.len() + .cmp(&right.len()) + .then_with(|| left_constraint.cmp(right_constraint)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Debug, Deserialize)] + struct ExactCoverFixture { + required_constraints: Vec, + options: Vec, + expected: ExactCoverFixtureExpected, + legal_boundary: ExactCoverFixtureLegalBoundary, + } + + #[derive(Debug, Deserialize)] + struct ExactCoverFixtureOption { + id: String, + covers: Vec, + weight: i64, + } + + #[derive(Debug, Deserialize)] + struct ExactCoverFixtureExpected { + status: String, + selected_option_ids: Vec, + covered_constraints: Vec, + } + + #[derive(Debug, Deserialize)] + struct ExactCoverFixtureLegalBoundary { + legal_review_required: bool, + attorney_review_status: String, + execution_allowed: bool, + disclaimer: String, + } + + fn option(id: &str, covers: &[&str], weight: i64) -> ExactCoverOption { + ExactCoverOption { + id: id.to_string(), + covers: covers.iter().map(|value| (*value).to_string()).collect(), + weight, + } + } + + fn required(values: &[&str]) -> Vec { + values.iter().map(|value| (*value).to_string()).collect() + } + + #[test] + fn exact_cover_v1_solves_batch_constraints() { + let result = solve_exact_cover_v1( + &required(&["tenant:alpha", "tenant:beta", "ev", "accessible"]), + &[ + option("slot-a", &["tenant:alpha", "ev"], 90), + option("slot-b", &["tenant:beta", "accessible"], 80), + option("slot-c", &["tenant:beta"], 70), + ], + ExactCoverLimits::default(), + ); + + assert_eq!(result.status, ExactCoverStatus::Solved); + assert_eq!(result.selected_option_ids, vec!["slot-a", "slot-b"]); + assert_eq!( + result.covered_constraints, + vec!["accessible", "ev", "tenant:alpha", "tenant:beta"] + ); + } + + #[test] + fn exact_cover_v1_uses_deterministic_weight_and_id_tiebreaks() { + let result = solve_exact_cover_v1( + &required(&["tenant:alpha"]), + &[ + option("slot-b", &["tenant:alpha"], 80), + option("slot-a", &["tenant:alpha"], 80), + option("slot-c", &["tenant:alpha"], 70), + ], + ExactCoverLimits::default(), + ); + + assert_eq!(result.status, ExactCoverStatus::Solved); + assert_eq!(result.selected_option_ids, vec!["slot-a"]); + } + + #[test] + fn exact_cover_v1_reports_no_solution_for_maintenance_gap() { + let result = solve_exact_cover_v1( + &required(&["tenant:alpha", "maintenance:open"]), + &[option("slot-a", &["tenant:alpha"], 90)], + ExactCoverLimits::default(), + ); + + assert_eq!(result.status, ExactCoverStatus::FallbackNoSolution); + assert!(result.selected_option_ids.is_empty()); + } + + #[test] + fn exact_cover_v1_enforces_input_limits() { + let result = solve_exact_cover_v1( + &required(&["tenant:alpha"]), + &[ + option("slot-a", &["tenant:alpha"], 90), + option("slot-b", &["tenant:alpha"], 80), + ], + ExactCoverLimits { + max_options: 1, + max_search_nodes: 10, + }, + ); + + assert_eq!(result.status, ExactCoverStatus::FallbackInputLimited); + assert_eq!(result.search_nodes, 0); + } + + #[test] + fn exact_cover_limits_respect_module_caps_and_request_overrides() { + let allocation = RecommendationAllocationConfig { + strategy: "exact_cover_v1".to_string(), + exact_cover_max_options: 8, + exact_cover_max_search_nodes: 500, + }; + + assert_eq!( + effective_limits(None, &allocation), + ExactCoverLimits { + max_options: 8, + max_search_nodes: 500, + } + ); + assert_eq!( + effective_limits( + Some(ExactCoverLimits { + max_options: 99, + max_search_nodes: 10_000, + }), + &allocation, + ), + ExactCoverLimits { + max_options: 8, + max_search_nodes: 500, + } + ); + assert_eq!( + effective_limits( + Some(ExactCoverLimits { + max_options: 3, + max_search_nodes: 50, + }), + &allocation, + ), + ExactCoverLimits { + max_options: 3, + max_search_nodes: 50, + } + ); + } + + #[test] + fn exact_cover_limits_bounded_clamps_both_max_fields_into_caps() { + // Over-cap request values clamp down to the module-config maximums for + // BOTH exact_cover_max_options and exact_cover_max_search_nodes. + let over_cap = ExactCoverLimits { + max_options: 9_999, + max_search_nodes: 9_999_999, + }; + assert_eq!( + over_cap.bounded(16, 250), + ExactCoverLimits { + max_options: 16, + max_search_nodes: 250, + } + ); + + // Zero / below-floor request values clamp up to the inclusive floor of 1 + // so the solver is never handed a degenerate limit. + let below_floor = ExactCoverLimits { + max_options: 0, + max_search_nodes: 0, + }; + assert_eq!( + below_floor.bounded(16, 250), + ExactCoverLimits { + max_options: 1, + max_search_nodes: 1, + } + ); + + // In-range values pass through untouched. + let in_range = ExactCoverLimits { + max_options: 8, + max_search_nodes: 100, + }; + assert_eq!(in_range.bounded(16, 250), in_range); + } + + #[test] + fn audit_persist_failure_response_fails_closed_with_error_code() { + // Compliance contract: if the immutable audit trace cannot be persisted, + // the endpoint MUST fail closed (5xx) instead of returning success with + // an allocation_trace_id that has no backing audit record. + let (status, Json(body)) = audit_persist_failure_response(); + + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert!(!body.success); + assert!(body.data.is_none()); + let error = body.error.expect("error payload present"); + assert_eq!(error.code, "AUDIT_TRACE_PERSIST_FAILED"); + assert!(error.message.contains("audit trace")); + } + + #[test] + fn exact_cover_v1_shared_fixtures_match_contract() { + let fixtures = [ + include_str!( + "../../../docs/recommendation-engine-fixtures/exact_cover_v1.batch_basic.json" + ), + include_str!("../../../docs/recommendation-engine-fixtures/exact_cover_v1.empty.json"), + include_str!( + "../../../docs/recommendation-engine-fixtures/exact_cover_v1.fairness_tiebreak.json" + ), + include_str!( + "../../../docs/recommendation-engine-fixtures/exact_cover_v1.no_solution.json" + ), + ]; + + for raw_fixture in fixtures { + let fixture: ExactCoverFixture = + serde_json::from_str(raw_fixture).expect("valid exact-cover fixture"); + let options = fixture + .options + .into_iter() + .map(|option| ExactCoverOption { + id: option.id, + covers: option.covers, + weight: option.weight, + }) + .collect::>(); + let result = solve_exact_cover_v1( + &fixture.required_constraints, + &options, + ExactCoverLimits::default(), + ); + + assert_eq!(super::status_name(result.status), fixture.expected.status); + assert_eq!( + result.selected_option_ids, + fixture.expected.selected_option_ids + ); + assert_eq!( + result.covered_constraints, + fixture.expected.covered_constraints + ); + assert!(fixture.legal_boundary.legal_review_required); + assert_eq!( + fixture.legal_boundary.attorney_review_status, + "required_before_customer_wording" + ); + assert!(!fixture.legal_boundary.execution_allowed); + assert!( + fixture + .legal_boundary + .disclaimer + .contains("attorney review") + ); + } + } +} diff --git a/parkhub-server/src/api/recommendations.rs b/parkhub-server/src/api/recommendations.rs index 0cf6ec06..a53ecb2d 100644 --- a/parkhub-server/src/api/recommendations.rs +++ b/parkhub-server/src/api/recommendations.rs @@ -67,6 +67,7 @@ pub struct RecommendationEngineConfig { pub explain: bool, pub profile_safe_mode: bool, pub pipeline: RecommendationPipelineConfig, + pub allocation: RecommendationAllocationConfig, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] @@ -77,6 +78,21 @@ pub struct RecommendationPipelineConfig { pub fallback_enabled: bool, } +#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RecommendationAllocationConfig { + /// Kept as `String` (not an enum) intentionally: the value is read from + /// module config at runtime via [`read_module_string`] and normalized with + /// [`normalize_allocation_strategy`], which falls back to `"weighted_v1"` + /// on unknown values. A strict-deserializing enum would reject any future + /// or typo'd value at the parse boundary rather than degrading gracefully, + /// breaking the documented unknown-strategy-fallback contract. New + /// strategies are added by extending [`is_supported_allocation_strategy`] + /// and [`normalize_allocation_strategy`] without a breaking API change. + pub strategy: String, + pub exact_cover_max_options: usize, + pub exact_cover_max_search_nodes: usize, +} + impl Default for RecommendationPipelineConfig { fn default() -> Self { Self { @@ -88,6 +104,16 @@ impl Default for RecommendationPipelineConfig { } } +impl Default for RecommendationAllocationConfig { + fn default() -> Self { + Self { + strategy: "weighted_v1".to_string(), + exact_cover_max_options: 256, + exact_cover_max_search_nodes: 10_000, + } + } +} + #[derive(Debug, Clone, Serialize, utoipa::ToSchema)] pub struct RecommendationAdapterStatus { pub requested_algorithm: String, @@ -109,12 +135,13 @@ impl Default for RecommendationEngineConfig { explain: true, profile_safe_mode: true, pipeline: RecommendationPipelineConfig::default(), + allocation: RecommendationAllocationConfig::default(), } } } impl RecommendationEngineConfig { - async fn load(db: &crate::db::Database) -> Self { + pub(crate) async fn load(db: &crate::db::Database) -> Self { let mut cfg = Self::default(); cfg.weights.frequency = read_module_f64(db, "weight_frequency", cfg.weights.frequency, 0.0, 100.0).await; @@ -193,6 +220,19 @@ impl RecommendationEngineConfig { ); cfg.algorithm = "weighted_v1".to_string(); } + let requested_allocation_strategy = + read_module_string(db, "allocation_strategy", "weighted_v1").await; + if !is_supported_allocation_strategy(&requested_allocation_strategy) { + tracing::warn!( + allocation_strategy = %requested_allocation_strategy, + "unknown allocation strategy requested; falling back to weighted_v1" + ); + } + cfg.allocation.strategy = normalize_allocation_strategy(&requested_allocation_strategy); + cfg.allocation.exact_cover_max_options = + read_module_usize(db, "exact_cover_max_options", 256, 1, 256).await; + cfg.allocation.exact_cover_max_search_nodes = + read_module_usize(db, "exact_cover_max_search_nodes", 10_000, 1, 10_000).await; cfg } } @@ -249,6 +289,19 @@ fn is_local_dev_test_host(host: &str) -> bool { && labels.iter().all(|label| !label.is_empty()) } +fn normalize_allocation_strategy(strategy: &str) -> String { + let strategy = strategy.trim(); + if is_supported_allocation_strategy(strategy) { + strategy.to_string() + } else { + "weighted_v1".to_string() + } +} + +fn is_supported_allocation_strategy(strategy: &str) -> bool { + matches!(strategy.trim(), "weighted_v1" | "exact_cover_v1") +} + fn is_kubernetes_service_host(host: &str) -> bool { let labels = host.split('.').collect::>(); let is_short_service = labels.len() == 3 && labels[2] == "svc"; @@ -644,6 +697,7 @@ pub struct RecommendationStats { pub metrics_source: String, pub algorithm: String, pub algorithm_weights: RecommendationWeights, + pub allocation: RecommendationAllocationConfig, pub algorithm_adapter: RecommendationAdapterStatus, pub legal_boundary: RecommendationLegalBoundary, pub top_recommended_lots: Vec, @@ -982,6 +1036,7 @@ fn recommendation_config_hash(engine: &RecommendationEngineConfig) -> String { "explain": engine.explain, "profile_safe_mode": engine.profile_safe_mode, "pipeline": &engine.pipeline, + "allocation": &engine.allocation, }); sha256_hex( serde_json::to_string(&payload) @@ -1053,6 +1108,7 @@ pub async fn get_recommendation_stats( }; let engine = RecommendationEngineConfig::load(&state_guard.db).await; + let algorithm_adapter = adapter_status_for_weighted_v1(&engine); let stats = RecommendationStats { total_recommendations: audit_stats.total_batches, @@ -1065,7 +1121,8 @@ pub async fn get_recommendation_stats( metrics_source: "audit_log.RecommendationServed".to_string(), algorithm: engine.algorithm.clone(), algorithm_weights: engine.weights, - algorithm_adapter: adapter_status_for_weighted_v1(&engine), + allocation: engine.allocation, + algorithm_adapter, legal_boundary: RecommendationLegalBoundary { legal_review_required: true, attorney_review_status: "required_before_customer_wording".to_string(), @@ -1202,6 +1259,7 @@ mod tests { metrics_source: "audit_log.RecommendationServed".to_string(), algorithm: "weighted_v1".to_string(), algorithm_weights: RecommendationWeights::default(), + allocation: RecommendationAllocationConfig::default(), algorithm_adapter: adapter_status_for_weighted_v1( &RecommendationEngineConfig::default(), ), @@ -1381,6 +1439,22 @@ mod tests { assert!(validate_pipeline_endpoint(Some("file:///tmp/pipeline".to_string())).is_none()); } + #[test] + fn test_allocation_strategy_falls_back_to_weighted_v1() { + assert_eq!( + normalize_allocation_strategy("exact_cover_v1"), + "exact_cover_v1" + ); + assert_eq!( + normalize_allocation_strategy(" weighted_v1 "), + "weighted_v1" + ); + assert_eq!( + normalize_allocation_strategy("unknown_strategy"), + "weighted_v1" + ); + } + #[test] fn test_pipeline_run_url_trims_edges() { assert_eq!( diff --git a/parkhub-server/src/db/encryption.rs b/parkhub-server/src/db/encryption.rs index 8bc563ed..68bbc065 100644 --- a/parkhub-server/src/db/encryption.rs +++ b/parkhub-server/src/db/encryption.rs @@ -20,6 +20,7 @@ use sha2::Sha256; /// request, so the cost is paid only once per process start. pub(super) const PBKDF2_ITERATIONS: u32 = 600_000; +#[derive(Clone)] pub(super) struct Encryptor { cipher: Aes256Gcm, } diff --git a/parkhub-server/src/db/mod.rs b/parkhub-server/src/db/mod.rs index 4849f241..dcb1e1e2 100644 --- a/parkhub-server/src/db/mod.rs +++ b/parkhub-server/src/db/mod.rs @@ -210,6 +210,7 @@ pub(crate) fn pagination_offset(page: i32, per_page: i32) -> (usize, usize) { } /// Main database wrapper with optional encryption support +#[derive(Clone)] pub struct Database { pub(crate) inner: Arc>, encryptor: Option, diff --git a/scripts/check-recommendation-contract.sh b/scripts/check-recommendation-contract.sh index 97319eb0..5401fe65 100644 --- a/scripts/check-recommendation-contract.sh +++ b/scripts/check-recommendation-contract.sh @@ -6,6 +6,12 @@ cd "$ROOT" fixture="docs/recommendation-engine-fixtures/weighted_v1.basic.json" expected_fixture_sha="fe8ffc6a8cdb645f48ded1bebcaf3f48eb4d8576c95520a75378e2f4394b4bfa" +exact_cover_fixtures=( + "6dfcb84cd4eb61339135552ac82be5c2bb5d2f20682fac78b8ae4d10d9dad116 docs/recommendation-engine-fixtures/exact_cover_v1.batch_basic.json" + "b81532d1e4be7cab0e909701aee355a45a52d183981f7523b877d1dd9b5628da docs/recommendation-engine-fixtures/exact_cover_v1.empty.json" + "ded9af5e6b86cb6657a19c6d27a04b317da44d6f6e0f212d581039a89e1e6dfb docs/recommendation-engine-fixtures/exact_cover_v1.fairness_tiebreak.json" + "971ba4478425b038464de9ab7e3c411d631ab9f8eef9b738f8df90b0c237c378 docs/recommendation-engine-fixtures/exact_cover_v1.no_solution.json" +) require_file() { local path="$1" @@ -56,4 +62,48 @@ require_grep_each 'RecommendationServed' parkhub-server/src/api/recommendations. require_grep '"weighted_v1", "fop_pipeline_v1"' parkhub-server/src/api/modules/schemas.rs require_grep 'execution_allowed=false' docs/recommendation-engine-contract.md +for entry in "${exact_cover_fixtures[@]}"; do + expected_exact_cover_fixture_sha="${entry%% *}" + exact_cover_fixture="${entry#* }" + require_file "$exact_cover_fixture" + actual_exact_cover_fixture_sha="$(sha256sum "$exact_cover_fixture" | awk '{print $1}')" + if [[ "$actual_exact_cover_fixture_sha" != "$expected_exact_cover_fixture_sha" ]]; then + echo "ERROR: $exact_cover_fixture hash drifted: $actual_exact_cover_fixture_sha" >&2 + echo " expected: $expected_exact_cover_fixture_sha" >&2 + echo " Update both Rust/PHP exact-cover fixtures, tests, and this gate together." >&2 + exit 1 + fi + require_grep '"algorithm": "exact_cover_v1"' "$exact_cover_fixture" + require_grep '"legal_review_required": true' "$exact_cover_fixture" + require_grep '"attorney_review_status": "required_before_customer_wording"' "$exact_cover_fixture" + require_grep '"execution_allowed": false' "$exact_cover_fixture" + require_grep 'attorney review, citation verification, client authorization, and final legal judgment remain required' "$exact_cover_fixture" +done + +require_grep '"selected_option_ids": ["slot-a", "slot-b"]' docs/recommendation-engine-fixtures/exact_cover_v1.batch_basic.json +require_grep '"status": "fallback_no_solution"' docs/recommendation-engine-fixtures/exact_cover_v1.no_solution.json +require_grep 'deterministic fairness tie-break' docs/recommendation-engine-fixtures/exact_cover_v1.fairness_tiebreak.json +require_grep_each 'exact_cover_v1' \ + parkhub-server/src/api/recommendation_allocation.rs \ + docs/recommendation-engine-contract.md +require_grep 'allocation trace' docs/recommendation-engine-contract.md +require_grep 'allocation_trace_id' docs/recommendation-engine-contract.md parkhub-server/src/api/recommendation_allocation.rs +require_grep 'ExactCoverAllocationServed' parkhub-server/src/api/recommendation_allocation.rs +require_grep 'constraint_set_hash' parkhub-server/src/api/recommendation_allocation.rs +require_grep 'candidate_set_hash' parkhub-server/src/api/recommendation_allocation.rs +require_grep 'tenant_id' parkhub-server/src/api/recommendation_allocation.rs +require_grep 'tenant ID' docs/recommendation-engine-contract.md +require_grep 'resolve_tenant_id' parkhub-server/src/api/recommendation_allocation.rs +require_grep 'retention_deletion_class' parkhub-server/src/api/recommendation_allocation.rs +require_grep 'pseudonymous IDs only' docs/recommendation-engine-contract.md +require_grep 'eligibility constraints' docs/recommendation-engine-contract.md +require_grep 'legal-review flag' docs/recommendation-engine-contract.md +require_grep 'solve_exact_cover_v1' parkhub-server/src/api/recommendation_allocation.rs +require_grep 'solve_exact_cover_allocation' parkhub-server/src/api/recommendation_allocation.rs parkhub-server/src/api/mod.rs +require_grep '/api/v1/recommendations/allocation/exact-cover' parkhub-server/src/api/mod.rs docs/recommendation-engine-contract.md +require_grep 'pub mod recommendation_allocation' parkhub-server/src/api/mod.rs +require_grep 'exact_cover_v1_shared_fixtures_match_contract' parkhub-server/src/api/recommendation_allocation.rs +require_grep 'allocation_strategy' parkhub-server/src/api/modules/schemas.rs parkhub-server/src/api/recommendations.rs +require_grep 'exact_cover_max_search_nodes' parkhub-server/src/api/modules/schemas.rs parkhub-server/src/api/recommendations.rs + echo "ParkHub Rust recommendation contract gate OK." diff --git a/scripts/tests/test-legal-readiness-wording.sh b/scripts/tests/test-legal-readiness-wording.sh index b5abd5d1..30a8424f 100755 --- a/scripts/tests/test-legal-readiness-wording.sh +++ b/scripts/tests/test-legal-readiness-wording.sh @@ -77,6 +77,7 @@ require_text docs/release-checklist.md "requires_attorney_review" require_text docs/release-checklist.md "requires_human_signoff" require_text docs/release-checklist.md "execution_allowed" require_text docs/release-checklist.md "safety_boundary" +require_text docs/release-checklist.md "docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md" require_text docs/release-checklist.md 'GitHub `nash87/parkhub-rust` remains the CI/review source of truth' require_text README.md "docs/deployment-readiness-record.md" require_text README.md "docs/legal-readiness-parity.md" @@ -129,6 +130,16 @@ require_text docs/deployment-readiness-record.md "requires_attorney_review" require_text docs/deployment-readiness-record.md "requires_human_signoff" require_text docs/deployment-readiness-record.md "execution_allowed" require_text docs/deployment-readiness-record.md "safety_boundary" +require_text docs/deployment-readiness-record.md "docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md" +require_text docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md "# 2026-05-21 Exact-Cover Nido/fop Legal Catalog Evidence" +require_text docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md "NO_COLOR=true fop legal catalog --json" +require_text docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md "source_revision" +require_text docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md "generated_at" +require_text docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md "requires_attorney_review" +require_text docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md "requires_human_signoff" +require_text docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md "execution_allowed" +require_text docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md "not legal advice" +require_text docs/release-evidence/2026-05-21-exact-cover-nido-legal-catalog.md "deployment-specific configuration review" require_text docs/recommendation-engine-contract.md "Nido/fop legal catalog service" require_text docs/recommendation-engine-contract.md "fop legal catalog --json" require_text docs/recommendation-engine-contract.md "source_revision"