diff --git a/Cargo.lock b/Cargo.lock index ed368c3..8fc2499 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1387,7 +1387,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2070,9 +2070,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.106" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" dependencies = [ "cfg-if", "futures-util", @@ -2827,9 +2827,9 @@ dependencies = [ [[package]] name = "pulldown-cmark" -version = "0.13.5" +version = "0.13.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "278e9268c93b9e498a2da46c34d90ba93a3255eb99009646398f34bc73ab36eb" +checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b" dependencies = [ "bitflags", "memchr", @@ -3180,7 +3180,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3245,7 +3245,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3858,7 +3858,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4072,9 +4072,9 @@ dependencies = [ [[package]] name = "tonic" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1fa9b1a51188f3266a823208078315811269d06d272c3a61be0b9a194194ff" +checksum = "e2cdfc673aabd30fe8fee92e65837b644c37c5d47f0f97ae6d5e0f1237d15e1b" dependencies = [ "async-trait", "axum", @@ -4102,9 +4102,9 @@ dependencies = [ [[package]] name = "tonic-build" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c07bd4e9f99e2a3ed8bebad596ae02a94e53745b68acc6900e22c3475c8bc44" +checksum = "ac6d916d206ed7bdda1778ddff9bda43303789c258877e7bc22fb44ee5c984b4" dependencies = [ "prettyplease 0.2.37", "proc-macro2", @@ -4114,9 +4114,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63c616ffaa648f7c5dd5657e97d9a285fcb44ff3b70da761d436f5a13ff7be6" +checksum = "eba464054e2226f182a34489b8b079bf4c6e3e7ab5fd7fc30e024f24aa92a484" dependencies = [ "bytes", "prost", @@ -4125,9 +4125,9 @@ dependencies = [ [[package]] name = "tonic-prost-build" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a05d8957fac6b3542999796cb4449d45da790751962f974a457e5e00c43d78c" +checksum = "2ea216ddc448e08e7839ab09e20e60b06b6a1c32745ccb212c0ebd161c199f1b" dependencies = [ "prettyplease 0.2.37", "proc-macro2", @@ -4141,9 +4141,9 @@ dependencies = [ [[package]] name = "tonic-types" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c7673cdf53817d382bad8ecb56507b4595a2adb07ef16bf0dd965ef48c1fced" +checksum = "ccbb6c31deb8e99dcdf87df19c5289b7838a600da74a8224baa671e52fe7c5ad" dependencies = [ "prost", "prost-types", @@ -4152,9 +4152,9 @@ dependencies = [ [[package]] name = "tonic-web" -version = "0.14.8" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67eda80540de6fe4952090ef5b8cb6a8b0cd559dc60818ebb57d23e14b10966e" +checksum = "323c7492987fadf7b27005a4597c977ffa51907a146621273bda37e231aff406" dependencies = [ "base64 0.22.1", "bytes", @@ -4434,9 +4434,12 @@ checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" [[package]] name = "utf8_iter" -version = "1.0.4" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0" +dependencies = [ + "utf_types", +] [[package]] name = "utf8parse" @@ -4444,6 +4447,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "utf_types" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6" + [[package]] name = "uuid" version = "1.29.0" @@ -4519,12 +4528,11 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" dependencies = [ "cfg-if", - "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -4532,9 +4540,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.79" +version = "0.4.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" dependencies = [ "js-sys", "tokio", @@ -4543,9 +4551,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -4553,9 +4561,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" dependencies = [ "bumpalo", "proc-macro2", @@ -4566,18 +4574,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.129" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.106" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" dependencies = [ "js-sys", "wasm-bindgen", @@ -4632,7 +4640,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/README.md b/README.md index 0224e5c..0b77ba4 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,9 @@ selected external-account references and durable owner-action requests. These records do not grant infrastructure or credential access. It also supports [software installations and workloads](docs/inventory.md#software-and-workloads) as current inventory, independently of discovery or execution adapters. +Devices can retain [partial equipment knowledge](docs/inventory.md#partly-identified-equipment), +including bounded quantities, model alternatives and supported exclusions, +then be refined without changing their identity. ## License diff --git a/crates/omloom-contracts/build.rs b/crates/omloom-contracts/build.rs index fd5893d..9f29d1e 100644 --- a/crates/omloom-contracts/build.rs +++ b/crates/omloom-contracts/build.rs @@ -57,10 +57,17 @@ fn main() -> Result<(), Box> { _ => return Err("add a preflight rule before using this protobuf type".into()), }; shapes.push_str(&format!( - "Shape {{ tag: {}, kind: {kind}, repeated: {}, group: {} }},\n", + "Shape {{ tag: {}, kind: {kind}, repeated: {}, group: {}, max_count: {} }},\n", field.number(), field.label() == Label::Repeated, - field.oneof_index.map_or(0, |index| index + 1) + field.oneof_index.map_or(0, |index| index + 1), + if message.name() == "EquipmentAlternatives" + && matches!(field.name(), "candidates" | "excluded") + { + 16 + } else { + 0 + } )); } shapes.push_str("],\n"); @@ -71,6 +78,11 @@ fn main() -> Result<(), Box> { .position(|(package, m)| *package == "omloom.v1" && m.name() == "Record") .ok_or("no Record")?; shapes.push_str(&format!("\nconst ROOT: usize = {record};\n")); + let equipment = messages + .iter() + .position(|(package, m)| *package == "omloom.v1" && m.name() == "EquipmentClaim") + .ok_or("no EquipmentClaim")?; + shapes.push_str(&format!("\nconst EQUIPMENT_ROOT: usize = {equipment};\n")); let request = messages .iter() .position(|(package, m)| *package == "omloom.workspace.v1" && m.name() == "Request") diff --git a/crates/omloom-contracts/src/equipment.rs b/crates/omloom-contracts/src/equipment.rs new file mode 100644 index 0000000..e2c2b9f --- /dev/null +++ b/crates/omloom-contracts/src/equipment.rs @@ -0,0 +1,273 @@ +//! Bounded current equipment claims shared by wire, storage and import admission. +use crate::{Error, wire::*}; +use alloc::vec::Vec; +use prost::Message; + +pub const FIELDS: &[&str] = &[ + "manufacturer", + "family", + "generation", + "model", + "variant", + "drive_bays", + "drive_bus", + "height", + "width", + "depth", + "weight", + "power_rating", +]; +pub const MAX_BYTES: usize = 8192; +/// Field semantics shared by intent, source evidence and reviewed imports. +pub fn validate_field(name: &str, value: &EquipmentClaim) -> Result<(), Error> { + validate(value)?; + if !FIELDS.contains(&name) { + return Err(Error::Value); + } + let unit = match name { + "drive_bays" => Some("count"), + "height" | "width" | "depth" => Some("mm"), + "weight" => Some("g"), + "power_rating" => Some("W"), + _ => None, + }; + match (&value.assertion, unit) { + (Some(equipment_claim::Assertion::Quantity(q)), Some(expected)) if q.unit == expected => { + Ok(()) + } + (Some(equipment_claim::Assertion::Alternatives(_)), None) => Ok(()), + (Some(equipment_claim::Assertion::Model(_)), None) + if matches!(name, "model" | "variant") => + { + Ok(()) + } + _ => Err(Error::Value), + } +} +/// Bounded decoded import with live accounting until the caller finishes use. +pub struct Input { + value: EquipmentClaim, + _reservation: crate::preflight::Reservation, +} +impl Input { + pub fn take(&mut self) -> EquipmentClaim { + core::mem::take(&mut self.value) + } +} +pub fn decode(bytes: &[u8], budget: &crate::MemoryBudget) -> Result { + let charge = crate::preflight::check_equipment(bytes)?; + let reservation = budget.reserve(charge)?; + let value = EquipmentClaim::decode(bytes).map_err(|_| Error::Wire)?; + validate(&value)?; + Ok(Input { + value, + _reservation: reservation, + }) +} +/// Check stored claim size and structural work without allocating a decoded value. +/// Storage owns its result budget; semantic field validation follows decoding. +pub fn check_encoded(bytes: &[u8]) -> Result<(), Error> { + crate::preflight::check_equipment(bytes).map(|_| ()) +} +/// Decode a bounded CSV cell, reserving its temporary bytes before allocation. +pub fn decode_hex(hex: &str, budget: &crate::MemoryBudget) -> Result { + if hex.len() > MAX_BYTES * 2 { + return Err(Error::Limit); + } + if !hex.len().is_multiple_of(2) { + return Err(Error::Wire); + } + let _encoded = budget.reserve(hex.len() / 2)?; + let mut bytes = Vec::with_capacity(hex.len() / 2); + fn digit(c: u8) -> Result { + match c { + b'0'..=b'9' => Ok(c - b'0'), + b'a'..=b'f' => Ok(c - b'a' + 10), + b'A'..=b'F' => Ok(c - b'A' + 10), + _ => Err(Error::Wire), + } + } + for pair in hex.as_bytes().as_chunks::<2>().0 { + bytes.push(digit(pair[0])? * 16 + digit(pair[1])?); + } + decode(&bytes, budget) +} +fn text(value: &str, max: usize, empty: bool) -> Result<(), Error> { + if value.len() > max + || (!empty && value.trim().is_empty()) + || value.chars().any(char::is_control) + { + return Err(Error::Value); + } + Ok(()) +} +fn reference(value: &EquipmentReference) -> Result<(), Error> { + text(&value.issuer, 160, false)?; + text(&value.key, 160, false)?; + text(&value.basis, 160, false) +} +/// Validate before canonicalization or allocating candidate indexes/copies. +pub fn validate(value: &EquipmentClaim) -> Result<(), Error> { + use equipment_claim::Assertion as A; + if value.encoded_len() > MAX_BYTES { + return Err(Error::Limit); + } + text(&value.basis, 512, false)?; + match value.assertion.as_ref().ok_or(Error::Missing)? { + A::Model(v) => reference(v), + A::Quantity(v) => { + if v.scale > 6 + || !matches!(v.unit.as_str(), "count" | "mm" | "g" | "W") + || (v.unit == "count" && v.scale != 0) + || (v.lower.is_none() && v.upper.is_none()) + || v.lower.is_some_and(|n| n < 0) + || v.upper.is_some_and(|n| n < 0) + || matches!((v.lower,v.upper), (Some(a),Some(b)) if a>b) + { + return Err(Error::Value); + } + Ok(()) + } + A::Alternatives(v) => { + if v.candidates.len() > 16 || v.excluded.len() > 16 { + return Err(Error::Limit); + } + if v.candidates.is_empty() && v.excluded.is_empty() { + return Err(Error::Missing); + } + text(&v.domain, 256, true)?; + if let Some(basis) = &v.calibration_basis { + text(basis, 256, false)?; + } + let probabilities = v.candidates.iter().any(|c| c.probability_ppm.is_some()); + let scores = v.candidates.iter().any(|c| c.score_ppm.is_some()); + if probabilities && scores { + return Err(Error::Value); + } + if probabilities || scores { + text(&v.domain, 256, false)?; + } + let mut sum = 0u64; + for (i, c) in v.candidates.iter().enumerate() { + text(&c.value, 160, false)?; + if let Some(r) = &c.reference { + reference(r)?; + } + if c.probability_ppm.is_some_and(|n| n > 1_000_000) + || c.score_ppm.is_some_and(|n| n > 1_000_000) + || (probabilities && c.probability_ppm.is_none()) + || (scores && c.score_ppm.is_none()) + { + return Err(Error::Value); + } + if v.candidates[..i] + .iter() + .any(|old| same_identity(&old.value, &old.reference, &c.value, &c.reference)) + { + return Err(Error::Duplicate); + } + sum += u64::from(c.probability_ppm.unwrap_or(0)); + } + if probabilities { + if v.calibration_basis.is_none() { + return Err(Error::Missing); + } + let residual = v.residual_ppm.ok_or(Error::Missing)?; + if sum + u64::from(residual) != 1_000_000 + || (!v.exhaustive && residual == 0) + || (v.exhaustive && residual != 0) + { + return Err(Error::Value); + } + } else if v.residual_ppm.is_some() || v.calibration_basis.is_some() { + return Err(Error::Value); + } + if v.exhaustive && v.candidates.is_empty() { + return Err(Error::Value); + } + for (i, e) in v.excluded.iter().enumerate() { + text(&e.value, 160, false)?; + text(&e.reason, 512, false)?; + text(&e.coverage, 256, false)?; + if let Some(r) = &e.reference { + reference(r)?; + } + if v.excluded[..i] + .iter() + .any(|old| same_identity(&old.value, &old.reference, &e.value, &e.reference)) + { + return Err(Error::Duplicate); + } + } + Ok(()) + } + } +} +fn same_identity( + left: &str, + left_ref: &Option, + right: &str, + right_ref: &Option, +) -> bool { + match (left_ref, right_ref) { + (Some(a), Some(b)) => a == b, + (None, None) => left == right, + _ => false, + } +} +fn identity(value: &Option) -> (&str, &str, &str) { + match value { + Some(r) => (&r.issuer, &r.key, &r.basis), + None => ("", "", ""), + } +} +/// One bounded semantic encoding for retries, changed-only storage and CSV. +/// Candidate/exclusion order is irrelevant; source and interpretation basis are not. +pub fn encode(value: &EquipmentClaim) -> Result, Error> { + validate(value)?; + let canonical = match &value.assertion { + Some(equipment_claim::Assertion::Quantity(q)) => { + q.scale == 0 + || q.lower.is_some_and(|n| n % 10 != 0) + || q.upper.is_some_and(|n| n % 10 != 0) + } + Some(equipment_claim::Assertion::Alternatives(v)) => { + v.candidates.is_sorted_by(|a, b| { + (&a.value, identity(&a.reference)) <= (&b.value, identity(&b.reference)) + }) && v.excluded.is_sorted_by(|a, b| { + (&a.value, identity(&a.reference)) <= (&b.value, identity(&b.reference)) + }) + } + _ => true, + }; + if canonical { + return Ok(value.encode_to_vec()); + } + let mut value = value.clone(); + if let Some(equipment_claim::Assertion::Quantity(q)) = &mut value.assertion { + while q.scale > 0 + && q.lower.is_none_or(|n| n % 10 == 0) + && q.upper.is_none_or(|n| n % 10 == 0) + { + if let Some(n) = &mut q.lower { + *n /= 10; + } + if let Some(n) = &mut q.upper { + *n /= 10; + } + q.scale -= 1; + } + } + if let Some(equipment_claim::Assertion::Alternatives(v)) = &mut value.assertion { + v.candidates.sort_unstable_by(|a, b| { + (&a.value, identity(&a.reference)).cmp(&(&b.value, identity(&b.reference))) + }); + v.excluded.sort_unstable_by(|a, b| { + (&a.value, identity(&a.reference)).cmp(&(&b.value, identity(&b.reference))) + }); + } + Ok(value.encode_to_vec()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/omloom-contracts/src/equipment/tests.rs b/crates/omloom-contracts/src/equipment/tests.rs new file mode 100644 index 0000000..9394ca1 --- /dev/null +++ b/crates/omloom-contracts/src/equipment/tests.rs @@ -0,0 +1,213 @@ +use super::*; +fn possible() -> EquipmentClaim { + EquipmentClaim { + basis: "Front label and visible bays".into(), + assertion: Some(equipment_claim::Assertion::Alternatives( + EquipmentAlternatives { + candidates: vec![ + EquipmentAlternative { + value: "DL160".into(), + probability_ppm: Some(600_000), + ..Default::default() + }, + EquipmentAlternative { + value: "DL120".into(), + probability_ppm: Some(200_000), + ..Default::default() + }, + ], + domain: "HPE rack models".into(), + calibration_basis: Some("asserted corpus/v1".into()), + residual_ppm: Some(200_000), + ..Default::default() + }, + )), + } +} +#[test] +fn incomplete_probabilities_keep_unknown_mass_and_are_order_independent() { + // A top-k list cannot become certainty; presentation order cannot cause writes. + let a = possible(); + let mut b = a.clone(); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut b.assertion else { + panic!() + }; + v.candidates.reverse(); + assert_eq!(encode(&a), encode(&b)); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut b.assertion else { + panic!() + }; + v.residual_ppm = Some(0); + assert_eq!(validate(&b), Err(Error::Value)); +} +#[test] +fn probability_score_and_calibration_are_distinct() { + // Mixed scales or an undocumented calibration cannot masquerade as probability. + let mut a = possible(); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut a.assertion else { + panic!() + }; + v.candidates[0].score_ppm = Some(900_000); + assert_eq!(validate(&a), Err(Error::Value)); + let mut a = possible(); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut a.assertion else { + panic!() + }; + v.calibration_basis = None; + assert_eq!(validate(&a), Err(Error::Missing)); +} +#[test] +fn qualified_reference_identity_does_not_depend_on_display_label() { + // Aliases of one exact model cannot consume two probability outcomes. + let mut a = possible(); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut a.assertion else { + panic!() + }; + let reference = EquipmentReference { + issuer: "private/catalog".into(), + key: "one-model".into(), + basis: "catalog/v1".into(), + }; + for candidate in &mut v.candidates { + candidate.reference = Some(reference.clone()); + } + assert_eq!(validate(&a), Err(Error::Duplicate)); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut a.assertion else { + panic!() + }; + v.candidates[1] + .reference + .as_mut() + .expect("qualified model") + .key = "other-model".into(); + v.excluded = vec![ + EquipmentExclusion { + value: "Model label".into(), + reference: Some(reference), + reason: "Rear pattern differs".into(), + coverage: "Rear inspected".into(), + }; + 2 + ]; + v.excluded[1].value = "Model alias".into(); + assert_eq!(validate(&a), Err(Error::Duplicate)); +} +#[test] +fn quantities_preserve_open_bounds_and_units() { + // Twelve visible bays establish a minimum, not twelve disks or an exact total. + let mut a = EquipmentClaim { + basis: "Twelve visible bays; rear concealed".into(), + assertion: Some(equipment_claim::Assertion::Quantity(QuantityRange { + lower: Some(12), + upper: None, + unit: "count".into(), + scale: 0, + })), + }; + assert_eq!(validate(&a), Ok(())); + let Some(equipment_claim::Assertion::Quantity(v)) = &mut a.assertion else { + panic!() + }; + v.upper = Some(10); + assert_eq!(validate(&a), Err(Error::Value)); +} +#[test] +fn equivalent_fixed_point_quantities_have_one_current_encoding() { + // Decimal spelling cannot create a replacement row for the same measurement. + let mut a = EquipmentClaim { + basis: "Measured height".into(), + assertion: Some(equipment_claim::Assertion::Quantity(QuantityRange { + lower: Some(120), + upper: None, + unit: "mm".into(), + scale: 1, + })), + }; + let encoded = encode(&a).expect("bounded quantity"); + let Some(equipment_claim::Assertion::Quantity(q)) = &mut a.assertion else { + panic!() + }; + q.lower = Some(12); + q.scale = 0; + assert_eq!(encoded, encode(&a).expect("same quantity")); +} +#[test] +fn exclusions_need_evidence_and_work_is_bounded() { + // Occlusion is not exclusion evidence; oversized sets are rejected before copying. + let mut a = possible(); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut a.assertion else { + panic!() + }; + v.excluded.push(EquipmentExclusion { + value: "DL120".into(), + ..Default::default() + }); + assert_eq!(validate(&a), Err(Error::Value)); + let mut a = possible(); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut a.assertion else { + panic!() + }; + v.candidates.resize(17, EquipmentAlternative::default()); + assert_eq!(encode(&a), Err(Error::Limit)); +} +#[test] +fn conflicting_supported_exclusion_remains_available_for_review() { + // A contradictory claim is preserved, rather than selecting the last arrival. + let mut a = possible(); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut a.assertion else { + panic!() + }; + v.excluded.push(EquipmentExclusion { + value: "DL160".into(), + reason: "Rear mounting pattern differs".into(), + coverage: "Rear inspected".into(), + reference: None, + }); + assert_eq!(validate(&a), Ok(())); + let input = decode( + &encode(&a).expect("bounded conflicting claims"), + &crate::MemoryBudget::new(1_048_576), + ) + .expect("preserved conflict"); + let Some(equipment_claim::Assertion::Alternatives(v)) = input.value.assertion else { + panic!() + }; + assert!(v.candidates.iter().any(|c| c.value == "DL160")); + assert_eq!( + v.excluded, + vec![EquipmentExclusion { + value: "DL160".into(), + reason: "Rear mounting pattern differs".into(), + coverage: "Rear inspected".into(), + reference: None + }] + ); +} + +#[test] +fn untrusted_candidate_count_is_refused_before_decode_allocation() { + // The wire walk enforces the semantic count, not just a post-allocation check. + let mut a = possible(); + let Some(equipment_claim::Assertion::Alternatives(v)) = &mut a.assertion else { + panic!() + }; + v.candidates.resize(17, EquipmentAlternative::default()); + let budget = crate::MemoryBudget::new(16 * 1_048_576); + assert!(matches!( + decode(&a.encode_to_vec(), &budget), + Err(Error::Limit) + )); + assert_eq!(budget.used(), 0); + let mut input = decode(&possible().encode_to_vec(), &budget).expect("bounded input"); + assert!(budget.used() > 0); + let _value = input.take(); + assert!(budget.used() > 0); + drop(input); + assert_eq!(budget.used(), 0); + assert!(matches!( + decode_hex("00", &crate::MemoryBudget::new(0)), + Err(Error::Limit) + )); + assert!(matches!(decode_hex("xz", &budget), Err(Error::Wire))); + assert_eq!(budget.used(), 0); +} diff --git a/crates/omloom-contracts/src/lib.rs b/crates/omloom-contracts/src/lib.rs index a93a777..8500911 100644 --- a/crates/omloom-contracts/src/lib.rs +++ b/crates/omloom-contracts/src/lib.rs @@ -15,6 +15,7 @@ //! assert_eq!(budget.used(), 0); //! ``` #![deny(unsafe_code)] +extern crate alloc; pub mod wire { include!(concat!(env!("OUT_DIR"), "/omloom.v1.rs")); @@ -22,6 +23,7 @@ pub mod wire { pub mod workspace { include!(concat!(env!("OUT_DIR"), "/omloom.workspace.v1.rs")); } +pub mod equipment; pub mod ids; mod preflight; mod validate; diff --git a/crates/omloom-contracts/src/preflight.rs b/crates/omloom-contracts/src/preflight.rs index 484847b..58502e5 100644 --- a/crates/omloom-contracts/src/preflight.rs +++ b/crates/omloom-contracts/src/preflight.rs @@ -88,6 +88,7 @@ struct Shape { kind: Kind, repeated: bool, group: u32, + max_count: u16, } include!(concat!(env!("OUT_DIR"), "/shapes.rs")); @@ -130,6 +131,7 @@ impl Work { } let mut seen = 0u64; let mut groups = 0u64; + let mut counts = [0u16; 64]; while !bytes.is_empty() { self.field()?; let key = varint(&mut bytes)?; @@ -139,6 +141,13 @@ impl Work { .find(|f| f.tag == tag) .ok_or(Error::UnknownField)?; let mask = 1u64.checked_shl(tag).ok_or(Error::Wire)?; + if shape.max_count != 0 { + let count = &mut counts[tag as usize]; + if *count == shape.max_count { + return Err(Error::Limit); + } + *count += 1; + } if !shape.repeated && seen & mask != 0 { return Err(Error::DuplicateField); } @@ -161,7 +170,12 @@ impl Work { let body = bytes.get(..len).ok_or(Error::Wire)?; bytes = &bytes[len..]; match kind { - Kind::Message(child) => self.message(body, child, depth + 1)?, + Kind::Message(child) => { + if child == EQUIPMENT_ROOT && body.len() > crate::equipment::MAX_BYTES { + return Err(Error::Limit); + } + self.message(body, child, depth + 1)?; + } Kind::Text => { std::str::from_utf8(body).map_err(|_| Error::Wire)?; } @@ -191,6 +205,17 @@ pub(crate) fn check(bytes: &[u8], limits: Limits) -> Result { pub(crate) fn check_workspace(bytes: &[u8], limits: Limits) -> Result { check_root(bytes, limits, WORKSPACE_ROOT) } +pub(crate) fn check_equipment(bytes: &[u8]) -> Result { + check_root( + bytes, + Limits { + bytes: crate::equipment::MAX_BYTES, + fields: 256, + depth: 8, + }, + EQUIPMENT_ROOT, + ) +} fn check_root(bytes: &[u8], limits: Limits, root: usize) -> Result { let limits = Limits { bytes: limits.bytes.min(MAX_BYTES), diff --git a/crates/omloom-contracts/src/validate.rs b/crates/omloom-contracts/src/validate.rs index ce2d476..93733ed 100644 --- a/crates/omloom-contracts/src/validate.rs +++ b/crates/omloom-contracts/src/validate.rs @@ -70,6 +70,7 @@ fn field(value: &Field) -> Result<(), Error> { token(&value.name)?; match need(&value.value)? { field::Value::Text(s) => text(s, 4096, true), + field::Value::Equipment(v) => crate::equipment::validate_field(&value.name, v), field::Value::Clear(v) if *v != NullValue::NullValue as i32 => Err(Error::Enumeration), _ => Ok(()), } diff --git a/crates/omloom-store/benches/retrieval.rs b/crates/omloom-store/benches/retrieval.rs index 3375e14..9801523 100644 --- a/crates/omloom-store/benches/retrieval.rs +++ b/crates/omloom-store/benches/retrieval.rs @@ -107,5 +107,44 @@ fn retrieval(c: &mut Criterion) { } group.finish(); } -criterion_group!(benches, retrieval); +fn equipment_claim(c: &mut Criterion) { + // Measure bounded semantic comparison inputs, including order normalization. + let mut group = c.benchmark_group("equipment_claim"); + group + .sample_size(10) + .warm_up_time(Duration::from_millis(100)) + .measurement_time(Duration::from_millis(250)); + for count in [1, 16] { + for reverse in [false, true] { + let mut alternatives = wire::EquipmentAlternatives { + candidates: (0..count) + .map(|i| wire::EquipmentAlternative { + value: format!("Candidate-{i:02}"), + ..Default::default() + }) + .collect(), + ..Default::default() + }; + if reverse { + alternatives.candidates.reverse(); + } + let claim = wire::EquipmentClaim { + basis: "Reviewed chassis label".into(), + assertion: Some(wire::equipment_claim::Assertion::Alternatives(alternatives)), + }; + group.bench_with_input( + BenchmarkId::new(if reverse { "reordered" } else { "canonical" }, count), + &claim, + |b, value| { + b.iter(|| { + omloom_contracts::equipment::encode(black_box(value)) + .expect("valid bounded claim") + }) + }, + ); + } + } + group.finish(); +} +criterion_group!(benches, retrieval, equipment_claim); criterion_main!(benches); diff --git a/crates/omloom-store/src/disk.rs b/crates/omloom-store/src/disk.rs index ae2a737..75126e6 100644 --- a/crates/omloom-store/src/disk.rs +++ b/crates/omloom-store/src/disk.rs @@ -22,7 +22,7 @@ use std::{ type Properties = BTreeMap; type Result = std::result::Result; const ROOT: &str = "installation"; -const SCHEMA_VERSION: u64 = 13; +const SCHEMA_VERSION: u64 = 14; const MAX_CHILDREN: usize = 2048; mod deltas; mod inventory; @@ -235,6 +235,13 @@ fn hash_field(hash: &mut Sha256, value: &wire::Field) -> Result<()> { } field::Value::Flag(value) => hash.update([3, u8::from(*value)]), field::Value::Clear(0) => hash.update([4]), + field::Value::Equipment(value) => { + hash.update([5]); + hash_part( + hash, + &omloom_contracts::equipment::encode(value).map_err(|_| Error::Invalid)?, + ); + } _ => return Err(Error::Invalid), } Ok(()) diff --git a/crates/omloom-store/src/disk/inventory.rs b/crates/omloom-store/src/disk/inventory.rs index d60c14d..a93aea5 100644 --- a/crates/omloom-store/src/disk/inventory.rs +++ b/crates/omloom-store/src/disk/inventory.rs @@ -1,4 +1,5 @@ use super::*; +use prost::Message; pub(super) fn field_props(field: &wire::Field) -> Result { let (kind, name, value) = match field.value.as_ref().ok_or(Error::Invalid)? { @@ -6,6 +7,11 @@ pub(super) fn field_props(field: &wire::Field) -> Result { field::Value::Integer(v) => (2, "vint", Value::Int(*v)), field::Value::Flag(v) => (3, "vflag", Value::Bool(*v)), field::Value::Clear(_) => (4, "vtext", Value::Null), + field::Value::Equipment(v) => ( + 5, + "payload", + Value::Binary(omloom_contracts::equipment::encode(v).map_err(|_| Error::Invalid)?), + ), }; Ok(props([ ("field", text(&field.name)), @@ -27,6 +33,28 @@ pub(super) fn read_field(p: &Properties) -> Result { .ok_or(Error::Corrupt)?, ), 4 => field::Value::Clear(0), + 5 => { + let bytes = p + .get("payload") + .and_then(|v| { + if let Value::Binary(b) = v { + Some(b.as_slice()) + } else { + None + } + }) + .ok_or(Error::Corrupt)?; + omloom_contracts::equipment::check_encoded(bytes).map_err(|_| Error::Corrupt)?; + let value = wire::EquipmentClaim::decode(bytes).map_err(|_| Error::Corrupt)?; + omloom_contracts::equipment::validate_field( + p.get("field") + .and_then(Value::as_str) + .ok_or(Error::Corrupt)?, + &value, + ) + .map_err(|_| Error::Corrupt)?; + field::Value::Equipment(value) + } _ => return Err(Error::Corrupt), }; Ok(wire::Field { @@ -50,11 +78,113 @@ pub(super) fn field_matches(field: &wire::Field, p: &Properties) -> bool { && p.get("vflag").and_then(Value::as_bool) == Some(*value) } Some(field::Value::Clear(0)) => p.get("vtype").and_then(Value::as_int) == Some(4), + Some(field::Value::Equipment(value)) => { + p.get("vtype").and_then(Value::as_int) == Some(5) + && omloom_contracts::equipment::encode(value) + .is_ok_and(|b| p.get("payload") == Some(&Value::Binary(b))) + } _ => false, } } type EvidenceWindow<'a> = (Option<&'a str>, Option<&'a str>, u64, u64); +pub(super) fn asset_projection( + target: AssetRef, + root: &Properties, + rows: &[Properties], + include: impl Fn(&str) -> bool, +) -> Result { + let mut output = wire::Asset { + target: Some(wire::AssetRef { + asset_id: target.asset.to_string(), + incarnation: target.incarnation.to_string(), + }), + kind: number(root, "kind")? as i32, + adoption: number(root, "adoption")? as i32, + revision: number(root, "revision")?, + ..Default::default() + }; + let by_key: BTreeMap<&str, &Properties> = rows + .iter() + .map(|p| { + Ok(( + p.get("key").and_then(Value::as_str).ok_or(Error::Corrupt)?, + p, + )) + }) + .collect::>()?; + for p in rows { + let Some(Value::String(layer)) = p.get("layer") else { + continue; + }; + if matches!(layer.as_str(), "intent" | "observed" | "imported") + && !include( + p.get("field") + .and_then(Value::as_str) + .ok_or(Error::Corrupt)?, + ) + { + continue; + } + match layer.as_str() { + "intent" => output.intended.push(wire::IntendedField { + field: Some(read_field(p)?), + authority_id: string(p, "authority")?, + }), + "mapping" => output.sources.push(wire::SourceIdentity { + connection_id: string(p, "connection")?, + object: Some(wire::SourceObject { + object_kind: string(p, "object_kind")?, + object_id: string(p, "object_id")?, + incarnation_evidence: Some(string(p, "lifetime")?), + }), + }), + "observed" | "imported" => { + let fresh_key = format!("{}/freshness", string(p, "key")?); + let fresh = by_key.get(fresh_key.as_str()).ok_or(Error::Corrupt)?; + let evidence = wire::Evidence { + source_id: string(p, "source")?, + source_revision: fresh + .get("source_revision") + .and_then(Value::as_str) + .map(str::to_owned), + observed_at_ms: number(fresh, "observed")?, + valid_until_ms: number(fresh, "valid")?, + fields: vec![read_field(p)?], + }; + let scan_id = fresh.get("scan").and_then(Value::as_str).map(str::to_owned); + let groups = if layer == "observed" { + &mut output.observed + } else { + &mut output.imported + }; + if let Some(group) = groups.iter_mut().find(|o| { + o.scan_id == scan_id + && o.evidence.as_ref().is_some_and(|e| { + e.source_id == evidence.source_id + && e.source_revision == evidence.source_revision + && e.observed_at_ms == evidence.observed_at_ms + && e.valid_until_ms == evidence.valid_until_ms + }) + }) { + group + .evidence + .as_mut() + .ok_or(Error::Corrupt)? + .fields + .extend(evidence.fields); + } else { + groups.push(wire::Observation { + scan_id, + evidence: Some(evidence), + }); + } + } + _ => {} + } + } + Ok(output) +} fn validate_evidence( asset: &wire::Asset, children: &[Properties], @@ -254,7 +384,10 @@ impl Store { }) .collect::>()?; validate_evidence(asset, &children, &by_key)?; - tx.validate_full_software_asset(target, asset)?; + tx.validate_full_asset_fields(target, asset)?; + let kind_changed = old + .as_ref() + .is_some_and(|p| p.get("kind") != Some(&Value::Int(asset.kind.into()))); let mut intent_changed = old.as_ref().is_none_or(|p| { p.get("kind") != Some(&Value::Int(asset.kind.into())) || p.get("adoption") != Some(&Value::Int(asset.adoption.into())) @@ -266,11 +399,14 @@ impl Store { let child = format!("{key}/intent/{}", field.name); keep.insert(child.clone()); let previous = by_key.get(child.as_str()).copied(); - if previous.is_some_and(|p| { - field_matches(field, p) - && p.get("authority").and_then(Value::as_str) + let same_value = previous.is_some_and(|p| field_matches(field, p)); + let same_claim = same_value + && previous.is_some_and(|p| { + p.get("authority").and_then(Value::as_str) == Some(intended.authority_id.as_str()) - }) { + }); + tx.validate_equipment_change(asset.kind, field, kind_changed || !same_claim)?; + if same_claim { continue; } let mut p = field_props(field)?; @@ -279,7 +415,7 @@ impl Store { field.name.as_str(), "runtime_object_id" | "runtime_lifetime" ) - && !previous.is_some_and(|p| field_matches(field, p)) + && !same_value { runtime_identity_changed = true; } @@ -343,7 +479,9 @@ impl Store { for field in &evidence.fields { let child = format!("{key}/{layer}/{}/{}", evidence.source_id, field.name); let previous = by_key.get(child.as_str()).copied(); - if !previous.is_some_and(|p| field_matches(field, p)) { + let changed = !previous.is_some_and(|p| field_matches(field, p)); + tx.validate_equipment_change(asset.kind, field, kind_changed || changed)?; + if changed { let mut value = field_props(field)?; value.extend(props([ ("layer", text(layer)), @@ -424,86 +562,7 @@ impl Store { let root = tx.asset(target)?; let key = asset_key(target); let rows = tx.children(&key)?; - let mut output = wire::Asset { - target: Some(wire::AssetRef { - asset_id: target.asset.to_string(), - incarnation: target.incarnation.to_string(), - }), - kind: number(&root, "kind")? as i32, - adoption: number(&root, "adoption")? as i32, - revision: number(&root, "revision")?, - ..Default::default() - }; - let by_key: BTreeMap<&str, &Properties> = rows - .iter() - .map(|p| { - Ok(( - p.get("key").and_then(Value::as_str).ok_or(Error::Corrupt)?, - p, - )) - }) - .collect::>()?; - for p in &rows { - let Some(Value::String(layer)) = p.get("layer") else { - continue; - }; - match layer.as_str() { - "intent" => output.intended.push(wire::IntendedField { - field: Some(read_field(p)?), - authority_id: string(p, "authority")?, - }), - "mapping" => output.sources.push(wire::SourceIdentity { - connection_id: string(p, "connection")?, - object: Some(wire::SourceObject { - object_kind: string(p, "object_kind")?, - object_id: string(p, "object_id")?, - incarnation_evidence: Some(string(p, "lifetime")?), - }), - }), - "observed" | "imported" => { - let fresh_key = format!("{}/freshness", string(p, "key")?); - let fresh = by_key.get(fresh_key.as_str()).ok_or(Error::Corrupt)?; - let evidence = wire::Evidence { - source_id: string(p, "source")?, - source_revision: fresh - .get("source_revision") - .and_then(Value::as_str) - .map(str::to_owned), - observed_at_ms: number(fresh, "observed")?, - valid_until_ms: number(fresh, "valid")?, - fields: vec![read_field(p)?], - }; - let scan_id = fresh.get("scan").and_then(Value::as_str).map(str::to_owned); - let groups = if layer == "observed" { - &mut output.observed - } else { - &mut output.imported - }; - if let Some(group) = groups.iter_mut().find(|o| { - o.scan_id == scan_id - && o.evidence.as_ref().is_some_and(|e| { - e.source_id == evidence.source_id - && e.source_revision == evidence.source_revision - && e.observed_at_ms == evidence.observed_at_ms - && e.valid_until_ms == evidence.valid_until_ms - }) - }) { - group - .evidence - .as_mut() - .ok_or(Error::Corrupt)? - .fields - .extend(evidence.fields); - } else { - groups.push(wire::Observation { - scan_id, - evidence: Some(evidence), - }); - } - } - _ => {} - } - } + let output = asset_projection(target, &root, &rows, |_| true)?; tx.commit()?; Ok(output) } diff --git a/crates/omloom-store/src/disk/tests.rs b/crates/omloom-store/src/disk/tests.rs index 55c5e51..61f476a 100644 --- a/crates/omloom-store/src/disk/tests.rs +++ b/crates/omloom-store/src/disk/tests.rs @@ -1,4 +1,5 @@ use super::*; +mod equipment; // Name projections may exhaust an old budget; explicit increases must unblock recovery without relaxing retention or restore fences. #[test] diff --git a/crates/omloom-store/src/disk/tests/equipment.rs b/crates/omloom-store/src/disk/tests/equipment.rs new file mode 100644 index 0000000..e69d446 --- /dev/null +++ b/crates/omloom-store/src/disk/tests/equipment.rs @@ -0,0 +1,74 @@ +use super::*; +use prost::Message; + +#[test] +fn format_thirteen_keeps_definite_fields_without_hypothesis_rows() +-> std::result::Result<(), Box> { + // The format marker advances once; definite values and their current rows survive. + let dir = tempfile::tempdir()?; + let policy = Policy { + classes: [crate::Budget { + rows: 2048, + bytes: 4 * 1_048_576, + }; CLASSES], + total_bytes: 32 * 1_048_576, + detail_age_ms: 1000, + review_age_ms: 500, + review_per_source: crate::Budget { + rows: 128, + bytes: 1_048_576, + }, + batch_rows: 128, + }; + let store = Store::open(dir.path(), policy.clone())?; + let mut value = wire::Asset { + target: Some(wire::AssetRef { + asset_id: omloom_contracts::ids::AssetId::generate().to_string(), + incarnation: omloom_contracts::ids::IncarnationId::generate().to_string(), + }), + kind: wire::AssetKind::Device as i32, + adoption: wire::Adoption::Adopted as i32, + revision: 1, + intended: vec![wire::IntendedField { + authority_id: omloom_contracts::ids::AuthorityId::generate().to_string(), + field: Some(wire::Field { + name: "name".into(), + value: Some(field::Value::Text("Definite old device".into())), + }), + }], + ..Default::default() + }; + value.intended.push(wire::IntendedField { + authority_id: omloom_contracts::ids::AuthorityId::generate().to_string(), + field: Some(wire::Field { + name: "model".into(), + value: Some(field::Value::Text("M".repeat(300))), + }), + }); + let identity = + omloom_contracts::ids::AssetRef::try_from(value.target.as_ref().ok_or(Error::Invalid)?)?; + let bytes = wire::Record { + body: Some(wire::record::Body::Asset(value)), + } + .encode_to_vec(); + let record = omloom_contracts::decode( + &bytes, + omloom_contracts::Limits::default(), + &omloom_contracts::MemoryBudget::new(16 * 1_048_576), + )?; + store.save_asset(&record, None, "fixture", 100)?; + let expected = store.asset(identity)?; + let mut tx = store.begin()?; + tx.root.insert("schema".into(), int(13)?); + tx.changed = true; + tx.commit()?; + let rows = store.usage()?.rows[Class::Inventory as usize]; + drop(store); + let store = Store::open(dir.path(), policy)?; + assert_eq!(store.asset(identity)?, expected); + assert_eq!(store.usage()?.rows[Class::Inventory as usize], rows); + let before = store.usage()?; + store.asset(identity)?; + assert_eq!(store.usage()?, before); + Ok(()) +} diff --git a/crates/omloom-store/src/disk/workspace/assets.rs b/crates/omloom-store/src/disk/workspace/assets.rs index d2187ad..a3cf799 100644 --- a/crates/omloom-store/src/disk/workspace/assets.rs +++ b/crates/omloom-store/src/disk/workspace/assets.rs @@ -994,12 +994,30 @@ impl Store { if fields.len() > MAX_PAGE * MAX_FIELDS { return Err(Error::Limit); } + let owners = rows + .iter() + .map(|row| { + let key = row + .get("key") + .and_then(Value::as_str) + .ok_or(Error::Corrupt)?; + Ok((key, (target(row)?, number(row, "kind")? as i32))) + }) + .collect::>>()?; let mut grouped: BTreeMap> = BTreeMap::new(); for f in &fields { let key = f .get("parent") .and_then(Value::as_str) .ok_or(Error::Corrupt)?; + let (owner, kind) = owners.get(key).ok_or(Error::Corrupt)?; + let name = f + .get("field") + .and_then(Value::as_str) + .ok_or(Error::Corrupt)?; + if !context.field(*owner, *kind, name, false) { + continue; + } grouped .entry(key.to_owned()) .or_default() @@ -1056,10 +1074,9 @@ impl Store { { values.extend(network::projection(settings, &["vrid".into()])?); } - if let Some(root) = rows.iter().find(|p| p.get("key") == Some(&text(&key))) { - let kind = number(root, "kind")? as i32; + if let Some((_, kind)) = owners.get(key.as_str()) { for value in values { - if context.field(owner, kind, &value.name, false) { + if context.field(owner, *kind, &value.name, false) { grouped.entry(key.clone()).or_default().push(value); } } @@ -1073,12 +1090,7 @@ impl Store { .get("key") .and_then(Value::as_str) .ok_or(Error::Corrupt)?; - let fields = grouped - .remove(key) - .unwrap_or_default() - .into_iter() - .filter(|f| context.field(target, kind, &f.name, false)) - .collect(); + let fields = grouped.remove(key).unwrap_or_default(); items.push(api::Item { target: Some(target_wire(target)), kind, @@ -1179,19 +1191,13 @@ impl Store { } return Ok(detail); } - let asset = self.asset(target)?; - if asset.adoption != wire::Adoption::Adopted as i32 { - return Err(Error::Missing); - } - if !context.readable(target, asset.kind) { - return Err(Error::Denied); - } - let mut fields: Vec<_> = asset - .intended - .into_iter() - .filter_map(|f| f.field) - .filter(|f| context.field(target, asset.kind, &f.name, false)) - .collect(); + let row = tx.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + let asset = + inventory::asset_projection(target, &row, &tx.children(&asset_key(target))?, |name| { + context.field(target, kind, name, false) + })?; + let mut fields: Vec<_> = asset.intended.into_iter().filter_map(|f| f.field).collect(); let mut relations = Vec::new(); let mut related_keys = BTreeSet::new(); let RelationPage { @@ -1262,22 +1268,6 @@ impl Store { fields, }); } - let redact = |observations: Vec| { - observations - .into_iter() - .filter_map(|mut o| { - let evidence = o.evidence.as_mut()?; - evidence - .fields - .retain(|f| context.field(target, asset.kind, &f.name, false)); - if evidence.fields.is_empty() { - None - } else { - Some(o) - } - }) - .collect() - }; let (participants, participants_complete, group) = self.workspace_network_fields(context, target, asset.kind, &mut fields)?; let current_addresses = if read.relations_cursor.is_none() { @@ -1293,8 +1283,8 @@ impl Store { fields, }), relations, - observed: redact(asset.observed), - imported: redact(asset.imported), + observed: asset.observed, + imported: asset.imported, participants, participants_complete, group, @@ -1947,6 +1937,7 @@ pub(super) fn validate_column(name: &str, definitions: &[api::Definition]) -> Re ] .contains(&name) || software::known_field(name) + || omloom_contracts::equipment::FIELDS.contains(&name) || definitions.iter().any(|d| d.id == name && !d.retired) { Ok(()) diff --git a/crates/omloom-store/src/disk/workspace/definitions.rs b/crates/omloom-store/src/disk/workspace/definitions.rs index a22103b..65a5de7 100644 --- a/crates/omloom-store/src/disk/workspace/definitions.rs +++ b/crates/omloom-store/src/disk/workspace/definitions.rs @@ -501,6 +501,34 @@ impl Tx<'_> { if value.name.len() > 100 { return Err(Error::Invalid); } + if omloom_contracts::equipment::FIELDS.contains(&value.name.as_str()) + && (kind == wire::AssetKind::Device as i32 + || !software::fields(wire::AssetKind::try_from(kind).map_err(|_| Error::Invalid)?) + .contains(&value.name.as_str())) + { + if kind != wire::AssetKind::Device as i32 { + return Err(Error::Invalid); + } + return match value.value.as_ref() { + Some(field::Value::Equipment(v)) => { + omloom_contracts::equipment::validate_field(&value.name, v) + .map_err(|_| Error::Invalid) + } + Some(field::Value::Text(s)) + if !matches!( + value.name.as_str(), + "drive_bays" | "height" | "width" | "depth" | "weight" | "power_rating" + ) && s.len() <= (if value.name == "model" { 512 } else { 160 }) + && !s.trim().is_empty() + && !s.chars().any(char::is_control) => + { + Ok(()) + } + Some(field::Value::Integer(n)) if *n >= 0 && value.name == "drive_bays" => Ok(()), + Some(field::Value::Clear(EXPLICIT_CLEAR)) => Ok(()), + _ => Err(Error::Invalid), + }; + } if software::known_field(&value.name) { return software::validate_field(kind, value); } diff --git a/crates/omloom-store/src/disk/workspace/exchange.rs b/crates/omloom-store/src/disk/workspace/exchange.rs index 92585ce..be60556 100644 --- a/crates/omloom-store/src/disk/workspace/exchange.rs +++ b/crates/omloom-store/src/disk/workspace/exchange.rs @@ -20,12 +20,58 @@ impl std::io::Write for CsvOutput { } } -fn csv_value(name: &str, value: &str, definitions: &[api::Definition]) -> Result { +fn csv_value( + name: &str, + value: &str, + definitions: &[api::Definition], + budget: &omloom_contracts::MemoryBudget, + inputs: &mut Vec, +) -> Result { let kind = definitions.iter().find(|d| d.id == name).map(|d| d.r#type); + if omloom_contracts::equipment::FIELDS.contains(&name) { + if let Some(body) = value.strip_prefix("equipment/claim/v1:") { + let mut input = + omloom_contracts::equipment::decode_hex(body, budget).map_err(|e| match e { + omloom_contracts::Error::Limit => Error::Limit, + _ => Error::Invalid, + })?; + let claim = input.take(); + inputs.push(input); + return Ok(wire::Field { + name: name.into(), + value: Some(field::Value::Equipment(claim)), + }); + } + if let Some(body) = value.strip_prefix("equipment/text/v1:") { + if body.len() > (if name == "model" { 1024 } else { 320 }) + || !body.len().is_multiple_of(2) + { + return Err(Error::Invalid); + } + let bytes = body + .as_bytes() + .as_chunks::<2>() + .0 + .iter() + .map(|c| { + std::str::from_utf8(c) + .ok() + .and_then(|s| u8::from_str_radix(s, 16).ok()) + .ok_or(Error::Invalid) + }) + .collect::>>()?; + return Ok(wire::Field { + name: name.into(), + value: Some(field::Value::Text( + String::from_utf8(bytes).map_err(|_| Error::Invalid)?, + )), + }); + } + } let value = if value == "" { field::Value::Clear(EXPLICIT_CLEAR) } else if kind == Some(api::FieldType::Integer as i32) - || matches!(name, "prefix_length" | "vlan_tag") + || matches!(name, "prefix_length" | "vlan_tag" | "drive_bays") { field::Value::Integer(value.parse().map_err(|_| Error::Invalid)?) } else if kind == Some(api::FieldType::Boolean as i32) { @@ -119,6 +165,9 @@ impl Store { return Err(Error::Invalid); } let definitions = self.workspace_definitions(context)?; + // Keep decoded-claim reservations for the entire preview construction. + let claim_budget = omloom_contracts::MemoryBudget::new(16 * 1_048_576); + let mut claim_inputs = Vec::new(); let mut reader = csv::ReaderBuilder::new() .flexible(false) .from_reader(request.csv.as_bytes()); @@ -201,22 +250,21 @@ impl Store { .map(|p| number(p, "revision")) .transpose()?; let write_target = target.unwrap_or_else(new_target); - let fields: Vec<_> = mapping - .iter() - .map(|(index, name)| { - diagnostic = - format!("column '{}'", headers.get(*index).ok_or(Error::Invalid)?); - let field = - csv_value(name, line.get(*index).ok_or(Error::Invalid)?, &definitions)?; - tx.require_changes( - context, - write_target, - kind, - std::slice::from_ref(&field), - )?; - Ok(field) - }) - .collect::>()?; + let mut fields = Vec::with_capacity(mapping.len()); + for (index, name) in &mapping { + diagnostic = format!("column '{}'", headers.get(*index).ok_or(Error::Invalid)?); + let cell = line.get(*index).ok_or(Error::Invalid)?; + if cell.is_empty() + && typed_kind == wire::AssetKind::Device + && omloom_contracts::equipment::FIELDS.contains(name) + { + continue; + } + let field = + csv_value(name, cell, &definitions, &claim_budget, &mut claim_inputs)?; + tx.require_changes(context, write_target, kind, std::slice::from_ref(&field))?; + fields.push(field); + } let change = api::Write { target: target.map(target_wire), expected, @@ -352,6 +400,17 @@ impl Store { ]; for name in &columns { let cell = match field_value(&item.fields, name) { + Some(field::Value::Equipment(v)) => format!( + "equipment/claim/v1:{}", + hex(&omloom_contracts::equipment::encode(v) + .map_err(|_| Error::Corrupt)?) + ), + Some(field::Value::Text(s)) + if omloom_contracts::equipment::FIELDS.contains(&name.as_str()) + && (s.starts_with("equipment/") || s == "") => + { + format!("equipment/text/v1:{}", hex(s.as_bytes())) + } None => String::new(), Some(field::Value::Clear(EXPLICIT_CLEAR)) => "".into(), // OWASP CSV Injection: protect literal text, including control diff --git a/crates/omloom-store/src/disk/workspace/software.rs b/crates/omloom-store/src/disk/workspace/software.rs index 00237a0..d6eab7e 100644 --- a/crates/omloom-store/src/disk/workspace/software.rs +++ b/crates/omloom-store/src/disk/workspace/software.rs @@ -1,4 +1,4 @@ -//! Current software facts use the ordinary field authority, CAS, CSV and audit +//! Current software and equipment facts use ordinary field authority, CAS, CSV and audit //! pipeline. Catalog references are relations, not copied installation snapshots. use super::*; use alloc::borrow::Cow; @@ -7,6 +7,12 @@ use wire::AssetKind as K; const RELEASE_IDENTITY: [&str; 4] = ["version", "edition", "architecture", "artifact_digest"]; const CONTAINER_IDENTITY: [&str; 2] = ["runtime_object_id", "runtime_lifetime"]; +fn is_equipment_field(kind: i32, value: &wire::Field) -> bool { + matches!(value.value, Some(field::Value::Equipment(_))) + || (kind == K::Device as i32 + && omloom_contracts::equipment::FIELDS.contains(&value.name.as_str())) +} + fn stored_reference(row: &Properties, asset: &str, incarnation: &str) -> Result { Ok(AssetRef { asset: row @@ -260,7 +266,21 @@ impl Tx<'_> { Ok(()) } - pub(in crate::disk) fn validate_full_software_asset( + pub(in crate::disk) fn validate_equipment_change( + &self, + kind: i32, + value: &wire::Field, + changed: bool, + ) -> Result<()> { + // Retained legacy claims survive unrelated saves; retyping changes their interpretation. + if changed && is_equipment_field(kind, value) { + self.validate_field(kind, value) + } else { + Ok(()) + } + } + + pub(in crate::disk) fn validate_full_asset_fields( &self, target: AssetRef, asset: &wire::Asset, @@ -270,9 +290,9 @@ impl Tx<'_> { .intended .iter() .filter_map(|v| v.field.as_ref()) - .filter(|v| known_field(&v.name)) + .filter(|v| known_field(&v.name) && !is_equipment_field(asset.kind, v)) { - validate_field(asset.kind, field)?; + self.validate_field(asset.kind, field)?; } self.validate_software_changes(target, kind, Changes::Full(&asset.intended)) } diff --git a/crates/omloom-store/src/disk/workspace/source_layout/tests.rs b/crates/omloom-store/src/disk/workspace/source_layout/tests.rs index 05726b2..9b7a3e9 100644 --- a/crates/omloom-store/src/disk/workspace/source_layout/tests.rs +++ b/crates/omloom-store/src/disk/workspace/source_layout/tests.rs @@ -254,7 +254,7 @@ fn identity_lookup_upgrade_preserves_restore_fence_and_evidence() -> TestResult let store = Store::open(dir.path(), policy())?; let tx = store.begin()?; assert_eq!(tx.root.get("restored"), Some(&Value::Bool(true))); - assert_eq!(number(&tx.root, "schema")?, 13); + assert_eq!(number(&tx.root, "schema")?, SCHEMA_VERSION); let row = tx.get("host-identity/legacy")?.ok_or(Error::Corrupt)?; assert_eq!(number(&row, "observed")?, 100); assert_eq!( diff --git a/crates/omloom-store/src/disk/workspace/typed_values/tests.rs b/crates/omloom-store/src/disk/workspace/typed_values/tests.rs index 98b3304..87a3d66 100644 --- a/crates/omloom-store/src/disk/workspace/typed_values/tests.rs +++ b/crates/omloom-store/src/disk/workspace/typed_values/tests.rs @@ -97,7 +97,10 @@ fn typed_values_upgrade_format_twelve_schema_and_backup() let restored_dir = tempfile::tempdir()?; let restored = Store::restore(restored_dir.path(), &backup, policy, backup.len() as u64)?; for store in [opened, restored] { - assert_eq!(number(&store.read(ROOT)?.ok_or("root")?, "schema")?, 13); + assert_eq!( + number(&store.read(ROOT)?.ok_or("root")?, "schema")?, + SCHEMA_VERSION + ); let legacy = store.read("legacy-route")?.ok_or("route")?; assert_eq!(string(&legacy, "contact")?, "Ask the owner"); assert_eq!( diff --git a/crates/omloom-store/src/disk/workspace/working.rs b/crates/omloom-store/src/disk/workspace/working.rs index 65ad630..b6bd85d 100644 --- a/crates/omloom-store/src/disk/workspace/working.rs +++ b/crates/omloom-store/src/disk/workspace/working.rs @@ -46,12 +46,21 @@ fn writable_fields( .filter(|name| context.field(target, kind as i32, name, true)) .map(|s| s.to_string()) .collect(); - fields.extend( - software::fields(kind) - .iter() - .filter(|name| context.field(target, kind as i32, name, true)) - .map(|name| name.to_string()), - ); + if kind == K::Device { + fields.extend( + omloom_contracts::equipment::FIELDS + .iter() + .filter(|name| context.field(target, kind as i32, name, true)) + .map(|s| s.to_string()), + ); + } + for name in software::fields(kind) { + if !fields.iter().any(|field| field == name) + && context.field(target, kind as i32, name, true) + { + fields.push(name.to_string()); + } + } fields.extend( definitions .iter() diff --git a/crates/omloom-store/tests/workspace.rs b/crates/omloom-store/tests/workspace.rs index 329eba4..1685766 100644 --- a/crates/omloom-store/tests/workspace.rs +++ b/crates/omloom-store/tests/workspace.rs @@ -14,6 +14,8 @@ mod contexts; mod correction; #[path = "workspace/definitions.rs"] mod definitions; +#[path = "workspace/equipment.rs"] +mod equipment; #[path = "workspace/exchange.rs"] mod exchange; #[path = "workspace/groups.rs"] diff --git a/crates/omloom-store/tests/workspace/equipment.rs b/crates/omloom-store/tests/workspace/equipment.rs new file mode 100644 index 0000000..6b734e7 --- /dev/null +++ b/crates/omloom-store/tests/workspace/equipment.rs @@ -0,0 +1,778 @@ +use super::*; +use api::{reply::Result as Reply, request::Command}; +use wire::{equipment_claim::Assertion as A, field::Value as V}; + +fn possibilities() -> wire::Field { + wire::Field { + name: "model".into(), + value: Some(V::Equipment(wire::EquipmentClaim { + basis: "Front label; rear not observed".into(), + assertion: Some(A::Alternatives(wire::EquipmentAlternatives { + candidates: vec![ + wire::EquipmentAlternative { + value: "DL160".into(), + probability_ppm: Some(600_000), + ..Default::default() + }, + wire::EquipmentAlternative { + value: "DL120".into(), + probability_ppm: Some(200_000), + ..Default::default() + }, + ], + domain: "HPE rack models".into(), + calibration_basis: Some("operator supplied corpus/v1".into()), + residual_ppm: Some(200_000), + ..Default::default() + })), + })), + } +} +fn write( + store: &Store, + actor: &Context, + target: &wire::AssetRef, + fields: Vec, +) -> Result { + call( + store, + actor, + Command::Write(api::Write { + target: Some(target.clone()), + expected: Some( + store + .asset(AssetRef::try_from(target).map_err(|_| Error::Invalid)?)? + .revision, + ), + kind: K::Device as i32, + changes: fields, + ..Default::default() + }), + ) +} +fn read(store: &Store, actor: &Context, target: &wire::AssetRef) -> Result { + match call( + store, + actor, + Command::Read(api::Read { + target: Some(target.clone()), + ..Default::default() + }), + )? { + Reply::Detail(d) => Ok(*d), + _ => Err(Error::Corrupt), + } +} + +#[test] +fn partial_equipment_refinement_keeps_identity_current_only_and_reopenable() -> TestResult { + // Different classification axes and twelve visible bays never instantiate disks. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Partly identified HPE")?; + let quantity = wire::Field { + name: "drive_bays".into(), + value: Some(V::Equipment(wire::EquipmentClaim { + basis: "12 front bays visible; total unknown".into(), + assertion: Some(A::Quantity(wire::QuantityRange { + lower: Some(12), + upper: None, + unit: "count".into(), + scale: 0, + })), + })), + }; + write( + &store, + &actor, + &target, + vec![ + text_field("manufacturer", "HPE"), + text_field("generation", "Gen10"), + quantity, + possibilities(), + ], + )?; + let before = store.usage()?; + let d = read(&store, &actor, &target)?; + let item = d.item.ok_or("item")?; + assert_eq!(item.target, Some(target.clone())); + assert!(item.fields.iter().any(|f| f.name == "model")); + let mut reordered = possibilities(); + let Some(V::Equipment(c)) = &mut reordered.value else { + panic!() + }; + let Some(A::Alternatives(a)) = &mut c.assertion else { + panic!() + }; + a.candidates.reverse(); + write(&store, &actor, &target, vec![reordered])?; + read(&store, &actor, &target)?; + assert_eq!(store.usage()?, before); + drop(store); + let store = Store::open(dir.path(), policy())?; + assert_eq!(read(&store, &actor, &target)?.item, Some(item)); + call( + &store, + &actor, + Command::Write(api::Write { + kind: K::Port as i32, + parent: Some(target.clone()), + changes: vec![text_field("name", "Retained physical port")], + ..Default::default() + }), + )?; + let relations = read(&store, &actor, &target)?.relations; + assert!(!relations.is_empty()); + let reference = wire::EquipmentReference { + issuer: "private/owner".into(), + key: "verified-model".into(), + basis: "measured/v1".into(), + }; + write( + &store, + &actor, + &target, + vec![wire::Field { + name: "model".into(), + value: Some(V::Equipment(wire::EquipmentClaim { + basis: "Reviewed chassis label".into(), + assertion: Some(A::Model(reference.clone())), + })), + }], + )?; + let d = read(&store, &actor, &target)?; + assert_eq!( + d.item.as_ref().and_then(|i| i.target.as_ref()), + Some(&target) + ); + assert_eq!(d.relations, relations); + assert!(d.item.ok_or("item")?.fields.iter().any(|f|f.name=="model" && matches!(&f.value,Some(V::Equipment(c)) if c.assertion==Some(A::Model(reference.clone()))))); + Ok(()) +} + +#[test] +fn equipment_denial_validation_and_stale_edits_are_atomic() -> TestResult { + // A visible device does not disclose its hidden alternatives or permit their edit. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Opaque device")?; + write(&store, &actor, &target, vec![possibilities()])?; + let mut limited = operator(); + limited.grants[0].read = Fields::Selected(vec!["name".into()]); + limited.grants[0].write = Fields::Selected(vec!["description".into()]); + let before = store.usage()?; + assert!( + !read(&store, &limited, &target)? + .item + .ok_or("item")? + .fields + .iter() + .any(|f| f.name == "model") + ); + assert!(matches!( + write(&store, &limited, &target, vec![possibilities()]), + Err(Error::Denied) + )); + let mut bad = possibilities(); + let Some(V::Equipment(c)) = &mut bad.value else { + panic!() + }; + let Some(A::Alternatives(a)) = &mut c.assertion else { + panic!() + }; + a.residual_ppm = Some(0); + assert!(matches!( + write(&store, &actor, &target, vec![bad]), + Err(Error::Invalid) + )); + let bad_unit = wire::Field { + name: "drive_bays".into(), + value: Some(V::Equipment(wire::EquipmentClaim { + basis: "Visible bays".into(), + assertion: Some(A::Quantity(wire::QuantityRange { + lower: Some(12), + unit: "mm".into(), + ..Default::default() + })), + })), + }; + assert!(matches!( + write(&store, &actor, &target, vec![bad_unit]), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + let old = store.asset(AssetRef::try_from(&target)?)?.revision; + write( + &store, + &actor, + &target, + vec![text_field("manufacturer", "HPE")], + )?; + assert!(matches!( + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(target), + expected: Some(old), + kind: K::Device as i32, + changes: vec![text_field("manufacturer", "Other")], + ..Default::default() + }) + ), + Err(Error::Conflict) + )); + Ok(()) +} + +#[test] +fn equipment_csv_roundtrip_preserves_uncertainty_and_scalar_values() -> TestResult { + // Machine CSV must not flatten a hypothesis into an authoritative model label. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "CSV equipment")?; + let mut alternatives = possibilities(); + let Some(V::Equipment(wire::EquipmentClaim { + assertion: Some(A::Alternatives(value)), + .. + })) = alternatives.value.as_mut() + else { + panic!("alternatives fixture") + }; + value.excluded.push(wire::EquipmentExclusion { + value: "Tower-only chassis".into(), + reason: "Rack ears and horizontal enclosure visible".into(), + coverage: "Entire front visible".into(), + ..Default::default() + }); + write( + &store, + &actor, + &target, + vec![ + text_field("manufacturer", "HPE"), + text_field("family", "equipment/claim/v1:literal label"), + alternatives, + ], + )?; + let q = api::Query { + roots: vec![target.clone()], + columns: vec![ + "name".into(), + "manufacturer".into(), + "family".into(), + "model".into(), + ], + limit: 25, + ..Default::default() + }; + let Reply::Download(download) = call( + &store, + &actor, + Command::Export(api::Export { + query: Some(q), + spreadsheet: false, + }), + )? + else { + panic!() + }; + assert!(download.csv.contains("equipment/claim/v1:")); + assert!(download.csv.contains("equipment/text/v1:")); + let mut reader = csv::Reader::from_reader(download.csv.as_bytes()); + let headers = reader.headers()?.clone(); + let row = reader.records().next().ok_or("row")??; + let mut writer = csv::Writer::from_writer(Vec::new()); + writer.write_record(["external_id", "name", "manufacturer", "family", "model"])?; + let mut values = vec!["copied-equipment".to_string()]; + for name in ["name", "manufacturer", "family", "model"] { + let i = headers.iter().position(|h| h == name).ok_or("column")?; + values.push(row[i].to_string()); + } + writer.write_record(values)?; + let csv = String::from_utf8(writer.into_inner()?)?; + let Reply::Preview(p) = call( + &store, + &actor, + Command::Import(api::Import { + source: "equipment-fixture".into(), + kind: K::Device as i32, + csv, + mappings: ["name", "manufacturer", "family", "model"] + .into_iter() + .map(|name| api::Mapping { + source: name.into(), + field: name.into(), + }) + .collect(), + ..Default::default() + }), + )? + else { + panic!() + }; + assert!(p.id.is_some(), "{p:?}"); + let Reply::Mutation(m) = call( + &store, + &actor, + Command::Import(api::Import { + preview: p.id, + ..Default::default() + }), + )? + else { + panic!() + }; + let original = read(&store, &actor, &target)?.item.ok_or("item")?; + let copied = read(&store, &actor, &m.targets[0])?.item.ok_or("item")?; + assert_eq!(original.fields, copied.fields); + assert_ne!(original.target, copied.target); + Ok(()) +} + +#[test] +fn equipment_csv_empty_cells_preserve_unknown_and_existing_values() -> TestResult { + // A mixed export contains blanks; they are absence, never zero or a clear. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let import = |csv: &str| -> Result, Error> { + let Reply::Preview(preview) = call( + &store, + &actor, + Command::Import(api::Import { + source: "mixed-equipment".into(), + kind: K::Device as i32, + csv: csv.into(), + mappings: ["name", "drive_bays", "manufacturer", "model", "height"] + .into_iter() + .map(|name| api::Mapping { + source: name.into(), + field: name.into(), + }) + .collect(), + ..Default::default() + }), + )? + else { + panic!("preview") + }; + assert!(preview.id.is_some(), "{preview:?}"); + let Reply::Mutation(result) = call( + &store, + &actor, + Command::Import(api::Import { + preview: preview.id, + ..Default::default() + }), + )? + else { + panic!("accepted import") + }; + Ok(result.targets) + }; + let targets = import( + "external_id,name,drive_bays,manufacturer,model,height\nknown,Known equipment,12,HPE,,\nunknown,Unknown equipment,,,,\n", + )?; + let items = targets + .iter() + .map(|t| read(&store, &actor, t).map(|d| d.item.expect("item"))) + .collect::, _>>()?; + let known = items + .iter() + .find(|i| i.fields.contains(&text_field("name", "Known equipment"))) + .ok_or("known equipment")?; + let unknown = items + .iter() + .find(|i| i.fields.contains(&text_field("name", "Unknown equipment"))) + .ok_or("unknown equipment")?; + assert_eq!( + unknown.fields, + vec![text_field("name", "Unknown equipment")] + ); + let count = wire::Field { + name: "drive_bays".into(), + value: Some(V::Integer(12)), + }; + assert!(known.fields.contains(&count)); + let target = known.target.as_ref().ok_or("target")?; + import("external_id,name,drive_bays,manufacturer,model,height\nknown,Renamed equipment,,,,\n")?; + assert!( + read(&store, &actor, target)? + .item + .ok_or("item")? + .fields + .contains(&count) + ); + import( + "external_id,name,drive_bays,manufacturer,model,height\nknown,Renamed equipment,,,,\n", + )?; + assert!( + read(&store, &actor, target)? + .item + .ok_or("item")? + .fields + .contains(&wire::Field { + name: "drive_bays".into(), + value: Some(V::Clear(0)) + }) + ); + Ok(()) +} + +#[test] +fn legacy_equipment_evidence_survives_full_save_and_freshness_refresh() -> TestResult { + // Older stores admitted scalar evidence that new equipment edits must reject. + use coordinode_core::graph::types::Value; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut record = asset(); + let identity = target(&record); + let observed = omloom_contracts::ids::SourceId::generate().to_string(); + let imported = omloom_contracts::ids::SourceId::generate().to_string(); + record.observed = vec![observation( + &observed, + 100, + vec![text_field("manufacturer", "HPE")], + )]; + record.imported = vec![observation( + &imported, + 100, + vec![text_field("manufacturer", "HPE")], + )]; + record.intended.push(wire::IntendedField { + field: Some(text_field("manufacturer", "HPE")), + authority_id: omloom_contracts::ids::AuthorityId::generate().to_string(), + }); + store.save_asset( + &admit(wire::record::Body::Asset(record)), + None, + "fixture", + 100, + )?; + let legacy = "x".repeat(200); + for suffix in [ + format!("observed/{observed}/manufacturer"), + format!("imported/{imported}/manufacturer"), + "intent/manufacturer".into(), + ] { + store.diagnostic_database().execute_cypher_shared( + "MATCH (n:OmRow {key:$key}) SET n.vtext=$value RETURN n.key", + Some(std::collections::HashMap::from([ + ( + "key".into(), + Value::String(format!("asset/{}/{suffix}", identity.asset)), + ), + ("value".into(), Value::String(legacy.clone())), + ])), + None, + None, + None, + )?; + } + let mut current = store.asset(identity)?; + current.intended[0].field = Some(text_field("name", "Renamed legacy device")); + store.save_asset( + &admit(wire::record::Body::Asset(current.clone())), + Some(current.revision), + "fixture", + 200, + )?; + current = store.asset(identity)?; + for group in current.observed.iter_mut().chain(&mut current.imported) { + let evidence = group.evidence.as_mut().ok_or("evidence")?; + evidence.observed_at_ms = 300; + evidence.valid_until_ms = 1300; + } + store.refresh_asset( + &admit(wire::record::Body::Asset(current.clone())), + current.revision, + store.usage()?.revision, + "fixture", + 300, + )?; + let mut after = store.asset(identity)?; + for group in after.observed.iter().chain(&after.imported) { + let evidence = group.evidence.as_ref().ok_or("evidence")?; + assert_eq!(evidence.fields, vec![text_field("manufacturer", &legacy)]); + assert_eq!(evidence.observed_at_ms, 300); + } + let before = store.usage()?; + after.observed[0] + .evidence + .as_mut() + .ok_or("evidence")? + .fields[0] = text_field("manufacturer", &"y".repeat(200)); + assert!(matches!( + store.save_asset( + &admit(wire::record::Body::Asset(after.clone())), + Some(after.revision), + "fixture", + 400 + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + assert!( + store + .asset(identity)? + .intended + .iter() + .any(|v| v.field == Some(text_field("manufacturer", &legacy))) + ); + let mut reassigned = store.asset(identity)?; + reassigned + .intended + .iter_mut() + .find(|v| v.field.as_ref().is_some_and(|f| f.name == "manufacturer")) + .ok_or("manufacturer")? + .authority_id = omloom_contracts::ids::AuthorityId::generate().to_string(); + assert!(matches!( + store.save_asset( + &admit(wire::record::Body::Asset(reassigned.clone())), + Some(reassigned.revision), + "fixture", + 400 + ), + Err(Error::Invalid) + )); + Ok(()) +} + +#[test] +fn equipment_sources_and_restore_keep_conflicts_independent() -> TestResult { + // Source disagreement cannot overwrite accepted intent or another source's window. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut record = asset(); + let identity = target(&record); + record.intended.push(wire::IntendedField { + field: Some(possibilities()), + authority_id: omloom_contracts::ids::AuthorityId::generate().to_string(), + }); + let first = omloom_contracts::ids::SourceId::generate().to_string(); + let second = omloom_contracts::ids::SourceId::generate().to_string(); + record.observed = vec![ + observation(&first, 100, vec![text_field("model", "DL120")]), + observation(&second, 200, vec![text_field("model", "DL160")]), + ]; + store + .save_asset( + &admit(wire::record::Body::Asset(record.clone())), + None, + "fixture", + 200, + ) + .map_err(|e| format!("saving source claims: {e}"))?; + let before = store.usage()?; + let d = read(&store, &operator(), record.target.as_ref().ok_or("target")?) + .map_err(|e| format!("reading source claims: {e}"))?; + assert_eq!(d.observed.len(), 2); + assert!(matches!( + d.item + .ok_or("item")? + .fields + .iter() + .find(|f| f.name == "model") + .and_then(|f| f.value.as_ref()), + Some(V::Equipment(_)) + )); + assert_eq!(store.usage()?, before); + record.observed = vec![observation( + &second, + 300, + vec![text_field("model", "Other")], + )]; + store + .refresh_asset( + &admit(wire::record::Body::Asset(record)), + 1, + before.revision, + "fixture", + 300, + ) + .map_err(|e| format!("refreshing source claims: {e}"))?; + let current = store.asset(identity)?; + assert_eq!(current.observed.len(), 2); + let mut restricted = operator(); + restricted.grants[0].read = Fields::Selected(vec!["name".into()]); + let reference = wire::AssetRef { + asset_id: identity.asset.to_string(), + incarnation: identity.incarnation.to_string(), + }; + let restricted_detail = read(&store, &restricted, &reference)?; + assert!(restricted_detail.observed.is_empty()); + restricted.grants[0].scope = Scope::Targets(Vec::new()); + assert!(matches!( + read(&store, &restricted, &reference), + Err(Error::Denied) + )); + assert!(current.observed.iter().any(|o| { + o.evidence + .as_ref() + .is_some_and(|e| e.source_id == first && e.observed_at_ms == 100) + })); + let mut image = Vec::new(); + store + .export(&mut image) + .map_err(|e| format!("exporting source claims: {e}"))?; + let restored_dir = tempfile::tempdir()?; + let restored = Store::restore(restored_dir.path(), &image, policy(), 16 * 1_048_576) + .map_err(|e| format!("restoring source claims: {e}"))?; + assert_eq!(restored.asset(identity)?, current); + Ok(()) +} + +#[test] +fn concurrent_equipment_refinement_admits_one_reviewed_revision() -> TestResult { + // Two editors cannot replace the same model basis from one accepted revision. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Concurrent device")?; + write(&store, &actor, &target, vec![possibilities()])?; + let expected = store.asset(AssetRef::try_from(&target)?)?.revision; + let results = std::thread::scope(|scope| { + let first = scope.spawn(|| { + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(target.clone()), + expected: Some(expected), + kind: K::Device as i32, + changes: vec![text_field("model", "DL120")], + ..Default::default() + }), + ) + }); + let second = scope.spawn(|| { + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(target.clone()), + expected: Some(expected), + kind: K::Device as i32, + changes: vec![text_field("model", "DL160")], + ..Default::default() + }), + ) + }); + [ + first.join().expect("first editor"), + second.join().expect("second editor"), + ] + }); + assert_eq!(results.iter().filter(|r| r.is_ok()).count(), 1); + assert_eq!( + results + .iter() + .filter(|r| matches!(r, Err(Error::Conflict))) + .count(), + 1 + ); + Ok(()) +} + +#[test] +fn hidden_equipment_payload_is_not_decoded_or_explained() -> TestResult { + // Corrupt source contents cannot turn a denied field into an observable error. + use coordinode_core::graph::types::Value; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut actor = operator(); + let target = create(&store, &actor, K::Device, "Visible device")?; + write(&store, &actor, &target, vec![possibilities()])?; + store.diagnostic_database().execute_cypher_shared( + "MATCH (n:OmRow {key:$key}) SET n.payload=$payload RETURN n.key", + Some(std::collections::HashMap::from([ + ( + "key".into(), + Value::String(format!("asset/{}/intent/model", target.asset_id)), + ), + ("payload".into(), Value::Binary(vec![255])), + ])), + None, + None, + None, + )?; + actor.grants[0].read = Fields::Selected(vec!["name".into()]); + let detail = read(&store, &actor, &target)?; + assert_eq!( + detail.item.ok_or("item")?.fields, + vec![text_field("name", "Visible device")] + ); + assert!(detail.observed.is_empty()); + let query = api::Query { + roots: vec![target.clone()], + columns: vec!["name".into(), "model".into()], + limit: 25, + ..Default::default() + }; + let Reply::Page(page) = call(&store, &actor, Command::Query(query.clone()))? else { + panic!("page") + }; + assert_eq!(page.items.len(), 1); + assert_eq!( + page.items[0].fields, + vec![text_field("name", "Visible device")] + ); + assert!(matches!( + call(&store, &operator(), Command::Query(query)), + Err(Error::Corrupt) + )); + assert!(matches!( + read(&store, &operator(), &target), + Err(Error::Corrupt) + )); + Ok(()) +} + +#[test] +fn equivalent_measurements_do_not_rewrite_the_field() -> TestResult { + // Fixed-point spelling is not a new measurement or a reason to write audit/history. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Measured chassis")?; + let mut field = wire::Field { + name: "height".into(), + value: Some(V::Equipment(wire::EquipmentClaim { + basis: "Independent ruler measurement".into(), + assertion: Some(A::Quantity(wire::QuantityRange { + lower: Some(120), + upper: None, + unit: "mm".into(), + scale: 1, + })), + })), + }; + write(&store, &actor, &target, vec![field.clone()])?; + let before = store.usage()?; + let key = format!("asset/{}/intent/height", target.asset_id); + let version = row_version(&store, &key)?; + let Some(V::Equipment(c)) = &mut field.value else { + panic!() + }; + let Some(A::Quantity(q)) = &mut c.assertion else { + panic!() + }; + q.lower = Some(12); + q.scale = 0; + write(&store, &actor, &target, vec![field.clone()])?; + assert_eq!(store.usage()?, before); + assert_eq!(row_version(&store, &key)?, version); + assert!( + read(&store, &actor, &target)? + .item + .ok_or("item")? + .fields + .contains(&field) + ); + Ok(()) +} diff --git a/docs/contracts.md b/docs/contracts.md index fc44e00..d7e9d0c 100644 --- a/docs/contracts.md +++ b/docs/contracts.md @@ -32,6 +32,9 @@ replaces its current claim; appending B's old sample to the layer is rejected. Empty current groups and split groups with identical provenance are rejected. Missing fields are absent claims; an explicit `clear` is a source's claim and does not erase another authority's value. Empty text and false are real values. +`Field.equipment` adds bounded current quantities, model references, alternatives +and supported exclusions under that same authority. It never establishes asset +identity or operational fitness. See [partial equipment](inventory.md#partly-identified-equipment). Provider revisions are opaque, and source timestamps cannot order other sources. `SourceObject` and `Evidence` have one shared validation contract in standalone records and scan rows. A `SourceIdentity` supplies a standalone object's connection; diff --git a/docs/inventory.md b/docs/inventory.md index 796130b..a0baf36 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -52,6 +52,48 @@ qualify authentication or deployment for other users. ## Work with inventory +### Partly identified equipment + +Create or edit a device and open **Equipment knowledge**. Manufacturer, family, +generation, model and chassis variant are independent axes. Record a known value, +quantity range, possible values or supported exclusions without selecting an exact +catalog model. A range with only a lower bound of twelve drive bays leaves the +total and drive bus unknown. It creates no disks or other installed components. + +Possible values can have uncalibrated normalized scores or attributed probabilities. +Probabilities require their hypothesis domain, calibration basis and explicit +unknown mass. An incomplete top-k list cannot sum to certainty. Calibration is +recorded source evidence, not a certification or permission to apply firmware or +declare physical fit. Exclusions require a reason and observed coverage; an unseen +connector is not proof of absence. Contradictory candidates/exclusions remain +visible for review, as do independent current sources. + +Refine the same device using a reviewed model reference with issuer, key and exact +basis. This retains device identity, relationships and assignments, and never +copies grants or creates the model's declared components. Ordinary known values +remain ordinary fields. Reads, reordered alternatives and identical edits do not +create history. Field permissions also apply to candidate detail and exports. + +The fields are `manufacturer`, `family`, `generation`, `model`, `variant`, +`drive_bays`, `drive_bus`, `height`, `width`, `depth`, `weight` and `power_rating`. +Quantities use fixed-point integer bounds with 0..6 decimal places: count for +bays (scale zero), mm for dimensions, g for weight and W for power. Unknown bounds +are absent, not zero. Each current claim is limited to 8192 encoded bytes, sixteen +candidates and sixteen exclusions. Numeric confidence uses integer millionths. + +The API uses the existing field write, read, source-evidence, bulk and import +paths. `Field.equipment` carries a quantity, model reference or alternative set. +It has no separate history stream, catalog synchronization or effect endpoint. +Machine CSV preserves claims as `equipment/claim/v1:` followed by hexadecimal +protobuf `EquipmentClaim` bytes. Ordinary known values remain readable text; +equipment text colliding with reserved markers exports as `equipment/text/v1:` +followed by UTF-8 hex. +Empty device equipment cells are omitted: unknown facts stay absent and existing +values are preserved. Use `` for an explicit clear; a zero bay count is a +supplied value. +Ordinary text and bay counts can also be entered directly in an import. Preview +and ordinary field validation apply before accepting imported claims. + ### Ownership and responsibility Open a resource's **Ownership and responsibility** card to inspect or edit its diff --git a/docs/storage.md b/docs/storage.md index 1a26f52..9e89825 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -10,7 +10,7 @@ synthetic contexts for test-owned data. These qualify application projection without claiming identity-provider enforcement. The backend pins CoordiNode Git revision -`1862962a3da20e5e57d7ab5126e69bd5817d7c61` and a consumer lockfile. This source +`1d0e00f714d669c092879fd3494a8b67f361556f` and a consumer lockfile. This source revision includes nullable Boolean, correlated lookup and edge-orientation corrections required by inventory queries. It is not a published embedded release. Its filesystem capability requires the `std` feature. Policy types also compile @@ -30,6 +30,22 @@ without rewriting the value or incrementing the accepted intent revision. Explicit clears remain claims. Different sources never overwrite each other. Repeated identical input and retrieval do not create application history. +Equipment uncertainty is one bounded typed value on its existing current field +row. Its actual consumer is scoped inspection/refinement, source reconciliation +and lossless export. The field envelope retains source, authority and revisions; +the claim retains only necessary basis, quantities, references and alternatives. +Canonical candidate/exclusion ordering and fixed-point scale normalization prevent +representation-only rewrites. The bounded +protobuf payload has no candidate index or second model inventory. Scalar known +values keep their existing representation. A changed assertion replaces only +that current field; deletion and required audit detail follow existing lifecycle +and retention. Physical reclamation remains subject to the backend's separately +qualified guarantees. + +Format 14 adds this value representation. Opening format 13 preserves definite +inventory without creating hypothesis rows and advances the format marker +transactionally. Older binaries reject format 14 rather than misread new values. + Resource responsibility adds at most five current assignment rows per asset. Only explicit assignments have an issuer/subject lookup digest, used by the owner-filter consumer; the full reference is checked before a match. Inherited @@ -108,8 +124,8 @@ envelope, but are **application accounting**, not total filesystem allocation. Pinned detail and unresolved work count against their budgets. Admission fails atomically when it cannot fit; it does not evict safety state. -This unreleased storage format uses schema version 13. Supported versions 2 -through 12 upgrade in bounded restartable batches, including asset name/sort +This unreleased storage format uses schema version 14. Supported versions 2 +through 13 upgrade in bounded restartable batches, including asset name/sort projections from accepted intent, normalized IP ordering and source-scoped current observation facts with separate freshness and accounting. No inventory history is added. The upgrade keeps restore fences diff --git a/proto/omloom/v1/contracts.proto b/proto/omloom/v1/contracts.proto index d338f53..2b1d8ce 100644 --- a/proto/omloom/v1/contracts.proto +++ b/proto/omloom/v1/contracts.proto @@ -104,6 +104,80 @@ message Field { bool flag = 4; // Source/user explicit clear/unknown claim; never clears another authority. NullValue clear = 5; + // Current equipment assertion, not identity adoption or operational proof. + // At most 8192 encoded bytes, subject to the same field/source authority as scalars. + EquipmentClaim equipment = 6; + } +} + +// Exact external/private reference supplied by the operator or source. No lookup, +// grant or catalog acquisition is implied by accepting this reference. +message EquipmentReference { + // Source namespace, max 160 bytes; private definitions identify their owner. + string issuer = 1; + // Opaque model/variant key within that namespace, max 160 bytes. + string key = 2; + // Exact interpretation revision, max 160 bytes; never implicit latest. + string basis = 3; +} +message QuantityRange { + // Observed lower bound in fixed-point units; absent means unknown. + optional sint64 lower = 1; + // Observed upper bound in the same units; absent does not mean zero. + optional sint64 upper = 2; + // Explicit unit: count, mm, g or W. No conversion is inferred from a model. + string unit = 3; + // Decimal places of the integer bounds, 0..6; count requires zero. This is + // numeric scale, not measurement precision; storage removes shared trailing zeros. + uint32 scale = 4; +} +message EquipmentAlternative { + // Candidate label for one classification axis, max 160 bytes. + string value = 1; + // Optional exact source-qualified model reference; label equality is not identity. + optional EquipmentReference reference = 2; + // Attributed probability in millionths, 0..1000000, requiring a domain and + // calibration basis on the containing set. Never grants operational authority. + optional uint32 probability_ppm = 3; + // Uncalibrated normalized score in millionths, not a probability. Mutually + // exclusive with probability_ppm across this entire candidate set. + optional uint32 score_ppm = 4; +} +message EquipmentExclusion { + // Excluded value on the current axis, max 160 bytes. + string value = 1; + // Optional exact source-qualified reference to the excluded model. + optional EquipmentReference reference = 2; + // Discriminating evidence, max 512 bytes; low score alone is insufficient. + string reason = 3; + // Observed coverage supporting this exclusion, max 256 bytes. + string coverage = 4; +} +message EquipmentAlternatives { + // At most 16 current candidates. Order carries no preference or identity. + repeated EquipmentAlternative candidates = 1; + // At most 16 current supported exclusions, independently of candidate rank. + repeated EquipmentExclusion excluded = 2; + // Explicit claim that the domain is exhaustive; top-k defaults to incomplete. + bool exhaustive = 3; + // Hypothesis domain, max 256 bytes, required for numeric confidence. + string domain = 4; + // Attributed producer/calibration revision, max 256 bytes, required for + // probabilities. Recording it does not certify the producer's calibration. + optional string calibration_basis = 5; + // Other/unknown probability, required for probability sets. Candidate mass + // plus this value equals 1000000; incomplete sets retain positive unknown mass. + optional uint32 residual_ppm = 6; +} +message EquipmentClaim { + // Relevant observation or reviewed manual basis, max 512 bytes. Source identity, + // time and authority stay with the enclosing existing field/evidence record. + string basis = 1; + // One current typed assertion. Known text/integer fields retain scalar encoding. + oneof assertion { + QuantityRange quantity = 2; + EquipmentReference model = 3; + EquipmentAlternatives alternatives = 4; } } diff --git a/web/app/App.vue b/web/app/App.vue index 2df7520..d46f95a 100644 --- a/web/app/App.vue +++ b/web/app/App.vue @@ -9,6 +9,7 @@ import VmPlacement from './VmPlacement.vue'; import ResponsibilityPanel from './ResponsibilityPanel.vue'; import AccountPanel from './AccountPanel.vue'; import { softwareFields } from './software-fields'; +import { equipmentFields } from './equipment-fields'; import { ref, computed, watch, onMounted, nextTick } from 'vue'; import { useRoute, useRouter } from 'vue-router'; import type { LocationQuery } from 'vue-router'; @@ -31,7 +32,7 @@ const editor = ref<'' | 'asset' | 'schema' | 'import' | 'host-import' | 'view' | const initialKind = computed(() => query.value.kinds.length===1 ? query.value.kinds[0] as K : ({ [model.View.OVERVIEW]: K.DEVICE, [model.View.NETWORK]: K.ADDRESS_REFERENCE, [model.View.HARDWARE]: K.DEVICE, [model.View.VIRTUALIZATION]: K.VIRTUAL_MACHINE, [model.View.SERVICES]: K.SERVICE })[query.value.view]); const kindOptions = kinds.map(k => ({ id: String(k), name: label(k) })); const columnOptions = computed(() => { - const options = [{ id: 'name', name: 'Name' }, { id: 'dns_name', name: 'DNS name' }, { id: 'description', name: 'Description' }, { id: 'serial', name: 'Serial number' }, ...softwareFields(), ...definitions.value.filter(d => !d.retired)]; + const options = [{ id: 'name', name: 'Name' }, { id: 'dns_name', name: 'DNS name' }, { id: 'description', name: 'Description' }, { id: 'serial', name: 'Serial number' }, ...equipmentFields.map(id=>({id,name:id.replaceAll('_',' ')})), ...softwareFields().filter(f=>!equipmentFields.includes(f.id)), ...definitions.value.filter(d => !d.retired)]; for (const id of query.value.columns) if (!options.some(o => o.id === id)) options.push({ id, name: `${id} (unavailable)` }); return options; }); diff --git a/web/app/AssetEditor.vue b/web/app/AssetEditor.vue index f166018..a643c97 100644 --- a/web/app/AssetEditor.vue +++ b/web/app/AssetEditor.vue @@ -4,6 +4,8 @@ import Dialog from './Dialog.vue'; import PlacementPicker from './PlacementSearch.vue'; import InitialAddresses from './InitialAddresses.vue'; import ObserveHypervisor from './ObserveHypervisor.vue'; +import EquipmentField from './EquipmentField.vue'; +import { equipmentFields as equipmentNames } from './equipment-fields'; import { softwareFields, softwareChoiceLabel } from './software-fields'; import { call, typedField, input, label, key, model, AssetKind, type AssetRef } from './api'; @@ -17,6 +19,12 @@ const placement = ref(); const placementReady = ref(true); const pla const placementActions = ref(); const initialAddresses = ref([model.InitialAddress.create()]); const values = reactive>({}); +const equipmentChanges = reactive>({}); +const equipmentValidity = reactive>({}); +const selectedEquipment = ref(props.item?.fields.filter(f=>equipmentNames.includes(f.name)).map(f=>f.name) ?? []); +const equipmentToAdd = ref(''); +const equipmentFields = computed(()=>kind.value===AssetKind.DEVICE ? (props.item ? actions.value?.fields : choice.value?.fields)?.filter(f=>equipmentNames.includes(f)) ?? [] : []); +function addEquipment(){if(equipmentFields.value.includes(equipmentToAdd.value)&&!selectedEquipment.value.includes(equipmentToAdd.value))selectedEquipment.value.push(equipmentToAdd.value);equipmentToAdd.value='';} const modes = reactive>({}); const error = ref(''); const busy = ref(false); const preparing = ref(true); const more = ref(false); type EditorField = { id: string; name: string; type: model.FieldType; choices: string[]; advanced: boolean }; @@ -68,7 +76,7 @@ const fields = computed(() => { ]; const custom = props.definitions.filter(d => !d.retired && kind.value !== undefined && d.kinds.includes(kind.value)) .map(d => ({ id: d.id, name: d.name, type: d.type, choices: d.choices, advanced: false })); - return [...builtins, ...softwareFields(allowed), ...custom].filter(f => allowed.includes(f.id)); + return [...builtins, ...softwareFields(allowed), ...custom].filter(f => allowed.includes(f.id) && !equipmentFields.value.includes(f.id)); }); watch(fields, available => { for (const f of available) { @@ -125,7 +133,9 @@ async function save() { busy.value = true; error.value = ''; try { if (kind.value === undefined || !actions.value || (!props.item && !choice.value)) throw Error('Choose a resource and its actual parent'); + if(equipmentFields.value.some(name=>equipmentValidity[name]===false))throw Error('Correct the equipment input before saving.'); const changes = fields.value.filter(f => modes[f.id] !== 'preserve').map(f => typedField(f.id, f.type, values[f.id] ?? '', modes[f.id] === 'clear')); + for(const name of equipmentFields.value){const change=equipmentChanges[name];if(change)changes.push(change);} if (placementKind.value && !placementReady.value) throw Error('Choose a listed placement or clear the search to leave it unknown'); if (props.item && !changes.length) { emit('saved'); return; } const working = model.WorkingContext.create({ ...props.working, focus: props.working.focus && placement.value?.target ? placement.value.target : props.working.focus, expected: placementActions.value?.revision ?? actions.value.revision }); @@ -172,6 +182,10 @@ async function save() { +
Equipment knowledge

Record only what is known. Manufacturer, generation and model are separate facts. Refinement keeps this device's identity.

+ + +