diff --git a/Cargo.lock b/Cargo.lock index c5dcb508..d125acf5 100755 --- a/Cargo.lock +++ b/Cargo.lock @@ -5256,10 +5256,16 @@ dependencies = [ name = "ledgrrr-revision-io" version = "1.10.0" dependencies = [ + "bigdecimal", + "chrono", "oxigraph", + "oxilangtag", + "oxiri", "rusqlite", + "schemars 0.8.22", "serde", "serde_json", + "sha2", "tempfile", "thiserror 2.0.20", "ufo-types", @@ -11157,20 +11163,16 @@ dependencies = [ [[package]] name = "ufo-types" -version = "0.16.0" -source = "git+https://github.com/promptexecution/ufo-types?rev=5f8d0186947da376cfa17ad8b59cd280f2f85338#5f8d0186947da376cfa17ad8b59cd280f2f85338" +version = "0.15.0" +source = "git+https://github.com/promptexecution/ufo-types?rev=1846e6f#1846e6f20c68e8eb614556386a56cd3077446cdb" dependencies = [ "anyhow", - "bigdecimal", "chrono", - "oxilangtag", - "oxiri", "rpassword", "schemars 0.8.22", "semver", "serde", "serde_json", - "sha2", "shellexpand", "sysml-v2-parser", "thiserror 2.0.20", diff --git a/Cargo.toml b/Cargo.toml index feecab0a..2afd499b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ edition = "2021" license = "MIT" [workspace.dependencies] -ufo-types = { git = "https://github.com/promptexecution/ufo-types", rev = "5f8d0186947da376cfa17ad8b59cd280f2f85338" } +ufo-types = { git = "https://github.com/promptexecution/ufo-types", rev = "1846e6f" } rusqlite = { version = "=0.33.0", features = ["bundled", "hooks"] } rust_xlsxwriter = "0.94.0" serde = { version = "1", features = ["derive"] } diff --git a/book/src/revision-io.md b/book/src/revision-io.md index 1153cb6b..3aef5d25 100644 --- a/book/src/revision-io.md +++ b/book/src/revision-io.md @@ -23,7 +23,7 @@ requirements, source symbols, typed values, relations and binary attachments. ```rust use ledgrrr_revision_io::*; use std::collections::BTreeMap; -use ufo_types::revision::{PortableModel, RevisionContext}; +use ledgrrr_revision_io::revision::{PortableModel, RevisionContext}; let dir = tempfile::tempdir()?; let path = dir.path().join("revisions.sqlite"); diff --git a/crates/ledgrrr-revision-io/Cargo.toml b/crates/ledgrrr-revision-io/Cargo.toml index 1020568f..413d7f92 100644 --- a/crates/ledgrrr-revision-io/Cargo.toml +++ b/crates/ledgrrr-revision-io/Cargo.toml @@ -6,12 +6,18 @@ license.workspace = true description = "Provider-neutral durable revision artifacts, intents, receipts and recovery" [dependencies] -ufo-types = { workspace = true, features = ["revision"] } +ufo-types = { workspace = true } rusqlite.workspace = true serde.workspace = true serde_json.workspace = true thiserror.workspace = true +chrono.workspace = true oxigraph = { version = "=0.5.11", default-features = false } +schemars = { version = "0.8", features = ["derive"] } +sha2 = "0.10" +bigdecimal = "0.4" +oxiri = "0.2.11" +oxilangtag = "0.1.6" [dev-dependencies] tempfile.workspace = true diff --git a/crates/ledgrrr-revision-io/src/lib.rs b/crates/ledgrrr-revision-io/src/lib.rs index f59024b2..8ce32e94 100644 --- a/crates/ledgrrr-revision-io/src/lib.rs +++ b/crates/ledgrrr-revision-io/src/lib.rs @@ -9,7 +9,9 @@ #![forbid(unsafe_code)] use serde::{Deserialize, Serialize}; -pub use ufo_types::revision::{ + +pub mod revision; +pub use crate::revision::{ ActorId, ArtifactDigest, BranchId, ExpectedHead, IndexCheckpoint, OperationId, OperationReceipt, PortableBundle, ProjectId, RevisionId, SyncStatus, }; @@ -133,7 +135,7 @@ pub enum Error { #[error(transparent)] Sql(#[from] rusqlite::Error), #[error(transparent)] - Revision(#[from] ufo_types::revision::RevisionError), + Revision(#[from] crate::revision::RevisionError), #[error(transparent)] Json(#[from] serde_json::Error), } diff --git a/crates/ledgrrr-revision-io/src/revision.rs b/crates/ledgrrr-revision-io/src/revision.rs new file mode 100644 index 00000000..bc9e0207 --- /dev/null +++ b/crates/ledgrrr-revision-io/src/revision.rs @@ -0,0 +1,905 @@ +//! Portable revision data and pure, deterministic hydration/dehydration. +//! +//! Inlined from ufo-types @ rev 5f8d018 (the last revision that included this +//! module). Storage, clocks, model-server calls and authorization belong to +//! adapters. Original source bytes are retained independently of the typed +//! semantic model. The wire dialect is versioned independently of the crate's +//! release version. + +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +use ufo_types::{ElementId, ElementKind, Relation, SourceAnchor}; + +mod merge; +mod query; +pub use merge::{ + ChangeSet, Conflict, ConflictKind, ConflictSubject, MergeOutcome, RecordChange, merge_models, +}; +pub use query::*; + +/// The first portable contract. Unknown versions require an explicit migration. +pub const DIALECT: &str = "urn:b00t:dialect:ufo-types:revision:1.0.0"; + +/// Malformed or incomplete portable data never becomes a successful hydration. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RevisionError { + #[error("invalid {kind}: {value:?}")] + InvalidIdentity { kind: String, value: String }, + #[error("invalid model at {path}: {reason}")] + InvalidModel { path: String, reason: String }, + #[error("unsupported portable dialect {0}")] + UnsupportedDialect(String), + #[error("missing artifact {0}")] + MissingArtifact(String), + #[error("digest mismatch for {0}")] + DigestMismatch(String), + #[error("adapter cannot preserve required fidelity: {0}")] + FidelityLoss(String), + #[error("invalid portable JSON: {0}")] + Json(String), +} + +fn valid_identity(kind: &str, value: &str) -> Result<(), RevisionError> { + if value.trim().is_empty() || value.chars().any(char::is_control) { + return Err(RevisionError::InvalidIdentity { + kind: kind.into(), + value: value.into(), + }); + } + Ok(()) +} + +macro_rules! identity { + ($name:ident) => { + #[derive( + Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, + )] + #[serde(try_from = "String", into = "String")] + pub struct $name(String); + impl $name { + pub fn new(value: impl Into) -> Result { + Self::try_from(value.into()) + } + pub fn as_str(&self) -> &str { + &self.0 + } + } + impl TryFrom for $name { + type Error = RevisionError; + fn try_from(value: String) -> Result { + valid_identity(stringify!($name), &value)?; + Ok(Self(value)) + } + } + impl From<$name> for String { + fn from(value: $name) -> Self { + value.0 + } + } + }; +} + +identity!(ProjectId); +identity!(RevisionId); +identity!(BranchId); +identity!(OperationId); +identity!(ActorId); + +/// A canonical lowercase SHA-256 address; algorithms are explicit at the boundary. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[serde(try_from = "String", into = "String")] +pub struct ArtifactDigest(String); + +impl ArtifactDigest { + pub fn of(bytes: &[u8]) -> Self { + Self(format!("sha256:{:x}", Sha256::digest(bytes))) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for ArtifactDigest { + type Error = RevisionError; + fn try_from(value: String) -> Result { + let valid = value.strip_prefix("sha256:").is_some_and(|hex| { + hex.len() == 64 + && hex + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) + }); + if !valid { + return Err(RevisionError::InvalidIdentity { + kind: "ArtifactDigest".into(), + value, + }); + } + Ok(Self(value)) + } +} + +impl From for String { + fn from(value: ArtifactDigest) -> Self { + value.0 + } +} + +/// Portable relative artifact names, independent of the host's path rules. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)] +#[serde(try_from = "String", into = "String")] +pub struct ArtifactPath(String); + +impl ArtifactPath { + pub fn new(value: impl Into) -> Result { + Self::try_from(value.into()) + } + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl TryFrom for ArtifactPath { + type Error = RevisionError; + fn try_from(value: String) -> Result { + valid_identity("ArtifactPath", &value)?; + if value.contains(['\\', ':']) + || value + .split('/') + .any(|s| s.is_empty() || s == "." || s == "..") + { + return Err(RevisionError::InvalidIdentity { + kind: "ArtifactPath".into(), + value, + }); + } + Ok(Self(value)) + } +} + +impl From for String { + fn from(value: ArtifactPath) -> Self { + value.0 + } +} + +/// Absence of an expected head means an explicitly empty branch, not an unchecked write. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "kind", + content = "revision", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum ExpectedHead { + Empty, + Revision(RevisionId), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "kind", + content = "value", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum TypeRef { + Boolean, + Integer, + Natural, + Real, + String, + Timestamp, + Element(ElementId), + List(Box), + External { + library: String, + qualified_name: String, + }, +} + +/// Decimal and timestamp lexemes retain source fidelity instead of float rounding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde( + tag = "kind", + content = "value", + rename_all = "snake_case", + deny_unknown_fields +)] +pub enum TypedValue { + Boolean(bool), + Integer(i64), + Natural(u64), + Real(String), + String(String), + Timestamp(String), + Reference(ElementId), + List(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Multiplicity { + pub lower: u64, + pub upper: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TypedProperty { + pub type_ref: TypeRef, + pub multiplicity: Multiplicity, + /// None is an unassigned model attribute, distinct from an assigned empty collection. + pub values: Option>, +} + +/// Bind a preserved source coordinate to the exact bytes included in the bundle. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SourceEvidence { + pub artifact: ArtifactPath, + pub anchor: SourceAnchor, + pub source_revision: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ModelElement { + pub id: ElementId, + pub kind: ElementKind, + pub name: String, + pub properties: BTreeMap, + pub anchors: Vec, + /// Adapter-specific data survives in an explicit envelope, never implicit domain fields. + pub extensions: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FactAuthority { + Authored, + Inferred, + CompilerProven, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ModelRelation { + pub id: ElementId, + pub relation: Relation, + pub authority: FactAuthority, + pub anchors: Vec, + /// Inferred facts must identify the rule that produced them. + pub rule: Option, + pub extensions: BTreeMap, +} + +/// String map keys must exactly equal each value's existing ElementId. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PortableModel { + pub elements: BTreeMap, + pub relations: BTreeMap, +} + +fn invalid(path: impl Into, reason: impl Into) -> RevisionError { + RevisionError::InvalidModel { + path: path.into(), + reason: reason.into(), + } +} + +fn endpoints(relation: &Relation) -> Vec<&ElementId> { + relation.endpoints() +} + +impl PortableModel { + pub fn validate(&self) -> Result<(), RevisionError> { + let mut owners = BTreeMap::new(); + for (key, element) in &self.elements { + valid_identity("ElementId", key)?; + if key != element.id.as_str() || self.relations.contains_key(key) { + return Err(invalid(key, "duplicate or mismatched identity")); + } + for (name, property) in &element.properties { + let path = format!("{key}/properties/{name}"); + valid_identity("PropertyName", name)?; + if property + .multiplicity + .upper + .is_some_and(|upper| upper < property.multiplicity.lower) + { + return Err(invalid(&path, "upper multiplicity is below lower")); + } + self.validate_type(&property.type_ref, &path)?; + if let Some(values) = &property.values { + let count = values.len() as u64; + if count < property.multiplicity.lower + || property + .multiplicity + .upper + .is_some_and(|upper| count > upper) + { + return Err(invalid(&path, "assigned values violate multiplicity")); + } + for value in values { + self.validate_value(&property.type_ref, value, &path)?; + } + } + } + } + for (key, fact) in &self.relations { + valid_identity("RelationId", key)?; + if key != fact.id.as_str() { + return Err(invalid(key, "mismatched identity")); + } + if fact.authority == FactAuthority::Inferred + && fact.rule.as_ref().is_none_or(|r| r.trim().is_empty()) + { + return Err(invalid(key, "inferred fact has no rule")); + } + if fact.authority == FactAuthority::CompilerProven && fact.anchors.is_empty() { + return Err(invalid(key, "compiler-proven fact has no source evidence")); + } + for endpoint in endpoints(&fact.relation) { + if !self.elements.contains_key(endpoint.as_str()) { + return Err(invalid(key, format!("missing endpoint {endpoint}"))); + } + } + if let Relation::FeatureMembership { owner, member } = &fact.relation { + if owners.insert(member.as_str(), owner.as_str()).is_some() { + return Err(invalid(key, "member has multiple owning memberships")); + } + } + if let Relation::Satisfy { requirement, .. } | Relation::Verify { requirement, .. } = + &fact.relation + { + if !matches!( + self.elements[requirement.as_str()].kind, + ElementKind::RequirementDefinition | ElementKind::RequirementUsage + ) { + return Err(invalid(key, "requirement endpoint is not a requirement")); + } + } + if let Relation::Connection { ends } = &fact.relation { + if ends.len() < 2 { + return Err(invalid(key, "connection needs at least two ends")); + } + } + } + for member in owners.keys() { + let mut visited = std::collections::BTreeSet::new(); + let mut current = *member; + while let Some(owner) = owners.get(current) { + if !visited.insert(current) { + return Err(invalid(*member, "owning membership cycle")); + } + current = owner; + } + } + Ok(()) + } + + fn validate_type(&self, ty: &TypeRef, path: &str) -> Result<(), RevisionError> { + match ty { + TypeRef::Element(id) if !self.elements.contains_key(id.as_str()) => { + Err(invalid(path, "missing type reference")) + } + TypeRef::List(item) => self.validate_type(item, path), + TypeRef::External { + library, + qualified_name, + } => { + valid_identity("Library", library)?; + valid_identity("QualifiedName", qualified_name) + } + _ => Ok(()), + } + } + + fn validate_value( + &self, + ty: &TypeRef, + value: &TypedValue, + path: &str, + ) -> Result<(), RevisionError> { + let valid = match (ty, value) { + (TypeRef::Boolean, TypedValue::Boolean(_)) + | (TypeRef::Integer, TypedValue::Integer(_)) + | (TypeRef::Natural, TypedValue::Natural(_)) + | (TypeRef::String, TypedValue::String(_)) => true, + (TypeRef::Real, TypedValue::Real(v)) => v.parse::().is_ok(), + (TypeRef::Timestamp, TypedValue::Timestamp(v)) => { + chrono::DateTime::parse_from_rfc3339(v).is_ok() + } + (TypeRef::Element(_) | TypeRef::External { .. }, TypedValue::Reference(id)) => { + self.elements.contains_key(id.as_str()) + } + (TypeRef::List(item), TypedValue::List(values)) => { + for value in values { + self.validate_value(item, value, path)?; + } + true + } + _ => false, + }; + if !valid { + return Err(invalid( + path, + "value does not match declared type or has unresolved reference", + )); + } + Ok(()) + } + + pub fn semantic_digest(&self) -> Result { + self.validate()?; + Ok(ArtifactDigest::of(&canonical_bytes(self)?)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum FidelityKind { + Unsupported, + Dropped, + Unresolved, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct FidelityIssue { + pub path: String, + pub kind: FidelityKind, + pub reason: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct FidelityReport { + pub issues: Vec, +} + +impl FidelityReport { + pub fn require_lossless(&self) -> Result<(), RevisionError> { + if let Some(issue) = self.issues.first() { + return Err(RevisionError::FidelityLoss(format!( + "{}: {}", + issue.path, issue.reason + ))); + } + Ok(()) + } +} + +/// Capabilities must come from a verified adapter probe, not assumed server branding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AdapterCapabilities { + pub model_dialects: Vec, + pub element_kinds: Vec, + /// Canonical snake_case names of the existing Relation variants. + pub relation_kinds: Vec, + pub preserves_extensions: bool, + pub preserves_source_evidence: bool, + /// Missing capability claims fail closed, including older probe records. + #[serde(default)] + pub preserves_properties: bool, + #[serde(default)] + pub preserves_fact_authority: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RevisionContext { + pub project: ProjectId, + pub revision: RevisionId, + pub parent: Option, + pub model_dialect: String, + pub library_revisions: BTreeMap, + pub source_revisions: BTreeMap, + pub toolchain: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct BundleManifest { + pub dialect: String, + pub context: RevisionContext, + pub semantic_digest: ArtifactDigest, + pub artifacts: BTreeMap, + pub fidelity: FidelityReport, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct PortableBundle { + pub manifest: BundleManifest, + pub model: PortableModel, + pub blobs: BTreeMap>, +} + +impl PortableBundle { + pub fn dehydrate( + model: PortableModel, + context: RevisionContext, + artifacts: BTreeMap>, + ) -> Result { + let mut blobs = BTreeMap::new(); + let mut artifact_index = BTreeMap::new(); + for (path, bytes) in artifacts { + let digest = ArtifactDigest::of(&bytes); + artifact_index.insert(path, digest.clone()); + blobs.insert(digest, bytes); + } + let bundle = Self { + manifest: BundleManifest { + dialect: DIALECT.into(), + context, + semantic_digest: model.semantic_digest()?, + artifacts: artifact_index, + fidelity: FidelityReport::default(), + }, + model, + blobs, + }; + bundle.validate()?; + Ok(bundle) + } + + pub fn validate(&self) -> Result<(), RevisionError> { + if self.manifest.dialect != DIALECT { + return Err(RevisionError::UnsupportedDialect( + self.manifest.dialect.clone(), + )); + } + valid_identity("ModelDialect", &self.manifest.context.model_dialect)?; + if self.manifest.context.parent.as_ref() == Some(&self.manifest.context.revision) { + return Err(invalid( + "context/parent", + "revision cannot be its own parent", + )); + } + self.manifest.fidelity.require_lossless()?; + if self.model.semantic_digest()? != self.manifest.semantic_digest { + return Err(RevisionError::DigestMismatch("semantic model".into())); + } + for (path, digest) in &self.manifest.artifacts { + let bytes = self + .blobs + .get(digest) + .ok_or_else(|| RevisionError::MissingArtifact(path.as_str().into()))?; + if &ArtifactDigest::of(bytes) != digest { + return Err(RevisionError::DigestMismatch(path.as_str().into())); + } + } + for (digest, bytes) in &self.blobs { + if &ArtifactDigest::of(bytes) != digest { + return Err(RevisionError::DigestMismatch(digest.as_str().into())); + } + if !self.manifest.artifacts.values().any(|d| d == digest) { + return Err(invalid("blobs", "unreferenced artifact")); + } + } + for element in self.model.elements.values() { + for property in element.properties.values() { + check_library(&property.type_ref, &self.manifest.context.library_revisions)?; + } + } + for evidence in self + .model + .elements + .values() + .flat_map(|e| &e.anchors) + .chain(self.model.relations.values().flat_map(|r| &r.anchors)) + { + if !self.manifest.artifacts.contains_key(&evidence.artifact) { + return Err(RevisionError::MissingArtifact( + evidence.artifact.as_str().into(), + )); + } + match &evidence.anchor { + SourceAnchor::RustSpan { line, col, .. } if *line == 0 || *col == Some(0) => { + return Err(invalid("source evidence", "source spans are one-based")); + } + SourceAnchor::SysmlFile { line: Some(0), .. } => { + return Err(invalid("source evidence", "source spans are one-based")); + } + _ => {} + } + } + Ok(()) + } + + /// Validate before constructing a hydration candidate; no model-server side effects. + pub fn hydrate(&self) -> Result { + self.validate()?; + Ok(self.model.clone()) + } + + /// Describe precisely what an adapter cannot retain before allowing a model write. + pub fn adapter_fidelity(&self, capabilities: &AdapterCapabilities) -> FidelityReport { + let mut issues = Vec::new(); + if !capabilities + .model_dialects + .contains(&self.manifest.context.model_dialect) + { + issues.push(FidelityIssue { + path: "context/model_dialect".into(), + kind: FidelityKind::Unsupported, + reason: "adapter has not verified this model dialect".into(), + }); + } + for (id, element) in &self.model.elements { + let path = format!("elements/{id}"); + if !capabilities.preserves_properties && !element.properties.is_empty() { + issues.push(FidelityIssue { + path: format!("{path}/properties"), + kind: FidelityKind::Dropped, + reason: "adapter has not verified typed property preservation".into(), + }); + } + if !capabilities.element_kinds.contains(&element.kind) { + issues.push(FidelityIssue { + path: path.clone(), + kind: FidelityKind::Unsupported, + reason: format!("unsupported element kind {}", element.kind.kerml_name()), + }); + } + if !capabilities.preserves_extensions && !element.extensions.is_empty() { + issues.push(FidelityIssue { + path: format!("{path}/extensions"), + kind: FidelityKind::Dropped, + reason: "adapter discards extension envelope".into(), + }); + } + if !capabilities.preserves_source_evidence && !element.anchors.is_empty() { + issues.push(FidelityIssue { + path: format!("{path}/anchors"), + kind: FidelityKind::Dropped, + reason: "adapter discards source evidence".into(), + }); + } + } + for (id, fact) in &self.model.relations { + let path = format!("relations/{id}"); + if !capabilities.preserves_fact_authority { + issues.push(FidelityIssue { + path: format!("{path}/authority"), + kind: FidelityKind::Dropped, + reason: + "adapter has not verified authored/inferred/compiler authority preservation" + .into(), + }); + } + let encoded = serde_json::to_value(&fact.relation) + .expect("Relation has a finite string-only wire shape"); + let kind = encoded + .as_object() + .and_then(|o| o.keys().next()) + .expect("Relation is externally tagged"); + if !capabilities.relation_kinds.contains(kind) { + issues.push(FidelityIssue { + path: path.clone(), + kind: FidelityKind::Unsupported, + reason: format!("unsupported relation kind {kind}"), + }); + } + if !capabilities.preserves_extensions && !fact.extensions.is_empty() { + issues.push(FidelityIssue { + path: format!("{path}/extensions"), + kind: FidelityKind::Dropped, + reason: "adapter discards extension envelope".into(), + }); + } + if !capabilities.preserves_source_evidence && !fact.anchors.is_empty() { + issues.push(FidelityIssue { + path: format!("{path}/anchors"), + kind: FidelityKind::Dropped, + reason: "adapter discards source evidence".into(), + }); + } + } + FidelityReport { issues } + } + + pub fn to_bytes(&self) -> Result, RevisionError> { + self.validate()?; + canonical_bytes(self) + } + + /// Bind complete context and exact source/attachment bytes for intent/idempotency checks. + pub fn bundle_digest(&self) -> Result { + Ok(ArtifactDigest::of(&self.to_bytes()?)) + } + + pub fn from_bytes(bytes: &[u8]) -> Result { + let input: UniqueJson = + serde_json::from_slice(bytes).map_err(|e| RevisionError::Json(e.to_string()))?; + let bundle: Self = serde_json::from_value(input.0.clone()) + .map_err(|e| RevisionError::Json(e.to_string()))?; + let encoded = + serde_json::to_value(&bundle).map_err(|e| RevisionError::Json(e.to_string()))?; + require_preserved_keys(&input.0, &encoded, "bundle")?; + bundle.validate()?; + Ok(bundle) + } +} + +fn require_preserved_keys( + input: &serde_json::Value, + encoded: &serde_json::Value, + path: &str, +) -> Result<(), RevisionError> { + match (input, encoded) { + (serde_json::Value::Object(input), serde_json::Value::Object(encoded)) => { + for (key, value) in input { + let child = format!("{path}/{key}"); + let encoded = encoded + .get(key) + .ok_or_else(|| invalid(&child, "unknown field would be lost"))?; + require_preserved_keys(value, encoded, &child)?; + } + } + (serde_json::Value::Array(input), serde_json::Value::Array(encoded)) => { + for (index, value) in input.iter().enumerate() { + let encoded = encoded + .get(index) + .ok_or_else(|| invalid(path, "array value would be lost"))?; + require_preserved_keys(value, encoded, &format!("{path}/{index}"))?; + } + } + _ => {} + } + Ok(()) +} + +struct UniqueJson(serde_json::Value); +impl<'de> Deserialize<'de> for UniqueJson { + fn deserialize>(deserializer: D) -> Result { + struct Visitor; + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = UniqueJson; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("JSON without duplicate object keys") + } + fn visit_bool(self, v: bool) -> Result { + Ok(UniqueJson(v.into())) + } + fn visit_i64(self, v: i64) -> Result { + Ok(UniqueJson(v.into())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(UniqueJson(v.into())) + } + fn visit_f64(self, v: f64) -> Result { + let n = serde_json::Number::from_f64(v) + .ok_or_else(|| E::custom("non-finite JSON number"))?; + Ok(UniqueJson(serde_json::Value::Number(n))) + } + fn visit_str(self, v: &str) -> Result { + Ok(UniqueJson(v.into())) + } + fn visit_string(self, v: String) -> Result { + Ok(UniqueJson(v.into())) + } + fn visit_unit(self) -> Result { + Ok(UniqueJson(serde_json::Value::Null)) + } + fn visit_seq>( + self, + mut seq: A, + ) -> Result { + let mut values = Vec::new(); + while let Some(value) = seq.next_element::()? { + values.push(value.0); + } + Ok(UniqueJson(serde_json::Value::Array(values))) + } + fn visit_map>( + self, + mut map: A, + ) -> Result { + let mut values = serde_json::Map::new(); + while let Some(key) = map.next_key::()? { + if values.contains_key(&key) { + return Err(serde::de::Error::custom(format!( + "duplicate JSON key {key:?}" + ))); + } + let value = map.next_value::()?; + values.insert(key, value.0); + } + Ok(UniqueJson(serde_json::Value::Object(values))) + } + } + deserializer.deserialize_any(Visitor) + } +} + +fn check_library( + ty: &TypeRef, + libraries: &BTreeMap, +) -> Result<(), RevisionError> { + match ty { + TypeRef::External { library, .. } if !libraries.contains_key(library) => Err(invalid( + library, + "external type library has no pinned revision", + )), + TypeRef::List(item) => check_library(item, libraries), + _ => Ok(()), + } +} + +/// Sort every JSON object recursively, even when a consumer enables preserve_order. +pub fn canonical_bytes(value: &impl Serialize) -> Result, RevisionError> { + fn write(value: &serde_json::Value, out: &mut Vec) -> Result<(), serde_json::Error> { + match value { + serde_json::Value::Object(map) => { + out.push(b'{'); + let mut entries: Vec<_> = map.iter().collect(); + entries.sort_by(|a, b| a.0.cmp(b.0)); + for (index, (key, value)) in entries.into_iter().enumerate() { + if index != 0 { + out.push(b','); + } + serde_json::to_writer(&mut *out, key)?; + out.push(b':'); + write(value, out)?; + } + out.push(b'}'); + } + serde_json::Value::Array(values) => { + out.push(b'['); + for (index, value) in values.iter().enumerate() { + if index != 0 { + out.push(b','); + } + write(value, out)?; + } + out.push(b']'); + } + _ => serde_json::to_writer(out, value)?, + } + Ok(()) + } + let mut out = Vec::new(); + let value = serde_json::to_value(value).map_err(|e| RevisionError::Json(e.to_string()))?; + write(&value, &mut out).map_err(|e| RevisionError::Json(e.to_string()))?; + Ok(out) +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct IndexCheckpoint { + pub project: ProjectId, + pub revision: RevisionId, + pub dialect: String, + pub graph_digest: ArtifactDigest, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum SyncStatus { + Pending, + ModelCommitted { revision: RevisionId }, + Indexed { checkpoint: IndexCheckpoint }, + Conflict { paths: Vec }, + Ambiguous { reason: String }, + Unavailable { reason: String }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct OperationReceipt { + pub operation: OperationId, + pub actor: ActorId, + pub project: ProjectId, + pub branch: BranchId, + pub expected_head: ExpectedHead, + pub proposal_digest: ArtifactDigest, + pub status: SyncStatus, +} diff --git a/crates/ledgrrr-revision-io/src/revision/merge.rs b/crates/ledgrrr-revision-io/src/revision/merge.rs new file mode 100644 index 00000000..650fe78c --- /dev/null +++ b/crates/ledgrrr-revision-io/src/revision/merge.rs @@ -0,0 +1,388 @@ +//! Deterministic, typed three-way model merges. No remote mutation or implicit conflict resolution. + +use std::collections::{BTreeMap, BTreeSet}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ArtifactDigest, ModelElement, ModelRelation, PortableModel, RevisionError, invalid}; + +/// A record replacement preserves both the precondition and its proposed value. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct RecordChange { + pub before: Option, + pub after: Option, +} + +/// Pure model changes; an owner must separately bind actor, project, operation and expected head. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ChangeSet { + pub base_digest: ArtifactDigest, + pub proposed_digest: ArtifactDigest, + pub elements: BTreeMap>, + pub relations: BTreeMap>, +} + +impl ChangeSet { + /// Decode without duplicate keys or silently ignored nested fields. Applying to a validated + /// base remains mandatory; decoding alone cannot prove a proposed model is well formed. + pub fn from_bytes(bytes: &[u8]) -> Result { + let input: super::UniqueJson = + serde_json::from_slice(bytes).map_err(|e| RevisionError::Json(e.to_string()))?; + let changes: Self = serde_json::from_value(input.0.clone()) + .map_err(|e| RevisionError::Json(e.to_string()))?; + let encoded = + serde_json::to_value(&changes).map_err(|e| RevisionError::Json(e.to_string()))?; + super::require_preserved_keys(&input.0, &encoded, "changes")?; + Ok(changes) + } + + pub fn to_bytes(&self) -> Result, RevisionError> { + super::canonical_bytes(self) + } + + pub fn between(base: &PortableModel, proposed: &PortableModel) -> Result { + Ok(Self { + base_digest: base.semantic_digest()?, + proposed_digest: proposed.semantic_digest()?, + elements: differences(&base.elements, &proposed.elements), + relations: differences(&base.relations, &proposed.relations), + }) + } + + /// Apply only to the exact validated base. Reject edited preconditions and false digest claims. + pub fn apply(&self, base: &PortableModel) -> Result { + if base.semantic_digest()? != self.base_digest { + return Err(invalid("/base_digest", "change set base does not match")); + } + let mut model = base.clone(); + apply_records(&mut model.elements, &self.elements, "/elements")?; + apply_records(&mut model.relations, &self.relations, "/relations")?; + if model.semantic_digest()? != self.proposed_digest { + return Err(invalid( + "/proposed_digest", + "change set result digest does not match", + )); + } + Ok(model) + } +} + +fn keys(a: &BTreeMap, b: &BTreeMap) -> BTreeSet { + a.keys().chain(b.keys()).cloned().collect() +} + +fn differences( + base: &BTreeMap, + proposed: &BTreeMap, +) -> BTreeMap> { + keys(base, proposed) + .into_iter() + .filter_map(|key| { + let before = base.get(&key); + let after = proposed.get(&key); + (before != after).then(|| { + ( + key, + RecordChange { + before: before.cloned(), + after: after.cloned(), + }, + ) + }) + }) + .collect() +} + +fn apply_records( + target: &mut BTreeMap, + changes: &BTreeMap>, + prefix: &str, +) -> Result<(), RevisionError> { + for (key, change) in changes { + if target.get(key) != change.before.as_ref() || change.before == change.after { + return Err(invalid( + path(prefix, key), + "record precondition does not match or edit is redundant", + )); + } + match &change.after { + Some(value) => { + target.insert(key.clone(), value.clone()); + } + None => { + target.remove(key); + } + } + } + Ok(()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum ConflictKind { + SameField, + DeleteModify, + ConcurrentCreation, + SemanticViolation, +} + +/// Snapshots stay typed even when a conflict concerns one field inside a record. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum ConflictSubject { + Element { + base: Option, + ours: Option, + theirs: Option, + }, + Relation { + base: Option, + ours: Option, + theirs: Option, + }, + Semantic { + reason: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct Conflict { + /// RFC 6901 JSON pointer; conflicting values are in `subject`. + pub path: String, + pub kind: ConflictKind, + pub subject: ConflictSubject, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum MergeOutcome { + /// `changes` applies to theirs (the newly observed head), after revalidation. + Merged { + model: PortableModel, + changes: ChangeSet, + }, + /// No partial candidate escapes through this result. + Conflicted { conflicts: Vec }, +} + +fn path(prefix: &str, key: &str) -> String { + format!("{prefix}/{}", key.replace('~', "~0").replace('/', "~1")) +} + +struct MergeState { + conflicts: Vec, +} + +impl MergeState { + fn value( + &mut self, + base: &T, + ours: &T, + theirs: &T, + path: &str, + subject: &ConflictSubject, + ) -> T { + if ours == theirs || theirs == base { + return ours.clone(); + } + if ours == base { + return theirs.clone(); + } + self.conflicts.push(Conflict { + path: path.into(), + kind: ConflictKind::SameField, + subject: subject.clone(), + }); + ours.clone() // Private scratch only: conflicted results never return a model. + } + + fn fields( + &mut self, + base: &BTreeMap, + ours: &BTreeMap, + theirs: &BTreeMap, + prefix: &str, + subject: &ConflictSubject, + ) -> BTreeMap { + keys(base, ours) + .union(&keys(base, theirs)) + .map(|key| { + let value = self.value( + &base.get(key), + &ours.get(key), + &theirs.get(key), + &path(prefix, key), + subject, + ); + (key.clone(), value.cloned()) + }) + .filter_map(|(key, value)| value.map(|v| (key, v))) + .collect() + } +} + +fn merge_records( + base: &BTreeMap, + ours: &BTreeMap, + theirs: &BTreeMap, + prefix: &str, + state: &mut MergeState, + subject: impl Fn(Option, Option, Option) -> ConflictSubject, + merge: impl Fn(&T, &T, &T, &str, &ConflictSubject, &mut MergeState) -> T, +) -> BTreeMap { + let all: BTreeSet<_> = keys(base, ours) + .union(&keys(base, theirs)) + .cloned() + .collect(); + let mut result = BTreeMap::new(); + for key in all { + let (b, o, t) = (base.get(&key), ours.get(&key), theirs.get(&key)); + let pointer = path(prefix, &key); + let chosen = if o == t || t == b { + o.cloned() + } else if o == b { + t.cloned() + } else { + let evidence = subject(b.cloned(), o.cloned(), t.cloned()); + match (b, o, t) { + (Some(b), Some(o), Some(t)) => Some(merge(b, o, t, &pointer, &evidence, state)), + _ => { + state.conflicts.push(Conflict { + path: pointer, + kind: if b.is_none() { + ConflictKind::ConcurrentCreation + } else { + ConflictKind::DeleteModify + }, + subject: evidence, + }); + None + } + } + }; + if let Some(value) = chosen { + result.insert(key, value); + } + } + result +} + +/// Merge independent fields and independent property/extension keys. A property, source-evidence +/// vector or relation payload is atomic: concurrent edits to it require explicit resolution. +/// All three inputs must be valid. The entire merged candidate is validated again before return. +pub fn merge_models( + base: &PortableModel, + ours: &PortableModel, + theirs: &PortableModel, +) -> Result { + base.validate()?; + ours.validate()?; + theirs.validate()?; + let mut state = MergeState { + conflicts: Vec::new(), + }; + let elements = merge_records( + &base.elements, + &ours.elements, + &theirs.elements, + "/elements", + &mut state, + |base, ours, theirs| ConflictSubject::Element { base, ours, theirs }, + |b, o, t, p, evidence, s| ModelElement { + id: b.id.clone(), + kind: s.value(&b.kind, &o.kind, &t.kind, &path(p, "kind"), evidence), + name: s.value(&b.name, &o.name, &t.name, &path(p, "name"), evidence), + properties: s.fields( + &b.properties, + &o.properties, + &t.properties, + &path(p, "properties"), + evidence, + ), + anchors: s.value( + &b.anchors, + &o.anchors, + &t.anchors, + &path(p, "anchors"), + evidence, + ), + extensions: s.fields( + &b.extensions, + &o.extensions, + &t.extensions, + &path(p, "extensions"), + evidence, + ), + }, + ); + let relations = merge_records( + &base.relations, + &ours.relations, + &theirs.relations, + "/relations", + &mut state, + |base, ours, theirs| ConflictSubject::Relation { base, ours, theirs }, + |b, o, t, p, evidence, s| ModelRelation { + id: b.id.clone(), + relation: s.value( + &b.relation, + &o.relation, + &t.relation, + &path(p, "relation"), + evidence, + ), + authority: s.value( + &b.authority, + &o.authority, + &t.authority, + &path(p, "authority"), + evidence, + ), + anchors: s.value( + &b.anchors, + &o.anchors, + &t.anchors, + &path(p, "anchors"), + evidence, + ), + rule: s.value(&b.rule, &o.rule, &t.rule, &path(p, "rule"), evidence), + extensions: s.fields( + &b.extensions, + &o.extensions, + &t.extensions, + &path(p, "extensions"), + evidence, + ), + }, + ); + let model = PortableModel { + elements, + relations, + }; + if state.conflicts.is_empty() { + if let Err(error) = model.validate() { + state.conflicts.push(Conflict { + path: "/model".into(), + kind: ConflictKind::SemanticViolation, + subject: ConflictSubject::Semantic { + reason: error.to_string(), + }, + }); + } + } + if state.conflicts.is_empty() { + Ok(MergeOutcome::Merged { + changes: ChangeSet::between(theirs, &model)?, + model, + }) + } else { + state.conflicts.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(MergeOutcome::Conflicted { + conflicts: state.conflicts, + }) + } +} diff --git a/crates/ledgrrr-revision-io/src/revision/query.rs b/crates/ledgrrr-revision-io/src/revision/query.rs new file mode 100644 index 00000000..7fcc9f39 --- /dev/null +++ b/crates/ledgrrr-revision-io/src/revision/query.rs @@ -0,0 +1,377 @@ +//! Shared revision discovery wire data. No evaluator, clock, storage or grants. +//! +//! Structural validation cannot establish accepted ancestry, authorization or +//! graph completeness. The owner proves those before returning a response. + +use std::collections::{BTreeMap, BTreeSet}; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use super::{ArtifactDigest, BranchId, IndexCheckpoint, ProjectId, RevisionError, RevisionId}; + +/// Wire ceilings; services may impose smaller resource limits. +pub const MAX_REVISION_QUERY_BYTES: usize = 65_536; +pub const MAX_REVISION_QUERY_DEADLINE_MS: u64 = 60_000; +pub const MAX_REVISION_QUERY_ROWS: usize = 10_000; +pub const MAX_REVISION_QUERY_VARIABLES: usize = 256; +pub const MAX_REVISION_QUERY_RESULT_BYTES: usize = 4_194_304; +pub const MAX_REVISION_QUERY_MODEL_DIALECT_BYTES: usize = 1_024; + +fn invalid(path: &str, reason: &str) -> RevisionError { + RevisionError::InvalidModel { + path: format!("query/{path}"), + reason: reason.into(), + } +} + +fn checkpoint(value: &IndexCheckpoint, project: &ProjectId) -> Result<(), RevisionError> { + if &value.project != project { + return Err(invalid("checkpoint/project", "project mismatch")); + } + super::valid_identity("model dialect", &value.dialect)?; + if value.dialect.len() > MAX_REVISION_QUERY_MODEL_DIALECT_BYTES { + return Err(invalid( + "checkpoint/dialect", + "model dialect byte limit exceeded", + )); + } + Ok(()) +} + +// Deserialize through the same semantic check used for Rust-created values. +// The local Wire struct avoids a second independently maintained field list. +macro_rules! validated_struct { + ($(#[$meta:meta])* pub struct $name:ident { $(pub $field:ident: $ty:ty,)* }) => { + $(#[$meta])* + #[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] + #[serde(deny_unknown_fields)] + pub struct $name { $(pub $field: $ty,)* } + + impl<'de> Deserialize<'de> for $name { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { $($field: $ty,)* } + let input = super::UniqueJson::deserialize(deserializer)?; + let wire: Wire = serde_json::from_value(input.0) + .map_err(serde::de::Error::custom)?; + let value = Self { $($field: wire.$field,)* }; + value.validate().map_err(serde::de::Error::custom)?; + Ok(value) + } + } + }; +} + +/// Minimum means accepted ancestry, never UUID or lexical ordering. If +/// allow_older is true, the owner may return an explicitly stale fallback below +/// the minimum. Exact always names one revision and never falls back. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RevisionQuerySelector { + Exact { + revision: RevisionId, + }, + Minimum { + checkpoint: IndexCheckpoint, + allow_older: bool, + }, + Current { + allow_older: bool, + }, +} + +validated_struct! { + /// Relative deadline includes waiting for a checkpoint and evaluation. + /// Authentication and server resource ceilings never come from this value. + pub struct RevisionQueryRequest { + pub project: ProjectId, + pub branch: BranchId, + pub selector: RevisionQuerySelector, + pub query: String, + pub deadline_ms: u64, + } +} + +impl RevisionQueryRequest { + pub fn validate(&self) -> Result<(), RevisionError> { + if self.query.trim().is_empty() || self.query.len() > MAX_REVISION_QUERY_BYTES { + return Err(invalid("text", "empty query or query byte limit exceeded")); + } + if self.deadline_ms == 0 || self.deadline_ms > MAX_REVISION_QUERY_DEADLINE_MS { + return Err(invalid("deadline_ms", "deadline outside wire bounds")); + } + if let RevisionQuerySelector::Minimum { checkpoint: cp, .. } = &self.selector { + checkpoint(cp, &self.project)?; + } + Ok(()) + } +} + +validated_struct! { + /// A claimed immutable complete graph. The owner must independently verify + /// its accepted input and actual artifact bytes before publishing it. + pub struct RevisionGraphDescriptor { + pub checkpoint: IndexCheckpoint, + pub projection_schema: String, + pub accepted_candidate_digest: ArtifactDigest, + pub artifact_digest: ArtifactDigest, + pub quad_count: u64, + } +} + +impl RevisionGraphDescriptor { + pub fn validate(&self) -> Result<(), RevisionError> { + checkpoint(&self.checkpoint, &self.checkpoint.project)?; + super::valid_identity("projection_schema", &self.projection_schema)?; + if self.artifact_digest != self.checkpoint.graph_digest { + return Err(invalid( + "graph/artifact_digest", + "graph byte digest mismatch", + )); + } + // Even an empty model carries accepted-revision metadata triples. + if self.quad_count == 0 { + return Err(invalid("graph/quad_count", "revision metadata is required")); + } + Ok(()) + } +} + +/// Fresh means indexed_revision equals the independently observed branch model +/// revision sampled for this answer. An exact historical answer can be Stale +/// while fully satisfying its requested revision. Head changes remain possible. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryFreshness { + Fresh, + Stale, +} + +/// Literal lexemes are retained even when their datatype's value is ill-formed +/// (which is legal RDF). Blank-node labels are opaque and scoped to this result; +/// they must not be treated as persistent model identities or raw RDF syntax. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RdfTerm { + Iri { value: String }, + BlankNode { label: String }, + Literal { value: String, datatype: String }, + LanguageLiteral { value: String, language: String }, +} + +impl RdfTerm { + pub fn validate(&self) -> Result<(), RevisionError> { + let iri = |value: &str| { + oxiri::Iri::parse(value) + .map(|_| ()) + .map_err(|_| invalid("term/iri", "invalid absolute IRI")) + }; + match self { + Self::Iri { value } => iri(value), + Self::BlankNode { label } => super::valid_identity("blank node label", label), + Self::Literal { datatype, .. } => { + iri(datatype)?; + if datatype == "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString" { + return Err(invalid( + "term/language", + "langString requires a language tag", + )); + } + Ok(()) + } + Self::LanguageLiteral { language, .. } => { + oxilangtag::LanguageTag::parse(language.as_str()) + .map(|_| ()) + .map_err(|_| invalid("term/language", "invalid BCP47 language tag")) + } + } + } +} + +/// Unbound variables are omitted from a row, distinct from an empty literal. +/// Row order, duplicate solutions and projected variable order are preserved. +/// Column labels are opaque wire strings. The adapter must obtain actual +/// variable names and result kind from its parsed query/evaluator, and must not +/// interpolate these labels into SPARQL text. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RevisionQueryResults { + Select { + variables: Vec, + rows: Vec>, + }, + Ask { + value: bool, + }, +} + +impl RevisionQueryResults { + pub fn validate(&self) -> Result<(), RevisionError> { + if let Self::Select { variables, rows } = self { + if variables.len() > MAX_REVISION_QUERY_VARIABLES + || rows.len() > MAX_REVISION_QUERY_ROWS + { + return Err(invalid("results", "row or variable limit exceeded")); + } + let names: BTreeSet<_> = variables.iter().collect(); + if names.len() != variables.len() { + return Err(invalid("results/variables", "duplicate projected variable")); + } + for name in variables { + super::valid_identity("projected variable", name)?; + } + for row in rows { + for (name, term) in row { + if !names.contains(name) { + return Err(invalid( + "results/rows", + "binding not in projected variables", + )); + } + term.validate()?; + } + } + } + let bytes = serde_json::to_vec(self).map_err(|e| RevisionError::Json(e.to_string()))?; + if bytes.len() > MAX_REVISION_QUERY_RESULT_BYTES { + return Err(invalid("results", "result byte limit exceeded")); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum QueryUnavailableReason { + MissingRevision, + MissingCheckpoint, + MissingArtifact, + CorruptArtifact, + UnknownLineage, + ExternalRevision, + UnsupportedProjection, + InvalidQuery, + UnsupportedQuery, + DeadlineExceeded, + CapacityExceeded, + EvaluationFailed, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum RevisionQueryOutcome { + Completed { + model_revision: RevisionId, + indexed_revision: RevisionId, + graph: RevisionGraphDescriptor, + freshness: QueryFreshness, + results: RevisionQueryResults, + }, + Pending { + model_revision: Option, + requested_revision: Option, + available_graph: Option, + }, + Unavailable { + model_revision: Option, + requested_revision: Option, + reason: QueryUnavailableReason, + }, +} + +validated_struct! { + /// Owner-supplied identities bind every outcome, including absence/failure. + pub struct RevisionQueryResponse { + pub project: ProjectId, + pub branch: BranchId, + pub outcome: RevisionQueryOutcome, + } +} + +impl RevisionQueryResponse { + pub fn validate(&self) -> Result<(), RevisionError> { + match &self.outcome { + RevisionQueryOutcome::Completed { + model_revision, + indexed_revision, + graph, + freshness, + results, + } => { + graph.validate()?; + checkpoint(&graph.checkpoint, &self.project)?; + if indexed_revision != &graph.checkpoint.revision { + return Err(invalid("indexed_revision", "checkpoint revision mismatch")); + } + if (*freshness == QueryFreshness::Fresh) != (model_revision == indexed_revision) { + return Err(invalid( + "freshness", + "freshness contradicts revision identity", + )); + } + results.validate()?; + } + RevisionQueryOutcome::Pending { + available_graph: Some(graph), + .. + } => { + graph.validate()?; + checkpoint(&graph.checkpoint, &self.project)?; + } + _ => {} + } + Ok(()) + } + + /// Check request/response consistency. Minimum ancestry and permission to + /// use an older checkpoint require owner evidence and cannot be proven here. + pub fn validate_for(&self, request: &RevisionQueryRequest) -> Result<(), RevisionError> { + request.validate()?; + self.validate()?; + if self.project != request.project || self.branch != request.branch { + return Err(invalid("response", "request project or branch mismatch")); + } + let requested = match &request.selector { + RevisionQuerySelector::Exact { revision } => Some(revision), + RevisionQuerySelector::Minimum { checkpoint, .. } => Some(&checkpoint.revision), + RevisionQuerySelector::Current { .. } => None, + }; + match &self.outcome { + RevisionQueryOutcome::Completed { + indexed_revision, + graph, + freshness, + .. + } => match &request.selector { + RevisionQuerySelector::Exact { revision } if revision != indexed_revision => { + return Err(invalid("response", "exact revision cannot fall back")); + } + RevisionQuerySelector::Minimum { + checkpoint: minimum, + .. + } if &minimum.revision == indexed_revision && minimum != &graph.checkpoint => { + return Err(invalid("response", "minimum checkpoint identity mismatch")); + } + RevisionQuerySelector::Current { allow_older: false } + if *freshness != QueryFreshness::Fresh => + { + return Err(invalid("response", "current revision cannot fall back")); + } + _ => {} + }, + RevisionQueryOutcome::Pending { + requested_revision, .. + } + | RevisionQueryOutcome::Unavailable { + requested_revision, .. + } => { + if requested.is_some() && requested != requested_revision.as_ref() { + return Err(invalid("response", "requested revision mismatch")); + } + } + } + Ok(()) + } +} diff --git a/crates/ledgrrr-revision-io/src/store.rs b/crates/ledgrrr-revision-io/src/store.rs index 1b22fd49..e6154bde 100644 --- a/crates/ledgrrr-revision-io/src/store.rs +++ b/crates/ledgrrr-revision-io/src/store.rs @@ -2,7 +2,7 @@ use crate::*; use rusqlite::{params, Connection, OptionalExtension, TransactionBehavior}; use serde::de::DeserializeOwned; use std::{path::Path, time::Duration}; -use ufo_types::revision::canonical_bytes; +use crate::revision::canonical_bytes; const SCHEMA: &str = include_str!("schema.sql"); const SCHEMA_V1: &str = include_str!("schema-v1.sql"); @@ -189,7 +189,7 @@ fn load_operation( } } if let Some(digest) = &value.conflict_evidence { - let conflicts: Vec = decode(&artifact(c, project, digest)?)?; + let conflicts: Vec = decode(&artifact(c, project, digest)?)?; if !matches!(&receipt.status,SyncStatus::Conflict{paths} if *paths==conflicts.iter().map(|c|c.path.clone()).collect::>()) { return Err(Error::Corrupt("conflict evidence identity".into())); @@ -1002,7 +1002,7 @@ impl Store { actor: &ActorId, token: &DispatchToken, generation: u64, - conflicts: &[ufo_types::revision::Conflict], + conflicts: &[crate::revision::Conflict], ) -> Result { if conflicts.is_empty() || conflicts.len() > 256 { return Err(Error::Invalid("bounded nonempty conflicts required".into())); @@ -1018,7 +1018,7 @@ impl Store { token: &DispatchToken, generation: u64, status: SyncStatus, - conflicts: Option<&[ufo_types::revision::Conflict]>, + conflicts: Option<&[crate::revision::Conflict]>, ) -> Result { if !matches!( status, diff --git a/crates/ledgrrr-revision-io/src/store/index.rs b/crates/ledgrrr-revision-io/src/store/index.rs index 311accf6..51cdb646 100644 --- a/crates/ledgrrr-revision-io/src/store/index.rs +++ b/crates/ledgrrr-revision-io/src/store/index.rs @@ -1,7 +1,7 @@ //! Durable revision projection ownership; provider observations remain trusted adapter input. use super::*; use serde::{Deserialize, Serialize}; -use ufo_types::revision::RevisionGraphDescriptor; +use crate::revision::RevisionGraphDescriptor; pub const DEFAULT_PROJECTION: &str = "urn:ledgrrr:revision-projection:1"; const MAX_GRAPH: usize = 64 * 1024 * 1024; diff --git a/crates/ledgrrr-revision-io/tests/durable_index.rs b/crates/ledgrrr-revision-io/tests/durable_index.rs index 24299f05..1543b698 100644 --- a/crates/ledgrrr-revision-io/tests/durable_index.rs +++ b/crates/ledgrrr-revision-io/tests/durable_index.rs @@ -1,6 +1,6 @@ use ledgrrr_revision_io::*; use std::collections::BTreeMap; -use ufo_types::revision::{ArtifactPath, PortableModel, RevisionContext, RevisionGraphDescriptor}; +use ledgrrr_revision_io::revision::{ArtifactPath, PortableModel, RevisionContext, RevisionGraphDescriptor}; fn a() -> ActorId { ActorId::new("owner").unwrap() diff --git a/crates/ledgrrr-revision-io/tests/durable_store.rs b/crates/ledgrrr-revision-io/tests/durable_store.rs index 70df2467..ac86f010 100644 --- a/crates/ledgrrr-revision-io/tests/durable_store.rs +++ b/crates/ledgrrr-revision-io/tests/durable_store.rs @@ -1,6 +1,6 @@ use ledgrrr_revision_io::*; use std::collections::BTreeMap; -use ufo_types::revision::{ArtifactPath, PortableModel, RevisionContext}; +use ledgrrr_revision_io::revision::{ArtifactPath, PortableModel, RevisionContext}; fn fixture() -> serde_json::Value { serde_json::from_str(include_str!("fixtures/durable_revision_cases.json")).unwrap() @@ -740,7 +740,7 @@ fn malformed_records_and_missing_artifacts_fail_closed() { record.receipt.actor = actor("forged"); c.execute( "UPDATE operations SET record=?1", - [ufo_types::revision::canonical_bytes(&record).unwrap()], + [ledgrrr_revision_io::revision::canonical_bytes(&record).unwrap()], ) .unwrap(); } @@ -969,7 +969,7 @@ fn independent_physical_bootstrap_race_and_preserving_v1_upgrade() { legacy.send_authorized = false; c.execute( "UPDATE operations SET record=?1", - [ufo_types::revision::canonical_bytes(&legacy).unwrap()], + [ledgrrr_revision_io::revision::canonical_bytes(&legacy).unwrap()], ) .unwrap(); drop(c); @@ -1079,7 +1079,7 @@ fn duplicate_legacy_migration_fails_atomically_and_noop_never_sends() { .unwrap(); c.execute( "INSERT INTO projects VALUES ('duplicate',?1,'owner')", - [ufo_types::revision::canonical_bytes(&binding()).unwrap()], + [ledgrrr_revision_io::revision::canonical_bytes(&binding()).unwrap()], ) .unwrap(); drop(c); @@ -1155,8 +1155,8 @@ fn upstream_typed_conflicts_are_digest_bound_and_survive_reopen() { let mut theirs = base.clone(); ours.elements.get_mut(id).unwrap().name = case["ours"].as_str().unwrap().into(); theirs.elements.get_mut(id).unwrap().name = case["theirs"].as_str().unwrap().into(); - let ufo_types::revision::MergeOutcome::Conflicted { conflicts } = - ufo_types::revision::merge_models(&base, &ours, &theirs).unwrap() + let ledgrrr_revision_io::revision::MergeOutcome::Conflicted { conflicts } = + ledgrrr_revision_io::revision::merge_models(&base, &ours, &theirs).unwrap() else { panic!("fixture must conflict") }; @@ -1198,6 +1198,6 @@ fn upstream_typed_conflicts_are_digest_bound_and_survive_reopen() { reopened.conflict_evidence.as_ref().unwrap(), ) .unwrap(); - let actual: Vec = serde_json::from_slice(&bytes).unwrap(); + let actual: Vec = serde_json::from_slice(&bytes).unwrap(); assert_eq!(actual, conflicts); } diff --git a/crates/ledgrrr-sysml-adapter/Cargo.toml b/crates/ledgrrr-sysml-adapter/Cargo.toml index 007cdd37..f8ec1a25 100644 --- a/crates/ledgrrr-sysml-adapter/Cargo.toml +++ b/crates/ledgrrr-sysml-adapter/Cargo.toml @@ -6,7 +6,7 @@ license.workspace = true description = "Strict native SysML projection and serialized owner transport" [dependencies] -ufo-types = { workspace = true, features = ["revision"] } +ufo-types = { workspace = true } ledgrrr-revision-io = { path = "../ledgrrr-revision-io" } serde.workspace = true serde_json = { workspace = true, features = ["raw_value"] } diff --git a/crates/ledgrrr-sysml-adapter/src/bin/revision-owner.rs b/crates/ledgrrr-sysml-adapter/src/bin/revision-owner.rs index fdd9efe3..618270c6 100644 --- a/crates/ledgrrr-sysml-adapter/src/bin/revision-owner.rs +++ b/crates/ledgrrr-sysml-adapter/src/bin/revision-owner.rs @@ -8,7 +8,7 @@ use ledgrrr_sysml_adapter::{ }; use serde::Deserialize; use std::{collections::BTreeMap, path::PathBuf, sync::Arc}; -use ufo_types::revision::{ArtifactPath, PortableModel, RevisionContext}; +use ledgrrr_revision_io::revision::{ArtifactPath, PortableModel, RevisionContext}; #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -142,7 +142,7 @@ async fn run() -> std::result::Result<(), Box> { .ok_or_else(|| invalid("store needs parent directory"))?; std::fs::create_dir_all(directory)?; let guard = directory.join("provider-authority.json"); - let identity = ufo_types::revision::canonical_bytes(&( + let identity = ledgrrr_revision_io::revision::canonical_bytes(&( config.backend_origin.clone(), config.store_path.clone(), ))?; @@ -297,7 +297,7 @@ fn index_command( 300_000, )? .ok_or_else(|| invalid("no claimable index job"))?; - std::fs::write(path, ufo_types::revision::canonical_bytes(&work.token)?)?; + std::fs::write(path, ledgrrr_revision_io::revision::canonical_bytes(&work.token)?)?; println!("{}", serde_json::to_string(&work.token)?); } "seal" | "publish" => { diff --git a/crates/ledgrrr-sysml-adapter/src/indexing.rs b/crates/ledgrrr-sysml-adapter/src/indexing.rs index 7c0b1ef2..a70202a7 100644 --- a/crates/ledgrrr-sysml-adapter/src/indexing.rs +++ b/crates/ledgrrr-sysml-adapter/src/indexing.rs @@ -6,7 +6,7 @@ use crate::{ }; use ledgrrr_revision_io::*; use std::time::{Duration, Instant}; -use ufo_types::revision::{ +use ledgrrr_revision_io::revision::{ QueryFreshness, QueryUnavailableReason, RevisionGraphDescriptor, RevisionQueryOutcome, RevisionQueryRequest, RevisionQueryResponse, RevisionQuerySelector, }; diff --git a/crates/ledgrrr-sysml-adapter/src/native.rs b/crates/ledgrrr-sysml-adapter/src/native.rs index 422a23a1..d5294009 100644 --- a/crates/ledgrrr-sysml-adapter/src/native.rs +++ b/crates/ledgrrr-sysml-adapter/src/native.rs @@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::collections::{BTreeMap, BTreeSet}; -use ufo_types::revision::{ +use ledgrrr_revision_io::revision::{ canonical_bytes, ArtifactDigest, FidelityIssue, FidelityKind, FidelityReport, PortableBundle, }; use ufo_types::{ElementKind, Relation}; @@ -90,7 +90,7 @@ pub enum Error { #[error("invalid native protocol: {0}")] Protocol(String), #[error(transparent)] - Revision(#[from] ufo_types::revision::RevisionError), + Revision(#[from] ledgrrr_revision_io::revision::RevisionError), #[error(transparent)] Json(#[from] serde_json::Error), } @@ -202,14 +202,12 @@ fn supported_element(kind: ElementKind) -> bool { | ElementKind::PartUsage | ElementKind::RequirementDefinition | ElementKind::RequirementUsage - | ElementKind::VerificationCaseDefinition - | ElementKind::VerificationCaseUsage ) } fn native_feature(kind: ElementKind) -> bool { matches!( kind, - ElementKind::PartUsage | ElementKind::RequirementUsage | ElementKind::VerificationCaseUsage + ElementKind::PartUsage | ElementKind::RequirementUsage ) } @@ -268,12 +266,10 @@ pub fn emit(bundle: &PortableBundle, marker: &OperationMarker) -> Result { + Relation::Verify { requirement, .. } => { bundle.model.elements[requirement.as_str()].kind == ElementKind::RequirementUsage - && bundle.model.elements[by.as_str()].kind == ElementKind::VerificationCaseUsage } Relation::Satisfy { requirement, @@ -290,11 +286,6 @@ pub fn emit(bundle: &PortableBundle, marker: &OperationMarker) -> Result { - let compatible = bundle.model.elements[feature.as_str()].kind.definition_of() - == Some(bundle.model.elements[type_.as_str()].kind); - compatible && typed_features.insert(feature.as_str()) - } Relation::Specialization { .. } => false, Relation::Dependency { .. } => true, _ => false, @@ -375,28 +366,6 @@ pub fn emit(bundle: &PortableBundle, marker: &OperationMarker) -> Result { - row.fields - .insert("typedFeature".into(), json!(native_ref(feature))); - row.fields.insert("type".into(), json!(native_ref(type_))); - let usage = rows - .get_mut(feature.as_str()) - .ok_or_else(|| loss(id, "missing typed usage"))?; - match bundle.model.elements[feature.as_str()].kind { - ElementKind::RequirementUsage => { - usage.requirement_definition = Some(native_ref(type_)) - } - ElementKind::VerificationCaseUsage => { - usage.verification_case_definition = Some(native_ref(type_)) - } - ElementKind::PartUsage => { - usage - .fields - .insert("partDefinition".into(), json!([native_ref(type_)])); - } - _ => return Err(loss(id, "unsupported native typed usage")), - } - } _ => return Err(loss(id, "unsupported relation")), } relation_rows.push(row); diff --git a/crates/ledgrrr-sysml-adapter/src/projection.rs b/crates/ledgrrr-sysml-adapter/src/projection.rs index 34c3573d..716e35d0 100644 --- a/crates/ledgrrr-sysml-adapter/src/projection.rs +++ b/crates/ledgrrr-sysml-adapter/src/projection.rs @@ -12,7 +12,7 @@ use oxigraph::{ }; use serde_json::Value; use std::collections::BTreeSet; -use ufo_types::revision::{ +use ledgrrr_revision_io::revision::{ ArtifactDigest, IndexCheckpoint, PortableBundle, ProjectId, RevisionGraphDescriptor, RevisionId, }; @@ -145,10 +145,10 @@ impl Builder<'_> { fn type_ref( &mut self, n: &NamedNode, - t: &ufo_types::revision::TypeRef, + t: &ledgrrr_revision_io::revision::TypeRef, element: &impl Fn(&str) -> NamedNode, ) -> Result<(), ProjectionError> { - use ufo_types::revision::TypeRef; + use ledgrrr_revision_io::revision::TypeRef; match t { TypeRef::Element(id) => { self.text(n, "type_kind", "element")?; @@ -180,10 +180,10 @@ impl Builder<'_> { fn typed_value( &mut self, n: &NamedNode, - v: &ufo_types::revision::TypedValue, + v: &ledgrrr_revision_io::revision::TypedValue, element: &impl Fn(&str) -> NamedNode, ) -> Result<(), ProjectionError> { - use ufo_types::revision::TypedValue; + use ledgrrr_revision_io::revision::TypedValue; match v { TypedValue::Boolean(v) => { self.text(n, "value_kind", "boolean")?; @@ -228,7 +228,7 @@ impl Builder<'_> { fn evidence( &mut self, owner: &NamedNode, - anchors: &[ufo_types::revision::SourceEvidence], + anchors: &[ledgrrr_revision_io::revision::SourceEvidence], bundle: &PortableBundle, ) -> Result<(), ProjectionError> { for (i, e) in anchors.iter().enumerate() { @@ -487,27 +487,27 @@ fn check_json_depth(v: &Value, d: usize, max: usize) -> Result<(), ProjectionErr Ok(()) } fn check_type_depth( - v: &ufo_types::revision::TypeRef, + v: &ledgrrr_revision_io::revision::TypeRef, d: usize, max: usize, ) -> Result<(), ProjectionError> { if d > max { return Err(ProjectionError::Limit("depth")); } - if let ufo_types::revision::TypeRef::List(t) = v { + if let ledgrrr_revision_io::revision::TypeRef::List(t) = v { check_type_depth(t, d + 1, max)?; } Ok(()) } fn check_value_depth( - v: &ufo_types::revision::TypedValue, + v: &ledgrrr_revision_io::revision::TypedValue, d: usize, max: usize, ) -> Result<(), ProjectionError> { if d > max { return Err(ProjectionError::Limit("depth")); } - if let ufo_types::revision::TypedValue::List(values) = v { + if let ledgrrr_revision_io::revision::TypedValue::List(values) = v { for v in values { check_value_depth(v, d + 1, max)?; } diff --git a/crates/ledgrrr-sysml-adapter/src/promotion.rs b/crates/ledgrrr-sysml-adapter/src/promotion.rs index 7e978a73..13fad943 100644 --- a/crates/ledgrrr-sysml-adapter/src/promotion.rs +++ b/crates/ledgrrr-sysml-adapter/src/promotion.rs @@ -5,7 +5,7 @@ use crate::{ }; use ledgrrr_revision_io::*; use std::path::PathBuf; -use ufo_types::revision::{merge_models, MergeOutcome, PortableModel}; +use ledgrrr_revision_io::revision::{merge_models, MergeOutcome, PortableModel}; #[derive(Debug, thiserror::Error)] pub enum Error { @@ -16,7 +16,7 @@ pub enum Error { #[error(transparent)] Transport(#[from] crate::client::Error), #[error(transparent)] - Revision(#[from] ufo_types::revision::RevisionError), + Revision(#[from] ledgrrr_revision_io::revision::RevisionError), #[error("authority unavailable: {0}")] Unavailable(String), } diff --git a/crates/ledgrrr-sysml-adapter/src/query.rs b/crates/ledgrrr-sysml-adapter/src/query.rs index a791ed5a..a8b53fc0 100644 --- a/crates/ledgrrr-sysml-adapter/src/query.rs +++ b/crates/ledgrrr-sysml-adapter/src/query.rs @@ -29,7 +29,7 @@ use tokio::{ io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt}, sync::Semaphore, }; -use ufo_types::revision::{ +use ledgrrr_revision_io::revision::{ QueryUnavailableReason, RdfTerm, RevisionGraphDescriptor, RevisionQueryRequest, RevisionQueryResults, RevisionQuerySelector, MAX_REVISION_QUERY_BYTES, MAX_REVISION_QUERY_RESULT_BYTES, MAX_REVISION_QUERY_ROWS, MAX_REVISION_QUERY_VARIABLES, diff --git a/crates/ledgrrr-sysml-adapter/src/server.rs b/crates/ledgrrr-sysml-adapter/src/server.rs index 72a43627..557da076 100644 --- a/crates/ledgrrr-sysml-adapter/src/server.rs +++ b/crates/ledgrrr-sysml-adapter/src/server.rs @@ -64,8 +64,8 @@ impl From for ApiError { Self::Owner(e.into()) } } -impl From for ApiError { - fn from(_: ufo_types::revision::RevisionError) -> Self { +impl From for ApiError { + fn from(_: ledgrrr_revision_io::revision::RevisionError) -> Self { Self::Invalid } } @@ -288,12 +288,12 @@ async fn query( Path((p, b)): Path<(String, String)>, headers: HeaderMap, body: Bytes, -) -> Result> { +) -> Result> { let actor = principal(&host, &headers)?; if body.len() > 128 * 1024 { return Err(ApiError::Invalid); } - let request: ufo_types::revision::RevisionQueryRequest = + let request: ledgrrr_revision_io::revision::RevisionQueryRequest = serde_json::from_slice(&body).map_err(|_| ApiError::Invalid)?; if request.project.as_str() != p || request.branch.as_str() != b || request.deadline_ms > 5000 { return Err(ApiError::Invalid); @@ -309,7 +309,7 @@ async fn checkpoint( State(host): State>, Path((p, r)): Path<(String, String)>, headers: HeaderMap, -) -> Result> { +) -> Result> { let actor = principal(&host, &headers)?; let p = ProjectId::new(p)?; let r = RevisionId::new(r)?; diff --git a/crates/ledgrrr-sysml-adapter/tests/fixtures/native_revision_cases.json b/crates/ledgrrr-sysml-adapter/tests/fixtures/native_revision_cases.json index 09423920..e2f1ee91 100644 --- a/crates/ledgrrr-sysml-adapter/tests/fixtures/native_revision_cases.json +++ b/crates/ledgrrr-sysml-adapter/tests/fixtures/native_revision_cases.json @@ -127,7 +127,7 @@ }, "test": { "id": "test", - "kind": "verification_case_usage", + "kind": "part_usage", "name": "Controller test", "properties": {}, "anchors": [], @@ -141,14 +141,6 @@ "anchors": [], "extensions": {} }, - "verification-definition": { - "id": "verification-definition", - "kind": "verification_case_definition", - "name": "Controller Verification", - "properties": {}, - "anchors": [], - "extensions": {} - }, "controller-definition": { "id": "controller-definition", "kind": "part_definition", @@ -156,6 +148,14 @@ "properties": {}, "anchors": [], "extensions": {} + }, + "verification-definition": { + "id": "verification-definition", + "kind": "requirement_definition", + "name": "VerificationDefinition", + "properties": {}, + "anchors": [], + "extensions": {} } }, "relations": { @@ -235,45 +235,6 @@ "anchors": [], "rule": null, "extensions": {} - }, - "type-req": { - "id": "type-req", - "relation": { - "feature_typing": { - "feature": "REQ-日本語", - "type": "REQ-2" - } - }, - "authority": "authored", - "anchors": [], - "rule": null, - "extensions": {} - }, - "type-verification": { - "id": "type-verification", - "relation": { - "feature_typing": { - "feature": "test", - "type": "verification-definition" - } - }, - "authority": "authored", - "anchors": [], - "rule": null, - "extensions": {} - }, - "type-controller": { - "id": "type-controller", - "relation": { - "feature_typing": { - "feature": "controller", - "type": "controller-definition" - } - }, - "authority": "authored", - "anchors": [], - "rule": null, - "extensions": {} } } }, @@ -858,221 +819,6 @@ "variant": [], "variantMembership": [] }, - "VerificationCaseDefinition": { - "action": [], - "actorParameter": [], - "calculation": [], - "declaredShortName": null, - "differencingType": [], - "directedFeature": [], - "directedUsage": [], - "documentation": [], - "endFeature": [], - "expression": [], - "feature": [], - "featureMembership": [], - "importedMembership": [], - "inheritedFeature": [], - "inheritedMembership": [], - "input": [], - "intersectingType": [], - "isAbstract": null, - "isConjugated": null, - "isImpliedIncluded": null, - "isIndividual": null, - "isLibraryElement": null, - "isModelLevelEvaluable": null, - "isSufficient": null, - "isVariation": null, - "member": [], - "membership": [], - "multiplicity": null, - "name": null, - "objectiveRequirement": null, - "output": [], - "ownedAction": [], - "ownedAllocation": [], - "ownedAnalysisCase": [], - "ownedAnnotation": [], - "ownedAttribute": [], - "ownedCalculation": [], - "ownedCase": [], - "ownedConcern": [], - "ownedConjugator": null, - "ownedConnection": [], - "ownedConstraint": [], - "ownedDifferencing": [], - "ownedDisjoining": [], - "ownedElement": [], - "ownedEndFeature": [], - "ownedEnumeration": [], - "ownedFeature": [], - "ownedFeatureMembership": [], - "ownedFlow": [], - "ownedImport": [], - "ownedInterface": [], - "ownedIntersecting": [], - "ownedItem": [], - "ownedMember": [], - "ownedMembership": [], - "ownedMetadata": [], - "ownedOccurrence": [], - "ownedPart": [], - "ownedPort": [], - "ownedReference": [], - "ownedRelationship": [], - "ownedRendering": [], - "ownedRequirement": [], - "ownedSpecialization": [], - "ownedState": [], - "ownedSubclassification": [], - "ownedTransition": [], - "ownedUnioning": [], - "ownedUsage": [], - "ownedUseCase": [], - "ownedVerificationCase": [], - "ownedView": [], - "ownedViewpoint": [], - "owner": null, - "owningMembership": null, - "owningNamespace": null, - "owningRelationship": null, - "parameter": [], - "qualifiedName": null, - "result": null, - "shortName": null, - "step": [], - "subjectParameter": null, - "textualRepresentation": [], - "unioningType": [], - "usage": [], - "variant": [], - "variantMembership": [] - }, - "VerificationCaseUsage": { - "actionDefinition": [], - "actorParameter": [], - "behavior": [], - "calculationDefinition": null, - "caseDefinition": null, - "chainingFeature": [], - "crossFeature": null, - "declaredShortName": null, - "definition": [], - "differencingType": [], - "directedFeature": [], - "directedUsage": [], - "direction": null, - "documentation": [], - "endFeature": [], - "endOwningType": null, - "feature": [], - "featureMembership": [], - "featureTarget": null, - "featuringType": [], - "function": null, - "importedMembership": [], - "individualDefinition": null, - "inheritedFeature": [], - "inheritedMembership": [], - "input": [], - "intersectingType": [], - "isAbstract": null, - "isComposite": null, - "isConjugated": null, - "isConstant": null, - "isDerived": null, - "isEnd": null, - "isImpliedIncluded": null, - "isIndividual": null, - "isLibraryElement": null, - "isModelLevelEvaluable": null, - "isOrdered": null, - "isPortion": null, - "isReference": null, - "isSufficient": null, - "isUnique": null, - "isVariable": null, - "isVariation": null, - "mayTimeVary": null, - "member": [], - "membership": [], - "multiplicity": null, - "name": null, - "nestedAction": [], - "nestedAllocation": [], - "nestedAnalysisCase": [], - "nestedAttribute": [], - "nestedCalculation": [], - "nestedCase": [], - "nestedConcern": [], - "nestedConnection": [], - "nestedConstraint": [], - "nestedEnumeration": [], - "nestedFlow": [], - "nestedInterface": [], - "nestedItem": [], - "nestedMetadata": [], - "nestedOccurrence": [], - "nestedPart": [], - "nestedPort": [], - "nestedReference": [], - "nestedRendering": [], - "nestedRequirement": [], - "nestedState": [], - "nestedTransition": [], - "nestedUsage": [], - "nestedUseCase": [], - "nestedVerificationCase": [], - "nestedView": [], - "nestedViewpoint": [], - "objectiveRequirement": null, - "occurrenceDefinition": [], - "output": [], - "ownedAnnotation": [], - "ownedConjugator": null, - "ownedCrossSubsetting": null, - "ownedDifferencing": [], - "ownedDisjoining": [], - "ownedElement": [], - "ownedEndFeature": [], - "ownedFeature": [], - "ownedFeatureChaining": [], - "ownedFeatureInverting": [], - "ownedFeatureMembership": [], - "ownedImport": [], - "ownedIntersecting": [], - "ownedMember": [], - "ownedMembership": [], - "ownedRedefinition": [], - "ownedReferenceSubsetting": null, - "ownedRelationship": [], - "ownedSpecialization": [], - "ownedSubsetting": [], - "ownedTypeFeaturing": [], - "ownedTyping": [], - "ownedUnioning": [], - "owner": null, - "owningDefinition": null, - "owningFeatureMembership": null, - "owningMembership": null, - "owningNamespace": null, - "owningRelationship": null, - "owningType": null, - "owningUsage": null, - "parameter": [], - "portionKind": null, - "qualifiedName": null, - "result": null, - "shortName": null, - "subjectParameter": null, - "textualRepresentation": [], - "type": [], - "unioningType": [], - "usage": [], - "variant": [], - "variantMembership": [] - }, "SatisfyRequirementUsage": { "actorParameter": [], "assertedConstraint": null, @@ -1389,35 +1135,6 @@ "target": [], "textualRepresentation": [], "visibility": null - }, - "FeatureTyping": { - "declaredShortName": null, - "documentation": [], - "general": null, - "isImplied": null, - "isImpliedIncluded": null, - "isLibraryElement": null, - "name": null, - "ownedAnnotation": [], - "ownedElement": [], - "ownedRelatedElement": [], - "ownedRelationship": [], - "owner": null, - "owningFeature": null, - "owningMembership": null, - "owningNamespace": null, - "owningRelatedElement": null, - "owningRelationship": null, - "owningType": null, - "qualifiedName": null, - "relatedElement": [], - "shortName": null, - "source": [], - "specific": null, - "target": [], - "textualRepresentation": [], - "type": null, - "typedFeature": null } }, "verified_machine": { @@ -1496,4 +1213,4 @@ "extensions": {} } } -} +} \ No newline at end of file diff --git a/crates/ledgrrr-sysml-adapter/tests/fixtures/revision_projection.json b/crates/ledgrrr-sysml-adapter/tests/fixtures/revision_projection.json index 9f594f81..0d9e19e9 100644 --- a/crates/ledgrrr-sysml-adapter/tests/fixtures/revision_projection.json +++ b/crates/ledgrrr-sysml-adapter/tests/fixtures/revision_projection.json @@ -317,7 +317,7 @@ }, "test": { "id": "test", - "kind": "verification_case_usage", + "kind": "part_usage", "name": "Controller test", "properties": {}, "anchors": [], @@ -333,7 +333,7 @@ }, "verification-definition": { "id": "verification-definition", - "kind": "verification_case_definition", + "kind": "part_definition", "name": "Controller Verification", "properties": {}, "anchors": [], @@ -485,7 +485,7 @@ }, "kind-verification_case_definition": { "id": "kind-verification_case_definition", - "kind": "verification_case_definition", + "kind": "part_definition", "name": "verification_case_definition", "properties": {}, "anchors": [], @@ -493,7 +493,7 @@ }, "kind-verification_case_usage": { "id": "kind-verification_case_usage", - "kind": "verification_case_usage", + "kind": "part_usage", "name": "verification_case_usage", "properties": {}, "anchors": [], @@ -634,45 +634,6 @@ "rule": null, "extensions": {} }, - "type-req": { - "id": "type-req", - "relation": { - "feature_typing": { - "feature": "REQ-日本語", - "type": "REQ-2" - } - }, - "authority": "authored", - "anchors": [], - "rule": null, - "extensions": {} - }, - "type-verification": { - "id": "type-verification", - "relation": { - "feature_typing": { - "feature": "test", - "type": "verification-definition" - } - }, - "authority": "authored", - "anchors": [], - "rule": null, - "extensions": {} - }, - "type-controller": { - "id": "type-controller", - "relation": { - "feature_typing": { - "feature": "controller", - "type": "controller-definition" - } - }, - "authority": "authored", - "anchors": [], - "rule": null, - "extensions": {} - }, "relation-feature_membership": { "id": "relation-feature_membership", "relation": { @@ -1045,8 +1006,6 @@ "state_usage", "requirement_definition", "requirement_usage", - "verification_case_definition", - "verification_case_usage", "constraint_definition", "constraint_usage", "allocation_usage", @@ -1064,7 +1023,6 @@ "succession", "refine", "domain", - "feature_typing", "dependency", "allocation", "verify", @@ -1083,4 +1041,4 @@ "SELECT (COUNT(*) AS ?count) WHERE {} HAVING(EXISTS { SERVICE {} })", "SELECT ?x WHERE {VALUES ?x {1}} ORDER BY(EXISTS { SERVICE {} })" ] -} +} \ No newline at end of file diff --git a/crates/ledgrrr-sysml-adapter/tests/index_owner_routes.rs b/crates/ledgrrr-sysml-adapter/tests/index_owner_routes.rs index 94f89463..62eb4877 100644 --- a/crates/ledgrrr-sysml-adapter/tests/index_owner_routes.rs +++ b/crates/ledgrrr-sysml-adapter/tests/index_owner_routes.rs @@ -6,7 +6,7 @@ use ledgrrr_sysml_adapter::{ server::{self, Credential, Host}, }; use std::sync::Arc; -use ufo_types::revision::{ +use ledgrrr_revision_io::revision::{ QueryUnavailableReason, RevisionQueryOutcome, RevisionQueryRequest, RevisionQueryResponse, }; diff --git a/crates/ledgrrr-sysml-adapter/tests/live_roundtrip.rs b/crates/ledgrrr-sysml-adapter/tests/live_roundtrip.rs index e4ccb584..cb0d38b2 100644 --- a/crates/ledgrrr-sysml-adapter/tests/live_roundtrip.rs +++ b/crates/ledgrrr-sysml-adapter/tests/live_roundtrip.rs @@ -5,7 +5,7 @@ //! Each invocation creates a fresh disposable project on the server. use ledgrrr_sysml_adapter::client::{Bounds, NativeClient}; use ledgrrr_sysml_adapter::native::{emit, hydrate, OperationMarker}; -use ufo_types::revision::{PortableBundle, PortableModel, RevisionContext}; +use ledgrrr_revision_io::revision::{PortableBundle, PortableModel, RevisionContext}; fn fixture_bundle() -> PortableBundle { let v: serde_json::Value = diff --git a/crates/ledgrrr-sysml-adapter/tests/native_codec.rs b/crates/ledgrrr-sysml-adapter/tests/native_codec.rs index d80d2078..9cd3678f 100644 --- a/crates/ledgrrr-sysml-adapter/tests/native_codec.rs +++ b/crates/ledgrrr-sysml-adapter/tests/native_codec.rs @@ -1,5 +1,5 @@ use ledgrrr_sysml_adapter::native::{emit, hydrate, OperationMarker}; -use ufo_types::revision::{PortableBundle, PortableModel, RevisionContext}; +use ledgrrr_revision_io::revision::{PortableBundle, PortableModel, RevisionContext}; fn fixture() -> PortableBundle { let v: serde_json::Value = serde_json::from_str(include_str!("fixtures/native_revision_cases.json")).unwrap(); @@ -42,19 +42,7 @@ fn empty_model_has_an_operation_anchor() { fn real_reference_shapes_and_non_default_semantics() { let b = fixture(); let p = emit(&b, &marker()).unwrap(); - let req = p - .elements - .iter() - .find(|e| e.kind == "RequirementUsage") - .unwrap(); - assert!(req.requirement_definition.is_some()); - let verify = p - .elements - .iter() - .find(|e| e.kind == "VerificationCaseUsage") - .unwrap(); - assert!(verify.verification_case_definition.is_some()); - assert_eq!(verify.verified_requirement.len(), 1); + // FeatureTyping removed from ufo-types >= 1846e6f; requirement_definition no longer set via native emission let satisfy = p .elements .iter() @@ -422,7 +410,7 @@ fn native_singleton_typing_shapes_are_not_generic_specialization() { serde_json::from_str(include_str!("fixtures/native_revision_cases.json")).unwrap(); b.model.relations.extend( serde_json::from_value::< - std::collections::BTreeMap, + std::collections::BTreeMap, >(value["pending_typing"].clone()) .unwrap(), ); @@ -436,13 +424,13 @@ fn verified_machine_evidence_does_not_fabricate_native_behavior() { serde_json::from_str(include_str!("fixtures/native_revision_cases.json")).unwrap(); b.model.elements.extend( serde_json::from_value::< - std::collections::BTreeMap, + std::collections::BTreeMap, >(value["verified_machine"]["elements"].clone()) .unwrap(), ); b.model.relations.extend( serde_json::from_value::< - std::collections::BTreeMap, + std::collections::BTreeMap, >(value["verified_machine"]["relations"].clone()) .unwrap(), ); @@ -462,44 +450,6 @@ fn marker_binds_the_logical_owner_project() { let p = emit(&b, &marker()).unwrap(); assert!(hydrate(&p.elements, &b.to_bytes().unwrap(), &wrong).is_err()); } -#[test] -fn true_feature_typing_projects_usage_specific_definition_links() { - let mut b = fixture(); - for (id, feature, definition) in [ - ("type-req", "REQ-日本語", "REQ-2"), - ("type-verification", "test", "verification-definition"), - ] { - let relation=serde_json::from_value(serde_json::json!({"id":id,"relation":{"feature_typing":{"feature":feature,"type":definition}},"authority":"authored","anchors":[],"rule":null,"extensions":{}})).unwrap(); - b.model.relations.insert(id.into(), relation); - } - b.manifest.semantic_digest = b.model.semantic_digest().unwrap(); - let projection = emit(&b, &marker()).unwrap(); - let req = projection - .elements - .iter() - .find(|e| e.kind == "RequirementUsage") - .unwrap(); - assert!(req.requirement_definition.is_some()); - let verify = projection - .elements - .iter() - .find(|e| e.kind == "VerificationCaseUsage") - .unwrap(); - assert!(verify.verification_case_definition.is_some()); - assert_eq!( - projection - .elements - .iter() - .filter(|e| e.kind == "FeatureTyping") - .count(), - 3 - ); - assert_eq!( - hydrate(&projection.elements, &b.to_bytes().unwrap(), &marker()).unwrap(), - b - ); -} - #[test] fn envelope_reference_checks_actual_artifact_bytes_and_reports_native_field_paths() { let b = fixture(); @@ -524,46 +474,12 @@ fn envelope_reference_checks_actual_artifact_bytes_and_reports_native_field_path } #[test] -fn feature_typing_cardinality_pair_compatibility_and_native_links_are_checked() { - use ufo_types::Relation; +fn native_link_integrity_checks_reject_broken_fields() { + // FeatureTyping / VerificationCaseUsage removed from ufo-types >= 1846e6f. + // Remaining test: SatisfyRequirementUsage and AllocationUsage link checks. let b = fixture(); - let mut incompatible = b.clone(); - incompatible - .model - .relations - .get_mut("type-req") - .unwrap() - .relation = Relation::FeatureTyping { - feature: "REQ-日本語".into(), - type_: "verification-definition".into(), - }; - incompatible.manifest.semantic_digest = incompatible.model.semantic_digest().unwrap(); - assert!(matches!( - emit(&incompatible, &marker()), - Err(ledgrrr_sysml_adapter::native::Error::Fidelity(_)) - )); - let mut duplicate = b.clone(); - let mut typing = duplicate.model.relations["type-req"].clone(); - typing.id = "duplicate-typing".into(); - duplicate - .model - .relations - .insert("duplicate-typing".into(), typing); - duplicate.manifest.semantic_digest = duplicate.model.semantic_digest().unwrap(); - assert!(emit(&duplicate, &marker()).is_err()); let projection = emit(&b, &marker()).unwrap(); - let part = projection - .elements - .iter() - .find(|e| e.id == projection.identity_map["controller"]) - .unwrap(); - assert!(part.fields["partDefinition"].is_array()); - for kind in [ - "SatisfyRequirementUsage", - "AllocationUsage", - "VerificationCaseUsage", - "FeatureTyping", - ] { + for kind in ["SatisfyRequirementUsage", "AllocationUsage"] { let mut changed = projection.elements.clone(); let e = changed.iter_mut().find(|e| e.kind == kind).unwrap(); match kind { @@ -571,11 +487,7 @@ fn feature_typing_cardinality_pair_compatibility_and_native_links_are_checked() e.satisfied_requirement.as_mut().unwrap().id = "wrong".into() } "AllocationUsage" => e.target_feature.clear(), - "VerificationCaseUsage" => e.verified_requirement.clear(), - _ => { - e.fields - .insert("typedFeature".into(), serde_json::json!({"@id":"wrong"})); - } + _ => unreachable!(), } assert!(hydrate(&changed, &b.to_bytes().unwrap(), &marker()).is_err()); } diff --git a/crates/ledgrrr-sysml-adapter/tests/owner_routes.rs b/crates/ledgrrr-sysml-adapter/tests/owner_routes.rs index 87ec2234..e89fb482 100644 --- a/crates/ledgrrr-sysml-adapter/tests/owner_routes.rs +++ b/crates/ledgrrr-sysml-adapter/tests/owner_routes.rs @@ -112,9 +112,9 @@ async fn authenticated_route_inventory_has_no_native_mutation_or_client_actor_co } let fixture: Value = serde_json::from_str(include_str!("fixtures/native_revision_cases.json")).unwrap(); - let model: ufo_types::revision::PortableModel = + let model: ledgrrr_revision_io::revision::PortableModel = serde_json::from_value(fixture["model"].clone()).unwrap(); - let mut context: ufo_types::revision::RevisionContext = + let mut context: ledgrrr_revision_io::revision::RevisionContext = serde_json::from_value(fixture["context"].clone()).unwrap(); context.project = ProjectId::new("p").unwrap(); let artifacts = serde_json::from_value(fixture["artifacts"].clone()).unwrap(); diff --git a/crates/ledgrrr-sysml-adapter/tests/revision_projection.rs b/crates/ledgrrr-sysml-adapter/tests/revision_projection.rs index c6d2e940..d0731ab9 100644 --- a/crates/ledgrrr-sysml-adapter/tests/revision_projection.rs +++ b/crates/ledgrrr-sysml-adapter/tests/revision_projection.rs @@ -1,7 +1,7 @@ use ledgrrr_sysml_adapter::projection::{ project_bundle, project_bundle_with_limits, ProjectionLimits, VOCAB, }; -use ufo_types::revision::{PortableBundle, PortableModel, RevisionContext, RevisionId}; +use ledgrrr_revision_io::revision::{PortableBundle, PortableModel, RevisionContext, RevisionId}; fn fixture() -> (PortableBundle, serde_json::Value) { let f: serde_json::Value = serde_json::from_str(include_str!("fixtures/revision_projection.json")).unwrap(); @@ -22,7 +22,7 @@ fn all_canonical_records_are_deterministic_queryable_and_bound_to_actual_accepta assert_eq!(p.descriptor.checkpoint.revision, revision); assert_eq!( p.descriptor.accepted_candidate_digest, - ufo_types::revision::ArtifactDigest::of(&b.to_bytes().unwrap()) + ledgrrr_revision_io::revision::ArtifactDigest::of(&b.to_bytes().unwrap()) ); let text = String::from_utf8(p.nquads.clone()).unwrap(); for kind in f["expected_element_kinds"] diff --git a/crates/ledgrrr-sysml-adapter/tests/revision_queries.rs b/crates/ledgrrr-sysml-adapter/tests/revision_queries.rs index 68c5ba6c..70765ca6 100644 --- a/crates/ledgrrr-sysml-adapter/tests/revision_queries.rs +++ b/crates/ledgrrr-sysml-adapter/tests/revision_queries.rs @@ -2,7 +2,7 @@ use ledgrrr_sysml_adapter::{ projection::{project_bundle, source_requirement_query, unverified_requirements_query}, query::{QueryEngine, QueryLimits}, }; -use ufo_types::revision::*; +use ledgrrr_revision_io::revision::*; fn engine(limits: QueryLimits) -> Result { QueryEngine::new(limits).map(|e| e.with_worker_path(env!("CARGO_BIN_EXE_revision-owner"))) } diff --git a/docs/pm-hooks-design.md b/docs/pm-hooks-design.md new file mode 100644 index 00000000..a058f596 --- /dev/null +++ b/docs/pm-hooks-design.md @@ -0,0 +1,176 @@ +# PM Hooks Design + +*Project-management / task capability operating on statechart hooks* + +--- + +## 1. Problem Statement + +The tax-pipeline has a well-defined lifecycle (Ingest → Normalize → Validate → Reconcile → Commit → Summarize). Tasks and project-management state — "what stage is work at, what's blocked, what needs human review" — must reflect that lifecycle in real time, not be maintained separately by a human or agent writing ad-hoc status updates. + +The PM capability must be **hook-driven from the statechart**, not polled or manually updated. + +--- + +## 2. Architecture + +``` +TurboLedgerService + └── hsm_chart: Mutex> ← lazily::statechart executor + │ + │ on advance() / fast_forward_to() / resume_from_marker() + ↓ + last_actions() → Vec ← lazily entry-action tokens + │ + ↓ + PmHookDispatcher::dispatch(actions) + ├── PmHookEvent::StageReady { stage } → update task status to "ready" + ├── PmHookEvent::StageBlocked { stage } → flag task as "needs review" + ├── PmHookEvent::PipelineComplete → close active PM cycle + └── PmHookEvent::PipelineReset → reopen PM cycle +``` + +The `PmHookDispatcher` is a thin mapping layer — it translates lazily action strings to typed `PmHookEvent`s and calls registered handlers. Handlers are registered at startup; they are synchronous and infallible (they log failures, never panic). + +--- + +## 3. Hook Events + +### 3a. Action-string to event mapping + +| lazily entry-action string | PmHookEvent | Meaning | +|---------------------------|-------------|---------| +| `"ingest.pending"` | `StageBlocked { stage: "ingest" }` | Pipeline started, waiting on PDF input | +| `"ingest.ready"` | `StageReady { stage: "ingest" }` | Ingest complete, next stage unblocked | +| `"normalize.pending"` | `StageBlocked { stage: "normalize" }` | Normalization not yet started | +| `"normalize.ready"` | `StageReady { stage: "normalize" }` | Normalization done | +| `"validate.pending"` | `StageBlocked { stage: "validate" }` | Validation pending | +| `"validate.ready"` | `StageReady { stage: "validate" }` | Validation clean | +| `"reconcile.pending"` | `StageBlocked { stage: "reconcile" }` | Reconciliation needed (human review) | +| `"reconcile.ready"` | `StageReady { stage: "reconcile" }` | Reconciliation approved | +| `"commit.pending"` | `StageBlocked { stage: "commit" }` | Awaiting commit authorization | +| `"commit.ready"` | `StageReady { stage: "commit" }` | Committed to Excel | +| `"summarize.pending"` | `StageBlocked { stage: "summarize" }` | Summary not yet generated | +| `"summarize.ready"` | `PipelineComplete` | Full pipeline cycle complete | +| `"reset"` | `PipelineReset` | Pipeline rewound to Ingest | + +### 3b. PmHookEvent enum (Rust) + +```rust +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum PmHookEvent { + StageReady { stage: String }, + StageBlocked { stage: String }, + PipelineComplete, + PipelineReset, +} +``` + +--- + +## 4. Handler Contract + +Handlers implement: + +```rust +pub trait PmHookHandler: Send + Sync { + fn on_event(&self, event: &PmHookEvent); +} +``` + +Three built-in handlers: + +| Handler | Effect | +|---------|--------| +| `LoggingPmHookHandler` | Emits a structured `tracing::info!` span per event | +| `SsePmHookHandler` | Pushes the event as a JSON SSE frame to all connected clients (uses ledgerr-mcp's SSE channel) | +| `TaskStatusPmHookHandler` | Updates an in-memory `TaskStatusStore` (stage → Ready/Blocked/Complete) queryable via the `task_status` MCP tool | + +Handlers are composable — `TurboLedgerService` holds a `Vec>`. + +--- + +## 5. Integration Points + +### 5a. TurboLedgerService construction + +```rust +let service = TurboLedgerService::builder() + .pm_hook(Box::new(LoggingPmHookHandler)) + .pm_hook(Box::new(SsePmHookHandler::new(sse_tx.clone()))) + .pm_hook(Box::new(TaskStatusPmHookHandler::new())) + .build()?; +``` + +### 5b. Statechart advance loop + +Every call to `advance()`, `fast_forward_to()`, or `resume_from_marker()` follows this pattern: + +```rust +let taken = self.hsm_chart.lock().unwrap() + .as_mut() + .map(|c| c.advance(target_state, target_substate)) + .unwrap_or(false); + +if taken { + let actions = self.hsm_chart.lock().unwrap() + .as_ref() + .map(|c| c.last_actions()) + .unwrap_or_default(); + self.pm_hook_dispatcher.dispatch(&actions); +} +``` + +### 5c. MCP tool: `task_status` + +Returns the current per-stage status from `TaskStatusStore`: + +```json +{ + "ingest": "complete", + "normalize": "complete", + "validate": "blocked", + "reconcile": "pending", + "commit": "pending", + "summarize": "pending" +} +``` + +This tool is a read-only projection of hook-driven state — it is never written directly. + +--- + +## 6. SSE PM Events + +The streamable-HTTP layer (PR #264) emits PM hook events as SSE frames: + +``` +event: pm_hook +data: {"type":"StageReady","stage":"normalize","ts":"2026-10-11T12:00:00Z"} + +event: pm_hook +data: {"type":"PipelineComplete","ts":"2026-10-11T14:22:00Z"} +``` + +Clients (Tauri desktop agent, MCP consumers) subscribe to `GET /events` and react to `pm_hook` frames to update UI status, trigger notifications, or advance dependent workflows. + +--- + +## 7. Design Invariants + +1. **PM state is derived, never authoritative.** The statechart is authoritative; `TaskStatusStore` and SSE frames are projections. +2. **Hooks are synchronous and infallible.** Handler errors are logged, never propagated to the pipeline. +3. **No handler writes back to the statechart.** Handlers observe; they never call `advance()` themselves. +4. **Action strings are the only coupling.** `HsmChart` and `PmHookDispatcher` share only the action string vocabulary — no shared types. + +--- + +## 8. Open Work + +| Item | Effort | +|------|--------| +| `PmHookEvent` enum + `PmHookHandler` trait (add to `ledgerr-mcp`) | ~1h | +| `TaskStatusPmHookHandler` + `task_status` MCP tool | ~1h | +| Wire handlers into `TurboLedgerService::new()` (already has `hsm_chart`) | ~30m | +| SSE frame shape finalization (type discriminant key) | ~15m | +| Tests: advance pipeline → hook fires → TaskStatusStore updated | ~1h | diff --git a/docs/statechart-harmonization.md b/docs/statechart-harmonization.md new file mode 100644 index 00000000..87c684a0 --- /dev/null +++ b/docs/statechart-harmonization.md @@ -0,0 +1,122 @@ +# Statechart Tooling Survey & Harmonization Plan + +*lazily-rs v0.59+ as the unified runtime across b00t-iface, kr0ki, ufo-types, and ledgerr-mcp* + +--- + +## 1. Decision Summary + +**Chosen runtime: [`lazily::statechart`](https://github.com/lazily-hub/lazily-rs)** + +| Property | Value | +|----------|-------| +| Crate | `lazily = { version = "0.59", features = ["statechart", "thread-safe"] }` | +| Model | Harel Statecharts (SCXML-compatible), hierarchical states, parallel regions | +| Concurrency | `ThreadSafeStateChart` + `ThreadSafeContext` — cheaply cloneable, lock-free send | +| Effects | `entry`/`exit` action strings → matched by caller; no magic callbacks | +| Guards | `HashMap` passed at send-time — caller evaluates predicates | +| MSRV | Rust 1.88 | +| License | Apache-2.0 | + +Evaluated and rejected alternatives: + +| Library | Language | Why not | +|---------|----------|---------| +| statecharts.dev / XState | TypeScript/JS | Wrong language. Use as visual design tool only, not Rust runtime. | +| cmars/statechart | Go | Wrong language. | +| `statig` | Rust | Simpler flat HSM macro; no reactive cells, no parallel regions, no SCXML-level hierarchy. Covers a subset of what lazily covers. | +| `scxml` crate | Rust | Document-model / interchange layer only — no executor. Stays for export. | + +--- + +## 2. What Each Component Has Today + +### 2a. ufo-types (`src/statechart.rs` — rev 1846e6f) + +- Exports the OODA proposal lifecycle as a **W3C SCXML document** via the `scxml` crate. +- `ooda_phases_to_statechart()` builds a `Statechart` struct (states + transitions) from `OodaPhase`/`OodaEvent` matches in the authoritative `OodaStateMachine::dispatch`. +- This is **interchange / export only** — ufo-types ships no live executor. +- Pattern: the authoritative transition table lives in Rust match arms; statechart.rs mirrors it for tooling (diagrams, validators, SCXML round-trip). + +### 2b. b00t-iface (`src/core/machine.rs` — PR #267, branch `task/b00t-lazily-surface-chart`) + +- `SurfaceChart` (feature-gated `#[cfg(feature = "lazily")]`) wraps a `ThreadSafeStateChart`. +- `surface_chart_def()` builds a `ChartDef` from the `valid_transition` table in the same file — the canonical authoritative shape is `valid_transition(from, to) -> Option<&str>`. +- States: Idle → Ready → Running → Healthy | Quarantined → Terminated. +- `SurfaceChart::send(event, guards)` drives the live executor; `last_actions()` surfaces entry effects for caller dispatch. +- Existing `MachineTransition` / `valid_transition` static table remains as the MECE truth — `SurfaceChart` is an additive live layer on top. + +### 2c. kr0ki-core (`src/chart.rs` — PR #265, branch `task/259-p4-kr0ki-statechart`) + +- `ProcessChart` builds a `ThreadSafeStateChart` from SysML-v2 element/relation slices. +- Mapping: `ActionDefinition` → compound root state; `ActionUsage` → atomic leaf; `Succession` → `on("next", target)` transition. +- `from_sysml(elements, relations) -> Result` is the public API. +- Phases 1–4 done; Phase 5 (DecisionNode/ForkNode/JoinNode) blocked on ufo-types PR #13 (issue #268). + +### 2d. ledgerr-mcp (`src/hsm.rs` — PR #264, branch `task/227-sse-streamable-http`) + +- `HsmChart` drives the tax-pipeline lifecycle: Ingest → Normalize → Validate → Reconcile → Commit → Summarize. +- Each stage has `.pending` / `.ready` substates (hierarchical). +- `TurboLedgerService` holds `Mutex>`; PM hooks fire as lazily entry-action strings when the chart advances. +- `advance()`, `fast_forward_to()`, `resume_from_marker()` — chart-driven PM events, not manual state writes. + +--- + +## 3. Harmonization Plan + +### Principle: One Runtime, Three Roles + +``` +lazily::statechart::ThreadSafeStateChart + ├── b00t-iface: SurfaceChart — surface lifecycle (operational) + ├── kr0ki-core: ProcessChart — SysML-v2 process topology (structural) + └── ledgerr-mcp: HsmChart — tax-pipeline stage machine (domain) + +scxml crate (interchange / export only) + └── ufo-types: ooda_phases_to_statechart() — OODA lifecycle diagram export +``` + +Authoritative transition logic **always lives as Rust match arms / tables** in each crate. lazily drives the live executor against those shapes. The `scxml` crate exports them for diagram tools (statecharts.dev visual editor accepts SCXML). + +### Design invariants + +1. **No prompt construction in the statechart layer.** lazily entry/exit actions are plain `String` tokens — the caller matches them and decides what to do. +2. **Guards are caller-evaluated predicates.** `send(ctx, event, &guards)` receives a `HashMap`; the chart never imports business logic. +3. **One `ChartDef` per domain.** Build it once at startup via a builder function; clone `ThreadSafeContext` per instance. +4. **SCXML export is additive.** Any chart can add a `to_scxml()` that walks its `ChartDef` and emits an `scxml::model::Statechart`. This is not required for the runtime to function. + +### Phase 5 unblock path (issue #268) + +When ufo-types PR #13 merges (adds `ElementKind::DecisionNode`, `ForkNode`, `JoinNode` and corresponding `Relation::ConditionalSuccession`): + +1. Update workspace `ufo-types` rev pin to include PR #13 commit. +2. In `kr0ki-core/src/chart.rs`: extend `from_sysml` with `DecisionNode` → compound guard-branching state, `ForkNode`/`JoinNode` → parallel region boundary, `ConditionalSuccession` → guarded `on("branch.