diff --git a/CHANGELOG.md b/CHANGELOG.md index 25e2c2f..1b063f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Changed (RFL-154 — helm operator seam cut) +- `helm-module-contracts` now owns the complete operator vocabulary: `operator_receipts` module (`JobReadinessPacket`, `OperatorLedgerEntry`, all receipt families, `OperatorControlError`, hash/ledger helpers) and `operator_preview` module (`OperatorControlPreview`, `OperatorControlPreviewBacking`, `OperatorReceiptFamilyView`, `operator_receipt_families()`). sha2 added as direct dep. +- `prio-agent-ops` is now manifest-only: capability advertisement remains, operator-control vocabulary removed. Dep-tree no longer includes truth-catalog→prism-analytics→polars; that transitive chain is Plan-B scope. +- `workbench-backend` repoints operator-control types to `helm-module-contracts`. +- `helm-operator-control` depends on `helm-module-contracts` only (drops `workbench-backend`, `prio-agent-ops`, `polars`). Seed loading extracted to `seed-gen` via `ShowcaseSeedSource` injection. No shim re-exports. +- `seed-gen` gains a library target (`src/lib.rs`) exposing `pub mod showcase_seed`, removing the `#![allow(dead_code)]` suppression. +- arena `cross-extension-smoke` repoint (prio-agent-ops → helm-module-contracts) lands in arena-tests immediately after this merge (RFL-154 T6). + +- `helm-module-contracts` bumped to 0.3.0: three new public modules (`showcase_pipeline`, `operator_receipts`, `operator_preview`) plus `sha2` as a direct runtime dep constitute minor surface expansion (RFL-154). All in-repo consumers updated. + +### Added (RFL-154 — helm operator seam cut) +- trybuild compile-fail suite in `helm-module-contracts`: one case proves private validation helpers (`validate_sha256`) are not callable from external code (parse-don't-validate gate). +- trybuild regression guards in `helm-operator-control`: two compile-fail cases prove `workbench_backend` and `prio_agent_ops` are no longer dep-resolvable. +- `#[ignore]`d soak test in `helm-operator-control/tests/module_test.rs` (`soak_packet_ledger_preview_no_drift`): 100 000 iterations of packet→ledger→preview via `StaticReadinessFeed`, asserts no id/hash drift; proof run at 10 000 iter in 1.65s (6 061 iter/s). +- `kb/Architecture/Foundation Contracts.md` — operator vocabulary row in contracts surface table. +- `kb/Architecture/Operator Control Common Module.md` — reflects RFL-154 ownership; contracts is the canonical import path. +- `kb/Architecture/Module Map.md` — `prio-agent-ops` scoped to manifest-only; new `Seam Contracts` section for `helm-module-contracts`. + ### Changed - `HelmModule` trait and `ModuleState` enum extracted from `runtime-runway/runway-app-host` into `helm-module-contracts` (RFL-128). `init()` no longer takes `&HostContext` — parameter was unused by all five modules. `helm-operator-control` and `helm-truth-execution` now import directly from `helm-module-contracts` with no `runway-app-host` dep; `helm-coordination`, `helm-governed-jobs`, and `helm-session-host` retain the dep for EventHub/SSE/SessionOwnershipLayer under approved `# RP-HELMS-SUBSTRATE-SEAM` seams. diff --git a/Cargo.lock b/Cargo.lock index 557db4f..6dfc1a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2734,6 +2734,12 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "dissimilar" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeda16ab4059c5fd2a83f2b9c9e9c981327b18aa8e3b313f7e6563799d4f093e" + [[package]] name = "dlopen2" version = "0.8.2" @@ -4199,14 +4205,17 @@ dependencies = [ [[package]] name = "helm-module-contracts" -version = "0.2.1" +version = "0.3.0" dependencies = [ "anyhow", "async-trait", "axum 0.8.9", + "proptest", "serde", "serde_json", + "sha2 0.11.0", "tokio", + "trybuild", ] [[package]] @@ -4231,13 +4240,11 @@ dependencies = [ "axum 0.8.9", "helm-module-contracts", "helm-truth-execution", - "polars", - "prio-agent-ops", "serde", "serde_json", "tokio", "tracing", - "workbench-backend", + "trybuild", ] [[package]] @@ -6654,6 +6661,7 @@ version = "0.1.0" dependencies = [ "application-kernel", "application-storage", + "helm-module-contracts", "organism-intelligence", "organism-notes", "prio-expenses", @@ -7709,8 +7717,6 @@ name = "prio-agent-ops" version = "0.2.1" dependencies = [ "capability-core", - "serde", - "sha2 0.11.0", ] [[package]] @@ -9341,9 +9347,14 @@ name = "seed-gen" version = "0.2.1" dependencies = [ "anyhow", + "async-trait", "chrono", + "helm-module-contracts", "polars", "rand 0.9.4", + "serde_json", + "tempfile", + "tokio", ] [[package]] @@ -10566,6 +10577,12 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" +[[package]] +name = "target-triple" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "591ef38edfb78ca4771ee32cf494cb8771944bee237a9b91fc9c1424ac4b777b" + [[package]] name = "tauri" version = "2.11.5" @@ -11581,6 +11598,22 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "trybuild" +version = "1.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0710d4dfbeae4f9c390baa784c49858a7468fa433f3fe5d0ec5ebef651cf59f9" +dependencies = [ + "dissimilar", + "glob", + "serde", + "serde_derive", + "serde_json", + "target-triple", + "termcolor", + "toml 1.1.2+spec-1.1.0", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -13165,9 +13198,9 @@ dependencies = [ "capability-core", "capability-registry", "chrono", + "helm-module-contracts", "organism-domain", "organism-runtime", - "prio-agent-ops", "serde", "serde_json", "thiserror 2.0.18", diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml index a23687b..1c5dd2f 100644 --- a/apps/desktop/src-tauri/Cargo.toml +++ b/apps/desktop/src-tauri/Cargo.toml @@ -10,12 +10,13 @@ tauri-build = { version = "2", features = [] } [features] default = ["embedded-backend"] -embedded-backend = ["dep:application-kernel", "dep:application-storage", "dep:workbench-backend"] +embedded-backend = ["dep:application-kernel", "dep:application-storage", "dep:workbench-backend", "dep:helm-module-contracts"] [dependencies] organism-intelligence = { workspace = true, features = ["ocr"] } organism-notes = { workspace = true, features = ["sources-apple-notes", "sources-web"] } prio-expenses = { path = "../../../crates/prio-expenses" } +helm-module-contracts = { version = "0.3.0", path = "../../../contracts/crates/helm-module-contracts", optional = true } workbench-backend = { path = "../../../crates/workbench-backend", optional = true } application-kernel = { path = "../../../crates/application-kernel", optional = true } application-storage = { path = "../../../crates/application-storage", features = ["surrealdb"], optional = true } diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index 32de808..db43e28 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -31,12 +31,13 @@ use tauri::State; #[cfg(feature = "embedded-backend")] use uuid::Uuid; #[cfg(feature = "embedded-backend")] +use helm_module_contracts::operator_preview::OperatorControlPreview; +#[cfg(feature = "embedded-backend")] use workbench_backend::{ AccountWorkspaceSummary, ApprovalFilter, ApprovalListItem, CatalogItemListItem, OperatorApp, - OperatorControlPreview, OperatorDashboard, OpportunityListItem, OrganizationListItem, - RecordReferenceItem, SubscriptionListItem, SystemProfile, TruthDetailItem, - TruthExecutionSession, TruthListItem, WorkbenchAppManifest, WorkflowCaseFilter, - WorkflowCaseListItem, + OperatorDashboard, OpportunityListItem, OrganizationListItem, RecordReferenceItem, + SubscriptionListItem, SystemProfile, TruthDetailItem, TruthExecutionSession, TruthListItem, + WorkbenchAppManifest, WorkflowCaseFilter, WorkflowCaseListItem, }; #[cfg(feature = "embedded-backend")] diff --git a/contracts/crates/helm-module-contracts/Cargo.toml b/contracts/crates/helm-module-contracts/Cargo.toml index 769e740..1cff427 100644 --- a/contracts/crates/helm-module-contracts/Cargo.toml +++ b/contracts/crates/helm-module-contracts/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "helm-module-contracts" description = "Shared contracts for Helm modules mounted into Runtime Runway." -version = "0.2.1" +version = "0.3.0" edition = "2024" license = "MIT" repository = "https://github.com/Reflective-Lab/helms" @@ -12,7 +12,10 @@ anyhow = "1" async-trait = "0.1" axum = "0.8" serde = { version = "1", features = ["derive"] } +sha2 = "0.11" [dev-dependencies] +proptest = "1" serde_json = "1" tokio = { version = "1", features = ["rt-multi-thread", "macros"] } +trybuild = { version = "1", features = ["diff"] } diff --git a/contracts/crates/helm-module-contracts/src/lib.rs b/contracts/crates/helm-module-contracts/src/lib.rs index 3cb885e..d6f257b 100644 --- a/contracts/crates/helm-module-contracts/src/lib.rs +++ b/contracts/crates/helm-module-contracts/src/lib.rs @@ -5,6 +5,25 @@ //! (`HelmModule`, `ModuleState`) so the interface lives in a neutral crate //! that both helms crates and Runtime Runway can consume without creating a //! foundation→substrate dependency (RP-LAYERING, RFL-128). +//! +//! The [`operator_receipts`] submodule owns the full operator-control receipt +//! vocabulary (all 18 types, hashing helpers, and `OperatorControlError`), +//! promoted here from `prio-agent-ops` as part of RFL-154. +//! +//! The [`operator_preview`] submodule provides read-only view types over the +//! receipts vocabulary (`OperatorControlPreview`, `OperatorControlPreviewBacking`, +//! `OperatorReceiptFamilyView`, `operator_receipt_families()`), promoted here +//! from `workbench-backend` as part of RFL-154 T2. +//! +//! The [`showcase_pipeline`] submodule owns the injection boundary types for +//! the showcase pipeline: [`showcase_pipeline::ShowcasePipelineInput`], +//! [`showcase_pipeline::SeedSourceError`], and the +//! [`showcase_pipeline::ShowcaseSeedSource`] trait the mounting app implements +//! (RFL-154 T5b). + +pub mod operator_preview; +pub mod operator_receipts; +pub mod showcase_pipeline; use std::sync::Arc; diff --git a/contracts/crates/helm-module-contracts/src/operator_preview.rs b/contracts/crates/helm-module-contracts/src/operator_preview.rs new file mode 100644 index 0000000..d651e06 --- /dev/null +++ b/contracts/crates/helm-module-contracts/src/operator_preview.rs @@ -0,0 +1,312 @@ +//! Read-only operator preview views over the receipts vocabulary. +//! +//! # Overview +//! +//! This module provides thin serialization-ready view types for composing +//! operator-control live feed payloads and workbench dashboard responses. +//! All types depend only on [`crate::operator_receipts`] (serde + sha2, +//! no transport); they are safe to import in any pure consumer. +//! +//! # Consumers +//! +//! - **Operator-control live feed** — `helm-operator-control` composes +//! [`OperatorControlPreview::live_app_feed`] from inbound packets and +//! ledger entries, then serializes it for the SSE/HTTP feed. +//! - **Workbench dashboard** — `workbench-backend` renders +//! [`OperatorReceiptFamilyView`] rows for the receipt-family explorer. +//! +//! # Transport-pure contract +//! +//! No axum import appears here. These types are constructed in the application +//! or workbench layer and serialized at the transport boundary, keeping this +//! module composable without pulling HTTP dependencies. + +use serde::Serialize; + +use crate::operator_receipts::{ + JobReadinessPacket, OperatorLedgerEntry, OperatorLedgerRecordKind, ReceiptFamily, +}; + +/// Operator-control preview payload for the live app feed. +/// +/// Compose via [`OperatorControlPreview::live_app_feed`]. +#[derive(Debug, Clone, Serialize)] +pub struct OperatorControlPreview { + /// The readiness packet for the job under review. + pub packet: JobReadinessPacket, + /// Ledger entries associated with this job. + pub ledger_entries: Vec, + /// All supported receipt families, each with their record kinds. + pub receipt_families: Vec, + /// What data source backs this preview. + pub backing: OperatorControlPreviewBacking, + /// Human-readable label for the backing source. + pub backing_label: &'static str, +} + +impl OperatorControlPreview { + /// Constructs a live app feed preview from an inbound readiness packet and + /// ledger entries. Sets [`OperatorControlPreviewBacking::LiveAppFeed`] and + /// populates all supported receipt family rows via + /// [`operator_receipt_families`]. + pub fn live_app_feed( + packet: JobReadinessPacket, + ledger_entries: Vec, + ) -> Self { + Self { + packet, + ledger_entries, + receipt_families: operator_receipt_families(), + backing: OperatorControlPreviewBacking::LiveAppFeed, + backing_label: OperatorControlPreviewBacking::LiveAppFeed.label(), + } + } +} + +/// The data source backing an [`OperatorControlPreview`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum OperatorControlPreviewBacking { + /// Backed by a live app evidence feed. + LiveAppFeed, +} + +impl OperatorControlPreviewBacking { + /// Returns the human-readable label for this backing variant. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::LiveAppFeed => "live", + } + } +} + +/// A view of a receipt family and its associated record kinds. +/// +/// Used in [`OperatorControlPreview::receipt_families`] to enumerate every +/// supported family for the operator dashboard. +#[derive(Debug, Clone, Serialize)] +pub struct OperatorReceiptFamilyView { + /// The receipt family discriminant. + pub family: ReceiptFamily, + /// Human-readable description of what receipts in this family track. + pub purpose: String, + /// All record kinds that belong to this family. + pub record_kinds: Vec, +} + +/// Returns the canonical ordered list of all operator receipt families with +/// their associated [`OperatorLedgerRecordKind`] members. +/// +/// Every call returns the same four-element slice in this order: +/// [`ReceiptFamily::Common`], [`ReceiptFamily::LongRunningJob`], +/// [`ReceiptFamily::TemporalEvidence`], [`ReceiptFamily::ContentPublication`]. +/// +/// This function is used by [`OperatorControlPreview::live_app_feed`] to +/// populate the `receipt_families` field on every preview response. +pub fn operator_receipt_families() -> Vec { + vec![ + OperatorReceiptFamilyView { + family: ReceiptFamily::Common, + purpose: "shared adapter and readiness receipts used by every app probe".to_string(), + record_kinds: vec![ + OperatorLedgerRecordKind::ObservationAdapterReceipt, + OperatorLedgerRecordKind::JobReadinessPacket, + ], + }, + OperatorReceiptFamilyView { + family: ReceiptFamily::LongRunningJob, + purpose: "approval, decision, plan, execution, action, and outcome milestones" + .to_string(), + record_kinds: vec![ + OperatorLedgerRecordKind::OperatorDecisionReceipt, + OperatorLedgerRecordKind::ApprovalReceipt, + OperatorLedgerRecordKind::PlanReceipt, + OperatorLedgerRecordKind::ExecutionReceipt, + OperatorLedgerRecordKind::ActionReceipt, + OperatorLedgerRecordKind::OutcomeReceipt, + ], + }, + OperatorReceiptFamilyView { + family: ReceiptFamily::TemporalEvidence, + purpose: "corpus snapshots, evidence windows, preserved disagreements, analyst review, and cited narrative claims".to_string(), + record_kinds: vec![ + OperatorLedgerRecordKind::CorpusSnapshotReceipt, + OperatorLedgerRecordKind::EvidenceWindowReceipt, + OperatorLedgerRecordKind::DisagreementReceipt, + OperatorLedgerRecordKind::AnalystReviewReceipt, + OperatorLedgerRecordKind::NarrativeClaimReceipt, + ], + }, + OperatorReceiptFamilyView { + family: ReceiptFamily::ContentPublication, + purpose: "canonical story, claim review, editorial approval, and publication boundary receipts".to_string(), + record_kinds: vec![ + OperatorLedgerRecordKind::CanonicalStoryReceipt, + OperatorLedgerRecordKind::ClaimReviewReceipt, + OperatorLedgerRecordKind::EditorialApprovalReceipt, + OperatorLedgerRecordKind::PublicationBoundaryReceipt, + ], + }, + ] +} + +#[cfg(test)] +mod tests { + use super::{ + operator_receipt_families, OperatorControlPreview, OperatorControlPreviewBacking, + }; + use crate::operator_receipts::{ + AdapterReceiptStatus, JobReadinessPacket, JobReadinessPacketInput, + OperatorLedgerRecordKind, ReceiptFamily, + }; + + fn minimal_packet() -> JobReadinessPacket { + let input = JobReadinessPacketInput { + package_id: "truth_package.test.1".to_string(), + truth_version: "truth.v1".to_string(), + domain_hint: "test.domain".to_string(), + job_key: "test-job".to_string(), + subject_ref: "test.subject.abc123".to_string(), + adapter_receipt_id: "artifact.adapter.abc123".to_string(), + adapter_status: AdapterReceiptStatus::Succeeded, + verdict: None, + authorizes_domain_action: false, + evidence_status: vec![], + fuzzy_trace: None, + verifier_forbidden_actions: vec![], + operator_actions: vec![], + }; + JobReadinessPacket::new(input).expect("minimal packet builds") + } + + #[test] + fn live_app_feed_sets_live_app_feed_backing() { + let preview = OperatorControlPreview::live_app_feed(minimal_packet(), vec![]); + assert_eq!(preview.backing, OperatorControlPreviewBacking::LiveAppFeed); + } + + #[test] + fn live_app_feed_label_is_live() { + let preview = OperatorControlPreview::live_app_feed(minimal_packet(), vec![]); + assert_eq!(preview.backing_label, "live"); + } + + #[test] + fn live_app_feed_backing_label_matches_variant_label() { + let preview = OperatorControlPreview::live_app_feed(minimal_packet(), vec![]); + assert_eq!(preview.backing_label, preview.backing.label()); + } + + #[test] + fn live_app_feed_populates_four_receipt_families() { + let preview = OperatorControlPreview::live_app_feed(minimal_packet(), vec![]); + assert_eq!(preview.receipt_families.len(), 4); + } + + #[test] + fn live_app_feed_receipt_families_order_is_stable() { + let preview = OperatorControlPreview::live_app_feed(minimal_packet(), vec![]); + assert_eq!(preview.receipt_families[0].family, ReceiptFamily::Common); + assert_eq!( + preview.receipt_families[1].family, + ReceiptFamily::LongRunningJob + ); + assert_eq!( + preview.receipt_families[2].family, + ReceiptFamily::TemporalEvidence + ); + assert_eq!( + preview.receipt_families[3].family, + ReceiptFamily::ContentPublication + ); + } + + #[test] + fn operator_receipt_families_common_record_kinds() { + let families = operator_receipt_families(); + let common = families + .iter() + .find(|f| f.family == ReceiptFamily::Common) + .expect("common family present"); + + assert!( + common + .record_kinds + .contains(&OperatorLedgerRecordKind::ObservationAdapterReceipt), + "common must include ObservationAdapterReceipt" + ); + assert!( + common + .record_kinds + .contains(&OperatorLedgerRecordKind::JobReadinessPacket), + "common must include JobReadinessPacket" + ); + } + + #[test] + fn operator_receipt_families_long_running_job_record_kinds() { + let families = operator_receipt_families(); + let lrj = families + .iter() + .find(|f| f.family == ReceiptFamily::LongRunningJob) + .expect("long running job family present"); + + for kind in [ + OperatorLedgerRecordKind::OperatorDecisionReceipt, + OperatorLedgerRecordKind::ApprovalReceipt, + OperatorLedgerRecordKind::PlanReceipt, + OperatorLedgerRecordKind::ExecutionReceipt, + OperatorLedgerRecordKind::ActionReceipt, + OperatorLedgerRecordKind::OutcomeReceipt, + ] { + assert!( + lrj.record_kinds.contains(&kind), + "LongRunningJob must include {kind:?}" + ); + } + } + + #[test] + fn operator_receipt_families_temporal_evidence_record_kinds() { + let families = operator_receipt_families(); + let te = families + .iter() + .find(|f| f.family == ReceiptFamily::TemporalEvidence) + .expect("temporal evidence family present"); + + for kind in [ + OperatorLedgerRecordKind::CorpusSnapshotReceipt, + OperatorLedgerRecordKind::EvidenceWindowReceipt, + OperatorLedgerRecordKind::DisagreementReceipt, + OperatorLedgerRecordKind::AnalystReviewReceipt, + OperatorLedgerRecordKind::NarrativeClaimReceipt, + ] { + assert!( + te.record_kinds.contains(&kind), + "TemporalEvidence must include {kind:?}" + ); + } + } + + #[test] + fn operator_receipt_families_content_publication_record_kinds() { + let families = operator_receipt_families(); + let cp = families + .iter() + .find(|f| f.family == ReceiptFamily::ContentPublication) + .expect("content publication family present"); + + for kind in [ + OperatorLedgerRecordKind::CanonicalStoryReceipt, + OperatorLedgerRecordKind::ClaimReviewReceipt, + OperatorLedgerRecordKind::EditorialApprovalReceipt, + OperatorLedgerRecordKind::PublicationBoundaryReceipt, + ] { + assert!( + cp.record_kinds.contains(&kind), + "ContentPublication must include {kind:?}" + ); + } + } +} diff --git a/contracts/crates/helm-module-contracts/src/operator_receipts.rs b/contracts/crates/helm-module-contracts/src/operator_receipts.rs new file mode 100644 index 0000000..b19fbcd --- /dev/null +++ b/contracts/crates/helm-module-contracts/src/operator_receipts.rs @@ -0,0 +1,1031 @@ +//! Operator receipt vocabulary for Helm operator-control modules. +//! +//! # Vocabulary +//! +//! This module owns the complete type vocabulary used by Helm's operator-control +//! layer: +//! +//! - [`JobReadinessPacket`] / [`JobReadinessPacketInput`] — read model for "can +//! this job be trusted enough for an operator to continue reviewing it?" +//! - [`OperatorLedgerEntry`] / [`OperatorLedgerEntryInput`] — deterministic +//! append-only ledger entries for Helm operator-control receipts. +//! - Supporting enums: [`AdapterReceiptStatus`], [`JobVerdict`], +//! [`EvidenceReadinessStatus`], [`OperatorLedgerRecordKind`], +//! [`ReceiptFamily`], [`AuthorityEffect`]. +//! - Evidence / fuzzy trace structs: [`JobEvidenceStatus`], +//! [`FuzzyReadinessTrace`], [`FuzzyMembership`], [`FuzzyRuleActivation`], +//! [`FuzzyDefuzzifiedScore`]. +//! - Free functions: [`job_readiness_packet_payload_hash`], +//! [`job_readiness_packet_ledger_entry`]. +//! - Error type: [`OperatorControlError`]. +//! +//! # Non-authority invariant +//! +//! [`OperatorLedgerEntry::new`] always yields [`AuthorityEffect::None`]. +//! Helm's operator-control layer is a control-plane journal; it stores ids, +//! refs, and hashes, and it **never** grants domain authority. Calling code that +//! attempts to request domain action authority via +//! [`JobReadinessPacketInput::authorizes_domain_action`] is rejected with +//! [`OperatorControlError::DomainActionAuthorityRequested`]. +//! +//! # Why this lives in a neutral contract crate +//! +//! Placing this vocabulary here (RP-LAYERING, RFL-128 → RFL-154) means both +//! `prio-agent-ops` and `workbench-backend` can consume it without introducing a +//! foundation→substrate dependency. The only dependencies are `serde` and +//! `sha2`, so pure consumers never pull in application-kernel, axum, or any +//! platform runtime. +//! +//! # Implementors and consumers +//! +//! - **Producers:** readiness-feed implementations in mounting apps (concrete +//! types that supply `JobReadinessPacket` / `OperatorLedgerEntry` snapshots +//! via `OperatorControlReadinessFeed`). `helm-operator-control` is the module +//! surface that wires these producers into the HTTP layer. +//! - **Consumer:** `workbench-backend` — the Helm workbench HTTP layer that +//! reads packets and entries for display and auditing. + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + error::Error, + fmt::{self, Write as _}, +}; + +/// Helm-owned read model for "can this job be trusted enough for an operator +/// to continue reviewing it?" +/// +/// This is intentionally not an Axiom type. Axiom supplies packages, reports, +/// clause ids, and adapter receipts. Helm composes those with app subject refs, +/// missing-evidence actions, and operator ledger links. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JobReadinessPacket { + pub packet_id: String, + pub package_id: String, + pub truth_version: String, + pub domain_hint: String, + pub job_key: String, + pub subject_ref: String, + pub adapter_receipt_id: String, + pub adapter_status: AdapterReceiptStatus, + pub verdict: Option, + pub authorizes_domain_action: bool, + pub evidence_status: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fuzzy_trace: Option, + pub verifier_forbidden_actions: Vec, + pub operator_actions: Vec, +} + +impl JobReadinessPacket { + /// Builds a deterministic packet and enforces the Helm boundary that a + /// readiness view never authorizes the underlying app action. + pub fn new(input: JobReadinessPacketInput) -> Result { + validate_nonempty("package_id", &input.package_id)?; + validate_nonempty("truth_version", &input.truth_version)?; + validate_nonempty("domain_hint", &input.domain_hint)?; + validate_nonempty("job_key", &input.job_key)?; + validate_nonempty("subject_ref", &input.subject_ref)?; + validate_nonempty("adapter_receipt_id", &input.adapter_receipt_id)?; + if let Some(trace) = &input.fuzzy_trace { + validate_fuzzy_trace(trace)?; + } + if input.authorizes_domain_action { + return Err(OperatorControlError::DomainActionAuthorityRequested); + } + + let packet_id = job_readiness_packet_id(&input); + Ok(Self { + packet_id, + package_id: input.package_id, + truth_version: input.truth_version, + domain_hint: input.domain_hint, + job_key: input.job_key, + subject_ref: input.subject_ref, + adapter_receipt_id: input.adapter_receipt_id, + adapter_status: input.adapter_status, + verdict: input.verdict, + authorizes_domain_action: false, + evidence_status: input.evidence_status, + fuzzy_trace: input.fuzzy_trace, + verifier_forbidden_actions: input.verifier_forbidden_actions, + operator_actions: input.operator_actions, + }) + } +} + +/// Input for constructing a [`JobReadinessPacket`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JobReadinessPacketInput { + pub package_id: String, + pub truth_version: String, + pub domain_hint: String, + pub job_key: String, + pub subject_ref: String, + pub adapter_receipt_id: String, + pub adapter_status: AdapterReceiptStatus, + pub verdict: Option, + pub authorizes_domain_action: bool, + pub evidence_status: Vec, + pub fuzzy_trace: Option, + pub verifier_forbidden_actions: Vec, + pub operator_actions: Vec, +} + +/// Evidence readiness status for a single clause in a [`JobReadinessPacket`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct JobEvidenceStatus { + pub clause_id: String, + pub clause_key: String, + pub label: String, + pub status: EvidenceReadinessStatus, + pub fact_ids: Vec, + pub evidence_refs: Vec, + pub trace_links: Vec, + pub concern_record_ids: Vec, +} + +/// Fuzzy logic readiness trace attached to a [`JobReadinessPacket`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FuzzyReadinessTrace { + pub variable_key: String, + pub observed_value_basis_points: u16, + pub memberships: Vec, + pub activated_rules: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub defuzzified_score: Option, +} + +/// A single fuzzy set membership in a [`FuzzyReadinessTrace`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FuzzyMembership { + pub label: String, + pub score_basis_points: u16, +} + +/// A single activated fuzzy rule in a [`FuzzyReadinessTrace`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FuzzyRuleActivation { + pub rule_id: String, + pub strength_basis_points: u16, + pub conclusion: String, +} + +/// Defuzzified output score in a [`FuzzyReadinessTrace`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FuzzyDefuzzifiedScore { + pub method: String, + pub score_basis_points: u16, + pub domain_min_basis_points: u16, + pub domain_max_basis_points: u16, + pub domain_steps: u32, +} + +/// Status of the adapter receipt attached to a job readiness packet. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AdapterReceiptStatus { + Succeeded, + Rejected, +} + +impl AdapterReceiptStatus { + /// Returns the canonical wire string for this status. + pub const fn as_str(self) -> &'static str { + match self { + Self::Succeeded => "succeeded", + Self::Rejected => "rejected", + } + } +} + +/// Job verdict in a [`JobReadinessPacket`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum JobVerdict { + Satisfied, + Blocked, + Exhausted, + Invalid, +} + +impl JobVerdict { + /// Returns the canonical wire string for this verdict. + pub const fn as_str(self) -> &'static str { + match self { + Self::Satisfied => "satisfied", + Self::Blocked => "blocked", + Self::Exhausted => "exhausted", + Self::Invalid => "invalid", + } + } +} + +/// Evidence readiness status for a single clause. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum EvidenceReadinessStatus { + Present, + Missing, + Disputed, + Blocked, + Concern, +} + +impl EvidenceReadinessStatus { + /// Returns the canonical wire string for this status. + pub const fn as_str(self) -> &'static str { + match self { + Self::Present => "present", + Self::Missing => "missing", + Self::Disputed => "disputed", + Self::Blocked => "blocked", + Self::Concern => "concern", + } + } +} + +/// Deterministic append-only ledger entry for Helm operator-control receipts. +/// +/// This is a control-plane journal entry. It stores ids, refs, hashes, and +/// backlinks; it does not store raw app transcripts and it never grants domain +/// authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperatorLedgerEntry { + pub entry_id: String, + pub sequence: u64, + pub record_kind: OperatorLedgerRecordKind, + pub receipt_family: ReceiptFamily, + pub source_ref: String, + pub package_id: String, + pub truth_version: String, + pub domain_hint: String, + pub payload_hash: String, + pub backlink_ids: Vec, + pub authority_effect: AuthorityEffect, + pub summary: String, +} + +impl OperatorLedgerEntry { + /// Builds a deterministic ledger entry. `authority_effect` is always + /// [`AuthorityEffect::None`] — this crate is a control-plane journal, not + /// an authority grant. + pub fn new(input: OperatorLedgerEntryInput) -> Result { + validate_nonempty("source_ref", &input.source_ref)?; + validate_nonempty("package_id", &input.package_id)?; + validate_nonempty("truth_version", &input.truth_version)?; + validate_nonempty("domain_hint", &input.domain_hint)?; + validate_nonempty("summary", &input.summary)?; + validate_sha256("payload_hash", &input.payload_hash)?; + if input.backlink_ids.iter().any(|id| id.trim().is_empty()) { + return Err(OperatorControlError::EmptyBacklink); + } + + let entry_id = operator_ledger_entry_id(&input); + Ok(Self { + entry_id, + sequence: input.sequence, + record_kind: input.record_kind, + receipt_family: input.receipt_family, + source_ref: input.source_ref, + package_id: input.package_id, + truth_version: input.truth_version, + domain_hint: input.domain_hint, + payload_hash: input.payload_hash, + backlink_ids: input.backlink_ids, + authority_effect: AuthorityEffect::None, + summary: input.summary, + }) + } +} + +/// Computes the deterministic SHA-256 payload hash for a [`JobReadinessPacket`]. +/// +/// The hash covers the full packet: its id, all field values, evidence status +/// items, fuzzy trace (if any), forbidden actions, and operator actions. Use +/// this hash as the `payload_hash` field when building an +/// [`OperatorLedgerEntry`] that records this packet. +pub fn job_readiness_packet_payload_hash(packet: &JobReadinessPacket) -> String { + let evidence_hash = evidence_status_hash(&packet.evidence_status); + let fuzzy_hash = packet + .fuzzy_trace + .as_ref() + .map_or_else(|| "none".to_string(), fuzzy_trace_hash); + let forbidden_hash = string_list_hash(&packet.verifier_forbidden_actions); + let action_hash = string_list_hash(&packet.operator_actions); + let verdict = packet.verdict.map_or("none", JobVerdict::as_str); + + sha256_lines(&[ + "job_readiness_packet_payload", + packet.packet_id.as_str(), + packet.package_id.as_str(), + packet.truth_version.as_str(), + packet.domain_hint.as_str(), + packet.job_key.as_str(), + packet.subject_ref.as_str(), + packet.adapter_receipt_id.as_str(), + packet.adapter_status.as_str(), + verdict, + evidence_hash.as_str(), + fuzzy_hash.as_str(), + forbidden_hash.as_str(), + action_hash.as_str(), + ]) +} + +/// Convenience constructor that builds an [`OperatorLedgerEntry`] for a +/// [`JobReadinessPacket`], using [`job_readiness_packet_payload_hash`] as the +/// `payload_hash` and [`OperatorLedgerRecordKind::JobReadinessPacket`] / +/// [`ReceiptFamily::Common`] as the record metadata. +pub fn job_readiness_packet_ledger_entry( + sequence: u64, + packet: &JobReadinessPacket, + backlink_ids: Vec, + summary: impl Into, +) -> Result { + OperatorLedgerEntry::new(OperatorLedgerEntryInput { + sequence, + record_kind: OperatorLedgerRecordKind::JobReadinessPacket, + receipt_family: ReceiptFamily::Common, + source_ref: packet.packet_id.clone(), + package_id: packet.package_id.clone(), + truth_version: packet.truth_version.clone(), + domain_hint: packet.domain_hint.clone(), + payload_hash: job_readiness_packet_payload_hash(packet), + backlink_ids, + summary: summary.into(), + }) +} + +/// Input for constructing an [`OperatorLedgerEntry`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperatorLedgerEntryInput { + pub sequence: u64, + pub record_kind: OperatorLedgerRecordKind, + pub receipt_family: ReceiptFamily, + pub source_ref: String, + pub package_id: String, + pub truth_version: String, + pub domain_hint: String, + pub payload_hash: String, + pub backlink_ids: Vec, + pub summary: String, +} + +/// Discriminant for the kind of receipt recorded in an [`OperatorLedgerEntry`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperatorLedgerRecordKind { + ObservationAdapterReceipt, + JobReadinessPacket, + OperatorDecisionReceipt, + ApprovalReceipt, + PlanReceipt, + ExecutionReceipt, + ActionReceipt, + OutcomeReceipt, + CorpusSnapshotReceipt, + EvidenceWindowReceipt, + DisagreementReceipt, + AnalystReviewReceipt, + NarrativeClaimReceipt, + CanonicalStoryReceipt, + ClaimReviewReceipt, + EditorialApprovalReceipt, + PublicationBoundaryReceipt, + AppLocalReceipt, +} + +impl OperatorLedgerRecordKind { + /// Returns the canonical wire string for this record kind. + pub const fn as_str(self) -> &'static str { + match self { + Self::ObservationAdapterReceipt => "observation_adapter_receipt", + Self::JobReadinessPacket => "job_readiness_packet", + Self::OperatorDecisionReceipt => "operator_decision_receipt", + Self::ApprovalReceipt => "approval_receipt", + Self::PlanReceipt => "plan_receipt", + Self::ExecutionReceipt => "execution_receipt", + Self::ActionReceipt => "action_receipt", + Self::OutcomeReceipt => "outcome_receipt", + Self::CorpusSnapshotReceipt => "corpus_snapshot_receipt", + Self::EvidenceWindowReceipt => "evidence_window_receipt", + Self::DisagreementReceipt => "disagreement_receipt", + Self::AnalystReviewReceipt => "analyst_review_receipt", + Self::NarrativeClaimReceipt => "narrative_claim_receipt", + Self::CanonicalStoryReceipt => "canonical_story_receipt", + Self::ClaimReviewReceipt => "claim_review_receipt", + Self::EditorialApprovalReceipt => "editorial_approval_receipt", + Self::PublicationBoundaryReceipt => "publication_boundary_receipt", + Self::AppLocalReceipt => "app_local_receipt", + } + } +} + +/// Receipt family classification for an [`OperatorLedgerEntry`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ReceiptFamily { + Common, + LongRunningJob, + TemporalEvidence, + ContentPublication, + AppLocal, +} + +impl ReceiptFamily { + /// Returns the canonical wire string for this family. + pub const fn as_str(self) -> &'static str { + match self { + Self::Common => "common", + Self::LongRunningJob => "long_running_job", + Self::TemporalEvidence => "temporal_evidence", + Self::ContentPublication => "content_publication", + Self::AppLocal => "app_local", + } + } +} + +/// The authority effect recorded on an [`OperatorLedgerEntry`]. +/// +/// The only valid value is [`AuthorityEffect::None`]: ledger entries are +/// control-plane journal records and never grant domain authority. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AuthorityEffect { + None, +} + +impl AuthorityEffect { + /// Returns the canonical wire string for this effect. + pub const fn as_str(self) -> &'static str { + match self { + Self::None => "none", + } + } +} + +/// Errors returned by operator receipt constructors. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum OperatorControlError { + /// A required string field was empty or whitespace-only. + EmptyField { field: &'static str }, + /// A backlink id in the backlink list was empty or whitespace-only. + EmptyBacklink, + /// A basis-point value exceeded 10 000. + InvalidBasisPoints { field: &'static str, value: u16 }, + /// A range had `min >= max`. + InvalidRange { field: &'static str, min: u16, max: u16 }, + /// A count field was zero. + InvalidCount { field: &'static str, value: u32 }, + /// A field expected to hold a `sha256:` digest held something else. + InvalidSha256 { field: &'static str, value: String }, + /// The caller set `authorizes_domain_action = true`; this is forbidden. + DomainActionAuthorityRequested, +} + +impl fmt::Display for OperatorControlError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::EmptyField { field } => write!(f, "`{field}` must not be empty"), + Self::EmptyBacklink => write!(f, "backlink ids must not contain empty values"), + Self::InvalidBasisPoints { field, value } => { + write!(f, "`{field}` must be between 0 and 10000, got `{value}`") + } + Self::InvalidRange { field, min, max } => { + write!(f, "`{field}` must have min < max, got `{min}`..`{max}`") + } + Self::InvalidCount { field, value } => { + write!(f, "`{field}` must be greater than zero, got `{value}`") + } + Self::InvalidSha256 { field, value } => { + write!(f, "`{field}` must be a sha256 hash, got `{value}`") + } + Self::DomainActionAuthorityRequested => { + write!(f, "job readiness packets must not authorize domain action") + } + } + } +} + +impl Error for OperatorControlError {} + +// ── Private hashing and validation helpers ──────────────────────────────────── + +fn job_readiness_packet_id(input: &JobReadinessPacketInput) -> String { + let evidence_hash = evidence_status_hash(&input.evidence_status); + let fuzzy_hash = input + .fuzzy_trace + .as_ref() + .map_or_else(|| "none".to_string(), fuzzy_trace_hash); + let forbidden_hash = string_list_hash(&input.verifier_forbidden_actions); + let action_hash = string_list_hash(&input.operator_actions); + let verdict = input.verdict.map_or("none", JobVerdict::as_str); + + short_id( + "helm.job_readiness", + &sha256_lines(&[ + "job_readiness_packet", + input.package_id.as_str(), + input.truth_version.as_str(), + input.domain_hint.as_str(), + input.job_key.as_str(), + input.subject_ref.as_str(), + input.adapter_receipt_id.as_str(), + input.adapter_status.as_str(), + verdict, + evidence_hash.as_str(), + fuzzy_hash.as_str(), + forbidden_hash.as_str(), + action_hash.as_str(), + ]), + ) +} + +fn operator_ledger_entry_id(input: &OperatorLedgerEntryInput) -> String { + let backlinks_hash = string_list_hash(&input.backlink_ids); + let sequence = input.sequence.to_string(); + short_id( + "helm.ledger_entry", + &sha256_lines(&[ + "operator_ledger_entry", + sequence.as_str(), + input.record_kind.as_str(), + input.receipt_family.as_str(), + input.source_ref.as_str(), + input.package_id.as_str(), + input.truth_version.as_str(), + input.domain_hint.as_str(), + input.payload_hash.as_str(), + backlinks_hash.as_str(), + ]), + ) +} + +fn evidence_status_hash(statuses: &[JobEvidenceStatus]) -> String { + let mut parts = Vec::new(); + for status in statuses { + parts.push(status.clause_id.clone()); + parts.push(status.clause_key.clone()); + parts.push(status.label.clone()); + parts.push(status.status.as_str().to_string()); + parts.push(string_list_hash(&status.fact_ids)); + parts.push(string_list_hash(&status.evidence_refs)); + parts.push(string_list_hash(&status.trace_links)); + parts.push(string_list_hash(&status.concern_record_ids)); + } + string_list_hash(&parts) +} + +fn fuzzy_trace_hash(trace: &FuzzyReadinessTrace) -> String { + let observed = trace.observed_value_basis_points.to_string(); + let membership_hash = fuzzy_membership_hash(&trace.memberships); + let rule_hash = fuzzy_rule_hash(&trace.activated_rules); + let defuzzified_hash = trace + .defuzzified_score + .as_ref() + .map_or_else(|| "none".to_string(), fuzzy_defuzzified_score_hash); + sha256_lines(&[ + "fuzzy_readiness_trace", + trace.variable_key.as_str(), + observed.as_str(), + membership_hash.as_str(), + rule_hash.as_str(), + defuzzified_hash.as_str(), + ]) +} + +fn fuzzy_membership_hash(memberships: &[FuzzyMembership]) -> String { + let mut parts = Vec::new(); + for membership in memberships { + parts.push(membership.label.clone()); + parts.push(membership.score_basis_points.to_string()); + } + string_list_hash(&parts) +} + +fn fuzzy_rule_hash(rules: &[FuzzyRuleActivation]) -> String { + let mut parts = Vec::new(); + for rule in rules { + parts.push(rule.rule_id.clone()); + parts.push(rule.strength_basis_points.to_string()); + parts.push(rule.conclusion.clone()); + } + string_list_hash(&parts) +} + +fn fuzzy_defuzzified_score_hash(score: &FuzzyDefuzzifiedScore) -> String { + let score_basis_points = score.score_basis_points.to_string(); + let domain_min_basis_points = score.domain_min_basis_points.to_string(); + let domain_max_basis_points = score.domain_max_basis_points.to_string(); + let domain_steps = score.domain_steps.to_string(); + sha256_lines(&[ + "fuzzy_defuzzified_score", + score.method.as_str(), + score_basis_points.as_str(), + domain_min_basis_points.as_str(), + domain_max_basis_points.as_str(), + domain_steps.as_str(), + ]) +} + +fn string_list_hash(values: &[String]) -> String { + let refs = values.iter().map(String::as_str).collect::>(); + sha256_lines(&refs) +} + +fn validate_fuzzy_trace(trace: &FuzzyReadinessTrace) -> Result<(), OperatorControlError> { + validate_nonempty("fuzzy_trace.variable_key", &trace.variable_key)?; + validate_basis_points( + "fuzzy_trace.observed_value_basis_points", + trace.observed_value_basis_points, + )?; + if trace.memberships.is_empty() { + return Err(OperatorControlError::EmptyField { + field: "fuzzy_trace.memberships", + }); + } + for membership in &trace.memberships { + validate_nonempty("fuzzy_trace.membership.label", &membership.label)?; + validate_basis_points( + "fuzzy_trace.membership.score_basis_points", + membership.score_basis_points, + )?; + } + for rule in &trace.activated_rules { + validate_nonempty("fuzzy_trace.rule.rule_id", &rule.rule_id)?; + validate_basis_points( + "fuzzy_trace.rule.strength_basis_points", + rule.strength_basis_points, + )?; + validate_nonempty("fuzzy_trace.rule.conclusion", &rule.conclusion)?; + } + if let Some(score) = &trace.defuzzified_score { + validate_nonempty("fuzzy_trace.defuzzified_score.method", &score.method)?; + validate_basis_points( + "fuzzy_trace.defuzzified_score.score_basis_points", + score.score_basis_points, + )?; + validate_basis_points( + "fuzzy_trace.defuzzified_score.domain_min_basis_points", + score.domain_min_basis_points, + )?; + validate_basis_points( + "fuzzy_trace.defuzzified_score.domain_max_basis_points", + score.domain_max_basis_points, + )?; + if score.domain_min_basis_points >= score.domain_max_basis_points { + return Err(OperatorControlError::InvalidRange { + field: "fuzzy_trace.defuzzified_score.domain", + min: score.domain_min_basis_points, + max: score.domain_max_basis_points, + }); + } + if score.domain_steps == 0 { + return Err(OperatorControlError::InvalidCount { + field: "fuzzy_trace.defuzzified_score.domain_steps", + value: score.domain_steps, + }); + } + } + Ok(()) +} + +fn validate_basis_points(field: &'static str, value: u16) -> Result<(), OperatorControlError> { + if value <= 10_000 { + Ok(()) + } else { + Err(OperatorControlError::InvalidBasisPoints { field, value }) + } +} + +fn validate_nonempty(field: &'static str, value: &str) -> Result<(), OperatorControlError> { + if value.trim().is_empty() { + Err(OperatorControlError::EmptyField { field }) + } else { + Ok(()) + } +} + +fn validate_sha256(field: &'static str, value: &str) -> Result<(), OperatorControlError> { + if value.strip_prefix("sha256:").is_some_and(|digest| { + digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) + }) { + Ok(()) + } else { + Err(OperatorControlError::InvalidSha256 { + field, + value: value.to_string(), + }) + } +} + +fn short_id(prefix: &str, digest: &str) -> String { + let short_digest = &digest + .strip_prefix("sha256:") + .expect("local digest has sha256 prefix")[..12]; + format!("{prefix}.{short_digest}") +} + +fn sha256_lines(parts: &[&str]) -> String { + sha256_bytes(parts.join("\n").as_bytes()) +} + +fn sha256_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let digest = hasher.finalize(); + let mut output = String::with_capacity("sha256:".len() + digest.len() * 2); + output.push_str("sha256:"); + for byte in digest { + write!(&mut output, "{byte:02x}").expect("writing to String cannot fail"); + } + output +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn job_readiness_packet_id_is_deterministic() { + let first = JobReadinessPacket::new(sample_packet_input()).expect("packet builds"); + let second = JobReadinessPacket::new(sample_packet_input()).expect("packet builds"); + + assert_eq!(first, second); + assert!(first.packet_id.starts_with("helm.job_readiness.")); + assert!(!first.authorizes_domain_action); + assert_eq!(first.evidence_status.len(), 2); + } + + #[test] + fn job_readiness_packet_id_changes_when_evidence_changes() { + let first = JobReadinessPacket::new(sample_packet_input()).expect("packet builds"); + let mut input = sample_packet_input(); + input.evidence_status[1].status = EvidenceReadinessStatus::Present; + input.evidence_status[1] + .fact_ids + .push("folio.editorial.claim-citations".to_string()); + let second = JobReadinessPacket::new(input).expect("packet builds"); + + assert_ne!(first.packet_id, second.packet_id); + } + + #[test] + fn job_readiness_packet_id_changes_when_fuzzy_trace_changes() { + let mut input = sample_packet_input(); + input.fuzzy_trace = Some(sample_fuzzy_trace(6_200, 3_500)); + let first = JobReadinessPacket::new(input).expect("packet builds"); + let mut input = sample_packet_input(); + input.fuzzy_trace = Some(sample_fuzzy_trace(7_000, 5_000)); + let second = JobReadinessPacket::new(input).expect("packet builds"); + + assert_ne!(first.packet_id, second.packet_id); + assert_eq!( + first + .fuzzy_trace + .as_ref() + .expect("fuzzy trace") + .memberships + .len(), + 2 + ); + } + + #[test] + fn job_readiness_packet_id_changes_when_defuzzified_score_changes() { + let mut first_input = sample_packet_input(); + let mut first_trace = sample_fuzzy_trace(6_200, 3_500); + first_trace.defuzzified_score = Some(sample_defuzzified_score(3_750)); + first_input.fuzzy_trace = Some(first_trace); + let first = JobReadinessPacket::new(first_input).expect("packet builds"); + + let mut second_input = sample_packet_input(); + let mut second_trace = sample_fuzzy_trace(6_200, 3_500); + second_trace.defuzzified_score = Some(sample_defuzzified_score(4_250)); + second_input.fuzzy_trace = Some(second_trace); + let second = JobReadinessPacket::new(second_input).expect("packet builds"); + + assert_ne!(first.packet_id, second.packet_id); + assert_eq!( + first + .fuzzy_trace + .as_ref() + .and_then(|trace| trace.defuzzified_score.as_ref()) + .expect("defuzzified score") + .method + .as_str(), + "centroid" + ); + } + + #[test] + fn job_readiness_packet_rejects_invalid_fuzzy_scores() { + let mut input = sample_packet_input(); + input.fuzzy_trace = Some(sample_fuzzy_trace(10_001, 3_500)); + + let error = JobReadinessPacket::new(input).expect_err("invalid score"); + assert_eq!( + error, + OperatorControlError::InvalidBasisPoints { + field: "fuzzy_trace.observed_value_basis_points", + value: 10_001 + } + ); + } + + #[test] + fn job_readiness_packet_rejects_invalid_defuzzified_score_domain() { + let mut input = sample_packet_input(); + let mut trace = sample_fuzzy_trace(6_200, 3_500); + trace.defuzzified_score = Some(FuzzyDefuzzifiedScore { + method: "centroid".to_string(), + score_basis_points: 3_750, + domain_min_basis_points: 10_000, + domain_max_basis_points: 10_000, + domain_steps: 1_000, + }); + input.fuzzy_trace = Some(trace); + + let error = JobReadinessPacket::new(input).expect_err("invalid domain"); + assert_eq!( + error, + OperatorControlError::InvalidRange { + field: "fuzzy_trace.defuzzified_score.domain", + min: 10_000, + max: 10_000, + } + ); + } + + #[test] + fn job_readiness_packet_rejects_domain_authority() { + let mut input = sample_packet_input(); + input.authorizes_domain_action = true; + + let error = JobReadinessPacket::new(input).expect_err("authority is rejected"); + assert_eq!(error, OperatorControlError::DomainActionAuthorityRequested); + } + + #[test] + fn operator_ledger_entry_is_deterministic_and_non_authoritative() { + let first = OperatorLedgerEntry::new(sample_ledger_input()).expect("entry builds"); + let second = OperatorLedgerEntry::new(sample_ledger_input()).expect("entry builds"); + + assert_eq!(first, second); + assert!(first.entry_id.starts_with("helm.ledger_entry.")); + assert_eq!(first.authority_effect, AuthorityEffect::None); + assert_eq!( + first.record_kind, + OperatorLedgerRecordKind::JobReadinessPacket + ); + assert_eq!(first.receipt_family, ReceiptFamily::Common); + } + + #[test] + fn operator_ledger_entry_rejects_non_hash_payloads() { + let mut input = sample_ledger_input(); + input.payload_hash = "raw-json-payload".to_string(); + + let error = OperatorLedgerEntry::new(input).expect_err("raw payload hash is rejected"); + assert_eq!( + error, + OperatorControlError::InvalidSha256 { + field: "payload_hash", + value: "raw-json-payload".to_string() + } + ); + } + + #[test] + fn operator_ledger_entry_id_changes_when_backlinks_change() { + let first = OperatorLedgerEntry::new(sample_ledger_input()).expect("entry builds"); + let mut input = sample_ledger_input(); + input + .backlink_ids + .push("helm.claim_review.9b8f00ab1111".to_string()); + let second = OperatorLedgerEntry::new(input).expect("entry builds"); + + assert_ne!(first.entry_id, second.entry_id); + } + + #[test] + fn job_readiness_packet_ledger_entry_uses_packet_payload_hash() { + let packet = JobReadinessPacket::new(sample_packet_input()).expect("packet builds"); + let entry = job_readiness_packet_ledger_entry( + 7, + &packet, + vec!["artifact.adapter.abcdef012345".to_string()], + "job readiness preview", + ) + .expect("entry builds"); + + assert_eq!( + entry.record_kind, + OperatorLedgerRecordKind::JobReadinessPacket + ); + assert_eq!(entry.receipt_family, ReceiptFamily::Common); + assert_eq!(entry.source_ref, packet.packet_id); + assert_eq!( + entry.payload_hash, + job_readiness_packet_payload_hash(&packet) + ); + assert_eq!(entry.authority_effect, AuthorityEffect::None); + } + + fn sample_packet_input() -> JobReadinessPacketInput { + JobReadinessPacketInput { + package_id: "truth_package.folio.1234".to_string(), + truth_version: "truth.v1".to_string(), + domain_hint: "folio-editor.publication-boundary".to_string(), + job_key: "folio-publication-package".to_string(), + subject_ref: "folio.subject.abcdef012345".to_string(), + adapter_receipt_id: "artifact.adapter.abcdef012345".to_string(), + adapter_status: AdapterReceiptStatus::Succeeded, + verdict: Some(JobVerdict::Invalid), + authorizes_domain_action: false, + evidence_status: vec![ + JobEvidenceStatus { + clause_id: "clause.evidence.1".to_string(), + clause_key: "canonical_story_snapshot_bound".to_string(), + label: "canonical story snapshot is bound".to_string(), + status: EvidenceReadinessStatus::Present, + fact_ids: vec!["folio.editorial.canonical-story".to_string()], + evidence_refs: vec!["evidence:folio.editorial.canonical-story".to_string()], + trace_links: vec!["trace:folio.editorial.canonical-story".to_string()], + concern_record_ids: Vec::new(), + }, + JobEvidenceStatus { + clause_id: "clause.evidence.2".to_string(), + clause_key: "claim_citations_attached".to_string(), + label: "public claims carry resolving citations".to_string(), + status: EvidenceReadinessStatus::Missing, + fact_ids: Vec::new(), + evidence_refs: Vec::new(), + trace_links: Vec::new(), + concern_record_ids: vec!["calibration.concern.123".to_string()], + }, + ], + fuzzy_trace: None, + verifier_forbidden_actions: vec![ + "public package is published without editorial approval".to_string(), + ], + operator_actions: vec![ + "inspect axiom report".to_string(), + "request missing evidence for claim_citations_attached".to_string(), + ], + } + } + + fn sample_fuzzy_trace( + observed_value_basis_points: u16, + material_score_basis_points: u16, + ) -> FuzzyReadinessTrace { + FuzzyReadinessTrace { + variable_key: "drift_severity".to_string(), + observed_value_basis_points, + memberships: vec![ + FuzzyMembership { + label: "moderate".to_string(), + score_basis_points: 4_000, + }, + FuzzyMembership { + label: "material".to_string(), + score_basis_points: material_score_basis_points, + }, + ], + activated_rules: vec![FuzzyRuleActivation { + rule_id: "revision-trigger-on-materializing-drift".to_string(), + strength_basis_points: material_score_basis_points, + conclusion: "revision_urgency:advisable".to_string(), + }], + defuzzified_score: None, + } + } + + fn sample_defuzzified_score(score_basis_points: u16) -> FuzzyDefuzzifiedScore { + FuzzyDefuzzifiedScore { + method: "centroid".to_string(), + score_basis_points, + domain_min_basis_points: 0, + domain_max_basis_points: 10_000, + domain_steps: 1_000, + } + } + + fn sample_ledger_input() -> OperatorLedgerEntryInput { + OperatorLedgerEntryInput { + sequence: 1, + record_kind: OperatorLedgerRecordKind::JobReadinessPacket, + receipt_family: ReceiptFamily::Common, + source_ref: "helm.job_readiness.abcdef012345".to_string(), + package_id: "truth_package.folio.1234".to_string(), + truth_version: "truth.v1".to_string(), + domain_hint: "folio-editor.publication-boundary".to_string(), + payload_hash: "sha256:90b8fb64fdd6f926a4ef42d67a145215aa7e7e07480863217f8558c472da579f" + .to_string(), + backlink_ids: vec!["artifact.adapter.abcdef012345".to_string()], + summary: "job readiness Invalid for folio-publication-package".to_string(), + } + } +} diff --git a/contracts/crates/helm-module-contracts/src/showcase_pipeline.rs b/contracts/crates/helm-module-contracts/src/showcase_pipeline.rs new file mode 100644 index 0000000..43495a7 --- /dev/null +++ b/contracts/crates/helm-module-contracts/src/showcase_pipeline.rs @@ -0,0 +1,104 @@ +//! Contract types for the showcase pipeline injection boundary. +//! +//! `ShowcasePipelineInput` is the typed value crossing from seed-IO +//! (Parquet, fixtures, JSON) into the operator-control spine. +//! `ShowcaseSeedSource` is the injection trait the mounting app implements; +//! this keeps the spine crate (`helm-operator-control`) free of heavy IO +//! dependencies such as `polars` / Parquet (RFL-154 T5b). +//! +//! # Mounting-app responsibility +//! +//! The mounting app (or seed-IO layer) implements [`ShowcaseSeedSource`] and +//! supplies it via `PipelineRouteState::with_seed_source`. A Parquet-based +//! reference implementation lives in `crates/seed-gen/src/showcase_seed.rs`. +//! +//! Mirrors the `OperatorControlReadinessFeed` injection pattern from +//! `helm-operator-control::lib`. + +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +// ── Input ────────────────────────────────────────────────────────────────────── + +/// Fully-typed input to the showcase pipeline. +/// +/// Produced by the mounting app (e.g. via [`ShowcaseSeedSource`]) and handed +/// to `PipelineRouteState`. All fields map 1-to-1 to truth input keys. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ShowcasePipelineInput { + pub prospect_name: String, + pub visitor_id: String, + /// JSON-serialised array of behavioural events. + pub usage_events_json: String, + pub inbound_summary: String, + pub meeting_count: u32, + pub window_start: String, + pub window_end: String, + /// Optional JSON-serialised calendar slot array. + pub calendar_slots_json: Option, + pub industry: Option, + pub website: Option, + pub contact_name: Option, + pub contact_title: Option, + pub contact_email: Option, +} + +// ── Error ────────────────────────────────────────────────────────────────────── + +/// Typed errors from seed-source loading. +/// +/// Variants carry structured context: signalling is typed (enum discriminant +/// + named fields); `StorageError` and `ParseError` variants include a +/// `detail: String` for foreign IO messages where a richer type is not +/// available (RFL-129 typed-contract rule). +#[derive(Debug, Clone)] +pub enum SeedSourceError { + /// The requested prospect was not found in the seed dataset. + ProspectNotFound { prospect_id: String }, + /// The underlying seed dataset could not be opened or read. + StorageError { detail: String }, + /// Data within the dataset was invalid or could not be parsed. + ParseError { detail: String }, +} + +impl std::fmt::Display for SeedSourceError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SeedSourceError::ProspectNotFound { prospect_id } => { + write!(f, "prospect '{prospect_id}' not found in seed dataset") + } + SeedSourceError::StorageError { detail } => { + write!(f, "seed storage error: {detail}") + } + SeedSourceError::ParseError { detail } => { + write!(f, "seed parse error: {detail}") + } + } + } +} + +impl std::error::Error for SeedSourceError {} + +// ── Trait ────────────────────────────────────────────────────────────────────── + +/// Injection contract for showcase-pipeline seed data. +/// +/// The mounting app implements this trait to supply [`ShowcasePipelineInput`] +/// to the `run_pipeline` HTTP handler. This decouples the spine from IO +/// concerns (Parquet, file system, remote APIs). Implementations live in the +/// app or seed-IO layer; the spine only holds the `dyn ShowcaseSeedSource` +/// pointer. +/// +/// # Pattern +/// +/// Mirrors [`helm_operator_control::OperatorControlReadinessFeed`]: both are +/// trait-object injection points stored in route state, wired at mount time. +#[async_trait] +pub trait ShowcaseSeedSource: Send + Sync + 'static { + /// Load the full pipeline input for a given prospect identifier. + /// + /// The prospect identifier comes from the `prospect_id` field of the + /// `POST /v1/pipeline/showcase/run` request body; it defaults to + /// `"prospect-001"` when omitted. + async fn load(&self, prospect_id: &str) -> Result; +} diff --git a/contracts/crates/helm-module-contracts/tests/compile_fail/private_validation_helper_not_accessible.rs b/contracts/crates/helm-module-contracts/tests/compile_fail/private_validation_helper_not_accessible.rs new file mode 100644 index 0000000..7fdc132 --- /dev/null +++ b/contracts/crates/helm-module-contracts/tests/compile_fail/private_validation_helper_not_accessible.rs @@ -0,0 +1,15 @@ +//! Compile-fail gate: private validation helpers are not part of the public API. +//! +//! `validate_sha256` (and its siblings) are `fn` — module-private. External +//! callers MUST go through `JobReadinessPacket::new()` or +//! `OperatorLedgerEntry::new()` to get a validated value. This file must not +//! compile, enforcing the parse-don't-validate contract at the type level. + +fn main() { + // validate_sha256 is private — calling it directly is a compile error. + // Callers must use JobReadinessPacket::new() / OperatorLedgerEntry::new(). + let _ = helm_module_contracts::operator_receipts::validate_sha256( + "payload_hash", + "sha256:abc", + ); +} diff --git a/contracts/crates/helm-module-contracts/tests/compile_fail/private_validation_helper_not_accessible.stderr b/contracts/crates/helm-module-contracts/tests/compile_fail/private_validation_helper_not_accessible.stderr new file mode 100644 index 0000000..71be965 --- /dev/null +++ b/contracts/crates/helm-module-contracts/tests/compile_fail/private_validation_helper_not_accessible.stderr @@ -0,0 +1,11 @@ +error[E0603]: function `validate_sha256` is private + --> tests/compile_fail/private_validation_helper_not_accessible.rs:11:55 + | +11 | let _ = helm_module_contracts::operator_receipts::validate_sha256( + | ^^^^^^^^^^^^^^^ private function + | +note: the function `validate_sha256` is defined here + --> src/operator_receipts.rs + | + | fn validate_sha256(field: &'static str, value: &str) -> Result<(), OperatorControlError> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/contracts/crates/helm-module-contracts/tests/negative_operator_receipts.rs b/contracts/crates/helm-module-contracts/tests/negative_operator_receipts.rs new file mode 100644 index 0000000..c16ae32 --- /dev/null +++ b/contracts/crates/helm-module-contracts/tests/negative_operator_receipts.rs @@ -0,0 +1,332 @@ +//! Negative constructor tests for `operator_receipts` — exact +//! `OperatorControlError` variant assertions (RFL-154 T7). +//! +//! Complements the in-module unit tests (which cover fuzzy-score domains and +//! domain-authority rejection) with the remaining rejection surface: +//! empty fields, empty backlinks, the basis-points boundary, and the +//! `sha256:` format gate. + +use helm_module_contracts::operator_receipts::{ + AdapterReceiptStatus, FuzzyMembership, FuzzyReadinessTrace, JobReadinessPacket, + JobReadinessPacketInput, OperatorControlError, OperatorLedgerEntry, OperatorLedgerEntryInput, + OperatorLedgerRecordKind, ReceiptFamily, +}; + +fn valid_packet_input() -> JobReadinessPacketInput { + JobReadinessPacketInput { + package_id: "pkg.test.001".to_string(), + truth_version: "truth.v1".to_string(), + domain_hint: "test.domain".to_string(), + job_key: "test-job".to_string(), + subject_ref: "test.subject.abcdef".to_string(), + adapter_receipt_id: "artifact.adapter.abcdef".to_string(), + adapter_status: AdapterReceiptStatus::Succeeded, + verdict: None, + authorizes_domain_action: false, + evidence_status: Vec::new(), + fuzzy_trace: None, + verifier_forbidden_actions: Vec::new(), + operator_actions: Vec::new(), + } +} + +const VALID_SHA256: &str = + "sha256:90b8fb64fdd6f926a4ef42d67a145215aa7e7e07480863217f8558c472da579f"; + +fn valid_ledger_input() -> OperatorLedgerEntryInput { + OperatorLedgerEntryInput { + sequence: 1, + record_kind: OperatorLedgerRecordKind::JobReadinessPacket, + receipt_family: ReceiptFamily::Common, + source_ref: "helm.job_readiness.abcdef012345".to_string(), + package_id: "pkg.test.001".to_string(), + truth_version: "truth.v1".to_string(), + domain_hint: "test.domain".to_string(), + payload_hash: VALID_SHA256.to_string(), + backlink_ids: vec!["helm.ledger.abcdef012345".to_string()], + summary: "test ledger entry".to_string(), + } +} + +fn valid_fuzzy_trace(observed: u16, membership_score: u16) -> FuzzyReadinessTrace { + FuzzyReadinessTrace { + variable_key: "drift-severity".to_string(), + observed_value_basis_points: observed, + memberships: vec![FuzzyMembership { + label: "high".to_string(), + score_basis_points: membership_score, + }], + activated_rules: Vec::new(), + defuzzified_score: None, + } +} + +// ── Empty-field rejections (packet) ─────────────────────────────────────────── + +#[test] +fn empty_package_id_is_rejected() { + let mut input = valid_packet_input(); + input.package_id = String::new(); + let err = JobReadinessPacket::new(input).expect_err("empty package_id must fail"); + assert_eq!(err, OperatorControlError::EmptyField { field: "package_id" }); +} + +#[test] +fn whitespace_only_package_id_is_rejected() { + let mut input = valid_packet_input(); + input.package_id = " ".to_string(); + let err = JobReadinessPacket::new(input).expect_err("whitespace package_id must fail"); + assert_eq!(err, OperatorControlError::EmptyField { field: "package_id" }); +} + +#[test] +fn empty_truth_version_is_rejected() { + let mut input = valid_packet_input(); + input.truth_version = String::new(); + let err = JobReadinessPacket::new(input).expect_err("empty truth_version must fail"); + assert_eq!( + err, + OperatorControlError::EmptyField { + field: "truth_version" + } + ); +} + +#[test] +fn empty_domain_hint_is_rejected() { + let mut input = valid_packet_input(); + input.domain_hint = String::new(); + let err = JobReadinessPacket::new(input).expect_err("empty domain_hint must fail"); + assert_eq!(err, OperatorControlError::EmptyField { field: "domain_hint" }); +} + +#[test] +fn empty_job_key_is_rejected() { + let mut input = valid_packet_input(); + input.job_key = String::new(); + let err = JobReadinessPacket::new(input).expect_err("empty job_key must fail"); + assert_eq!(err, OperatorControlError::EmptyField { field: "job_key" }); +} + +#[test] +fn empty_subject_ref_is_rejected() { + let mut input = valid_packet_input(); + input.subject_ref = String::new(); + let err = JobReadinessPacket::new(input).expect_err("empty subject_ref must fail"); + assert_eq!(err, OperatorControlError::EmptyField { field: "subject_ref" }); +} + +#[test] +fn empty_adapter_receipt_id_is_rejected() { + let mut input = valid_packet_input(); + input.adapter_receipt_id = String::new(); + let err = JobReadinessPacket::new(input).expect_err("empty adapter_receipt_id must fail"); + assert_eq!( + err, + OperatorControlError::EmptyField { + field: "adapter_receipt_id" + } + ); +} + +#[test] +fn empty_fuzzy_variable_key_is_rejected() { + let mut input = valid_packet_input(); + let mut trace = valid_fuzzy_trace(5_000, 5_000); + trace.variable_key = String::new(); + input.fuzzy_trace = Some(trace); + let err = JobReadinessPacket::new(input).expect_err("empty variable_key must fail"); + assert_eq!( + err, + OperatorControlError::EmptyField { + field: "fuzzy_trace.variable_key" + } + ); +} + +#[test] +fn empty_fuzzy_memberships_are_rejected() { + let mut input = valid_packet_input(); + let mut trace = valid_fuzzy_trace(5_000, 5_000); + trace.memberships = Vec::new(); + input.fuzzy_trace = Some(trace); + let err = JobReadinessPacket::new(input).expect_err("empty memberships must fail"); + assert_eq!( + err, + OperatorControlError::EmptyField { + field: "fuzzy_trace.memberships" + } + ); +} + +// ── Empty-field rejections (ledger) ─────────────────────────────────────────── + +#[test] +fn empty_source_ref_is_rejected() { + let mut input = valid_ledger_input(); + input.source_ref = String::new(); + let err = OperatorLedgerEntry::new(input).expect_err("empty source_ref must fail"); + assert_eq!(err, OperatorControlError::EmptyField { field: "source_ref" }); +} + +#[test] +fn empty_summary_is_rejected() { + let mut input = valid_ledger_input(); + input.summary = String::new(); + let err = OperatorLedgerEntry::new(input).expect_err("empty summary must fail"); + assert_eq!(err, OperatorControlError::EmptyField { field: "summary" }); +} + +// ── Empty-backlink rejection ────────────────────────────────────────────────── + +#[test] +fn empty_backlink_id_in_list_is_rejected() { + let mut input = valid_ledger_input(); + input.backlink_ids = vec!["valid.backlink.abc".to_string(), String::new()]; + let err = OperatorLedgerEntry::new(input).expect_err("empty backlink id must fail"); + assert_eq!(err, OperatorControlError::EmptyBacklink); +} + +#[test] +fn whitespace_only_backlink_id_is_rejected() { + let mut input = valid_ledger_input(); + input.backlink_ids = vec![" ".to_string()]; + let err = OperatorLedgerEntry::new(input).expect_err("whitespace backlink id must fail"); + assert_eq!(err, OperatorControlError::EmptyBacklink); +} + +#[test] +fn empty_backlink_list_is_accepted() { + let mut input = valid_ledger_input(); + input.backlink_ids = Vec::new(); + OperatorLedgerEntry::new(input).expect("an empty backlink LIST is valid — only empty IDS fail"); +} + +// ── Basis-points boundary ───────────────────────────────────────────────────── + +#[test] +fn basis_points_10000_is_accepted() { + let mut input = valid_packet_input(); + input.fuzzy_trace = Some(valid_fuzzy_trace(10_000, 10_000)); + JobReadinessPacket::new(input).expect("10000 basis points is the inclusive maximum"); +} + +#[test] +fn observed_basis_points_10001_is_rejected() { + let mut input = valid_packet_input(); + input.fuzzy_trace = Some(valid_fuzzy_trace(10_001, 5_000)); + let err = JobReadinessPacket::new(input).expect_err("10001 observed basis points must fail"); + assert_eq!( + err, + OperatorControlError::InvalidBasisPoints { + field: "fuzzy_trace.observed_value_basis_points", + value: 10_001, + } + ); +} + +#[test] +fn membership_basis_points_10001_is_rejected() { + let mut input = valid_packet_input(); + input.fuzzy_trace = Some(valid_fuzzy_trace(5_000, 10_001)); + let err = JobReadinessPacket::new(input).expect_err("10001 membership score must fail"); + assert_eq!( + err, + OperatorControlError::InvalidBasisPoints { + field: "fuzzy_trace.membership.score_basis_points", + value: 10_001, + } + ); +} + +// ── sha256 format rejections ────────────────────────────────────────────────── + +#[test] +fn raw_string_payload_hash_is_rejected() { + let mut input = valid_ledger_input(); + input.payload_hash = "raw-payload-without-prefix".to_string(); + let err = OperatorLedgerEntry::new(input).expect_err("raw payload hash must fail"); + assert_eq!( + err, + OperatorControlError::InvalidSha256 { + field: "payload_hash", + value: "raw-payload-without-prefix".to_string(), + } + ); +} + +#[test] +fn sha256_prefix_with_short_digest_is_rejected() { + let mut input = valid_ledger_input(); + // 63 hex chars — one short of 64. + let short = format!("sha256:{}", &VALID_SHA256["sha256:".len()..VALID_SHA256.len() - 1]); + input.payload_hash = short.clone(); + let err = OperatorLedgerEntry::new(input).expect_err("63-char digest must fail"); + assert_eq!( + err, + OperatorControlError::InvalidSha256 { + field: "payload_hash", + value: short, + } + ); +} + +#[test] +fn sha256_prefix_with_non_hex_digest_is_rejected() { + let mut input = valid_ledger_input(); + // 64 chars but two are 'z' — not hex. + let bad = format!( + "sha256:{}zz", + &VALID_SHA256["sha256:".len()..VALID_SHA256.len() - 2] + ); + input.payload_hash = bad.clone(); + let err = OperatorLedgerEntry::new(input).expect_err("non-hex digest must fail"); + assert_eq!( + err, + OperatorControlError::InvalidSha256 { + field: "payload_hash", + value: bad, + } + ); +} + +#[test] +fn empty_payload_hash_is_rejected_as_invalid_sha256() { + let mut input = valid_ledger_input(); + input.payload_hash = String::new(); + let err = OperatorLedgerEntry::new(input).expect_err("empty payload hash must fail"); + assert_eq!( + err, + OperatorControlError::InvalidSha256 { + field: "payload_hash", + value: String::new(), + } + ); +} + +#[test] +fn valid_sha256_payload_hash_is_accepted() { + OperatorLedgerEntry::new(valid_ledger_input()) + .expect("well-formed sha256 payload hash must be accepted"); +} + +// ── Display contract ────────────────────────────────────────────────────────── + +#[test] +fn error_display_names_the_offending_field() { + let err = OperatorControlError::EmptyField { field: "package_id" }; + assert!(err.to_string().contains("package_id")); + + let err = OperatorControlError::InvalidBasisPoints { + field: "fuzzy_trace.observed_value_basis_points", + value: 10_001, + }; + assert!(err.to_string().contains("10001")); + + let err = OperatorControlError::InvalidSha256 { + field: "payload_hash", + value: "nope".to_string(), + }; + assert!(err.to_string().contains("payload_hash")); + assert!(err.to_string().contains("nope")); +} diff --git a/contracts/crates/helm-module-contracts/tests/proptest_operator_receipts.rs b/contracts/crates/helm-module-contracts/tests/proptest_operator_receipts.rs new file mode 100644 index 0000000..44f2283 --- /dev/null +++ b/contracts/crates/helm-module-contracts/tests/proptest_operator_receipts.rs @@ -0,0 +1,568 @@ +//! Property-based tests for the `operator_receipts` vocabulary (RFL-154 T7). +//! +//! Properties under test: +//! - `JobReadinessPacket::new` is deterministic: same input ⇒ identical `packet_id`. +//! - Any single-field mutation of the input changes `packet_id`. +//! - `job_readiness_packet_payload_hash` always yields a `sha256:`-prefixed +//! 64-hex digest. +//! - `OperatorLedgerEntry::new` always yields `AuthorityEffect::None` +//! (the non-authority invariant). +//! - Serde round-trips are lossless for packets, ledger entries, and every +//! vocabulary enum. +//! +//! Run: `cargo test -p helm-module-contracts` + +use proptest::prelude::*; + +use helm_module_contracts::operator_receipts::{ + AdapterReceiptStatus, AuthorityEffect, EvidenceReadinessStatus, FuzzyDefuzzifiedScore, + FuzzyMembership, FuzzyReadinessTrace, FuzzyRuleActivation, JobEvidenceStatus, + JobReadinessPacket, JobReadinessPacketInput, JobVerdict, OperatorLedgerEntry, + OperatorLedgerEntryInput, OperatorLedgerRecordKind, ReceiptFamily, + job_readiness_packet_ledger_entry, job_readiness_packet_payload_hash, +}; + +// ── Strategies ──────────────────────────────────────────────────────────────── + +fn arb_nonempty_str() -> impl Strategy { + "[a-z][a-z0-9\\-\\.]{0,19}" +} + +fn arb_adapter_status() -> impl Strategy { + prop_oneof![ + Just(AdapterReceiptStatus::Succeeded), + Just(AdapterReceiptStatus::Rejected), + ] +} + +fn arb_verdict() -> impl Strategy> { + prop_oneof![ + Just(None), + Just(Some(JobVerdict::Satisfied)), + Just(Some(JobVerdict::Blocked)), + Just(Some(JobVerdict::Exhausted)), + Just(Some(JobVerdict::Invalid)), + ] +} + +fn arb_evidence_readiness() -> impl Strategy { + prop_oneof![ + Just(EvidenceReadinessStatus::Present), + Just(EvidenceReadinessStatus::Missing), + Just(EvidenceReadinessStatus::Disputed), + Just(EvidenceReadinessStatus::Blocked), + Just(EvidenceReadinessStatus::Concern), + ] +} + +fn arb_evidence_item() -> impl Strategy { + ( + arb_nonempty_str(), + arb_nonempty_str(), + arb_nonempty_str(), + arb_evidence_readiness(), + prop::collection::vec(arb_nonempty_str(), 0..3), + ) + .prop_map( + |(clause_id, clause_key, label, status, fact_ids)| JobEvidenceStatus { + clause_id, + clause_key, + label, + status, + fact_ids, + evidence_refs: Vec::new(), + trace_links: Vec::new(), + concern_record_ids: Vec::new(), + }, + ) +} + +fn arb_valid_basis_points() -> impl Strategy { + 0u16..=10_000u16 +} + +fn arb_fuzzy_membership() -> impl Strategy { + (arb_nonempty_str(), arb_valid_basis_points()).prop_map(|(label, score_basis_points)| { + FuzzyMembership { + label, + score_basis_points, + } + }) +} + +fn arb_fuzzy_rule() -> impl Strategy { + ( + arb_nonempty_str(), + arb_valid_basis_points(), + arb_nonempty_str(), + ) + .prop_map( + |(rule_id, strength_basis_points, conclusion)| FuzzyRuleActivation { + rule_id, + strength_basis_points, + conclusion, + }, + ) +} + +fn arb_defuzzified_score() -> impl Strategy { + (arb_nonempty_str(), arb_valid_basis_points(), 1u32..10_000u32).prop_map( + |(method, score_basis_points, domain_steps)| FuzzyDefuzzifiedScore { + method, + score_basis_points, + domain_min_basis_points: 0, + domain_max_basis_points: 10_000, + domain_steps, + }, + ) +} + +fn arb_fuzzy_trace() -> impl Strategy> { + prop_oneof![ + Just(None), + ( + arb_nonempty_str(), + arb_valid_basis_points(), + prop::collection::vec(arb_fuzzy_membership(), 1..4), + prop::collection::vec(arb_fuzzy_rule(), 0..3), + prop::option::of(arb_defuzzified_score()), + ) + .prop_map( + |(variable_key, observed, memberships, activated_rules, defuzzified_score)| { + Some(FuzzyReadinessTrace { + variable_key, + observed_value_basis_points: observed, + memberships, + activated_rules, + defuzzified_score, + }) + } + ), + ] +} + +fn arb_valid_packet_input() -> impl Strategy { + ( + ( + arb_nonempty_str(), // package_id + arb_nonempty_str(), // truth_version + arb_nonempty_str(), // domain_hint + arb_nonempty_str(), // job_key + arb_nonempty_str(), // subject_ref + arb_nonempty_str(), // adapter_receipt_id + ), + arb_adapter_status(), + arb_verdict(), + prop::collection::vec(arb_evidence_item(), 0..4), + arb_fuzzy_trace(), + prop::collection::vec(arb_nonempty_str(), 0..3), // verifier_forbidden_actions + prop::collection::vec(arb_nonempty_str(), 0..3), // operator_actions + ) + .prop_map( + |( + (package_id, truth_version, domain_hint, job_key, subject_ref, adapter_receipt_id), + adapter_status, + verdict, + evidence_status, + fuzzy_trace, + verifier_forbidden_actions, + operator_actions, + )| { + JobReadinessPacketInput { + package_id, + truth_version, + domain_hint, + job_key, + subject_ref, + adapter_receipt_id, + adapter_status, + verdict, + authorizes_domain_action: false, + evidence_status, + fuzzy_trace, + verifier_forbidden_actions, + operator_actions, + } + }, + ) +} + +fn arb_record_kind() -> impl Strategy { + prop_oneof![ + Just(OperatorLedgerRecordKind::ObservationAdapterReceipt), + Just(OperatorLedgerRecordKind::JobReadinessPacket), + Just(OperatorLedgerRecordKind::OperatorDecisionReceipt), + Just(OperatorLedgerRecordKind::ApprovalReceipt), + Just(OperatorLedgerRecordKind::PlanReceipt), + Just(OperatorLedgerRecordKind::ExecutionReceipt), + Just(OperatorLedgerRecordKind::ActionReceipt), + Just(OperatorLedgerRecordKind::OutcomeReceipt), + Just(OperatorLedgerRecordKind::CorpusSnapshotReceipt), + Just(OperatorLedgerRecordKind::EvidenceWindowReceipt), + Just(OperatorLedgerRecordKind::DisagreementReceipt), + Just(OperatorLedgerRecordKind::AnalystReviewReceipt), + Just(OperatorLedgerRecordKind::NarrativeClaimReceipt), + Just(OperatorLedgerRecordKind::CanonicalStoryReceipt), + Just(OperatorLedgerRecordKind::ClaimReviewReceipt), + Just(OperatorLedgerRecordKind::EditorialApprovalReceipt), + Just(OperatorLedgerRecordKind::PublicationBoundaryReceipt), + Just(OperatorLedgerRecordKind::AppLocalReceipt), + ] +} + +fn arb_receipt_family() -> impl Strategy { + prop_oneof![ + Just(ReceiptFamily::Common), + Just(ReceiptFamily::LongRunningJob), + Just(ReceiptFamily::TemporalEvidence), + Just(ReceiptFamily::ContentPublication), + Just(ReceiptFamily::AppLocal), + ] +} + +const VALID_SHA256: &str = + "sha256:90b8fb64fdd6f926a4ef42d67a145215aa7e7e07480863217f8558c472da579f"; + +fn arb_ledger_entry_input() -> impl Strategy { + ( + 0u64..1_000_000u64, + arb_record_kind(), + arb_receipt_family(), + arb_nonempty_str(), // source_ref + arb_nonempty_str(), // package_id + arb_nonempty_str(), // truth_version + arb_nonempty_str(), // domain_hint + prop::collection::vec(arb_nonempty_str(), 0..3), // backlink_ids + arb_nonempty_str(), // summary + ) + .prop_map( + |( + sequence, + record_kind, + receipt_family, + source_ref, + package_id, + truth_version, + domain_hint, + backlink_ids, + summary, + )| { + OperatorLedgerEntryInput { + sequence, + record_kind, + receipt_family, + source_ref, + package_id, + truth_version, + domain_hint, + payload_hash: VALID_SHA256.to_string(), + backlink_ids, + summary, + } + }, + ) +} + +// ── Determinism ─────────────────────────────────────────────────────────────── + +proptest! { + /// Same input ⇒ identical packet (including `packet_id`). + #[test] + fn packet_id_is_deterministic(input in arb_valid_packet_input()) { + let first = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let second = JobReadinessPacket::new(input).expect("packet builds again"); + prop_assert_eq!(&first.packet_id, &second.packet_id); + prop_assert_eq!(first, second); + } + + /// Same input ⇒ identical ledger entry (including `entry_id`). + #[test] + fn ledger_entry_id_is_deterministic(input in arb_ledger_entry_input()) { + let first = OperatorLedgerEntry::new(input.clone()).expect("entry builds"); + let second = OperatorLedgerEntry::new(input).expect("entry builds again"); + prop_assert_eq!(&first.entry_id, &second.entry_id); + prop_assert_eq!(first, second); + } +} + +// ── Single-field mutation sensitivity ───────────────────────────────────────── + +proptest! { + #[test] + fn packet_id_changes_on_package_id_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.package_id.push_str("-x"); + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_truth_version_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.truth_version.push_str("-x"); + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_domain_hint_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.domain_hint.push_str("-x"); + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_job_key_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.job_key.push_str("-x"); + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_subject_ref_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.subject_ref.push_str("-x"); + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_adapter_receipt_id_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.adapter_receipt_id.push_str("-x"); + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_adapter_status_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.adapter_status = match mutated.adapter_status { + AdapterReceiptStatus::Succeeded => AdapterReceiptStatus::Rejected, + AdapterReceiptStatus::Rejected => AdapterReceiptStatus::Succeeded, + }; + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_verdict_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.verdict = match mutated.verdict { + None => Some(JobVerdict::Satisfied), + Some(JobVerdict::Satisfied) => Some(JobVerdict::Blocked), + Some(_) => None, + }; + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_evidence_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.evidence_status.push(JobEvidenceStatus { + clause_id: "mutation.clause".to_string(), + clause_key: "mutation-added".to_string(), + label: "mutation sentinel".to_string(), + status: EvidenceReadinessStatus::Concern, + fact_ids: Vec::new(), + evidence_refs: Vec::new(), + trace_links: Vec::new(), + concern_record_ids: Vec::new(), + }); + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_operator_actions_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated.operator_actions.push("mutation-action".to_string()); + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn packet_id_changes_on_forbidden_actions_mutation(input in arb_valid_packet_input()) { + let original = JobReadinessPacket::new(input.clone()).expect("packet builds"); + let mut mutated = input; + mutated + .verifier_forbidden_actions + .push("mutation-forbidden".to_string()); + let changed = JobReadinessPacket::new(mutated).expect("mutated packet builds"); + prop_assert_ne!(original.packet_id, changed.packet_id); + } + + #[test] + fn ledger_entry_id_changes_on_sequence_mutation(input in arb_ledger_entry_input()) { + let original = OperatorLedgerEntry::new(input.clone()).expect("entry builds"); + let mut mutated = input; + mutated.sequence = mutated.sequence.wrapping_add(1); + let changed = OperatorLedgerEntry::new(mutated).expect("mutated entry builds"); + prop_assert_ne!(original.entry_id, changed.entry_id); + } + + #[test] + fn ledger_entry_id_changes_on_backlink_mutation(input in arb_ledger_entry_input()) { + let original = OperatorLedgerEntry::new(input.clone()).expect("entry builds"); + let mut mutated = input; + mutated.backlink_ids.push("mutation.backlink".to_string()); + let changed = OperatorLedgerEntry::new(mutated).expect("mutated entry builds"); + prop_assert_ne!(original.entry_id, changed.entry_id); + } +} + +// ── Hash format ─────────────────────────────────────────────────────────────── + +proptest! { + /// `job_readiness_packet_payload_hash` always yields `sha256:` + 64 hex chars. + #[test] + fn payload_hash_is_always_sha256_prefixed_64_hex(input in arb_valid_packet_input()) { + let packet = JobReadinessPacket::new(input).expect("packet builds"); + let hash = job_readiness_packet_payload_hash(&packet); + let digest = hash + .strip_prefix("sha256:") + .expect("hash must start with sha256:"); + prop_assert_eq!(digest.len(), 64); + prop_assert!(digest.bytes().all(|b| b.is_ascii_hexdigit())); + } +} + +// ── Non-authority invariant ─────────────────────────────────────────────────── + +proptest! { + /// `OperatorLedgerEntry::new` always yields `AuthorityEffect::None`, + /// whatever the input. + #[test] + fn ledger_entry_authority_effect_is_always_none(input in arb_ledger_entry_input()) { + let entry = OperatorLedgerEntry::new(input).expect("entry builds"); + prop_assert_eq!(entry.authority_effect, AuthorityEffect::None); + } + + /// The convenience constructor inherits the invariant. + #[test] + fn packet_ledger_entry_helper_is_always_non_authoritative( + input in arb_valid_packet_input(), + sequence in 0u64..1_000u64, + ) { + let packet = JobReadinessPacket::new(input).expect("packet builds"); + let entry = job_readiness_packet_ledger_entry( + sequence, + &packet, + vec!["proptest.backlink".to_string()], + "proptest summary", + ) + .expect("entry builds"); + prop_assert_eq!(entry.authority_effect, AuthorityEffect::None); + prop_assert_eq!(&entry.payload_hash, &job_readiness_packet_payload_hash(&packet)); + prop_assert_eq!(&entry.source_ref, &packet.packet_id); + } +} + +// ── Serde round-trips ───────────────────────────────────────────────────────── + +proptest! { + #[test] + fn job_readiness_packet_serde_roundtrip(input in arb_valid_packet_input()) { + let packet = JobReadinessPacket::new(input).expect("packet builds"); + let json = serde_json::to_string(&packet).expect("serialize"); + let roundtripped: JobReadinessPacket = serde_json::from_str(&json).expect("deserialize"); + prop_assert_eq!(packet, roundtripped); + } + + #[test] + fn operator_ledger_entry_serde_roundtrip(input in arb_ledger_entry_input()) { + let entry = OperatorLedgerEntry::new(input).expect("entry builds"); + let json = serde_json::to_string(&entry).expect("serialize"); + let roundtripped: OperatorLedgerEntry = serde_json::from_str(&json).expect("deserialize"); + prop_assert_eq!(entry, roundtripped); + } + + #[test] + fn packet_input_serde_roundtrip(input in arb_valid_packet_input()) { + let json = serde_json::to_string(&input).expect("serialize"); + let roundtripped: JobReadinessPacketInput = + serde_json::from_str(&json).expect("deserialize"); + prop_assert_eq!(input, roundtripped); + } + + #[test] + fn ledger_entry_input_serde_roundtrip(input in arb_ledger_entry_input()) { + let json = serde_json::to_string(&input).expect("serialize"); + let roundtripped: OperatorLedgerEntryInput = + serde_json::from_str(&json).expect("deserialize"); + prop_assert_eq!(input, roundtripped); + } + + #[test] + fn record_kind_serde_roundtrip(kind in arb_record_kind()) { + let json = serde_json::to_string(&kind).expect("serialize"); + let roundtripped: OperatorLedgerRecordKind = + serde_json::from_str(&json).expect("deserialize"); + prop_assert_eq!(kind, roundtripped); + // Wire string equals the canonical `as_str` value. + prop_assert_eq!(json, format!("\"{}\"", kind.as_str())); + } + + #[test] + fn receipt_family_serde_roundtrip(family in arb_receipt_family()) { + let json = serde_json::to_string(&family).expect("serialize"); + let roundtripped: ReceiptFamily = serde_json::from_str(&json).expect("deserialize"); + prop_assert_eq!(family, roundtripped); + prop_assert_eq!(json, format!("\"{}\"", family.as_str())); + } +} + +/// Serde round-trip for the remaining vocabulary enums (small closed sets; +/// exhaustive loops instead of proptest). +#[test] +fn remaining_vocab_enums_serde_roundtrip_exhaustively() { + for v in [AdapterReceiptStatus::Succeeded, AdapterReceiptStatus::Rejected] { + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, format!("\"{}\"", v.as_str())); + let rt: AdapterReceiptStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(v, rt); + } + for v in [ + JobVerdict::Satisfied, + JobVerdict::Blocked, + JobVerdict::Exhausted, + JobVerdict::Invalid, + ] { + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, format!("\"{}\"", v.as_str())); + let rt: JobVerdict = serde_json::from_str(&json).unwrap(); + assert_eq!(v, rt); + } + for v in [ + EvidenceReadinessStatus::Present, + EvidenceReadinessStatus::Missing, + EvidenceReadinessStatus::Disputed, + EvidenceReadinessStatus::Blocked, + EvidenceReadinessStatus::Concern, + ] { + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, format!("\"{}\"", v.as_str())); + let rt: EvidenceReadinessStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(v, rt); + } + { + let v = AuthorityEffect::None; + let json = serde_json::to_string(&v).unwrap(); + assert_eq!(json, format!("\"{}\"", v.as_str())); + let rt: AuthorityEffect = serde_json::from_str(&json).unwrap(); + assert_eq!(v, rt); + } +} diff --git a/contracts/crates/helm-module-contracts/tests/trybuild_compile_fail.rs b/contracts/crates/helm-module-contracts/tests/trybuild_compile_fail.rs new file mode 100644 index 0000000..7c7d959 --- /dev/null +++ b/contracts/crates/helm-module-contracts/tests/trybuild_compile_fail.rs @@ -0,0 +1,13 @@ +//! Compile-fail suite for `helm-module-contracts` (RFL-154 T7). +//! +//! Each `.rs` file under `tests/compile_fail/` must fail to compile. +//! The expected compiler output is captured in the matching `.stderr` file +//! alongside each case. Run with `TRYBUILD=overwrite cargo test +//! -p helm-module-contracts --test trybuild_compile_fail` to regenerate +//! snapshots after intentional API changes. + +#[test] +fn compile_fail_cases() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/compile_fail/*.rs"); +} diff --git a/crates/helm-coordination/Cargo.toml b/crates/helm-coordination/Cargo.toml index 533fdb3..a742101 100644 --- a/crates/helm-coordination/Cargo.toml +++ b/crates/helm-coordination/Cargo.toml @@ -15,7 +15,7 @@ axum.workspace = true chrono.workspace = true application-kernel = { version = "0.2.1", path = "../application-kernel" } helm-governed-jobs = { version = "0.2.1", path = "../helm-governed-jobs" } -helm-module-contracts = { version = "0.2.1", path = "../../contracts/crates/helm-module-contracts" } +helm-module-contracts = { version = "0.3.0", path = "../../contracts/crates/helm-module-contracts" } # RP-HELMS-SUBSTRATE-SEAM: approved foundation→substrate edge; needed for # EventHub / EventHubHandle / EventEnvelope / SSE types. HelmModule / ModuleState # are now imported from helm-module-contracts (RFL-128). diff --git a/crates/helm-governed-jobs/Cargo.toml b/crates/helm-governed-jobs/Cargo.toml index 9c0852c..d6bda5f 100644 --- a/crates/helm-governed-jobs/Cargo.toml +++ b/crates/helm-governed-jobs/Cargo.toml @@ -15,7 +15,7 @@ axum.workspace = true chrono.workspace = true converge-core.workspace = true futures = "0.3" -helm-module-contracts = { version = "0.2.1", path = "../../contracts/crates/helm-module-contracts" } +helm-module-contracts = { version = "0.3.0", path = "../../contracts/crates/helm-module-contracts" } helm-truth-execution = { version = "0.2.1", path = "../helm-truth-execution" } application-kernel = { version = "0.2.1", path = "../application-kernel" } application-storage = { version = "0.2.1", path = "../application-storage" } diff --git a/crates/helm-operator-control/Cargo.toml b/crates/helm-operator-control/Cargo.toml index 5ce1a98..77ab0d6 100644 --- a/crates/helm-operator-control/Cargo.toml +++ b/crates/helm-operator-control/Cargo.toml @@ -18,8 +18,8 @@ tracing.workspace = true application-kernel = { version = "0.2.1", path = "../application-kernel" } application-storage = { version = "0.2.1", path = "../application-storage" } -helm-module-contracts = { version = "0.2.1", path = "../../contracts/crates/helm-module-contracts" } +helm-module-contracts = { version = "0.3.0", path = "../../contracts/crates/helm-module-contracts" } helm-truth-execution = { version = "0.2.1", path = "../helm-truth-execution" } -workbench-backend = { version = "0.2.1", path = "../workbench-backend" } -prio-agent-ops = { version = "0.2.1", path = "../prio-agent-ops" } -polars = { version = "0.51", features = ["lazy", "parquet"] } + +[dev-dependencies] +trybuild = { version = "1", features = ["diff"] } diff --git a/crates/helm-operator-control/src/http_api.rs b/crates/helm-operator-control/src/http_api.rs index e5b77eb..42fd0eb 100644 --- a/crates/helm-operator-control/src/http_api.rs +++ b/crates/helm-operator-control/src/http_api.rs @@ -5,38 +5,34 @@ //! all other workbench and CRM routes remain in application-server until a //! later phase. +use std::fmt; use std::sync::Arc; -use crate::{OperatorControlPreview, OperatorControlReadinessFeed}; -use application_storage::{AppConfig, InMemoryKernelStore, KernelStore}; +use crate::OperatorControlReadinessFeed; use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::{Json, Router}; +use helm_module_contracts::operator_preview::OperatorControlPreview; +use helm_module_contracts::operator_receipts::OperatorControlError; use serde::Serialize; -use workbench_backend::{OperatorApp, OperatorAppError}; // ── State ──────────────────────────────────────────────────────────────────── /// Focused state struct for operator-control routes. /// -/// Holds only what these two routes need: an `OperatorApp`. The broader -/// `HttpState` in application-server carries billing, runtime stores, and -/// other concerns that do not belong to operator-control. +/// Holds only what these routes need: an optional live readiness feed. +/// `OperatorApp` (previously held here) was dead weight — the preview +/// methods implement live-feed logic directly (RFL-154 T5a). #[derive(Clone)] -pub struct OperatorControlState { - pub operator: OperatorApp, +pub struct OperatorControlState { readiness_feed: Option>, } -impl OperatorControlState -where - S: KernelStore + Clone, -{ - pub fn new(config: AppConfig, store: S) -> Self { +impl OperatorControlState { + pub fn new() -> Self { Self { - operator: OperatorApp::new(config, store), readiness_feed: None, } } @@ -46,36 +42,64 @@ where self } - pub fn operator_control_preview(&self) -> Result { + pub fn operator_control_preview(&self) -> Result { let mut previews = self.live_previews()?; if !previews.is_empty() { return Ok(previews.remove(0)); } - Err(OperatorAppError::OperatorControl( - "operator-control preview requires an injected live readiness feed".to_string(), - )) + Err(OperatorStateError::NotAvailable) } pub fn operator_control_previews( &self, - ) -> Result, OperatorAppError> { + ) -> Result, OperatorStateError> { self.live_previews() } - fn live_previews(&self) -> Result, OperatorAppError> { + fn live_previews(&self) -> Result, OperatorStateError> { let Some(feed) = &self.readiness_feed else { return Ok(Vec::new()); }; - let snapshots = feed - .previews() - .map_err(|error| OperatorAppError::OperatorControl(error.to_string()))?; - + let snapshots = feed.previews().map_err(OperatorStateError::Feed)?; Ok(snapshots.into_iter().map(Into::into).collect()) } } +impl Default for OperatorControlState { + fn default() -> Self { + Self::new() + } +} + +// ── State-layer error ───────────────────────────────────────────────────────── + +/// Error returned by [`OperatorControlState`] preview methods. +/// +/// - [`OperatorStateError::Feed`] wraps a validation error propagated from the +/// live readiness feed's [`OperatorControlReadinessFeed::previews`] call. +/// - [`OperatorStateError::NotAvailable`] signals that no live preview is +/// available from the configured feed (feed absent or returned empty). +#[derive(Debug)] +pub enum OperatorStateError { + Feed(OperatorControlError), + NotAvailable, +} + +impl fmt::Display for OperatorStateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Feed(e) => e.fmt(f), + Self::NotAvailable => { + f.write_str("operator-control preview requires an injected live readiness feed") + } + } + } +} + +impl std::error::Error for OperatorStateError {} + // ── Router ─────────────────────────────────────────────────────────────────── /// Returns the Axum router for operator-control routes. @@ -83,46 +107,37 @@ where /// Paths exposed: /// - `GET /v1/workbench/operator-control/preview` — first injected live preview /// - `GET /v1/workbench/operator-control/previews` — injected live preview list -pub fn router(state: Arc>) -> Router -where - S: KernelStore + Clone + Send + Sync + 'static, -{ +pub fn router(state: Arc) -> Router { Router::new() .route( "/v1/workbench/operator-control/preview", - get(workbench_operator_control_preview::), + get(workbench_operator_control_preview), ) .route( "/v1/workbench/operator-control/previews", - get(workbench_operator_control_previews::), + get(workbench_operator_control_previews), ) .with_state(state) } // ── Handlers ───────────────────────────────────────────────────────────────── -async fn workbench_operator_control_preview( - State(state): State>>, -) -> Result, ApiError> -where - S: KernelStore + Clone + Send + Sync + 'static, -{ +async fn workbench_operator_control_preview( + State(state): State>, +) -> Result, ApiError> { state .operator_control_preview() .map(Json) - .map_err(api_error_from_operator) + .map_err(api_error_from_operator_state) } -async fn workbench_operator_control_previews( - State(state): State>>, -) -> Result>, ApiError> -where - S: KernelStore + Clone + Send + Sync + 'static, -{ +async fn workbench_operator_control_previews( + State(state): State>, +) -> Result>, ApiError> { state .operator_control_previews() .map(Json) - .map_err(api_error_from_operator) + .map_err(api_error_from_operator_state) } // ── Error handling ──────────────────────────────────────────────────────────── @@ -159,67 +174,54 @@ impl IntoResponse for ApiError { } } -fn api_error_from_operator(error: OperatorAppError) -> ApiError { +/// Maps the operator-control state-layer error to an HTTP [`ApiError`]. +/// +/// `NotAvailable` → 404; feed validation errors → via +/// [`api_error_from_operator_control`]. +fn api_error_from_operator_state(error: OperatorStateError) -> ApiError { match error { - OperatorAppError::Storage(error) => api_error_from_storage(error), - OperatorAppError::TruthNotFound(key) => { - ApiError::new(StatusCode::NOT_FOUND, format!("truth not found: {key}")) + OperatorStateError::NotAvailable => { + ApiError::new(StatusCode::NOT_FOUND, error.to_string()) } - OperatorAppError::MissingInput(field) => ApiError::new( + OperatorStateError::Feed(e) => api_error_from_operator_control(e), + } +} + +/// Maps a contracts-layer [`OperatorControlError`] to an HTTP [`ApiError`]. +/// +/// All 7 variants are reachable through the feed path (the feed validates +/// packets and ledger entries before returning them). Validation failures → +/// 400 Bad Request. `DomainActionAuthorityRequested` is a construction-time +/// invariant violation; it maps to 400 as well because the caller supplied +/// an invalid packet. +fn api_error_from_operator_control(error: OperatorControlError) -> ApiError { + match error { + OperatorControlError::EmptyField { field } => { + ApiError::new(StatusCode::BAD_REQUEST, format!("`{field}` must not be empty")) + } + OperatorControlError::EmptyBacklink => ApiError::new( StatusCode::BAD_REQUEST, - format!("missing required input: {field}"), + "backlink ids must not contain empty values", ), - OperatorAppError::InvalidUuid { field, value } => ApiError::new( + OperatorControlError::InvalidBasisPoints { field, value } => ApiError::new( StatusCode::BAD_REQUEST, - format!("invalid uuid for {field}: {value}"), + format!("`{field}` must be between 0 and 10000, got `{value}`"), ), - OperatorAppError::InvalidInteger { field, value } => ApiError::new( + OperatorControlError::InvalidRange { field, min, max } => ApiError::new( StatusCode::BAD_REQUEST, - format!("invalid integer for {field}: {value}"), + format!("`{field}` must have min < max, got `{min}`..`{max}`"), ), - OperatorAppError::Validation(message) - | OperatorAppError::OperatorControl(message) - | OperatorAppError::UnsupportedTruth(message) => { - ApiError::new(StatusCode::BAD_REQUEST, message) - } - } -} - -fn api_error_from_storage(error: application_storage::StorageError) -> ApiError { - match error { - application_storage::StorageError::LockPoisoned => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "storage lock poisoned") - } - application_storage::StorageError::Kernel(error) => api_error_from_kernel(error), - application_storage::StorageError::ConnectionFailed { backend, message } => ApiError::new( - StatusCode::SERVICE_UNAVAILABLE, - format!("{backend} connection failed: {message}"), + OperatorControlError::InvalidCount { field, value } => ApiError::new( + StatusCode::BAD_REQUEST, + format!("`{field}` must be greater than zero, got `{value}`"), + ), + OperatorControlError::InvalidSha256 { field, value } => ApiError::new( + StatusCode::BAD_REQUEST, + format!("`{field}` must be a sha256 hash, got `{value}`"), + ), + OperatorControlError::DomainActionAuthorityRequested => ApiError::new( + StatusCode::BAD_REQUEST, + "job readiness packets must not authorize domain action", ), - application_storage::StorageError::SerializationFailed { message } => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, message) - } - application_storage::StorageError::Timeout { operation } => { - ApiError::new(StatusCode::GATEWAY_TIMEOUT, operation) - } - application_storage::StorageError::RuntimeStore { message } => { - ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, message) - } - } -} - -fn api_error_from_kernel(error: application_kernel::KernelError) -> ApiError { - match error { - application_kernel::KernelError::Validation(message) => { - ApiError::new(StatusCode::BAD_REQUEST, message) - } - application_kernel::KernelError::NotFound { kind, id } => { - ApiError::new(StatusCode::NOT_FOUND, format!("{kind} not found: {id}")) - } - application_kernel::KernelError::Invariant(message) => { - ApiError::new(StatusCode::PRECONDITION_FAILED, message) - } - application_kernel::KernelError::Conflict(message) => { - ApiError::new(StatusCode::CONFLICT, message) - } } } diff --git a/crates/helm-operator-control/src/lib.rs b/crates/helm-operator-control/src/lib.rs index bd5d3c7..46997fb 100644 --- a/crates/helm-operator-control/src/lib.rs +++ b/crates/helm-operator-control/src/lib.rs @@ -5,8 +5,7 @@ //! Wraps the operator-control HTTP routes under `/v1/workbench/operator-control/` //! and the showcase pipeline routes under `/v1/pipeline/showcase/` into a HelmModule //! for runway-app-host. Downstream apps should import operator-control packet -//! and ledger contracts from this crate, not from the legacy `prio-agent-ops` -//! implementation crate. +//! and ledger contracts from `helm-module-contracts`, not from this crate. //! //! # Routes exposed //! @@ -31,6 +30,15 @@ //! //! - `job_stream.rs` core run loop — deferred to Phase 4b (see helm-governed-jobs) //! - SSE realtime streaming — coupled to application-server's RealtimeHub (Phase 4b) +//! +//! # Vocabulary +//! +//! All operator-control receipt and preview types live in `helm-module-contracts` +//! (`operator_receipts` and `operator_preview` submodules). Consumers must import +//! from there directly; receipts/preview vocabulary is never re-exported from this +//! crate. Pipeline API types (`ShowcaseSeedSource`, `ShowcasePipelineInput`, +//! `SeedSourceError`) are re-exported from `pipeline` because they appear in its +//! own public API signatures (RFL-154 T5a seam cut). #![allow(clippy::result_large_err)] @@ -39,53 +47,33 @@ pub mod pipeline; use std::sync::Arc; -use application_storage::{AppConfig, InMemoryKernelStore, KernelStore}; use async_trait::async_trait; use axum::Router; +use helm_module_contracts::operator_preview::OperatorControlPreview; +use helm_module_contracts::operator_receipts::{ + JobReadinessPacket, OperatorControlError, OperatorLedgerEntry, +}; use helm_module_contracts::{ HelmModule, HelmModuleReadiness, HelmModuleState, HelmModuleStatus, ModuleState, }; use helm_truth_execution::TruthExecutionModule; -pub use helm_module_contracts::{ - HelmModuleReadiness as OperatorControlModuleReadiness, - HelmModuleState as OperatorControlModuleState, HelmModuleStatus as OperatorControlModuleStatus, -}; -pub use http_api::OperatorControlState; +pub use http_api::{OperatorControlState, OperatorStateError}; pub use pipeline::PipelineRouteState; -pub use workbench_backend::{ - OperatorControlPreview, OperatorControlPreviewBacking, OperatorReceiptFamilyView, -}; - -// Re-export types that downstream apps consume -// without needing to depend on prio-agent-ops directly. -pub use prio_agent_ops::{ - AdapterReceiptStatus, AuthorityEffect, EvidenceReadinessStatus, FuzzyDefuzzifiedScore, - FuzzyMembership, FuzzyReadinessTrace, FuzzyRuleActivation, JobEvidenceStatus, - JobReadinessPacket, JobReadinessPacketInput, JobVerdict, OperatorControlError, - OperatorLedgerEntry, OperatorLedgerEntryInput, OperatorLedgerRecordKind, ReceiptFamily, - job_readiness_packet_ledger_entry, job_readiness_packet_payload_hash, -}; // ── Module ──────────────────────────────────────────────────────────────────── /// A `HelmModule` that mounts the operator-control workbench routes and (optionally) /// the showcase pipeline routes. /// -/// The generic parameter `S` is the `KernelStore` implementation. For most -/// Runtime Runway-hosted deployments this will be `InMemoryKernelStore` (the default) -/// or a remote-backed store wired up at startup via -/// [`OperatorControlModule::with_store`]. -/// /// # Constructors /// -/// - [`OperatorControlModule::new`] — zero-arg default for existing consumers. Pipeline -/// routes exist but return "not implemented" because no truth bodies are registered. -/// - [`OperatorControlModule::with_store`] — explicit store, still no truth registry. +/// - [`OperatorControlModule::new`] — zero-arg default. Pipeline routes exist but +/// return "not implemented" because no truth bodies are registered. /// - [`OperatorControlModule::with_truths`] — full constructor for callers that want /// the pipeline to actually dispatch truths. -pub struct OperatorControlModule { - state: Arc>, +pub struct OperatorControlModule { + state: Arc, pipeline: Arc, live_evidence: Option, readiness_feed: Option>, @@ -177,23 +165,22 @@ impl LiveReadinessEvidence { } } -impl OperatorControlModule { - /// Construct using the default in-memory kernel store and an empty truth registry. +impl OperatorControlModule { + /// Construct with a default state and an empty truth registry. /// /// Suitable for development, demos, and existing consumers that do not need /// pipeline truth dispatch. Pipeline routes will respond with `501 Not /// Implemented` for each truth key until bodies are registered. - pub fn new(config: AppConfig) -> Self { - let store = InMemoryKernelStore::default_local(); + pub fn new() -> Self { Self { - state: Arc::new(OperatorControlState::new(config, store)), + state: Arc::new(OperatorControlState::new()), pipeline: Arc::new(PipelineRouteState::new()), live_evidence: None, readiness_feed: None, } } - /// Construct with an in-memory store **and** a populated truth registry. + /// Construct with a populated truth registry. /// /// Use this constructor when the caller has registered truth bodies (e.g. /// `score-inbound-fit`, `qualify-inbound-lead`, `schedule-strategic-meetings`) @@ -201,7 +188,6 @@ impl OperatorControlModule { /// /// ```rust,no_run /// use std::sync::Arc; - /// use application_storage::AppConfig; /// use helm_operator_control::OperatorControlModule; /// use helm_truth_execution::TruthExecutionModule; /// @@ -209,32 +195,16 @@ impl OperatorControlModule { /// TruthExecutionModule::new() /// // .register(Arc::new(MyTruthBody)) /// ); - /// let module = OperatorControlModule::with_truths(AppConfig::from_env(), truths); + /// let module = OperatorControlModule::with_truths(truths); /// ``` - pub fn with_truths(config: AppConfig, truths: Arc) -> Self { - let store = InMemoryKernelStore::default_local(); + pub fn with_truths(truths: Arc) -> Self { Self { - state: Arc::new(OperatorControlState::new(config, store)), + state: Arc::new(OperatorControlState::new()), pipeline: Arc::new(PipelineRouteState::with_truths(truths)), live_evidence: None, readiness_feed: None, } } -} - -impl OperatorControlModule -where - S: KernelStore + Clone + Send + Sync + 'static, -{ - /// Construct with an explicit store and an empty truth registry. - pub fn with_store(config: AppConfig, store: S) -> Self { - Self { - state: Arc::new(OperatorControlState::new(config, store)), - pipeline: Arc::new(PipelineRouteState::new()), - live_evidence: None, - readiness_feed: None, - } - } /// Mark the operator-control module as backed by live app evidence. /// @@ -316,10 +286,13 @@ where } } -impl HelmModuleReadiness for OperatorControlModule -where - S: KernelStore + Clone + Send + Sync + 'static, -{ +impl Default for OperatorControlModule { + fn default() -> Self { + Self::new() + } +} + +impl HelmModuleReadiness for OperatorControlModule { fn module_state(&self) -> HelmModuleState { OperatorControlModule::module_state(self) } @@ -330,10 +303,7 @@ where } #[async_trait] -impl HelmModule for OperatorControlModule -where - S: KernelStore + Clone + Send + Sync + 'static, -{ +impl HelmModule for OperatorControlModule { fn module_id(&self) -> &'static str { "helm.operator-control" } diff --git a/crates/helm-operator-control/src/pipeline.rs b/crates/helm-operator-control/src/pipeline.rs index 443f13e..77b24b7 100644 --- a/crates/helm-operator-control/src/pipeline.rs +++ b/crates/helm-operator-control/src/pipeline.rs @@ -3,7 +3,7 @@ //! The showcase pipeline: score-inbound-fit → qualify-inbound-lead → schedule-strategic-meetings //! //! Each step is a distinct convergence run. The coordinator: -//! 1. Reads seed data (Parquet) +//! 1. Reads seed data via an injected [`ShowcaseSeedSource`] (app/seed-IO layer) //! 2. Executes step N //! 3. Extracts relevant outputs from step N's projection //! 4. Maps them to step N+1's inputs @@ -17,6 +17,19 @@ //! `KernelStore` generic with the concrete `AppKernelStore` enum so that truth //! bodies can be trait-object-safe. //! +//! # Seed data injection (RFL-154 T5b) +//! +//! The former Parquet seed-loaders (`load_prospect_events_from_seed`, +//! `load_prospect_context_from_seed`) have been removed from this crate to +//! eliminate the `polars` dependency from the spine. The mounting app supplies +//! a [`ShowcaseSeedSource`] implementation via +//! [`PipelineRouteState::with_seed_source`]. A Parquet-based reference +//! implementation is in `crates/seed-gen/src/showcase_seed.rs`. +//! +//! When no seed source is wired the `POST /v1/pipeline/showcase/run` endpoint +//! returns `501 Not Implemented`, mirroring the existing behaviour for +//! unregistered truth bodies. +//! //! # HTTP surface //! //! - `POST /v1/pipeline/showcase/run` — synchronous pipeline run; returns `PipelineResult` @@ -28,7 +41,6 @@ //! to `application-server` internals (Phase 4b). use std::collections::HashMap; -use std::path::Path; use std::sync::Arc; use application_kernel::Actor as CrmActor; @@ -46,6 +58,11 @@ use helm_truth_execution::{ dispatcher::{TruthExecutionContext, execute_truth}, }; +// Re-export the public contract types so callers can import from this module. +pub use helm_module_contracts::showcase_pipeline::{ + SeedSourceError, ShowcasePipelineInput, ShowcaseSeedSource, +}; + // ── Pipeline Types ────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize)] @@ -81,63 +98,61 @@ pub enum StepStatus { Failed { error: String }, } -// ── Pipeline Input ────────────────────────────────────────────────── - -#[derive(Debug, Clone, Deserialize)] -pub struct ShowcasePipelineInput { - pub prospect_name: String, - pub visitor_id: String, - pub usage_events_json: String, - pub inbound_summary: String, - pub meeting_count: u32, - pub window_start: String, - pub window_end: String, - pub calendar_slots_json: Option, - pub industry: Option, - pub website: Option, - pub contact_name: Option, - pub contact_title: Option, - pub contact_email: Option, -} - // ── HTTP Route State ──────────────────────────────────────────────── /// State for the pipeline HTTP routes. /// -/// Holds the truth registry, a concrete kernel store, runtime stores, and the -/// last pipeline result for the `/status` endpoint. +/// Holds the truth registry, a concrete kernel store, runtime stores, the +/// last pipeline result for the `/status` endpoint, and an optional +/// app-supplied seed source. /// -/// Construct with [`PipelineRouteState::new`] (empty registry, in-memory stores) -/// or [`PipelineRouteState::with_truths`] when truth bodies are registered. +/// Construct with [`PipelineRouteState::new`] (empty registry, in-memory +/// stores, no seed source) or the builder methods for fuller configuration. #[derive(Clone)] pub struct PipelineRouteState { pub truths: Arc, pub store: AppKernelStore, pub runtime_stores: AppRuntimeStores, pub current_result: Arc>>, + /// App-supplied seed source. `None` → `run_pipeline` returns 501. + seed_source: Option>, } impl PipelineRouteState { - /// Default state with empty truth registry and in-memory stores. - /// Pipeline execution will return `501 Not Implemented` for each truth key. + /// Default state with empty truth registry, in-memory stores, and no seed source. + /// + /// Pipeline execution will return `501 Not Implemented` for each truth key + /// and for the seed-load step until sources are wired via builder methods. pub fn new() -> Self { Self { truths: Arc::new(TruthExecutionModule::new()), store: AppKernelStore::Memory(InMemoryKernelStore::default_local()), runtime_stores: AppRuntimeStores::default(), current_result: Arc::new(RwLock::new(None)), + seed_source: None, } } - /// State with a populated truth registry (in-memory stores). + /// State with a populated truth registry (in-memory stores, no seed source). pub fn with_truths(truths: Arc) -> Self { Self { truths, store: AppKernelStore::Memory(InMemoryKernelStore::default_local()), runtime_stores: AppRuntimeStores::default(), current_result: Arc::new(RwLock::new(None)), + seed_source: None, } } + + /// Wire an app-supplied seed source for the `run_pipeline` handler. + /// + /// The source is called with the `prospect_id` from the HTTP request body + /// (defaulting to `"prospect-001"`). Without a source the handler returns + /// `501 Not Implemented`. + pub fn with_seed_source(mut self, source: Arc) -> Self { + self.seed_source = Some(source); + self + } } impl Default for PipelineRouteState { @@ -177,12 +192,19 @@ async fn run_pipeline( State(state): State>, Json(request): Json, ) -> Result, (StatusCode, String)> { - use std::path::PathBuf; - let prospect_id = request.prospect_id.unwrap_or_else(|| "prospect-001".into()); - let seed_dir = PathBuf::from("data/seed"); - let mut input = load_prospect_context_from_seed(&seed_dir, &prospect_id) - .map_err(|e| (StatusCode::BAD_REQUEST, e))?; + + let seed_source = state.seed_source.as_ref().ok_or_else(|| { + ( + StatusCode::NOT_IMPLEMENTED, + "no seed source is configured; mount this module with .with_seed_source(...)".into(), + ) + })?; + + let mut input = seed_source + .load(&prospect_id) + .await + .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?; if let Some(name) = request.prospect_name { input.prospect_name = name; @@ -539,122 +561,98 @@ fn step_result_from_artifacts( } } -// ── Seed Data Loader ──────────────────────────────────────────────── - -/// Load behavioral events from seed Parquet for a specific prospect. -/// Returns the events as a JSON string suitable for score-inbound-fit input. -pub fn load_prospect_events_from_seed( - seed_dir: &Path, - prospect_id: &str, -) -> Result { - use polars::prelude::*; - - let path = seed_dir.join("behavior_events.parquet"); - let parquet_path = path - .to_str() - .ok_or_else(|| format!("invalid parquet path: {}", path.display()))?; - let df = LazyFrame::scan_parquet(PlPath::new(parquet_path), Default::default()) - .map_err(|e| format!("failed to read parquet: {e}"))? - .filter(col("prospect_id").eq(lit(prospect_id))) - .collect() - .map_err(|e| format!("failed to filter prospect: {e}"))?; - - let mut events = Vec::new(); - let rows = df.height(); - for i in 0..rows { - let visitor_id = df - .column("prospect_id") - .map_err(|e| e.to_string())? - .str() - .map_err(|e| e.to_string())? - .get(i) - .unwrap_or(""); - let timestamp = df - .column("timestamp") - .map_err(|e| e.to_string())? - .i64() - .map_err(|e| e.to_string())? - .get(i) - .unwrap_or(0); - let event_type = df - .column("event_types") - .map_err(|e| e.to_string())? - .str() - .map_err(|e| e.to_string())? - .get(i) - .unwrap_or(""); - let page = df - .column("page_sections") - .map_err(|e| e.to_string())? - .str() - .map_err(|e| e.to_string())? - .get(i) - .unwrap_or(""); - - events.push(serde_json::json!({ - "visitor_id": visitor_id, - "timestamp": timestamp, - "event_type": event_type, - "page": page, - })); +// ── Tests ─────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use async_trait::async_trait; + + use super::*; + + /// Minimal stub that always returns a fixed `ShowcasePipelineInput`. + struct StubSeedSource { + prospect_name: String, } - serde_json::to_string(&events).map_err(|e| format!("json serialization failed: {e}")) -} + #[async_trait] + impl ShowcaseSeedSource for StubSeedSource { + async fn load(&self, prospect_id: &str) -> Result { + Ok(ShowcasePipelineInput { + prospect_name: self.prospect_name.clone(), + visitor_id: prospect_id.into(), + usage_events_json: "[]".into(), + inbound_summary: format!("Stub inbound for {prospect_id}"), + meeting_count: 1, + window_start: "2026-04-21".into(), + window_end: "2026-04-25".into(), + calendar_slots_json: None, + industry: None, + website: None, + contact_name: None, + contact_title: None, + contact_email: None, + }) + } + } + + /// Stub that always returns a `ProspectNotFound` error. + struct MissingSeedSource; -/// Load account context from seed Parquet for a specific prospect. -pub fn load_prospect_context_from_seed( - seed_dir: &Path, - prospect_id: &str, -) -> Result { - use polars::prelude::*; - - let path = seed_dir.join("account_context.parquet"); - let parquet_path = path - .to_str() - .ok_or_else(|| format!("invalid parquet path: {}", path.display()))?; - let df = LazyFrame::scan_parquet(PlPath::new(parquet_path), Default::default()) - .map_err(|e| format!("failed to read account_context parquet: {e}"))? - .filter(col("prospect_id").eq(lit(prospect_id))) - .collect() - .map_err(|e| format!("failed to filter prospect: {e}"))?; - - if df.height() == 0 { - return Err(format!( - "prospect '{prospect_id}' not found in account_context" - )); + #[async_trait] + impl ShowcaseSeedSource for MissingSeedSource { + async fn load(&self, prospect_id: &str) -> Result { + Err(SeedSourceError::ProspectNotFound { + prospect_id: prospect_id.into(), + }) + } + } + + #[tokio::test] + async fn stub_seed_source_supplies_pipeline_input_via_injection() { + let stub = Arc::new(StubSeedSource { + prospect_name: "Acme Corp".into(), + }); + let state = PipelineRouteState::new().with_seed_source(stub); + + let input = state + .seed_source + .as_ref() + .expect("seed source wired in") + .load("prospect-001") + .await + .expect("stub always succeeds"); + + assert_eq!(input.prospect_name, "Acme Corp"); + assert_eq!(input.visitor_id, "prospect-001"); + assert_eq!(input.meeting_count, 1); } - let name = df - .column("company_name") - .ok() - .and_then(|c| c.str().ok()) - .and_then(|s| s.get(0)) - .unwrap_or(prospect_id) - .to_string(); - - let industry = df - .column("industry") - .ok() - .and_then(|c| c.str().ok()) - .and_then(|s| s.get(0)) - .map(ToString::to_string); - - let events_json = load_prospect_events_from_seed(seed_dir, prospect_id)?; - - Ok(ShowcasePipelineInput { - prospect_name: name, - visitor_id: prospect_id.to_string(), - usage_events_json: events_json, - inbound_summary: format!("Inbound inquiry from {prospect_id} via website demo request"), - meeting_count: 1, - window_start: "2026-04-21".into(), - window_end: "2026-04-25".into(), - calendar_slots_json: None, - industry, - website: None, - contact_name: None, - contact_title: None, - contact_email: None, - }) + #[tokio::test] + async fn pipeline_state_without_seed_source_has_none() { + let state = PipelineRouteState::new(); + assert!(state.seed_source.is_none(), "no source wired by default"); + } + + #[tokio::test] + async fn missing_seed_source_error_carries_typed_prospect_id() { + let stub = Arc::new(MissingSeedSource); + let state = PipelineRouteState::new().with_seed_source(stub); + + let err = state + .seed_source + .as_ref() + .unwrap() + .load("prospect-999") + .await + .expect_err("missing source returns error"); + + assert!( + matches!(err, SeedSourceError::ProspectNotFound { ref prospect_id } if prospect_id == "prospect-999"), + "error variant carries prospect id" + ); + assert!( + err.to_string().contains("prospect-999"), + "Display includes prospect id: {err}" + ); + } } diff --git a/crates/helm-operator-control/tests/compile_fail/prio_agent_ops_not_a_dep.rs b/crates/helm-operator-control/tests/compile_fail/prio_agent_ops_not_a_dep.rs new file mode 100644 index 0000000..b3b0d0b --- /dev/null +++ b/crates/helm-operator-control/tests/compile_fail/prio_agent_ops_not_a_dep.rs @@ -0,0 +1,12 @@ +//! Regression guard (RFL-154): prio_agent_ops is NOT a dependency of +//! helm-operator-control after the seam cut. +//! +//! Before T3/T5a, operator-control imported 18 vocabulary types from +//! prio-agent-ops (including JobReadinessPacket). Those types now live in +//! `helm_module_contracts::operator_receipts`. This file must NOT compile, +//! proving the old dep-edge is gone and cannot be accidentally re-introduced. + +fn main() { + // prio_agent_ops is not in the dep tree — this crate path is not resolvable. + let _: prio_agent_ops::JobReadinessPacket; +} diff --git a/crates/helm-operator-control/tests/compile_fail/prio_agent_ops_not_a_dep.stderr b/crates/helm-operator-control/tests/compile_fail/prio_agent_ops_not_a_dep.stderr new file mode 100644 index 0000000..89de379 --- /dev/null +++ b/crates/helm-operator-control/tests/compile_fail/prio_agent_ops_not_a_dep.stderr @@ -0,0 +1,7 @@ +error[E0433]: cannot find module or crate `prio_agent_ops` in this scope + --> tests/compile_fail/prio_agent_ops_not_a_dep.rs:11:12 + | +11 | let _: prio_agent_ops::JobReadinessPacket; + | ^^^^^^^^^^^^^^ use of unresolved module or unlinked crate `prio_agent_ops` + | + = help: if you wanted to use a crate named `prio_agent_ops`, use `cargo add prio_agent_ops` to add it to your `Cargo.toml` diff --git a/crates/helm-operator-control/tests/compile_fail/workbench_backend_not_a_dep.rs b/crates/helm-operator-control/tests/compile_fail/workbench_backend_not_a_dep.rs new file mode 100644 index 0000000..5f5646a --- /dev/null +++ b/crates/helm-operator-control/tests/compile_fail/workbench_backend_not_a_dep.rs @@ -0,0 +1,12 @@ +//! Regression guard (RFL-154): workbench_backend is NOT a dependency of +//! helm-operator-control after the seam cut. +//! +//! Before T5a, operator-control depended on workbench-backend for +//! OperatorControlPreview. That type now lives in +//! `helm_module_contracts::operator_preview`. This file must NOT compile, +//! proving the old dep-edge is gone and cannot be accidentally re-introduced. + +fn main() { + // workbench_backend is not in the dep tree — this crate path is not resolvable. + let _: workbench_backend::views::OperatorControlPreview; +} diff --git a/crates/helm-operator-control/tests/compile_fail/workbench_backend_not_a_dep.stderr b/crates/helm-operator-control/tests/compile_fail/workbench_backend_not_a_dep.stderr new file mode 100644 index 0000000..bc2be27 --- /dev/null +++ b/crates/helm-operator-control/tests/compile_fail/workbench_backend_not_a_dep.stderr @@ -0,0 +1,7 @@ +error[E0433]: cannot find module or crate `workbench_backend` in this scope + --> tests/compile_fail/workbench_backend_not_a_dep.rs:11:12 + | +11 | let _: workbench_backend::views::OperatorControlPreview; + | ^^^^^^^^^^^^^^^^^ use of unresolved module or unlinked crate `workbench_backend` + | + = help: if you wanted to use a crate named `workbench_backend`, use `cargo add workbench_backend` to add it to your `Cargo.toml` diff --git a/crates/helm-operator-control/tests/module_test.rs b/crates/helm-operator-control/tests/module_test.rs index 8249524..dc57971 100644 --- a/crates/helm-operator-control/tests/module_test.rs +++ b/crates/helm-operator-control/tests/module_test.rs @@ -1,21 +1,19 @@ use std::sync::Arc; -use application_storage::{AppConfig, InMemoryKernelStore}; -use helm_module_contracts::{HelmModule, ModuleState}; -use helm_operator_control::{ +use helm_module_contracts::operator_preview::{OperatorControlPreview, OperatorControlPreviewBacking}; +use helm_module_contracts::operator_receipts::{ AdapterReceiptStatus, AuthorityEffect, EvidenceReadinessStatus, JobEvidenceStatus, - JobReadinessPacket, JobReadinessPacketInput, JobVerdict, LiveOperatorControlSnapshot, - LiveReadinessEvidence, OperatorControlModule, OperatorControlModuleState, - OperatorControlPreview, OperatorControlPreviewBacking, OperatorControlReadinessFeed, - OperatorControlState, OperatorLedgerRecordKind, ReceiptFamily, - job_readiness_packet_ledger_entry, job_readiness_packet_payload_hash, + JobReadinessPacket, JobReadinessPacketInput, JobVerdict, OperatorControlError, + OperatorLedgerRecordKind, ReceiptFamily, job_readiness_packet_ledger_entry, + job_readiness_packet_payload_hash, +}; +use helm_module_contracts::{HelmModule, HelmModuleState, ModuleState}; +use helm_operator_control::{ + LiveOperatorControlSnapshot, LiveReadinessEvidence, OperatorControlModule, + OperatorControlReadinessFeed, OperatorControlState, }; use serde_json::json; -fn test_config() -> AppConfig { - AppConfig::default() -} - #[derive(Clone)] struct StaticReadinessFeed { evidence: LiveReadinessEvidence, @@ -27,9 +25,7 @@ impl OperatorControlReadinessFeed for StaticReadinessFeed { self.evidence } - fn previews( - &self, - ) -> Result, helm_operator_control::OperatorControlError> { + fn previews(&self) -> Result, OperatorControlError> { Ok(self.snapshots.clone()) } } @@ -77,30 +73,28 @@ fn sample_snapshot() -> LiveOperatorControlSnapshot { #[test] fn module_id_is_stable() { - let m: Arc> = - Arc::new(OperatorControlModule::new(test_config())); + let m: Arc = Arc::new(OperatorControlModule::new()); assert_eq!(m.module_id(), "helm.operator-control"); } #[test] fn module_exposes_router() { - let m: Arc> = - Arc::new(OperatorControlModule::new(test_config())); + let m: Arc = Arc::new(OperatorControlModule::new()); // Calling router() consumes the Arc — just verify it doesn't panic. let _router = m.router(); } #[test] fn default_module_reports_shell_default() { - let m: OperatorControlModule = OperatorControlModule::new(test_config()); + let m: OperatorControlModule = OperatorControlModule::new(); let status = m.readiness_status(); - assert_eq!(m.module_state(), OperatorControlModuleState::ShellDefault); + assert_eq!(m.module_state(), HelmModuleState::ShellDefault); assert_eq!( - as HelmModule>::module_state(&m), + ::module_state(&m), ModuleState::Shell ); - assert_eq!(status.state, OperatorControlModuleState::ShellDefault); + assert_eq!(status.state, HelmModuleState::ShellDefault); assert_eq!(status.registered_truths, Some(0)); assert!( status @@ -126,22 +120,22 @@ fn default_module_reports_shell_default() { #[test] fn complete_live_evidence_reports_live() { - let m: OperatorControlModule = OperatorControlModule::new(test_config()) + let m: OperatorControlModule = OperatorControlModule::new() .with_live_readiness_evidence(LiveReadinessEvidence::complete()); let status = m.readiness_status(); - assert_eq!(m.module_state(), OperatorControlModuleState::Live); + assert_eq!(m.module_state(), HelmModuleState::Live); assert_eq!( - as HelmModule>::module_state(&m), + ::module_state(&m), ModuleState::Live ); - assert_eq!(status.state, OperatorControlModuleState::Live); + assert_eq!(status.state, HelmModuleState::Live); assert!(status.missing_live_requirements.is_empty()); } #[test] fn readiness_status_serializes_shell_default_for_rr_verifier() { - let m: OperatorControlModule = OperatorControlModule::new(test_config()); + let m: OperatorControlModule = OperatorControlModule::new(); let value = serde_json::to_value(m.readiness_status()).expect("status serializes"); assert_eq!(value["module_id"], "helm.operator-control"); @@ -169,7 +163,7 @@ fn readiness_status_serializes_shell_default_for_rr_verifier() { #[test] fn readiness_status_serializes_live_without_missing_requirements() { - let m: OperatorControlModule = OperatorControlModule::new(test_config()) + let m: OperatorControlModule = OperatorControlModule::new() .with_live_readiness_evidence(LiveReadinessEvidence::complete()); let value = serde_json::to_value(m.readiness_status()).expect("status serializes"); @@ -185,16 +179,16 @@ fn live_readiness_feed_reports_live_when_evidence_and_snapshot_exist() { evidence: LiveReadinessEvidence::complete(), snapshots: vec![sample_snapshot()], }); - let m: OperatorControlModule = - OperatorControlModule::new(test_config()).with_live_readiness_feed(feed); + let m: OperatorControlModule = + OperatorControlModule::new().with_live_readiness_feed(feed); let status = m.readiness_status(); - assert_eq!(m.module_state(), OperatorControlModuleState::Live); + assert_eq!(m.module_state(), HelmModuleState::Live); assert_eq!( - as HelmModule>::module_state(&m), + ::module_state(&m), ModuleState::Live ); - assert_eq!(status.state, OperatorControlModuleState::Live); + assert_eq!(status.state, HelmModuleState::Live); assert!( status .live_requirements @@ -209,16 +203,16 @@ fn live_readiness_feed_requires_at_least_one_snapshot() { evidence: LiveReadinessEvidence::complete(), snapshots: Vec::new(), }); - let m: OperatorControlModule = - OperatorControlModule::new(test_config()).with_live_readiness_feed(feed); + let m: OperatorControlModule = + OperatorControlModule::new().with_live_readiness_feed(feed); let status = m.readiness_status(); - assert_eq!(m.module_state(), OperatorControlModuleState::ShellDefault); + assert_eq!(m.module_state(), HelmModuleState::ShellDefault); assert_eq!( - as HelmModule>::module_state(&m), + ::module_state(&m), ModuleState::Shell ); - assert_eq!(status.state, OperatorControlModuleState::ShellDefault); + assert_eq!(status.state, HelmModuleState::ShellDefault); assert!( status .missing_live_requirements @@ -232,8 +226,7 @@ fn operator_control_state_uses_live_feed_previews_when_present() { evidence: LiveReadinessEvidence::complete(), snapshots: vec![sample_snapshot()], }); - let store = InMemoryKernelStore::default_local(); - let state = OperatorControlState::new(store.config.clone(), store).with_readiness_feed(feed); + let state = OperatorControlState::new().with_readiness_feed(feed); let preview = state .operator_control_preview() .expect("live feed preview is available"); @@ -249,8 +242,7 @@ fn operator_control_state_does_not_fall_back_to_static_demo_when_live_feed_is_em evidence: LiveReadinessEvidence::complete(), snapshots: Vec::new(), }); - let store = InMemoryKernelStore::default_local(); - let state = OperatorControlState::new(store.config.clone(), store).with_readiness_feed(feed); + let state = OperatorControlState::new().with_readiness_feed(feed); let previews = state .operator_control_previews() .expect("empty live feed returns empty previews"); @@ -268,8 +260,7 @@ fn operator_control_state_does_not_fall_back_to_static_demo_when_live_feed_is_em #[test] fn operator_control_state_returns_empty_previews_without_live_feed() { - let store = InMemoryKernelStore::default_local(); - let state = OperatorControlState::new(store.config.clone(), store); + let state = OperatorControlState::new(); assert!( state @@ -311,3 +302,147 @@ fn helm_crate_exports_operator_control_contracts() { assert_eq!(entry.authority_effect, AuthorityEffect::None); assert_eq!(entry.source_ref, packet.packet_id); } + +// ── Soak ────────────────────────────────────────────────────────────────────── + +/// Soak: packet build → ledger entry → preview loop via StaticReadinessFeed. +/// +/// Runs `SOAK_ITERS` iterations (default 100 000). On every iteration: +/// - Builds a `JobReadinessPacket` and ledger entry from a fixed input. +/// - Wraps them in a `StaticReadinessFeed` and asserts the live preview +/// resolves correctly. +/// - Asserts that packet_id and entry_id are stable across all iterations +/// (determinism / no drift). +/// - Every 10 000 iterations, builds a packet from a *mutated* input and +/// asserts the ids differ (inequality check). +/// +/// Run: +/// ```text +/// SOAK_ITERS=10000 cargo test -p helm-operator-control -- --ignored soak --nocapture +/// ``` +#[test] +#[ignore = "soak — run with: SOAK_ITERS=10000 cargo test -p helm-operator-control -- --ignored soak --nocapture"] +fn soak_packet_ledger_preview_no_drift() { + use std::sync::Arc; + use std::time::Instant; + + let iters: u64 = std::env::var("SOAK_ITERS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(100_000); + + // Canonical baseline input. + let base_input = || JobReadinessPacketInput { + package_id: "soak.pkg.001".to_string(), + truth_version: "truth.soak.v1".to_string(), + domain_hint: "soak.operator-control".to_string(), + job_key: "soak-readiness-check".to_string(), + subject_ref: "soak.subject.abcdef".to_string(), + adapter_receipt_id: "soak.adapter.receipt.abcdef".to_string(), + adapter_status: AdapterReceiptStatus::Succeeded, + verdict: Some(JobVerdict::Blocked), + authorizes_domain_action: false, + evidence_status: vec![JobEvidenceStatus { + clause_id: "soak.clause.1".to_string(), + clause_key: "soak-evidence-key".to_string(), + label: "Soak evidence present".to_string(), + status: EvidenceReadinessStatus::Present, + fact_ids: vec!["soak.fact.1".to_string()], + evidence_refs: vec!["soak.evidence.1".to_string()], + trace_links: Vec::new(), + concern_record_ids: Vec::new(), + }], + fuzzy_trace: None, + verifier_forbidden_actions: vec!["soak.forbidden".to_string()], + operator_actions: vec!["soak.action".to_string()], + }; + + // Build baseline once to capture reference ids and hash. + let ref_packet = + JobReadinessPacket::new(base_input()).expect("baseline packet builds"); + let ref_entry = job_readiness_packet_ledger_entry( + 1, + &ref_packet, + vec!["soak.backlink.1".to_string()], + "soak baseline ledger entry", + ) + .expect("baseline ledger entry builds"); + + let ref_packet_id = ref_packet.packet_id.clone(); + let ref_entry_id = ref_entry.entry_id.clone(); + let ref_hash = job_readiness_packet_payload_hash(&ref_packet); + + // Build the mutated input once (append "-mutated" to package_id). + let mutated_packet = { + let mut m = base_input(); + m.package_id.push_str("-mutated"); + JobReadinessPacket::new(m).expect("mutated packet builds") + }; + assert_ne!( + mutated_packet.packet_id, ref_packet_id, + "mutated input must produce a different packet_id" + ); + + let start = Instant::now(); + eprintln!("soak: starting {iters} iterations…"); + + for i in 0..iters { + // Build packet + ledger entry from identical input. + let packet = JobReadinessPacket::new(base_input()).expect("packet builds"); + assert_eq!( + packet.packet_id, ref_packet_id, + "iteration {i}: packet_id drifted" + ); + + let hash = job_readiness_packet_payload_hash(&packet); + assert_eq!(hash, ref_hash, "iteration {i}: payload hash drifted"); + + let entry = job_readiness_packet_ledger_entry( + 1, + &packet, + vec!["soak.backlink.1".to_string()], + "soak baseline ledger entry", + ) + .expect("ledger entry builds"); + assert_eq!( + entry.entry_id, ref_entry_id, + "iteration {i}: entry_id drifted" + ); + assert_eq!( + entry.authority_effect, + AuthorityEffect::None, + "iteration {i}: authority_effect is not None" + ); + + // Thread through the live feed → preview path every iteration. + let snapshot = LiveOperatorControlSnapshot::new(packet, vec![entry]); + let feed = Arc::new(StaticReadinessFeed { + evidence: LiveReadinessEvidence::complete(), + snapshots: vec![snapshot], + }); + let m = OperatorControlModule::new().with_live_readiness_feed(feed); + assert_eq!( + m.module_state(), + HelmModuleState::Live, + "iteration {i}: module should report Live" + ); + + // Inequality check every 10 000 iterations. + if i % 10_000 == 9_999 { + let mut mi = base_input(); + mi.package_id = format!("soak.pkg.001-iter{i}"); + let mp = JobReadinessPacket::new(mi).expect("mutated packet builds"); + assert_ne!( + mp.packet_id, ref_packet_id, + "iteration {i}: mutated packet_id must differ from baseline" + ); + } + } + + let elapsed = start.elapsed(); + eprintln!( + "soak: {iters} iterations in {:.2}s ({:.0} iter/s)", + elapsed.as_secs_f64(), + iters as f64 / elapsed.as_secs_f64() + ); +} diff --git a/crates/helm-operator-control/tests/trybuild_shim_regression.rs b/crates/helm-operator-control/tests/trybuild_shim_regression.rs new file mode 100644 index 0000000..df9bd23 --- /dev/null +++ b/crates/helm-operator-control/tests/trybuild_shim_regression.rs @@ -0,0 +1,14 @@ +//! Trybuild regression suite for the RFL-154 seam cut (helm-operator-control). +//! +//! Guards that the two dropped dep-edges (workbench-backend, prio-agent-ops) +//! cannot be re-introduced without a compile failure. Each `.rs` file in +//! `tests/compile_fail/` must fail to compile. +//! +//! Run `TRYBUILD=overwrite cargo test -p helm-operator-control \ +//! --test trybuild_shim_regression` to regenerate stderr snapshots. + +#[test] +fn shim_regression_guards() { + let t = trybuild::TestCases::new(); + t.compile_fail("tests/compile_fail/*.rs"); +} diff --git a/crates/helm-session-host/Cargo.toml b/crates/helm-session-host/Cargo.toml index ee35ced..2818b03 100644 --- a/crates/helm-session-host/Cargo.toml +++ b/crates/helm-session-host/Cargo.toml @@ -14,7 +14,7 @@ axum.workspace = true chrono.workspace = true director-contracts = { version = "0.2.1", path = "../director-contracts" } helm-client = { version = "0.2.1", path = "../helm-client" } -helm-module-contracts = { version = "0.2.1", path = "../../contracts/crates/helm-module-contracts" } +helm-module-contracts = { version = "0.3.0", path = "../../contracts/crates/helm-module-contracts" } helm-session-contracts = { version = "0.2.1", path = "../helm-session-contracts" } # RP-HELMS-SUBSTRATE-SEAM: approved foundation→substrate edge; needed for # SessionOwnershipLayer, EventHub / EventHubHandle / EventCursor / SSE. HelmModule diff --git a/crates/helm-truth-execution/Cargo.toml b/crates/helm-truth-execution/Cargo.toml index a576ac0..b986783 100644 --- a/crates/helm-truth-execution/Cargo.toml +++ b/crates/helm-truth-execution/Cargo.toml @@ -15,7 +15,7 @@ chrono.workspace = true converge-core.workspace = true converge-kernel.workspace = true converge-pack.workspace = true -helm-module-contracts = { version = "0.2.1", path = "../../contracts/crates/helm-module-contracts" } +helm-module-contracts = { version = "0.3.0", path = "../../contracts/crates/helm-module-contracts" } serde.workspace = true serde_json.workspace = true tokio.workspace = true diff --git a/crates/prio-agent-ops/Cargo.toml b/crates/prio-agent-ops/Cargo.toml index 43a18cd..d64ac60 100644 --- a/crates/prio-agent-ops/Cargo.toml +++ b/crates/prio-agent-ops/Cargo.toml @@ -9,5 +9,3 @@ publish.workspace = true [dependencies] capability-core = { version = "0.2.1", path = "../capability-core" } -serde = { workspace = true, features = ["derive"] } -sha2 = { workspace = true } diff --git a/crates/prio-agent-ops/src/lib.rs b/crates/prio-agent-ops/src/lib.rs index 9b0d5ec..0b6f7a8 100644 --- a/crates/prio-agent-ops/src/lib.rs +++ b/crates/prio-agent-ops/src/lib.rs @@ -1,10 +1,4 @@ use capability_core::{ApiSurface, CapabilityModule, ModuleManifest, ModuleSuite}; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use std::{ - error::Error, - fmt::{self, Write as _}, -}; pub struct AgentOpsModule; @@ -40,951 +34,3 @@ impl ModuleManifest for AgentOpsModule { MODULE } } - -/// Helm-owned read model for "can this job be trusted enough for an operator -/// to continue reviewing it?" -/// -/// This is intentionally not an Axiom type. Axiom supplies packages, reports, -/// clause ids, and adapter receipts. Helm composes those with app subject refs, -/// missing-evidence actions, and operator ledger links. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct JobReadinessPacket { - pub packet_id: String, - pub package_id: String, - pub truth_version: String, - pub domain_hint: String, - pub job_key: String, - pub subject_ref: String, - pub adapter_receipt_id: String, - pub adapter_status: AdapterReceiptStatus, - pub verdict: Option, - pub authorizes_domain_action: bool, - pub evidence_status: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub fuzzy_trace: Option, - pub verifier_forbidden_actions: Vec, - pub operator_actions: Vec, -} - -impl JobReadinessPacket { - /// Builds a deterministic packet and enforces the Helm boundary that a - /// readiness view never authorizes the underlying app action. - pub fn new(input: JobReadinessPacketInput) -> Result { - validate_nonempty("package_id", &input.package_id)?; - validate_nonempty("truth_version", &input.truth_version)?; - validate_nonempty("domain_hint", &input.domain_hint)?; - validate_nonempty("job_key", &input.job_key)?; - validate_nonempty("subject_ref", &input.subject_ref)?; - validate_nonempty("adapter_receipt_id", &input.adapter_receipt_id)?; - if let Some(trace) = &input.fuzzy_trace { - validate_fuzzy_trace(trace)?; - } - if input.authorizes_domain_action { - return Err(OperatorControlError::DomainActionAuthorityRequested); - } - - let packet_id = job_readiness_packet_id(&input); - Ok(Self { - packet_id, - package_id: input.package_id, - truth_version: input.truth_version, - domain_hint: input.domain_hint, - job_key: input.job_key, - subject_ref: input.subject_ref, - adapter_receipt_id: input.adapter_receipt_id, - adapter_status: input.adapter_status, - verdict: input.verdict, - authorizes_domain_action: false, - evidence_status: input.evidence_status, - fuzzy_trace: input.fuzzy_trace, - verifier_forbidden_actions: input.verifier_forbidden_actions, - operator_actions: input.operator_actions, - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct JobReadinessPacketInput { - pub package_id: String, - pub truth_version: String, - pub domain_hint: String, - pub job_key: String, - pub subject_ref: String, - pub adapter_receipt_id: String, - pub adapter_status: AdapterReceiptStatus, - pub verdict: Option, - pub authorizes_domain_action: bool, - pub evidence_status: Vec, - pub fuzzy_trace: Option, - pub verifier_forbidden_actions: Vec, - pub operator_actions: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct JobEvidenceStatus { - pub clause_id: String, - pub clause_key: String, - pub label: String, - pub status: EvidenceReadinessStatus, - pub fact_ids: Vec, - pub evidence_refs: Vec, - pub trace_links: Vec, - pub concern_record_ids: Vec, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FuzzyReadinessTrace { - pub variable_key: String, - pub observed_value_basis_points: u16, - pub memberships: Vec, - pub activated_rules: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub defuzzified_score: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FuzzyMembership { - pub label: String, - pub score_basis_points: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FuzzyRuleActivation { - pub rule_id: String, - pub strength_basis_points: u16, - pub conclusion: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct FuzzyDefuzzifiedScore { - pub method: String, - pub score_basis_points: u16, - pub domain_min_basis_points: u16, - pub domain_max_basis_points: u16, - pub domain_steps: u32, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AdapterReceiptStatus { - Succeeded, - Rejected, -} - -impl AdapterReceiptStatus { - pub const fn as_str(self) -> &'static str { - match self { - Self::Succeeded => "succeeded", - Self::Rejected => "rejected", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum JobVerdict { - Satisfied, - Blocked, - Exhausted, - Invalid, -} - -impl JobVerdict { - pub const fn as_str(self) -> &'static str { - match self { - Self::Satisfied => "satisfied", - Self::Blocked => "blocked", - Self::Exhausted => "exhausted", - Self::Invalid => "invalid", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum EvidenceReadinessStatus { - Present, - Missing, - Disputed, - Blocked, - Concern, -} - -impl EvidenceReadinessStatus { - pub const fn as_str(self) -> &'static str { - match self { - Self::Present => "present", - Self::Missing => "missing", - Self::Disputed => "disputed", - Self::Blocked => "blocked", - Self::Concern => "concern", - } - } -} - -/// Deterministic append-only ledger entry for Helm operator-control receipts. -/// -/// This is a control-plane journal entry. It stores ids, refs, hashes, and -/// backlinks; it does not store raw app transcripts and it never grants domain -/// authority. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OperatorLedgerEntry { - pub entry_id: String, - pub sequence: u64, - pub record_kind: OperatorLedgerRecordKind, - pub receipt_family: ReceiptFamily, - pub source_ref: String, - pub package_id: String, - pub truth_version: String, - pub domain_hint: String, - pub payload_hash: String, - pub backlink_ids: Vec, - pub authority_effect: AuthorityEffect, - pub summary: String, -} - -impl OperatorLedgerEntry { - pub fn new(input: OperatorLedgerEntryInput) -> Result { - validate_nonempty("source_ref", &input.source_ref)?; - validate_nonempty("package_id", &input.package_id)?; - validate_nonempty("truth_version", &input.truth_version)?; - validate_nonempty("domain_hint", &input.domain_hint)?; - validate_nonempty("summary", &input.summary)?; - validate_sha256("payload_hash", &input.payload_hash)?; - if input.backlink_ids.iter().any(|id| id.trim().is_empty()) { - return Err(OperatorControlError::EmptyBacklink); - } - - let entry_id = operator_ledger_entry_id(&input); - Ok(Self { - entry_id, - sequence: input.sequence, - record_kind: input.record_kind, - receipt_family: input.receipt_family, - source_ref: input.source_ref, - package_id: input.package_id, - truth_version: input.truth_version, - domain_hint: input.domain_hint, - payload_hash: input.payload_hash, - backlink_ids: input.backlink_ids, - authority_effect: AuthorityEffect::None, - summary: input.summary, - }) - } -} - -pub fn job_readiness_packet_payload_hash(packet: &JobReadinessPacket) -> String { - let evidence_hash = evidence_status_hash(&packet.evidence_status); - let fuzzy_hash = packet - .fuzzy_trace - .as_ref() - .map_or_else(|| "none".to_string(), fuzzy_trace_hash); - let forbidden_hash = string_list_hash(&packet.verifier_forbidden_actions); - let action_hash = string_list_hash(&packet.operator_actions); - let verdict = packet.verdict.map_or("none", JobVerdict::as_str); - - sha256_lines(&[ - "job_readiness_packet_payload", - packet.packet_id.as_str(), - packet.package_id.as_str(), - packet.truth_version.as_str(), - packet.domain_hint.as_str(), - packet.job_key.as_str(), - packet.subject_ref.as_str(), - packet.adapter_receipt_id.as_str(), - packet.adapter_status.as_str(), - verdict, - evidence_hash.as_str(), - fuzzy_hash.as_str(), - forbidden_hash.as_str(), - action_hash.as_str(), - ]) -} - -pub fn job_readiness_packet_ledger_entry( - sequence: u64, - packet: &JobReadinessPacket, - backlink_ids: Vec, - summary: impl Into, -) -> Result { - OperatorLedgerEntry::new(OperatorLedgerEntryInput { - sequence, - record_kind: OperatorLedgerRecordKind::JobReadinessPacket, - receipt_family: ReceiptFamily::Common, - source_ref: packet.packet_id.clone(), - package_id: packet.package_id.clone(), - truth_version: packet.truth_version.clone(), - domain_hint: packet.domain_hint.clone(), - payload_hash: job_readiness_packet_payload_hash(packet), - backlink_ids, - summary: summary.into(), - }) -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct OperatorLedgerEntryInput { - pub sequence: u64, - pub record_kind: OperatorLedgerRecordKind, - pub receipt_family: ReceiptFamily, - pub source_ref: String, - pub package_id: String, - pub truth_version: String, - pub domain_hint: String, - pub payload_hash: String, - pub backlink_ids: Vec, - pub summary: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum OperatorLedgerRecordKind { - ObservationAdapterReceipt, - JobReadinessPacket, - OperatorDecisionReceipt, - ApprovalReceipt, - PlanReceipt, - ExecutionReceipt, - ActionReceipt, - OutcomeReceipt, - CorpusSnapshotReceipt, - EvidenceWindowReceipt, - DisagreementReceipt, - AnalystReviewReceipt, - NarrativeClaimReceipt, - CanonicalStoryReceipt, - ClaimReviewReceipt, - EditorialApprovalReceipt, - PublicationBoundaryReceipt, - AppLocalReceipt, -} - -impl OperatorLedgerRecordKind { - pub const fn as_str(self) -> &'static str { - match self { - Self::ObservationAdapterReceipt => "observation_adapter_receipt", - Self::JobReadinessPacket => "job_readiness_packet", - Self::OperatorDecisionReceipt => "operator_decision_receipt", - Self::ApprovalReceipt => "approval_receipt", - Self::PlanReceipt => "plan_receipt", - Self::ExecutionReceipt => "execution_receipt", - Self::ActionReceipt => "action_receipt", - Self::OutcomeReceipt => "outcome_receipt", - Self::CorpusSnapshotReceipt => "corpus_snapshot_receipt", - Self::EvidenceWindowReceipt => "evidence_window_receipt", - Self::DisagreementReceipt => "disagreement_receipt", - Self::AnalystReviewReceipt => "analyst_review_receipt", - Self::NarrativeClaimReceipt => "narrative_claim_receipt", - Self::CanonicalStoryReceipt => "canonical_story_receipt", - Self::ClaimReviewReceipt => "claim_review_receipt", - Self::EditorialApprovalReceipt => "editorial_approval_receipt", - Self::PublicationBoundaryReceipt => "publication_boundary_receipt", - Self::AppLocalReceipt => "app_local_receipt", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum ReceiptFamily { - Common, - LongRunningJob, - TemporalEvidence, - ContentPublication, - AppLocal, -} - -impl ReceiptFamily { - pub const fn as_str(self) -> &'static str { - match self { - Self::Common => "common", - Self::LongRunningJob => "long_running_job", - Self::TemporalEvidence => "temporal_evidence", - Self::ContentPublication => "content_publication", - Self::AppLocal => "app_local", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AuthorityEffect { - None, -} - -impl AuthorityEffect { - pub const fn as_str(self) -> &'static str { - match self { - Self::None => "none", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum OperatorControlError { - EmptyField { - field: &'static str, - }, - EmptyBacklink, - InvalidBasisPoints { - field: &'static str, - value: u16, - }, - InvalidRange { - field: &'static str, - min: u16, - max: u16, - }, - InvalidCount { - field: &'static str, - value: u32, - }, - InvalidSha256 { - field: &'static str, - value: String, - }, - DomainActionAuthorityRequested, -} - -impl fmt::Display for OperatorControlError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::EmptyField { field } => write!(f, "`{field}` must not be empty"), - Self::EmptyBacklink => write!(f, "backlink ids must not contain empty values"), - Self::InvalidBasisPoints { field, value } => { - write!(f, "`{field}` must be between 0 and 10000, got `{value}`") - } - Self::InvalidRange { field, min, max } => { - write!(f, "`{field}` must have min < max, got `{min}`..`{max}`") - } - Self::InvalidCount { field, value } => { - write!(f, "`{field}` must be greater than zero, got `{value}`") - } - Self::InvalidSha256 { field, value } => { - write!(f, "`{field}` must be a sha256 hash, got `{value}`") - } - Self::DomainActionAuthorityRequested => { - write!(f, "job readiness packets must not authorize domain action") - } - } - } -} - -impl Error for OperatorControlError {} - -fn job_readiness_packet_id(input: &JobReadinessPacketInput) -> String { - let evidence_hash = evidence_status_hash(&input.evidence_status); - let fuzzy_hash = input - .fuzzy_trace - .as_ref() - .map_or_else(|| "none".to_string(), fuzzy_trace_hash); - let forbidden_hash = string_list_hash(&input.verifier_forbidden_actions); - let action_hash = string_list_hash(&input.operator_actions); - let verdict = input.verdict.map_or("none", JobVerdict::as_str); - - short_id( - "helm.job_readiness", - &sha256_lines(&[ - "job_readiness_packet", - input.package_id.as_str(), - input.truth_version.as_str(), - input.domain_hint.as_str(), - input.job_key.as_str(), - input.subject_ref.as_str(), - input.adapter_receipt_id.as_str(), - input.adapter_status.as_str(), - verdict, - evidence_hash.as_str(), - fuzzy_hash.as_str(), - forbidden_hash.as_str(), - action_hash.as_str(), - ]), - ) -} - -fn operator_ledger_entry_id(input: &OperatorLedgerEntryInput) -> String { - let backlinks_hash = string_list_hash(&input.backlink_ids); - let sequence = input.sequence.to_string(); - short_id( - "helm.ledger_entry", - &sha256_lines(&[ - "operator_ledger_entry", - sequence.as_str(), - input.record_kind.as_str(), - input.receipt_family.as_str(), - input.source_ref.as_str(), - input.package_id.as_str(), - input.truth_version.as_str(), - input.domain_hint.as_str(), - input.payload_hash.as_str(), - backlinks_hash.as_str(), - ]), - ) -} - -fn evidence_status_hash(statuses: &[JobEvidenceStatus]) -> String { - let mut parts = Vec::new(); - for status in statuses { - parts.push(status.clause_id.clone()); - parts.push(status.clause_key.clone()); - parts.push(status.label.clone()); - parts.push(status.status.as_str().to_string()); - parts.push(string_list_hash(&status.fact_ids)); - parts.push(string_list_hash(&status.evidence_refs)); - parts.push(string_list_hash(&status.trace_links)); - parts.push(string_list_hash(&status.concern_record_ids)); - } - string_list_hash(&parts) -} - -fn fuzzy_trace_hash(trace: &FuzzyReadinessTrace) -> String { - let observed = trace.observed_value_basis_points.to_string(); - let membership_hash = fuzzy_membership_hash(&trace.memberships); - let rule_hash = fuzzy_rule_hash(&trace.activated_rules); - let defuzzified_hash = trace - .defuzzified_score - .as_ref() - .map_or_else(|| "none".to_string(), fuzzy_defuzzified_score_hash); - sha256_lines(&[ - "fuzzy_readiness_trace", - trace.variable_key.as_str(), - observed.as_str(), - membership_hash.as_str(), - rule_hash.as_str(), - defuzzified_hash.as_str(), - ]) -} - -fn fuzzy_membership_hash(memberships: &[FuzzyMembership]) -> String { - let mut parts = Vec::new(); - for membership in memberships { - parts.push(membership.label.clone()); - parts.push(membership.score_basis_points.to_string()); - } - string_list_hash(&parts) -} - -fn fuzzy_rule_hash(rules: &[FuzzyRuleActivation]) -> String { - let mut parts = Vec::new(); - for rule in rules { - parts.push(rule.rule_id.clone()); - parts.push(rule.strength_basis_points.to_string()); - parts.push(rule.conclusion.clone()); - } - string_list_hash(&parts) -} - -fn fuzzy_defuzzified_score_hash(score: &FuzzyDefuzzifiedScore) -> String { - let score_basis_points = score.score_basis_points.to_string(); - let domain_min_basis_points = score.domain_min_basis_points.to_string(); - let domain_max_basis_points = score.domain_max_basis_points.to_string(); - let domain_steps = score.domain_steps.to_string(); - sha256_lines(&[ - "fuzzy_defuzzified_score", - score.method.as_str(), - score_basis_points.as_str(), - domain_min_basis_points.as_str(), - domain_max_basis_points.as_str(), - domain_steps.as_str(), - ]) -} - -fn string_list_hash(values: &[String]) -> String { - let refs = values.iter().map(String::as_str).collect::>(); - sha256_lines(&refs) -} - -fn validate_fuzzy_trace(trace: &FuzzyReadinessTrace) -> Result<(), OperatorControlError> { - validate_nonempty("fuzzy_trace.variable_key", &trace.variable_key)?; - validate_basis_points( - "fuzzy_trace.observed_value_basis_points", - trace.observed_value_basis_points, - )?; - if trace.memberships.is_empty() { - return Err(OperatorControlError::EmptyField { - field: "fuzzy_trace.memberships", - }); - } - for membership in &trace.memberships { - validate_nonempty("fuzzy_trace.membership.label", &membership.label)?; - validate_basis_points( - "fuzzy_trace.membership.score_basis_points", - membership.score_basis_points, - )?; - } - for rule in &trace.activated_rules { - validate_nonempty("fuzzy_trace.rule.rule_id", &rule.rule_id)?; - validate_basis_points( - "fuzzy_trace.rule.strength_basis_points", - rule.strength_basis_points, - )?; - validate_nonempty("fuzzy_trace.rule.conclusion", &rule.conclusion)?; - } - if let Some(score) = &trace.defuzzified_score { - validate_nonempty("fuzzy_trace.defuzzified_score.method", &score.method)?; - validate_basis_points( - "fuzzy_trace.defuzzified_score.score_basis_points", - score.score_basis_points, - )?; - validate_basis_points( - "fuzzy_trace.defuzzified_score.domain_min_basis_points", - score.domain_min_basis_points, - )?; - validate_basis_points( - "fuzzy_trace.defuzzified_score.domain_max_basis_points", - score.domain_max_basis_points, - )?; - if score.domain_min_basis_points >= score.domain_max_basis_points { - return Err(OperatorControlError::InvalidRange { - field: "fuzzy_trace.defuzzified_score.domain", - min: score.domain_min_basis_points, - max: score.domain_max_basis_points, - }); - } - if score.domain_steps == 0 { - return Err(OperatorControlError::InvalidCount { - field: "fuzzy_trace.defuzzified_score.domain_steps", - value: score.domain_steps, - }); - } - } - Ok(()) -} - -fn validate_basis_points(field: &'static str, value: u16) -> Result<(), OperatorControlError> { - if value <= 10_000 { - Ok(()) - } else { - Err(OperatorControlError::InvalidBasisPoints { field, value }) - } -} - -fn validate_nonempty(field: &'static str, value: &str) -> Result<(), OperatorControlError> { - if value.trim().is_empty() { - Err(OperatorControlError::EmptyField { field }) - } else { - Ok(()) - } -} - -fn validate_sha256(field: &'static str, value: &str) -> Result<(), OperatorControlError> { - if value.strip_prefix("sha256:").is_some_and(|digest| { - digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit()) - }) { - Ok(()) - } else { - Err(OperatorControlError::InvalidSha256 { - field, - value: value.to_string(), - }) - } -} - -fn short_id(prefix: &str, digest: &str) -> String { - let short_digest = &digest - .strip_prefix("sha256:") - .expect("local digest has sha256 prefix")[..12]; - format!("{prefix}.{short_digest}") -} - -fn sha256_lines(parts: &[&str]) -> String { - sha256_bytes(parts.join("\n").as_bytes()) -} - -fn sha256_bytes(bytes: &[u8]) -> String { - let mut hasher = Sha256::new(); - hasher.update(bytes); - let digest = hasher.finalize(); - let mut output = String::with_capacity("sha256:".len() + digest.len() * 2); - output.push_str("sha256:"); - for byte in digest { - write!(&mut output, "{byte:02x}").expect("writing to String cannot fail"); - } - output -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn job_readiness_packet_id_is_deterministic() { - let first = JobReadinessPacket::new(sample_packet_input()).expect("packet builds"); - let second = JobReadinessPacket::new(sample_packet_input()).expect("packet builds"); - - assert_eq!(first, second); - assert!(first.packet_id.starts_with("helm.job_readiness.")); - assert!(!first.authorizes_domain_action); - assert_eq!(first.evidence_status.len(), 2); - } - - #[test] - fn job_readiness_packet_id_changes_when_evidence_changes() { - let first = JobReadinessPacket::new(sample_packet_input()).expect("packet builds"); - let mut input = sample_packet_input(); - input.evidence_status[1].status = EvidenceReadinessStatus::Present; - input.evidence_status[1] - .fact_ids - .push("folio.editorial.claim-citations".to_string()); - let second = JobReadinessPacket::new(input).expect("packet builds"); - - assert_ne!(first.packet_id, second.packet_id); - } - - #[test] - fn job_readiness_packet_id_changes_when_fuzzy_trace_changes() { - let mut input = sample_packet_input(); - input.fuzzy_trace = Some(sample_fuzzy_trace(6_200, 3_500)); - let first = JobReadinessPacket::new(input).expect("packet builds"); - let mut input = sample_packet_input(); - input.fuzzy_trace = Some(sample_fuzzy_trace(7_000, 5_000)); - let second = JobReadinessPacket::new(input).expect("packet builds"); - - assert_ne!(first.packet_id, second.packet_id); - assert_eq!( - first - .fuzzy_trace - .as_ref() - .expect("fuzzy trace") - .memberships - .len(), - 2 - ); - } - - #[test] - fn job_readiness_packet_id_changes_when_defuzzified_score_changes() { - let mut first_input = sample_packet_input(); - let mut first_trace = sample_fuzzy_trace(6_200, 3_500); - first_trace.defuzzified_score = Some(sample_defuzzified_score(3_750)); - first_input.fuzzy_trace = Some(first_trace); - let first = JobReadinessPacket::new(first_input).expect("packet builds"); - - let mut second_input = sample_packet_input(); - let mut second_trace = sample_fuzzy_trace(6_200, 3_500); - second_trace.defuzzified_score = Some(sample_defuzzified_score(4_250)); - second_input.fuzzy_trace = Some(second_trace); - let second = JobReadinessPacket::new(second_input).expect("packet builds"); - - assert_ne!(first.packet_id, second.packet_id); - assert_eq!( - first - .fuzzy_trace - .as_ref() - .and_then(|trace| trace.defuzzified_score.as_ref()) - .expect("defuzzified score") - .method - .as_str(), - "centroid" - ); - } - - #[test] - fn job_readiness_packet_rejects_invalid_fuzzy_scores() { - let mut input = sample_packet_input(); - input.fuzzy_trace = Some(sample_fuzzy_trace(10_001, 3_500)); - - let error = JobReadinessPacket::new(input).expect_err("invalid score"); - assert_eq!( - error, - OperatorControlError::InvalidBasisPoints { - field: "fuzzy_trace.observed_value_basis_points", - value: 10_001 - } - ); - } - - #[test] - fn job_readiness_packet_rejects_invalid_defuzzified_score_domain() { - let mut input = sample_packet_input(); - let mut trace = sample_fuzzy_trace(6_200, 3_500); - trace.defuzzified_score = Some(FuzzyDefuzzifiedScore { - method: "centroid".to_string(), - score_basis_points: 3_750, - domain_min_basis_points: 10_000, - domain_max_basis_points: 10_000, - domain_steps: 1_000, - }); - input.fuzzy_trace = Some(trace); - - let error = JobReadinessPacket::new(input).expect_err("invalid domain"); - assert_eq!( - error, - OperatorControlError::InvalidRange { - field: "fuzzy_trace.defuzzified_score.domain", - min: 10_000, - max: 10_000, - } - ); - } - - #[test] - fn job_readiness_packet_rejects_domain_authority() { - let mut input = sample_packet_input(); - input.authorizes_domain_action = true; - - let error = JobReadinessPacket::new(input).expect_err("authority is rejected"); - assert_eq!(error, OperatorControlError::DomainActionAuthorityRequested); - } - - #[test] - fn operator_ledger_entry_is_deterministic_and_non_authoritative() { - let first = OperatorLedgerEntry::new(sample_ledger_input()).expect("entry builds"); - let second = OperatorLedgerEntry::new(sample_ledger_input()).expect("entry builds"); - - assert_eq!(first, second); - assert!(first.entry_id.starts_with("helm.ledger_entry.")); - assert_eq!(first.authority_effect, AuthorityEffect::None); - assert_eq!( - first.record_kind, - OperatorLedgerRecordKind::JobReadinessPacket - ); - assert_eq!(first.receipt_family, ReceiptFamily::Common); - } - - #[test] - fn operator_ledger_entry_rejects_non_hash_payloads() { - let mut input = sample_ledger_input(); - input.payload_hash = "raw-json-payload".to_string(); - - let error = OperatorLedgerEntry::new(input).expect_err("raw payload hash is rejected"); - assert_eq!( - error, - OperatorControlError::InvalidSha256 { - field: "payload_hash", - value: "raw-json-payload".to_string() - } - ); - } - - #[test] - fn operator_ledger_entry_id_changes_when_backlinks_change() { - let first = OperatorLedgerEntry::new(sample_ledger_input()).expect("entry builds"); - let mut input = sample_ledger_input(); - input - .backlink_ids - .push("helm.claim_review.9b8f00ab1111".to_string()); - let second = OperatorLedgerEntry::new(input).expect("entry builds"); - - assert_ne!(first.entry_id, second.entry_id); - } - - #[test] - fn job_readiness_packet_ledger_entry_uses_packet_payload_hash() { - let packet = JobReadinessPacket::new(sample_packet_input()).expect("packet builds"); - let entry = job_readiness_packet_ledger_entry( - 7, - &packet, - vec!["artifact.adapter.abcdef012345".to_string()], - "job readiness preview", - ) - .expect("entry builds"); - - assert_eq!( - entry.record_kind, - OperatorLedgerRecordKind::JobReadinessPacket - ); - assert_eq!(entry.receipt_family, ReceiptFamily::Common); - assert_eq!(entry.source_ref, packet.packet_id); - assert_eq!( - entry.payload_hash, - job_readiness_packet_payload_hash(&packet) - ); - assert_eq!(entry.authority_effect, AuthorityEffect::None); - } - - fn sample_packet_input() -> JobReadinessPacketInput { - JobReadinessPacketInput { - package_id: "truth_package.folio.1234".to_string(), - truth_version: "truth.v1".to_string(), - domain_hint: "folio-editor.publication-boundary".to_string(), - job_key: "folio-publication-package".to_string(), - subject_ref: "folio.subject.abcdef012345".to_string(), - adapter_receipt_id: "artifact.adapter.abcdef012345".to_string(), - adapter_status: AdapterReceiptStatus::Succeeded, - verdict: Some(JobVerdict::Invalid), - authorizes_domain_action: false, - evidence_status: vec![ - JobEvidenceStatus { - clause_id: "clause.evidence.1".to_string(), - clause_key: "canonical_story_snapshot_bound".to_string(), - label: "canonical story snapshot is bound".to_string(), - status: EvidenceReadinessStatus::Present, - fact_ids: vec!["folio.editorial.canonical-story".to_string()], - evidence_refs: vec!["evidence:folio.editorial.canonical-story".to_string()], - trace_links: vec!["trace:folio.editorial.canonical-story".to_string()], - concern_record_ids: Vec::new(), - }, - JobEvidenceStatus { - clause_id: "clause.evidence.2".to_string(), - clause_key: "claim_citations_attached".to_string(), - label: "public claims carry resolving citations".to_string(), - status: EvidenceReadinessStatus::Missing, - fact_ids: Vec::new(), - evidence_refs: Vec::new(), - trace_links: Vec::new(), - concern_record_ids: vec!["calibration.concern.123".to_string()], - }, - ], - fuzzy_trace: None, - verifier_forbidden_actions: vec![ - "public package is published without editorial approval".to_string(), - ], - operator_actions: vec![ - "inspect axiom report".to_string(), - "request missing evidence for claim_citations_attached".to_string(), - ], - } - } - - fn sample_fuzzy_trace( - observed_value_basis_points: u16, - material_score_basis_points: u16, - ) -> FuzzyReadinessTrace { - FuzzyReadinessTrace { - variable_key: "drift_severity".to_string(), - observed_value_basis_points, - memberships: vec![ - FuzzyMembership { - label: "moderate".to_string(), - score_basis_points: 4_000, - }, - FuzzyMembership { - label: "material".to_string(), - score_basis_points: material_score_basis_points, - }, - ], - activated_rules: vec![FuzzyRuleActivation { - rule_id: "revision-trigger-on-materializing-drift".to_string(), - strength_basis_points: material_score_basis_points, - conclusion: "revision_urgency:advisable".to_string(), - }], - defuzzified_score: None, - } - } - - fn sample_defuzzified_score(score_basis_points: u16) -> FuzzyDefuzzifiedScore { - FuzzyDefuzzifiedScore { - method: "centroid".to_string(), - score_basis_points, - domain_min_basis_points: 0, - domain_max_basis_points: 10_000, - domain_steps: 1_000, - } - } - - fn sample_ledger_input() -> OperatorLedgerEntryInput { - OperatorLedgerEntryInput { - sequence: 1, - record_kind: OperatorLedgerRecordKind::JobReadinessPacket, - receipt_family: ReceiptFamily::Common, - source_ref: "helm.job_readiness.abcdef012345".to_string(), - package_id: "truth_package.folio.1234".to_string(), - truth_version: "truth.v1".to_string(), - domain_hint: "folio-editor.publication-boundary".to_string(), - payload_hash: "sha256:90b8fb64fdd6f926a4ef42d67a145215aa7e7e07480863217f8558c472da579f" - .to_string(), - backlink_ids: vec!["artifact.adapter.abcdef012345".to_string()], - summary: "job readiness Invalid for folio-publication-package".to_string(), - } - } -} diff --git a/crates/seed-gen/Cargo.toml b/crates/seed-gen/Cargo.toml index d1ceb9a..b2eafbe 100644 --- a/crates/seed-gen/Cargo.toml +++ b/crates/seed-gen/Cargo.toml @@ -10,7 +10,16 @@ name = "seed-gen" path = "src/main.rs" [dependencies] +# helm-module-contracts provides ShowcasePipelineInput used by showcase_seed +# (RFL-154 T5b: Parquet loaders moved here from helm-operator-control). +helm-module-contracts = { version = "0.3.0", path = "../../contracts/crates/helm-module-contracts" } polars = { version = "0.51.0", features = ["lazy", "parquet", "rows"] } rand = { version = "0.9", features = ["small_rng"] } chrono.workspace = true anyhow.workspace = true +serde_json = "1" +async-trait = "0.1" +tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/seed-gen/src/lib.rs b/crates/seed-gen/src/lib.rs new file mode 100644 index 0000000..2e78926 --- /dev/null +++ b/crates/seed-gen/src/lib.rs @@ -0,0 +1,9 @@ +//! seed-gen library target. +//! +//! Exposes the Parquet seed-loading utilities extracted from +//! `helm-operator-control` (RFL-154 T5b) so they can be consumed by a +//! mounting app without depending on the `seed-gen` binary. +//! +//! See [`showcase_seed`] for usage notes and the known column-name mismatch. + +pub mod showcase_seed; diff --git a/crates/seed-gen/src/showcase_seed.rs b/crates/seed-gen/src/showcase_seed.rs new file mode 100644 index 0000000..f9be5fd --- /dev/null +++ b/crates/seed-gen/src/showcase_seed.rs @@ -0,0 +1,304 @@ +//! Parquet-based seed-data loaders for the showcase pipeline. +//! +//! These functions were extracted verbatim from +//! `helm-operator-control/src/pipeline.rs` as part of RFL-154 T5b. +//! They live here because `seed-gen` is the seed-IO layer: it both *writes* +//! the Parquet files (via `main.rs`) and now *reads* them back for pipeline +//! seeding. +//! +//! # Reference implementation +//! +//! [`ParquetSeedSource`] is the reference implementation of +//! [`helm_module_contracts::showcase_pipeline::ShowcaseSeedSource`]. +//! Mounting apps can use it directly or write their own implementation. +//! +//! # Known schema mismatch +//! +//! The Parquet files generated by `seed-gen/src/main.rs` use column names +//! `event_type` and `page_section` (singular). The loaders below read +//! `event_types` and `page_sections` (plural) — an existing inconsistency +//! inherited from the original `application-server` pipeline. A mounting app +//! implementing `ShowcaseSeedSource` should reconcile this against the actual +//! parquet schema before shipping. + +use std::path::{Path, PathBuf}; + +use async_trait::async_trait; +use helm_module_contracts::showcase_pipeline::{ + SeedSourceError, ShowcasePipelineInput, ShowcaseSeedSource, +}; +use polars::prelude::*; + +// ── Internal helpers ─────────────────────────────────────────────────────────── + +/// Load behavioral events from seed Parquet for a specific prospect. +/// Returns the events as a JSON string suitable for score-inbound-fit input. +/// +/// Errors are mapped to [`SeedSourceError`] variants: +/// - File not found or unreadable → `StorageError` +/// - Column missing or parse failure → `ParseError` +fn load_prospect_events( + seed_dir: &Path, + prospect_id: &str, +) -> Result { + let path = seed_dir.join("behavior_events.parquet"); + + // Pre-check existence so callers receive StorageError for missing files + // rather than a ParseError from polars' lazy collect pipeline. + if !path.exists() { + return Err(SeedSourceError::StorageError { + detail: format!("seed file not found: {}", path.display()), + }); + } + + let parquet_path = path.to_str().ok_or_else(|| SeedSourceError::StorageError { + detail: format!("invalid parquet path: {}", path.display()), + })?; + let df = LazyFrame::scan_parquet(PlPath::new(parquet_path), Default::default()) + .map_err(|e| SeedSourceError::StorageError { + detail: format!("failed to open parquet: {e}"), + })? + .filter(col("prospect_id").eq(lit(prospect_id))) + .collect() + .map_err(|e| SeedSourceError::ParseError { + detail: format!("failed to read events: {e}"), + })?; + + let mut events = Vec::new(); + let rows = df.height(); + for i in 0..rows { + let visitor_id = df + .column("prospect_id") + .map_err(|e| SeedSourceError::ParseError { detail: e.to_string() })? + .str() + .map_err(|e| SeedSourceError::ParseError { detail: e.to_string() })? + .get(i) + .unwrap_or(""); + let timestamp = df + .column("timestamp") + .map_err(|e| SeedSourceError::ParseError { detail: e.to_string() })? + .i64() + .map_err(|e| SeedSourceError::ParseError { detail: e.to_string() })? + .get(i) + .unwrap_or(0); + let event_type = df + .column("event_types") + .map_err(|e| SeedSourceError::ParseError { detail: e.to_string() })? + .str() + .map_err(|e| SeedSourceError::ParseError { detail: e.to_string() })? + .get(i) + .unwrap_or(""); + let page = df + .column("page_sections") + .map_err(|e| SeedSourceError::ParseError { detail: e.to_string() })? + .str() + .map_err(|e| SeedSourceError::ParseError { detail: e.to_string() })? + .get(i) + .unwrap_or(""); + + events.push(serde_json::json!({ + "visitor_id": visitor_id, + "timestamp": timestamp, + "event_type": event_type, + "page": page, + })); + } + + serde_json::to_string(&events).map_err(|e| SeedSourceError::ParseError { + detail: format!("json serialization failed: {e}"), + }) +} + +/// Load account context from seed Parquet for a specific prospect. +/// +/// Errors are mapped to [`SeedSourceError`] variants: +/// - File not found or unreadable → `StorageError` +/// - Column missing or parse failure → `ParseError` +/// - Prospect absent in the dataset → `ProspectNotFound` +fn load_prospect_context( + seed_dir: &Path, + prospect_id: &str, +) -> Result { + let path = seed_dir.join("account_context.parquet"); + + // Pre-check existence so callers receive StorageError for missing files. + if !path.exists() { + return Err(SeedSourceError::StorageError { + detail: format!("seed file not found: {}", path.display()), + }); + } + + let parquet_path = path.to_str().ok_or_else(|| SeedSourceError::StorageError { + detail: format!("invalid parquet path: {}", path.display()), + })?; + let df = LazyFrame::scan_parquet(PlPath::new(parquet_path), Default::default()) + .map_err(|e| SeedSourceError::StorageError { + detail: format!("failed to open account_context parquet: {e}"), + })? + .filter(col("prospect_id").eq(lit(prospect_id))) + .collect() + .map_err(|e| SeedSourceError::ParseError { + detail: format!("failed to read account_context: {e}"), + })?; + + if df.height() == 0 { + return Err(SeedSourceError::ProspectNotFound { + prospect_id: prospect_id.to_string(), + }); + } + + let name = df + .column("company_name") + .ok() + .and_then(|c| c.str().ok()) + .and_then(|s| s.get(0)) + .unwrap_or(prospect_id) + .to_string(); + + let industry = df + .column("industry") + .ok() + .and_then(|c| c.str().ok()) + .and_then(|s| s.get(0)) + .map(ToString::to_string); + + let events_json = load_prospect_events(seed_dir, prospect_id)?; + + Ok(ShowcasePipelineInput { + prospect_name: name, + visitor_id: prospect_id.to_string(), + usage_events_json: events_json, + inbound_summary: format!("Inbound inquiry from {prospect_id} via website demo request"), + meeting_count: 1, + window_start: "2026-04-21".into(), + window_end: "2026-04-25".into(), + calendar_slots_json: None, + industry, + website: None, + contact_name: None, + contact_title: None, + contact_email: None, + }) +} + +// ── Public struct ────────────────────────────────────────────────────────────── + +/// Parquet-based reference implementation of [`ShowcaseSeedSource`]. +/// +/// Reads `behavior_events.parquet` and `account_context.parquet` from +/// `data_dir`. This is the canonical seed-IO path: seed-gen writes the files +/// and this struct reads them back. +/// +/// # Error mapping +/// +/// | Failure mode | Variant | +/// |---------------------------|-----------------------------------| +/// | File not found / I/O | [`SeedSourceError::StorageError`] | +/// | Bad column / parse | [`SeedSourceError::ParseError`] | +/// | Prospect absent in data | [`SeedSourceError::ProspectNotFound`] | +/// +/// # Async contract +/// +/// The polars IO is blocking and internally may spin its own thread pool. +/// `load` dispatches to [`tokio::task::spawn_blocking`] so it is safe to +/// call from within an async Tokio context. +pub struct ParquetSeedSource { + pub data_dir: PathBuf, +} + +impl ParquetSeedSource { + pub fn new(data_dir: impl Into) -> Self { + Self { + data_dir: data_dir.into(), + } + } +} + +#[async_trait] +impl ShowcaseSeedSource for ParquetSeedSource { + async fn load(&self, prospect_id: &str) -> Result { + let data_dir = self.data_dir.clone(); + let prospect_id = prospect_id.to_string(); + tokio::task::spawn_blocking(move || load_prospect_context(&data_dir, &prospect_id)) + .await + .map_err(|e| SeedSourceError::StorageError { + detail: format!("blocking task panicked: {e}"), + })? + } +} + +// ── Tests ────────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + /// A missing parquet file must surface as `StorageError`, not a panic or + /// a stringly-typed error. + #[tokio::test] + async fn missing_file_yields_storage_error() { + let src = ParquetSeedSource::new("/nonexistent/path/that/cannot/exist"); + let err = src.load("prospect-001").await.unwrap_err(); + assert!( + matches!(err, SeedSourceError::StorageError { .. }), + "expected StorageError, got: {err}" + ); + } + + /// A prospect id not present in a real (but empty-filtered) dataset must + /// surface as `ProspectNotFound`. + /// + /// We build minimal in-memory Parquet fixtures on a background thread + /// (to avoid nesting a Tokio runtime inside the test runtime) then query + /// for a prospect that isn't in the data. + #[tokio::test] + async fn unknown_prospect_yields_prospect_not_found() { + // Build fixtures on a blocking thread so polars doesn't try to start + // its own runtime inside the test's Tokio executor. + let tmp_path = tokio::task::spawn_blocking(|| { + let tmp = tempfile::tempdir().expect("tmp dir"); + + // account_context — one row for "p-known" + let mut df = df!( + "prospect_id" => ["p-known"], + "company_name" => ["ACME Corp"], + "industry" => ["SaaS"], + ) + .expect("df"); + let file = + std::fs::File::create(tmp.path().join("account_context.parquet")).expect("create"); + ParquetWriter::new(file).finish(&mut df).expect("write"); + + // behavior_events — empty schema so the events loader doesn't + // fail with StorageError before we reach the prospect check + let mut events_df = df!( + "prospect_id" => Vec::<&str>::new(), + "timestamp" => Vec::::new(), + "event_types" => Vec::<&str>::new(), + "page_sections" => Vec::<&str>::new(), + ) + .expect("events df"); + let ef = + std::fs::File::create(tmp.path().join("behavior_events.parquet")).expect("create"); + ParquetWriter::new(ef).finish(&mut events_df).expect("write"); + + // Keep `tmp` alive by moving it out; caller holds the PathBuf. + tmp.keep() + }) + .await + .expect("fixture thread"); + + let src = ParquetSeedSource::new(&tmp_path); + let err = src.load("p-unknown").await.unwrap_err(); + assert!( + matches!( + err, + SeedSourceError::ProspectNotFound { ref prospect_id } if prospect_id == "p-unknown" + ), + "expected ProspectNotFound for p-unknown, got: {err}" + ); + + // Clean up the temp dir (was kept alive via into_path()). + let _ = std::fs::remove_dir_all(&tmp_path); + } +} diff --git a/crates/workbench-backend/Cargo.toml b/crates/workbench-backend/Cargo.toml index 65c0bdd..e772020 100644 --- a/crates/workbench-backend/Cargo.toml +++ b/crates/workbench-backend/Cargo.toml @@ -15,7 +15,7 @@ capability-core = { version = "0.2.1", path = "../capability-core" } capability-registry = { version = "0.2.1", path = "../capability-registry" } organism-domain.workspace = true organism-runtime.workspace = true -prio-agent-ops = { version = "0.2.1", path = "../prio-agent-ops" } +helm-module-contracts = { version = "0.3.0", path = "../../contracts/crates/helm-module-contracts" } truth-catalog = { version = "0.2.1", path = "../truth-catalog" } serde.workspace = true serde_json.workspace = true diff --git a/crates/workbench-backend/src/lib.rs b/crates/workbench-backend/src/lib.rs index 019e3b8..530676e 100644 --- a/crates/workbench-backend/src/lib.rs +++ b/crates/workbench-backend/src/lib.rs @@ -16,6 +16,7 @@ use application_storage::{ }; use capability_registry::all_modules; use chrono::Utc; +use helm_module_contracts::operator_preview::OperatorControlPreview; use organism_domain::packs; use organism_runtime::Registry; use thiserror::Error; @@ -30,16 +31,14 @@ use uuid::Uuid; pub use views::{ AccountWorkspaceSummary, ApprovalFilter, ApprovalListItem, AxiomIntentView, CatalogItemListItem, ConvergeTruthResolutionView, CriteriaOutcomeItem, CriterionStatus, - EntitlementListItem, ExecutionState, FeatureToggles, FormationSelectionView, - OperatorControlPreview, OperatorControlPreviewBacking, OperatorDashboard, - OperatorReceiptFamilyView, OpportunityListItem, OrganismCapabilityRequirementView, - OrganismPackRequirementView, OrganismTruthResolutionView, OrganizationListItem, - OrganizationWorkspaceItem, PersonWorkspaceItem, RecordReferenceItem, SubscriptionListItem, - SystemProfile, TimelineEventItem, TruthDetailItem, TruthExecutionProjection, - TruthExecutionResult, TruthExecutionSession, TruthListItem, TruthModuleTouchItem, - TruthReadinessConfirmationView, TruthReadinessGapView, TruthReadinessView, WorkbenchAppKind, - WorkbenchAppManifest, WorkbenchAppStatus, WorkflowCaseFilter, WorkflowCaseListItem, - operator_receipt_families, + EntitlementListItem, ExecutionState, FeatureToggles, FormationSelectionView, OperatorDashboard, + OpportunityListItem, OrganismCapabilityRequirementView, OrganismPackRequirementView, + OrganismTruthResolutionView, OrganizationListItem, OrganizationWorkspaceItem, + PersonWorkspaceItem, RecordReferenceItem, SubscriptionListItem, SystemProfile, + TimelineEventItem, TruthDetailItem, TruthExecutionProjection, TruthExecutionResult, + TruthExecutionSession, TruthListItem, TruthModuleTouchItem, TruthReadinessConfirmationView, + TruthReadinessGapView, TruthReadinessView, WorkbenchAppKind, WorkbenchAppManifest, + WorkbenchAppStatus, WorkflowCaseFilter, WorkflowCaseListItem, }; const QUALIFY_INBOUND_LEAD: &str = "qualify-inbound-lead"; diff --git a/crates/workbench-backend/src/views.rs b/crates/workbench-backend/src/views.rs index 1b6bf2a..f9cbcf6 100644 --- a/crates/workbench-backend/src/views.rs +++ b/crates/workbench-backend/src/views.rs @@ -5,9 +5,6 @@ use application_kernel::{ use application_storage::{AppConfig, RuntimeModuleConfig}; use capability_core::CapabilityModule; use chrono::{DateTime, Utc}; -use prio_agent_ops::{ - JobReadinessPacket, OperatorLedgerEntry, OperatorLedgerRecordKind, ReceiptFamily, -}; use serde::{Deserialize, Serialize}; use truth_catalog::TruthKind; @@ -19,99 +16,6 @@ pub struct OperatorDashboard { pub recent_timeline: Vec, } -#[derive(Debug, Clone, Serialize)] -pub struct OperatorControlPreview { - pub packet: JobReadinessPacket, - pub ledger_entries: Vec, - pub receipt_families: Vec, - pub backing: OperatorControlPreviewBacking, - pub backing_label: &'static str, -} - -impl OperatorControlPreview { - pub fn live_app_feed( - packet: JobReadinessPacket, - ledger_entries: Vec, - ) -> Self { - Self { - packet, - ledger_entries, - receipt_families: operator_receipt_families(), - backing: OperatorControlPreviewBacking::LiveAppFeed, - backing_label: OperatorControlPreviewBacking::LiveAppFeed.label(), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] -#[serde(rename_all = "kebab-case")] -pub enum OperatorControlPreviewBacking { - LiveAppFeed, -} - -impl OperatorControlPreviewBacking { - #[must_use] - pub const fn label(self) -> &'static str { - match self { - Self::LiveAppFeed => "live", - } - } -} - -#[derive(Debug, Clone, Serialize)] -pub struct OperatorReceiptFamilyView { - pub family: ReceiptFamily, - pub purpose: String, - pub record_kinds: Vec, -} - -pub fn operator_receipt_families() -> Vec { - vec![ - OperatorReceiptFamilyView { - family: ReceiptFamily::Common, - purpose: "shared adapter and readiness receipts used by every app probe".to_string(), - record_kinds: vec![ - OperatorLedgerRecordKind::ObservationAdapterReceipt, - OperatorLedgerRecordKind::JobReadinessPacket, - ], - }, - OperatorReceiptFamilyView { - family: ReceiptFamily::LongRunningJob, - purpose: "approval, decision, plan, execution, action, and outcome milestones" - .to_string(), - record_kinds: vec![ - OperatorLedgerRecordKind::OperatorDecisionReceipt, - OperatorLedgerRecordKind::ApprovalReceipt, - OperatorLedgerRecordKind::PlanReceipt, - OperatorLedgerRecordKind::ExecutionReceipt, - OperatorLedgerRecordKind::ActionReceipt, - OperatorLedgerRecordKind::OutcomeReceipt, - ], - }, - OperatorReceiptFamilyView { - family: ReceiptFamily::TemporalEvidence, - purpose: "corpus snapshots, evidence windows, preserved disagreements, analyst review, and cited narrative claims".to_string(), - record_kinds: vec![ - OperatorLedgerRecordKind::CorpusSnapshotReceipt, - OperatorLedgerRecordKind::EvidenceWindowReceipt, - OperatorLedgerRecordKind::DisagreementReceipt, - OperatorLedgerRecordKind::AnalystReviewReceipt, - OperatorLedgerRecordKind::NarrativeClaimReceipt, - ], - }, - OperatorReceiptFamilyView { - family: ReceiptFamily::ContentPublication, - purpose: "canonical story, claim review, editorial approval, and publication boundary receipts".to_string(), - record_kinds: vec![ - OperatorLedgerRecordKind::CanonicalStoryReceipt, - OperatorLedgerRecordKind::ClaimReviewReceipt, - OperatorLedgerRecordKind::EditorialApprovalReceipt, - OperatorLedgerRecordKind::PublicationBoundaryReceipt, - ], - }, - ] -} - #[derive(Debug, Clone, Serialize)] pub struct SystemProfile { pub config: AppConfig, diff --git a/kb/Architecture/Foundation Contracts.md b/kb/Architecture/Foundation Contracts.md index ccc3e6f..b05a5b4 100644 --- a/kb/Architecture/Foundation Contracts.md +++ b/kb/Architecture/Foundation Contracts.md @@ -12,6 +12,7 @@ redefine them. | Governed execution out-of-process | `converge-client` | `converge-protocol` for typed wire access | runtime internals | | Durable app subject identity | `converge-pack::SubjectRef` through `converge-kernel` / `converge-model` | app-owned scheme/kind vocabulary | raw URI strings in Converge-facing metadata | | App server execution container | Runtime Runway app execution container | Helm mounted as operator-control/job module | app-owned HTTP/gRPC/GraphQL servers | +| Operator vocabulary (packets, ledger entries, receipt families, hashes, errors) | `helm-module-contracts::operator_receipts` and `helm-module-contracts::operator_preview` | `helm-operator-control` for the live HTTP module | `prio-agent-ops` (now manifest-only), `workbench-backend` for operator types, or any crate that is not `helm-module-contracts` | | Commercial authority | Commerce-Rails contracts | Helm read-only operator projections over Commerce-Rails receipts | subscriptions, plans, entitlements, payments, provider refs, or plan-to-app grants in Helm | | Capability contracts for chat and routing | `converge-provider` | Manifold adapters for concrete provider implementations | direct vendor HTTP spread across product code | | Reusable reasoning and planning | `organism-pack`, `organism-runtime` | `organism-domain`, `organism-intelligence`, `organism-notes` | Organism phase crates | diff --git a/kb/Architecture/Module Map.md b/kb/Architecture/Module Map.md index e9ecfb6..b8a8bc1 100644 --- a/kb/Architecture/Module Map.md +++ b/kb/Architecture/Module Map.md @@ -41,7 +41,7 @@ Truths are not modules. They sit above these capabilities and compose them into - `prio-audit`: provenance, decision trace, evidence links, replayable history - `prio-intents`: jobs, intent context, success criteria, outcomes, agent runs - `prio-memory`: semantic memory, embeddings, entity graph, retrieval context -- `prio-agent-ops`: agent runs, operator-control readiness packets, receipt ledger entries, validation contracts, execution traceability +- `prio-agent-ops`: capability manifest only (agent runs, capability advertisement to capability-registry). Operator-control vocabulary (packets, ledger entries, receipt families, errors) moved to `helm-module-contracts` in RFL-154. truth-catalog→prism-analytics→polars transitive chain is Plan-B scope; not imported by this crate after T3. ## Suites @@ -93,11 +93,22 @@ commercial contracts for marquee apps. - `memory` - `agent-ops` -`agent-ops` still contains the legacy implementation for the first Helm -operator-control slice. The public app-facing contract is -`helm-operator-control`: `JobReadinessPacket`, `OperatorLedgerEntry`, receipt -families, and the non-authority invariant for readiness views are imported -through that Helm-named crate. See [[Operator Control Common Module]]. +After RFL-154, `prio-agent-ops` is manifest-only (capability advertisement, +not operator-control vocabulary). The operator vocabulary now lives in +`helm-module-contracts` (`operator_receipts` and `operator_preview` modules). +`JobReadinessPacket`, `OperatorLedgerEntry`, receipt families, and the +non-authority invariant for readiness views are imported from +`helm-module-contracts`; the live HTTP surface is in `helm-operator-control`. +See [[Operator Control Common Module]]. + +### Seam Contracts (Shared Across Repo Boundaries) + +- `helm-module-contracts` (`contracts/crates/helm-module-contracts`) — HelmModule + trait + ModuleState, operator vocabulary (`operator_receipts`, + `operator_preview`), showcase pipeline injection contract. Published to + crates.io so Runtime Runway can consume it without checking out the full helms + workspace. Polars and analytics transitive chains (truth-catalog→prism-analytics→polars) + are Plan-B scope and deliberately absent from this crate's dep tree. ## API Naming Convention diff --git a/kb/Architecture/Operator Control Common Module.md b/kb/Architecture/Operator Control Common Module.md index 6dd7be2..d7166cd 100644 --- a/kb/Architecture/Operator Control Common Module.md +++ b/kb/Architecture/Operator Control Common Module.md @@ -5,11 +5,24 @@ app transcripts. This module is not a new truth engine and not a promotion authority. It is the common control-plane surface that lets Helm show what is ready, what is missing, and which receipt chain explains the current state. -The public app-facing import surface now lives in `crates/helm-operator-control`. -That crate re-exports the packet, ledger, receipt-family, hash, and ledger-entry -helpers that downstream apps need. `crates/prio-agent-ops` is still the legacy -implementation crate for this slice and should not be treated as the marquee-app -contract. +The operator vocabulary lives in `contracts/crates/helm-module-contracts` +(modules `operator_receipts` and `operator_preview`). These are the canonical +import paths for all downstream consumers: + +- `helm_module_contracts::operator_receipts` — `JobReadinessPacket`, + `OperatorLedgerEntry`, receipt families, error types, and the + `job_readiness_packet_payload_hash` / `job_readiness_packet_ledger_entry` + helpers. +- `helm_module_contracts::operator_preview` — `OperatorControlPreview`, + `OperatorControlPreviewBacking`, `OperatorReceiptFamilyView`, and + `operator_receipt_families()`. + +`crates/helm-operator-control` owns the HTTP module (routes, live feed +injection, `OperatorControlModule`, `OperatorControlState`). It depends on +`helm-module-contracts` only — not on `prio-agent-ops` or `workbench-backend`. +Downstream code that previously imported operator-control contracts from +`prio_agent_ops` or `workbench_backend` must be updated to import from +`helm_module_contracts` (RFL-154 seam cut). This module should be hostable inside the Runtime Runway app execution container. The current Helm `application-server` remains a useful reference host, but it should @@ -103,11 +116,12 @@ returns an empty list. The singular view over the first live packet and returns an operator-control error when no live packet is supplied. Helm no longer synthesizes a static app portfolio. -Import rule: marquee apps should depend on `helm-operator-control` for -`JobReadinessPacket`, `OperatorLedgerEntry`, `ReceiptFamily`, and the -`job_readiness_packet_*` helpers. New app code importing `prio-agent-ops` -directly for operator-control contracts is transitional boundary debt and should -be rejected during review. +Import rule: marquee apps should depend on `helm-module-contracts` for the +operator vocabulary (`JobReadinessPacket`, `OperatorLedgerEntry`, `ReceiptFamily`, +and the `job_readiness_packet_*` helpers). `helm-operator-control` is the right +dependency only when you are mounting or wiring the live HTTP module. New app +code importing `prio-agent-ops` or `workbench-backend` for operator-control +contracts is boundary debt and must be rejected during review. App examples and portfolios belong in app repos, showcases, or arena tests. Helm's invariant is the contract shape: packet plus ledger entries plus receipt