From bf639b96620518d73f07a22580194ef2a2a36823 Mon Sep 17 00:00:00 2001 From: Dmitry Prudnikov Date: Sun, 1 Nov 2026 20:02:57 +0200 Subject: [PATCH 01/25] feat: plan racks, cabling and replacements Add persistent physical definitions, rack layout, cable ends and scoped projections. Use shared requirements and plans for atomic reservations, partial installation and reviewed component replacements, with bounded feedback and recovery evidence. Provide desktop and phone workflows over the same validated inventory. Closes #38 --- README.md | 3 + crates/omloom-contracts/build.rs | 28 + crates/omloom-contracts/src/equipment.rs | 53 +- .../omloom-contracts/src/equipment/tests.rs | 24 + crates/omloom-contracts/src/ids.rs | 1 + crates/omloom-contracts/tests/workspace.rs | 48 + crates/omloom-store/src/disk.rs | 29 +- crates/omloom-store/src/disk/inventory.rs | 204 +- crates/omloom-store/src/disk/workspace.rs | 2 + .../omloom-store/src/disk/workspace/assets.rs | 99 +- .../src/disk/workspace/definitions.rs | 16 +- .../src/disk/workspace/physical.rs | 817 ++ .../src/disk/workspace/physical/admission.rs | 540 ++ .../disk/workspace/physical/availability.rs | 282 + .../src/disk/workspace/physical/cables.rs | 426 + .../src/disk/workspace/physical/clearance.rs | 84 + .../src/disk/workspace/physical/completion.rs | 394 + .../src/disk/workspace/physical/connector.rs | 40 + .../src/disk/workspace/physical/coverage.rs | 49 + .../src/disk/workspace/physical/current.rs | 424 + .../src/disk/workspace/physical/equipment.rs | 251 + .../src/disk/workspace/physical/flow.rs | 189 + .../src/disk/workspace/physical/lifecycle.rs | 229 + .../src/disk/workspace/physical/materials.rs | 486 ++ .../src/disk/workspace/physical/positions.rs | 309 + .../src/disk/workspace/physical/rack_grid.rs | 49 + .../disk/workspace/physical/replacements.rs | 569 ++ .../disk/workspace/physical/reservations.rs | 118 + .../src/disk/workspace/physical/reviews.rs | 366 + .../src/disk/workspace/physical/wiring.rs | 589 ++ .../src/disk/workspace/requirements.rs | 199 +- .../disk/workspace/requirements/binding.rs | 52 + .../src/disk/workspace/requirements/power.rs | 240 + .../src/disk/workspace/working.rs | 15 +- crates/omloom-store/src/lib.rs | 2 + crates/omloom-store/src/physical.rs | 201 + crates/omloom-store/src/physical/power.rs | 117 + .../omloom-store/src/physical/power/tests.rs | 133 + crates/omloom-store/src/physical/tests.rs | 149 + crates/omloom-store/src/plans.rs | 159 + crates/omloom-store/src/plans/tests.rs | 154 + crates/omloom-store/src/requirements.rs | 1 + crates/omloom-store/src/requirements/tests.rs | 52 + crates/omloom-store/tests/workspace.rs | 2 + .../omloom-store/tests/workspace/physical.rs | 2225 ++++++ .../tests/workspace/physical/admission.rs | 1021 +++ .../tests/workspace/physical/availability.rs | 432 + .../tests/workspace/physical/cables.rs | 727 ++ .../tests/workspace/physical/clearance.rs | 104 + .../workspace/physical/endpoint_mappings.rs | 362 + .../tests/workspace/physical/materials.rs | 863 ++ .../tests/workspace/physical/mounting.rs | 460 ++ .../tests/workspace/physical/occupancy.rs | 241 + .../tests/workspace/physical/power.rs | 382 + .../tests/workspace/physical/rack_grid.rs | 122 + .../tests/workspace/physical/reviews.rs | 466 ++ docs/inventory.md | 147 + docs/storage.md | 48 +- package.json | 2 +- proto/omloom/v1/contracts.proto | 4 + proto/omloom/v1/workspace.proto | 784 ++ rust-toolchain.toml | 2 + web/app/App.vue | 18 +- web/app/AssetEditor.vue | 2 +- web/app/AvailabilityAccess.vue | 20 + web/app/BindingCheck.vue | 22 + web/app/CableEnds.vue | 53 + web/app/EndpointMappings.vue | 64 + web/app/EquipmentField.vue | 4 +- web/app/MatingEditor.vue | 19 + web/app/PhysicalFacts.vue | 180 + web/app/PhysicalMaterials.vue | 123 + web/app/PhysicalOffers.vue | 46 + web/app/PhysicalPlanner.vue | 490 ++ web/app/PhysicalPositions.vue | 49 + web/app/PlacementSearch.vue | 22 +- web/app/PlanStepControls.vue | 44 + web/app/PowerPathsPanel.vue | 40 + web/app/ReplacementReview.vue | 30 + web/app/RequirementsPanel.vue | 15 +- web/app/api.ts | 5 + web/app/physical-elevation.ts | 27 + web/app/physical-units.ts | 31 + web/app/rack-grid.ts | 38 + web/browser/inventory.spec.mjs | 1136 ++- web/src/generated/omloom/v1/contracts.ts | 14 +- web/src/generated/omloom/v1/workspace.ts | 6915 ++++++++++++----- web/tests/physical-elevation.test.mjs | 37 + web/tests/physical-units.test.mjs | 52 + web/tests/rack-grid.test.mjs | 38 + 90 files changed, 24032 insertions(+), 2088 deletions(-) create mode 100644 crates/omloom-store/src/disk/workspace/physical.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/admission.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/availability.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/cables.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/clearance.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/completion.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/connector.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/coverage.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/current.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/equipment.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/flow.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/lifecycle.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/materials.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/positions.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/rack_grid.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/replacements.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/reservations.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/reviews.rs create mode 100644 crates/omloom-store/src/disk/workspace/physical/wiring.rs create mode 100644 crates/omloom-store/src/disk/workspace/requirements/binding.rs create mode 100644 crates/omloom-store/src/disk/workspace/requirements/power.rs create mode 100644 crates/omloom-store/src/physical.rs create mode 100644 crates/omloom-store/src/physical/power.rs create mode 100644 crates/omloom-store/src/physical/power/tests.rs create mode 100644 crates/omloom-store/src/physical/tests.rs create mode 100644 crates/omloom-store/src/plans.rs create mode 100644 crates/omloom-store/src/plans/tests.rs create mode 100644 crates/omloom-store/tests/workspace/physical.rs create mode 100644 crates/omloom-store/tests/workspace/physical/admission.rs create mode 100644 crates/omloom-store/tests/workspace/physical/availability.rs create mode 100644 crates/omloom-store/tests/workspace/physical/cables.rs create mode 100644 crates/omloom-store/tests/workspace/physical/clearance.rs create mode 100644 crates/omloom-store/tests/workspace/physical/endpoint_mappings.rs create mode 100644 crates/omloom-store/tests/workspace/physical/materials.rs create mode 100644 crates/omloom-store/tests/workspace/physical/mounting.rs create mode 100644 crates/omloom-store/tests/workspace/physical/occupancy.rs create mode 100644 crates/omloom-store/tests/workspace/physical/power.rs create mode 100644 crates/omloom-store/tests/workspace/physical/rack_grid.rs create mode 100644 crates/omloom-store/tests/workspace/physical/reviews.rs create mode 100644 rust-toolchain.toml create mode 100644 web/app/AvailabilityAccess.vue create mode 100644 web/app/BindingCheck.vue create mode 100644 web/app/CableEnds.vue create mode 100644 web/app/EndpointMappings.vue create mode 100644 web/app/MatingEditor.vue create mode 100644 web/app/PhysicalFacts.vue create mode 100644 web/app/PhysicalMaterials.vue create mode 100644 web/app/PhysicalOffers.vue create mode 100644 web/app/PhysicalPlanner.vue create mode 100644 web/app/PhysicalPositions.vue create mode 100644 web/app/PlanStepControls.vue create mode 100644 web/app/PowerPathsPanel.vue create mode 100644 web/app/ReplacementReview.vue create mode 100644 web/app/physical-elevation.ts create mode 100644 web/app/physical-units.ts create mode 100644 web/app/rack-grid.ts create mode 100644 web/tests/physical-elevation.test.mjs create mode 100644 web/tests/physical-units.test.mjs create mode 100644 web/tests/rack-grid.test.mjs diff --git a/README.md b/README.md index 7752e5f..0f690ec 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,9 @@ including bounded quantities, model alternatives and supported exclusions, then be refined without changing their identity. The workspace can [check recorded requirements](docs/inventory.md#recorded-requirements) against permitted current facts, preserving missing, stale and conflicting evidence. +It includes [physical planning](docs/inventory.md#physical-plans-and-cable-ends) with exact +rack elevations, cable ends, material requirements, reservations and reviewed +component replacement. Manual results remain separate from verified service health. ## License diff --git a/crates/omloom-contracts/build.rs b/crates/omloom-contracts/build.rs index 986183d..a443226 100644 --- a/crates/omloom-contracts/build.rs +++ b/crates/omloom-contracts/build.rs @@ -23,6 +23,12 @@ fn main() -> Result<(), Box> { .boxed(".omloom.workspace.v1.Reply.result.detail") .boxed(".omloom.workspace.v1.Reply.result.vm_placement_review") .boxed(".omloom.workspace.v1.HostImport.report") + // Keep the request stack bound; allocate this volume only for an explicit coverage assertion. + .boxed(".omloom.workspace.v1.PhysicalDefinition.occupancy_coverage") + .boxed(".omloom.workspace.v1.PhysicalDefinition.rack_grid") + .boxed(".omloom.workspace.v1.PhysicalDefinition.clearance") + .boxed(".omloom.workspace.v1.PhysicalMaterial.physical") + .boxed(".omloom.workspace.v1.PhysicalReplacement.feedback") .file_descriptor_set_path(&descriptor) .compile_protos(&[workspace], &["../../proto"])?; let set = FileDescriptorSet::decode(fs::read(descriptor)?.as_slice())?; @@ -67,8 +73,30 @@ fn main() -> Result<(), Box> { 16 } else if (message.name() == "EvaluateRequirements" && field.name() == "nodes") || (message.name() == "RequirementGroup" && field.name() == "members") + || (message.name() == "PhysicalPlan" && field.name() == "steps") + || (message.name() == "PhysicalView" && field.name() == "definitions") + || (message.name() == "PhysicalCompletion" + && matches!(field.name(), "completed_steps" | "not_performed_steps")) + || (message.name() == "PhysicalPlan" && field.name() == "materials") + || (message.name() == "PhysicalMaterial" && field.name() == "allocations") + || (message.name() == "PhysicalReplacement" && field.name() == "endpoints") + || (message.name() == "PhysicalEndpointMappings" && field.name() == "pairs") + || (message.name() == "PhysicalCableEnd" && field.name() == "channels") + || (message.name() == "PhysicalChannelSet" && field.name() == "channels") + || (message.name() == "PhysicalDefinition" && field.name() == "channels") { 64 + } else if message.name() == "PhysicalMaterial" && field.name() == "specifications" { + 12 + } else if message.name() == "PhysicalView" + && matches!( + field.name(), + "occupancy" | "plans" | "positions" | "replacement_quality" + ) + { + 25 + } else if message.name() == "PhysicalView" && field.name() == "items" { + 256 } else { 0 } diff --git a/crates/omloom-contracts/src/equipment.rs b/crates/omloom-contracts/src/equipment.rs index e2c2b9f..f59c462 100644 --- a/crates/omloom-contracts/src/equipment.rs +++ b/crates/omloom-contracts/src/equipment.rs @@ -221,29 +221,13 @@ fn identity(value: &Option) -> (&str, &str, &str) { 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> { +/// Normalize an owned bounded claim for changed-only storage and returned plans. +pub fn canonicalize(value: &mut 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(); + canonicalize_validated(value); + Ok(()) +} +fn canonicalize_validated(value: &mut EquipmentClaim) { if let Some(equipment_claim::Assertion::Quantity(q)) = &mut value.assertion { while q.scale > 0 && q.lower.is_none_or(|n| n % 10 == 0) @@ -266,6 +250,31 @@ pub fn encode(value: &EquipmentClaim) -> Result, Error> { (&a.value, identity(&a.reference)).cmp(&(&b.value, identity(&b.reference))) }); } +} +/// 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(); + canonicalize_validated(&mut value); Ok(value.encode_to_vec()) } diff --git a/crates/omloom-contracts/src/equipment/tests.rs b/crates/omloom-contracts/src/equipment/tests.rs index 9394ca1..2e8cfb1 100644 --- a/crates/omloom-contracts/src/equipment/tests.rs +++ b/crates/omloom-contracts/src/equipment/tests.rs @@ -1,4 +1,28 @@ use super::*; + +// Owned plans and the persisted encoding must share normalization, including retry order and fixed-point spelling. +#[test] +fn owned_claim_canonicalization_matches_encoding_and_is_idempotent() { + let mut value = possible(); + let encoded = encode(&value).expect("valid claim"); + canonicalize(&mut value).expect("valid owned claim"); + assert_eq!(value.encode_to_vec(), encoded); + let previous = value.clone(); + canonicalize(&mut value).expect("already canonical claim"); + assert_eq!(value, previous); + let mut quantity = EquipmentClaim { + basis: "Recorded physical measurement".into(), + assertion: Some(equipment_claim::Assertion::Quantity(QuantityRange { + lower: Some(1200), + upper: None, + scale: 2, + unit: "mm".into(), + })), + }; + let encoded = encode(&quantity).expect("valid measurement"); + canonicalize(&mut quantity).expect("owned measurement"); + assert_eq!(quantity.encode_to_vec(), encoded); +} fn possible() -> EquipmentClaim { EquipmentClaim { basis: "Front label and visible bays".into(), diff --git a/crates/omloom-contracts/src/ids.rs b/crates/omloom-contracts/src/ids.rs index 9cba3fb..6a45d56 100644 --- a/crates/omloom-contracts/src/ids.rs +++ b/crates/omloom-contracts/src/ids.rs @@ -52,6 +52,7 @@ id!(SourceId); id!(AuthorityId); id!(RelationId); id!(OperationId); +id!(PlanId); id!(ScanId); id!(TrustId); id!(CredentialBindingId); diff --git a/crates/omloom-contracts/tests/workspace.rs b/crates/omloom-contracts/tests/workspace.rs index 6736eee..26a5579 100644 --- a/crates/omloom-contracts/tests/workspace.rs +++ b/crates/omloom-contracts/tests/workspace.rs @@ -1,6 +1,54 @@ use omloom_contracts::{Error, Limits, MemoryBudget, decode_workspace, workspace as api}; use prost::Message; +// Physical wire containers enforce repeated bounds before prost allocates their vectors. +#[test] +fn physical_channels_and_endpoint_maps_are_bounded_before_decode() { + let budget = MemoryBudget::new(16 * 1_048_576); + let channels = api::Request { + working: None, + command: Some(api::request::Command::Physical(api::Physical { + action: Some(api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + channels: vec![1; 65], + ..Default::default() + }), + })), + })), + } + .encode_to_vec(); + assert!(matches!( + decode_workspace(&channels, Limits::default(), &budget), + Err(Error::Limit) + )); + assert_eq!(budget.used(), 0); + let mappings = api::Request { + working: None, + command: Some(api::request::Command::Physical(api::Physical { + action: Some(api::physical::Action::Save(api::PhysicalSave { + plan: Some(api::PhysicalPlan { + steps: vec![api::PhysicalStep { + action: Some(api::physical_step::Action::Replacement( + api::PhysicalReplacement { + endpoints: vec![Default::default(); 65], + ..Default::default() + }, + )), + ..Default::default() + }], + ..Default::default() + }), + })), + })), + } + .encode_to_vec(); + assert!(matches!( + decode_workspace(&mappings, Limits::default(), &budget), + Err(Error::Limit) + )); + assert_eq!(budget.used(), 0); +} + // Omitted wire input cannot implicitly request an exclusive allocation. #[test] fn network_sharing_requires_an_explicit_mechanism() { diff --git a/crates/omloom-store/src/disk.rs b/crates/omloom-store/src/disk.rs index 75126e6..5e77c49 100644 --- a/crates/omloom-store/src/disk.rs +++ b/crates/omloom-store/src/disk.rs @@ -22,7 +22,23 @@ use std::{ type Properties = BTreeMap; type Result = std::result::Result; const ROOT: &str = "installation"; -const SCHEMA_VERSION: u64 = 14; +const SCHEMA_VERSION: u64 = 22; +const PHYSICAL_TEXT: &[&str] = &["basis", "mounting", "item", "enclosure", "old_item"]; +const PHYSICAL_NUMBERS: &[&str] = &[ + "material_count", + "expected", + "x_start", + "x_length", + "y_start", + "y_length", + "z_start", + "z_length", + "max_mass_g", + "demand_mw", + "capacity_mw", + "cooling_mw", + "cooling_capacity_mw", +]; const MAX_CHILDREN: usize = 2048; mod deltas; mod inventory; @@ -374,6 +390,12 @@ impl Store { } let mut schema = LabelSchema::new_node_id("OmRow"); schema.add_property(PropertyDef::new("key", PropertyType::String).not_null()); + for name in PHYSICAL_TEXT { + schema.add_property(PropertyDef::new(*name, PropertyType::String)); + } + for name in PHYSICAL_NUMBERS { + schema.add_property(PropertyDef::new(*name, PropertyType::Int)); + } for name in ["candidate", "cursor", "snapshot"] { schema.add_property(PropertyDef::new(name, PropertyType::String)); } @@ -546,6 +568,11 @@ impl Store { .keys() .filter(|key| !current.properties.contains_key(*key)) .any(|key| { + if PHYSICAL_TEXT.contains(&key.as_str()) + || PHYSICAL_NUMBERS.contains(&key.as_str()) + { + return false; + } if [ "issuer", "subject", diff --git a/crates/omloom-store/src/disk/inventory.rs b/crates/omloom-store/src/disk/inventory.rs index a93aea5..cec3204 100644 --- a/crates/omloom-store/src/disk/inventory.rs +++ b/crates/omloom-store/src/disk/inventory.rs @@ -1,6 +1,14 @@ use super::*; use prost::Message; +#[derive(Clone, Copy)] +enum RelationEdit<'a> { + Ordinary, + Rebound, + RetainingEnd(&'a str), + Physical { plan: &'a str, actual: bool }, +} + pub(super) fn field_props(field: &wire::Field) -> Result { let (kind, name, value) = match field.value.as_ref().ok_or(Error::Invalid)? { field::Value::Text(v) => (1, "vtext", text(v)), @@ -309,6 +317,12 @@ impl Store { if number(p, "revision")? == rev && super::target(p)? == target => {} _ => return Err(Error::Conflict), } + if asset.kind == wire::AssetKind::PhysicalPosition as i32 { + let prior = old.as_ref().ok_or(Error::Invalid)?; + if number(prior, "kind")? != wire::AssetKind::PhysicalPosition as u64 { + return Err(Error::Invalid); + } + } let children = tx.children(&key)?; if asset.kind != wire::AssetKind::Container as i32 && old.as_ref().is_some_and(|p| { @@ -682,6 +696,68 @@ pub(super) fn relation_projection(p: &Properties) -> Result { } impl Tx<'_> { + pub(super) fn require_structural_relation_edit(&self, relation: &wire::Relation) -> Result<()> { + // The descriptor and owner relation form one structure. Keeping the relation prevents + // generic archival or retyping from stranding a retired position descriptor. + for endpoint in [relation.from.as_ref(), relation.to.as_ref()] + .into_iter() + .flatten() + { + let target = AssetRef::try_from(endpoint).map_err(|_| Error::Invalid)?; + let identity = format!("{}/{}", target.asset, target.incarnation); + if relation.kind == wire::RelationKind::Contains as i32 + && relation.to.as_ref() == Some(endpoint) + && self.get(&format!("position/{identity}"))?.is_some() + { + return Err(Error::Conflict); + } + } + Ok(()) + } + pub(super) fn remove_binding_relation( + &mut self, + relation: &wire::Relation, + actor: &str, + now: u64, + ) -> Result<()> { + self.remove_binding_relation_retaining_end(relation, actor, now, None) + } + pub(super) fn remove_binding_relation_retaining_end( + &mut self, + relation: &wire::Relation, + actor: &str, + now: u64, + end: Option<&str>, + ) -> Result<()> { + let id = omloom_contracts::ids::RelationId::parse(&relation.relation_id) + .map_err(|_| Error::Invalid)?; + let key = format!("relation/{id}"); + let row = self.get(&key)?.ok_or(Error::Conflict)?; + if relation_projection(&row)? != *relation { + return Err(Error::Conflict); + } + self.remove_physical_pose(&relation.relation_id)?; + self.invalidate_cable_attachment(relation, end, None)?; + for query in [ + "MATCH (r:OmRow {key:$key})<-[e:OmRelationFrom]-() DELETE e", + "MATCH (r:OmRow {key:$key})-[e:OmRelationTo]->() DELETE e", + ] { + self.store.db.execute_in_transaction( + self.id.ok_or(Error::Corrupt)?, + query, + params([("key", text(&key))]), + )?; + } + self.delete(&key)?; + self.audit( + &key, + actor, + "relation.removed", + relation.revision.checked_add(1).ok_or(Error::Limit)?, + now, + )?; + Ok(()) + } pub(super) fn save_typed_relation( &mut self, relation: &wire::Relation, @@ -689,7 +765,7 @@ impl Tx<'_> { actor: &str, now: u64, ) -> Result { - self.save_relation_inner(relation, expected, actor, now, false) + self.save_relation_inner(relation, expected, actor, now, RelationEdit::Ordinary) } pub(super) fn save_rebound_relation( &mut self, @@ -698,7 +774,39 @@ impl Tx<'_> { actor: &str, now: u64, ) -> Result { - self.save_relation_inner(relation, Some(expected), actor, now, true) + self.save_relation_inner(relation, Some(expected), actor, now, RelationEdit::Rebound) + } + pub(super) fn save_relation_retaining_end( + &mut self, + relation: &wire::Relation, + expected: Option, + actor: &str, + now: u64, + end: &str, + ) -> Result { + self.save_relation_inner( + relation, + expected, + actor, + now, + RelationEdit::RetainingEnd(end), + ) + } + pub(super) fn save_physical_relation( + &mut self, + relation: &wire::Relation, + actor: &str, + now: u64, + plan: &str, + actual: bool, + ) -> Result { + self.save_relation_inner( + relation, + None, + actor, + now, + RelationEdit::Physical { plan, actual }, + ) } fn save_relation_inner( &mut self, @@ -706,8 +814,13 @@ impl Tx<'_> { expected: Option, actor: &str, now: u64, - detached: bool, + edit: RelationEdit<'_>, ) -> Result { + let (detached, retained_end) = match edit { + RelationEdit::Ordinary | RelationEdit::Physical { .. } => (false, None), + RelationEdit::Rebound => (true, None), + RelationEdit::RetainingEnd(end) => (true, Some(end)), + }; let from = AssetRef::try_from(relation.from.as_ref().ok_or(Error::Invalid)?) .map_err(|_| Error::Invalid)?; let to = AssetRef::try_from(relation.to.as_ref().ok_or(Error::Invalid)?) @@ -727,6 +840,18 @@ impl Tx<'_> { if !compatible(from_kind, to_kind, kind) { return Err(Error::Invalid); } + if kind == wire::RelationKind::ConnectedTo { + let port = if from_kind == wire::AssetKind::Port { + from + } else { + to + }; + if let Some(binding) = self.cable_binding_at(port)? + && binding.relation_id != relation.relation_id + { + return Err(Error::Conflict); + } + } self.check_software_relation(from, from_kind, kind, &relation.relation_id)?; if kind == wire::RelationKind::RunsOn && from_kind == wire::AssetKind::VirtualMachine @@ -742,6 +867,13 @@ impl Tx<'_> { return Err(Error::Conflict); } if let Some(old) = &old { + let previous = relation_projection(old)?; + if previous.from != relation.from + || previous.to != relation.to + || previous.kind != relation.kind + { + self.require_structural_relation_edit(&previous)?; + } self.check_software_relation_replacement(old, from, from_kind, to, kind, detached)?; } let placement = match kind { @@ -750,10 +882,37 @@ impl Tx<'_> { _ => None, }; if let Some((child, parent)) = placement { + if number(&self.asset(child)?, "kind")? == wire::AssetKind::PhysicalPosition as u64 { + let position = self + .get(&format!("position/{}/{}", child.asset, child.incarnation))? + .ok_or(Error::Incomplete)?; + if string(&position, "parent")? + != format!("positions/{}/{}", parent.asset, parent.incarnation) + { + return Err(Error::Conflict); + } + } self.check_physical_parent(child, parent, &key)?; } else if kind == wire::RelationKind::RunsOn { self.check_placement_cycle(from, to, Some(&key))?; } + if kind == wire::RelationKind::InstalledIn && to_kind == wire::AssetKind::PhysicalPosition { + match edit { + RelationEdit::Physical { + plan, + actual: false, + } => { + self.check_physical_lock("physical-slot", to, Some(plan))?; + } + // A report of performed work preserves the future hold for reconciliation. + RelationEdit::Physical { actual: true, .. } => {} + _ => self.check_physical_lock("physical-slot", to, None)?, + } + let occupied=self.query("MATCH (p:OmRow {key:$position})<-[:OmRelationTo]-(r:OmRow) WHERE r.kind=$kind AND r.key<>$key RETURN properties(r) AS p LIMIT 1",params([("position",text(asset_key(to))),("kind",int(wire::RelationKind::InstalledIn as u64)?),("key",text(&key))]))?; + if !occupied.is_empty() { + return Err(Error::Conflict); + } + } if kind == wire::RelationKind::Contains && from_kind == wire::AssetKind::Interface && to_kind == wire::AssetKind::AddressReference @@ -776,6 +935,27 @@ impl Tx<'_> { return expected.ok_or(Error::Corrupt); } let revision = expected.unwrap_or(0).checked_add(1).ok_or(Error::Limit)?; + if let Some(prior) = &old { + let prior = relation_projection(prior)?; + let same = prior.from == relation.from + && prior.to == relation.to + && prior.kind == relation.kind; + self.invalidate_cable_attachment( + &prior, + retained_end, + if same { Some(revision) } else { None }, + )?; + if prior.from != relation.from || prior.to != relation.to || prior.kind != relation.kind + { + self.remove_physical_pose(&relation.relation_id)?; + } + } + if kind == wire::RelationKind::InstalledIn { + data.insert("actor".into(), text(actor)); + if to_kind == wire::AssetKind::PhysicalPosition { + self.clear_position_empty_report(to)?; + } + } if kind == wire::RelationKind::RunsOn && from_kind == wire::AssetKind::VirtualMachine { self.require_no_pending_operations(from)?; } @@ -932,6 +1112,7 @@ pub(super) fn compatible( (K::Device, K::Rack | K::Site | K::Location) | (K::Rack, K::Site | K::Location) | (K::Location, K::Site | K::Location) + | (K::PhysicalComponent | K::Device, K::PhysicalPosition) ), R::Contains => matches!( (from, to), @@ -949,12 +1130,17 @@ pub(super) fn compatible( | (K::Service, K::SoftwareInstallation) | (K::Service, K::ProviderAccount) | (K::Workload, K::Container | K::Service) + | ( + K::Device | K::Rack | K::Port | K::PhysicalComponent, + K::PhysicalPosition + ) + | (K::Port, K::Interface) | ( K::Container, K::Service | K::Interface | K::SoftwareInstallation ) ), - R::HasPort => matches!((from, to), (K::Device, K::Port)), + R::HasPort => matches!((from, to), (K::Device | K::PhysicalComponent, K::Port)), R::Carries => matches!((from, to), (K::Port, K::Interface)), R::ConnectedTo => matches!((from, to), (K::Port, K::Cable) | (K::Cable, K::Port)), R::AssignedTo => matches!((from, to), (K::IpAddress, K::Interface)), @@ -973,7 +1159,15 @@ pub(super) fn compatible( (from, to), (K::Device, K::Interface) | (K::Vrf, K::AddressSpace) ), - R::PoweredBy => matches!((from, to), (K::Device, K::Port) | (K::Port, K::PowerFeed)), + R::PoweredBy => matches!( + (from, to), + (K::Device | K::PhysicalComponent, K::Port) + | (K::Port, K::PowerFeed) + | ( + K::PowerFeed, + K::Device | K::PhysicalComponent | K::PowerFeed + ) + ), R::Manages => matches!( (from, to), (K::Device, K::Device) | (K::Service, K::Device | K::VirtualMachine) diff --git a/crates/omloom-store/src/disk/workspace.rs b/crates/omloom-store/src/disk/workspace.rs index 23d4039..830a4f4 100644 --- a/crates/omloom-store/src/disk/workspace.rs +++ b/crates/omloom-store/src/disk/workspace.rs @@ -17,6 +17,7 @@ mod host_import; mod interface_facts; mod network; pub(super) mod network_facts; +mod physical; mod projection; mod requirements; mod responsibility; @@ -259,6 +260,7 @@ impl Store { use api::request::Command as C; let working = request.working; match request.command.ok_or(Error::Invalid)? { + C::Physical(value) => self.workspace_physical(context, value, working.as_ref(), now), C::ObserveProxmox(_) => Err(Error::Invalid), C::Identity(_) => Ok(reply(api::reply::Result::Identity(api::Identity { name: context.actor.clone(), diff --git a/crates/omloom-store/src/disk/workspace/assets.rs b/crates/omloom-store/src/disk/workspace/assets.rs index a3cf799..bd78631 100644 --- a/crates/omloom-store/src/disk/workspace/assets.rs +++ b/crates/omloom-store/src/disk/workspace/assets.rs @@ -35,6 +35,8 @@ fn ownership_groups( K::SoftwareRelease, K::Workload, K::Container, + K::PhysicalComponent, + K::PhysicalPosition, ]; let mut groups = BTreeMap::<(i32, bool, i32), Vec>::new(); for parent in kinds { @@ -111,6 +113,46 @@ fn browse_clause( ) } +pub(super) fn detail_page_fingerprint( + context: &Context, + working: &api::WorkingContext, + target: AssetRef, + domain: &str, +) -> String { + query_fingerprint( + &api::Query { + roots: working.roots.clone(), + focus: Some(target_wire(target)), + search: format!("{domain}/{:?}", working.focus), + ..Default::default() + }, + context, + ) +} + +pub(super) fn page_position<'a>( + cursor: &'a str, + revision: u64, + fingerprint: &str, + authority: u64, +) -> Result<&'a str> { + if cursor.len() > 256 { + return Err(Error::Invalid); + } + let mut parts = cursor.split(':'); + if parts.next().and_then(|s| s.parse::().ok()) != Some(revision) + || parts.next() != Some(fingerprint) + || parts.next().and_then(|s| s.parse::().ok()) != Some(authority) + { + return Err(Error::Expired); + } + let position = parts.next().ok_or(Error::Expired)?; + if parts.next().is_some() { + return Err(Error::Expired); + } + Ok(position) +} + impl Tx<'_> { fn relation_page( &self, @@ -120,27 +162,12 @@ impl Tx<'_> { cursor: Option<&str>, ) -> Result { let revision = number(&self.root, "revision")?; - let query = api::Query { - roots: working.roots.clone(), - focus: Some(target_wire(target)), - search: format!("relations/{:?}", working.focus), - ..Default::default() - }; - let fingerprint = query_fingerprint(&query, context); + let fingerprint = detail_page_fingerprint(context, working, target, "relations"); let mut offset = 0; if let Some(cursor) = cursor { - if cursor.len() > 256 { - return Err(Error::Invalid); - } - let parts: Vec<_> = cursor.split(':').collect(); - if parts.len() != 4 - || parts[0].parse::().ok() != Some(revision) - || parts[1] != fingerprint - || parts[2].parse::().ok() != Some(context.authority_revision) - { - return Err(Error::Expired); - } - offset = parts[3].parse::().map_err(|_| Error::Invalid)?; + offset = page_position(cursor, revision, &fingerprint, context.authority_revision)? + .parse::() + .map_err(|_| Error::Invalid)?; if offset > 100_000 { return Err(Error::Limit); } @@ -310,6 +337,8 @@ pub(super) fn kind_for_view(view: api::View) -> &'static [wire::AssetKind] { api::View::Overview => &[], api::View::Hardware => &[ K::Device, + K::PhysicalComponent, + K::PhysicalPosition, K::Port, K::Cable, K::Rack, @@ -1476,6 +1505,9 @@ impl Tx<'_> { working: &api::WorkingContext, now: u64, ) -> Result { + if write.target.is_none() && write.kind == wire::AssetKind::PhysicalPosition as i32 { + return Err(Error::Invalid); + } if write.kind == wire::AssetKind::AddressReference as i32 { if write.changes.is_empty() || write.changes.len() > MAX_FIELDS { return Err(Error::Invalid); @@ -1560,27 +1592,8 @@ impl Store { { return Err(Error::Conflict); } - let key = format!("relation/{id}"); - // Each relation owns one edge in each direction. Delete those - // exact attachments before removing their metadata row. - for query in [ - "MATCH (r:OmRow {key:$key})<-[e:OmRelationFrom]-() DELETE e", - "MATCH (r:OmRow {key:$key})-[e:OmRelationTo]->() DELETE e", - ] { - self.db.execute_in_transaction( - tx.id.ok_or(Error::Corrupt)?, - query, - params([("key", text(&key))]), - )?; - } - tx.delete(&key)?; - tx.audit( - &key, - &context.actor, - "relation.removed", - relation.revision.checked_add(1).ok_or(Error::Limit)?, - now, - )?; + tx.require_structural_relation_edit(&existing)?; + tx.remove_binding_relation(&existing, &context.actor, now)?; return Ok(mutation(tx.commit()?, Vec::new())); } } @@ -1808,6 +1821,7 @@ impl Tx<'_> { // Existing operation/retirement code owns uncertain effects and identity fences. self.require_no_account_references(target)?; self.require_no_pending_operations(target)?; + self.require_no_physical_reservations(target)?; let linked = self.query("MATCH (n:OmRow) WHERE n.parent='relations' AND (n.asset=$asset OR n.to_asset=$asset) RETURN properties(n) AS p LIMIT 1",params([("asset", text(target.asset.to_string()))]))?; if !linked.is_empty() { return Err(Error::Conflict); @@ -1845,6 +1859,11 @@ impl Tx<'_> { self.delete(&group_key)?; } self.retire_current_facts(target)?; + self.remove_physical_offers(target)?; + self.delete(&format!("physical/{scope}"))?; + if number(row, "kind")? == wire::AssetKind::Cable as u64 { + self.remove_cable_ends(target)?; + } let mappings = self.query("MATCH (n:OmRow {asset:$asset}) WHERE n.parent='csv-mappings' AND n.incarnation=$incarnation AND n.class=$class RETURN properties(n) AS p LIMIT 2049", params([("asset", text(target.asset.to_string())), ("incarnation", text(target.incarnation.to_string())), ("class", Value::Int(Class::Inventory as i64))]))?; if mappings.len() > MAX_CHILDREN { return Err(Error::Limit); diff --git a/crates/omloom-store/src/disk/workspace/definitions.rs b/crates/omloom-store/src/disk/workspace/definitions.rs index 65a5de7..839ae92 100644 --- a/crates/omloom-store/src/disk/workspace/definitions.rs +++ b/crates/omloom-store/src/disk/workspace/definitions.rs @@ -502,11 +502,18 @@ impl Tx<'_> { 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())) + && (matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device | wire::AssetKind::PhysicalComponent) + ) || !software::fields( + wire::AssetKind::try_from(kind).map_err(|_| Error::Invalid)?, + ) + .contains(&value.name.as_str())) { - if kind != wire::AssetKind::Device as i32 { + if !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device | wire::AssetKind::PhysicalComponent) + ) { return Err(Error::Invalid); } return match value.value.as_ref() { @@ -578,6 +585,7 @@ impl Tx<'_> { | wire::AssetKind::Rack | wire::AssetKind::Port | wire::AssetKind::Cable) + | Ok(wire::AssetKind::PhysicalComponent) ) => { Err(Error::Invalid) diff --git a/crates/omloom-store/src/disk/workspace/physical.rs b/crates/omloom-store/src/disk/workspace/physical.rs new file mode 100644 index 0000000..489446f --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical.rs @@ -0,0 +1,817 @@ +//! Current physical definitions use scalar indexed inventory rows, not wire blobs. +use super::*; +use crate::physical::{Span, Volume}; +mod admission; +mod availability; +mod cables; +mod clearance; +mod completion; +mod connector; +mod coverage; +mod current; +mod equipment; +mod flow; +mod lifecycle; +mod materials; +mod positions; +mod rack_grid; +mod replacements; +mod reservations; +mod reviews; +mod wiring; + +#[derive(Clone, Copy)] +struct Placement<'a> { + item: &'a Option, + material: Option<&'a api::PhysicalMaterialUnit>, + enclosure: &'a Option, + volume: &'a Option, +} +impl<'a> From<&'a api::PhysicalPlacement> for Placement<'a> { + fn from(value: &'a api::PhysicalPlacement) -> Self { + Self { + item: &value.item, + material: value.material.as_ref(), + enclosure: &value.enclosure, + volume: &value.volume, + } + } +} +impl<'a> Placement<'a> { + fn requirement( + &self, + materials: &'a [api::PhysicalMaterial], + ) -> Result> { + match (self.item.is_some(), self.material) { + (true, None) => Ok(None), + (false, Some(unit)) => { + let material = materials + .get(unit.material as usize) + .ok_or(Error::Invalid)?; + if unit.unit >= material.quantity { + return Err(Error::Invalid); + } + Ok(Some(material)) + } + _ => Err(Error::Invalid), + } + } + fn reference( + &self, + materials: &'a [api::PhysicalMaterial], + ) -> Result> { + let Some(material) = self.requirement(materials)? else { + return Ok(self.item.as_ref()); + }; + let unit = self.material.ok_or(Error::Invalid)?.unit; + Ok(material + .allocations + .iter() + .find(|a| a.unit == unit) + .and_then(|a| a.item.as_ref())) + } + fn actual(&self, materials: &'a [api::PhysicalMaterial]) -> Result> { + self.reference(materials)? + .map(|r| parse_target(Some(r))) + .transpose() + } +} +fn step_placement(step: &api::PhysicalStep) -> Option> { + match &step.action { + Some(api::physical_step::Action::Placement(value)) => Some(Placement::from(value)), + Some(api::physical_step::Action::Replacement(value)) if value.volume.is_some() => { + Some(Placement { + item: &value.replacement, + material: None, + enclosure: &value.position, + volume: &value.volume, + }) + } + _ => None, + } +} +fn placement_steps(plan: &api::PhysicalPlan) -> impl Iterator)> { + plan.steps + .iter() + .enumerate() + .filter_map(|(index, step)| step_placement(step).map(|p| (index, p))) +} + +fn placements(plan: &api::PhysicalPlan) -> impl Iterator> { + placement_steps(plan).map(|(_, p)| p) +} +fn replacement_steps(plan: &api::PhysicalPlan) -> impl Iterator { + plan.steps.iter().filter_map(|step| match &step.action { + Some(api::physical_step::Action::Replacement(value)) => Some(value), + _ => None, + }) +} +fn step_row(step: &api::PhysicalStep) -> Result { + let mut row = match step.action.as_ref().ok_or(Error::Invalid)? { + api::physical_step::Action::Placement(p) => { + let mut row = props([ + ("action", text("placement")), + ( + "enclosure", + text(network::identity(parse_target(p.enclosure.as_ref())?)), + ), + ]); + match (&p.item, &p.material) { + (Some(item), None) => { + row.insert( + "item".into(), + text(network::identity(parse_target(Some(item))?)), + ); + } + (None, Some(unit)) => { + row.insert("vint".into(), int(u64::from(unit.material))?); + row.insert("generation".into(), int(u64::from(unit.unit))?); + } + _ => return Err(Error::Invalid), + } + write_volume(&mut row, p.volume.as_ref().ok_or(Error::Invalid)?)?; + row + } + api::physical_step::Action::Replacement(r) => { + let mut row = replacements::replacement_row(r)?; + row.insert("action".into(), text("replacement")); + row + } + api::physical_step::Action::Wiring(w) => wiring::step_row(w)?, + }; + row.insert( + "state".into(), + int(u64::try_from(step.state).map_err(|_| Error::Invalid)?)?, + ); + if let Some(control) = &step.control { + // The bounded immutable control artifact is consumed whole by plan evaluation. + row.insert("query".into(), Value::Binary(control.encode_to_vec())); + } + Ok(row) +} +fn read_step(row: &Properties) -> Result { + let state = i32::try_from(number(row, "state")?).map_err(|_| Error::Corrupt)?; + if api::PhysicalStepState::try_from(state).map_err(|_| Error::Corrupt)? + == api::PhysicalStepState::Unspecified + { + return Err(Error::Corrupt); + } + let action = match string(row, "action")?.as_str() { + "placement" => api::physical_step::Action::Placement(api::PhysicalPlacement { + item: row + .get("item") + .map(|_| { + string(row, "item") + .and_then(|s| lifecycle::reference(&s)) + .map(target_wire) + }) + .transpose()?, + material: row + .get("vint") + .map(|_| -> Result { + Ok(api::PhysicalMaterialUnit { + material: u32::try_from(number(row, "vint")?) + .map_err(|_| Error::Corrupt)?, + unit: u32::try_from(number(row, "generation")?) + .map_err(|_| Error::Corrupt)?, + }) + }) + .transpose()?, + enclosure: Some(target_wire(lifecycle::reference(&string( + row, + "enclosure", + )?)?)), + volume: read_volume(row)?, + }), + "replacement" => { + api::physical_step::Action::Replacement(replacements::read_replacement(row)?) + } + "wiring" => api::physical_step::Action::Wiring(wiring::read_step(row)?), + _ => return Err(Error::Corrupt), + }; + Ok(api::PhysicalStep { + state, + action: Some(action), + control: row + .get("query") + .map(|_| { + let bytes = binary(row, "query")?; + if bytes.len() > 65536 { + return Err(Error::Corrupt); + } + api::PlanControl::decode(bytes).map_err(|_| Error::Corrupt) + }) + .transpose()?, + ..Default::default() + }) +} + +fn volume(value: &api::PhysicalVolume) -> Result { + fn axis(value: Option<&api::PhysicalExtent>) -> Result { + let value = value.ok_or(Error::Invalid)?; + Span::new(value.start_um, value.length_um).map_err(|_| Error::Invalid) + } + Ok(Volume { + x: axis(value.x.as_ref())?, + y: axis(value.y.as_ref())?, + z: axis(value.z.as_ref())?, + }) +} +fn write_volume(data: &mut Properties, value: &api::PhysicalVolume) -> Result<()> { + volume(value)?; + for (name, axis) in [("x", &value.x), ("y", &value.y), ("z", &value.z)] { + let axis = axis.as_ref().ok_or(Error::Invalid)?; + data.insert(format!("{name}_start"), Value::Int(axis.start_um)); + data.insert(format!("{name}_length"), int(axis.length_um)?); + } + Ok(()) +} +fn read_volume(data: &Properties) -> Result> { + if !data.contains_key("x_start") { + return Ok(None); + } + fn axis(data: &Properties, name: &str) -> Result { + Ok(api::PhysicalExtent { + start_um: data + .get(&format!("{name}_start")) + .and_then(Value::as_int) + .ok_or(Error::Corrupt)?, + length_um: number(data, &format!("{name}_length"))?, + }) + } + let value = api::PhysicalVolume { + x: Some(axis(data, "x")?), + y: Some(axis(data, "y")?), + z: Some(axis(data, "z")?), + }; + volume(&value).map_err(|_| Error::Corrupt)?; + Ok(Some(value)) +} +fn definition_row(value: &api::PhysicalDefinition) -> Result { + let target = parse_target(value.target.as_ref())?; + let mut data = definition_facts(value)?; + data.extend(props([ + ("asset", text(target.asset.to_string())), + ("incarnation", text(target.incarnation.to_string())), + ])); + Ok(data) +} +fn definition_kind(kind: wire::AssetKind, value: &api::PhysicalDefinition) -> Result<()> { + if !value.channels.is_empty() && kind != wire::AssetKind::Port { + return Err(Error::Invalid); + } + if value.assembly_kind != api::PhysicalAssemblyKind::Unspecified as i32 + && kind != wire::AssetKind::Cable + { + return Err(Error::Invalid); + } + if (value.rack_grid.is_some() && kind != wire::AssetKind::Rack) + || (value.power_sources_complete + && !matches!( + kind, + wire::AssetKind::Device + | wire::AssetKind::PhysicalComponent + | wire::AssetKind::Port + | wire::AssetKind::PowerFeed + )) + || (value.component_kind != api::PhysicalComponentKind::Unspecified as i32 + && !matches!( + kind, + wire::AssetKind::Device | wire::AssetKind::PhysicalComponent + )) + { + return Err(Error::Invalid); + } + Ok(()) +} +fn definition_facts(value: &api::PhysicalDefinition) -> Result { + api::PhysicalAssemblyKind::try_from(value.assembly_kind).map_err(|_| Error::Invalid)?; + api::PhysicalComponentKind::try_from(value.component_kind).map_err(|_| Error::Invalid)?; + if value.basis.is_empty() + || value.basis.len() > 512 + || value.basis.chars().any(char::is_control) + || value + .mounting + .as_ref() + .is_some_and(|s| s.is_empty() || s.len() > 128 || s.chars().any(char::is_control)) + { + return Err(Error::Invalid); + } + let mut data = props([("basis", text(&value.basis))]); + if !value.channels.is_empty() { + if value.channels.len() > 64 { + return Err(Error::Limit); + } + let mut channels = value.channels.clone(); + cables::normalize_channels(&mut channels)?; + data.insert( + "query".into(), + Value::Binary(api::PhysicalChannelSet { channels }.encode_to_vec()), + ); + } + if value.component_kind != api::PhysicalComponentKind::Unspecified as i32 { + data.insert("vint".into(), int(value.component_kind as u64)?); + } + if value.assembly_kind != api::PhysicalAssemblyKind::Unspecified as i32 { + data.insert("vtype".into(), int(value.assembly_kind as u64)?); + } + if let Some(v) = &value.volume { + write_volume(&mut data, v)?; + } + for (key, n) in [ + ("max_mass_g", value.max_mass_g), + ("demand_mw", value.demand_mw), + ("capacity_mw", value.capacity_mw), + ("cooling_mw", value.cooling_mw), + ("cooling_capacity_mw", value.cooling_capacity_mw), + ] { + if let Some(n) = n { + data.insert(key.into(), int(n)?); + } + } + if let Some(mounting) = &value.mounting { + data.insert("mounting".into(), text(mounting)); + } + connector::store(&mut data, value.mating.as_ref())?; + clearance::write(&mut data, value.clearance.as_deref())?; + coverage::write(&mut data, value.occupancy_coverage.as_deref())?; + rack_grid::write(&mut data, value.rack_grid.as_deref(), value.volume.as_ref())?; + if value.power_sources_complete { + data.insert("approved".into(), Value::Bool(true)); + } + Ok(data) +} +fn definition(data: &Properties) -> Result { + let mut value = definition_facts_read(data)?; + value.target = Some(target_wire(target(data)?)); + Ok(value) +} +fn definition_facts_read(data: &Properties) -> Result { + let component_kind = if data.contains_key("vint") { + i32::try_from(number(data, "vint")?).map_err(|_| Error::Corrupt)? + } else { + api::PhysicalComponentKind::Unspecified as i32 + }; + api::PhysicalComponentKind::try_from(component_kind).map_err(|_| Error::Corrupt)?; + let optional = |key: &str| -> Result> { + if data.contains_key(key) { + number(data, key).map(Some) + } else { + Ok(None) + } + }; + Ok(api::PhysicalDefinition { + target: None, + channels: data + .get("query") + .map(|_| -> Result> { + let bytes = binary(data, "query")?; + if bytes.len() > 256 { + return Err(Error::Corrupt); + } + let mut channels = api::PhysicalChannelSet::decode(bytes) + .map_err(|_| Error::Corrupt)? + .channels; + cables::normalize_channels(&mut channels).map_err(|_| Error::Corrupt)?; + Ok(channels) + }) + .transpose()? + .unwrap_or_default(), + assembly_kind: if data.contains_key("vtype") { + let kind = i32::try_from(number(data, "vtype")?).map_err(|_| Error::Corrupt)?; + api::PhysicalAssemblyKind::try_from(kind).map_err(|_| Error::Corrupt)?; + kind + } else { + api::PhysicalAssemblyKind::Unspecified as i32 + }, + volume: read_volume(data)?, + basis: string(data, "basis")?, + component_kind, + max_mass_g: optional("max_mass_g")?, + demand_mw: optional("demand_mw")?, + capacity_mw: optional("capacity_mw")?, + cooling_mw: optional("cooling_mw")?, + cooling_capacity_mw: optional("cooling_capacity_mw")?, + mounting: data + .get("mounting") + .map(|_| string(data, "mounting")) + .transpose()?, + mating: connector::read(data)?, + clearance: clearance::read(data)?.map(Box::new), + occupancy_coverage: coverage::read(data)?.map(Box::new), + power_sources_complete: match data.get("approved") { + None => false, + Some(Value::Bool(value)) => *value, + _ => return Err(Error::Corrupt), + }, + rack_grid: rack_grid::read(data)?.map(Box::new), + }) +} +impl Tx<'_> { + fn physical_access( + &self, + context: &Context, + target: AssetRef, + working: &api::WorkingContext, + write: bool, + ) -> Result { + self.selection(context, working, write)? + .contains(target, self)?; + let row = self.get(&asset_key(target))?.ok_or(Error::Missing)?; + Self::physical_metadata_access(context, target, &row, write) + } + fn physical_metadata_access( + context: &Context, + expected: AssetRef, + row: &Properties, + write: bool, + ) -> Result { + if target(row)? != expected { + return Err(Error::Conflict); + } + if number(row, "adoption")? != wire::Adoption::Adopted as u64 { + return Err(Error::Missing); + } + let kind = number(row, "kind")? as i32; + if !context.readable(expected, kind) + || !context.field(expected, kind, "physical", false) + || (write && !context.field(expected, kind, "physical", true)) + { + return Err(Error::Denied); + } + wire::AssetKind::try_from(kind).map_err(|_| Error::Corrupt) + } + fn physical_definition(&self, target: AssetRef) -> Result> { + self.get(&format!("physical/{}", network::identity(target)))? + .as_ref() + .map(definition) + .transpose() + } +} +impl Store { + pub(super) fn workspace_physical( + &self, + context: &Context, + request: api::Physical, + working: Option<&api::WorkingContext>, + now: u64, + ) -> Result { + use api::physical::Action; + let working = || working.ok_or(Error::Invalid); + let mut tx = self.begin()?; + let mut view = api::PhysicalView { + revision: number(&tx.root, "revision")?, + state: api::RequirementState::Unknown as i32, + reason: api::RequirementReason::Missing as i32, + ..Default::default() + }; + match request.action.ok_or(Error::Invalid)? { + Action::ReviewReplacement(value) => { + let (plan, hypothesis) = tx.review_replacement(context, value, working()?, now)?; + view.plan = Some(plan); + view.replacement_hypothesis = Some(hypothesis); + } + Action::ReplacementQuality(value) => { + let (buckets, next) = tx.replacement_quality(context, value, working()?, now)?; + view.replacement_quality = buckets; + view.next = next; + } + Action::Offer(value) => { + view.offers + .push(tx.save_physical_offer(context, value, working()?, now)?) + } + Action::Offers(value) => { + let (offers, next) = tx.physical_offers(context, value, working()?)?; + view.offers = offers; + view.next = next; + } + Action::Availability(value) => { + working()?; + let (offer, result) = tx.offered_availability(context, value)?; + view.offers.push(offer); + (view.state, view.reason) = requirements::wire_decision(result); + } + Action::CableEnd(value) => { + view.cable_ends + .push(tx.save_cable_end(context, value, working()?, now)?) + } + Action::CableEnds(value) => { + let (ends, next) = tx.cable_ends(context, value, working()?)?; + view.cable_ends = ends; + view.next = next; + } + Action::Position(value) => { + view.positions + .push(tx.save_physical_position(context, value, working()?, now)?); + } + Action::Positions(value) => { + let (positions, next) = tx.physical_positions(context, value, working()?)?; + view.positions = positions; + view.next = next; + } + Action::List(request) => { + let limit = if request.limit == 0 { + 25 + } else { + request.limit + }; + if limit > 25 { + return Err(Error::Limit); + } + if request.after.as_ref().is_some_and(|s| s.len() > 64) { + return Err(Error::Invalid); + } + let after = request.after.as_deref().unwrap_or(""); + let rows = tx.query("MATCH (n:OmRow {parent:'plans'}) WHERE n.actor=$actor AND n.action='physical' AND n.key>$after RETURN properties(n) AS p ORDER BY n.key LIMIT $limit",params([("actor",text(&context.actor)),("after",text(after)),("limit",int(u64::from(limit)+1)?)]))?; + for row in rows.iter().take(limit as usize) { + let key = string(row, "key")?; + let id = key.strip_prefix("plan/").ok_or(Error::Corrupt)?; + PlanId::parse(id).map_err(|_| Error::Corrupt)?; + view.plans.push(api::PhysicalPlanSummary { + id: id.into(), + name: string(row, "name")?, + revision: number(row, "revision")?, + state: i32::try_from(number(row, "state")?).map_err(|_| Error::Corrupt)?, + }); + } + if rows.len() > limit as usize { + view.next = Some(string(&rows[limit as usize - 1], "key")?); + } + } + Action::Read(read) => { + if read.after.is_some() && !read.occupants { + return Err(Error::Invalid); + } + let target = parse_target(read.enclosure.as_ref())?; + tx.physical_access(context, target, working()?, false)?; + if let Some(value) = tx.physical_definition(target)? { + view.definitions.push(value); + } + tx.physical_equipment_projection(context, target, &mut view)?; + if read.occupants { + tx.read_physical_current( + context, + target, + working()?, + read.after.as_deref(), + &mut view, + )?; + } + } + Action::Define(edit) => { + let value = edit.definition.ok_or(Error::Invalid)?; + let target = parse_target(value.target.as_ref())?; + let kind = tx.physical_access(context, target, working()?, true)?; + definition_kind(kind, &value)?; + let data = definition_row(&value)?; + if tx.put( + &format!("physical/{}", network::identity(target)), + "physical", + Class::Inventory, + data, + )? { + tx.audit( + &network::identity(target), + &context.actor, + "physical-definition", + view.revision.checked_add(1).ok_or(Error::Limit)?, + now, + )?; + } + view.definitions.push(value); + } + Action::Preview(preview) => { + let mut plan = preview.plan.ok_or(Error::Invalid)?; + materials::normalize(&mut plan)?; + tx.prepare_wiring_plan(context, &mut plan, working()?)?; + tx.validate_physical_draft(context, &plan, working()?, false)?; + if let Some(id) = &plan.id { + let existing = tx.load_physical_plan( + context, + &api::PhysicalPlanKey { + id: id.clone(), + revision: plan.revision, + }, + working()?, + false, + )?; + reviews::check_saved_feedback(&plan, Some(&existing))?; + } else { + reviews::check_saved_feedback(&plan, None)?; + } + let decision = match tx.assess_physical(context, &plan, working()?) { + Ok(_) => crate::requirements::Decision::PASS, + Err(failure) => match failure.decision { + Some(decision) => decision, + None => return Err(failure.error), + }, + }; + (view.state, view.reason) = requirements::wire_decision(decision); + view.plan = Some(plan); + } + Action::Save(save) => { + let mut plan = save.plan.ok_or(Error::Invalid)?; + materials::normalize(&mut plan)?; + tx.prepare_wiring_plan(context, &mut plan, working()?)?; + let materials = tx.validate_physical_draft(context, &plan, working()?, true)?; + tx.refresh_plan_control(context, &mut plan, working()?, now)?; + for p in placements(&plan) { + if let Some(item) = p.actual(&plan.materials)? { + tx.physical_access(context, item, working()?, true)?; + } + tx.physical_access( + context, + parse_target(p.enclosure.as_ref())?, + working()?, + true, + )?; + } + let id = match &plan.id { + Some(id) => PlanId::parse(id).map_err(|_| Error::Invalid)?, + None => PlanId::generate(), + }; + let key = format!("plan/{id}"); + let mut previous_count = 0; + let mut previous_material_count = 0; + for step in &mut plan.steps { + step.state = api::PhysicalStepState::Planned as i32; + } + if let Some(old) = tx.get(&key)? { + if number(&old, "revision")? != plan.revision { + return Err(Error::Conflict); + } + if string(&old, "actor")? != context.actor { + return Err(Error::Denied); + } + let existing = tx.load_physical_plan( + context, + &api::PhysicalPlanKey { + id: id.to_string(), + revision: plan.revision, + }, + working()?, + true, + )?; + let mut existing = existing; + tx.refresh_plan_control(context, &mut existing, working()?, now)?; + if existing.state != api::PhysicalPlanState::Draft as i32 { + return Err(Error::Conflict); + } + previous_count = existing.steps.len(); + previous_material_count = existing.materials.len(); + reviews::check_saved_feedback(&plan, Some(&existing))?; + plan.state = api::PhysicalPlanState::Draft as i32; + if existing == plan { + view.plan = Some(existing); + tx.physical_labels(context, &mut view)?; + view.revision = tx.commit()?; + return Ok(reply(api::reply::Result::Physical(view))); + } + tx.reconcile_saved_feedback(&existing, &plan, now)?; + } else if plan.id.is_some() { + return Err(Error::Missing); + } else { + reviews::check_saved_feedback(&plan, None)?; + } + plan.id = Some(id.to_string()); + plan.state = api::PhysicalPlanState::Draft as i32; + plan.reserved_until_ms = None; + plan.revision = view.revision.checked_add(1).ok_or(Error::Limit)?; + let mut header = props([ + ("name", text(&plan.name)), + ("actor", text(&context.actor)), + ("action", text("physical")), + ("state", int(api::PhysicalPlanState::Draft as u64)?), + ("revision", int(plan.revision)?), + ("count", int(plan.steps.len() as u64)?), + ]); + if !plan.materials.is_empty() { + header.insert("material_count".into(), int(plan.materials.len() as u64)?); + } + tx.put(&key, "plans", Class::Inventory, header)?; + tx.save_materials(&key, materials, previous_material_count)?; + for (i, step) in plan.steps.iter().enumerate() { + tx.put( + &format!("{key}/{i}"), + &key, + Class::Inventory, + step_row(step)?, + )?; + } + for i in plan.steps.len()..previous_count { + tx.delete(&format!("{key}/{i}"))?; + } + tx.audit( + &id.to_string(), + &context.actor, + "save-plan", + plan.revision, + now, + )?; + view.plan = Some(plan); + } + Action::Load(key) => { + view.plan = Some(tx.load_physical_plan(context, &key, working()?, false)?); + } + Action::Receive(receipt) => { + view.plan = Some(tx.receive_material(context, receipt, working()?, now)?); + } + Action::Reserve(value) => { + let key = value.plan.ok_or(Error::Invalid)?; + view.plan = + Some(tx.reserve_physical(context, &key, working()?, now, value.until_ms)?); + } + Action::Complete(value) => { + view.plan = Some(tx.complete_physical_steps(context, value, working()?, now)?); + } + Action::Cancel(key) => { + view.plan = Some(tx.cancel_physical(context, &key, working()?, now)?); + } + } + if let Some(plan) = &mut view.plan { + tx.refresh_plan_control(context, plan, working()?, now)?; + } + tx.physical_labels(context, &mut view)?; + view.revision = tx.commit()?; + Ok(reply(api::reply::Result::Physical(view))) + } +} +impl Tx<'_> { + fn validate_physical_draft( + &self, + context: &Context, + plan: &api::PhysicalPlan, + working: &api::WorkingContext, + write: bool, + ) -> Result { + if plan.name.is_empty() + || plan.name.len() > 160 + || plan.name.chars().any(char::is_control) + || (plan.steps.is_empty() && plan.materials.is_empty()) + || plan.steps.len() > 64 + { + return Err(Error::Invalid); + } + if plan.reserved_until_ms.is_some() + || !matches!( + api::PhysicalPlanState::try_from(plan.state), + Ok(api::PhysicalPlanState::Unspecified | api::PhysicalPlanState::Draft) + ) + { + return Err(Error::Invalid); + } + let mut items = BTreeSet::new(); + let mut units = BTreeSet::new(); + if !plan.steps.is_empty() { + flow::analysis(&plan.steps)?; + } + let materials = self.prepare_materials(context, plan, working, write)?; + let mut controls = 0usize; + for step in &plan.steps { + if let Some(control) = &step.control { + let size = control.encoded_len(); + if size > 65536 - controls { + return Err(Error::Limit); + } + controls += size; + if let Some(check) = &control.check { + self.validate_requirement_graph(context, check.clone(), working)?; + } + } + if !matches!( + api::PhysicalStepState::try_from(step.state), + Ok(api::PhysicalStepState::Unspecified | api::PhysicalStepState::Planned) + ) || step.action.is_none() + { + return Err(Error::Invalid); + } + } + for p in placements(plan) { + let item = p.actual(&plan.materials)?; + let enclosure = parse_target(p.enclosure.as_ref())?; + if let Some(unit) = p.material + && !units.insert((unit.material, unit.unit)) + { + return Err(Error::Invalid); + } + let item_kind = if let Some(item) = item { + if item == enclosure || !items.insert(item) { + return Err(Error::Invalid); + } + self.physical_access(context, item, working, false)? + } else { + wire::AssetKind::try_from( + p.requirement(&plan.materials)?.ok_or(Error::Invalid)?.kind, + ) + .map_err(|_| Error::Invalid)? + }; + let enclosure_kind = self.physical_access(context, enclosure, working, false)?; + if !inventory::compatible(item_kind, enclosure_kind, wire::RelationKind::InstalledIn) { + return Err(Error::Invalid); + } + volume(p.volume.as_ref().ok_or(Error::Invalid)?)?; + } + self.check_replacements(context, plan, working, write)?; + Ok(materials) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/admission.rs b/crates/omloom-store/src/disk/workspace/physical/admission.rs new file mode 100644 index 0000000..0f3fcef --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/admission.rs @@ -0,0 +1,540 @@ +//! Read-only admission shared by transient previews and atomic reservation. +use super::*; +use crate::requirements::{Decision, Reason}; + +pub(super) struct Failure { + pub error: Error, + pub decision: Option, +} +impl From for Failure { + fn from(error: Error) -> Self { + let reason = match error { + Error::Incomplete => Some(Reason::Missing), + Error::Denied => Some(Reason::Restricted), + Error::Conflict => Some(Reason::Conflict), + Error::Limit => Some(Reason::Incomplete), + _ => None, + }; + Self { + error, + decision: reason.map(Decision::unknown), + } + } +} +impl Failure { + pub(super) fn mismatch() -> Self { + Self { + error: Error::Conflict, + decision: Some(Decision::FAIL), + } + } + pub(super) fn basis(error: Error) -> Self { + if matches!(error, Error::Conflict) { + Self { + error, + decision: Some(Decision::unknown(Reason::Stale)), + } + } else { + error.into() + } + } +} + +fn require_mounting( + required: Option<&str>, + actual: Option<&str>, +) -> core::result::Result<(), Failure> { + match (required, actual) { + (Some(required), Some(actual)) if required == actual => Ok(()), + (Some(_), Some(_)) => Err(Failure::mismatch()), + _ => Err(Error::Incomplete.into()), + } +} + +fn component_load_limits( + incoming: AssetRef, + target: &api::PhysicalDefinition, + item: &api::PhysicalDefinition, +) -> core::result::Result, Failure> { + for (limit, demand) in [ + (target.capacity_mw, item.demand_mw), + (target.cooling_capacity_mw, item.cooling_mw), + ] { + if let Some(limit) = limit { + let demand = demand.ok_or(Error::Incomplete)?; + if demand > limit { + return Err(Failure::mismatch()); + } + } + } + Ok(target.max_mass_g.map(|limit| (incoming, limit))) +} + +fn require_coverage( + definition: &api::PhysicalDefinition, + region: crate::physical::Region, +) -> core::result::Result<(), Failure> { + let decision = coverage::assess(definition.occupancy_coverage.as_deref(), region)?; + if decision.state != crate::requirements::State::Satisfied { + return Err(Failure { + error: Error::Incomplete, + decision: Some(decision), + }); + } + Ok(()) +} + +#[derive(Default)] +pub(super) struct Claims { + pub(super) parts: BTreeSet, + positions: BTreeSet, + pub(super) ends: BTreeSet, +} + +impl Tx<'_> { + // The claims were checked in this same transaction; namespace CAS protects final admission. + pub(super) fn acquire_physical_claims(&mut self, claims: Claims, owner: &str) -> Result<()> { + for key in claims.ends { + let parent = key.rsplit_once('/').ok_or(Error::Corrupt)?.0; + self.put( + &key, + parent, + Class::Pending, + props([("owner", text(owner))]), + )?; + } + for (targets, prefix, parent) in [ + (claims.parts, "physical-lock", "physical-locks"), + (claims.positions, "physical-slot", "physical-slots"), + ] { + for target in targets { + self.put( + &format!("{prefix}/{}", network::identity(target)), + parent, + Class::Pending, + props([("owner", text(owner))]), + )?; + } + } + Ok(()) + } + pub(super) fn assess_physical( + &self, + context: &Context, + plan: &api::PhysicalPlan, + working: &api::WorkingContext, + ) -> core::result::Result { + if plan.steps.is_empty() { + return Err(Error::Incomplete.into()); + } + let mut claims = self.check_replacement_capacity(context, plan, working)?; + for allocation in plan.materials.iter().flat_map(|m| &m.allocations) { + claims.parts.insert(parse_target(allocation.item.as_ref())?); + } + let graph = flow::analysis(&plan.steps)?; + self.assess_wiring(context, plan, working, &graph, &mut claims)?; + let profiles = self.placement_profiles(plan)?; + let mut enclosures = BTreeMap::::new(); + let mut rows_read = 0; + for (index, p) in placement_steps(plan) { + let item = p.actual(&plan.materials)?; + let material = p.requirement(&plan.materials)?; + let declared = if item.is_none() { + material.and_then(|m| m.physical.as_deref()) + } else { + None + }; + let enclosure = parse_target(p.enclosure.as_ref())?; + let row = self.visible_asset(context, enclosure)?; + if !context.field( + enclosure, + number(&row, "kind")? as i32, + "physical_availability", + false, + ) { + return Err(Error::Denied.into()); + } + if let Some(item) = item { + claims.parts.insert(item); + let reference = p.reference(&plan.materials)?; + if !self.installed_bindings(item)?.is_empty() + && !replacement_steps(plan).any(|r| r.previous.as_ref() == reference) + { + return Err(Failure::mismatch()); + } + } + if let std::collections::btree_map::Entry::Vacant(entry) = enclosures.entry(enclosure) { + let occupied = + self.physical_use(enclosure, plan, &graph.ancestors, 2048 - rows_read)?; + rows_read += occupied.inspected; + entry.insert(occupied); + } + let occupied = enclosures.get_mut(&enclosure).ok_or(Error::Corrupt)?; + let definition = &occupied.definition; + let mounting = |target: &AssetRef| { + profiles + .get(target) + .and_then(|row| row.get("mounting")) + .and_then(Value::as_str) + }; + let item_mounting = match item { + Some(item) => mounting(&item), + None => declared.and_then(|p| p.mounting.as_deref()), + }; + if mounting(&enclosure).is_some() || item_mounting.is_some() { + require_mounting(mounting(&enclosure), item_mounting)?; + } + let item_volume = match item { + Some(item) => self.equipment_envelope(context, item)?, + None => equipment::declared_envelope(material.ok_or(Error::Invalid)?)?, + } + .ok_or(Error::Incomplete)?; + let requested = p.volume.as_ref().ok_or(Error::Invalid)?; + for (actual, proposed) in [ + (&item_volume.x, &requested.x), + (&item_volume.y, &requested.y), + (&item_volume.z, &requested.z), + ] { + if actual.as_ref().ok_or(Error::Incomplete)?.length_um + > proposed.as_ref().ok_or(Error::Invalid)?.length_um + { + return Err(Failure::mismatch()); + } + } + let envelope = definition.volume.as_ref().map(volume).transpose()?; + let proposed = volume(requested)?; + let margins = item + .and_then(|item| profiles.get(&item)) + .map(clearance::read) + .transpose()? + .flatten(); + let margin = match item { + Some(_) => margins.as_ref(), + None => declared.and_then(|p| p.clearance.as_deref()), + }; + let region = clearance::region(proposed, margin)?; + match crate::physical::placement(envelope, Some(region), &occupied.volumes).state { + crate::requirements::State::Satisfied => {} + crate::requirements::State::Failed => return Err(Failure::mismatch()), + crate::requirements::State::Unknown => return Err(Error::Incomplete.into()), + } + for (removal, prior) in &occupied.conditional_removals { + if index == *removal || graph.ancestors[index] & (1u64 << removal) != 0 { + continue; + } + let prior = prior.ok_or(Error::Incomplete)?; + if crate::physical::placement(envelope, Some(region), core::slice::from_ref(&prior)) + .state + == crate::requirements::State::Failed + { + return Err(Error::Conflict.into()); + } + } + require_coverage(definition, region)?; + occupied.volumes.push(region); + if let Some(item) = item { + occupied.items.insert(item); + } else { + let needed = [ + definition.max_mass_g.is_some(), + definition.capacity_mw.is_some(), + definition.cooling_capacity_mw.is_some(), + ]; + if needed.iter().any(|v| *v) { + occupied.future_loads.push(equipment::declared_loads( + material.ok_or(Error::Invalid)?, + needed, + )?); + } + } + } + // Constraint disclosure never includes hidden occupants or their individual demands. + for (enclosure, occupied) in &enclosures { + let definition = &occupied.definition; + let limits = [ + definition.max_mass_g, + definition.capacity_mw, + definition.cooling_capacity_mw, + ]; + if limits.iter().all(Option::is_none) { + continue; + } + let envelope = definition + .volume + .as_ref() + .map(volume) + .transpose()? + .ok_or(Error::Incomplete)?; + require_coverage(definition, clearance::region(envelope, None)?)?; + if limits[0].is_some() { + for placement in placements(plan) { + if parse_target(placement.enclosure.as_ref())? != *enclosure { + continue; + } + let Some(item) = placement.actual(&plan.materials)? else { + continue; + }; + let root = self.visible_asset(context, item)?; + if !context.field(item, number(&root, "kind")? as i32, "weight", false) { + return Err(Error::Denied.into()); + } + } + } + let mut demands = + self.physical_loads(&occupied.items, limits.map(|value| value.is_some()))?; + for demand in &occupied.future_loads { + equipment::add_loads(&mut demands, *demand)?; + } + for (limit, demand) in limits.into_iter().zip(demands) { + if let Some(limit) = limit { + let demand = demand.ok_or(Error::Incomplete)?; + if demand > limit { + // A conservative bound may include an old load and its successor. + // Do not claim a measured overload when the plan lacks a common release phase. + if !occupied.conditional_removals.is_empty() { + return Err(Error::Incomplete.into()); + } + return Err(Failure::mismatch()); + } + } + } + } + for part in &claims.parts { + self.require_no_pending_operations(*part)?; + self.check_physical_lock("physical-lock", *part, plan.id.as_deref())?; + } + for position in &claims.positions { + self.check_physical_lock("physical-slot", *position, plan.id.as_deref())?; + } + Ok(claims) + } + + fn placement_profiles( + &self, + plan: &api::PhysicalPlan, + ) -> Result> { + if plan.steps.len() > 64 { + return Err(Error::Limit); + } + let mut selected = BTreeSet::new(); + for placement in placements(plan) { + if let Some(item) = placement.actual(&plan.materials)? { + selected.insert(item); + } + selected.insert(parse_target(placement.enclosure.as_ref())?); + } + if selected.is_empty() { + return Ok(BTreeMap::new()); + } + let keys = selected + .iter() + .map(|target| text(format!("physical/{}", network::identity(*target)))) + .collect(); + let rows=self.query("UNWIND $keys AS key MATCH (n:OmRow {key:key}) RETURN {asset:n.asset,incarnation:n.incarnation,mounting:n.mounting,counts:n.counts} AS p LIMIT 128",params([("keys",Value::Array(keys))]))?; + let mut values = BTreeMap::new(); + for row in rows { + values.insert(target(&row)?, row); + } + Ok(values) + } + + pub(in crate::disk) fn check_physical_lock( + &self, + prefix: &str, + target: AssetRef, + owner: Option<&str>, + ) -> Result<()> { + if let Some(row) = self.get(&format!("{prefix}/{}", network::identity(target)))? { + let actual = string(&row, "owner")?; + if Some(actual.as_str()) != owner { + return Err(Error::Conflict); + } + } + Ok(()) + } + + fn check_replacement_capacity( + &self, + context: &Context, + plan: &api::PhysicalPlan, + working: &api::WorkingContext, + ) -> core::result::Result { + let mut claims = Claims::default(); + let mut mass_limits = Vec::new(); + let removed: BTreeSet<_> = replacement_steps(plan) + .filter_map(|replacement| replacement.previous_binding.as_deref()) + .collect(); + for replacement in replacement_steps(plan) { + self.replacement_access(context, replacement, working, false)?; + let destination = parse_target(replacement.position.as_ref())?; + let destination_kind = wire::AssetKind::try_from( + i32::try_from(number(&self.asset(destination)?, "kind")?) + .map_err(|_| Error::Corrupt)?, + ) + .map_err(|_| Error::Corrupt)?; + if !context.field( + destination, + destination_kind as i32, + "physical_availability", + false, + ) { + return Err(Error::Denied.into()); + } + self.replacement_basis(replacement) + .map_err(Failure::basis)?; + self.require_replacement_space(replacement, &removed, destination_kind) + .map_err(|error| { + if matches!(error, Error::Conflict) { + Failure::mismatch() + } else { + error.into() + } + })?; + if replacement.previous.is_none() { + let position = self + .get(&format!("position/{}", network::identity(destination)))? + .ok_or(Error::Incomplete)?; + if position.get("vflag") != Some(&Value::Bool(true)) { + return Err(Error::Incomplete.into()); + } + } + if let Some(incoming) = &replacement.replacement { + let incoming = parse_target(Some(incoming))?; + if destination_kind == wire::AssetKind::Rack && replacement.volume.is_none() { + return Err(Error::Incomplete.into()); + } + let item = self + .physical_definition(incoming)? + .ok_or(Error::Incomplete)?; + let target = self + .physical_definition(destination)? + .ok_or(Error::Incomplete)?; + if replacement.volume.is_none() + && let Some(limit) = component_load_limits(incoming, &target, &item)? + { + mass_limits.push(limit); + } + if destination_kind == wire::AssetKind::PhysicalPosition { + self.require_position_class(destination, item.component_kind)?; + } + require_mounting(target.mounting.as_deref(), item.mounting.as_deref())?; + let target_volume = target + .volume + .as_ref() + .map(volume) + .transpose()? + .ok_or(Error::Incomplete)?; + let item_volume = self + .equipment_envelope(context, incoming)? + .as_ref() + .map(volume) + .transpose()? + .ok_or(Error::Incomplete)?; + let locate = |target: Span, item: Span| { + Span::new( + target.start(), + u64::try_from(item.end() - item.start()).map_err(|_| Error::Corrupt)?, + ) + .map_err(|_| Error::Invalid) + }; + let located = Volume { + x: locate(target_volume.x, item_volume.x)?, + y: locate(target_volume.y, item_volume.y)?, + z: locate(target_volume.z, item_volume.z)?, + }; + let region = clearance::region(located, item.clearance.as_deref())?; + if crate::physical::placement(Some(target_volume), Some(region), &[]).state + != crate::requirements::State::Satisfied + { + return Err(Failure::mismatch()); + } + } + for part in [ + replacement.previous.as_ref(), + replacement.replacement.as_ref(), + ] + .into_iter() + .flatten() + { + claims.parts.insert(parse_target(Some(part))?); + } + if destination_kind == wire::AssetKind::PhysicalPosition { + claims.positions.insert(destination); + } + if let Some(source) = &replacement.source_position { + let source = parse_target(Some(source))?; + if source != destination + && number(&self.asset(source)?, "kind")? + == wire::AssetKind::PhysicalPosition as u64 + { + claims.positions.insert(source); + } + } + } + self.check_replacement_masses(context, &mass_limits)?; + Ok(claims) + } + + fn require_position_class( + &self, + destination: AssetRef, + incoming: i32, + ) -> core::result::Result<(), Failure> { + let position = self + .get(&format!("position/{}", network::identity(destination)))? + .ok_or(Error::Corrupt)?; + let expected = number(&position, "vint")?; + if expected == api::PhysicalComponentKind::Unspecified as u64 + || incoming == api::PhysicalComponentKind::Unspecified as i32 + { + return Err(Error::Incomplete.into()); + } + if expected != incoming as u64 { + return Err(Failure::mismatch()); + } + Ok(()) + } + + fn check_replacement_masses( + &self, + context: &Context, + mass_limits: &[(AssetRef, u64)], + ) -> core::result::Result<(), Failure> { + if !mass_limits.is_empty() { + let selected: BTreeSet<_> = mass_limits.iter().map(|(item, _)| *item).collect(); + let keys = selected.iter().map(|item| text(asset_key(*item))).collect(); + let metadata = self.query("UNWIND $keys AS key MATCH (n:OmRow {key:key}) RETURN {asset:n.asset,incarnation:n.incarnation,kind:n.kind} AS p LIMIT 64", params([("keys", Value::Array(keys))]))?; + let metadata = metadata + .into_iter() + .map(|row| Ok((target(&row)?, row))) + .collect::>>()?; + for item in &selected { + let row = metadata.get(item).ok_or(Error::Conflict)?; + if !context.field(*item, number(row, "kind")? as i32, "weight", false) { + return Err(Error::Denied.into()); + } + } + let keys = selected + .iter() + .map(|item| text(format!("{}/intent/weight", asset_key(*item)))) + .collect(); + let rows = self.query( + "UNWIND $keys AS key MATCH (n:OmRow {key:key}) RETURN properties(n) AS p LIMIT 64", + params([("keys", Value::Array(keys))]), + )?; + let rows = rows + .into_iter() + .map(|row| Ok((string(&row, "key")?, row))) + .collect::>>()?; + for &(item, limit) in mass_limits { + let row = rows.get(&format!("{}/intent/weight", asset_key(item))); + let demand = equipment::row_loads(None, row, [true, false, false])?[0] + .ok_or(Error::Incomplete)?; + if demand > limit { + return Err(Failure::mismatch()); + } + } + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/availability.rs b/crates/omloom-store/src/disk/workspace/physical/availability.rs new file mode 100644 index 0000000..33fb157 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/availability.rs @@ -0,0 +1,282 @@ +//! Explicit disclosure profiles derive availability from current authoritative bindings. +use super::*; +use crate::requirements::{Decision, Reason, State}; + +fn offer_key(id: &str) -> Result { + id.parse::().map_err(|_| Error::Invalid)?; + Ok(format!("physical-offer/{id}")) +} +fn offer_parent(enclosure: AssetRef) -> String { + format!("physical-offers/{}", network::identity(enclosure)) +} +fn offer(row: &Properties) -> Result { + let flags = number(row, "vint")?; + if flags > 7 { + return Err(Error::Corrupt); + } + let key = string(row, "key")?; + Ok(api::PhysicalOffer { + id: Some( + key.strip_prefix("physical-offer/") + .ok_or(Error::Corrupt)? + .into(), + ), + enclosure: Some(target_wire(target(row)?)), + region: read_volume(row)?, + recipient: string(row, "source")?, + name: string(row, "name")?, + revision: number(row, "revision")?, + revoked: row.get("vflag") == Some(&Value::Bool(true)), + mass: flags & 1 != 0, + power: flags & 2 != 0, + cooling: flags & 4 != 0, + }) +} +impl Tx<'_> { + fn offer_publisher( + &self, + context: &Context, + enclosure: AssetRef, + working: &api::WorkingContext, + write: bool, + ) -> Result<()> { + self.selection(context, working, write)? + .contains(enclosure, self)?; + let row = self.visible_asset(context, enclosure)?; + let kind = number(&row, "kind")? as i32; + if !context.field(enclosure, kind, "physical_availability", write) { + return Err(Error::Denied); + } + if !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Rack | wire::AssetKind::PhysicalPosition | wire::AssetKind::Device) + ) { + return Err(Error::Invalid); + } + Ok(()) + } + pub(super) fn save_physical_offer( + &mut self, + context: &Context, + value: api::PhysicalOfferEdit, + working: &api::WorkingContext, + now: u64, + ) -> Result { + let mut value = value.offer.ok_or(Error::Invalid)?; + let enclosure = parse_target(value.enclosure.as_ref())?; + self.offer_publisher(context, enclosure, working, true)?; + for (value, max) in [(&value.name, 160), (&value.recipient, 128)] { + if value.trim().is_empty() || value.len() > max || value.chars().any(char::is_control) { + return Err(Error::Invalid); + } + } + let region = volume(value.region.as_ref().ok_or(Error::Invalid)?)?; + if !value.revoked { + let root = self.asset(enclosure)?; + if !context.field(enclosure, number(&root, "kind")? as i32, "physical", false) { + return Err(Error::Denied); + } + let definition = self + .physical_definition(enclosure)? + .ok_or(Error::Incomplete)?; + let envelope = definition + .volume + .as_ref() + .map(volume) + .transpose()? + .ok_or(Error::Incomplete)?; + if !envelope.contains(region) { + return Err(Error::Invalid); + } + } + let key = match &value.id { + Some(id) => offer_key(id)?, + None => format!("physical-offer/{}", AuthorityId::generate()), + }; + if let Some(prior) = self.get(&key)? { + if string(&prior, "owner")? != context.actor { + return Err(Error::Denied); + } + if target(&prior)? != enclosure || number(&prior, "revision")? != value.revision { + return Err(Error::Conflict); + } + if offer(&prior)? == value { + return Ok(value); + } + } else { + if value.id.is_some() || value.revision != 0 { + return Err(Error::Missing); + } + let rows = self.query( + "MATCH (n:OmRow {parent:$parent}) RETURN {key:n.key} AS p LIMIT 65", + params([("parent", text(offer_parent(enclosure)))]), + )?; + if rows.len() >= 64 { + return Err(Error::Limit); + } + } + value.revision = number(&self.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?; + let mut row = target_props(enclosure); + row.extend(props([ + ("owner", text(&context.actor)), + ("name", text(&value.name)), + ("source", text(&value.recipient)), + ("vflag", Value::Bool(value.revoked)), + ( + "vint", + int(u64::from(value.mass) + | u64::from(value.power) << 1 + | u64::from(value.cooling) << 2)?, + ), + ("revision", int(value.revision)?), + ])); + write_volume(&mut row, value.region.as_ref().ok_or(Error::Invalid)?)?; + self.put(&key, &offer_parent(enclosure), Class::Inventory, row)?; + self.audit(&key, &context.actor, "physical-offer", value.revision, now)?; + value.id = Some( + key.strip_prefix("physical-offer/") + .ok_or(Error::Corrupt)? + .into(), + ); + Ok(value) + } + pub(super) fn physical_offers( + &self, + context: &Context, + request: api::PhysicalOffers, + working: &api::WorkingContext, + ) -> Result<(Vec, Option)> { + let enclosure = parse_target(request.enclosure.as_ref())?; + self.offer_publisher(context, enclosure, working, false)?; + let after = request + .after + .as_deref() + .map(offer_key) + .transpose()? + .unwrap_or_default(); + let rows=self.query("MATCH (n:OmRow {parent:$parent}) WHERE n.owner=$owner AND n.key>$after RETURN properties(n) AS p ORDER BY n.key LIMIT 26",params([("parent",text(offer_parent(enclosure))),("owner",text(&context.actor)),("after",text(after))]))?; + let offers = rows + .iter() + .take(25) + .map(offer) + .collect::>>()?; + let next = if rows.len() > 25 { + offers.last().and_then(|o| o.id.clone()) + } else { + None + }; + Ok((offers, next)) + } + pub(super) fn offered_availability( + &self, + context: &Context, + request: api::PhysicalOfferRead, + ) -> Result<(api::PhysicalOffer, Decision)> { + let row = self.get(&offer_key(&request.id)?)?.ok_or(Error::Denied)?; + let enclosure = target(&row)?; + let root = self.asset(enclosure)?; + if (string(&row, "source")? != context.actor && string(&row, "owner")? != context.actor) + || !context.field( + enclosure, + number(&root, "kind")? as i32, + "physical_availability", + false, + ) + || row.get("vflag") == Some(&Value::Bool(true)) + { + return Err(Error::Denied); + } + let offer = offer(&row)?; + if self.root.get("restored") == Some(&Value::Bool(true)) { + return Ok((offer, Decision::unknown(Reason::Stale))); + } + let assessment = (|| -> Result { + let occupied = self.physical_use( + enclosure, + &api::PhysicalPlan::default(), + &[0; crate::plans::MAX_STEPS], + 2048, + )?; + let definition = &occupied.definition; + let region = + clearance::region(volume(offer.region.as_ref().ok_or(Error::Corrupt)?)?, None)?; + let state = crate::physical::placement( + definition.volume.as_ref().map(volume).transpose()?, + Some(region), + &occupied.volumes, + ); + if state.state != State::Satisfied { + return Ok(state); + } + let knowledge = coverage::assess(definition.occupancy_coverage.as_deref(), region)?; + if knowledge.state != State::Satisfied { + return Ok(knowledge); + } + let selected = [offer.mass, offer.power, offer.cooling]; + if selected.iter().any(|&v| v) { + let envelope = definition + .volume + .as_ref() + .map(volume) + .transpose()? + .ok_or(Error::Incomplete)?; + let knowledge = coverage::assess( + definition.occupancy_coverage.as_deref(), + clearance::region(envelope, None)?, + )?; + if knowledge.state != State::Satisfied { + return Ok(knowledge); + } + let mut demand = self.physical_loads(&occupied.items, selected)?; + for future in &occupied.future_loads { + equipment::add_loads(&mut demand, *future)?; + } + for ((selected, limit), demand) in selected + .into_iter() + .zip([ + definition.max_mass_g, + definition.capacity_mw, + definition.cooling_capacity_mw, + ]) + .zip(demand) + { + if !selected { + continue; + } + let (Some(limit), Some(demand)) = (limit, demand) else { + return Ok(Decision::unknown(Reason::Missing)); + }; + if demand > limit { + return Ok(Decision::FAIL); + } + } + } + Ok(Decision::PASS) + })(); + let result = match assessment { + Ok(result) => result, + Err(Error::Incomplete | Error::Missing) => Decision::unknown(Reason::Missing), + Err(Error::Limit) => Decision::unknown(Reason::Incomplete), + Err(error) => return Err(error), + }; + Ok((offer, result)) + } + pub(in crate::disk::workspace) fn remove_physical_offers( + &mut self, + enclosure: AssetRef, + ) -> Result<()> { + let rows = self.query( + "MATCH (n:OmRow {parent:$parent}) RETURN {key:n.key} AS p LIMIT 65", + params([("parent", text(offer_parent(enclosure)))]), + )?; + if rows.len() > 64 { + return Err(Error::Corrupt); + } + for row in rows { + self.delete(&string(&row, "key")?)?; + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/cables.rs b/crates/omloom-store/src/disk/workspace/physical/cables.rs new file mode 100644 index 0000000..811d852 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/cables.rs @@ -0,0 +1,426 @@ +//! Named cable-end definitions, independent of their replaceable attachments. +use super::*; + +fn parent(cable: AssetRef) -> String { + format!("cable-ends/{}", network::identity(cable)) +} +fn token(value: &str) -> Result<()> { + if value.is_empty() + || value.len() > 64 + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.:-".contains(&b)) + { + return Err(Error::Invalid); + } + Ok(()) +} +fn end(row: &Properties) -> Result { + let key = row + .get("key") + .and_then(Value::as_str) + .and_then(|s| s.rsplit_once('/')) + .map(|(_, key)| key) + .ok_or(Error::Corrupt)?; + token(key).map_err(|_| Error::Corrupt)?; + Ok(api::PhysicalCableEnd { + key: key.into(), + name: string(row, "name")?, + basis: string(row, "basis")?, + connector: row + .get("meaning") + .map(|_| string(row, "meaning")) + .transpose()?, + revision: number(row, "revision")?, + attachment: None, + intended_port: None, + mating: connector::read(row)?, + channels: read_channels(row)?, + }) +} +pub(super) fn normalize_channels(channels: &mut [u32]) -> Result<()> { + if channels.len() > 64 { + return Err(Error::Limit); + } + channels.sort_unstable(); + let mut previous = 0; + for channel in channels { + if *channel <= previous || *channel > 4096 { + return Err(Error::Invalid); + } + previous = *channel; + } + Ok(()) +} +fn read_channels(row: &Properties) -> Result> { + let Some(Value::Array(values)) = row.get("counts") else { + if row.contains_key("counts") { + return Err(Error::Corrupt); + } + return Ok(Vec::new()); + }; + if values.len() > 64 { + return Err(Error::Corrupt); + } + let mut result = Vec::with_capacity(values.len()); + let mut previous = 0; + for value in values { + let Value::Int(value) = value else { + return Err(Error::Corrupt); + }; + let value = u32::try_from(*value).map_err(|_| Error::Corrupt)?; + if value <= previous || value > 4096 { + return Err(Error::Corrupt); + } + result.push(value); + previous = value; + } + Ok(result) +} +impl Tx<'_> { + pub(super) fn save_cable_end( + &mut self, + context: &Context, + value: api::PhysicalCableEndEdit, + working: &api::WorkingContext, + now: u64, + ) -> Result { + let cable = parse_target(value.cable.as_ref())?; + if self.physical_access(context, cable, working, true)? != wire::AssetKind::Cable { + return Err(Error::Invalid); + } + let mut proposed = value.end.ok_or(Error::Invalid)?; + normalize_channels(&mut proposed.channels)?; + if proposed.attachment.is_some() || proposed.intended_port.is_some() { + return Err(Error::Invalid); + } + token(&proposed.key)?; + for (text, max) in [(&proposed.name, 128), (&proposed.basis, 512)] { + if text.trim().is_empty() || text.len() > max || text.chars().any(char::is_control) { + return Err(Error::Invalid); + } + } + if proposed.connector.as_ref().is_some_and(|s| { + s.trim().is_empty() || s.len() > 128 || s.chars().any(char::is_control) + }) { + return Err(Error::Invalid); + } + let parent = parent(cable); + let key = format!("{parent}/{}", proposed.key); + let prior = self.get(&key)?; + if prior + .as_ref() + .map(|row| number(row, "revision")) + .transpose()? + != value.expected + { + return Err(Error::Conflict); + } + if let Some(row) = &prior { + let previous = end(row)?; + if previous.name == proposed.name + && previous.basis == proposed.basis + && previous.connector == proposed.connector + && previous.mating == proposed.mating + && previous.channels == proposed.channels + { + return Ok(previous); + } + } else { + let existing = self.query( + "MATCH (n:OmRow {parent:$parent}) RETURN {key:n.key} AS p LIMIT 65", + params([("parent", text(&parent))]), + )?; + if existing.len() >= 64 { + return Err(Error::Limit); + } + } + if !proposed.channels.is_empty() { + // At most 64 real end definitions, not one inventory object per lane. + // A physical channel has at most two ends even in a breakout assembly. + let rows = self.query( + "MATCH (n:OmRow {parent:$parent}) RETURN properties(n) AS p LIMIT 65", + params([("parent", text(&parent))]), + )?; + if rows.len() > 64 { + return Err(Error::Corrupt); + } + let mut counts = BTreeMap::new(); + for row in rows { + if string(&row, "key")? == key { + continue; + } + for channel in read_channels(&row)? { + *counts.entry(channel).or_insert(0usize) += 1; + } + } + if proposed + .channels + .iter() + .any(|channel| counts.get(channel).is_some_and(|count| *count >= 2)) + { + return Err(Error::Conflict); + } + } + let revision = match prior.as_ref() { + Some(row) => number(row, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?, + None => 1, + }; + let mut row = prior.unwrap_or_default(); + operations::strip_envelope(&mut row); + row.extend(props([ + ("name", text(&proposed.name)), + ("basis", text(&proposed.basis)), + ("revision", int(revision)?), + ])); + if let Some(connector) = &proposed.connector { + row.insert("meaning".into(), text(connector)); + } else { + row.remove("meaning"); + } + connector::store(&mut row, proposed.mating.as_ref())?; + if proposed.channels.is_empty() { + row.remove("counts"); + } else { + row.insert( + "counts".into(), + Value::Array( + proposed + .channels + .iter() + .map(|v| Value::Int(i64::from(*v))) + .collect(), + ), + ); + } + self.put(&key, &parent, Class::Inventory, row)?; + self.audit( + &format!("{}/{}", cable.asset, proposed.key), + &context.actor, + "cable-end", + revision, + now, + )?; + Ok(api::PhysicalCableEnd { + revision, + ..proposed + }) + } + pub(super) fn cable_ends( + &self, + context: &Context, + value: api::PhysicalCableEnds, + working: &api::WorkingContext, + ) -> Result<(Vec, Option)> { + let cable = parse_target(value.cable.as_ref())?; + if self.physical_access(context, cable, working, false)? != wire::AssetKind::Cable { + return Err(Error::Invalid); + } + if let Some(after) = &value.after { + token(after)?; + } + let parent = parent(cable); + let after = match value.after { + Some(value) => format!("{parent}/{value}"), + None => String::new(), + }; + let rows=self.query("MATCH (n:OmRow {parent:$parent}) WHERE n.key>$after RETURN properties(n) AS p ORDER BY n.key LIMIT 26",params([("parent",text(parent)),("after",text(after))]))?; + let selected = &rows[..rows.len().min(25)]; + let mut ends = selected.iter().map(end).collect::>>()?; + self.project_cable_attachments(context, cable, selected, &mut ends)?; + let next = if rows.len() > 25 { + Some(ends.last().ok_or(Error::Corrupt)?.key.clone()) + } else { + None + }; + Ok((ends, next)) + } + pub(in crate::disk::workspace) fn remove_cable_ends(&mut self, cable: AssetRef) -> Result<()> { + let rows = self.query( + "MATCH (n:OmRow {parent:$parent}) RETURN {key:n.key} AS p LIMIT 65", + params([("parent", text(parent(cable)))]), + )?; + if rows.len() > 64 { + return Err(Error::Corrupt); + } + for row in rows { + self.delete(&string(&row, "key")?)?; + } + Ok(()) + } +} + +pub(super) fn intended(row: &Properties) -> Result> { + match (row.get("to_asset"), row.get("to_incarnation")) { + (None, None) => Ok(None), + (Some(asset), Some(incarnation)) => Ok(Some(AssetRef { + asset: asset + .as_str() + .ok_or(Error::Corrupt)? + .parse() + .map_err(|_| Error::Corrupt)?, + incarnation: incarnation + .as_str() + .ok_or(Error::Corrupt)? + .parse() + .map_err(|_| Error::Corrupt)?, + })), + _ => Err(Error::Corrupt), + } +} +pub(super) fn attached_port(binding: &wire::Relation, cable: AssetRef) -> Result { + let from = parse_target(binding.from.as_ref())?; + let to = parse_target(binding.to.as_ref())?; + if binding.kind != wire::RelationKind::ConnectedTo as i32 { + return Err(Error::Corrupt); + } + if from == cable { + Ok(to) + } else if to == cable { + Ok(from) + } else { + Err(Error::Corrupt) + } +} +impl Tx<'_> { + fn project_cable_attachments( + &self, + context: &Context, + cable: AssetRef, + rows: &[Properties], + output: &mut [api::PhysicalCableEnd], + ) -> Result<()> { + if !context.field(cable, wire::AssetKind::Cable as i32, "relations", false) { + return Ok(()); + } + let mut keys = Vec::with_capacity(rows.len()); + for row in rows { + if row.get("state") == Some(&Value::Int(1)) + && let Some(id) = row.get("binding") + { + keys.push(text(format!( + "relation/{}", + id.as_str().ok_or(Error::Corrupt)? + ))); + } + } + let mut bindings = BTreeMap::new(); + let mut ports = BTreeSet::new(); + if !keys.is_empty() { + for row in self.query( + "UNWIND $keys AS key MATCH (r:OmRow {key:key}) RETURN properties(r) AS p LIMIT 25", + params([("keys", Value::Array(keys))]), + )? { + let binding = inventory::relation_projection(&row)?; + ports.insert(attached_port(&binding, cable)?); + if bindings + .insert(binding.relation_id.clone(), binding) + .is_some() + { + return Err(Error::Corrupt); + } + } + } + for row in rows { + if let Some(port) = intended(row)? { + ports.insert(port); + } + } + let mut visible = BTreeSet::new(); + if !ports.is_empty() { + let keys = ports.iter().map(|p| text(asset_key(*p))).collect(); + for row in self.query("UNWIND $keys AS key MATCH (p:OmRow {key:key}) RETURN {asset:p.asset,incarnation:p.incarnation,kind:p.kind,adoption:p.adoption} AS p LIMIT 50",params([("keys",Value::Array(keys))]))? { + let target=target(&row)?; + if number(&row,"kind")?==wire::AssetKind::Port as u64&&number(&row,"adoption")?==wire::Adoption::Adopted as u64&&context.readable(target,wire::AssetKind::Port as i32)&&context.field(target,wire::AssetKind::Port as i32,"relations",false){visible.insert(target);} + } + } + for (row, result) in rows.iter().zip(output) { + if let Some(port) = intended(row)? + && visible.contains(&port) + { + result.intended_port = Some(target_wire(port)); + } + if row.get("state") == Some(&Value::Int(2)) { + if row.contains_key("binding") { + return Err(Error::Corrupt); + } + result.attachment = Some(api::physical_cable_end::Attachment::Loose(api::Empty {})); + continue; + } + if row.get("state") != Some(&Value::Int(1)) { + continue; + } + let Some(id) = row.get("binding").and_then(Value::as_str) else { + continue; + }; + let Some(binding) = bindings.get(id) else { + continue; + }; + let port = attached_port(binding, cable)?; + if binding.revision == number(row, "expected")? && visible.contains(&port) { + result.attachment = Some(api::physical_cable_end::Attachment::Connected( + api::PhysicalCableAttachment { + port: Some(target_wire(port)), + binding_id: binding.relation_id.clone(), + binding_revision: binding.revision, + }, + )); + } + } + Ok(()) + } +} + +impl Tx<'_> { + // A generic binding edit cannot silently move an identified cable end. + pub(in crate::disk) fn invalidate_cable_attachment( + &mut self, + relation: &wire::Relation, + skip: Option<&str>, + retained_revision: Option, + ) -> Result<()> { + if relation.kind != wire::RelationKind::ConnectedTo as i32 { + return Ok(()); + } + let from = parse_target(relation.from.as_ref())?; + let to = parse_target(relation.to.as_ref())?; + let cable = if number(&self.asset(from)?, "kind")? == wire::AssetKind::Cable as u64 { + from + } else if number(&self.asset(to)?, "kind")? == wire::AssetKind::Cable as u64 { + to + } else { + return Err(Error::Corrupt); + }; + let rows=self.query("MATCH (n:OmRow {parent:$parent}) WHERE n.binding=$binding RETURN properties(n) AS p LIMIT 2",params([("parent",text(parent(cable))),("binding",text(&relation.relation_id))]))?; + if rows.len() > 1 { + return Err(Error::Corrupt); + } + for mut row in rows { + let key = string(&row, "key")?; + if skip == Some(key.as_str()) { + continue; + } + if retained_revision.is_none() { + return Err(Error::Conflict); + } + operations::strip_envelope(&mut row); + if let Some(revision) = retained_revision { + row.insert("expected".into(), int(revision)?); + } else { + row.remove("binding"); + row.remove("expected"); + row.remove("state"); + } + row.insert( + "revision".into(), + int(number(&row, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?)?, + ); + self.put(&key, &parent(cable), Class::Inventory, row)?; + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/clearance.rs b/crates/omloom-store/src/disk/workspace/physical/clearance.rs new file mode 100644 index 0000000..1e0e32a --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/clearance.rs @@ -0,0 +1,84 @@ +//! Six current margins use the existing typed numeric-array column; expanded geometry is never persisted. +use super::*; +use crate::physical::Region; + +pub(super) fn write(row: &mut Properties, value: Option<&api::PhysicalClearance>) -> Result<()> { + if let Some(v) = value { + let values = [ + v.before_x_um, + v.after_x_um, + v.before_y_um, + v.after_y_um, + v.before_z_um, + v.after_z_um, + ]; + row.insert( + "counts".into(), + Value::Array(values.into_iter().map(int).collect::>>()?), + ); + } + Ok(()) +} +pub(super) fn read(row: &Properties) -> Result> { + let values = match row.get("counts") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(values)) if values.len() == 6 => values, + _ => return Err(Error::Corrupt), + }; + let number = |i: usize| -> Result { + u64::try_from(values[i].as_int().ok_or(Error::Corrupt)?).map_err(|_| Error::Corrupt) + }; + Ok(Some(api::PhysicalClearance { + before_x_um: number(0)?, + after_x_um: number(1)?, + before_y_um: number(2)?, + after_y_um: number(3)?, + before_z_um: number(4)?, + after_z_um: number(5)?, + })) +} +pub(super) fn region(body: Volume, margin: Option<&api::PhysicalClearance>) -> Result { + let service = match margin { + None => body, + Some(m) => Volume { + x: body + .x + .expand(m.before_x_um, m.after_x_um) + .map_err(|_| Error::Invalid)?, + y: body + .y + .expand(m.before_y_um, m.after_y_um) + .map_err(|_| Error::Invalid)?, + z: body + .z + .expand(m.before_z_um, m.after_z_um) + .map_err(|_| Error::Invalid)?, + }, + }; + Region::new(body, service).map_err(|_| Error::Invalid) +} +impl Tx<'_> { + pub(super) fn current_regions( + &self, + bodies: &[(AssetRef, Volume)], + items: &BTreeSet, + ) -> Result> { + if bodies.len() > 2048 || items.len() > 2048 { + return Err(Error::Limit); + } + let mut margins = BTreeMap::new(); + if !items.is_empty() { + let keys = items + .iter() + .map(|item| text(format!("physical/{}", network::identity(*item)))) + .collect(); + for row in self.query("UNWIND $keys AS key MATCH (n:OmRow {key:key}) WHERE n.counts IS NOT NULL RETURN {asset:n.asset,incarnation:n.incarnation,counts:n.counts} AS p LIMIT 2048",params([("keys",Value::Array(keys))]))?{ + if let Some(value)=read(&row)?{margins.insert(target(&row)?,value);} + } + } + bodies + .iter() + .map(|(item, body)| region(*body, margins.get(item))) + .collect() + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/completion.rs b/crates/omloom-store/src/disk/workspace/physical/completion.rs new file mode 100644 index 0000000..a4ba0be --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/completion.rs @@ -0,0 +1,394 @@ +use super::*; + +pub(super) struct PlacementEffect<'a> { + pub(super) proposal: &'a api::PhysicalPlacement, + pub(super) item: AssetRef, +} + +impl Tx<'_> { + fn readmit_physical_replacements( + &mut self, + context: &Context, + plan: &api::PhysicalPlan, + selected: &BTreeMap, + working: &api::WorkingContext, + ) -> Result<()> { + let id = plan.id.as_deref().ok_or(Error::Corrupt)?; + let mut proposal = api::PhysicalPlan { + id: Some(id.into()), + ..Default::default() + }; + let mut indexes = [None; crate::plans::MAX_STEPS]; + for (&index, &done) in selected { + let step = &plan.steps[index as usize]; + if !done || step.state == api::PhysicalStepState::Recorded as i32 { + continue; + } + let Some(api::physical_step::Action::Replacement(replacement)) = &step.action else { + continue; + }; + if replacement.volume.is_some() { + let row = self + .get(&reservations::reservation_key(id, index as usize))? + .ok_or(Error::Conflict)?; + if string(&row, "claim")? != format!("plan/{id}/{index}") { + return Err(Error::Corrupt); + } + self.clear_physical_reservation(id, index as usize)?; + } + indexes[index as usize] = Some(proposal.steps.len() as u32); + proposal.steps.push(step.clone()); + } + // External prerequisites were checked on the original graph. Preserve ordering among + // this atomic batch, without replaying recorded steps or counting its holds twice. + for step in &mut proposal.steps { + if let Some(control) = &mut step.control { + control.after.retain_mut(|dependency| { + if let Some(index) = indexes[dependency.step as usize] { + dependency.step = index; + true + } else { + false + } + }); + } + } + self.assess_physical(context, &proposal, working) + .map_err(|failure| failure.error)?; + Ok(()) + } + pub(super) fn installed_bindings(&self, item: AssetRef) -> Result> { + let rows=self.query("MATCH (a:OmRow {key:$key})-[:OmRelationFrom]->(r:OmRow) WHERE r.kind=$kind RETURN properties(r) AS p LIMIT 2",params([("key",text(asset_key(item))),("kind",int(wire::RelationKind::InstalledIn as u64)?)]))?; + if rows.len() > 1 { + return Err(Error::Conflict); + } + rows.iter().map(inventory::relation_projection).collect() + } + pub(super) fn record_physical_placement( + &mut self, + context: &Context, + effect: PlacementEffect<'_>, + report: &api::PhysicalCompletion, + index: usize, + working: &api::WorkingContext, + now: u64, + ) -> Result<()> { + let plan = report.plan.as_ref().ok_or(Error::Invalid)?.id.as_str(); + let placement = effect.proposal; + let item = effect.item; + let enclosure = parse_target(placement.enclosure.as_ref())?; + self.physical_access(context, item, working, true)?; + self.physical_access(context, enclosure, working, true)?; + self.require_no_pending_operations(item)?; + let reservation = self.get(&reservations::reservation_key(plan, index))?; + if reservation.is_none() && !report.actual_state { + return Err(Error::Conflict); + } + if let Some(row) = &reservation + && string(row, "claim")? != format!("plan/{plan}/{index}") + { + return Err(Error::Corrupt); + } + if let Some(lock) = self.get(&format!("physical-lock/{}", network::identity(item)))? + && string(&lock, "owner")? != plan + { + return Err(Error::Conflict); + } + // Consume this hold inside the same transaction so re-admission does not count it twice. + // Any rejected report rolls back the consumption together with all other effects. + self.clear_physical_reservation(plan, index)?; + if !report.actual_state { + let proposal = api::PhysicalPlan { + id: Some(plan.into()), + steps: vec![api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: Some(target_wire(item)), + enclosure: placement.enclosure.clone(), + volume: placement.volume, + material: None, + }, + )), + ..Default::default() + }], + ..Default::default() + }; + self.assess_physical(context, &proposal, working) + .map_err(|failure| failure.error)?; + } + let existing = self.installed_bindings(item)?; + let binding = if let Some(existing) = existing.first() { + if existing.to != placement.enclosure { + return Err(Error::UnsafeTransition); + } + existing.clone() + } else { + let relation = wire::Relation { + relation_id: RelationId::generate().to_string(), + from: Some(target_wire(item)), + to: Some(target_wire(enclosure)), + kind: wire::RelationKind::InstalledIn as i32, + revision: 1, + authority_id: self.workspace_authority()?, + }; + let selection = self.selection(context, working, true)?; + self.check_relationship(context, &selection, &relation)?; + self.save_physical_relation(&relation, &context.actor, now, plan, report.actual_state)?; + relation + }; + self.store_physical_pose( + &binding, + placement.volume.as_ref().ok_or(Error::Invalid)?, + &context.actor, + )?; + Ok(()) + } + pub(super) fn complete_physical_steps( + &mut self, + context: &Context, + value: api::PhysicalCompletion, + working: &api::WorkingContext, + now: u64, + ) -> Result { + let key = value.plan.as_ref().ok_or(Error::Invalid)?; + if key.revision == 0 + || value.completed_steps.len() + value.not_performed_steps.len() > 64 + || value.discrepancy.as_ref().is_some_and(|s| s.len() > 2048) + { + return Err(Error::Invalid); + } + let mut plan = self.load_physical_plan(context, key, working, true)?; + self.refresh_plan_control(context, &mut plan, working, now)?; + let state = api::PhysicalPlanState::try_from(plan.state).map_err(|_| Error::Corrupt)?; + if !matches!( + state, + api::PhysicalPlanState::Reserved + | api::PhysicalPlanState::Partial + | api::PhysicalPlanState::Cancelled + ) && !(value.actual_state && state == api::PhysicalPlanState::Draft) + { + return Err(Error::UnsafeTransition); + } + let mut selected = BTreeMap::new(); + for (indexes, done) in [ + (&value.completed_steps, true), + (&value.not_performed_steps, false), + ] { + for index in indexes { + if *index as usize >= plan.steps.len() || selected.insert(*index, done).is_some() { + return Err(Error::Invalid); + } + } + } + for (&index, &done) in &selected { + let step = &plan.steps[index as usize]; + if done + && step.state != api::PhysicalStepState::Recorded as i32 + && step.readiness != api::PlanReadiness::Ready as i32 + { + if !value.actual_state { + return Err(Error::UnsafeTransition); + } + if value + .discrepancy + .as_ref() + .is_none_or(|s| s.trim().is_empty()) + { + return Err(Error::Invalid); + } + } + } + if value.actual_state && state == api::PhysicalPlanState::Draft { + for step in &plan.steps { + match step.action.as_ref().ok_or(Error::Corrupt)? { + api::physical_step::Action::Placement(p) => { + if let Some(item) = Placement::from(p).actual(&plan.materials)? { + self.lock_physical_part(item, &key.id)?; + } + } + api::physical_step::Action::Replacement(r) => { + for part in [r.previous.as_ref(), r.replacement.as_ref()] + .into_iter() + .flatten() + { + self.lock_physical_part(parse_target(Some(part))?, &key.id)?; + } + } + api::physical_step::Action::Wiring(w) => { + for target in wiring::targets(w)? { + self.lock_physical_part(target, &key.id)?; + } + } + } + } + } + let replacements = selected + .iter() + .filter_map(|(&index, &done)| { + let step = &plan.steps[index as usize]; + if !done || step.state == api::PhysicalStepState::Recorded as i32 { + return None; + } + match &step.action { + Some(api::physical_step::Action::Replacement(r)) => Some(r), + _ => None, + } + }) + .collect::>(); + if !replacements.is_empty() { + if !value.actual_state { + self.readmit_physical_replacements(context, &plan, &selected, working)?; + } + self.record_physical_replacements(context, &replacements, &value, working, now)?; + } + let mut changed = false; + // Explicit hardware transitions precede placement realization in this atomic report. + for replacements_first in [true, false] { + for (&index, &done) in &selected { + if matches!( + plan.steps[index as usize].action, + Some(api::physical_step::Action::Replacement(_)) + ) != replacements_first + { + continue; + } + let wiring_basis = match &plan.steps[index as usize].action { + Some(api::physical_step::Action::Wiring(w)) + if done + && plan.steps[index as usize].state + != api::PhysicalStepState::Recorded as i32 => + { + Some(match w.basis.as_ref().ok_or(Error::Corrupt)? { + api::physical_wiring::Basis::ExpectedEndRevision(revision) => *revision, + api::physical_wiring::Basis::AfterStep(prior) => { + let prior = + plan.steps.get(*prior as usize).ok_or(Error::Corrupt)?; + if prior.state != api::PhysicalStepState::Recorded as i32 { + return Err(Error::UnsafeTransition); + } + let Some(api::physical_step::Action::Wiring(prior)) = &prior.action + else { + return Err(Error::Corrupt); + }; + prior.result_revision.ok_or(Error::Corrupt)? + } + }) + } + _ => None, + }; + let placement_item = if done { + match plan.steps[index as usize].action.as_ref() { + Some(api::physical_step::Action::Placement(p)) => { + Placement::from(p).actual(&plan.materials)? + } + _ => None, + } + } else { + None + }; + let step = &mut plan.steps[index as usize]; + let action = step.action.as_mut().ok_or(Error::Corrupt)?; + let desired = if done { + api::PhysicalStepState::Recorded + } else { + api::PhysicalStepState::NotPerformed + }; + if step.state == desired as i32 { + continue; + } + if matches!( + api::PhysicalStepState::try_from(step.state), + Ok(api::PhysicalStepState::Recorded | api::PhysicalStepState::NotPerformed) + ) { + return Err(Error::Conflict); + } + if done { + match action { + api::physical_step::Action::Placement(p) => self + .record_physical_placement( + context, + PlacementEffect { + proposal: p, + item: placement_item.ok_or(Error::Incomplete)?, + }, + &value, + index as usize, + working, + now, + )?, + api::physical_step::Action::Replacement(_) => { + self.clear_physical_reservation(&key.id, index as usize)?; + } + api::physical_step::Action::Wiring(w) => { + self.record_wiring( + context, + w, + wiring_basis.ok_or(Error::Corrupt)?, + &value, + working, + now, + )?; + } + } + } else { + self.clear_physical_reservation(&key.id, index as usize)?; + } + step.state = desired as i32; + self.put( + &format!("plan/{}/{index}", key.id), + &format!("plan/{}", key.id), + Class::Inventory, + step_row(step)?, + )?; + changed = true; + } + } + if let Some(discrepancy) = value.discrepancy { + let header = format!("plan/{}", key.id); + let mut row = self.get(&header)?.ok_or(Error::Corrupt)?; + if row.get("description").and_then(Value::as_str) != Some(discrepancy.as_str()) { + operations::strip_envelope(&mut row); + row.insert("description".into(), text(discrepancy)); + self.put(&header, "plans", Class::Inventory, row)?; + changed = true; + } + } + self.refresh_plan_control(context, &mut plan, working, now)?; + self.release_resolved_physical_locks(&plan)?; + let terminal = plan.steps.iter().all(|step| { + matches!( + api::PhysicalStepState::try_from(step.state), + Ok(api::PhysicalStepState::Recorded | api::PhysicalStepState::NotPerformed) + ) && (step.state != api::PhysicalStepState::Recorded as i32 + || step + .control + .as_ref() + .and_then(|c| c.check.as_ref()) + .is_none() + || step.checked_state == Some(api::RequirementState::Satisfied as i32)) + }); + let next = if terminal { + plan.reserved_until_ms = None; + if plan + .steps + .iter() + .any(|s| s.state == api::PhysicalStepState::Recorded as i32) + && plan.steps.iter().all(|s| { + s.state == api::PhysicalStepState::Recorded as i32 + || s.readiness == api::PlanReadiness::Unselected as i32 + }) + { + api::PhysicalPlanState::Completed + } else { + api::PhysicalPlanState::Cancelled + } + } else { + api::PhysicalPlanState::Partial + }; + if !changed && plan.state == next as i32 { + return Ok(plan); + } + self.physical_state(context, &mut plan, next, now)?; + Ok(plan) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/connector.rs b/crates/omloom-store/src/disk/workspace/physical/connector.rs new file mode 100644 index 0000000..ee973e8 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/connector.rs @@ -0,0 +1,40 @@ +use super::*; + +pub(super) fn declaration(value: &api::PhysicalMating) -> Result> { + use crate::physical::MatingRole; + let profile = value.profile.as_ref().ok_or(Error::Invalid)?; + let role = match api::PhysicalMatingRole::try_from(value.role).map_err(|_| Error::Invalid)? { + api::PhysicalMatingRole::SideA => MatingRole::SideA, + api::PhysicalMatingRole::SideB => MatingRole::SideB, + api::PhysicalMatingRole::SelfMating => MatingRole::SelfMating, + _ => return Err(Error::Invalid), + }; + crate::physical::Mating::new(&profile.issuer, &profile.key, &profile.basis, role) + .map_err(|_| Error::Invalid) +} +pub(super) fn store(row: &mut Properties, value: Option<&api::PhysicalMating>) -> Result<()> { + if let Some(value) = value { + declaration(value)?; + if value.encoded_len() > 512 { + return Err(Error::Limit); + } + // The whole small mating declaration is consumed together by one primary-key read. + // It has no independently indexed fields, samples or version stream. + row.insert("payload".into(), Value::Binary(value.encode_to_vec())); + } else { + row.remove("payload"); + } + Ok(()) +} +pub(super) fn read(row: &Properties) -> Result> { + if !row.contains_key("payload") { + return Ok(None); + } + let bytes = binary(row, "payload")?; + if bytes.len() > 512 { + return Err(Error::Corrupt); + } + let value = api::PhysicalMating::decode(bytes).map_err(|_| Error::Corrupt)?; + declaration(&value).map_err(|_| Error::Corrupt)?; + Ok(Some(value)) +} diff --git a/crates/omloom-store/src/disk/workspace/physical/coverage.rs b/crates/omloom-store/src/disk/workspace/physical/coverage.rs new file mode 100644 index 0000000..4959ccb --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/coverage.rs @@ -0,0 +1,49 @@ +//! Current source completeness is independent of usable dimensions and derived availability. +use super::*; +use crate::physical::Region; +use crate::requirements::{Decision, Reason}; + +pub(super) fn write(row: &mut Properties, value: Option<&api::PhysicalVolume>) -> Result<()> { + if let Some(value) = value { + volume(value)?; + let mut values = Vec::with_capacity(6); + for axis in [&value.x, &value.y, &value.z] { + let axis = axis.as_ref().ok_or(Error::Invalid)?; + values.push(Value::Int(axis.start_um)); + values.push(int(axis.length_um)?); + } + row.insert("sizes".into(), Value::Array(values)); + } + Ok(()) +} +pub(super) fn read(row: &Properties) -> Result> { + let values = match row.get("sizes") { + None | Some(Value::Null) => return Ok(None), + Some(Value::Array(values)) if values.len() == 6 => values, + _ => return Err(Error::Corrupt), + }; + let axis = |index: usize| -> Result> { + Ok(Some(api::PhysicalExtent { + start_um: values[index].as_int().ok_or(Error::Corrupt)?, + length_um: u64::try_from(values[index + 1].as_int().ok_or(Error::Corrupt)?) + .map_err(|_| Error::Corrupt)?, + })) + }; + let value = api::PhysicalVolume { + x: axis(0)?, + y: axis(2)?, + z: axis(4)?, + }; + volume(&value).map_err(|_| Error::Corrupt)?; + Ok(Some(value)) +} +pub(super) fn assess(coverage: Option<&api::PhysicalVolume>, region: Region) -> Result { + let Some(coverage) = coverage else { + return Ok(Decision::unknown(Reason::Missing)); + }; + Ok(if region.within(volume(coverage)?) { + Decision::PASS + } else { + Decision::unknown(Reason::Incomplete) + }) +} diff --git a/crates/omloom-store/src/disk/workspace/physical/current.rs b/crates/omloom-store/src/disk/workspace/physical/current.rs new file mode 100644 index 0000000..c21a593 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/current.rs @@ -0,0 +1,424 @@ +//! InstalledIn owns current placement. Only independently reported geometry is stored beside it. +use super::*; + +pub(super) fn pose_key(binding: &str) -> String { + format!("physical-pose/{binding}") +} +pub(super) struct PhysicalUse { + pub definition: api::PhysicalDefinition, + pub volumes: Vec, + pub conditional_removals: Vec<(usize, Option)>, + pub items: BTreeSet, + pub future_loads: Vec<[Option; 3]>, + pub inspected: usize, +} +fn map<'a>(row: &'a Properties, name: &str) -> Result<&'a Properties> { + match row.get(name) { + Some(Value::Map(value)) => Ok(value), + _ => Err(Error::Corrupt), + } +} +fn binding_id(row: &Properties) -> Result { + let key = string(row, "key")?; + let id = key.strip_prefix("relation/").ok_or(Error::Corrupt)?; + RelationId::parse(id).map_err(|_| Error::Corrupt)?; + Ok(id.into()) +} +impl Tx<'_> { + pub(in crate::disk) fn require_no_physical_reservations(&self, target: AssetRef) -> Result<()> { + let identity = network::identity(target); + for key in [ + format!("physical-lock/{identity}"), + format!("physical-slot/{identity}"), + ] { + if self.get(&key)?.is_some() { + return Err(Error::Conflict); + } + } + let reservations = self.query( + "MATCH (n:OmRow {parent:$parent}) RETURN properties(n) AS p LIMIT 1", + params([("parent", text(format!("physical-reservations/{identity}")))]), + )?; + if !reservations.is_empty() { + return Err(Error::Conflict); + } + let intended=self.query("MATCH (n:OmRow {to_asset:$asset}) WHERE n.to_incarnation=$incarnation AND n.parent STARTS WITH 'cable-ends/' RETURN {key:n.key} AS p LIMIT 1",params([("asset",text(target.asset.to_string())),("incarnation",text(target.incarnation.to_string()))]))?; + if !intended.is_empty() { + return Err(Error::Conflict); + } + Ok(()) + } + pub(in crate::disk) fn remove_physical_pose(&mut self, binding: &str) -> Result<()> { + self.delete(&pose_key(binding)) + } + pub(in crate::disk) fn clear_position_empty_report(&mut self, target: AssetRef) -> Result<()> { + let key = format!("position/{}", network::identity(target)); + let Some(mut row) = self.get(&key)? else { + return Ok(()); + }; + if !row.contains_key("vflag") { + return Ok(()); + } + let parent = string(&row, "parent")?; + operations::strip_envelope(&mut row); + row.remove("vflag"); + row.remove("actor"); + row.remove("enclosure"); + row.insert( + "revision".into(), + int(number(&self.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?)?, + ); + self.put(&key, &parent, Class::Inventory, row)?; + Ok(()) + } + pub(super) fn current_physical_rows( + &self, + enclosure: AssetRef, + limit: u64, + ) -> Result> { + self.query("MATCH (p:OmRow {key:$key})<-[:OmRelationTo]-(r:OmRow) WHERE r.parent='relations' AND r.kind=$kind AND r.to_asset=$asset AND r.to_incarnation=$incarnation RETURN properties(r) AS p LIMIT $limit",params([("key",text(asset_key(enclosure))),("kind",int(wire::RelationKind::InstalledIn as u64)?),("asset",text(enclosure.asset.to_string())),("incarnation",text(enclosure.incarnation.to_string())),("limit",int(limit)?)])) + } + pub(super) fn physical_use( + &self, + enclosure: AssetRef, + plan: &api::PhysicalPlan, + ancestors: &[u64; crate::plans::MAX_STEPS], + remaining: usize, + ) -> Result { + debug_assert!(remaining <= 2048); + let definition = self + .physical_definition(enclosure)? + .ok_or(Error::Incomplete)?; + let current = self.current_physical_rows( + enclosure, + u64::try_from(remaining + 1).map_err(|_| Error::Limit)?, + )?; + if current.len() > remaining { + return Err(Error::Limit); + } + let pending = self.query( + "MATCH (p:OmRow {parent:$parent}) RETURN properties(p) AS p LIMIT $limit", + params([ + ( + "parent", + text(format!( + "physical-reservations/{}", + network::identity(enclosure) + )), + ), + ("limit", int((remaining - current.len() + 1) as u64)?), + ]), + )?; + let inspected = current.len() + pending.len(); + if inspected > remaining { + return Err(Error::Limit); + } + let mut removed = BTreeMap::new(); + for (index, step) in plan.steps.iter().enumerate() { + if let Some(api::physical_step::Action::Replacement(r)) = &step.action + && let Some(binding) = r.previous_binding.as_deref() + && removed.insert(binding, index).is_some() + { + return Err(Error::Invalid); + } + } + let mut bindings = Vec::with_capacity(current.len()); + for row in current { + let id = binding_id(&row)?; + let removal = removed.get(id.as_str()).copied(); + if let Some(removal) = removal { + let mut always_before = true; + for (index, p) in placement_steps(plan) { + if parse_target(p.enclosure.as_ref())? == enclosure + && index != removal + && ancestors[index] & (1u64 << removal) == 0 + { + always_before = false; + break; + } + } + if always_before { + continue; + } + } + bindings.push((id, target(&row)?, removal)); + } + let mut keys = bindings + .iter() + .map(|(id, _, _)| text(pose_key(id))) + .collect::>(); + for row in &pending { + keys.push(text(string(row, "claim")?)); + } + let mut poses = BTreeMap::new(); + if !keys.is_empty() { + for row in self.query("UNWIND $keys AS key MATCH (p:OmRow {key:key}) RETURN properties(p) AS p LIMIT 2048",params([("keys",Value::Array(keys))]))? {poses.insert(string(&row,"key")?,row);} + } + let mut output = PhysicalUse { + definition, + volumes: Vec::with_capacity(bindings.len() + pending.len()), + conditional_removals: Vec::with_capacity(bindings.len()), + items: BTreeSet::new(), + future_loads: Vec::new(), + inspected, + }; + let mut bodies = Vec::with_capacity(bindings.len() + pending.len()); + let mut removals = Vec::with_capacity(bindings.len() + pending.len()); + for (id, item, removal) in bindings { + output.items.insert(item); + let Some(pose) = poses.get(&pose_key(&id)) else { + if let Some(removal) = removal { + output.conditional_removals.push((removal, None)); + continue; + } + return Err(Error::Incomplete); + }; + bodies.push((item, volume(&read_volume(pose)?.ok_or(Error::Corrupt)?)?)); + removals.push(removal); + } + let needed = [ + output.definition.max_mass_g.is_some(), + output.definition.capacity_mw.is_some(), + output.definition.cooling_capacity_mw.is_some(), + ]; + let mut future = Vec::new(); + for reservation in pending { + let claim = string(&reservation, "claim")?; + let row = poses.get(&claim).ok_or(Error::Corrupt)?; + let step = read_step(row)?; + if step.state != api::PhysicalStepState::Reserved as i32 { + return Err(Error::Corrupt); + } + let placement = step_placement(&step).ok_or(Error::Corrupt)?; + if parse_target(placement.enclosure.as_ref())? != enclosure { + return Err(Error::Corrupt); + } + if let Some(unit) = placement.material { + if placement.item.is_some() { + return Err(Error::Corrupt); + } + let plan = claim.rsplit_once('/').ok_or(Error::Corrupt)?.0; + future.push(( + format!("{plan}/materials/{}", unit.material), + unit.unit, + volume(placement.volume.as_ref().ok_or(Error::Corrupt)?)?, + )); + continue; + } + let item = parse_target(placement.item.as_ref())?; + bodies.push(( + item, + volume(placement.volume.as_ref().ok_or(Error::Corrupt)?)?, + )); + output.items.insert(item); + removals.push(None); + } + if !future.is_empty() { + let mut keys = BTreeSet::new(); + for (key, _, _) in &future { + keys.insert(key.as_str()); + } + let mut selected = + Vec::with_capacity(future.len() + keys.len() * (1 + usize::from(needed[0]))); + for (key, unit, _) in &future { + selected.push(text(format!("{key}/unit/{unit}"))); + } + for key in keys { + selected.push(text(key)); + if needed[0] { + selected.push(text(format!("{key}/field/weight"))); + } + } + let limit = selected.len() as u64; + let rows=self.query("UNWIND $keys AS key MATCH (n:OmRow {key:key}) RETURN properties(n) AS p LIMIT $limit",params([("keys",Value::Array(selected)),("limit",int(limit)?)]))?; + let rows = rows + .into_iter() + .map(|row| Ok((string(&row, "key")?, row))) + .collect::>>()?; + for (key, unit, body) in future { + let profile = rows.get(&key).ok_or(Error::Corrupt)?; + if u64::from(unit) >= number(profile, "count")? { + return Err(Error::Corrupt); + } + output + .volumes + .push(clearance::region(body, clearance::read(profile)?.as_ref())?); + if let Some(allocation) = rows.get(&format!("{key}/unit/{unit}")) { + let item = target(allocation)?; + output.items.insert(item); + bodies.push((item, body)); + removals.push(None); + continue; + } + if needed.iter().any(|v| *v) { + output.future_loads.push(equipment::row_loads( + Some(profile), + rows.get(&format!("{key}/field/weight")), + needed, + )?); + } + } + } + for (region, removal) in self + .current_regions(&bodies, &output.items)? + .into_iter() + .zip(removals) + { + if let Some(removal) = removal { + output.conditional_removals.push((removal, Some(region))); + } else { + output.volumes.push(region); + } + } + Ok(output) + } + pub(super) fn store_physical_pose( + &mut self, + binding: &wire::Relation, + value: &api::PhysicalVolume, + actor: &str, + ) -> Result<()> { + let mut row = props([("actor", text(actor))]); + write_volume(&mut row, value)?; + let key = pose_key(&binding.relation_id); + if let Some(prior) = self.get(&key)? + && row.iter().all(|(key, value)| prior.get(key) == Some(value)) + { + return Ok(()); + } + row.insert( + "revision".into(), + int(number(&self.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?)?, + ); + self.put(&key, "physical-poses", Class::Inventory, row)?; + Ok(()) + } + pub(super) fn read_physical_current( + &self, + context: &Context, + enclosure: AssetRef, + working: &api::WorkingContext, + after: Option<&str>, + view: &mut api::PhysicalView, + ) -> Result<()> { + if let Some(focus) = working.focus.as_ref() + && parse_target(Some(focus))? != enclosure + { + return Ok(()); + } + let fingerprint = + assets::detail_page_fingerprint(context, working, enclosure, "physical-occupants"); + let offset = if let Some(cursor) = after { + let value = assets::page_position( + cursor, + view.revision, + &fingerprint, + context.authority_revision, + )?; + value.parse::().map_err(|_| Error::Invalid)? + } else { + 0 + }; + if offset > 100_000 { + return Err(Error::Limit); + } + let mut values = HashMap::from([ + ("offset".into(), int(offset)?), + ("key".into(), text(asset_key(enclosure))), + ("asset".into(), text(enclosure.asset.to_string())), + ( + "incarnation".into(), + text(enclosure.incarnation.to_string()), + ), + ("kind".into(), int(wire::RelationKind::InstalledIn as u64)?), + ("adopted".into(), int(wire::Adoption::Adopted as u64)?), + ]); + let visible = assets::visible_clause_for(context, Some("physical"), &mut values, "a"); + let mut rows=self.query(&format!("MATCH (p:OmRow {{key:$key}})<-[:OmRelationTo]-(r:OmRow) WHERE r.parent='relations' AND r.kind=$kind AND r.to_asset=$asset AND r.to_incarnation=$incarnation WITH r, 'asset/'+r.asset AS key MATCH (a:OmRow {{key:key}}) WHERE a.incarnation=r.incarnation AND a.adoption=$adopted AND {visible} RETURN {{binding:properties(r), item:properties(a)}} AS p ORDER BY p.binding.key SKIP $offset LIMIT 26"),Some(values))?; + if rows.len() > 25 { + rows.truncate(25); + view.next = Some(format!( + "{}:{fingerprint}:{}:{}", + view.revision, + context.authority_revision, + offset + 25 + )); + } + let mut poses = BTreeMap::new(); + let keys = rows + .iter() + .map(|row| { + let relation = map(row, "binding")?; + Ok(text(pose_key(&binding_id(relation)?))) + }) + .collect::>>()?; + if !keys.is_empty() { + for row in self.query( + "UNWIND $keys AS key MATCH (p:OmRow {key:key}) RETURN properties(p) AS p LIMIT 128", + params([("keys", Value::Array(keys))]), + )? { + poses.insert(string(&row, "key")?, row); + } + } + let enclosure_kind = + i32::try_from(number(&self.asset(enclosure)?, "kind")?).map_err(|_| Error::Corrupt)?; + for row in rows { + let relation = map(&row, "binding")?; + let item = map(&row, "item")?; + let target = target(item)?; + let kind = i32::try_from(number(item, "kind")?).map_err(|_| Error::Corrupt)?; + let id = binding_id(relation)?; + let pose = poses.get(&pose_key(&id)); + let fields = if context.field(target, kind, "name", false) { + item.get("name") + .and_then(Value::as_str) + .map(|value| wire::Field { + name: "name".into(), + value: Some(field::Value::Text(value.into())), + }) + .into_iter() + .collect() + } else { + Vec::new() + }; + view.items.push(api::Item { + target: Some(target_wire(target)), + kind, + revision: number(item, "revision")?, + fields, + }); + let binding = if context.field(target, kind, "relations", false) + && context.field(enclosure, enclosure_kind, "relations", false) + { + Some(inventory::relation_projection(relation)?) + } else { + None + }; + view.occupancy.push(api::PhysicalOccupancy { + placement: Some(api::PhysicalPlacement { + material: None, + item: Some(target_wire(target)), + enclosure: Some(target_wire(enclosure)), + volume: pose.map(read_volume).transpose()?.flatten(), + }), + reported_by: relation + .get("actor") + .map(|_| string(relation, "actor")) + .transpose()?, + revision: if binding.is_some() { + number(relation, "revision")? + } else { + 0 + }, + binding, + pose_revision: pose.map(|row| number(row, "revision")).transpose()?, + pose_reported_by: pose.map(|row| string(row, "actor")).transpose()?, + }); + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/equipment.rs b/crates/omloom-store/src/disk/workspace/physical/equipment.rs new file mode 100644 index 0000000..8bbfd08 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/equipment.rs @@ -0,0 +1,251 @@ +//! Physical consumers read the canonical accepted equipment claims, without copying them. +use super::*; + +fn upper(row: Option<&Properties>, scale: u32) -> Result> { + let Some(row) = row else { + return Ok(None); + }; + let field = inventory::read_field(row)?; + let Some(field::Value::Equipment(claim)) = field.value else { + return Ok(None); + }; + let Some(wire::equipment_claim::Assertion::Quantity(quantity)) = claim.assertion else { + return Ok(None); + }; + quantity_upper(&quantity, scale) +} +fn quantity_upper(quantity: &wire::QuantityRange, scale: u32) -> Result> { + let Some(value) = quantity.upper else { + return Ok(None); + }; + let value = u64::try_from(value).map_err(|_| Error::Corrupt)?; + let value = if quantity.scale <= scale { + value + .checked_mul(10_u64.pow(scale - quantity.scale)) + .ok_or(Error::Limit)? + } else { + let divisor = 10_u64.pow(quantity.scale - scale); + value / divisor + u64::from(value % divisor != 0) + }; + Ok(Some(value)) +} +pub(super) fn declared_envelope( + material: &api::PhysicalMaterial, +) -> Result> { + let mut axes = [None; 3]; + for (index, name) in ["width", "height", "depth"].iter().enumerate() { + let Some(field) = material.specifications.iter().find(|f| f.name == *name) else { + continue; + }; + if let Some(field::Value::Equipment(claim)) = &field.value + && let Some(wire::equipment_claim::Assertion::Quantity(q)) = &claim.assertion + { + axes[index] = quantity_upper(q, 3)?; + } + } + envelope(axes) +} +fn envelope(axes: [Option; 3]) -> Result> { + let [Some(x), Some(y), Some(z)] = axes else { + return Ok(None); + }; + if x == 0 || y == 0 || z == 0 { + return Ok(None); + } + let axis = |length_um| { + Some(api::PhysicalExtent { + start_um: 0, + length_um, + }) + }; + Ok(Some(api::PhysicalVolume { + x: axis(x), + y: axis(y), + z: axis(z), + })) +} +pub(super) fn declared_loads( + material: &api::PhysicalMaterial, + needed: [bool; 3], +) -> Result<[Option; 3]> { + let weight = if needed[0] { + match material + .specifications + .iter() + .find(|f| f.name == "weight") + .and_then(|f| f.value.as_ref()) + { + Some(field::Value::Equipment(c)) => match &c.assertion { + Some(wire::equipment_claim::Assertion::Quantity(q)) => quantity_upper(q, 0)?, + _ => None, + }, + _ => None, + } + } else { + None + }; + Ok([ + weight, + if needed[1] { + material.physical.as_ref().and_then(|p| p.demand_mw) + } else { + None + }, + if needed[2] { + material.physical.as_ref().and_then(|p| p.cooling_mw) + } else { + None + }, + ]) +} +pub(super) fn row_loads( + profile: Option<&Properties>, + weight: Option<&Properties>, + needed: [bool; 3], +) -> Result<[Option; 3]> { + let quantity = |name| { + profile + .filter(|p| p.contains_key(name)) + .map(|p| number(p, name)) + .transpose() + }; + Ok([ + if needed[0] { upper(weight, 0)? } else { None }, + if needed[1] { + quantity("demand_mw")? + } else { + None + }, + if needed[2] { + quantity("cooling_mw")? + } else { + None + }, + ]) +} +pub(super) fn add_loads(total: &mut [Option; 3], values: [Option; 3]) -> Result<()> { + for (sum, value) in total.iter_mut().zip(values) { + *sum = match (*sum, value) { + (Some(a), Some(b)) => Some(a.checked_add(b).ok_or(Error::Limit)?), + _ => None, + }; + } + Ok(()) +} +impl Tx<'_> { + pub(super) fn equipment_envelope( + &self, + context: &Context, + item: AssetRef, + ) -> Result> { + let root = self.visible_asset(context, item)?; + let kind = i32::try_from(number(&root, "kind")?).map_err(|_| Error::Corrupt)?; + for name in ["width", "height", "depth"] { + if !context.field(item, kind, name, false) { + return Err(Error::Denied); + } + } + let prefix = format!("{}/intent/", asset_key(item)); + let keys = ["width", "height", "depth"].map(|name| text(format!("{prefix}{name}"))); + let rows = self.query( + "UNWIND $keys AS key MATCH (p:OmRow {key:key}) RETURN properties(p) AS p LIMIT 3", + params([("keys", Value::Array(keys.into()))]), + )?; + let mut axes = [None; 3]; + for row in rows { + let name = string(&row, "field")?; + let index = match name.as_str() { + "width" => 0, + "height" => 1, + "depth" => 2, + _ => return Err(Error::Corrupt), + }; + axes[index] = upper(Some(&row), 3)?; + } + envelope(axes) + } + pub(super) fn physical_loads( + &self, + items: &BTreeSet, + needed: [bool; 3], + ) -> Result<[Option; 3]> { + if items.len() > 2048 { + return Err(Error::Limit); + } + let mut keys = Vec::with_capacity( + items.len() * (usize::from(needed[0]) + usize::from(needed[1] || needed[2])), + ); + for item in items { + if needed[1] || needed[2] { + keys.push(text(format!("physical/{}", network::identity(*item)))); + } + if needed[0] { + keys.push(text(format!("{}/intent/weight", asset_key(*item)))); + } + } + let mut rows = BTreeMap::new(); + if !keys.is_empty() { + for row in self.query("UNWIND $keys AS key MATCH (p:OmRow {key:key}) RETURN properties(p) AS p LIMIT 4096",params([("keys",Value::Array(keys))]))? { + rows.insert(string(&row,"key")?,row); + } + } + let mut total = needed.map(|needed| if needed { Some(0_u64) } else { None }); + for item in items { + let profile = rows.get(&format!("physical/{}", network::identity(*item))); + add_loads( + &mut total, + row_loads( + profile, + rows.get(&format!("{}/intent/weight", asset_key(*item))), + needed, + )?, + )?; + } + Ok(total) + } + pub(super) fn physical_equipment_projection( + &self, + context: &Context, + item: AssetRef, + view: &mut api::PhysicalView, + ) -> Result<()> { + let root = self.visible_asset(context, item)?; + let kind = i32::try_from(number(&root, "kind")?).map_err(|_| Error::Corrupt)?; + if !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device | wire::AssetKind::PhysicalComponent) + ) { + return Ok(()); + } + let mut keys = Vec::with_capacity(4); + for name in ["width", "height", "depth", "weight"] { + if context.field(item, kind, name, false) { + keys.push(text(format!("{}/intent/{name}", asset_key(item)))); + } + } + let mut fields = Vec::with_capacity(keys.len() + 1); + if !keys.is_empty() { + for row in self.query( + "UNWIND $keys AS key MATCH (p:OmRow {key:key}) RETURN properties(p) AS p LIMIT 4", + params([("keys", Value::Array(keys))]), + )? { + fields.push(inventory::read_field(&row)?); + } + } + if context.field(item, kind, "name", false) + && let Some(name) = root.get("name").and_then(Value::as_str) + { + fields.push(wire::Field { + name: "name".into(), + value: Some(field::Value::Text(name.into())), + }); + } + view.items.push(api::Item { + target: Some(target_wire(item)), + kind, + revision: number(&root, "revision")?, + fields, + }); + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/flow.rs b/crates/omloom-store/src/disk/workspace/physical/flow.rs new file mode 100644 index 0000000..98e0cb2 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/flow.rs @@ -0,0 +1,189 @@ +//! Domain projection over the shared plan dependency and requirement mechanisms. +use super::*; +use crate::plans as core; + +pub(super) struct Analysis { + pub states: Vec, + pub ancestors: [u64; core::MAX_STEPS], +} +pub(super) fn analysis(steps: &[api::PhysicalStep]) -> Result { + if steps.len() > core::MAX_STEPS { + return Err(Error::Limit); + } + let mut edges = 0; + let mut dependencies = Vec::with_capacity(steps.len()); + for (index, step) in steps.iter().enumerate() { + let source = step + .control + .as_ref() + .map(|c| c.after.as_slice()) + .unwrap_or(&[]); + if source.len() > core::MAX_EDGES - edges { + return Err(Error::Limit); + } + let mut derived = None; + if let Some(api::physical_step::Action::Wiring(wire)) = &step.action + && let Some(api::physical_wiring::Basis::AfterStep(previous)) = wire.basis + { + let previous = previous as usize; + if previous >= index { + return Err(Error::Invalid); + } + let Some(api::physical_step::Action::Wiring(prior)) = &steps[previous].action else { + return Err(Error::Invalid); + }; + if prior.cable != wire.cable || prior.end_key != wire.end_key { + return Err(Error::Invalid); + } + for intervening in &steps[previous + 1..index] { + if let Some(api::physical_step::Action::Wiring(intervening)) = &intervening.action + && intervening.cable == wire.cable + && intervening.end_key == wire.end_key + { + return Err(Error::Invalid); + } + } + derived = Some(previous); + for dependency in source { + if dependency.step as usize == previous { + derived = None; + break; + } + } + } + let count = source.len() + usize::from(derived.is_some()); + if count > core::MAX_EDGES - edges { + return Err(Error::Limit); + } + edges += count; + let mut after = Vec::with_capacity(count); + for dependency in source { + let trigger = + match api::PlanTrigger::try_from(dependency.when).map_err(|_| Error::Invalid)? { + api::PlanTrigger::Reported => core::Trigger::Reported, + api::PlanTrigger::Satisfied => core::Trigger::Satisfied, + api::PlanTrigger::Failed => core::Trigger::Failed, + _ => return Err(Error::Invalid), + }; + if dependency.step as usize >= steps.len() { + return Err(Error::Invalid); + } + if trigger != core::Trigger::Reported + && steps[dependency.step as usize] + .control + .as_ref() + .and_then(|c| c.check.as_ref()) + .is_none() + { + return Err(Error::Invalid); + } + after.push(core::Dependency { + step: dependency.step as usize, + trigger, + }); + } + if let Some(step) = derived { + after.push(core::Dependency { + step, + trigger: core::Trigger::Reported, + }); + } + dependencies.push(after); + } + let mut nodes = Vec::with_capacity(steps.len()); + for (step, after) in steps.iter().zip(&dependencies) { + let outcome = + match api::PhysicalStepState::try_from(step.state).map_err(|_| Error::Invalid)? { + api::PhysicalStepState::Recorded => core::Outcome::Reported, + api::PhysicalStepState::NotPerformed => core::Outcome::NotPerformed, + _ => core::Outcome::Pending, + }; + let check = step + .checked_state + .map(|state| -> Result { + Ok(crate::requirements::Decision { + state: match api::RequirementState::try_from(state) + .map_err(|_| Error::Corrupt)? + { + api::RequirementState::Satisfied => crate::requirements::State::Satisfied, + api::RequirementState::Failed => crate::requirements::State::Failed, + _ => crate::requirements::State::Unknown, + }, + reason: crate::requirements::Reason::Incomplete, + }) + }) + .transpose()?; + nodes.push(core::Step { + after, + outcome, + check, + }); + } + let mut output = vec![core::Readiness::Waiting; steps.len()]; + let mut ancestors = [0u64; core::MAX_STEPS]; + core::assess(&nodes, &mut output, Some(&mut ancestors)).map_err(|e| match e { + core::Invalid::Limit => Error::Limit, + _ => Error::Invalid, + })?; + Ok(Analysis { + states: output, + ancestors, + }) +} + +impl Tx<'_> { + pub(super) fn refresh_plan_control( + &self, + context: &Context, + plan: &mut api::PhysicalPlan, + working: &api::WorkingContext, + now: u64, + ) -> Result<()> { + if plan.steps.is_empty() { + return Ok(()); + } + for step in &mut plan.steps { + step.checked_state = None; + step.checked_reason = None; + if step.state != api::PhysicalStepState::Recorded as i32 { + continue; + } + if let Some(check) = step.control.as_ref().and_then(|c| c.check.as_ref()) { + let result = match self.evaluate_requirements_in_phase( + context, + check.clone(), + working, + now, + crate::requirements::Phase::PostEffect, + ) { + Ok(value) => value, + Err(Error::Denied | Error::Missing | Error::Conflict) => { + api::RequirementEvaluation { + state: api::RequirementState::Unknown as i32, + reason: api::RequirementReason::Restricted as i32, + ..Default::default() + } + } + Err(Error::Limit) => api::RequirementEvaluation { + state: api::RequirementState::Unknown as i32, + reason: api::RequirementReason::Incomplete as i32, + ..Default::default() + }, + Err(error) => return Err(error), + }; + step.checked_state = Some(result.state); + step.checked_reason = Some(result.reason); + } + } + let states = analysis(&plan.steps)?.states; + for (step, state) in plan.steps.iter_mut().zip(states) { + step.readiness = match state { + core::Readiness::Ready => api::PlanReadiness::Ready, + core::Readiness::Waiting => api::PlanReadiness::Waiting, + core::Readiness::Unselected => api::PlanReadiness::Unselected, + core::Readiness::Complete => api::PlanReadiness::Complete, + } as i32; + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/lifecycle.rs b/crates/omloom-store/src/disk/workspace/physical/lifecycle.rs new file mode 100644 index 0000000..1b70c46 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/lifecycle.rs @@ -0,0 +1,229 @@ +use super::*; + +pub(super) fn reference(value: &str) -> Result { + let (asset, incarnation) = value.split_once('/').ok_or(Error::Corrupt)?; + Ok(AssetRef { + asset: asset.parse().map_err(|_| Error::Corrupt)?, + incarnation: incarnation.parse().map_err(|_| Error::Corrupt)?, + }) +} +fn key(value: &api::PhysicalPlanKey) -> Result { + let id = PlanId::parse(&value.id).map_err(|_| Error::Invalid)?; + Ok(format!("plan/{id}")) +} +impl Tx<'_> { + pub(super) fn load_physical_plan( + &self, + context: &Context, + request: &api::PhysicalPlanKey, + working: &api::WorkingContext, + write: bool, + ) -> Result { + let key = key(request)?; + let row = self.get(&key)?.ok_or(Error::Missing)?; + if string(&row, "action")? != "physical" { + return Err(Error::Invalid); + } + if string(&row, "actor")? != context.actor { + return Err(Error::Denied); + } + if request.revision != 0 && number(&row, "revision")? != request.revision { + return Err(Error::Conflict); + } + let state = i32::try_from(number(&row, "state")?).map_err(|_| Error::Corrupt)?; + let terminal = matches!( + api::PhysicalPlanState::try_from(state), + Ok(api::PhysicalPlanState::Completed | api::PhysicalPlanState::Cancelled) + ); + let count = number(&row, "count")?; + if count > 64 { + return Err(Error::Corrupt); + } + let materials = self.load_materials( + &key, + if row.contains_key("material_count") { + usize::try_from(number(&row, "material_count")?).map_err(|_| Error::Corrupt)? + } else { + 0 + }, + number(&row, "class")? == Class::Detail as u64, + )?; + let mut steps = Vec::with_capacity(count as usize); + for i in 0..count { + let p = self.get(&format!("{key}/{i}"))?.ok_or(if terminal { + Error::Expired + } else { + Error::Corrupt + })?; + let step = read_step(&p)?; + match step.action.as_ref().ok_or(Error::Corrupt)? { + api::physical_step::Action::Placement(p) => { + if let Some(item) = Placement::from(p).actual(&materials)? { + self.physical_access(context, item, working, write)?; + } + self.physical_access( + context, + parse_target(p.enclosure.as_ref())?, + working, + write, + )?; + } + api::physical_step::Action::Replacement(r) => { + self.replacement_access(context, r, working, write)? + } + api::physical_step::Action::Wiring(w) => { + self.wiring_access(context, w, working, write)? + } + } + steps.push(step); + } + let plan = api::PhysicalPlan { + id: Some(request.id.clone()), + name: string(&row, "name")?, + steps, + materials, + state, + revision: number(&row, "revision")?, + reserved_until_ms: if row.contains_key("until") { + Some(number(&row, "until")?) + } else { + None + }, + }; + if !plan.materials.is_empty() { + self.material_access(context, &plan.materials, working, write)?; + } + Ok(plan) + } + pub(super) fn physical_state( + &mut self, + context: &Context, + plan: &mut api::PhysicalPlan, + state: api::PhysicalPlanState, + now: u64, + ) -> Result<()> { + let id = plan.id.as_ref().ok_or(Error::Corrupt)?; + let key = format!("plan/{id}"); + let mut row = self.get(&key)?.ok_or(Error::Corrupt)?; + operations::strip_envelope(&mut row); + plan.state = state as i32; + plan.revision = number(&self.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?; + row.insert("state".into(), int(state as u64)?); + row.insert("revision".into(), int(plan.revision)?); + if let Some(until) = plan.reserved_until_ms { + row.insert("until".into(), int(until)?); + } else { + row.remove("until"); + } + let terminal_detail = matches!( + state, + api::PhysicalPlanState::Completed | api::PhysicalPlanState::Cancelled + ) && plan.steps.iter().all(|step| { + matches!( + api::PhysicalStepState::try_from(step.state), + Ok(api::PhysicalStepState::Recorded | api::PhysicalStepState::NotPerformed) + ) + }); + let class = if terminal_detail { + Class::Detail + } else { + Class::Inventory + }; + if terminal_detail { + row.insert("at".into(), int(now)?); + if !plan.materials.is_empty() { + self.retire_materials(&key, now)?; + } + for index in 0..plan.steps.len() { + let child_key = format!("{key}/{index}"); + let mut child = self.get(&child_key)?.ok_or(Error::Corrupt)?; + operations::strip_envelope(&mut child); + child.insert("at".into(), int(now)?); + self.put(&child_key, &key, Class::Detail, child)?; + } + } + self.put(&key, "plans", class, row)?; + self.audit(id, &context.actor, "physical-plan", plan.revision, now)?; + Ok(()) + } + pub(super) fn reserve_physical( + &mut self, + context: &Context, + key: &api::PhysicalPlanKey, + working: &api::WorkingContext, + now: u64, + until: u64, + ) -> Result { + if key.revision == 0 || until <= now { + return Err(Error::Invalid); + } + int(until)?; + let mut plan = self.load_physical_plan(context, key, working, true)?; + if plan.state != api::PhysicalPlanState::Draft as i32 { + return Err(Error::Conflict); + } + let claims = self + .assess_physical(context, &plan, working) + .map_err(|failure| failure.error)?; + self.acquire_physical_claims(claims, &key.id)?; + for (index, p) in placement_steps(&plan) { + let enclosure = parse_target(p.enclosure.as_ref())?; + self.put( + &reservations::reservation_key(&key.id, index), + &format!("physical-reservations/{}", network::identity(enclosure)), + Class::Pending, + props([("claim", text(format!("plan/{}/{index}", key.id)))]), + )?; + } + for (index, step) in plan.steps.iter_mut().enumerate() { + step.state = api::PhysicalStepState::Reserved as i32; + self.put( + &format!("plan/{}/{index}", key.id), + &format!("plan/{}", key.id), + Class::Inventory, + step_row(step)?, + )?; + } + plan.reserved_until_ms = Some(until); + self.physical_state(context, &mut plan, api::PhysicalPlanState::Reserved, now)?; + Ok(plan) + } + pub(super) fn cancel_physical( + &mut self, + context: &Context, + key: &api::PhysicalPlanKey, + working: &api::WorkingContext, + now: u64, + ) -> Result { + if key.revision == 0 { + return Err(Error::Invalid); + } + let mut plan = self.load_physical_plan(context, key, working, true)?; + if plan.state == api::PhysicalPlanState::Cancelled as i32 { + return Ok(plan); + } + if plan.state == api::PhysicalPlanState::Completed as i32 { + return Err(Error::UnsafeTransition); + } + let held = plan.state != api::PhysicalPlanState::Draft as i32; + // Cancelling future intent never silently releases unresolved reserved work. + if plan.state == api::PhysicalPlanState::Draft as i32 { + for (index, step) in plan.steps.iter_mut().enumerate() { + step.state = api::PhysicalStepState::NotPerformed as i32; + self.put( + &format!("plan/{}/{index}", key.id), + &format!("plan/{}", key.id), + Class::Inventory, + step_row(step)?, + )?; + } + } + self.physical_state(context, &mut plan, api::PhysicalPlanState::Cancelled, now)?; + if held { + self.release_resolved_physical_locks(&plan)?; + } + Ok(plan) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/materials.rs b/crates/omloom-store/src/disk/workspace/physical/materials.rs new file mode 100644 index 0000000..75151cb --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/materials.rs @@ -0,0 +1,486 @@ +//! Plan-local demand and sparse real allocations, never catalogue-derived assets. +use super::*; + +const MAX_LINES: usize = 64; +const MAX_ALLOCATIONS: usize = 64; +const MAX_CLAIM_BYTES: usize = 65_536; +const MAX_ROWS: usize = + MAX_LINES * (1 + omloom_contracts::equipment::FIELDS.len()) + MAX_ALLOCATIONS; + +pub(super) struct Prepared { + rows: Vec, +} +struct PreparedRow { + path: String, + parent: String, + data: Properties, +} +fn label(value: &str, limit: usize) -> Result<()> { + if value.trim().is_empty() || value.len() > limit || value.chars().any(char::is_control) { + return Err(Error::Invalid); + } + Ok(()) +} +fn material_kind(raw: i32) -> Result { + let kind = wire::AssetKind::try_from(raw).map_err(|_| Error::Invalid)?; + if !matches!( + kind, + wire::AssetKind::Device + | wire::AssetKind::PhysicalComponent + | wire::AssetKind::Rack + | wire::AssetKind::Cable + ) { + return Err(Error::Invalid); + } + Ok(kind) +} +pub(super) fn normalize(plan: &mut api::PhysicalPlan) -> Result<()> { + if plan.materials.len() > MAX_LINES { + return Err(Error::Limit); + } + let mut allocations = 0; + for material in &plan.materials { + if material.allocations.len() > MAX_ALLOCATIONS - allocations { + return Err(Error::Limit); + } + allocations += material.allocations.len(); + } + for material in &mut plan.materials { + if material.specifications.len() > omloom_contracts::equipment::FIELDS.len() + || material.allocations.len() > MAX_ALLOCATIONS + { + return Err(Error::Limit); + } + for field in &mut material.specifications { + if let Some(wire::field::Value::Equipment(value)) = &mut field.value { + omloom_contracts::equipment::canonicalize(value).map_err(|_| Error::Invalid)?; + } + } + material + .specifications + .sort_unstable_by(|a, b| a.name.cmp(&b.name)); + material.allocations.sort_unstable_by_key(|a| a.unit); + } + Ok(()) +} +impl Tx<'_> { + pub(super) fn material_access( + &self, + context: &Context, + materials: &[api::PhysicalMaterial], + working: &api::WorkingContext, + write: bool, + ) -> Result<()> { + if materials.len() > MAX_LINES { + return Err(Error::Limit); + } + let mut count = 0; + let mut references = BTreeMap::new(); + for material in materials { + label(&material.name, 160)?; + if material.quantity == 0 { + return Err(Error::Invalid); + } + if material.allocations.len() > MAX_ALLOCATIONS - count { + return Err(Error::Limit); + } + count += material.allocations.len(); + let kind = material_kind(material.kind)?; + let mut units = BTreeSet::new(); + for allocation in &material.allocations { + if allocation.unit >= material.quantity || !units.insert(allocation.unit) { + return Err(Error::Invalid); + } + label(&allocation.basis, 512)?; + let target = parse_target(allocation.item.as_ref())?; + if references.insert(target, kind).is_some() { + return Err(Error::Invalid); + } + } + } + let selection = self.selection(context, working, write)?; + if !references.is_empty() { + let keys = references.keys().map(|r| text(asset_key(*r))).collect(); + let metadata=self.query("UNWIND $keys AS key MATCH (n:OmRow {key:key}) RETURN {asset:n.asset,incarnation:n.incarnation,kind:n.kind,adoption:n.adoption} AS p LIMIT 64",params([("keys",Value::Array(keys))]))?; + let by_target = metadata + .into_iter() + .map(|row| Ok((target(&row)?, row))) + .collect::>>()?; + for (target, expected) in references { + selection.contains(target, self)?; + if Self::physical_metadata_access( + context, + target, + by_target.get(&target).ok_or(Error::Conflict)?, + write, + )? != expected + { + return Err(Error::Invalid); + } + } + } + Ok(()) + } + pub(super) fn receive_material( + &mut self, + context: &Context, + receipt: api::PhysicalMaterialReceipt, + working: &api::WorkingContext, + now: u64, + ) -> Result { + let key = receipt.plan.ok_or(Error::Invalid)?; + if key.revision == 0 { + return Err(Error::Invalid); + } + let mut plan = self.load_physical_plan(context, &key, working, true)?; + if !matches!( + api::PhysicalPlanState::try_from(plan.state), + Ok(api::PhysicalPlanState::Draft + | api::PhysicalPlanState::Reserved + | api::PhysicalPlanState::Partial) + ) { + return Err(Error::UnsafeTransition); + } + let value = receipt.allocation.ok_or(Error::Invalid)?; + let incoming = parse_target(value.item.as_ref())?; + let allocation_count: usize = plan.materials.iter().map(|m| m.allocations.len()).sum(); + let material = plan + .materials + .get_mut(receipt.material as usize) + .ok_or(Error::Invalid)?; + let previous = if let Some(allocation) = material + .allocations + .iter_mut() + .find(|a| a.unit == value.unit) + { + if *allocation == value { + return Ok(plan); + } + Some(core::mem::replace(allocation, value)) + } else { + if allocation_count >= MAX_ALLOCATIONS { + return Err(Error::Limit); + } + material.allocations.push(value); + None + }; + if let Some(previous) = &previous + && parse_target(previous.item.as_ref())? != incoming + { + let item = previous.item.as_ref(); + let old = parse_target(item)?; + for step in &plan.steps { + let used = match step.action.as_ref().ok_or(Error::Corrupt)? { + api::physical_step::Action::Placement(p) => { + p.item.as_ref() == item + || p.material.as_ref().is_some_and(|u| { + u.material == receipt.material && u.unit == previous.unit + }) + } + api::physical_step::Action::Replacement(r) => { + r.previous.as_ref() == item || r.replacement.as_ref() == item + } + api::physical_step::Action::Wiring(w) => wiring::targets(w)?.contains(&old), + }; + if used { + return Err(Error::UnsafeTransition); + } + } + } + normalize(&mut plan)?; + let prepared = self.prepare_materials(context, &plan, working, true)?; + if plan.state != api::PhysicalPlanState::Draft as i32 { + self.lock_physical_part(incoming, &key.id)?; + if let Some(previous) = previous + && parse_target(previous.item.as_ref())? != incoming + { + let old = parse_target(previous.item.as_ref())?; + let lock = format!("physical-lock/{}", network::identity(old)); + let row = self.get(&lock)?.ok_or(Error::Corrupt)?; + if string(&row, "owner")? != key.id { + return Err(Error::Conflict); + } + self.delete(&lock)?; + } + } + self.save_materials(&format!("plan/{}", key.id), prepared, plan.materials.len())?; + let state = api::PhysicalPlanState::try_from(plan.state).map_err(|_| Error::Corrupt)?; + self.physical_state(context, &mut plan, state, now)?; + Ok(plan) + } + pub(super) fn prepare_materials( + &self, + context: &Context, + plan: &api::PhysicalPlan, + working: &api::WorkingContext, + write: bool, + ) -> Result { + if plan.materials.len() > MAX_LINES { + return Err(Error::Limit); + } + if plan.materials.is_empty() { + return Ok(Prepared { rows: Vec::new() }); + } + let mut rows = plan.materials.len(); + let mut claims = 0; + for material in &plan.materials { + let kind = material_kind(material.kind)?; + if material.specifications.len() > omloom_contracts::equipment::FIELDS.len() { + return Err(Error::Limit); + } + rows += material.specifications.len() + material.allocations.len(); + let mut fields = BTreeSet::new(); + for field in &material.specifications { + if !matches!( + kind, + wire::AssetKind::Device | wire::AssetKind::PhysicalComponent + ) || !fields.insert(&field.name) + { + return Err(Error::Invalid); + } + let Some(wire::field::Value::Equipment(value)) = &field.value else { + return Err(Error::Invalid); + }; + omloom_contracts::equipment::validate_field(&field.name, value) + .map_err(|_| Error::Invalid)?; + let size = field.encoded_len(); + if size > MAX_CLAIM_BYTES - claims { + return Err(Error::Limit); + } + claims += size; + } + if let Some(profile) = &material.physical { + if profile.target.is_some() + || profile.occupancy_coverage.is_some() + || profile.power_sources_complete + { + return Err(Error::Invalid); + } + definition_kind(kind, profile)?; + let size = profile.encoded_len(); + if size > MAX_CLAIM_BYTES - claims { + return Err(Error::Limit); + } + claims += size; + } + } + self.material_access(context, &plan.materials, working, write)?; + let mut prepared = Prepared { + rows: Vec::with_capacity(rows), + }; + for (index, material) in plan.materials.iter().enumerate() { + let path = index.to_string(); + let mut data = material + .physical + .as_deref() + .map(definition_facts) + .transpose()? + .unwrap_or_default(); + data.extend(props([ + ("name", text(&material.name)), + ("count", int(u64::from(material.quantity))?), + ( + "kind", + int(u64::try_from(material.kind).map_err(|_| Error::Invalid)?)?, + ), + ])); + if !material.specifications.is_empty() { + data.insert( + "expected".into(), + int(material.specifications.len() as u64)?, + ); + } + if !material.allocations.is_empty() { + data.insert("generation".into(), int(material.allocations.len() as u64)?); + } + prepared.rows.push(PreparedRow { + path: path.clone(), + parent: String::new(), + data, + }); + for field in &material.specifications { + prepared.rows.push(PreparedRow { + path: format!("{path}/field/{}", field.name), + parent: path.clone(), + data: inventory::field_props(field)?, + }); + } + for allocation in &material.allocations { + let mut data = target_props(parse_target(allocation.item.as_ref())?); + data.extend(props([ + ("count", int(u64::from(allocation.unit))?), + ("basis", text(&allocation.basis)), + ])); + prepared.rows.push(PreparedRow { + path: format!("{path}/unit/{}", allocation.unit), + parent: path.clone(), + data, + }); + } + } + Ok(prepared) + } + + fn material_rows(&self, plan: &str) -> Result<(Vec, Vec)> { + let parent = format!("{plan}/materials"); + let lines = self.query( + "MATCH (n:OmRow {parent:$parent}) RETURN properties(n) AS p ORDER BY n.key LIMIT 65", + params([("parent", text(&parent))]), + )?; + if lines.len() > MAX_LINES { + return Err(Error::Corrupt); + } + let mut children = Vec::new(); + if !lines.is_empty() { + let parents = lines + .iter() + .map(|row| string(row, "key").map(text)) + .collect::>>()?; + children = self.query("UNWIND $parents AS parent MATCH (n:OmRow {parent:parent}) RETURN properties(n) AS p LIMIT 833",params([("parents",Value::Array(parents))]))?; + if children.len() > MAX_ROWS - MAX_LINES { + return Err(Error::Corrupt); + } + } + Ok((lines, children)) + } + pub(super) fn save_materials( + &mut self, + plan: &str, + prepared: Prepared, + previous_count: usize, + ) -> Result<()> { + if prepared.rows.is_empty() && previous_count == 0 { + return Ok(()); + } + let (lines, children) = if previous_count == 0 { + (Vec::new(), Vec::new()) + } else { + self.material_rows(plan)? + }; + let mut previous = lines + .into_iter() + .chain(children) + .map(|row| Ok((string(&row, "key")?, row))) + .collect::>>()?; + for row in prepared.rows { + let key = format!("{plan}/materials/{}", row.path); + let parent = if row.parent.is_empty() { + format!("{plan}/materials") + } else { + format!("{plan}/materials/{}", row.parent) + }; + self.put_against( + &key, + &parent, + Class::Inventory, + row.data, + previous.remove(&key).as_ref(), + )?; + } + for key in previous.keys() { + self.delete(key)?; + } + Ok(()) + } + pub(super) fn load_materials( + &self, + plan: &str, + count: usize, + retired: bool, + ) -> Result> { + if count > MAX_LINES { + return Err(Error::Corrupt); + } + if count == 0 { + return Ok(Vec::new()); + } + let missing = || { + if retired { + Error::Expired + } else { + Error::Corrupt + } + }; + let (lines, children) = self.material_rows(plan)?; + if lines.len() != count { + return Err(missing()); + } + let by_key = lines + .iter() + .map(|row| Ok((string(row, "key")?, row))) + .collect::>>()?; + let mut children_by_parent = BTreeMap::>::new(); + for row in &children { + children_by_parent + .entry(string(row, "parent")?) + .or_default() + .push(row); + } + let mut result = Vec::with_capacity(count); + for index in 0..count { + let key = format!("{plan}/materials/{index}"); + let row = by_key.get(&key).ok_or_else(missing)?; + let mut material = api::PhysicalMaterial { + name: string(row, "name")?, + quantity: u32::try_from(number(row, "count")?).map_err(|_| Error::Corrupt)?, + kind: i32::try_from(number(row, "kind")?).map_err(|_| Error::Corrupt)?, + physical: if row.contains_key("basis") { + Some(Box::new(definition_facts_read(row)?)) + } else { + None + }, + ..Default::default() + }; + if let Some(children) = children_by_parent.get(&key) { + for row in children { + if row.contains_key("field") { + let field = inventory::read_field(row)?; + if !matches!(field.value, Some(wire::field::Value::Equipment(_))) { + return Err(Error::Corrupt); + } + material.specifications.push(field); + } else { + material.allocations.push(api::PhysicalMaterialAllocation { + unit: u32::try_from(number(row, "count")?) + .map_err(|_| Error::Corrupt)?, + item: Some(target_wire(target(row)?)), + basis: string(row, "basis")?, + }); + } + } + } + material + .specifications + .sort_unstable_by(|a, b| a.name.cmp(&b.name)); + material.allocations.sort_unstable_by_key(|a| a.unit); + let expected = if row.contains_key("expected") { + number(row, "expected")? + } else { + 0 + }; + let allocated = if row.contains_key("generation") { + number(row, "generation")? + } else { + 0 + }; + if material.specifications.len() as u64 != expected + || material.allocations.len() as u64 != allocated + { + return Err(missing()); + } + result.push(material); + } + Ok(result) + } + pub(super) fn retire_materials(&mut self, plan: &str, now: u64) -> Result<()> { + let (lines, children) = self.material_rows(plan)?; + for mut row in lines.into_iter().chain(children) { + let key = string(&row, "key")?; + let parent = string(&row, "parent")?; + operations::strip_envelope(&mut row); + row.insert("at".into(), int(now)?); + self.put(&key, &parent, Class::Detail, row)?; + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/positions.rs b/crates/omloom-store/src/disk/workspace/physical/positions.rs new file mode 100644 index 0000000..2c56f27 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/positions.rs @@ -0,0 +1,309 @@ +use super::*; + +fn position_key(owner: AssetRef, structural: &str) -> Result { + if structural.is_empty() + || structural.len() > 128 + || !structural + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_./:-".contains(&b)) + { + return Err(Error::Invalid); + } + Ok(format!( + "position-key/{}/{structural}", + network::identity(owner) + )) +} +fn position(row: &Properties, owner: AssetRef) -> Result { + let kind = i32::try_from(number(row, "vint")?).map_err(|_| Error::Corrupt)?; + api::PhysicalComponentKind::try_from(kind).map_err(|_| Error::Corrupt)?; + Ok(api::PhysicalPosition { + target: Some(target_wire(target(row)?)), + owner: Some(target_wire(owner)), + structural_key: string(row, "basis")?, + component_kind: kind, + revision: number(row, "revision")?, + }) +} +impl Tx<'_> { + pub(super) fn save_physical_position( + &mut self, + context: &Context, + value: api::PhysicalPositionEdit, + working: &api::WorkingContext, + now: u64, + ) -> Result { + let owner = parse_target(value.owner.as_ref())?; + self.physical_access(context, owner, working, true)?; + api::PhysicalComponentKind::try_from(value.component_kind).map_err(|_| Error::Invalid)?; + let lookup = position_key(owner, &value.structural_key)?; + let existing = self.get(&lookup)?.map(|row| target(&row)).transpose()?; + let prior = existing + .map(|target| self.get(&format!("position/{}", network::identity(target)))) + .transpose()? + .flatten(); + if prior + .as_ref() + .map(|row| number(row, "revision")) + .transpose()? + != value.expected + { + return Err(Error::Conflict); + } + if existing.is_some() && prior.is_none() { + return Err(Error::Corrupt); + } + if let Some(target) = existing { + self.physical_access(context, target, working, true)?; + if let Some(prior) = &prior { + let root = self.asset(target)?; + if root.get("name").and_then(Value::as_str) == Some(value.name.as_str()) + && number(prior, "vint")? == value.component_kind as u64 + { + return position(prior, owner); + } + } + } + let parent = self.creation_parent( + context, + working, + Some(&target_wire(owner)), + wire::AssetKind::PhysicalPosition, + )?; + let target = self.workspace_write( + context, + api::Write { + target: existing.map(target_wire), + expected: existing + .map(|target| self.asset(target).and_then(|row| number(&row, "revision"))) + .transpose()?, + kind: wire::AssetKind::PhysicalPosition as i32, + changes: vec![wire::Field { + name: "name".into(), + value: Some(field::Value::Text(value.name)), + }], + ..Default::default() + }, + now, + )?; + let revision = number(&self.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?; + let mut row = prior.unwrap_or_else(|| target_props(target)); + operations::strip_envelope(&mut row); + row.remove("enclosure"); + row.extend(props([ + ("basis", text(value.structural_key)), + ("vint", int(value.component_kind as u64)?), + ("revision", int(revision)?), + ])); + self.put( + &format!("position/{}", network::identity(target)), + &format!("positions/{}", network::identity(owner)), + Class::Inventory, + row.clone(), + )?; + if existing.is_none() { + self.put( + &lookup, + "position-keys", + Class::Inventory, + target_props(target), + )?; + self.attach_created( + context, + target, + wire::AssetKind::PhysicalPosition, + parent, + now, + )?; + } + self.audit( + &network::identity(target), + &context.actor, + "physical-position", + revision, + now, + )?; + position(&row, owner) + } + pub(super) fn physical_positions( + &self, + context: &Context, + value: api::PhysicalPositionQuery, + working: &api::WorkingContext, + ) -> Result<(Vec, Option)> { + let owner = parse_target(value.owner.as_ref())?; + self.physical_access(context, owner, working, false)?; + let limit = if value.limit == 0 { 25 } else { value.limit }; + if limit > 25 { + return Err(Error::Limit); + } + let after = value.after.as_deref().unwrap_or(""); + if after.len() > 256 { + return Err(Error::Invalid); + } + let mut values = HashMap::from([ + ( + "parent".into(), + text(format!("positions/{}", network::identity(owner))), + ), + ("after".into(), text(after)), + ("limit".into(), int(u64::from(limit) + 1)?), + ]); + let visible = assets::visible_clause_for(context, Some("physical"), &mut values, "a"); + let rows=self.query(&format!("MATCH (p:OmRow {{parent:$parent}}) WITH p, 'asset/'+p.asset AS key MATCH (a:OmRow {{key:key}}) WHERE a.incarnation=p.incarnation AND p.key>$after AND {visible} RETURN properties(p) AS p ORDER BY p.key LIMIT $limit"),Some(values))?; + let mut output = Vec::with_capacity(rows.len().min(limit as usize)); + for row in rows.iter().take(limit as usize) { + output.push(position(row, owner)?); + } + let next = if rows.len() > limit as usize { + Some(string(&rows[limit as usize - 1], "key")?) + } else { + None + }; + Ok((output, next)) + } + pub(super) fn physical_labels( + &self, + context: &Context, + view: &mut api::PhysicalView, + ) -> Result<()> { + let mut targets = BTreeSet::new(); + for position in &view.positions { + targets.insert(parse_target(position.target.as_ref())?); + targets.insert(parse_target(position.owner.as_ref())?); + } + let materials = view + .plan + .as_ref() + .map(|p| p.materials.as_slice()) + .unwrap_or(&[]); + for placement in view.plan.iter().flat_map(placements).chain( + view.occupancy + .iter() + .filter_map(|o| o.placement.as_ref()) + .map(Placement::from), + ) { + if let Some(item) = placement.actual(materials)? { + targets.insert(item); + } + targets.insert(parse_target(placement.enclosure.as_ref())?); + } + for replacement in view.plan.iter().flat_map(replacement_steps) { + for pair in &replacement.endpoints { + for port in [pair.previous.as_ref(), pair.replacement.as_ref()] + .into_iter() + .flatten() + { + targets.insert(parse_target(Some(port))?); + } + } + for item in [ + replacement.position.as_ref(), + replacement.source_position.as_ref(), + replacement.previous.as_ref(), + replacement.replacement.as_ref(), + ] + .into_iter() + .flatten() + { + targets.insert(parse_target(Some(item))?); + } + } + for step in view.plan.iter().flat_map(|p| &p.steps) { + if let Some(api::physical_step::Action::Wiring(w)) = &step.action { + targets.extend(wiring::targets(w)?); + } + } + for allocation in view + .plan + .iter() + .flat_map(|p| &p.materials) + .flat_map(|m| &m.allocations) + { + targets.insert(parse_target(allocation.item.as_ref())?); + } + for end in &view.cable_ends { + for port in [ + end.intended_port.as_ref(), + match &end.attachment { + Some(api::physical_cable_end::Attachment::Connected(binding)) => { + binding.port.as_ref() + } + _ => None, + }, + ] + .into_iter() + .flatten() + { + targets.insert(parse_target(Some(port))?); + } + } + if targets.is_empty() { + return Ok(()); + } + for item in &view.items { + targets.remove(&parse_target(item.target.as_ref())?); + } + if targets.is_empty() { + return Ok(()); + } + if view.items.len() + targets.len() > 256 { + return Err(Error::Limit); + } + let mut values = HashMap::from([( + "keys".into(), + Value::Array(targets.iter().map(|t| text(asset_key(*t))).collect()), + )]); + let visible = assets::visible_clause_for(context, None, &mut values, "a"); + let rows=self.query(&format!("UNWIND $keys AS key MATCH (a:OmRow {{key:key}}) WHERE {visible} RETURN properties(a) AS p LIMIT 256"),Some(values))?; + for row in rows { + let target = target(&row)?; + if !targets.contains(&target) { + continue; + } + let kind = i32::try_from(number(&row, "kind")?).map_err(|_| Error::Corrupt)?; + let fields = if context.field(target, kind, "name", false) { + row.get("name") + .and_then(Value::as_str) + .map(|value| wire::Field { + name: "name".into(), + value: Some(field::Value::Text(value.into())), + }) + .into_iter() + .collect() + } else { + Vec::new() + }; + view.items.push(api::Item { + target: Some(target_wire(target)), + kind, + revision: number(&row, "revision")?, + fields, + }); + } + if view.plan.is_some() { + let mut keys = Vec::with_capacity(view.items.len()); + let mut owners = BTreeMap::new(); + for (index, item) in view.items.iter().enumerate() { + let target = parse_target(item.target.as_ref())?; + if context.field(target, item.kind, "serial", false) { + let key = asset_key(target); + keys.push(text(format!("{key}/intent/serial"))); + owners.insert(key, index); + } + } + if !keys.is_empty() { + let rows=self.query("UNWIND $keys AS key MATCH (f:OmRow {key:key}) RETURN properties(f) AS p LIMIT 256",params([("keys",Value::Array(keys))]))?; + for row in rows { + let owner = string(&row, "parent")?; + if let Some(&index) = owners.get(&owner) { + view.items[index].fields.push(inventory::read_field(&row)?); + } + } + } + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/rack_grid.rs b/crates/omloom-store/src/disk/workspace/physical/rack_grid.rs new file mode 100644 index 0000000..1d447be --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/rack_grid.rs @@ -0,0 +1,49 @@ +//! One bounded rail declaration on the current profile, no rows per rack unit. +use super::*; + +fn validate(grid: &api::PhysicalRackGrid) -> Result { + if grid.unit_count == 0 + || grid.first_unit == 0 + || grid.first_unit.checked_add(grid.unit_count - 1).is_none() + || !matches!( + api::PhysicalRackDirection::try_from(grid.direction), + Ok(api::PhysicalRackDirection::TopDown | api::PhysicalRackDirection::BottomUp) + ) + { + return Err(Error::Invalid); + } + Span::new(grid.top_um, u64::from(grid.unit_count) * 44_450).map_err(|_| Error::Invalid) +} + +pub(super) fn write( + row: &mut Properties, + value: Option<&api::PhysicalRackGrid>, + envelope: Option<&api::PhysicalVolume>, +) -> Result<()> { + let Some(value) = value else { + return Ok(()); + }; + let rails = validate(value)?; + if let Some(envelope) = envelope + && !volume(envelope)?.y.contains(rails) + { + return Err(Error::Invalid); + } + // These four integers are consumed together by the selected rack's profile read. + // No range index or materialized slot inventory is needed. + row.insert("working".into(), Value::Binary(value.encode_to_vec())); + Ok(()) +} + +pub(super) fn read(row: &Properties) -> Result> { + if !row.contains_key("working") { + return Ok(None); + } + let bytes = binary(row, "working")?; + if bytes.len() > 64 { + return Err(Error::Corrupt); + } + let value = api::PhysicalRackGrid::decode(bytes).map_err(|_| Error::Corrupt)?; + validate(&value).map_err(|_| Error::Corrupt)?; + Ok(Some(value)) +} diff --git a/crates/omloom-store/src/disk/workspace/physical/replacements.rs b/crates/omloom-store/src/disk/workspace/physical/replacements.rs new file mode 100644 index 0000000..5b42ff1 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/replacements.rs @@ -0,0 +1,569 @@ +use super::*; + +fn claim_endpoint( + set: &mut BTreeSet<(AssetRef, Option)>, + port: AssetRef, + channel: Option, +) -> Result<()> { + if channel.is_none() { + if set + .range((port, None)..=(port, Some(4096))) + .next() + .is_some() + { + return Err(Error::Conflict); + } + } else if set.contains(&(port, None)) { + return Err(Error::Conflict); + } + if !set.insert((port, channel)) { + return Err(Error::Conflict); + } + Ok(()) +} + +pub(super) fn replacement_row(value: &api::PhysicalReplacement) -> Result { + let mut row = props([( + "enclosure", + text(network::identity(parse_target(value.position.as_ref())?)), + )]); + if let Some(item) = &value.previous { + row.insert( + "old_item".into(), + text(network::identity(parse_target(Some(item))?)), + ); + } + if let Some(item) = &value.replacement { + row.insert( + "item".into(), + text(network::identity(parse_target(Some(item))?)), + ); + } + if let Some(position) = &value.source_position { + row.insert( + "source".into(), + text(network::identity(parse_target(Some(position))?)), + ); + } + if let Some(binding) = &value.previous_binding { + row.insert("claim".into(), text(binding)); + } + if let Some(revision) = value.previous_revision { + row.insert("expected".into(), int(revision)?); + } + if let Some(volume) = &value.volume { + write_volume(&mut row, volume)?; + } + if !value.endpoints.is_empty() { + if value.endpoints.len() > 64 { + return Err(Error::Limit); + } + let mappings = api::PhysicalEndpointMappings { + pairs: value.endpoints.clone(), + }; + if mappings.pairs.len() > 64 || mappings.encoded_len() > 16_384 { + return Err(Error::Limit); + } + // One bounded review consumes every pair together; none is indexed or + // duplicated into the authoritative wiring or endpoint inventory. + row.insert("payload".into(), Value::Binary(mappings.encode_to_vec())); + } + if let Some(feedback) = &value.feedback { + if feedback.encoded_len() > 512 { + return Err(Error::Limit); + } + row.insert("working".into(), Value::Binary(feedback.encode_to_vec())); + } + Ok(row) +} +pub(super) fn read_replacement(row: &Properties) -> Result { + let reference = |name: &str| -> Result> { + row.get(name) + .map(|_| { + string(row, name) + .and_then(|s| lifecycle::reference(&s)) + .map(target_wire) + }) + .transpose() + }; + Ok(api::PhysicalReplacement { + position: Some(target_wire(lifecycle::reference(&string( + row, + "enclosure", + )?)?)), + previous: reference("old_item")?, + replacement: reference("item")?, + source_position: reference("source")?, + volume: read_volume(row)?, + previous_binding: row.get("claim").map(|_| string(row, "claim")).transpose()?, + previous_revision: row + .get("expected") + .map(|_| number(row, "expected")) + .transpose()?, + endpoints: if row.contains_key("payload") { + let bytes = binary(row, "payload")?; + if bytes.len() > 16_384 { + return Err(Error::Corrupt); + } + let mappings = + api::PhysicalEndpointMappings::decode(bytes).map_err(|_| Error::Corrupt)?; + if mappings.pairs.len() > 64 { + return Err(Error::Corrupt); + } + mappings.pairs + } else { + Vec::new() + }, + feedback: row + .get("working") + .map(|_| -> Result> { + let bytes = binary(row, "working")?; + if bytes.len() > 512 { + return Err(Error::Corrupt); + } + api::PhysicalReplacementFeedback::decode(bytes) + .map(Box::new) + .map_err(|_| Error::Corrupt) + }) + .transpose()?, + }) +} +impl Tx<'_> { + pub(super) fn require_replacement_space + Ord>( + &self, + replacement: &api::PhysicalReplacement, + removed: &BTreeSet, + destination_kind: wire::AssetKind, + ) -> Result<()> { + let position = parse_target(replacement.position.as_ref())?; + if destination_kind == wire::AssetKind::PhysicalPosition { + let rows=self.query("MATCH (p:OmRow {key:$key})<-[:OmRelationTo]-(r:OmRow) WHERE r.kind=$kind RETURN properties(r) AS p LIMIT 2",params([("key",text(asset_key(position))),("kind",int(wire::RelationKind::InstalledIn as u64)?)]))?; + if rows.len() > 1 { + return Err(Error::Conflict); + } + for row in rows { + let binding = inventory::relation_projection(&row)?; + if !removed.contains(binding.relation_id.as_str()) + && binding.from != replacement.replacement + { + return Err(Error::Conflict); + } + } + } + if let Some(incoming) = &replacement.replacement { + for binding in self.installed_bindings(parse_target(Some(incoming))?)? { + if !removed.contains(binding.relation_id.as_str()) + && binding.to != replacement.position + { + return Err(Error::Conflict); + } + } + } + Ok(()) + } + + pub(super) fn record_physical_replacements( + &mut self, + context: &Context, + replacements: &[&api::PhysicalReplacement], + report: &api::PhysicalCompletion, + working: &api::WorkingContext, + now: u64, + ) -> Result<()> { + let plan = report.plan.as_ref().ok_or(Error::Invalid)?.id.as_str(); + let selection = self.selection(context, working, true)?; + let mut old = Vec::with_capacity(replacements.len()); + let mut removed = BTreeSet::new(); + for replacement in replacements { + self.replacement_access(context, replacement, working, true)?; + for part in [ + replacement.previous.as_ref(), + replacement.replacement.as_ref(), + ] + .into_iter() + .flatten() + { + let part = parse_target(Some(part))?; + self.require_no_pending_operations(part)?; + if let Some(lock) = + self.get(&format!("physical-lock/{}", network::identity(part)))? + && string(&lock, "owner")? != plan + { + return Err(Error::Conflict); + } + } + if let Some(binding) = self.replacement_basis(replacement)? { + self.check_relationship(context, &selection, &binding)?; + if !removed.insert(binding.relation_id.clone()) { + return Err(Error::Invalid); + } + old.push(binding); + } + } + let mut incoming_positions = BTreeSet::new(); + for replacement in replacements { + let position = parse_target(replacement.position.as_ref())?; + if replacement.replacement.is_some() { + incoming_positions.insert(position); + } + let kind = wire::AssetKind::try_from( + i32::try_from(number(&self.asset(position)?, "kind")?) + .map_err(|_| Error::Corrupt)?, + ) + .map_err(|_| Error::Corrupt)?; + self.require_replacement_space(replacement, &removed, kind)?; + } + // Detach every selected old binding before attaching incoming parts, including swaps. + for binding in &old { + self.remove_binding_relation(binding, &context.actor, now)?; + } + for replacement in replacements { + let position = parse_target(replacement.position.as_ref())?; + let Some(incoming) = &replacement.replacement else { + continue; + }; + let item = parse_target(Some(incoming))?; + let existing = self.installed_bindings(item)?; + let binding = if existing.is_empty() { + let binding = wire::Relation { + relation_id: RelationId::generate().to_string(), + from: Some(target_wire(item)), + to: Some(target_wire(position)), + kind: wire::RelationKind::InstalledIn as i32, + revision: 1, + authority_id: self.workspace_authority()?, + }; + self.check_relationship(context, &selection, &binding)?; + self.save_physical_relation( + &binding, + &context.actor, + now, + plan, + report.actual_state, + )?; + binding + } else { + existing.into_iter().next().ok_or(Error::Corrupt)? + }; + if let Some(volume) = &replacement.volume { + self.store_physical_pose(&binding, volume, &context.actor)?; + } + } + let mut touched = BTreeSet::new(); + for binding in &old { + touched.insert(parse_target(binding.to.as_ref())?); + } + for position in touched { + // An incoming binding already clears a prior empty report atomically. + if incoming_positions.contains(&position) { + continue; + } + if number(&self.asset(position)?, "kind")? != wire::AssetKind::PhysicalPosition as u64 { + continue; + } + let key = format!("position/{}", network::identity(position)); + let mut row = self.get(&key)?.ok_or(Error::Corrupt)?; + let parent = string(&row, "parent")?; + operations::strip_envelope(&mut row); + row.remove("enclosure"); + row.insert("vflag".into(), Value::Bool(true)); + row.insert("actor".into(), text(&context.actor)); + row.insert( + "revision".into(), + int(number(&self.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?)?, + ); + self.put(&key, &parent, Class::Inventory, row)?; + } + Ok(()) + } + pub(super) fn replacement_access( + &self, + context: &Context, + value: &api::PhysicalReplacement, + working: &api::WorkingContext, + write: bool, + ) -> Result<()> { + for reference in [ + value.position.as_ref(), + value.source_position.as_ref(), + value.previous.as_ref(), + value.replacement.as_ref(), + ] + .into_iter() + .flatten() + { + let target = parse_target(Some(reference))?; + self.physical_access(context, target, working, write)?; + let kind = + i32::try_from(number(&self.asset(target)?, "kind")?).map_err(|_| Error::Corrupt)?; + if !context.field(target, kind, "relations", false) + || (write && !context.field(target, kind, "relations", true)) + { + return Err(Error::Denied); + } + } + self.endpoint_mapping_access(context, value, working, write)?; + Ok(()) + } + fn endpoint_mapping_access( + &self, + context: &Context, + value: &api::PhysicalReplacement, + working: &api::WorkingContext, + write: bool, + ) -> Result<()> { + if value.endpoints.len() > 64 { + return Err(Error::Limit); + } + let ports = self.mapping_ports(context, value, working, write)?; + let mut sources = BTreeSet::new(); + let mut destinations = BTreeSet::new(); + for pair in &value.endpoints { + let source = parse_target(pair.previous.as_ref())?; + for channel in [pair.previous_channel, pair.replacement_channel] + .into_iter() + .flatten() + { + if channel == 0 || channel > 4096 { + return Err(Error::Invalid); + } + } + claim_endpoint(&mut sources, source, pair.previous_channel)?; + match api::PhysicalMappingDecision::try_from(pair.decision) + .map_err(|_| Error::Invalid)? + { + api::PhysicalMappingDecision::Unresolved + if pair.replacement.is_none() && pair.replacement_channel.is_none() => {} + api::PhysicalMappingDecision::Accepted | api::PhysicalMappingDecision::Rejected + if pair.replacement.is_some() => {} + _ => return Err(Error::Invalid), + } + if let Some(destination) = &pair.replacement { + let destination = parse_target(Some(destination))?; + if pair.decision == api::PhysicalMappingDecision::Accepted as i32 { + claim_endpoint(&mut destinations, destination, pair.replacement_channel)?; + for (port, channel) in [ + (source, pair.previous_channel), + (destination, pair.replacement_channel), + ] { + if let Some(channel) = channel { + let declared = ports.get(&port).is_some_and(|d| d.contains(&channel)); + if !declared { + return Err(Error::Incomplete); + } + } + } + } + } + } + Ok(()) + } + fn mapping_ports( + &self, + context: &Context, + value: &api::PhysicalReplacement, + working: &api::WorkingContext, + write: bool, + ) -> Result>> { + let mut owners: BTreeMap> = BTreeMap::new(); + let mut ports = BTreeSet::new(); + let mut channels = BTreeSet::new(); + for pair in &value.endpoints { + for (owner, port, channel) in [ + ( + value.previous.as_ref(), + pair.previous.as_ref(), + pair.previous_channel, + ), + ( + value.replacement.as_ref(), + pair.replacement.as_ref(), + pair.replacement_channel, + ), + ] { + if let Some(port) = port { + let port = parse_target(Some(port))?; + let owner = parse_target(owner)?; + owners.entry(owner).or_default().insert(port); + ports.insert(port); + if channel.is_some() { + channels.insert(port); + } + } + } + } + if ports.is_empty() { + return Ok(BTreeMap::new()); + } + let selection = self.selection(context, working, write)?; + let keys = ports.iter().map(|p| text(asset_key(*p))).collect(); + let mut values = HashMap::from([("keys".into(), Value::Array(keys))]); + let visible = assets::visible_clause_for(context, Some("physical"), &mut values, "a"); + let rows=self.query(&format!("UNWIND $keys AS key MATCH (a:OmRow {{key:key}}) WHERE {visible} RETURN {{asset:a.asset,incarnation:a.incarnation,kind:a.kind,adoption:a.adoption}} AS p LIMIT 128"),Some(values))?; + if rows.len() != ports.len() { + return Err(Error::Denied); + } + for row in rows { + let target = target(&row)?; + if !ports.contains(&target) { + return Err(Error::Conflict); + } + selection.contains(target, self)?; + if Self::physical_metadata_access(context, target, &row, write)? + != wire::AssetKind::Port + { + return Err(Error::Invalid); + } + if !context.field(target, wire::AssetKind::Port as i32, "relations", false) { + return Err(Error::Denied); + } + } + // At most two actual suppliers, each with indexed endpoint probes in one + // statement. Check exact incarnations before accepting any supplied port. + for (owner, expected) in owners { + let rows=self.query("UNWIND $ports AS port MATCH (r:OmRow {to_asset:port}) WHERE r.parent='relations' AND r.kind=$kind AND r.asset=$owner AND r.incarnation=$incarnation AND r.to_incarnation IN $incarnations RETURN {asset:r.to_asset,incarnation:r.to_incarnation} AS p LIMIT 129",params([ + ("ports",Value::Array(expected.iter().map(|p|text(p.asset.to_string())).collect())), + ("incarnations",Value::Array(expected.iter().map(|p|text(p.incarnation.to_string())).collect())), + ("kind",int(wire::RelationKind::HasPort as u64)?),("owner",text(owner.asset.to_string())),("incarnation",text(owner.incarnation.to_string())), + ]))?; + if rows.len() > 128 { + return Err(Error::Limit); + } + let mut found = BTreeSet::new(); + for row in rows { + let target = target(&row)?; + if expected.contains(&target) { + found.insert(target); + } + } + if found != expected { + return Err(Error::Invalid); + } + } + let mut result = BTreeMap::new(); + if !channels.is_empty() { + let keys = channels + .iter() + .map(|p| text(format!("physical/{}", network::identity(*p)))) + .collect(); + for row in self.query( + "UNWIND $keys AS key MATCH (p:OmRow {key:key}) RETURN properties(p) AS p LIMIT 128", + params([("keys", Value::Array(keys))]), + )? { + let definition = definition(&row)?; + result.insert( + parse_target(definition.target.as_ref())?, + definition.channels, + ); + } + } + Ok(result) + } + pub(super) fn check_replacements( + &self, + context: &Context, + plan: &api::PhysicalPlan, + working: &api::WorkingContext, + write: bool, + ) -> Result<()> { + let mut positions = BTreeSet::new(); + let mut parts = BTreeSet::new(); + let mut bindings = BTreeSet::new(); + let mut mapping_count = 0usize; + for replacement in replacement_steps(plan) { + if replacement.endpoints.len() > 64 - mapping_count { + return Err(Error::Limit); + } + mapping_count += replacement.endpoints.len(); + self.replacement_access(context, replacement, working, write)?; + let position = parse_target(replacement.position.as_ref())?; + if !positions.insert(position) + || (replacement.previous.is_none() && replacement.replacement.is_none()) + { + return Err(Error::Invalid); + } + for item in [ + replacement.previous.as_ref(), + replacement.replacement.as_ref(), + ] + .into_iter() + .flatten() + { + let item = parse_target(Some(item))?; + let kind = wire::AssetKind::try_from(number(&self.asset(item)?, "kind")? as i32) + .map_err(|_| Error::Corrupt)?; + if item == position + || !matches!( + kind, + wire::AssetKind::PhysicalComponent | wire::AssetKind::Device + ) + { + return Err(Error::Invalid); + } + } + if let Some(item) = &replacement.replacement + && !parts.insert(parse_target(Some(item))?) + { + return Err(Error::Invalid); + } + let position_kind = + wire::AssetKind::try_from(number(&self.asset(position)?, "kind")? as i32) + .map_err(|_| Error::Corrupt)?; + if !matches!( + position_kind, + wire::AssetKind::PhysicalPosition + | wire::AssetKind::Rack + | wire::AssetKind::Location + | wire::AssetKind::Site + ) { + return Err(Error::Invalid); + } + match (&replacement.previous_binding, replacement.previous_revision) { + (Some(id), Some(revision)) if replacement.previous.is_some() && revision != 0 => { + if !bindings.insert(RelationId::parse(id).map_err(|_| Error::Invalid)?) { + return Err(Error::Invalid); + } + } + (None, None) => {} + _ => return Err(Error::Invalid), + } + if replacement.source_position.is_some() && replacement.previous.is_none() { + return Err(Error::Invalid); + } + } + Ok(()) + } + pub(super) fn replacement_basis( + &self, + replacement: &api::PhysicalReplacement, + ) -> Result> { + let Some(previous) = replacement.previous.as_ref() else { + return Ok(None); + }; + let id = RelationId::parse( + replacement + .previous_binding + .as_ref() + .ok_or(Error::Incomplete)?, + ) + .map_err(|_| Error::Invalid)?; + let row = self + .get(&format!("relation/{id}"))? + .ok_or(Error::Conflict)?; + let relation = inventory::relation_projection(&row)?; + let source = replacement + .source_position + .as_ref() + .or(replacement.position.as_ref()); + if relation.from.as_ref() != Some(previous) + || relation.to.as_ref() != source + || Some(relation.revision) != replacement.previous_revision + || relation.kind != wire::RelationKind::InstalledIn as i32 + { + return Err(Error::Conflict); + } + Ok(Some(relation)) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/reservations.rs b/crates/omloom-store/src/disk/workspace/physical/reservations.rs new file mode 100644 index 0000000..b008cd6 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/reservations.rs @@ -0,0 +1,118 @@ +use super::*; + +pub(super) fn reservation_key(plan: &str, index: usize) -> String { + format!("reservation/{plan}/{index}") +} +impl Tx<'_> { + pub(super) fn lock_physical_part(&mut self, item: AssetRef, plan: &str) -> Result<()> { + self.require_no_pending_operations(item)?; + let key = format!("physical-lock/{}", network::identity(item)); + if let Some(row) = self.get(&key)? { + if string(&row, "owner")? != plan { + return Err(Error::Conflict); + } + return Ok(()); + } + self.put( + &key, + "physical-locks", + Class::Pending, + props([("owner", text(plan))]), + )?; + Ok(()) + } + pub(super) fn clear_physical_reservation(&mut self, plan: &str, index: usize) -> Result<()> { + let key = reservation_key(plan, index); + if let Some(row) = self.get(&key)? { + if string(&row, "claim")? != format!("plan/{plan}/{index}") { + return Err(Error::Corrupt); + } + self.delete(&key)?; + } + Ok(()) + } + pub(super) fn release_resolved_physical_locks( + &mut self, + plan: &api::PhysicalPlan, + ) -> Result<()> { + let owner = plan.id.as_deref().ok_or(Error::Corrupt)?; + let mut resolved = BTreeSet::new(); + let mut pending = BTreeSet::new(); + for step in &plan.steps { + let terminal = matches!( + api::PhysicalStepState::try_from(step.state), + Ok(api::PhysicalStepState::Recorded | api::PhysicalStepState::NotPerformed) + ); + let keys = if terminal { + &mut resolved + } else { + &mut pending + }; + match step.action.as_ref().ok_or(Error::Corrupt)? { + api::physical_step::Action::Placement(p) => { + if let Some(item) = Placement::from(p).actual(&plan.materials)? { + keys.insert(format!("physical-lock/{}", network::identity(item))); + } + } + api::physical_step::Action::Replacement(r) => { + for item in [r.previous.as_ref(), r.replacement.as_ref()] + .into_iter() + .flatten() + { + keys.insert(format!( + "physical-lock/{}", + network::identity(parse_target(Some(item))?) + )); + } + for item in [r.position.as_ref(), r.source_position.as_ref()] + .into_iter() + .flatten() + { + keys.insert(format!( + "physical-slot/{}", + network::identity(parse_target(Some(item))?) + )); + } + } + api::physical_step::Action::Wiring(w) => { + keys.insert(wiring::hold_key( + parse_target(w.cable.as_ref())?, + &w.end_key, + )); + for target in wiring::targets(w)? { + keys.insert(format!("physical-lock/{}", network::identity(target))); + } + } + } + } + // Keep unused BOM units for accepted future work, never after its cancellation. + let future_work = plan.steps.iter().any(|s| { + !matches!( + api::PhysicalStepState::try_from(s.state), + Ok(api::PhysicalStepState::Recorded | api::PhysicalStepState::NotPerformed) + ) + }) && matches!( + api::PhysicalPlanState::try_from(plan.state), + Ok(api::PhysicalPlanState::Reserved | api::PhysicalPlanState::Partial) + ); + for allocation in plan.materials.iter().flat_map(|m| &m.allocations) { + let key = format!( + "physical-lock/{}", + network::identity(parse_target(allocation.item.as_ref())?) + ); + if future_work && !resolved.contains(&key) { + pending.insert(key); + } else { + resolved.insert(key); + } + } + for key in resolved.difference(&pending) { + if let Some(row) = self.get(key)? + && string(&row, "owner")? == owner + { + self.delete(key)?; + } + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/reviews.rs b/crates/omloom-store/src/disk/workspace/physical/reviews.rs new file mode 100644 index 0000000..046002e --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/reviews.rs @@ -0,0 +1,366 @@ +//! Current judgments live with their owning plan step. Fixed-window counters +//! contain no resource identities and expire through ordinary detail cleanup. +use super::*; + +pub(super) fn check_saved_feedback( + plan: &api::PhysicalPlan, + existing: Option<&api::PhysicalPlan>, +) -> Result<()> { + for step in &plan.steps { + if let Some(api::physical_step::Action::Replacement(replacement)) = &step.action { + let prior = existing + .and_then(|p| { + replacement_steps(p).find(|r| { + r.position == replacement.position + && r.previous == replacement.previous + && r.replacement == replacement.replacement + && r.source_position == replacement.source_position + && r.previous_binding == replacement.previous_binding + && r.previous_revision == replacement.previous_revision + }) + }) + .and_then(|r| r.feedback.as_ref()); + if replacement.feedback.as_ref() != prior { + return Err(Error::Invalid); + } + } + } + Ok(()) +} + +fn scope(context: &Context, working: &api::WorkingContext) -> Result { + // Read scope, not authorship or write privileges, determines disclosure. + // The host supplies grants and their revision; callers select only roots. + if working.roots.len() > MAX_BULK { + return Err(Error::Limit); + } + for root in &working.roots { + parse_target(Some(root))?; + } + // Validated canonical UUID text has the same pair ordering as the nominal identities. + let mut roots: Vec<_> = working.roots.iter().collect(); + roots + .sort_unstable_by(|a, b| (&a.asset_id, &a.incarnation).cmp(&(&b.asset_id, &b.incarnation))); + roots.dedup(); + let mut hash = Sha256::new(); + hash.update(context.authority_revision.to_le_bytes()); + let mut grants: Vec<_> = context + .grants + .iter() + .map(|grant| Sha256::digest(format!("{:?}/{:?}", grant.scope, grant.read).as_bytes())) + .collect(); + grants.sort_unstable(); + grants.dedup(); + for grant in grants { + hash.update(grant); + } + // Two validated UUID strings, each with its one-byte protobuf tag and length. + let mut encoded = [0_u8; 2 * (2 + 36)]; + for root in roots { + root.encode(&mut encoded.as_mut_slice()) + .map_err(|_| Error::Corrupt)?; + hash.update(encoded.as_slice()); + } + Ok(format!("{:x}", hash.finalize())) +} +fn parent(scope: &str) -> String { + format!("replacement-quality/{scope}") +} +fn bucket(value: &api::PhysicalReplacementFeedback) -> String { + format!( + "{}/{}/{}/{}/{}/{}/{}", + parent(&value.scope), + value.until_ms, + value.rule, + value.component_kind, + value.strength, + value.decision, + value.reason + ) +} +fn valid_decision(decision: i32, reason: i32) -> Result<()> { + use api::{PhysicalReplacementDecision as D, PhysicalReplacementReason as R}; + match (D::try_from(decision), R::try_from(reason)) { + (Ok(D::Confirmed | D::Rejected | D::Deferred), Ok(_)) => Ok(()), + _ => Err(Error::Invalid), + } +} +impl Tx<'_> { + pub(super) fn reconcile_saved_feedback( + &mut self, + previous: &api::PhysicalPlan, + current: &api::PhysicalPlan, + now: u64, + ) -> Result<()> { + for replacement in replacement_steps(previous) { + if let Some(feedback) = replacement.feedback.as_deref() + && !replacement_steps(current) + .any(|value| value.feedback.as_deref() == Some(feedback)) + { + self.quality_delta(feedback, false, now)?; + } + } + Ok(()) + } + fn replacement_hypothesis( + &self, + value: &api::PhysicalReplacement, + ) -> Result<(api::PhysicalReplacementHypothesis, i32)> { + let mut hash = Sha256::new(); + hash.update(b"replacement-rules-1"); + let mut component_kind = api::PhysicalComponentKind::Unspecified as i32; + for reference in [ + value.position.as_ref(), + value.previous.as_ref(), + value.replacement.as_ref(), + value.source_position.as_ref(), + ] { + if let Some(reference) = reference { + hash.update(reference.encode_to_vec()); + let item = parse_target(Some(reference))?; + hash.update(number(&self.asset(item)?, "revision")?.to_le_bytes()); + if let Some(definition) = self.physical_definition(item)? { + hash.update(definition.encode_to_vec()); + if value.previous.as_ref() == Some(reference) { + component_kind = definition.component_kind; + } + } + } else { + hash.update([0]); + } + } + let same = value.previous.is_some() && value.previous == value.replacement; + let binding_current = match self.replacement_basis(value) { + Ok(Some(binding)) => { + hash.update(binding.encode_to_vec()); + true + } + Ok(None) | Err(Error::Conflict | Error::Incomplete) => false, + Err(error) => return Err(error), + }; + let (strength, explanation) = if same { + ( + api::PhysicalReplacementStrength::EstablishedIdentity as i32, + "The selected asset identity is unchanged. Reseat or move is plausible; this does not prove attachment or service continuity.", + ) + } else if binding_current && value.replacement.is_some() { + ( + api::PhysicalReplacementStrength::Contextual as i32, + "Distinct adopted assets and a current predecessor binding support a possible replacement. An addition, moved part or wrong predecessor remain alternatives. Model, name and IP are not identity proof.", + ) + } else { + ( + api::PhysicalReplacementStrength::Limited as i32, + "Predecessor or current occupancy evidence is incomplete. Removal, source error, addition and replacement remain unresolved alternatives.", + ) + }; + Ok(( + api::PhysicalReplacementHypothesis { + basis: format!("{:x}", hash.finalize()), + rule: 1, + strength, + explanation: explanation.into(), + }, + component_kind, + )) + } + fn quality_delta( + &mut self, + value: &api::PhysicalReplacementFeedback, + add: bool, + now: u64, + ) -> Result<()> { + if value.until_ms <= now { + return Ok(()); + } + let key = bucket(value); + let old = self.get(&key)?; + let count = old + .as_ref() + .map(|p| number(p, "count")) + .transpose()? + .unwrap_or(0); + let count = if add { + count.checked_add(1) + } else { + count.checked_sub(1) + } + .ok_or(Error::Corrupt)?; + if count == 0 { + self.delete(&key)?; + return Ok(()); + } + let at = value + .until_ms + .checked_sub(self.store.policy.detail_age_ms) + .ok_or(Error::Corrupt)?; + self.put_against( + &key, + &parent(&value.scope), + Class::Detail, + props([ + ("at", int(at)?), + ("expires", int(value.until_ms)?), + ("count", int(count)?), + ("generation", int(u64::from(value.rule))?), + ("kind", int(value.component_kind as u64)?), + ( + "expected", + int(u64::try_from(value.strength).map_err(|_| Error::Corrupt)?)?, + ), + ("state", int(value.decision as u64)?), + ("vint", int(value.reason as u64)?), + ]), + old.as_ref(), + )?; + Ok(()) + } + pub(super) fn review_replacement( + &mut self, + context: &Context, + request: api::PhysicalReplacementReview, + working: &api::WorkingContext, + now: u64, + ) -> Result<(api::PhysicalPlan, api::PhysicalReplacementHypothesis)> { + let key = request.plan.as_ref().ok_or(Error::Invalid)?; + let mut plan = + self.load_physical_plan(context, key, working, request.decision.is_some())?; + let step = plan + .steps + .get(request.step as usize) + .ok_or(Error::Invalid)?; + let Some(api::physical_step::Action::Replacement(replacement)) = &step.action else { + return Err(Error::Invalid); + }; + let (hypothesis, kind) = self.replacement_hypothesis(replacement)?; + let Some(decision) = request.decision else { + if request.basis.is_some() || request.reason != 0 { + return Err(Error::Invalid); + } + return Ok((plan, hypothesis)); + }; + valid_decision(decision, request.reason)?; + if key.revision == 0 || request.basis.as_ref() != Some(&hypothesis.basis) { + return Err(Error::Conflict); + } + let scope = scope(context, working)?; + let old = replacement.feedback.as_ref(); + if let Some(old) = old { + if old.scope != scope { + return Err(Error::Denied); + } + if old.until_ms <= now { + return Err(Error::Expired); + } + if old.basis == hypothesis.basis + && old.decision == decision + && old.reason == request.reason + { + return Ok((plan, hypothesis)); + } + } + let duration = self.store.policy.detail_age_ms; + let until = (now / duration) + .checked_add(1) + .and_then(|n| n.checked_mul(duration)) + .ok_or(Error::Limit)?; + let feedback = api::PhysicalReplacementFeedback { + decision, + reason: request.reason, + basis: hypothesis.basis.clone(), + until_ms: old.map(|v| v.until_ms).unwrap_or(until), + rule: hypothesis.rule, + strength: hypothesis.strength, + component_kind: kind, + scope, + }; + if let Some(old) = old { + self.quality_delta(old, false, now)?; + } + self.quality_delta(&feedback, true, now)?; + let Some(api::physical_step::Action::Replacement(replacement)) = + &mut plan.steps[request.step as usize].action + else { + return Err(Error::Corrupt); + }; + replacement.feedback = Some(Box::new(feedback)); + let step_key = format!("plan/{}/{}", key.id, request.step); + let old = self.get(&step_key)?.ok_or(Error::Corrupt)?; + let mut row = step_row(&plan.steps[request.step as usize])?; + let class = if number(&old, "class")? == Class::Detail as u64 { + row.insert("at".into(), int(number(&old, "at")?)?); + Class::Detail + } else { + Class::Inventory + }; + self.put_against( + &step_key, + &format!("plan/{}", key.id), + class, + row, + Some(&old), + )?; + let header_key = format!("plan/{}", key.id); + let old = self.get(&header_key)?.ok_or(Error::Corrupt)?; + let mut row = old.clone(); + let class = if number(&row, "class")? == Class::Detail as u64 { + Class::Detail + } else { + Class::Inventory + }; + operations::strip_envelope(&mut row); + plan.revision = number(&self.root, "revision")? + .checked_add(1) + .ok_or(Error::Limit)?; + row.insert("revision".into(), int(plan.revision)?); + self.put_against(&header_key, "plans", class, row, Some(&old))?; + self.audit( + &key.id, + &context.actor, + "replacement.reviewed", + plan.revision, + now, + )?; + Ok((plan, hypothesis)) + } + pub(super) fn replacement_quality( + &self, + context: &Context, + request: api::PhysicalReplacementQualityQuery, + working: &api::WorkingContext, + now: u64, + ) -> Result<(Vec, Option)> { + if self.root.get("restored") == Some(&Value::Bool(true)) { + return Err(Error::RecoveryRequired); + } + if request.after.as_ref().is_some_and(|s| s.len() > 256) { + return Err(Error::Invalid); + } + let scope = scope(context, working)?; + // Hashing bounds the selection; asset lifecycle changes need current visibility too. + for root in &working.roots { + self.visible_asset(context, parse_target(Some(root))?)?; + } + let parent = parent(&scope); + let rows = self.query("MATCH (n:OmRow {parent:$parent}) WHERE n.expires>$now AND n.key>$after RETURN properties(n) AS p ORDER BY n.key LIMIT 26", + params([("parent",text(parent)),("now",int(now)?),("after",text(request.after.as_deref().unwrap_or("")))]))?; + let next = if rows.len() > 25 { + Some(string(&rows[24], "key")?) + } else { + None + }; + let mut output = Vec::with_capacity(rows.len().min(25)); + for row in rows.iter().take(25) { + output.push(api::PhysicalReplacementQuality { + component_kind: i32::try_from(number(row, "kind")?).map_err(|_| Error::Corrupt)?, + rule: u32::try_from(number(row, "generation")?).map_err(|_| Error::Corrupt)?, + strength: i32::try_from(number(row, "expected")?).map_err(|_| Error::Corrupt)?, + decision: i32::try_from(number(row, "state")?).map_err(|_| Error::Corrupt)?, + reason: i32::try_from(number(row, "vint")?).map_err(|_| Error::Corrupt)?, + count: number(row, "count")?, + until_ms: number(row, "expires")?, + }); + } + Ok((output, next)) + } +} diff --git a/crates/omloom-store/src/disk/workspace/physical/wiring.rs b/crates/omloom-store/src/disk/workspace/physical/wiring.rs new file mode 100644 index 0000000..9ed1d24 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/wiring.rs @@ -0,0 +1,589 @@ +use super::*; + +fn end_parent(cable: AssetRef) -> String { + format!("cable-ends/{}", network::identity(cable)) +} +pub(super) fn hold_key(cable: AssetRef, end: &str) -> String { + format!("physical-end-holds/{}/{end}", network::identity(cable)) +} +fn row_key(cable: AssetRef, end: &str) -> Result { + if end.is_empty() + || end.len() > 64 + || !end + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.:-".contains(&b)) + { + return Err(Error::Invalid); + } + Ok(format!("{}/{end}", end_parent(cable))) +} +pub(super) fn targets(step: &api::PhysicalWiring) -> Result> { + let mut targets = vec![parse_target(step.cable.as_ref())?]; + if let Some(api::physical_wiring::After::Port(port)) = &step.after { + targets.push(parse_target(Some(port))?); + } + if let Some(port) = step.previous.as_ref().and_then(|b| b.port.as_ref()) { + targets.push(parse_target(Some(port))?); + } + if let Some(port) = &step.previous_intended_port { + targets.push(parse_target(Some(port))?); + } + Ok(targets) +} +pub(super) fn step_row(value: &api::PhysicalWiring) -> Result { + let cable = parse_target(value.cable.as_ref())?; + row_key(cable, &value.end_key)?; + let mut row = props([ + ("action", text("wiring")), + ("item", text(network::identity(cable))), + ("basis", text(&value.end_key)), + ("vint", int(value.intent as u64)?), + ]); + match value.basis.as_ref().ok_or(Error::Invalid)? { + api::physical_wiring::Basis::ExpectedEndRevision(value) => { + row.insert("expected".into(), int(*value)?); + } + api::physical_wiring::Basis::AfterStep(value) => { + row.insert("generation".into(), int(u64::from(*value))?); + } + } + if let Some(revision) = value.result_revision { + row.insert("source_binding_revision".into(), int(revision)?); + } + match value.after.as_ref().ok_or(Error::Invalid)? { + api::physical_wiring::After::Port(port) => { + row.insert("meaning".into(), text("port")); + row.insert( + "enclosure".into(), + text(network::identity(parse_target(Some(port))?)), + ); + } + api::physical_wiring::After::Loose(_) => { + row.insert("meaning".into(), text("loose")); + } + api::physical_wiring::After::Unknown(_) => { + row.insert("meaning".into(), text("unknown")); + } + } + if let Some(binding) = &value.previous { + row.insert("binding".into(), text(&binding.binding_id)); + row.insert("epoch".into(), int(binding.binding_revision)?); + row.insert( + "old_item".into(), + text(network::identity(parse_target(binding.port.as_ref())?)), + ); + } + if let Some(port) = &value.previous_intended_port { + let port = parse_target(Some(port))?; + row.insert("to_asset".into(), text(port.asset.to_string())); + row.insert("to_incarnation".into(), text(port.incarnation.to_string())); + } + Ok(row) +} +pub(super) fn read_step(row: &Properties) -> Result { + let after = match string(row, "meaning")?.as_str() { + "port" => api::physical_wiring::After::Port(target_wire(lifecycle::reference(&string( + row, + "enclosure", + )?)?)), + "loose" => api::physical_wiring::After::Loose(api::Empty {}), + "unknown" => api::physical_wiring::After::Unknown(api::Empty {}), + _ => return Err(Error::Corrupt), + }; + let basis = if row.contains_key("generation") { + api::physical_wiring::Basis::AfterStep( + u32::try_from(number(row, "generation")?).map_err(|_| Error::Corrupt)?, + ) + } else { + api::physical_wiring::Basis::ExpectedEndRevision(number(row, "expected")?) + }; + let intent = i32::try_from(number(row, "vint")?).map_err(|_| Error::Corrupt)?; + api::PhysicalWiringIntent::try_from(intent).map_err(|_| Error::Corrupt)?; + Ok(api::PhysicalWiring { + cable: Some(target_wire(lifecycle::reference(&string(row, "item")?)?)), + end_key: string(row, "basis")?, + basis: Some(basis), + intent, + after: Some(after), + previous: if row.contains_key("binding") { + Some(api::PhysicalCableAttachment { + port: Some(target_wire(lifecycle::reference(&string( + row, "old_item", + )?)?)), + binding_id: string(row, "binding")?, + binding_revision: number(row, "epoch")?, + }) + } else { + None + }, + previous_intended_port: cables::intended(row)?.map(target_wire), + result_revision: row + .get("source_binding_revision") + .map(|_| number(row, "source_binding_revision")) + .transpose()?, + }) +} +fn wiring(step: &api::PhysicalStep) -> Option<&api::PhysicalWiring> { + match &step.action { + Some(api::physical_step::Action::Wiring(value)) => Some(value), + _ => None, + } +} +fn vacates(step: &api::PhysicalWiring, port: &wire::AssetRef) -> bool { + match &step.after { + Some(api::physical_wiring::After::Loose(_)) => true, + Some(api::physical_wiring::After::Port(next)) => next != port, + _ => false, + } +} +fn check_planned_sockets(plan: &api::PhysicalPlan, graph: &flow::Analysis) -> Result<()> { + // At most 64 steps. Compare conflicting claims and prove an intervening release in the shared DAG. + for (index, step) in plan.steps.iter().enumerate() { + let Some(current) = wiring(step) else { + continue; + }; + let Some(api::physical_wiring::After::Port(port)) = ¤t.after else { + continue; + }; + for (other, step) in plan.steps[..index].iter().enumerate() { + let Some(prior) = wiring(step) else { + continue; + }; + if prior.after != current.after { + continue; + } + let (before, after, first, second) = if graph.ancestors[index] & (1u64 << other) != 0 { + (other, index, prior, current) + } else if graph.ancestors[other] & (1u64 << index) != 0 { + (index, other, current, prior) + } else { + return Err(Error::Conflict); + }; + if first.cable == second.cable && first.end_key == second.end_key { + continue; + } + let mut released = false; + for (middle, step) in plan.steps.iter().enumerate() { + if graph.ancestors[after] & (1u64 << middle) == 0 + || graph.ancestors[middle] & (1u64 << before) == 0 + { + continue; + } + let Some(step) = wiring(step) else { + continue; + }; + if step.cable == first.cable && step.end_key == first.end_key && vacates(step, port) + { + released = true; + break; + } + } + if !released { + return Err(Error::Conflict); + } + } + } + Ok(()) +} +impl Tx<'_> { + pub(in crate::disk) fn cable_binding_at( + &self, + port: AssetRef, + ) -> Result> { + let mut bindings = Vec::with_capacity(2); + for pattern in [ + "(p:OmRow {key:$port})-[:OmRelationFrom]->(r:OmRow)", + "(p:OmRow {key:$port})<-[:OmRelationTo]-(r:OmRow)", + ] { + let query = format!( + "MATCH {pattern} WHERE r.parent='relations' AND r.kind=$kind RETURN properties(r) AS p LIMIT 2" + ); + let rows = self.query( + &query, + params([ + ("port", text(asset_key(port))), + ("kind", int(wire::RelationKind::ConnectedTo as u64)?), + ]), + )?; + for row in rows { + if !bindings.is_empty() { + return Err(Error::Conflict); + } + bindings.push(inventory::relation_projection(&row)?); + } + } + Ok(bindings.pop()) + } + pub(super) fn assess_wiring( + &self, + context: &Context, + plan: &api::PhysicalPlan, + working: &api::WorkingContext, + graph: &flow::Analysis, + claims: &mut admission::Claims, + ) -> core::result::Result<(), admission::Failure> { + check_planned_sockets(plan, graph).map_err(|error| { + if matches!(error, Error::Conflict) { + admission::Failure::mismatch() + } else { + error.into() + } + })?; + let mut initial = BTreeSet::new(); + for (index, step) in plan.steps.iter().enumerate() { + let Some(api::physical_step::Action::Wiring(wire)) = &step.action else { + continue; + }; + self.wiring_access(context, wire, working, true)?; + let cable = parse_target(wire.cable.as_ref())?; + let key = row_key(cable, &wire.end_key)?; + let row = self.get(&key)?.ok_or(Error::Missing)?; + if let Some(api::physical_wiring::Basis::ExpectedEndRevision(revision)) = wire.basis { + if !initial.insert(key.clone()) { + return Err(Error::Invalid.into()); + } + if number(&row, "revision")? != revision { + return Err(admission::Failure::basis(Error::Conflict)); + } + } + let end = hold_key(cable, &wire.end_key); + if let Some(held) = self.get(&end)? + && Some(string(&held, "owner")?.as_str()) != plan.id.as_deref() + { + return Err(admission::Failure::mismatch()); + } + claims.ends.insert(end); + claims.parts.insert(cable); + if let Some(previous) = &wire.previous { + claims.parts.insert(parse_target(previous.port.as_ref())?); + } + match wire.after.as_ref().ok_or(Error::Invalid)? { + api::physical_wiring::After::Unknown(_) => return Err(Error::Incomplete.into()), + api::physical_wiring::After::Loose(_) => {} + api::physical_wiring::After::Port(target) => { + let port = parse_target(Some(target))?; + if let Some(binding) = self.cable_binding_at(port)? { + let mut permitted = wire.previous.as_ref().is_some_and(|p| { + p.binding_id == binding.relation_id + && p.binding_revision == binding.revision + }); + if !permitted { + for (prior, step) in plan.steps.iter().enumerate() { + if graph.ancestors[index] & (1u64 << prior) == 0 { + continue; + } + let Some(api::physical_step::Action::Wiring(candidate)) = + &step.action + else { + continue; + }; + let Some(previous) = &candidate.previous else { + continue; + }; + if previous.binding_id != binding.relation_id + || previous.binding_revision != binding.revision + { + continue; + } + if candidate.cable == wire.cable + && candidate.end_key == wire.end_key + { + permitted = true; + break; + } + match &candidate.after { + Some(api::physical_wiring::After::Loose(_)) => { + permitted = true; + break; + } + Some(api::physical_wiring::After::Port(next)) + if previous.port.as_ref() != Some(next) => + { + permitted = true; + break; + } + _ => {} + } + } + } + if !permitted { + return Err(admission::Failure::mismatch()); + } + } + self.wiring_mating(context, cable, &row, port)?; + claims.parts.insert(port); + } + } + } + for port in &claims.parts { + if let Some(held) = self.get(&format!("physical-lock/{}", network::identity(*port)))? + && Some(string(&held, "owner")?.as_str()) != plan.id.as_deref() + { + return Err(admission::Failure::mismatch()); + } + } + Ok(()) + } + pub(super) fn prepare_wiring_plan( + &self, + context: &Context, + plan: &mut api::PhysicalPlan, + working: &api::WorkingContext, + ) -> Result<()> { + if (plan.steps.is_empty() && plan.materials.is_empty()) || plan.steps.len() > 64 { + return Err(Error::Invalid); + } + if !plan.steps.is_empty() { + flow::analysis(&plan.steps)?; + } + for step in &mut plan.steps { + if let Some(api::physical_step::Action::Wiring(wire)) = &mut step.action { + self.capture_wiring(context, wire, working)?; + } + } + Ok(()) + } + fn wiring_mating( + &self, + context: &Context, + cable: AssetRef, + end: &Properties, + port: AssetRef, + ) -> core::result::Result<(), admission::Failure> { + if !context.field(cable, wire::AssetKind::Cable as i32, "physical", false) + || !context.field(port, wire::AssetKind::Port as i32, "physical", false) + { + return Err(Error::Denied.into()); + } + let end_mating = connector::read(end)?; + let port_row = self.get(&format!("physical/{}", network::identity(port)))?; + let port_mating = port_row + .as_ref() + .map(connector::read) + .transpose()? + .flatten(); + let result = crate::physical::mating( + end_mating + .as_ref() + .map(connector::declaration) + .transpose()?, + port_mating + .as_ref() + .map(connector::declaration) + .transpose()?, + ); + match result.state { + crate::requirements::State::Satisfied => Ok(()), + crate::requirements::State::Failed => Err(admission::Failure::mismatch()), + crate::requirements::State::Unknown => Err(Error::Incomplete.into()), + } + } + pub(super) fn record_wiring( + &mut self, + context: &Context, + step: &mut api::PhysicalWiring, + expected: u64, + report: &api::PhysicalCompletion, + working: &api::WorkingContext, + now: u64, + ) -> Result<()> { + let owner = report.plan.as_ref().ok_or(Error::Invalid)?.id.as_str(); + self.wiring_access(context, step, working, true)?; + let cable = parse_target(step.cable.as_ref())?; + let key = row_key(cable, &step.end_key)?; + let mut row = self.get(&key)?.ok_or(Error::Missing)?; + if number(&row, "revision")? != expected { + return Err(Error::Conflict); + } + let hold = hold_key(cable, &step.end_key); + if let Some(held) = self.get(&hold)? + && string(&held, "owner")? != owner + { + return Err(Error::Conflict); + } + let previous = if let Some(id) = row.get("binding") { + let binding = self + .get(&format!("relation/{}", id.as_str().ok_or(Error::Corrupt)?))? + .ok_or(Error::Corrupt)?; + let binding = inventory::relation_projection(&binding)?; + if binding.revision != number(&row, "expected")? { + return Err(Error::Conflict); + } + Some(binding) + } else { + None + }; + step.previous = previous + .as_ref() + .map(|b| -> Result { + Ok(api::PhysicalCableAttachment { + port: Some(target_wire(cables::attached_port(b, cable)?)), + binding_id: b.relation_id.clone(), + binding_revision: b.revision, + }) + }) + .transpose()?; + step.previous_intended_port = cables::intended(&row)?.map(target_wire); + self.wiring_access(context, step, working, true)?; + for target in targets(step)? { + self.lock_physical_part(target, owner)?; + } + let destination = match step.after.as_ref().ok_or(Error::Invalid)? { + api::physical_wiring::After::Port(port) => Some(parse_target(Some(port))?), + _ => None, + }; + if let Some(port) = destination + && !report.actual_state + { + self.wiring_mating(context, cable, &row, port) + .map_err(|failure| failure.error)?; + } + if let Some(port) = destination + && let Some(occupied) = self.cable_binding_at(port)? + && previous + .as_ref() + .is_none_or(|p| p.relation_id != occupied.relation_id) + { + return Err(Error::Conflict); + } + // Unknown attachment preserves the last possible binding and its capacity claim. + if !matches!(step.after, Some(api::physical_wiring::After::Unknown(_))) { + if let Some(binding) = &previous { + self.remove_binding_relation_retaining_end( + binding, + &context.actor, + now, + Some(&key), + )?; + } + row.remove("binding"); + row.remove("expected"); + if let Some(port) = destination { + let relation = wire::Relation { + relation_id: RelationId::generate().to_string(), + from: Some(target_wire(cable)), + to: Some(target_wire(port)), + kind: wire::RelationKind::ConnectedTo as i32, + revision: 0, + authority_id: self.workspace_authority()?, + }; + let selection = self.selection(context, working, true)?; + self.check_relationship(context, &selection, &relation)?; + let revision = + self.save_relation_retaining_end(&relation, None, &context.actor, now, &key)?; + row.insert("binding".into(), text(&relation.relation_id)); + row.insert("expected".into(), int(revision)?); + row.insert("state".into(), int(1)?); + } else { + row.insert("state".into(), int(2)?); + } + } else { + row.remove("state"); + } + match api::PhysicalWiringIntent::try_from(step.intent).map_err(|_| Error::Invalid)? { + api::PhysicalWiringIntent::Preserve => {} + api::PhysicalWiringIntent::ClearDestination => { + row.remove("to_asset"); + row.remove("to_incarnation"); + } + api::PhysicalWiringIntent::AdoptDestination => { + let port = destination.ok_or(Error::Invalid)?; + row.insert("to_asset".into(), text(port.asset.to_string())); + row.insert("to_incarnation".into(), text(port.incarnation.to_string())); + } + _ => return Err(Error::Invalid), + } + let revision = expected.checked_add(1).ok_or(Error::Limit)?; + row.insert("revision".into(), int(revision)?); + row.insert("actor".into(), text(&context.actor)); + operations::strip_envelope(&mut row); + self.put(&key, &end_parent(cable), Class::Inventory, row)?; + step.result_revision = Some(revision); + self.audit(&key, &context.actor, "cable-end.reported", revision, now)?; + Ok(()) + } + pub(super) fn wiring_access( + &self, + context: &Context, + step: &api::PhysicalWiring, + working: &api::WorkingContext, + write: bool, + ) -> Result<()> { + let cable = parse_target(step.cable.as_ref())?; + if self.physical_access(context, cable, working, write)? != wire::AssetKind::Cable { + return Err(Error::Invalid); + } + row_key(cable, &step.end_key)?; + let selection = self.selection(context, working, write)?; + for target in targets(step)? { + selection.contains(target, self)?; + let row = self.visible_asset(context, target)?; + let kind = number(&row, "kind")? as i32; + if target != cable && kind != wire::AssetKind::Port as i32 { + return Err(Error::Invalid); + } + if !context.field(target, kind, "relations", false) + || (write && !context.field(target, kind, "relations", true)) + { + return Err(Error::Denied); + } + } + Ok(()) + } + pub(super) fn capture_wiring( + &self, + context: &Context, + step: &mut api::PhysicalWiring, + working: &api::WorkingContext, + ) -> Result<()> { + step.previous = None; + step.previous_intended_port = None; + step.result_revision = None; + self.wiring_access(context, step, working, true)?; + if step.after.is_none() { + return Err(Error::Invalid); + } + let cable = parse_target(step.cable.as_ref())?; + let row = self + .get(&row_key(cable, &step.end_key)?)? + .ok_or(Error::Missing)?; + match step.basis.as_ref().ok_or(Error::Invalid)? { + api::physical_wiring::Basis::ExpectedEndRevision(revision) + if *revision != 0 && *revision == number(&row, "revision")? => {} + api::physical_wiring::Basis::AfterStep(_) => {} + _ => return Err(Error::Conflict), + } + if matches!( + step.basis, + Some(api::physical_wiring::Basis::ExpectedEndRevision(_)) + ) && let Some(id) = row.get("binding") + { + let binding = self + .get(&format!("relation/{}", id.as_str().ok_or(Error::Corrupt)?))? + .ok_or(Error::Corrupt)?; + let binding = inventory::relation_projection(&binding)?; + if binding.revision != number(&row, "expected")? { + return Err(Error::Conflict); + } + step.previous = Some(api::PhysicalCableAttachment { + port: Some(target_wire(cables::attached_port(&binding, cable)?)), + binding_id: binding.relation_id, + binding_revision: binding.revision, + }); + } + if matches!( + step.basis, + Some(api::physical_wiring::Basis::ExpectedEndRevision(_)) + ) { + step.previous_intended_port = cables::intended(&row)?.map(target_wire); + } + self.wiring_access(context, step, working, true)?; + match api::PhysicalWiringIntent::try_from(step.intent).map_err(|_| Error::Invalid)? { + api::PhysicalWiringIntent::Preserve | api::PhysicalWiringIntent::ClearDestination => {} + api::PhysicalWiringIntent::AdoptDestination + if matches!(step.after, Some(api::physical_wiring::After::Port(_))) => {} + _ => return Err(Error::Invalid), + } + Ok(()) + } +} diff --git a/crates/omloom-store/src/disk/workspace/requirements.rs b/crates/omloom-store/src/disk/workspace/requirements.rs index aac26af..91396b9 100644 --- a/crates/omloom-store/src/disk/workspace/requirements.rs +++ b/crates/omloom-store/src/disk/workspace/requirements.rs @@ -1,6 +1,8 @@ //! Current selected facts enter the common evaluator through this restricted query. use super::*; use crate::requirements::{self as core, knowledge as k}; +mod binding; +mod power; impl k::Alternatives for wire::EquipmentAlternatives { fn candidates(&self) -> usize { @@ -123,7 +125,7 @@ fn compare( }; Ok(k::compare(claim(actual), predicate)) } -fn wire_decision(decision: core::Decision) -> (i32, i32) { +pub(super) fn wire_decision(decision: core::Decision) -> (i32, i32) { use crate::requirements::{Reason as C, State}; use api::{RequirementReason as R, RequirementState as S}; let state = match decision.state { @@ -179,6 +181,26 @@ fn group_mode(group: &api::RequirementGroup) -> Result { fn semantic_keys(nodes: &[api::RequirementNode]) -> Result> { let mut digests = [None; core::MAX_NODES]; for (index, node) in nodes.iter().enumerate() { + if let Some(api::requirement_node::Expression::PowerPaths(condition)) = &node.expression { + let (mut sources, mut boundaries) = power::validate(condition)?; + sources.sort_unstable(); + boundaries.sort_unstable(); + let canonical = api::PowerPathRequirement { + sources: sources.into_iter().map(target_wire).collect(), + boundaries: boundaries.into_iter().map(target_wire).collect(), + }; + let mut hash = Sha256::new(); + hash.update(b"power-paths"); + hash.update(canonical.encode_to_vec()); + digests[index] = Some(hash.finalize().into()); + } + if let Some(api::requirement_node::Expression::Binding(condition)) = &node.expression { + binding::validate(condition)?; + let mut hash = Sha256::new(); + hash.update(b"binding"); + hash.update(condition.encode_to_vec()); + digests[index] = Some(hash.finalize().into()); + } if let Some(api::requirement_node::Expression::Field(condition)) = &node.expression { let mut condition = condition.clone(); if let Some(wire::Field { @@ -286,6 +308,53 @@ impl Store { working: &api::WorkingContext, now: u64, ) -> Result { + let tx = self.begin()?; + let evaluation = tx.evaluate_requirements(context, request, working, now)?; + tx.commit()?; + Ok(reply(api::reply::Result::RequirementEvaluation(evaluation))) + } +} + +impl Tx<'_> { + pub(super) fn evaluate_requirements( + &self, + context: &Context, + request: api::EvaluateRequirements, + working: &api::WorkingContext, + now: u64, + ) -> Result { + self.evaluate_requirements_in_phase(context, request, working, now, core::Phase::Current) + } + pub(super) fn evaluate_requirements_in_phase( + &self, + context: &Context, + request: api::EvaluateRequirements, + working: &api::WorkingContext, + now: u64, + phase: core::Phase, + ) -> Result { + self.requirement_evaluation(context, request, working, now, Some(phase)) + } + pub(super) fn validate_requirement_graph( + &self, + context: &Context, + request: api::EvaluateRequirements, + working: &api::WorkingContext, + ) -> Result<()> { + let result = self.requirement_evaluation(context, request, working, 0, None)?; + if result.reason == api::RequirementReason::Cycle as i32 { + return Err(Error::Invalid); + } + Ok(()) + } + fn requirement_evaluation( + &self, + context: &Context, + request: api::EvaluateRequirements, + working: &api::WorkingContext, + now: u64, + phase: Option, + ) -> Result { if request.nodes.is_empty() || request.nodes.len() > core::MAX_NODES || request.root as usize >= request.nodes.len() @@ -308,15 +377,23 @@ impl Store { } total_edges += group.members.len(); } + Some(api::requirement_node::Expression::PowerPaths(condition)) => { + power::validate(condition)?; + } + Some(api::requirement_node::Expression::Binding(condition)) => { + binding::validate(condition)?; + } None => return Err(Error::Invalid), } } let semantic_keys = semantic_keys(&request.nodes)?; let anchor = parse_target(request.anchor.as_ref())?; - let tx = self.begin()?; - tx.selection(context, working, false)? - .contains(anchor, &tx)?; - tx.visible_asset(context, anchor)?; + let tx = self; + if phase.is_some() { + tx.selection(context, working, false)? + .contains(anchor, tx)?; + tx.visible_asset(context, anchor)?; + } // Reserve the finite containers once; fields are selected by their indexed parent. let mut decisions = Vec::with_capacity(request.nodes.len()); let mut responses = Vec::with_capacity(request.nodes.len()); @@ -329,7 +406,9 @@ impl Store { ..Default::default() }; let decision = match node.expression.as_ref().ok_or(Error::Invalid)? { - api::requirement_node::Expression::Field(_) => { + api::requirement_node::Expression::Field(_) + | api::requirement_node::Expression::Binding(_) + | api::requirement_node::Expression::PowerPaths(_) => { core::Decision::unknown(core::Reason::Missing) } api::requirement_node::Expression::Group(group) => { @@ -383,6 +462,16 @@ impl Store { }, ) } + api::requirement_node::Expression::PowerPaths(_) + | api::requirement_node::Expression::Binding(_) => ( + core::Subject { + namespace: "requirement", + resource: &keys[index], + incarnation: "", + network: core::NetworkScope::NotApplicable, + }, + core::Expression::Fact(decisions[index]), + ), }; nodes.push(core::Node { subject, @@ -408,6 +497,7 @@ impl Store { core::evaluate(&nodes, request.root as usize, &mut results).map_err(map_error)?; if shape.reason != core::Reason::Cycle { // Validate all selected group semantics before loading any field evidence. + let mut power_budget = power::Budget::new(); for (index, node) in nodes.iter_mut().enumerate() { if results[index].reason == core::Reason::NotSelected { continue; @@ -426,12 +516,39 @@ impl Store { if let Some(api::requirement_node::Expression::Field(condition)) = request.nodes[index].expression.as_ref() { - node.expression = core::Expression::Fact(tx.requirement_field( - context, - condition, - &mut responses[index], - now, - )?); + let decision = if let Some(phase) = phase { + tx.requirement_field(context, condition, &mut responses[index], now, phase)? + } else { + validate_field_condition(condition)?; + core::Decision::unknown(core::Reason::Missing) + }; + node.expression = core::Expression::Fact(decision); + } + if let Some(api::requirement_node::Expression::PowerPaths(condition)) = + request.nodes[index].expression.as_ref() + { + let decision = if phase.is_some() { + tx.requirement_power_paths( + context, + condition, + &mut power_budget, + &mut responses[index], + )? + } else { + power::validate(condition)?; + core::Decision::unknown(core::Reason::Missing) + }; + node.expression = core::Expression::Fact(decision); + } + if let Some(api::requirement_node::Expression::Binding(condition)) = + &request.nodes[index].expression + { + let decision = if phase.is_some() { + tx.requirement_binding(context, condition, working)? + } else { + core::Decision::unknown(core::Reason::Missing) + }; + node.expression = core::Expression::Fact(decision); } } } @@ -458,11 +575,36 @@ impl Store { reason, results: output, }; - tx.commit()?; - Ok(reply(api::reply::Result::RequirementEvaluation(evaluation))) + Ok(evaluation) } } +fn validate_field_condition(condition: &api::FieldRequirement) -> Result<()> { + parse_target(condition.target.as_ref())?; + if !matches!( + api::RequirementLayer::try_from(condition.layer), + Ok(api::RequirementLayer::Accepted | api::RequirementLayer::Observed) + ) { + return Err(Error::Invalid); + } + let expected = condition.expected.as_ref().ok_or(Error::Invalid)?; + if expected.name.is_empty() + || expected.name.len() > 64 + || !expected + .name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + || expected.encoded_len() > 8192 + { + return Err(Error::Invalid); + } + if let Some(field::Value::Equipment(value)) = &expected.value { + omloom_contracts::equipment::validate_field(&expected.name, value) + .map_err(|_| Error::Invalid)?; + } + compare(expected, expected, condition.comparison)?; + Ok(()) +} impl Tx<'_> { fn requirement_field( &self, @@ -470,25 +612,11 @@ impl Tx<'_> { condition: &api::FieldRequirement, response: &mut api::RequirementResult, now: u64, + phase: core::Phase, ) -> Result { let target = parse_target(condition.target.as_ref())?; let expected = condition.expected.as_ref().ok_or(Error::Invalid)?; - if expected.name.is_empty() - || expected.name.len() > 64 - || !expected - .name - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') - || expected.encoded_len() > 8192 - { - return Err(Error::Invalid); - } - // Validate the desired predicate before any source lookup, including missing facts. - if let Some(field::Value::Equipment(value)) = &expected.value { - omloom_contracts::equipment::validate_field(&expected.name, value) - .map_err(|_| Error::Invalid)?; - } - compare(expected, expected, condition.comparison)?; + validate_field_condition(condition)?; let row = match self.asset(target) { Ok(row) => row, // A stale incarnation must not reveal that a protected asset ID exists. @@ -629,7 +757,11 @@ impl Tx<'_> { subject: subject(condition.target.as_ref().ok_or(Error::Invalid)?), semantic: &expected.name, vantage: "recorded-source-claim", - phase: core::Phase::Current, + phase: if layer == "intent" { + core::Phase::Current + } else { + phase + }, kind: if layer == "intent" { core::EvidenceKind::Configuration } else { @@ -641,7 +773,10 @@ impl Tx<'_> { let validity = core::fact( required, core::Evidence { - requirement: required, + requirement: core::Requirement { + phase: core::Phase::Current, + ..required + }, origin: if layer == "intent" { core::Origin::Accepted } else { diff --git a/crates/omloom-store/src/disk/workspace/requirements/binding.rs b/crates/omloom-store/src/disk/workspace/requirements/binding.rs new file mode 100644 index 0000000..c2730b0 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/requirements/binding.rs @@ -0,0 +1,52 @@ +//! Exact accepted binding predicates share the requirement graph and snapshot. +use super::*; + +pub(super) fn validate(value: &api::BindingRequirement) -> Result<()> { + RelationId::parse(&value.relation_id).map_err(|_| Error::Invalid)?; + if value.revision == 0 + || wire::RelationKind::try_from(value.kind).map_err(|_| Error::Invalid)? + == wire::RelationKind::Unspecified + { + return Err(Error::Invalid); + } + parse_target(value.from.as_ref())?; + parse_target(value.to.as_ref())?; + Ok(()) +} +impl Tx<'_> { + pub(super) fn requirement_binding( + &self, + context: &Context, + value: &api::BindingRequirement, + working: &api::WorkingContext, + ) -> Result { + validate(value)?; + let selection = self.selection(context, working, false)?; + for endpoint in [value.from.as_ref(), value.to.as_ref()] { + let target = parse_target(endpoint)?; + selection.contains(target, self)?; + let row = match self.visible_asset(context, target) { + Ok(row) => row, + Err(Error::Denied | Error::Missing | Error::Conflict) => { + return Ok(core::Decision::unknown(core::Reason::Restricted)); + } + Err(error) => return Err(error), + }; + if !context.field(target, number(&row, "kind")? as i32, "relations", false) { + return Ok(core::Decision::unknown(core::Reason::Restricted)); + } + } + let Some(row) = self.get(&format!("relation/{}", value.relation_id))? else { + return Ok(core::Decision::FAIL); + }; + let actual = inventory::relation_projection(&row)?; + if actual.from != value.from + || actual.to != value.to + || actual.kind != value.kind + || actual.revision != value.revision + { + return Ok(core::Decision::FAIL); + } + Ok(core::Decision::PASS) + } +} diff --git a/crates/omloom-store/src/disk/workspace/requirements/power.rs b/crates/omloom-store/src/disk/workspace/requirements/power.rs new file mode 100644 index 0000000..66c04bd --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/requirements/power.rs @@ -0,0 +1,240 @@ +//! Selected accepted supply paths, read in the evaluator's existing transaction. +use super::*; +use crate::physical::power as graph; + +pub(super) fn validate( + condition: &api::PowerPathRequirement, +) -> Result<(Vec, Vec)> { + if !(2..=graph::MAX_SOURCES).contains(&condition.sources.len()) + || condition.boundaries.len() > graph::MAX_SOURCES + { + return Err(Error::Invalid); + } + let parse = |values: &[wire::AssetRef]| { + values + .iter() + .map(|v| parse_target(Some(v))) + .collect::>>() + }; + let sources = parse(&condition.sources)?; + let boundaries = parse(&condition.boundaries)?; + let unique: BTreeSet<_> = boundaries.iter().copied().collect(); + if unique.len() != boundaries.len() { + return Err(Error::Invalid); + } + Ok((sources, boundaries)) +} + +/// One graph-work allowance for the entire selected requirement request. +pub(super) struct Budget { + nodes: usize, + edges: usize, +} +impl Budget { + pub(super) fn new() -> Self { + Self { + nodes: graph::MAX_NODES, + edges: graph::MAX_EDGES, + } + } +} + +impl Tx<'_> { + pub(super) fn requirement_power_paths( + &self, + context: &Context, + condition: &api::PowerPathRequirement, + budget: &mut Budget, + response: &mut api::RequirementResult, + ) -> Result { + let unknown = core::Decision::unknown; + let (sources, boundaries) = validate(condition)?; + let capacity = budget.nodes; + let mut nodes = Vec::with_capacity(capacity); + let mut indices = BTreeMap::new(); + let mut roles = Vec::with_capacity(sources.len()); + for source in sources { + let index = match indices.get(&source) { + Some(&index) => index, + None => { + if nodes.len() == capacity { + return Ok(unknown(core::Reason::Incomplete)); + } + let index = nodes.len(); + nodes.push(source); + indices.insert(source, index); + index + } + }; + roles.push(index); + } + let mut complete = [false; graph::MAX_NODES]; + let mut edges = Vec::with_capacity(budget.edges); + let mut start = 0; + let mut cut_boundary = false; + while start < nodes.len() { + let end = nodes.len(); + let width = end - start; + if width > budget.nodes { + return Ok(unknown(core::Reason::Incomplete)); + } + budget.nodes -= width; + let keys = nodes[start..end] + .iter() + .map(|&n| text(asset_key(n))) + .collect(); + let rows = self.query( + "UNWIND $keys AS key MATCH (n:OmRow {key:key}) RETURN {asset:n.asset,incarnation:n.incarnation,kind:n.kind,adoption:n.adoption} AS p LIMIT 128", + params([("keys", Value::Array(keys))]), + )?; + if rows.len() != width { + return Ok(unknown(core::Reason::Restricted)); + } + let mut seen = [false; graph::MAX_NODES]; + for row in rows { + let asset = target(&row)?; + let Some(&index) = indices.get(&asset) else { + return Ok(unknown(core::Reason::Restricted)); + }; + if index < start || index >= end { + return Err(Error::Corrupt); + } + if seen[index] { + return Ok(unknown(core::Reason::Restricted)); + } + seen[index] = true; + let kind = i32::try_from(number(&row, "kind")?).map_err(|_| Error::Corrupt)?; + if !context.readable(asset, kind) + || !context.field(asset, kind, "physical", false) + || !context.field(asset, kind, "relations", false) + || number(&row, "adoption")? != wire::Adoption::Adopted as u64 + { + return Ok(unknown(core::Reason::Restricted)); + } + if !matches!( + wire::AssetKind::try_from(kind), + Ok(wire::AssetKind::Device + | wire::AssetKind::PhysicalComponent + | wire::AssetKind::Port + | wire::AssetKind::PowerFeed) + ) { + return Ok(unknown(core::Reason::Unsupported)); + } + } + for present in &seen[start..end] { + if !present { + return Ok(unknown(core::Reason::Restricted)); + } + } + // A terminal boundary may not conceal known upstream equipment. + // Authorize its direct targets, then stop without traversing further. + if cut_boundary { + return Ok(unknown(core::Reason::ScopeMismatch)); + } + let terminals: Vec<_> = boundaries + .iter() + .filter_map(|b| indices.get(b).copied()) + .collect(); + let partial = graph::Graph { + complete: &complete[..nodes.len()], + edges: &edges, + sources: &roles, + boundaries: &terminals, + } + .evaluate() + .map_err(|_| Error::Corrupt)?; + if partial.decision.state == core::State::Failed { + return Ok(power_assessment(partial, &nodes, response)); + } + let keys = nodes[start..end] + .iter() + .map(|&n| text(format!("physical/{}", network::identity(n)))) + .collect(); + for row in self.query( + "UNWIND $keys AS key MATCH (n:OmRow {key:key}) RETURN {asset:n.asset,incarnation:n.incarnation,approved:n.approved} AS p LIMIT 128", + params([("keys", Value::Array(keys))]), + )? { + let asset = target(&row)?; + let &index = indices.get(&asset).ok_or(Error::Corrupt)?; + complete[index] = match row.get("approved") { + None | Some(Value::Null) => false, + Some(Value::Bool(v)) => *v, + _ => return Err(Error::Corrupt), + }; + } + let keys = nodes[start..end] + .iter() + .map(|&n| text(asset_key(n))) + .collect(); + let rows = self.query("UNWIND $keys AS key MATCH (a:OmRow {key:key})-[:OmRelationFrom]->(r:OmRow) WHERE r.parent='relations' AND r.kind=$kind AND r.incarnation=a.incarnation RETURN {asset:r.asset,incarnation:r.incarnation,to_asset:r.to_asset,to_incarnation:r.to_incarnation} AS p LIMIT $limit", params([("keys",Value::Array(keys)),("kind",int(wire::RelationKind::PoweredBy as u64)?),("limit",int((budget.edges+1) as u64)?)]))?; + if rows.len() > budget.edges { + return Ok(unknown(core::Reason::Incomplete)); + } + budget.edges -= rows.len(); + for row in rows { + let read = |a: &str, i: &str| -> Result { + Ok(AssetRef { + asset: string(&row, a)?.parse().map_err(|_| Error::Corrupt)?, + incarnation: string(&row, i)?.parse().map_err(|_| Error::Corrupt)?, + }) + }; + let from = target(&row)?; + let to = read("to_asset", "to_incarnation")?; + let &child = indices.get(&from).ok_or(Error::Corrupt)?; + if boundaries.contains(&from) { + cut_boundary = true; + } + let parent = match indices.get(&to) { + Some(&index) => index, + None => { + if nodes.len() == capacity { + return Ok(unknown(core::Reason::Incomplete)); + } + let index = nodes.len(); + nodes.push(to); + indices.insert(to, index); + index + } + }; + edges.push((child, parent)); + } + start = end; + } + if cut_boundary { + return Ok(unknown(core::Reason::ScopeMismatch)); + } + let terminals: Vec<_> = boundaries + .iter() + .filter_map(|b| indices.get(b).copied()) + .collect(); + if terminals.is_empty() { + return Ok(unknown(core::Reason::ScopeMismatch)); + } + let assessment = graph::Graph { + complete: &complete[..nodes.len()], + edges: &edges, + sources: &roles, + boundaries: &terminals, + } + .evaluate() + .map_err(|_| Error::Corrupt)?; + Ok(power_assessment(assessment, &nodes, response)) + } +} +fn power_assessment( + assessment: graph::Assessment, + nodes: &[AssetRef], + response: &mut api::RequirementResult, +) -> core::Decision { + if let Some(index) = assessment.subject { + let resource = nodes[index]; + response.target = Some(target_wire(resource)); + response.field = Some("physical".into()); + if assessment.decision.reason == core::Reason::Missing { + response + .resolutions + .push(api::RequirementResolution::ReviewSource as i32); + } + } + assessment.decision +} diff --git a/crates/omloom-store/src/disk/workspace/working.rs b/crates/omloom-store/src/disk/workspace/working.rs index b6bd85d..ac94f88 100644 --- a/crates/omloom-store/src/disk/workspace/working.rs +++ b/crates/omloom-store/src/disk/workspace/working.rs @@ -36,7 +36,10 @@ fn writable_fields( &["name", "description", "dns_name"] } else if kind == K::SoftwareInstallation { &["name", "description", "virtualization_role"] - } else if matches!(kind, K::Device | K::Rack | K::Port | K::Cable) { + } else if matches!( + kind, + K::Device | K::Rack | K::Port | K::Cable | K::PhysicalComponent + ) { &["name", "description", "serial"] } else { &["name", "description"] @@ -46,7 +49,7 @@ fn writable_fields( .filter(|name| context.field(target, kind as i32, name, true)) .map(|s| s.to_string()) .collect(); - if kind == K::Device { + if matches!(kind, K::Device | K::PhysicalComponent) { fields.extend( omloom_contracts::equipment::FIELDS .iter() @@ -177,7 +180,12 @@ pub(super) fn attachment( (K::Service, K::SoftwareInstallation) => Some((R::Contains, false)), (K::Service, K::ProviderAccount) => Some((R::Contains, false)), (K::Device, K::Port) => Some((R::HasPort, false)), - (K::Port, K::Interface) => Some((R::Carries, false)), + (K::PhysicalComponent, K::Port) => Some((R::HasPort, false)), + (K::Device | K::Rack | K::Port | K::PhysicalComponent, K::PhysicalPosition) => { + Some((R::Contains, false)) + } + (K::PhysicalPosition, K::PhysicalComponent | K::Device) => Some((R::InstalledIn, true)), + (K::Port, K::Interface) => Some((R::Contains, false)), (K::Device, K::VirtualMachine | K::Service) | (K::VirtualMachine, K::Service) => { Some((R::RunsOn, true)) } @@ -661,6 +669,7 @@ impl Store { K::SoftwareRelease, K::Workload, K::Container, + K::PhysicalComponent, ] { let raw = kind as i32; if let Some(parent) = &parent_item { diff --git a/crates/omloom-store/src/lib.rs b/crates/omloom-store/src/lib.rs index 17e9c1f..23e1004 100644 --- a/crates/omloom-store/src/lib.rs +++ b/crates/omloom-store/src/lib.rs @@ -6,6 +6,8 @@ extern crate alloc; +pub mod physical; +pub mod plans; pub mod requirements; /// Independently bounded data classes. Recovery checkpoints never expire with detail. diff --git a/crates/omloom-store/src/physical.rs b/crates/omloom-store/src/physical.rs new file mode 100644 index 0000000..0d33109 --- /dev/null +++ b/crates/omloom-store/src/physical.rs @@ -0,0 +1,201 @@ +//! Integer physical constraints shared by planning and final admission. +//! Geometry proves only the supplied constraints, never authority or live health. +use crate::requirements::{Decision, Reason}; +pub mod power; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MatingRole { + SideA, + SideB, + SelfMating, +} +#[derive(Clone, Copy, Debug)] +pub struct Mating<'a> { + issuer: &'a str, + key: &'a str, + basis: &'a str, + role: MatingRole, +} +impl<'a> Mating<'a> { + pub fn new( + issuer: &'a str, + key: &'a str, + basis: &'a str, + role: MatingRole, + ) -> Result { + for value in [issuer, key, basis] { + if value.trim().is_empty() || value.chars().any(char::is_control) { + return Err(Invalid::Empty); + } + if value.len() > 160 { + return Err(Invalid::Limit); + } + } + Ok(Self { + issuer, + key, + basis, + role, + }) + } +} +/// Compare complete declarations, not connector labels or inferred model similarity. +pub fn mating(left: Option>, right: Option>) -> Decision { + let (Some(left), Some(right)) = (left, right) else { + return Decision::unknown(Reason::Missing); + }; + if left.issuer != right.issuer || left.key != right.key || left.basis != right.basis { + return Decision::unknown(Reason::Unconfigured); + } + match (left.role, right.role) { + (MatingRole::SideA, MatingRole::SideB) + | (MatingRole::SideB, MatingRole::SideA) + | (MatingRole::SelfMating, MatingRole::SelfMating) => Decision::PASS, + _ => Decision::FAIL, + } +} + +/// Micrometres. Signed coordinates permit an explicit clearance outside a chassis. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Span { + start: i64, + end: i64, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Invalid { + Empty, + Overflow, + Reversed, + Limit, +} +impl Span { + pub fn new(start: i64, length: u64) -> Result { + if length == 0 { + return Err(Invalid::Empty); + } + let length = i64::try_from(length).map_err(|_| Invalid::Overflow)?; + let end = start.checked_add(length).ok_or(Invalid::Overflow)?; + Ok(Self { start, end }) + } + pub fn start(self) -> i64 { + self.start + } + pub fn end(self) -> i64 { + self.end + } + pub fn overlaps(self, other: Self) -> bool { + self.start < other.end && other.start < self.end + } + pub fn contains(self, other: Self) -> bool { + self.start <= other.start && other.end <= self.end + } + pub fn expand(self, before: u64, after: u64) -> Result { + let before = i64::try_from(before).map_err(|_| Invalid::Overflow)?; + let after = i64::try_from(after).map_err(|_| Invalid::Overflow)?; + Ok(Self { + start: self.start.checked_sub(before).ok_or(Invalid::Overflow)?, + end: self.end.checked_add(after).ok_or(Invalid::Overflow)?, + }) + } +} +/// A half-open physical volume in its owning enclosure's coordinate system. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Volume { + pub x: Span, + pub y: Span, + pub z: Span, +} +impl Volume { + pub fn overlaps(self, other: Self) -> bool { + self.x.overlaps(other.x) && self.y.overlaps(other.y) && self.z.overlaps(other.z) + } + pub fn contains(self, other: Self) -> bool { + self.x.contains(other.x) && self.y.contains(other.y) && self.z.contains(other.z) + } +} +/// Material and its declared service space are distinct: service spaces may overlap each other. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Region { + body: Volume, + service: Volume, +} +impl Region { + pub fn new(body: Volume, service: Volume) -> Result { + if !service.contains(body) { + return Err(Invalid::Reversed); + } + Ok(Self { body, service }) + } + pub fn within(self, space: Volume) -> bool { + space.contains(self.service) + } +} +/// A sourced interval, retaining uncertainty instead of averaging it away. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Quantity { + lower: u64, + upper: u64, +} +impl Quantity { + pub fn new(lower: u64, upper: u64) -> Result { + if lower > upper { + return Err(Invalid::Reversed); + } + Ok(Self { lower, upper }) + } + pub fn lower(self) -> u64 { + self.lower + } + pub fn upper(self) -> u64 { + self.upper + } + pub fn checked_sum(self, other: Self) -> Result { + Self::new( + self.lower + .checked_add(other.lower) + .ok_or(Invalid::Overflow)?, + self.upper + .checked_add(other.upper) + .ok_or(Invalid::Overflow)?, + ) + } +} +/// Capacity and demand must already use the same explicit unit and measurement kind. +pub fn capacity(capacity: Option, demand: Option) -> Decision { + let (Some(capacity), Some(demand)) = (capacity, demand) else { + return Decision::unknown(Reason::Missing); + }; + if demand.upper <= capacity.lower { + return Decision::PASS; + } + if demand.lower > capacity.upper { + return Decision::FAIL; + } + Decision::unknown(Reason::Incomplete) +} +/// Compare one candidate with an already bounded set of occupied/reserved volumes. +/// The storage owner supplies authoritative occupants, including hidden ones. +pub fn placement( + enclosure: Option, + candidate: Option, + occupied: &[Region], +) -> Decision { + if occupied.len() > 2048 { + return Decision::unknown(Reason::Unsupported); + } + let (Some(enclosure), Some(candidate)) = (enclosure, candidate) else { + return Decision::unknown(Reason::Missing); + }; + if !candidate.within(enclosure) { + return Decision::FAIL; + } + for volume in occupied { + if candidate.body.overlaps(volume.service) || candidate.service.overlaps(volume.body) { + return Decision::FAIL; + } + } + Decision::PASS +} + +#[cfg(test)] +mod tests; diff --git a/crates/omloom-store/src/physical/power.rs b/crates/omloom-store/src/physical/power.rs new file mode 100644 index 0000000..168b50a --- /dev/null +++ b/crates/omloom-store/src/physical/power.rs @@ -0,0 +1,117 @@ +//! Recorded dependency-path disjointness, not live electrical redundancy. +use super::{Decision, Invalid, Reason}; + +pub const MAX_NODES: usize = 128; +pub const MAX_EDGES: usize = 256; +pub const MAX_SOURCES: usize = 8; + +/// An authorized, bounded graph in one read snapshot. An edge points upstream. +pub struct Graph<'a> { + pub complete: &'a [bool], + pub edges: &'a [(usize, usize)], + pub sources: &'a [usize], + pub boundaries: &'a [usize], +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Assessment { + pub decision: Decision, + pub subject: Option, +} +fn assessment(decision: Decision, subject: Option) -> Assessment { + Assessment { decision, subject } +} + +impl Graph<'_> { + /// Missing paths, cycles and unqualified source lists never establish a pass. + /// The fixed scratch and bounded passes require no allocation or recursion. + pub fn evaluate(&self) -> Result { + let n = self.complete.len(); + if n == 0 || self.sources.len() < 2 { + return Err(Invalid::Empty); + } + if n > MAX_NODES + || self.edges.len() > MAX_EDGES + || self.sources.len() > MAX_SOURCES + || self.boundaries.len() > MAX_SOURCES + { + return Err(Invalid::Limit); + } + if self.sources.iter().chain(self.boundaries).any(|&i| i >= n) + || self.edges.iter().any(|&(a, b)| a >= n || b >= n) + { + return Err(Invalid::Reversed); + } + let mut masks = [0u8; MAX_NODES]; + for (role, &source) in self.sources.iter().enumerate() { + if masks[source] != 0 { + return Ok(assessment(Decision::FAIL, Some(source))); + } + masks[source] = 1 << role; + } + for _ in 0..n { + let mut changed = false; + for &(child, parent) in self.edges { + let mask = masks[parent] | masks[child]; + if mask.count_ones() > 1 { + return Ok(assessment(Decision::FAIL, Some(parent))); + } + if mask != masks[parent] { + masks[parent] = mask; + changed = true; + } + } + if !changed { + break; + } + } + let mut valid = [false; MAX_NODES]; + for i in 0..n { + if masks[i] == 0 { + continue; + } + if !self.complete[i] { + return Ok(assessment(Decision::unknown(Reason::Missing), Some(i))); + } + let boundary = self.boundaries.contains(&i); + let upstream = self.edges.iter().any(|&(child, _)| child == i); + if boundary == upstream { + return Ok(assessment( + Decision::unknown(Reason::ScopeMismatch), + Some(i), + )); + } + valid[i] = boundary; + } + for _ in 0..n { + let mut changed = false; + for i in 0..n { + if masks[i] == 0 || valid[i] { + continue; + } + let mut ready = true; + for &(child, parent) in self.edges { + if child == i && !valid[parent] { + ready = false; + break; + } + } + if ready { + valid[i] = true; + changed = true; + } + } + if !changed { + break; + } + } + for &source in self.sources { + if !valid[source] { + return Ok(assessment(Decision::unknown(Reason::Cycle), Some(source))); + } + } + Ok(assessment(Decision::PASS, None)) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/omloom-store/src/physical/power/tests.rs b/crates/omloom-store/src/physical/power/tests.rs new file mode 100644 index 0000000..4cf3198 --- /dev/null +++ b/crates/omloom-store/src/physical/power/tests.rs @@ -0,0 +1,133 @@ +use super::*; + +// Different inlet identities do not hide a shared upstream UPS or utility feed. +#[test] +fn shared_upstream_is_not_disjoint() -> Result<(), Invalid> { + let graph = Graph { + complete: &[true; 3], + edges: &[(0, 2), (1, 2)], + sources: &[0, 1], + boundaries: &[2], + }; + assert_eq!(graph.evaluate()?.decision, Decision::FAIL); + assert_eq!( + Graph { + boundaries: &[], + ..graph + } + .evaluate()? + .subject, + Some(2) + ); + assert_eq!( + Graph { + complete: &[false; 3], + ..graph + } + .evaluate()? + .decision, + Decision::FAIL + ); + Ok(()) +} + +// A positive scoped result requires explicit complete paths and terminal boundaries. +#[test] +fn absence_and_early_boundary_are_not_independence() -> Result<(), Invalid> { + let graph = Graph { + complete: &[true; 4], + edges: &[(0, 2), (1, 3)], + sources: &[0, 1], + boundaries: &[2, 3], + }; + assert_eq!(graph.evaluate()?.decision, Decision::PASS); + assert_eq!( + Graph { + complete: &[true, false, true, true], + ..graph + } + .evaluate()? + .decision, + Decision::unknown(Reason::Missing) + ); + assert_eq!( + Graph { + boundaries: &[0, 1], + ..graph + } + .evaluate()? + .decision, + Decision::unknown(Reason::ScopeMismatch) + ); + assert_eq!( + Graph { + edges: &[], + ..graph + } + .evaluate()? + .decision, + Decision::unknown(Reason::ScopeMismatch) + ); + Ok(()) +} + +// Cycles terminate within a fixed work bound and cannot qualify a complete path. +#[test] +fn cycle_duplicate_and_invalid_indices_are_bounded() -> Result<(), Invalid> { + let graph = Graph { + complete: &[true; 4], + edges: &[(0, 2), (2, 0), (1, 3)], + sources: &[0, 1], + boundaries: &[3], + }; + assert_eq!(graph.evaluate()?.decision, Decision::unknown(Reason::Cycle)); + assert_eq!( + Graph { + sources: &[0, 0], + ..graph + } + .evaluate()? + .decision, + Decision::FAIL + ); + assert_eq!( + Graph { + edges: &[(0, 4)], + ..graph + } + .evaluate(), + Err(Invalid::Reversed) + ); + Ok(()) +} + +// Neither oversized graph storage nor source fan-out starts the bounded evaluator. +#[test] +fn graph_limits_are_checked_before_work() { + let nodes = [true; MAX_NODES + 1]; + let graph = Graph { + complete: &nodes, + edges: &[], + sources: &[0, 1], + boundaries: &[0, 1], + }; + assert_eq!(graph.evaluate(), Err(Invalid::Limit)); + assert_eq!( + Graph { + complete: &[true; 2], + sources: &[0; MAX_SOURCES + 1], + ..graph + } + .evaluate(), + Err(Invalid::Limit) + ); + assert_eq!( + Graph { + complete: &[true; 2], + edges: &[(0, 1); MAX_EDGES + 1], + ..graph + } + .evaluate(), + Err(Invalid::Limit) + ); +} diff --git a/crates/omloom-store/src/physical/tests.rs b/crates/omloom-store/src/physical/tests.rs new file mode 100644 index 0000000..8497437 --- /dev/null +++ b/crates/omloom-store/src/physical/tests.rs @@ -0,0 +1,149 @@ +use super::*; + +fn volume(x: i64, y: i64, z: i64, width: u64, height: u64, depth: u64) -> Result { + Ok(Volume { + x: Span::new(x, width)?, + y: Span::new(y, height)?, + z: Span::new(z, depth)?, + }) +} +fn body(value: Volume) -> Result { + Region::new(value, value) +} + +// A rear device may share U space only when its real depth does not intersect. +#[test] +fn opposing_faces_and_half_width_use_actual_volumes() -> Result<(), Invalid> { + let rack = volume(0, 0, 0, 500_000, 2_000_000, 1_000_000)?; + let front = volume(0, 0, 0, 250_000, 44_450, 400_000)?; + let rear = volume(0, 0, 600_000, 500_000, 44_450, 400_000)?; + let sibling = volume(250_000, 0, 0, 250_000, 44_450, 400_000)?; + assert_eq!( + placement(Some(rack), Some(body(rear)?), &[body(front)?]), + Decision::PASS + ); + assert_eq!( + placement(Some(rack), Some(body(sibling)?), &[body(front)?]), + Decision::PASS + ); + let full = volume(0, 0, 0, 500_000, 44_450, 1_000_000)?; + assert_eq!( + placement(Some(rack), Some(body(rear)?), &[body(full)?]), + Decision::FAIL + ); + Ok(()) +} + +// Service clearance and zero-U obstruction are volumes, not special exemptions. +#[test] +fn clearance_and_zero_u_obstructions_conflict() -> Result<(), Invalid> { + let rack = volume(-50_000, 0, 0, 600_000, 2_000_000, 1_000_000)?; + let pdu = volume(0, 0, 950_000, 50_000, 1_800_000, 50_000)?; + let device = volume(0, 0, 0, 500_000, 44_450, 900_000)?; + assert_eq!( + placement(Some(rack), Some(body(device)?), &[body(pdu)?]), + Decision::PASS + ); + let service = Volume { + z: device.z.expand(0, 100_000)?, + ..device + }; + assert_eq!( + placement( + Some(rack), + Some(Region::new(device, service)?), + &[body(pdu)?] + ), + Decision::FAIL + ); + Ok(()) +} + +// Two service envelopes may share empty maintenance space; neither may intersect the other material body. +#[test] +fn service_envelopes_are_not_duplicate_material_occupancy() -> Result<(), Invalid> { + let room = volume(0, 0, 0, 100, 100, 100)?; + let first = Region::new(volume(0, 0, 0, 10, 10, 10)?, volume(0, 0, 0, 20, 10, 10)?)?; + let second = Region::new(volume(20, 0, 0, 10, 10, 10)?, volume(10, 0, 0, 20, 10, 10)?)?; + assert_eq!( + placement(Some(room), Some(second), &[first]), + Decision::PASS + ); + assert!(matches!( + Region::new(room, volume(0, 0, 0, 10, 10, 10)?), + Err(Invalid::Reversed) + )); + Ok(()) +} + +// Unknown dimensions cannot become a positive fit or an assumed zero load. +#[test] +fn missing_geometry_and_uncertain_capacity_remain_unknown() -> Result<(), Invalid> { + let rack = volume(0, 0, 0, 500_000, 2_000_000, 1_000_000)?; + assert_eq!( + placement(Some(rack), None, &[]), + Decision::unknown(Reason::Missing) + ); + assert_eq!( + capacity(None, Some(Quantity::new(0, 10)?)), + Decision::unknown(Reason::Missing) + ); + assert_eq!( + capacity( + Some(Quantity::new(100, 120)?), + Some(Quantity::new(90, 110)?) + ), + Decision::unknown(Reason::Incomplete) + ); + assert_eq!( + capacity( + Some(Quantity::new(100, 120)?), + Some(Quantity::new(121, 130)?) + ), + Decision::FAIL + ); + assert_eq!( + capacity( + Some(Quantity::new(100, 120)?), + Some(Quantity::new(90, 100)?) + ), + Decision::PASS + ); + Ok(()) +} + +// Invalid units/ranges and arithmetic overflow are rejected before geometry work. +#[test] +fn coordinate_and_capacity_boundaries_are_checked() -> Result<(), Invalid> { + assert_eq!(Span::new(0, 0), Err(Invalid::Empty)); + assert_eq!(Span::new(i64::MAX, 1), Err(Invalid::Overflow)); + assert_eq!(Quantity::new(2, 1), Err(Invalid::Reversed)); + assert_eq!( + Quantity::new(u64::MAX, u64::MAX)?.checked_sum(Quantity::new(1, 1)?), + Err(Invalid::Overflow) + ); + let first = Span::new(-100, 100)?; + assert!(!first.overlaps(Span::new(0, 100)?)); + assert!(first.overlaps(Span::new(-1, 100)?)); + Ok(()) +} + +// Labels cannot establish fit: only an exact declaration with complementary sides can satisfy it. +#[test] +fn mating_contract_keeps_unknown_and_incompatible_distinct() -> Result<(), Invalid> { + let a = Mating::new("private:test", "pair", "revision-1", MatingRole::SideA)?; + let b = Mating::new("private:test", "pair", "revision-1", MatingRole::SideB)?; + let changed = Mating::new("private:test", "pair", "revision-2", MatingRole::SideB)?; + assert_eq!(mating(Some(a), Some(b)), Decision::PASS); + assert_eq!(mating(Some(a), Some(a)), Decision::FAIL); + assert_eq!(mating(Some(a), None), Decision::unknown(Reason::Missing)); + assert_eq!( + mating(Some(a), Some(changed)), + Decision::unknown(Reason::Unconfigured) + ); + assert!(matches!( + Mating::new("", "pair", "revision-1", MatingRole::SideA), + Err(Invalid::Empty) + )); + Ok(()) +} diff --git a/crates/omloom-store/src/plans.rs b/crates/omloom-store/src/plans.rs new file mode 100644 index 0000000..a13cee2 --- /dev/null +++ b/crates/omloom-store/src/plans.rs @@ -0,0 +1,159 @@ +//! Bounded plan dependencies shared by manual and provider consumers. +//! This module schedules no work and grants no authority to perform an effect. +use crate::requirements::{Decision, State}; + +pub const MAX_STEPS: usize = 64; +pub const MAX_EDGES: usize = 256; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Outcome { + Pending, + Reported, + NotPerformed, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Trigger { + Reported, + Satisfied, + Failed, +} +#[derive(Clone, Copy, Debug)] +pub struct Dependency { + pub step: usize, + pub trigger: Trigger, +} +#[derive(Clone, Copy, Debug)] +pub struct Step<'a> { + pub after: &'a [Dependency], + pub outcome: Outcome, + pub check: Option, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Readiness { + Ready, + Waiting, + Unselected, + Complete, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Invalid { + Limit, + Dependency, + Cycle, +} + +/// Validate every branch, then derive readiness in O(steps + dependencies). +/// Caller-owned fixed bounds avoid allocation and recursion for untrusted graphs. +pub fn assess( + steps: &[Step<'_>], + output: &mut [Readiness], + mut ancestors: Option<&mut [u64]>, +) -> Result<(), Invalid> { + if steps.is_empty() || steps.len() > MAX_STEPS || output.len() < steps.len() { + return Err(Invalid::Limit); + } + if let Some(matrix) = &mut ancestors { + if matrix.len() < steps.len() { + return Err(Invalid::Limit); + } + matrix[..steps.len()].fill(0); + } + let mut indegree = [0u8; MAX_STEPS]; + let mut children = [0u64; MAX_STEPS]; + let mut edges = 0; + for (index, step) in steps.iter().enumerate() { + if step.after.len() > MAX_EDGES - edges { + return Err(Invalid::Limit); + } + edges += step.after.len(); + let mut seen = 0u64; + for dependency in step.after { + if dependency.step >= steps.len() || dependency.step == index { + return Err(Invalid::Dependency); + } + let bit = 1u64 << dependency.step; + if seen & bit != 0 { + return Err(Invalid::Dependency); + } + seen |= bit; + indegree[index] += 1; + children[dependency.step] |= 1u64 << index; + } + } + let mut queue = [0usize; MAX_STEPS]; + let mut tail = 0; + for (index, &count) in indegree[..steps.len()].iter().enumerate() { + if count == 0 { + queue[tail] = index; + tail += 1; + } + } + output[..steps.len()].fill(Readiness::Waiting); + let mut head = 0; + while head < tail { + let index = queue[head]; + head += 1; + let step = &steps[index]; + let mut waiting = false; + let mut excluded = false; + for dependency in step.after { + if let Some(matrix) = &mut ancestors { + let inherited = matrix[dependency.step]; + matrix[index] |= inherited | (1u64 << dependency.step); + } + if output[dependency.step] == Readiness::Unselected { + excluded = true; + continue; + } + let predecessor = &steps[dependency.step]; + let wanted = match (predecessor.outcome, dependency.trigger) { + (Outcome::Pending, _) => None, + (Outcome::Reported, Trigger::Reported) => Some(true), + (Outcome::Reported, Trigger::Satisfied) => { + predecessor.check.and_then(|d| match d.state { + State::Unknown => None, + state => Some(state == State::Satisfied), + }) + } + (Outcome::Reported, Trigger::Failed) => { + predecessor.check.and_then(|d| match d.state { + State::Unknown => None, + state => Some(state == State::Failed), + }) + } + _ => Some(false), + }; + match wanted { + Some(true) => {} + Some(false) => excluded = true, + None => waiting = true, + } + } + output[index] = match step.outcome { + Outcome::Reported => Readiness::Complete, + Outcome::NotPerformed if excluded => Readiness::Unselected, + Outcome::NotPerformed => Readiness::Complete, + _ if excluded => Readiness::Unselected, + _ if waiting => Readiness::Waiting, + _ => Readiness::Ready, + }; + let mut successors = children[index]; + while successors != 0 { + let child = successors.trailing_zeros() as usize; + successors &= successors - 1; + debug_assert!(indegree[child] > 0); + indegree[child] -= 1; + if indegree[child] == 0 { + queue[tail] = child; + tail += 1; + } + } + } + if tail != steps.len() { + return Err(Invalid::Cycle); + } + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/omloom-store/src/plans/tests.rs b/crates/omloom-store/src/plans/tests.rs new file mode 100644 index 0000000..af4b865 --- /dev/null +++ b/crates/omloom-store/src/plans/tests.rs @@ -0,0 +1,154 @@ +use super::*; +use crate::requirements::Reason; + +// Reported work unlocks a check, but cannot impersonate its observed success. +#[test] +fn reports_and_guarded_checks_have_distinct_meanings() -> Result<(), Invalid> { + let after_report = [Dependency { + step: 0, + trigger: Trigger::Reported, + }]; + let positive = [Dependency { + step: 1, + trigger: Trigger::Satisfied, + }]; + let negative = [Dependency { + step: 1, + trigger: Trigger::Failed, + }]; + let mut steps = [ + Step { + after: &[], + outcome: Outcome::Reported, + check: None, + }, + Step { + after: &after_report, + outcome: Outcome::Reported, + check: Some(Decision::unknown(Reason::Missing)), + }, + Step { + after: &positive, + outcome: Outcome::Pending, + check: None, + }, + Step { + after: &negative, + outcome: Outcome::Pending, + check: None, + }, + ]; + let mut result = [Readiness::Waiting; 4]; + assess(&steps, &mut result, None)?; + assert_eq!( + result, + [ + Readiness::Complete, + Readiness::Complete, + Readiness::Waiting, + Readiness::Waiting + ] + ); + steps[1].check = Some(Decision::FAIL); + assess(&steps, &mut result, None)?; + assert_eq!( + result, + [ + Readiness::Complete, + Readiness::Complete, + Readiness::Unselected, + Readiness::Ready + ] + ); + steps[1].check = Some(Decision::PASS); + assess(&steps, &mut result, None)?; + assert_eq!( + result, + [ + Readiness::Complete, + Readiness::Complete, + Readiness::Ready, + Readiness::Unselected + ] + ); + // Declining an excluded branch differs from abandoning selected mandatory work. + steps[3].outcome = Outcome::NotPerformed; + steps[2].outcome = Outcome::NotPerformed; + assess(&steps, &mut result, None)?; + assert_eq!(result[3], Readiness::Unselected); + assert_eq!(result[2], Readiness::Complete); + Ok(()) +} + +// Cycles and duplicate dependency edges cannot hide in an unselected branch. +#[test] +fn malformed_graphs_are_rejected() { + let cyclic = [Dependency { + step: 1, + trigger: Trigger::Reported, + }]; + let back = [Dependency { + step: 0, + trigger: Trigger::Reported, + }]; + let mut out = [Readiness::Waiting; 2]; + assert_eq!( + assess( + &[ + Step { + after: &cyclic, + outcome: Outcome::Pending, + check: None + }, + Step { + after: &back, + outcome: Outcome::Pending, + check: None + } + ], + &mut out, + None + ), + Err(Invalid::Cycle) + ); + let duplicate = [back[0], back[0]]; + assert_eq!( + assess( + &[ + Step { + after: &[], + outcome: Outcome::Pending, + check: None + }, + Step { + after: &duplicate, + outcome: Outcome::Pending, + check: None + } + ], + &mut out, + None + ), + Err(Invalid::Dependency) + ); +} + +// The longest supported chain is iterative and includes the highest bit safely. +#[test] +fn full_length_chain_is_bounded() -> Result<(), Invalid> { + let edges: [Dependency; MAX_STEPS] = core::array::from_fn(|i| Dependency { + step: if i == 0 { 0 } else { i - 1 }, + trigger: Trigger::Reported, + }); + let steps: [Step<'_>; MAX_STEPS] = core::array::from_fn(|i| Step { + after: if i == 0 { &[] } else { &edges[i..i + 1] }, + outcome: Outcome::Reported, + check: None, + }); + let mut out = [Readiness::Waiting; MAX_STEPS]; + let mut ancestors = [0u64; MAX_STEPS]; + assess(&steps, &mut out, Some(&mut ancestors))?; + assert_eq!(ancestors[MAX_STEPS - 1], u64::MAX >> 1); + assert_eq!(out, [Readiness::Complete; MAX_STEPS]); + Ok(()) +} diff --git a/crates/omloom-store/src/requirements.rs b/crates/omloom-store/src/requirements.rs index f4e6050..86137e3 100644 --- a/crates/omloom-store/src/requirements.rs +++ b/crates/omloom-store/src/requirements.rs @@ -69,6 +69,7 @@ pub struct Subject<'a> { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Phase { Current, + PostEffect, Predicted, ConcurrentIntent, } diff --git a/crates/omloom-store/src/requirements/tests.rs b/crates/omloom-store/src/requirements/tests.rs index 2d9a837..8bdc8b2 100644 --- a/crates/omloom-store/src/requirements/tests.rs +++ b/crates/omloom-store/src/requirements/tests.rs @@ -9,6 +9,58 @@ fn subject(id: &'static str) -> Subject<'static> { network: NetworkScope::Bound("lan"), } } + +// A previous healthy sample cannot certify a newly performed change merely by arriving late. +#[test] +fn post_effect_requirement_rejects_current_phase_evidence() { + let wanted = Requirement { + subject: subject("endpoint"), + semantic: "reachable", + vantage: "customer", + phase: Phase::PostEffect, + kind: EvidenceKind::Observation, + interval: None, + }; + let evidence = Evidence { + requirement: Requirement { + phase: Phase::Current, + ..wanted + }, + origin: Origin::DirectObservation, + observed_at: Some(10), + valid_until: Some(100), + coverage: None, + qualified: true, + decision: Decision::PASS, + }; + assert_eq!( + fact(wanted, evidence, 20), + Decision::unknown(Reason::WrongEvidence) + ); +} +// Fresh, matching post-effect evidence must remain usable after rejecting older phases. +#[test] +fn post_effect_requirement_accepts_matching_post_effect_evidence() { + let wanted = Requirement { + subject: subject("endpoint"), + semantic: "reachable", + vantage: "customer", + phase: Phase::PostEffect, + kind: EvidenceKind::Observation, + interval: None, + }; + let evidence = Evidence { + requirement: wanted, + origin: Origin::DirectObservation, + observed_at: Some(10), + valid_until: Some(100), + coverage: None, + qualified: true, + decision: Decision::PASS, + }; + assert_eq!(fact(wanted, evidence, 20), Decision::PASS); +} + fn leaf(id: &'static str, decision: Decision) -> Node<'static> { Node { subject: subject(id), diff --git a/crates/omloom-store/tests/workspace.rs b/crates/omloom-store/tests/workspace.rs index a844a82..7c1547b 100644 --- a/crates/omloom-store/tests/workspace.rs +++ b/crates/omloom-store/tests/workspace.rs @@ -24,6 +24,8 @@ mod groups; mod host_import; #[path = "workspace/host_network.rs"] mod host_network; +#[path = "workspace/physical.rs"] +mod physical; #[path = "workspace/projections.rs"] mod projections; #[path = "workspace/requirements.rs"] diff --git a/crates/omloom-store/tests/workspace/physical.rs b/crates/omloom-store/tests/workspace/physical.rs new file mode 100644 index 0000000..311f91c --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical.rs @@ -0,0 +1,2225 @@ +use super::*; +use api::{reply::Result as Reply, request::Command}; +use std::collections::BTreeSet; +#[path = "physical/admission.rs"] +mod admission; +#[path = "physical/availability.rs"] +mod availability; +#[path = "physical/cables.rs"] +mod cables; +#[path = "physical/clearance.rs"] +mod clearance; +#[path = "physical/endpoint_mappings.rs"] +mod endpoint_mappings; +#[path = "physical/materials.rs"] +mod materials; +#[path = "physical/mounting.rs"] +mod mounting; +#[path = "physical/occupancy.rs"] +mod occupancy; +#[path = "physical/power.rs"] +mod power; +#[path = "physical/rack_grid.rs"] +mod rack_grid; +#[path = "physical/reviews.rs"] +mod reviews; + +fn volume() -> api::PhysicalVolume { + let extent = |length| { + Some(api::PhysicalExtent { + start_um: 0, + length_um: length, + }) + }; + api::PhysicalVolume { + x: extent(500_000), + y: extent(2_000_000), + z: extent(1_000_000), + } +} +fn define(target: wire::AssetRef) -> api::PhysicalDefinition { + api::PhysicalDefinition { + target: Some(target), + volume: Some(volume()), + occupancy_coverage: Some(Box::new(volume())), + basis: "Measured mounting envelope and recorded fixture occupants".into(), + ..Default::default() + } +} +fn equipment_quantity(name: &str, value: i64, unit: &str, scale: u32) -> wire::Field { + wire::Field { + name: name.into(), + value: Some(wire::field::Value::Equipment(wire::EquipmentClaim { + basis: "Measured equipment specification".into(), + assertion: Some(wire::equipment_claim::Assertion::Quantity( + wire::QuantityRange { + lower: Some(value), + upper: Some(value), + unit: unit.into(), + scale, + }, + )), + })), + } +} +fn equipment_dimensions( + store: &Store, + actor: &Context, + target: &wire::AssetRef, +) -> Result<(), Error> { + let asset = store.asset(AssetRef::try_from(target).map_err(|_| Error::Invalid)?)?; + call( + store, + actor, + Command::Write(api::Write { + target: Some(target.clone()), + expected: Some(asset.revision), + kind: asset.kind, + changes: vec![ + equipment_quantity("width", 500_000, "mm", 3), + equipment_quantity("height", 2_000_000, "mm", 3), + equipment_quantity("depth", 1_000_000, "mm", 3), + ], + ..Default::default() + }), + )?; + Ok(()) +} +fn physical( + store: &Store, + actor: &Context, + action: api::physical::Action, +) -> Result { + match call( + store, + actor, + Command::Physical(api::Physical { + action: Some(action), + }), + )? { + Reply::Physical(value) => Ok(value), + _ => Err(Error::Corrupt), + } +} + +// Current facts survive reopen; repeated input and read-only inspection do not grow state. +#[test] +fn physical_definition_is_current_bounded_and_durable() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Measured rack")?; + let definition = define(rack.clone()); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition.clone()), + }), + )?; + let before = store.usage()?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition.clone()), + }), + )?; + let state = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: false, + after: None, + }), + )?; + assert_eq!(state.definitions, vec![definition.clone()]); + assert_eq!(store.usage()?, before); + drop(store); + let store = Store::open(dir.path(), policy())?; + let state = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: false, + after: None, + }), + )?; + assert_eq!(state.definitions, vec![definition]); + Ok(()) +} + +// Removing an unlinked resource must reclaim its current physical facts, not leave an unreachable row. +#[test] +fn resource_removal_reclaims_its_current_physical_profile() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let item = create(&store, &actor, K::Device, "Unlinked chassis")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(item.clone())), + }), + )?; + let before = store.usage()?; + call( + &store, + &actor, + Command::Bulk(api::Bulk { + targets: vec![item], + expected: before.revision, + remove: true, + ..Default::default() + }), + )?; + assert_eq!( + store.usage()?.rows[omloom_store::Class::Inventory as usize], + before.rows[omloom_store::Class::Inventory as usize] - 3, + "Asset, name and physical profile are one removal lifecycle" + ); + Ok(()) +} + +// Pending reservations cannot become unreconcilable through ordinary asset removal. +#[test] +fn pending_physical_reservation_protects_both_spare_and_enclosure_removal() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Reserved rack")?; + let item = create(&store, &actor, K::Device, "Reserved spare")?; + equipment_dimensions(&store, &actor, &item)?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let plan = draft(&store, &actor, item.clone(), rack.clone())?; + reserve(&store, &actor, plan)?; + for target in [item, rack] { + let before = store.usage()?; + assert!(matches!( + call( + &store, + &actor, + Command::Bulk(api::Bulk { + targets: vec![target], + expected: before.revision, + remove: true, + ..Default::default() + }) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + } + Ok(()) +} + +// A physical-field denial protects both predicate inputs and mutation, independently of names. +#[test] +fn physical_field_denial_precedes_decoding_and_writes() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Protected rack")?; + let mut reader = actor.clone(); + reader.grants[0].read = Fields::Selected(vec!["name".into()]); + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &reader, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: false, + after: None, + }) + ), + Err(Error::Denied) + )); + assert!(matches!( + physical( + &store, + &reader, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack)) + }) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Physical planning consumes canonical equipment facts rather than requiring a duplicate dimension record. +#[test] +fn canonical_equipment_dimensions_are_the_only_placement_size_authority() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + equipment_dimensions(&store, &actor, &item)?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let plan = draft(&store, &actor, item, rack)?; + reserve(&store, &actor, plan)?; + Ok(()) +} + +// Interior usable space cannot replace the part's canonical external size during admission. +#[test] +fn equipment_internal_space_does_not_shrink_its_required_external_envelope() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Chassis")?; + equipment_dimensions(&store, &actor, &item)?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let mut interior = define(item.clone()); + let small = api::PhysicalVolume { + x: Some(api::PhysicalExtent { + start_um: 0, + length_um: 1, + }), + y: Some(api::PhysicalExtent { + start_um: 0, + length_um: 1, + }), + z: Some(api::PhysicalExtent { + start_um: 0, + length_um: 1, + }), + }; + interior.volume = Some(small); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(interior), + }), + )?; + let plan = api::PhysicalPlan { + name: "Undersized reservation".into(), + steps: vec![api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: Some(item), + enclosure: Some(rack), + volume: Some(small), + material: None, + }, + )), + ..Default::default() + }], + ..Default::default() + }; + let saved = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or("saved plan")?; + let before = store.usage()?; + assert!(matches!( + reserve(&store, &actor, saved), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// A selected-resource inspection neither expands installed children nor bypasses equipment-field grants. +#[test] +fn physical_fact_projection_stays_shallow_and_filters_equipment_before_decoding() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + equipment_dimensions(&store, &actor, &item)?; + let plan = draft(&store, &actor, item.clone(), rack.clone())?; + report_steps(&store, &actor, &plan, vec![0])?; + let before = store.usage()?; + let facts = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: false, + after: None, + }), + )?; + assert!(facts.occupancy.is_empty()); + assert!(facts.items.is_empty()); + let occupants = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: true, + after: None, + }), + )?; + assert_eq!(occupants.occupancy.len(), 1); + let all = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(item.clone()), + occupants: false, + after: None, + }), + )?; + assert_eq!( + all.items[0] + .fields + .iter() + .filter(|field| matches!(field.name.as_str(), "width" | "height" | "depth")) + .count(), + 3 + ); + let mut restricted = actor.clone(); + restricted.grants[0].read = + Fields::Selected(vec!["physical".into(), "name".into(), "height".into()]); + let redacted = physical( + &store, + &restricted, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }), + )?; + assert_eq!(redacted.occupancy.len(), 1); + assert!(redacted.occupancy[0].binding.is_none()); + assert_eq!( + redacted.occupancy[0].revision, 0, + "A withheld binding must not expose its revision" + ); + assert!(occupants.occupancy[0].revision > 0); + let selected = physical( + &store, + &restricted, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(item), + occupants: false, + after: None, + }), + )?; + let names = selected.items[0] + .fields + .iter() + .map(|field| field.name.as_str()) + .collect::>(); + assert_eq!(names, BTreeSet::from(["height", "name"])); + assert_eq!( + store.usage()?, + before, + "Facts and projections must not write access metadata" + ); + Ok(()) +} + +// Draft authoring requires the actual write grant; a preview alone grants no saved plan. +#[test] +fn read_only_actor_cannot_save_physical_draft() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + let mut reader = actor.clone(); + reader.grants[0].writable = false; + let plan = api::PhysicalPlan { + name: "Proposed placement".into(), + steps: vec![api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: Some(item), + enclosure: Some(rack), + volume: Some(volume()), + material: None, + }, + )), + ..Default::default() + }], + ..Default::default() + }; + let before = store.usage()?; + physical( + &store, + &reader, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert!(matches!( + physical( + &store, + &reader, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +fn draft( + store: &Store, + actor: &Context, + item: wire::AssetRef, + rack: wire::AssetRef, +) -> Result { + physical( + store, + actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(api::PhysicalPlan { + name: "Install server".into(), + steps: vec![api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: Some(item), + enclosure: Some(rack), + volume: Some(volume()), + material: None, + }, + )), + ..Default::default() + }], + ..Default::default() + }), + }), + )? + .plan + .ok_or(Error::Corrupt) +} +fn reserve( + store: &Store, + actor: &Context, + plan: api::PhysicalPlan, +) -> Result { + physical( + store, + actor, + api::physical::Action::Reserve(api::PhysicalReserve { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + until_ms: 1000, + }), + ) +} + +// A serialized spare cannot be reserved in two distinct enclosures, even with free geometry. +#[test] +fn serialized_item_reservation_is_exclusive_across_enclosures() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::Rack, "Rack A")?; + let b = create(&store, &actor, K::Rack, "Rack B")?; + for target in [&a, &b] { + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(target.clone())), + }), + )?; + } + let item = create(&store, &actor, K::Device, "One serialized spare")?; + equipment_dimensions(&store, &actor, &item)?; + let first = draft(&store, &actor, item.clone(), a)?; + let second = draft(&store, &actor, item, b)?; + reserve(&store, &actor, first)?; + let before = store.usage()?; + assert!(matches!( + reserve(&store, &actor, second), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Manual completion couples occupancy and the existing containment relation in one transaction. +#[test] +fn completion_preserves_actual_occupancy_and_reuses_inventory_containment() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + equipment_dimensions(&store, &actor, &item)?; + let plan = draft(&store, &actor, item.clone(), rack.clone())?; + let reserved = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let done = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: reserved.id.ok_or(Error::Corrupt)?, + revision: reserved.revision, + }), + completed_steps: vec![0], + discrepancy: None, + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!(done.state, api::PhysicalPlanState::Completed as i32); + let before = store.usage()?; + let view = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: true, + after: None, + }), + )?; + assert_eq!(view.occupancy.len(), 1); + assert_eq!( + view.occupancy[0] + .placement + .as_ref() + .and_then(|p| p.item.as_ref()), + Some(&item) + ); + assert_eq!(store.usage()?, before); + let relationships = store.relations(AssetRef::try_from(&item)?, 16)?; + assert!( + relationships + .iter() + .any(|r| r.kind == wire::RelationKind::InstalledIn as i32 + && r.to.as_ref() == Some(&rack)) + ); + drop(store); + let store = Store::open(dir.path(), policy())?; + let view = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }), + )?; + assert_eq!(view.occupancy.len(), 1); + // Expiring terminal plan details cannot free the authoritative installed footprint. + assert!(store.cleanup(5000, None)?.removed > 0); + let view = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: view.occupancy[0] + .placement + .as_ref() + .and_then(|p| p.enclosure.clone()), + occupants: true, + after: None, + }), + )?; + assert_eq!(view.occupancy.len(), 1); + Ok(()) +} + +// Saved-plan discovery is owner-scoped and does not hydrate resources or write metadata. +#[test] +fn saved_plan_list_is_personal_bounded_and_read_only() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Equipment")?; + let plan = draft(&store, &actor, item, rack)?; + let before = store.usage()?; + let list = store.workspace( + &actor, + api::Request { + command: Some(Command::Physical(api::Physical { + action: Some(api::physical::Action::List(api::PhysicalPlanList { + limit: 1, + after: None, + })), + })), + working: None, + }, + 100, + )?; + let Some(api::reply::Result::Physical(list)) = list.result else { + return Err(Error::Corrupt.into()); + }; + assert_eq!(list.plans.len(), 1); + assert_eq!(Some(&list.plans[0].id), plan.id.as_ref()); + assert!(list.plan.is_none()); + assert!(list.occupancy.is_empty()); + assert!(matches!( + store.workspace( + &actor, + api::Request { + command: Some(Command::Physical(api::Physical { + action: Some(api::physical::Action::Read(api::PhysicalRead::default())), + })), + working: None, + }, + 100 + ), + Err(Error::Invalid) + )); + let mut other = actor.clone(); + other.actor = "another-operator".into(); + assert!( + physical( + &store, + &other, + api::physical::Action::List(api::PhysicalPlanList::default()) + )? + .plans + .is_empty() + ); + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::List(api::PhysicalPlanList { + limit: 26, + after: None + }) + ), + Err(Error::Limit) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Geometry cannot admit a placement whose sourced aggregate mass exceeds its enclosure limit. +#[test] +fn reservation_enforces_declared_mass_power_and_cooling_limits() -> TestResult { + for axis in 0..3 { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Capacity-limited rack")?; + let item = create(&store, &actor, K::Device, "Demanding equipment")?; + equipment_dimensions(&store, &actor, &item)?; + let mut enclosure = define(rack.clone()); + let mut equipment = api::PhysicalDefinition { + target: Some(item.clone()), + basis: "Declared design demand".into(), + ..Default::default() + }; + match axis { + 0 => { + enclosure.max_mass_g = Some(1000); + let asset = store.asset(AssetRef::try_from(&item)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(item.clone()), + kind: asset.kind, + expected: Some(asset.revision), + changes: vec![equipment_quantity("weight", 2000, "g", 0)], + ..Default::default() + }), + )?; + } + 1 => { + enclosure.capacity_mw = Some(1000); + equipment.demand_mw = Some(2000); + } + _ => { + enclosure.cooling_capacity_mw = Some(1000); + equipment.cooling_mw = Some(2000); + } + } + for definition in [enclosure, equipment] { + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition), + }), + )?; + } + let plan = draft(&store, &actor, item, rack)?; + let before = store.usage()?; + assert!( + matches!(reserve(&store, &actor, plan), Err(Error::Conflict)), + "Capacity axis {axis} must reject admission" + ); + assert_eq!(store.usage()?, before); + } + Ok(()) +} + +// Structural keys identify positions only inside their actual owner's incarnation, not occupants. +#[test] +fn structural_positions_are_scoped_current_and_not_populated_implicitly() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let first = create(&store, &actor, K::Device, "First chassis")?; + let second = create(&store, &actor, K::Device, "Second chassis")?; + let edit = api::PhysicalPositionEdit { + owner: Some(first.clone()), + structural_key: "psu/1".into(), + name: "PSU bay 1".into(), + component_kind: api::PhysicalComponentKind::PowerSupply as i32, + expected: None, + }; + let created = physical( + &store, + &actor, + api::physical::Action::Position(edit.clone()), + )?; + let position = &created.positions[0]; + let before = store.usage()?; + let mut again = edit.clone(); + again.expected = Some(position.revision); + assert_eq!( + physical(&store, &actor, api::physical::Action::Position(again))?.positions, + created.positions + ); + assert_eq!(store.usage()?, before); + let other = physical( + &store, + &actor, + api::physical::Action::Position(api::PhysicalPositionEdit { + owner: Some(second), + ..edit + }), + )?; + assert_ne!(other.positions[0].target, position.target); + let list = physical( + &store, + &actor, + api::physical::Action::Positions(api::PhysicalPositionQuery { + owner: Some(first), + after: None, + limit: 25, + }), + )?; + assert_eq!(list.positions, created.positions); + assert_eq!( + list.items + .iter() + .filter(|i| i.target == position.target) + .count(), + 1 + ); + let read = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: position.target.clone(), + occupants: true, + after: None, + }), + )?; + assert!(read.occupancy.is_empty()); + assert!(matches!( + create(&store, &actor, K::PhysicalPosition, "Unscoped invented bay"), + Err(Error::Invalid) + )); + Ok(()) +} + +// The descriptor owns a position's accepted class; a physical profile cannot introduce another value. +#[test] +fn structural_position_class_has_one_owner() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Chassis")?; + let position = position_fixture( + &store, + &actor, + owner, + "optic/1", + api::PhysicalComponentKind::Transceiver, + )?; + let mut profile = define(position); + profile.component_kind = api::PhysicalComponentKind::Disk as i32; + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(profile) + }) + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Occupant changes own their binding revision, not a duplicate descriptor update for the same slot. +#[test] +fn installation_does_not_rewrite_an_unchanged_position_descriptor() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Chassis")?; + let position = position_fixture( + &store, + &actor, + owner.clone(), + "disk/1", + api::PhysicalComponentKind::Disk, + )?; + let item = create(&store, &actor, K::PhysicalComponent, "Disk")?; + let query = api::PhysicalPositionQuery { + owner: Some(owner), + limit: 25, + ..Default::default() + }; + let before = physical( + &store, + &actor, + api::physical::Action::Positions(query.clone()), + )? + .positions; + install_component(&store, &actor, position, item)?; + let after = physical(&store, &actor, api::physical::Action::Positions(query))?.positions; + assert_eq!( + after, before, + "The InstalledIn binding is the occupant-change authority" + ); + Ok(()) +} + +// Preview must inspect the same constraints as admission without saving a draft or taking locks. +#[test] +fn physical_preview_checks_fit_and_unknowns_without_writes() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Preview rack")?; + let item = create(&store, &actor, K::Device, "Preview equipment")?; + equipment_dimensions(&store, &actor, &item)?; + let mut plan = draft(&store, &actor, item, rack.clone())?; + plan.id = None; + plan.revision = 0; + let before = store.usage()?; + let unknown = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(unknown.state, api::RequirementState::Unknown as i32); + assert_eq!(store.usage()?, before); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack)), + }), + )?; + let before = store.usage()?; + let mut reader = actor.clone(); + for grant in &mut reader.grants { + grant.write = Fields::Selected(Vec::new()); + } + let fits = physical( + &store, + &reader, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(fits.state, api::RequirementState::Satisfied as i32); + assert_eq!(fits.reason, api::RequirementReason::Match as i32); + assert_eq!(store.usage()?, before); + for grant in &mut reader.grants { + grant.read = Fields::Selected(vec![ + "name".into(), + "physical".into(), + "physical_availability".into(), + "relations".into(), + ]); + } + let restricted = physical( + &store, + &reader, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!( + restricted.state, + api::RequirementState::Unknown as i32, + "Fit cannot be used to infer denied equipment dimensions" + ); + assert_eq!(restricted.reason, api::RequirementReason::Restricted as i32); + assert_eq!(store.usage()?, before); + if let Some(api::physical_step::Action::Placement(p)) = plan.steps[0].action.as_mut() { + p.volume + .as_mut() + .ok_or(Error::Corrupt)? + .y + .as_mut() + .ok_or(Error::Corrupt)? + .start_um = 2_000_000; + } + let outside = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { plan: Some(plan) }), + )?; + assert_eq!(outside.state, api::RequirementState::Failed as i32); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// A stale predecessor is missing current proof, not evidence that hardware cannot fit. +#[test] +fn physical_preview_distinguishes_stale_binding_from_incompatibility() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Chassis")?; + let position = position_fixture( + &store, + &actor, + owner, + "slot/1", + api::PhysicalComponentKind::Disk, + )?; + let item = create(&store, &actor, K::PhysicalComponent, "Disk")?; + install_component(&store, &actor, position.clone(), item.clone())?; + let previous = binding(&store, &item)?; + let plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position), + previous: Some(item), + previous_binding: Some(previous.relation_id), + previous_revision: Some(previous.revision + 1), + ..Default::default() + }], + )?; + let before = store.usage()?; + let view = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { plan: Some(plan) }), + )?; + assert_eq!(view.state, api::RequirementState::Unknown as i32); + assert_eq!(view.reason, api::RequirementReason::Stale as i32); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// A rack is not one slot: a replacement must include its reviewed region and share one binding. +#[test] +fn whole_chassis_replacement_requires_explicit_rack_placement() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let old = create(&store, &actor, K::Device, "Old switch")?; + let new = create(&store, &actor, K::Device, "New switch")?; + for target in [&rack, &old, &new] { + let mut definition = define(target.clone()); + definition.mounting = Some("Qualified rack attachment".into()); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition), + }), + )?; + } + equipment_dimensions(&store, &actor, &old)?; + equipment_dimensions(&store, &actor, &new)?; + let initial = draft(&store, &actor, old.clone(), rack.clone())?; + report_steps(&store, &actor, &initial, vec![0])?; + let previous = binding(&store, &old)?; + let mut plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(rack.clone()), + previous: Some(old.clone()), + replacement: Some(new.clone()), + previous_binding: Some(previous.relation_id), + previous_revision: Some(previous.revision), + ..Default::default() + }], + )?; + let before = store.usage()?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Unknown as i32); + assert!(matches!( + reserve(&store, &actor, plan.clone()), + Err(Error::Incomplete) + )); + assert_eq!(store.usage()?, before); + let Some(api::physical_step::Action::Replacement(replacement)) = &mut plan.steps[0].action + else { + return Err(Error::Corrupt.into()); + }; + replacement.volume = Some(volume()); + let saved = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + let admitted = reserve(&store, &actor, saved)?.plan.ok_or(Error::Corrupt)?; + let third = create(&store, &actor, K::Device, "Another chassis proposal")?; + equipment_dimensions(&store, &actor, &third)?; + let mut third_facts = define(third.clone()); + third_facts.mounting = Some("Qualified rack attachment".into()); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(third_facts), + }), + )?; + let competing = draft(&store, &actor, third, rack.clone())?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(competing), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Failed as i32); + let completed = report_steps(&store, &actor, &admitted, vec![0])?; + assert_eq!(completed.state, api::PhysicalPlanState::Completed as i32); + let view = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }), + )?; + assert_eq!(view.occupancy.len(), 1); + assert_eq!( + view.occupancy[0] + .placement + .as_ref() + .and_then(|p| p.item.as_ref()), + Some(&new) + ); + assert_eq!( + view.occupancy[0] + .placement + .as_ref() + .ok_or(Error::Corrupt)? + .volume, + Some(volume()) + ); + assert_eq!( + store.usage()?.rows[omloom_store::Class::Pending as usize], + before.rows[omloom_store::Class::Pending as usize] + ); + assert!( + store + .relations(AssetRef::try_from(&old).map_err(|_| Error::Invalid)?, 32)? + .iter() + .all(|r| r.kind != wire::RelationKind::InstalledIn as i32) + ); + Ok(()) +} + +// Resolving every work item does not imply that the selected goal was achieved. +#[test] +fn abandoning_required_work_does_not_complete_the_goal() -> TestResult { + // Resolved reservations are not proof that every selected installation happened. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::Device, "Installed machine")?; + let b = create(&store, &actor, K::Device, "Abandoned machine")?; + let rack_a = create(&store, &actor, K::Rack, "Rack A")?; + let rack_b = create(&store, &actor, K::Rack, "Rack B")?; + let mut plan = draft(&store, &actor, a.clone(), rack_a)?; + plan.steps.push(api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: Some(b.clone()), + enclosure: Some(rack_b), + volume: Some(volume()), + material: None, + }, + )), + ..Default::default() + }); + let saved = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + let current = report_steps(&store, &actor, &saved, vec![0])?; + let resolved = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: current.id.ok_or(Error::Corrupt)?, + revision: current.revision, + }), + not_performed_steps: vec![1], + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!(resolved.state, api::PhysicalPlanState::Cancelled as i32); + binding(&store, &a)?; + assert!( + store + .relations(AssetRef::try_from(&b).map_err(|_| Error::Invalid)?, 32)? + .iter() + .all(|r| r.kind != wire::RelationKind::InstalledIn as i32) + ); + Ok(()) +} + +// Guided work cannot skip a checkpoint; a fresh accepted fact unlocks only its selected branch. +#[test] +fn plan_steps_recheck_shared_requirements_before_advancing() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack_a = create(&store, &actor, K::Rack, "Rack A")?; + let rack_b = create(&store, &actor, K::Rack, "Rack B")?; + let a = create(&store, &actor, K::Device, "First machine")?; + let b = create(&store, &actor, K::Device, "Second machine")?; + for target in [&rack_a, &rack_b] { + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(target.clone())), + }), + )?; + } + for target in [&a, &b] { + equipment_dimensions(&store, &actor, target)?; + } + let mut plan = draft(&store, &actor, a.clone(), rack_a)?; + plan.steps[0].control = Some(api::PlanControl { + after: vec![], + check: Some(api::EvaluateRequirements { + anchor: Some(a.clone()), + root: 0, + nodes: vec![api::RequirementNode { + expression: Some(api::requirement_node::Expression::Field( + api::FieldRequirement { + target: Some(a.clone()), + expected: Some(text_field("description", "Ready for next step")), + comparison: api::RequirementComparison::Equal as i32, + layer: api::RequirementLayer::Accepted as i32, + }, + )), + }], + }), + }); + plan.steps.push(api::PhysicalStep { + control: Some(api::PlanControl { + after: vec![api::PlanDependency { + step: 0, + when: api::PlanTrigger::Satisfied as i32, + }], + check: None, + }), + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: Some(b), + enclosure: Some(rack_b), + volume: Some(volume()), + material: None, + }, + )), + ..Default::default() + }); + let saved = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + let mut current = reserve(&store, &actor, saved)?.plan.ok_or(Error::Corrupt)?; + let complete = |plan: &api::PhysicalPlan, indexes: Vec| { + physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: indexes, + ..Default::default() + }), + ) + }; + let before = store.usage()?; + assert!(matches!( + complete(¤t, vec![1]), + Err(Error::UnsafeTransition) + )); + assert_eq!(store.usage()?, before); + current = complete(¤t, vec![0])?.plan.ok_or(Error::Corrupt)?; + assert_eq!(current.state, api::PhysicalPlanState::Partial as i32); + assert_eq!( + current.steps[0].checked_state, + Some(api::RequirementState::Unknown as i32) + ); + assert_eq!( + current.steps[1].readiness, + api::PlanReadiness::Waiting as i32 + ); + let resource = store.asset(AssetRef::try_from(&a).map_err(|_| Error::Invalid)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(a), + kind: K::Device as i32, + expected: Some(resource.revision), + changes: vec![text_field("description", "Ready for next step")], + ..Default::default() + }), + )?; + let before = store.usage()?; + current = complete(¤t, vec![])?.plan.ok_or(Error::Corrupt)?; + assert_eq!( + current.steps[0].checked_state, + Some(api::RequirementState::Satisfied as i32) + ); + assert_eq!(current.steps[1].readiness, api::PlanReadiness::Ready as i32); + assert_eq!( + store.usage()?, + before, + "Rechecking unchanged progress creates no history or new operation" + ); + current = complete(¤t, vec![1])?.plan.ok_or(Error::Corrupt)?; + assert_eq!(current.state, api::PhysicalPlanState::Completed as i32); + drop(store); + let reopened = Store::open(dir.path(), policy())?; + let loaded = physical( + &reopened, + &actor, + api::physical::Action::Load(api::PhysicalPlanKey { + id: current.id.ok_or(Error::Corrupt)?, + revision: 0, + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!( + loaded.steps[1] + .control + .as_ref() + .ok_or(Error::Corrupt)? + .after[0] + .step, + 0 + ); + Ok(()) +} + +// Fresh current observations still lack causal qualification for the newly reported effect. +#[test] +fn postcondition_does_not_relabel_an_old_observation_as_verification() -> TestResult { + use omloom_contracts::ids::AuthorityId; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Machine")?; + equipment_dimensions(&store, &actor, &item)?; + let mut resource = store.asset(AssetRef::try_from(&item).map_err(|_| Error::Invalid)?)?; + let revision = resource.revision; + resource.observed = vec![observation( + &AuthorityId::generate().to_string(), + 50, + vec![text_field("description", "Previously healthy")], + )]; + store.refresh_asset( + &admit(wire::record::Body::Asset(resource)), + revision, + store.usage()?.revision, + "fixture", + 60, + )?; + let mut plan = draft(&store, &actor, item.clone(), rack)?; + let request = api::EvaluateRequirements { + anchor: Some(item.clone()), + nodes: vec![api::RequirementNode { + expression: Some(api::requirement_node::Expression::Field( + api::FieldRequirement { + target: Some(item), + expected: Some(text_field("description", "Previously healthy")), + comparison: api::RequirementComparison::Equal as i32, + layer: api::RequirementLayer::Observed as i32, + }, + )), + }], + root: 0, + }; + let current = call( + &store, + &actor, + Command::EvaluateRequirements(request.clone()), + )?; + let Reply::RequirementEvaluation(current) = current else { + return Err("requirement result".into()); + }; + assert_eq!(current.state, api::RequirementState::Satisfied as i32); + plan.steps[0].control = Some(api::PlanControl { + after: vec![], + check: Some(request), + }); + let saved = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + let reported = report_steps(&store, &actor, &saved, vec![0])?; + assert_eq!(reported.state, api::PhysicalPlanState::Partial as i32); + assert_eq!( + reported.steps[0].checked_state, + Some(api::RequirementState::Unknown as i32) + ); + assert_eq!( + reported.steps[0].checked_reason, + Some(api::RequirementReason::WrongEvidence as i32) + ); + Ok(()) +} + +// Losing a check's field right blocks verification, not reporting actual physical work. +#[test] +fn unavailable_postcondition_does_not_discard_reported_work() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Installed machine")?; + let mut plan = draft(&store, &actor, item.clone(), rack)?; + plan.steps[0].control = Some(api::PlanControl { + after: vec![], + check: Some(api::EvaluateRequirements { + anchor: Some(item.clone()), + root: 0, + nodes: vec![api::RequirementNode { + expression: Some(api::requirement_node::Expression::Field( + api::FieldRequirement { + target: Some(item.clone()), + expected: Some(text_field("description", "Ready")), + comparison: api::RequirementComparison::Equal as i32, + layer: api::RequirementLayer::Accepted as i32, + }, + )), + }], + }), + }); + let saved = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + let mut narrowed = actor; + narrowed.grants[0].read = Fields::Selected(vec![ + "name".into(), + "physical".into(), + "relations".into(), + "physical_availability".into(), + ]); + let usage = store.usage()?; + physical( + &store, + &narrowed, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(saved.clone()), + }), + )?; + assert_eq!(store.usage()?, usage); + let recorded = report_steps(&store, &narrowed, &saved, vec![0])?; + assert_eq!( + recorded.steps[0].state, + api::PhysicalStepState::Recorded as i32 + ); + assert_eq!( + recorded.steps[0].checked_state, + Some(api::RequirementState::Unknown as i32) + ); + assert_eq!( + recorded.steps[0].checked_reason, + Some(api::RequirementReason::Restricted as i32) + ); + assert_eq!(recorded.state, api::PhysicalPlanState::Partial as i32); + binding(&store, &item)?; + Ok(()) +} + +// A draft cannot reserve an attachment the canonical inventory relation rules would reject later. +#[test] +fn physical_draft_reuses_inventory_endpoint_compatibility() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + for (from, to) in [ + (K::Service, K::Rack), + (K::Device, K::Interface), + (K::Port, K::Rack), + ] { + let item = create(&store, &actor, from, "Invalid placed resource")?; + let enclosure = create(&store, &actor, to, "Invalid enclosure")?; + let before = store.usage()?; + assert!(matches!( + draft(&store, &actor, item, enclosure), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + } + Ok(()) +} + +// A hardware realization is a peer binding, not the owner of OS interface/configuration intent. +#[test] +fn physical_carriage_does_not_create_logical_endpoint_ownership() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let port = create(&store, &actor, K::Port, "Current physical connector")?; + let interface = create(&store, &actor, K::Interface, "Surviving logical interface")?; + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(port.clone()), + to: Some(interface), + kind: wire::RelationKind::Carries as i32, + ..Default::default() + }), + remove: false, + }), + )?; + let Reply::Page(page) = call( + &store, + &actor, + Command::Query(api::Query { + view: api::View::Network as i32, + focus: Some(port), + level_only: true, + columns: vec!["name".into()], + limit: 25, + ..Default::default() + }), + )? + else { + return Err("expected page".into()); + }; + assert_eq!( + page.total, 0, + "Removing a hardware realization must not remove or scope-own logical configuration" + ); + Ok(()) +} + +// Dedicated management/console endpoints can explicitly belong to a surviving physical port. +#[test] +fn explicit_port_containment_owns_a_dedicated_logical_endpoint() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let port = create(&store, &actor, K::Port, "Dedicated console port")?; + let interface = create(&store, &actor, K::Interface, "Port-bound console channel")?; + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(port.clone()), + to: Some(interface.clone()), + kind: wire::RelationKind::Contains as i32, + ..Default::default() + }), + remove: false, + }), + )?; + let Reply::Page(page) = call( + &store, + &actor, + Command::Query(api::Query { + view: api::View::Network as i32, + focus: Some(port), + level_only: true, + columns: vec!["name".into()], + limit: 25, + ..Default::default() + }), + )? + else { + return Err("expected page".into()); + }; + assert_eq!( + page.items + .iter() + .map(|item| item.target.clone()) + .collect::>(), + vec![Some(interface)] + ); + Ok(()) +} + +fn replacement_plan( + store: &Store, + actor: &Context, + replacements: Vec, +) -> Result { + physical( + store, + actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(api::PhysicalPlan { + name: "Reviewed physical transition".into(), + steps: replacements + .into_iter() + .map(|r| api::PhysicalStep { + action: Some(api::physical_step::Action::Replacement(r)), + ..Default::default() + }) + .collect(), + ..Default::default() + }), + }), + )? + .plan + .ok_or(Error::Corrupt) +} +fn report_steps( + store: &Store, + actor: &Context, + plan: &api::PhysicalPlan, + indexes: Vec, +) -> Result { + physical( + store, + actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: indexes, + actual_state: true, + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt) +} +fn position_fixture( + store: &Store, + actor: &Context, + owner: wire::AssetRef, + key: &str, + kind: api::PhysicalComponentKind, +) -> Result { + physical( + store, + actor, + api::physical::Action::Position(api::PhysicalPositionEdit { + owner: Some(owner), + structural_key: key.into(), + name: key.into(), + component_kind: kind as i32, + expected: None, + }), + )? + .positions + .into_iter() + .next() + .and_then(|p| p.target) + .ok_or(Error::Corrupt) +} +fn binding(store: &Store, item: &wire::AssetRef) -> Result { + store + .relations(AssetRef::try_from(item).map_err(|_| Error::Invalid)?, 32)? + .into_iter() + .find(|r| r.kind == wire::RelationKind::InstalledIn as i32) + .ok_or(Error::Corrupt) +} +fn mountable_component_fixture( + store: &Store, + actor: &Context, + owner: wire::AssetRef, + key: &str, +) -> Result<(wire::AssetRef, wire::AssetRef), Error> { + let position = position_fixture(store, actor, owner, key, api::PhysicalComponentKind::Disk)?; + let mut slot = define(position.clone()); + slot.mounting = Some("drive-carrier-v1".into()); + physical( + store, + actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(slot), + }), + )?; + let item = create(store, actor, K::PhysicalComponent, key)?; + equipment_dimensions(store, actor, &item)?; + physical( + store, + actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + target: Some(item.clone()), + basis: "Measured drive carrier".into(), + mounting: Some("drive-carrier-v1".into()), + component_kind: api::PhysicalComponentKind::Disk as i32, + ..Default::default() + }), + }), + )?; + install_component(store, actor, position.clone(), item.clone())?; + Ok((position, item)) +} + +// Structural identities can only originate from the position owner, including through low-level writes. +#[test] +fn low_level_asset_writes_cannot_create_or_retype_physical_positions() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let mut value = asset(); + value.kind = K::PhysicalPosition as i32; + let before = store.usage()?; + assert!(matches!( + store.save_asset( + &admit(wire::record::Body::Asset(value.clone())), + None, + "fixture", + 100 + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + value.kind = K::PhysicalComponent as i32; + store.save_asset( + &admit(wire::record::Body::Asset(value.clone())), + None, + "fixture", + 100, + )?; + value.kind = K::PhysicalPosition as i32; + value.revision = 2; + let before = store.usage()?; + let record = admit(wire::record::Body::Asset(value)); + let projection = physical( + &store, + &operator(), + api::physical::Action::List(api::PhysicalPlanList::default()), + )? + .revision; + assert!(matches!( + store.save_asset(&record, Some(1), "fixture", 100), + Err(Error::Invalid) + )); + assert!(matches!( + store.refresh_asset(&record, 1, projection, "fixture", 100), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +fn install_component( + store: &Store, + actor: &Context, + position: wire::AssetRef, + item: wire::AssetRef, +) -> Result { + let plan = replacement_plan( + store, + actor, + vec![api::PhysicalReplacement { + position: Some(position), + replacement: Some(item), + ..Default::default() + }], + )?; + report_steps(store, actor, &plan, vec![0]) +} + +// One typed transition preserves position identity across optics, disks, PSUs, NICs and line cards. +#[test] +fn component_replacement_preserves_position_and_does_not_merge_part_identity() -> TestResult { + for kind in [ + api::PhysicalComponentKind::Transceiver, + api::PhysicalComponentKind::Disk, + api::PhysicalComponentKind::PowerSupply, + api::PhysicalComponentKind::NetworkAdapter, + api::PhysicalComponentKind::LineCard, + ] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Owning chassis")?; + let position = position_fixture(&store, &actor, owner, "slot/1", kind)?; + let old = create( + &store, + &actor, + K::PhysicalComponent, + "Old independently identified part", + )?; + let new = create( + &store, + &actor, + K::PhysicalComponent, + "New independently identified part", + )?; + install_component(&store, &actor, position.clone(), old.clone())?; + let previous = binding(&store, &old)?; + let plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(old.clone()), + replacement: Some(new.clone()), + previous_binding: Some(previous.relation_id), + previous_revision: Some(previous.revision), + ..Default::default() + }], + )?; + let done = report_steps(&store, &actor, &plan, vec![0])?; + assert_eq!(done.state, api::PhysicalPlanState::Completed as i32); + assert_eq!(binding(&store, &new)?.to, Some(position)); + assert!(matches!(binding(&store, &old), Err(Error::Corrupt))); + assert_eq!( + store.asset(AssetRef::try_from(&old)?)?.target, + Some(old.clone()), + "Removal does not dispose of the old adopted spare" + ); + assert_ne!(old, new); + } + Ok(()) +} + +// An atomic swap detaches both old bindings before realizing either new one; partial swaps conflict. +#[test] +fn two_known_parts_swap_positions_without_changing_their_identities() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Modular chassis")?; + let a = position_fixture( + &store, + &actor, + owner.clone(), + "optic/1", + api::PhysicalComponentKind::Transceiver, + )?; + let b = position_fixture( + &store, + &actor, + owner, + "optic/2", + api::PhysicalComponentKind::Transceiver, + )?; + let first = create(&store, &actor, K::PhysicalComponent, "First optic")?; + let second = create(&store, &actor, K::PhysicalComponent, "Second optic")?; + install_component(&store, &actor, a.clone(), first.clone())?; + install_component(&store, &actor, b.clone(), second.clone())?; + let mut changes = Vec::new(); + for (item, source, destination) in [(&first, &a, &b), (&second, &b, &a)] { + let old = binding(&store, item)?; + changes.push(api::PhysicalReplacement { + position: Some(destination.clone()), + source_position: Some(source.clone()), + previous: Some(item.clone()), + replacement: Some(item.clone()), + previous_binding: Some(old.relation_id), + previous_revision: Some(old.revision), + volume: None, + endpoints: Vec::new(), + feedback: None, + }); + } + let plan = replacement_plan(&store, &actor, changes)?; + let before = store.usage()?; + assert!(matches!( + report_steps(&store, &actor, &plan, vec![0]), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + report_steps(&store, &actor, &plan, vec![0, 1])?; + assert_eq!(binding(&store, &first)?.to, Some(b)); + assert_eq!(binding(&store, &second)?.to, Some(a)); + Ok(()) +} + +// Removing the owning binding also removes its pose; no parallel occupancy can survive it. +#[test] +fn generic_relation_removal_removes_current_placement_and_pose() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + let plan = draft(&store, &actor, item.clone(), rack.clone())?; + report_steps(&store, &actor, &plan, vec![0])?; + let before = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: true, + after: None, + }), + )?; + assert!( + before.occupancy[0] + .placement + .as_ref() + .ok_or("placement")? + .volume + .is_some() + ); + let current = binding(&store, &item)?; + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(current), + remove: true, + }), + )?; + let after = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }), + )?; + assert!(after.occupancy.is_empty()); + assert!(matches!(binding(&store, &item), Err(Error::Corrupt))); + Ok(()) +} + +// A changed binding preserves component identity but cannot carry coordinates from its old enclosure. +#[test] +fn generic_relation_rebinding_invalidates_old_coordinates() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Original rack")?; + let other = create(&store, &actor, K::Rack, "Destination rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + let plan = draft(&store, &actor, item.clone(), rack.clone())?; + report_steps(&store, &actor, &plan, vec![0])?; + let mut current = binding(&store, &item)?; + let id = current.relation_id.clone(); + current.to = Some(other.clone()); + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(current), + remove: false, + }), + )?; + let old = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }), + )?; + assert!(old.occupancy.is_empty()); + let destination = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(other.clone()), + occupants: true, + after: None, + }), + )?; + assert_eq!(destination.occupancy.len(), 1); + let placement = destination.occupancy[0] + .placement + .as_ref() + .ok_or("placement")?; + assert_eq!(placement.item, Some(item.clone())); + assert!( + placement.volume.is_none(), + "Unknown coordinates do not imply an empty enclosure" + ); + assert_eq!(binding(&store, &item)?.relation_id, id); + assert_eq!(binding(&store, &item)?.to, Some(other)); + Ok(()) +} + +// Reservations read their immutable plan step; resolving one frees only that future region. +#[test] +fn reservation_region_tracks_the_owning_step_until_explicit_resolution() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Shared rack")?; + let first = create(&store, &actor, K::Device, "First server")?; + let second = create(&store, &actor, K::Device, "Second server")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + equipment_dimensions(&store, &actor, &first)?; + equipment_dimensions(&store, &actor, &second)?; + let first = draft(&store, &actor, first, rack.clone())?; + let second = draft(&store, &actor, second, rack.clone())?; + let first = reserve(&store, &actor, first)? + .plan + .ok_or("first reserved plan")?; + let before = store.usage()?; + assert!(matches!( + reserve(&store, &actor, second.clone()), + Err(Error::Conflict) + )); + assert_eq!( + store.usage()?, + before, + "A rejected overlap cannot leave another hold" + ); + physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: first.id.ok_or("plan id")?, + revision: first.revision, + }), + not_performed_steps: vec![0], + ..Default::default() + }), + )?; + let second = reserve(&store, &actor, second)? + .plan + .ok_or("second reserved plan")?; + report_steps(&store, &actor, &second, vec![0])?; + let current = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }), + )?; + assert_eq!(current.occupancy.len(), 1); + Ok(()) +} + +// Renaming a structural position must not erase an independently reported empty state. +#[test] +fn position_rename_preserves_reported_empty_state() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Chassis")?; + let position = position_fixture( + &store, + &actor, + owner.clone(), + "disk/1", + api::PhysicalComponentKind::Disk, + )?; + let old = create(&store, &actor, K::PhysicalComponent, "Removed drive")?; + let incoming = create(&store, &actor, K::PhysicalComponent, "Spare drive")?; + install_component(&store, &actor, position.clone(), old.clone())?; + let previous = binding(&store, &old)?; + let removal = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(old), + previous_binding: Some(previous.relation_id), + previous_revision: Some(previous.revision), + ..Default::default() + }], + )?; + report_steps(&store, &actor, &removal, vec![0])?; + let positions = physical( + &store, + &actor, + api::physical::Action::Positions(api::PhysicalPositionQuery { + owner: Some(owner.clone()), + limit: 1, + ..Default::default() + }), + )?; + physical( + &store, + &actor, + api::physical::Action::Position(api::PhysicalPositionEdit { + owner: Some(owner), + structural_key: "disk/1".into(), + name: "Boot drive bay".into(), + component_kind: api::PhysicalComponentKind::Disk as i32, + expected: Some(positions.positions[0].revision), + }), + )?; + equipment_dimensions(&store, &actor, &incoming)?; + for target in [&position, &incoming] { + let mut definition = define(target.clone()); + if target == &incoming { + definition.component_kind = api::PhysicalComponentKind::Disk as i32; + } + definition.mounting = Some("Declared disk bay standard".into()); + if target == &incoming { + definition.volume = None; + } + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition), + }), + )?; + } + let installation = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position), + replacement: Some(incoming), + ..Default::default() + }], + )?; + reserve(&store, &actor, installation)?; + Ok(()) +} + +// A slot's structural identity cannot migrate to a different chassis through an ordinary edge edit. +#[test] +fn generic_relation_cannot_reparent_a_structural_position() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Original chassis")?; + let other = create(&store, &actor, K::Device, "Other chassis")?; + let position = position_fixture( + &store, + &actor, + owner, + "slot/1", + api::PhysicalComponentKind::LineCard, + )?; + let mut parent = store + .relations(AssetRef::try_from(&position)?, 32)? + .into_iter() + .find(|r| r.kind == wire::RelationKind::Contains as i32) + .ok_or("parent binding")?; + parent.from = Some(other); + let before = store.usage()?; + assert!(matches!( + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(parent), + remove: false + }) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Re-saving an unchanged accepted draft must not create a new revision or audit record. +#[test] +fn unchanged_saved_draft_is_a_noop() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + let item = create(&store, &actor, K::Device, "Server")?; + let plan = draft(&store, &actor, item, rack)?; + let before = store.usage()?; + let unchanged = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(plan.clone()), + }), + )?; + assert!( + !unchanged.items.is_empty(), + "A no-op save retains the same permitted subject labels as a changed save" + ); + let same = unchanged.plan.ok_or(Error::Corrupt)?; + assert_eq!(same, plan); + assert_eq!(store.usage()?, before); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/admission.rs b/crates/omloom-store/tests/workspace/physical/admission.rs new file mode 100644 index 0000000..49243e7 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/admission.rs @@ -0,0 +1,1021 @@ +use super::*; + +// One predecessor cannot fund two distinct replacement steps or acquire their reservations. +#[test] +fn replacement_drafts_reject_duplicate_predecessor_bindings() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Chassis")?; + let (source, old) = mountable_component_fixture(&store, &actor, owner.clone(), "first")?; + let destination = position_fixture( + &store, + &actor, + owner, + "second", + api::PhysicalComponentKind::Disk, + )?; + let previous = binding(&store, &old)?; + let mut steps = Vec::new(); + for position in [&source, &destination] { + let incoming = create( + &store, + &actor, + K::PhysicalComponent, + "Distinct incoming part", + )?; + steps.push(api::PhysicalStep { + action: Some(api::physical_step::Action::Replacement( + api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(old.clone()), + replacement: Some(incoming), + source_position: Some(source.clone()), + previous_binding: Some(previous.relation_id.clone()), + previous_revision: Some(previous.revision), + ..Default::default() + }, + )), + ..Default::default() + }); + } + let plan = api::PhysicalPlan { + name: "Duplicate predecessor".into(), + steps, + ..Default::default() + }; + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(plan.clone()) + }) + ), + Err(Error::Invalid) + )); + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { plan: Some(plan) }) + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + assert_eq!(binding(&store, &old)?, previous); + Ok(()) +} + +// Generic relation and asset APIs cannot strand a structural position descriptor. +#[test] +fn structural_positions_reject_generic_retirement() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Chassis")?; + for archived in [true, false] { + let position = position_fixture( + &store, + &actor, + owner.clone(), + if archived { "retired" } else { "retyped" }, + api::PhysicalComponentKind::Disk, + )?; + let contains = store + .relations(AssetRef::try_from(&owner)?, 32)? + .into_iter() + .find(|r| { + r.to.as_ref() == Some(&position) && r.kind == wire::RelationKind::Contains as i32 + }) + .ok_or(Error::Corrupt)?; + assert!(matches!( + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(contains), + remove: true, + }), + ), + Err(Error::Conflict) + )); + let mut asset = store.asset(AssetRef::try_from(&position)?)?; + let revision = asset.revision; + asset.revision += 1; + if archived { + asset.adoption = wire::Adoption::Archived as i32; + } else { + asset.kind = K::Device as i32; + } + assert!(matches!( + store.save_asset( + &admit(wire::record::Body::Asset(asset)), + Some(revision), + "fixture", + 100, + ), + Err(Error::Conflict) + )); + } + let current = position_fixture( + &store, + &actor, + owner.clone(), + "current", + api::PhysicalComponentKind::Disk, + )?; + let before = store.usage()?; + let page = physical( + &store, + &actor, + api::physical::Action::Positions(api::PhysicalPositionQuery { + owner: Some(owner), + limit: 25, + ..Default::default() + }), + )?; + assert_eq!(page.positions.len(), 3); + assert!( + page.positions + .iter() + .any(|p| p.target.as_ref() == Some(¤t)) + ); + assert!(page.next.is_none()); + assert_eq!(store.usage()?, before); + Ok(()) +} + +fn rack_replacement_fixture( + store: &Store, + actor: &Context, +) -> Result { + let rack = create(store, actor, K::Rack, "Replacement enclosure")?; + let old = create(store, actor, K::Device, "Previous chassis")?; + let incoming = create(store, actor, K::Device, "Incoming chassis")?; + for item in [&old, &incoming] { + equipment_dimensions(store, actor, item)?; + let mut profile = define(item.clone()); + profile.mounting = Some("rack-19".into()); + profile.demand_mw = Some(20); + profile.cooling_mw = Some(20); + physical( + store, + actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(profile), + }), + )?; + } + let mut enclosure = define(rack.clone()); + enclosure.mounting = Some("rack-19".into()); + enclosure.capacity_mw = Some(20); + enclosure.cooling_capacity_mw = Some(20); + physical( + store, + actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(enclosure), + }), + )?; + let installed = draft(store, actor, old.clone(), rack.clone())?; + report_steps(store, actor, &installed, vec![0])?; + let previous = binding(store, &old)?; + Ok(api::PhysicalReplacement { + position: Some(rack), + previous: Some(old), + replacement: Some(incoming), + previous_binding: Some(previous.relation_id), + previous_revision: Some(previous.revision), + volume: Some(volume()), + ..Default::default() + }) +} + +// Replacement allocations pass the same requested-coordinate checks as ordinary placements. +#[test] +fn replacement_preview_and_reservation_reject_outside_coordinates() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let base = rack_replacement_fixture(&store, &actor)?; + for axis in 0..3 { + for displacement in [-1, 1] { + let mut replacement = base.clone(); + let pose = replacement.volume.as_mut().ok_or(Error::Corrupt)?; + let extent = match axis { + 0 => &mut pose.x, + 1 => &mut pose.y, + _ => &mut pose.z, + }; + extent.as_mut().ok_or(Error::Corrupt)?.start_um = displacement; + let plan = replacement_plan(&store, &actor, vec![replacement])?; + let before = store.usage()?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Failed as i32); + assert!(matches!( + reserve(&store, &actor, plan), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + } + } + Ok(()) +} + +// Reservation is not proof that current enclosure budgets still admit the incoming body. +#[test] +fn rack_replacement_completion_rechecks_current_capacity() -> TestResult { + assert_rack_replacement_readmission("power")?; + assert_rack_replacement_readmission("cooling") +} + +// A once-valid proposed pose must not become accepted occupancy after the enclosure shrinks. +#[test] +fn rack_replacement_completion_rechecks_current_envelope() -> TestResult { + assert_rack_replacement_readmission("envelope") +} + +fn assert_rack_replacement_readmission(change: &str) -> TestResult { + for actual_state in [false, true] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let replacement = rack_replacement_fixture(&store, &actor)?; + let old = replacement.previous.clone().ok_or(Error::Corrupt)?; + let previous = binding(&store, &old)?; + let plan = replacement_plan(&store, &actor, vec![replacement.clone()])?; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let mut enclosure = define(replacement.position.clone().ok_or(Error::Corrupt)?); + enclosure.mounting = Some("rack-19".into()); + enclosure.capacity_mw = (change == "power").then_some(10); + enclosure.cooling_capacity_mw = (change == "cooling").then_some(10); + if change == "envelope" { + enclosure + .volume + .as_mut() + .ok_or(Error::Corrupt)? + .x + .as_mut() + .ok_or(Error::Corrupt)? + .length_um = 100_000; + } + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(enclosure), + }), + )?; + let mut completion = api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: vec![0], + ..Default::default() + }; + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(completion.clone()) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert_eq!(binding(&store, &old)?, previous); + completion.actual_state = actual_state; + if actual_state { + completion.discrepancy = Some( + "Performed replacement does not meet the revised enclosure constraints".into(), + ); + } else { + let mut restored = define(replacement.position.clone().ok_or(Error::Corrupt)?); + restored.mounting = Some("rack-19".into()); + restored.capacity_mw = Some(20); + restored.cooling_capacity_mw = Some(20); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(restored), + }), + )?; + } + physical(&store, &actor, api::physical::Action::Complete(completion))?; + assert_eq!( + binding( + &store, + replacement.replacement.as_ref().ok_or(Error::Corrupt)? + )? + .to, + replacement.position + ); + } + Ok(()) +} + +// Changed mounting or usable space cannot be bypassed by completing a reserved slot replacement. +#[test] +fn component_completion_rechecks_current_mounting_and_envelope() -> TestResult { + for change in ["mounting", "envelope"] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Enclosure")?; + let (position, item) = mountable_component_fixture(&store, &actor, owner, "drive")?; + let old = binding(&store, &item)?; + let plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(item.clone()), + replacement: Some(item.clone()), + previous_binding: Some(old.relation_id.clone()), + previous_revision: Some(old.revision), + ..Default::default() + }], + )?; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let mut slot = define(position.clone()); + slot.mounting = Some( + if change == "mounting" { + "different-carrier" + } else { + "drive-carrier-v1" + } + .into(), + ); + if change == "envelope" { + slot.volume + .as_mut() + .ok_or(Error::Corrupt)? + .x + .as_mut() + .ok_or(Error::Corrupt)? + .length_um = 100_000; + } + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(slot), + }), + )?; + let mut completion = api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: vec![0], + ..Default::default() + }; + let before = store.usage()?; + assert!( + matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(completion.clone()) + ), + Err(Error::Conflict) + ), + "{change}" + ); + assert_eq!(store.usage()?, before); + assert_eq!(binding(&store, &item)?, old); + completion.actual_state = true; + completion.discrepancy = + Some("Recorded work does not meet the revised slot specification".into()); + physical(&store, &actor, api::physical::Action::Complete(completion))?; + assert_eq!(binding(&store, &item)?.to, Some(position)); + } + Ok(()) +} + +// Corrected slot facts do not authorize an incompatible admitted reinstall or erase actual work. +#[test] +fn changed_slot_class_is_rechecked_before_admitted_component_binding() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Enclosure")?; + let (position, item) = mountable_component_fixture(&store, &actor, owner.clone(), "disk-bay")?; + let old = binding(&store, &item)?; + let plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(item.clone()), + replacement: Some(item.clone()), + previous_binding: Some(old.relation_id.clone()), + previous_revision: Some(old.revision), + ..Default::default() + }], + )?; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let descriptor = physical( + &store, + &actor, + api::physical::Action::Positions(api::PhysicalPositionQuery { + owner: Some(owner.clone()), + ..Default::default() + }), + )? + .positions + .into_iter() + .find(|p| p.target.as_ref() == Some(&position)) + .ok_or(Error::Corrupt)?; + physical( + &store, + &actor, + api::physical::Action::Position(api::PhysicalPositionEdit { + owner: Some(owner), + structural_key: descriptor.structural_key, + name: "Power supply bay".into(), + component_kind: api::PhysicalComponentKind::PowerSupply as i32, + expected: Some(descriptor.revision), + }), + )?; + let mut report = api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: vec![0], + ..Default::default() + }; + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(report.clone()) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert_eq!(binding(&store, &item)?, old); + report.actual_state = true; + report.discrepancy = + Some("Recorded work needs reconciliation with the corrected slot class".into()); + physical(&store, &actor, api::physical::Action::Complete(report))?; + assert_eq!(binding(&store, &item)?.to, Some(position)); + Ok(()) +} + +// Performed work remains recordable without consuming a different plan's future slot hold. +#[test] +fn actual_component_report_preserves_a_conflicting_future_reservation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Enclosure")?; + let (position, item) = mountable_component_fixture(&store, &actor, owner, "disk-bay")?; + let old = binding(&store, &item)?; + let removal = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(item.clone()), + previous_binding: Some(old.relation_id), + previous_revision: Some(old.revision), + ..Default::default() + }], + )?; + report_steps(&store, &actor, &removal, vec![0])?; + let future = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + replacement: Some(item), + ..Default::default() + }], + )?; + let future = reserve(&store, &actor, future)? + .plan + .ok_or(Error::Corrupt)?; + let found = create( + &store, + &actor, + K::PhysicalComponent, + "Already installed component", + )?; + let actual = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + replacement: Some(found.clone()), + ..Default::default() + }], + )?; + physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: actual.id.ok_or(Error::Corrupt)?, + revision: actual.revision, + }), + completed_steps: vec![0], + actual_state: true, + discrepancy: Some("Performed work discovered after the reservation".into()), + ..Default::default() + }), + )?; + assert_eq!(binding(&store, &found)?.to, Some(position.clone())); + let held = store + .diagnostic_database() + .execute_cypher_shared( + "MATCH (n:OmRow {key:$key}) RETURN n.owner AS owner", + Some(std::collections::HashMap::from([( + "key".into(), + coordinode_core::graph::types::Value::String(format!( + "physical-slot/{}/{}", + position.asset_id, position.incarnation + )), + )])), + None, + None, + None, + )? + .rows; + assert_eq!(held.len(), 1); + assert_eq!( + held[0] + .get("owner") + .and_then(coordinode_core::graph::types::Value::as_str), + future.id.as_deref() + ); + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: future.id.ok_or(Error::Corrupt)?, + revision: future.revision + }), + completed_steps: vec![0], + ..Default::default() + }) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Ordinary relation writes cannot occupy a held slot; the admitted plan retains its installation path. +#[test] +fn installed_in_writes_respect_the_physical_slot_reservation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Enclosure")?; + let (position, item) = mountable_component_fixture(&store, &actor, owner, "disk-bay")?; + let old = binding(&store, &item)?; + let removal = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(item.clone()), + previous_binding: Some(old.relation_id), + previous_revision: Some(old.revision), + ..Default::default() + }], + )?; + report_steps(&store, &actor, &removal, vec![0])?; + let plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + replacement: Some(item), + ..Default::default() + }], + )?; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let intruder = create(&store, &actor, K::PhysicalComponent, "Unplanned component")?; + let before = store.usage()?; + assert!(matches!( + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(intruder), + to: Some(position), + kind: wire::RelationKind::InstalledIn as i32, + ..Default::default() + }), + remove: false, + }) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + let completed = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: vec![0], + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!(completed.state, api::PhysicalPlanState::Completed as i32); + Ok(()) +} + +// An occupied destination cannot be reserved unless its occupant is explicitly removed in the plan. +#[test] +fn component_move_admission_checks_destination_and_preserves_atomic_swaps() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Two-slot enclosure")?; + let (a, first) = mountable_component_fixture(&store, &actor, owner.clone(), "disk/1")?; + let (b, second) = mountable_component_fixture(&store, &actor, owner, "disk/2")?; + let old = binding(&store, &first)?; + let first_move = api::PhysicalReplacement { + position: Some(b.clone()), + source_position: Some(a.clone()), + previous: Some(first.clone()), + replacement: Some(first.clone()), + previous_binding: Some(old.relation_id), + previous_revision: Some(old.revision), + ..Default::default() + }; + let impossible = replacement_plan(&store, &actor, vec![first_move.clone()])?; + let before = store.usage()?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(impossible.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Failed as i32); + assert!(matches!( + reserve(&store, &actor, impossible), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + let old = binding(&store, &second)?; + let swap = replacement_plan( + &store, + &actor, + vec![ + first_move, + api::PhysicalReplacement { + position: Some(a.clone()), + source_position: Some(b.clone()), + previous: Some(second.clone()), + replacement: Some(second.clone()), + previous_binding: Some(old.relation_id), + previous_revision: Some(old.revision), + ..Default::default() + }, + ], + )?; + let reserved = reserve(&store, &actor, swap)?.plan.ok_or(Error::Corrupt)?; + let completed = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: reserved.id.ok_or(Error::Corrupt)?, + revision: reserved.revision, + }), + completed_steps: vec![0, 1], + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!(completed.state, api::PhysicalPlanState::Completed as i32); + assert_eq!(binding(&store, &first)?.to, Some(b)); + assert_eq!(binding(&store, &second)?.to, Some(a)); + Ok(()) +} + +// A manual report records work already performed, not a new physical-effect authorization. +#[test] +fn changed_geometry_does_not_discard_an_attributed_manual_installation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Changed enclosure")?; + let item = create(&store, &actor, K::Device, "Previously fitting server")?; + equipment_dimensions(&store, &actor, &item)?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let plan = draft(&store, &actor, item, rack.clone())?; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let mut updated = define(rack.clone()); + updated + .volume + .as_mut() + .ok_or(Error::Corrupt)? + .x + .as_mut() + .ok_or(Error::Corrupt)? + .length_um = 1; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(updated), + }), + )?; + let mut report = api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: vec![0], + discrepancy: Some( + "Already installed; recorded enclosure width needs reconciliation".into(), + ), + ..Default::default() + }; + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(report.clone()) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + report.actual_state = true; + physical(&store, &actor, api::physical::Action::Complete(report))?; + let current = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }), + )?; + assert_eq!(current.occupancy.len(), 1); + assert_eq!( + current.definitions[0] + .volume + .as_ref() + .ok_or(Error::Corrupt)? + .x + .as_ref() + .ok_or(Error::Corrupt)? + .length_um, + 1 + ); + Ok(()) +} + +// A serialized item cannot reuse an obsolete fit decision after its measured dimensions change. +#[test] +fn reserved_direct_item_is_readmitted_after_equipment_dimensions_change() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Enclosure")?; + let item = create(&store, &actor, K::Device, "Serialized server")?; + equipment_dimensions(&store, &actor, &item)?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let plan = draft(&store, &actor, item.clone(), rack)?; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let current = store.asset(AssetRef::try_from(&item)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(item.clone()), + expected: Some(current.revision), + kind: current.kind, + changes: vec![equipment_quantity("width", 3_000_000, "mm", 3)], + ..Default::default() + }), + )?; + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision + }), + completed_steps: vec![0], + ..Default::default() + }) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert!(store.relations(AssetRef::try_from(&item)?, 32)?.is_empty()); + Ok(()) +} + +// Merely listing a future removal cannot free current space for an independent step. +#[test] +fn replacement_space_credit_requires_the_shared_plan_prerequisite() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Occupied enclosure")?; + let old = create(&store, &actor, K::Device, "Established server")?; + let incoming = create(&store, &actor, K::Device, "Incoming server")?; + for target in [&old, &incoming] { + equipment_dimensions(&store, &actor, target)?; + } + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let installed = draft(&store, &actor, old.clone(), rack.clone())?; + report_steps(&store, &actor, &installed, vec![0])?; + let previous = binding(&store, &old)?; + let mut replacement = api::PhysicalPlan { + name: "Remove then place, with explicit dependency".into(), + steps: vec![ + api::PhysicalStep { + action: Some(api::physical_step::Action::Replacement( + api::PhysicalReplacement { + position: Some(rack.clone()), + previous: Some(old.clone()), + previous_binding: Some(previous.relation_id), + previous_revision: Some(previous.revision), + ..Default::default() + }, + )), + ..Default::default() + }, + api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: Some(incoming.clone()), + enclosure: Some(rack.clone()), + volume: Some(volume()), + material: None, + }, + )), + ..Default::default() + }, + ], + ..Default::default() + }; + let unordered = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(replacement.clone()), + }), + )? + .plan + .ok_or(Error::Corrupt)?; + let usage = store.usage()?; + assert!(matches!( + reserve(&store, &actor, unordered), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, usage); + replacement.steps[1].control = Some(api::PlanControl { + after: vec![api::PlanDependency { + step: 0, + when: api::PlanTrigger::Reported as i32, + }], + ..Default::default() + }); + // An unrelated placement in genuinely free space need not inherit the removal. + let parallel = create(&store, &actor, K::Device, "Independent neighboring server")?; + equipment_dimensions(&store, &actor, ¶llel)?; + let mut enclosure = define(rack.clone()); + enclosure + .volume + .as_mut() + .ok_or(Error::Corrupt)? + .x + .as_mut() + .ok_or(Error::Corrupt)? + .length_um = 1_000_000; + enclosure.occupancy_coverage = enclosure.volume.map(Box::new); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(enclosure.clone()), + }), + )?; + let mut neighboring = volume(); + neighboring.x.as_mut().ok_or(Error::Corrupt)?.start_um = 500_000; + replacement.steps.push(api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: Some(parallel.clone()), + enclosure: Some(rack), + volume: Some(neighboring), + material: None, + }, + )), + ..Default::default() + }); + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(replacement.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Satisfied as i32); + // Final load fits, but the independent neighbor could arrive before the old heavy server leaves. + for (target, grams) in [(&old, 20), (&incoming, 5), (¶llel, 10)] { + let current = store.asset(AssetRef::try_from(target)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(target.clone()), + kind: current.kind, + expected: Some(current.revision), + changes: vec![equipment_quantity("weight", grams, "g", 0)], + ..Default::default() + }), + )?; + } + enclosure.max_mass_g = Some(25); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(enclosure), + }), + )?; + let ordered = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(replacement), + }), + )? + .plan + .ok_or(Error::Corrupt)?; + let usage = store.usage()?; + assert!(matches!( + reserve(&store, &actor, ordered.clone()), + Err(Error::Incomplete) + )); + assert_eq!(store.usage()?, usage); + let mut ordered = ordered; + ordered.steps[2].control = ordered.steps[1].control.clone(); + let ordered = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(ordered), + }), + )? + .plan + .ok_or(Error::Corrupt)?; + reserve(&store, &actor, ordered)?; + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/availability.rs b/crates/omloom-store/tests/workspace/physical/availability.rs new file mode 100644 index 0000000..79fe46c --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/availability.rs @@ -0,0 +1,432 @@ +use super::*; + +// Reading an owner's offer requires read permission, while publishing still requires write permission. +#[test] +fn read_only_publisher_can_list_but_not_modify_its_offers() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Published enclosure")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let saved = offer(&store, &actor, &rack)?; + let mut reader = actor.clone(); + reader.grants[0].writable = false; + reader.grants[0].write = Fields::Selected(vec![]); + let before = store.usage()?; + let listed = physical( + &store, + &reader, + api::physical::Action::Offers(api::PhysicalOffers { + enclosure: Some(rack), + after: None, + }), + )?; + assert_eq!(listed.offers, vec![saved.clone()]); + assert!(matches!( + physical( + &store, + &reader, + api::physical::Action::Offer(api::PhysicalOfferEdit { offer: Some(saved) }) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// Recording a rack's dimensions does not establish that its unrecorded space is empty. +#[test] +fn dimensional_definition_alone_does_not_qualify_free_capacity() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Partly documented rack")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + target: Some(rack.clone()), + volume: Some(volume()), + basis: "Dimensions measured; current occupancy not surveyed".into(), + ..Default::default() + }), + }), + )?; + let offer = offer(&store, &actor, &rack)?; + let before = store.usage()?; + let availability = read(&store, &actor, &offer)?; + assert_eq!(availability.state, api::RequirementState::Unknown as i32); + assert_eq!(availability.reason, api::RequirementReason::Missing as i32); + assert_eq!(store.usage()?, before); + let item = create(&store, &actor, K::Device, "Known loose equipment")?; + equipment_dimensions(&store, &actor, &item)?; + let plan = draft(&store, &actor, item, rack)?; + assert!(matches!( + reserve(&store, &actor, plan), + Err(Error::Incomplete) + )); + Ok(()) +} + +// A limited survey qualifies only that region; withdrawing it cannot evict an admitted allocation. +#[test] +fn occupancy_coverage_is_explicit_scoped_and_not_a_reservation_lifetime() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Partly surveyed rack")?; + let mut facts = define(rack.clone()); + let mut partial = volume(); + partial.z.as_mut().ok_or(Error::Corrupt)?.length_um = 500_000; + facts.occupancy_coverage = Some(Box::new(partial)); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(facts.clone()), + }), + )?; + let mut offer = offer(&store, &actor, &rack)?; + assert_eq!( + read(&store, &actor, &offer)?.reason, + api::RequirementReason::Incomplete as i32 + ); + offer.region = Some(partial); + offer = physical( + &store, + &actor, + api::physical::Action::Offer(api::PhysicalOfferEdit { offer: Some(offer) }), + )? + .offers + .pop() + .ok_or(Error::Corrupt)?; + assert_eq!( + read(&store, &actor, &offer)?.state, + api::RequirementState::Satisfied as i32 + ); + // No observed load is not a complete whole-rack power statement. + offer.power = true; + offer = physical( + &store, + &actor, + api::physical::Action::Offer(api::PhysicalOfferEdit { offer: Some(offer) }), + )? + .offers + .pop() + .ok_or(Error::Corrupt)?; + assert_eq!( + read(&store, &actor, &offer)?.reason, + api::RequirementReason::Incomplete as i32 + ); + let item = create(&store, &actor, K::Device, "Unplaced known equipment")?; + equipment_dimensions(&store, &actor, &item)?; + let plan = draft(&store, &actor, item, rack)?; + assert!(matches!( + reserve(&store, &actor, plan.clone()), + Err(Error::Incomplete) + )); + facts.occupancy_coverage = Some(Box::new(volume())); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(facts.clone()), + }), + )?; + reserve(&store, &actor, plan)?; + let held = store.usage()?.rows[omloom_store::Class::Pending as usize]; + facts.occupancy_coverage = None; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(facts), + }), + )?; + assert_eq!( + store.usage()?.rows[omloom_store::Class::Pending as usize], + held + ); + // The retained allocation still proves this region is not offered, even after survey withdrawal. + assert_eq!( + read(&store, &actor, &offer)?.state, + api::RequirementState::Failed as i32 + ); + Ok(()) +} + +fn offer( + store: &Store, + actor: &Context, + rack: &wire::AssetRef, +) -> Result { + physical( + store, + actor, + api::physical::Action::Offer(api::PhysicalOfferEdit { + offer: Some(api::PhysicalOffer { + enclosure: Some(rack.clone()), + region: Some(volume()), + recipient: "regional-reader".into(), + name: "Reviewed customer region".into(), + ..Default::default() + }), + }), + )? + .offers + .pop() + .ok_or(Error::Corrupt) +} +fn read( + store: &Store, + actor: &Context, + offer: &api::PhysicalOffer, +) -> Result { + physical( + store, + actor, + api::physical::Action::Availability(api::PhysicalOfferRead { + id: offer.id.clone().ok_or(Error::Corrupt)?, + }), + ) +} + +// The separate offer grant reveals one approved region result, never hidden occupants or their geometry. +#[test] +fn availability_offer_hides_occupants_and_obeys_revoke() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Private rack")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let mut offer = offer(&store, &actor, &rack)?; + let mut recipient = actor.clone(); + recipient.actor = "regional-reader".into(); + recipient.grants = vec![Grant { + scope: Scope::Targets(vec![AssetRef::try_from(&rack).map_err(|_| Error::Invalid)?]), + read: Fields::Selected(vec!["physical_availability".into()]), + write: Fields::Selected(vec![]), + writable: false, + }]; + let usage = store.usage()?; + let available = read(&store, &recipient, &offer)?; + assert_eq!(available.state, api::RequirementState::Satisfied as i32); + assert!( + available.occupancy.is_empty() + && available.items.is_empty() + && available.definitions.is_empty() + ); + assert_eq!(available.offers, vec![offer.clone()]); + assert_eq!(store.usage()?, usage); + let hidden = create(&store, &actor, K::Device, "Secret occupant")?; + equipment_dimensions(&store, &actor, &hidden)?; + let plan = draft(&store, &actor, hidden, rack.clone())?; + reserve(&store, &actor, plan)?; + let unavailable = read(&store, &recipient, &offer)?; + assert_eq!(unavailable.state, api::RequirementState::Failed as i32); + assert!( + unavailable.occupancy.is_empty() + && unavailable.items.is_empty() + && unavailable.definitions.is_empty() + ); + assert_eq!(unavailable.offers, vec![offer.clone()]); + let usage = store.usage()?; + assert!(matches!( + physical( + &store, + &recipient, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }) + ), + Err(Error::Denied) + )); + for _ in 0..3 { + read(&store, &recipient, &offer)?; + } + assert_eq!(store.usage()?, usage); + // Loss of geometry cannot prevent withdrawing an already disclosed region. + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + target: offer.enclosure.clone(), + basis: "Geometry withdrawn after review".into(), + ..Default::default() + }), + }), + )?; + offer.revoked = true; + physical( + &store, + &actor, + api::physical::Action::Offer(api::PhysicalOfferEdit { + offer: Some(offer.clone()), + }), + )?; + assert!(matches!( + read(&store, &recipient, &offer), + Err(Error::Denied) + )); + Ok(()) +} + +// Offer IDs and region profiles grant nothing without current recipient and field authority. +#[test] +fn availability_offer_is_revisioned_bounded_and_not_a_bearer_grant() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Rack")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let offer = offer(&store, &actor, &rack)?; + let usage = store.usage()?; + physical( + &store, + &actor, + api::physical::Action::Offer(api::PhysicalOfferEdit { + offer: Some(offer.clone()), + }), + )?; + assert_eq!(store.usage()?, usage); + let mut other = actor.clone(); + other.actor = "other-recipient".into(); + assert!(matches!(read(&store, &other, &offer), Err(Error::Denied))); + other.actor = "regional-reader".into(); + other.grants.clear(); + assert!(matches!(read(&store, &other, &offer), Err(Error::Denied))); + let mut invalid = offer.clone(); + invalid + .region + .as_mut() + .ok_or(Error::Corrupt)? + .z + .as_mut() + .ok_or(Error::Corrupt)? + .length_um += 1; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Offer(api::PhysicalOfferEdit { + offer: Some(invalid) + }) + ), + Err(Error::Invalid) + )); + let mut stale = offer.clone(); + stale.revision += 1; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Offer(api::PhysicalOfferEdit { offer: Some(stale) }) + ), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, usage); + drop(store); + let store = Store::open(dir.path(), policy())?; + assert_eq!(read(&store, &actor, &offer)?.offers, vec![offer.clone()]); + // A backup preserves the profile, not fresh proof that its formerly free region is still available. + let mut image = Vec::new(); + store.export(&mut image)?; + let restored_dir = tempfile::tempdir()?; + let restored = Store::restore(restored_dir.path(), &image, policy(), image.len() as u64)?; + let stale = read(&restored, &actor, &offer)?; + assert_eq!(stale.offers, vec![offer]); + assert_eq!(stale.state, api::RequirementState::Unknown as i32); + assert_eq!(stale.reason, api::RequirementReason::Stale as i32); + Ok(()) +} + +// Continuations cover every offer; revocation never frees the finite profile admission budget. +#[test] +fn offer_pages_and_creation_limit_include_revoked_profiles() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Bounded profiles")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let mut created = Vec::new(); + for _ in 0..26 { + created.push(offer(&store, &actor, &rack)?); + } + let first = physical( + &store, + &actor, + api::physical::Action::Offers(api::PhysicalOffers { + enclosure: Some(rack.clone()), + after: None, + }), + )?; + assert_eq!(first.offers.len(), 25); + assert_eq!( + first.next, + first.offers.last().and_then(|value| value.id.clone()) + ); + let second = physical( + &store, + &actor, + api::physical::Action::Offers(api::PhysicalOffers { + enclosure: Some(rack.clone()), + after: first.next, + }), + )?; + assert_eq!(second.offers.len(), 1); + assert!(second.next.is_none()); + let mut ids: Vec<_> = first + .offers + .iter() + .chain(&second.offers) + .map(|value| value.id.clone()) + .collect(); + ids.sort(); + let mut expected: Vec<_> = created.iter().map(|value| value.id.clone()).collect(); + expected.sort(); + assert_eq!(ids, expected); + for _ in 26..64 { + offer(&store, &actor, &rack)?; + } + let mut revoked = created.remove(0); + revoked.revoked = true; + physical( + &store, + &actor, + api::physical::Action::Offer(api::PhysicalOfferEdit { + offer: Some(revoked), + }), + )?; + let usage = store.usage()?; + assert!(matches!(offer(&store, &actor, &rack), Err(Error::Limit))); + assert_eq!(store.usage()?, usage); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/cables.rs b/crates/omloom-store/tests/workspace/physical/cables.rs new file mode 100644 index 0000000..895e195 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/cables.rs @@ -0,0 +1,727 @@ +use super::*; + +fn edit(cable: &wire::AssetRef, key: &str) -> api::PhysicalCableEndEdit { + api::PhysicalCableEndEdit { + cable: Some(cable.clone()), + end: Some(api::PhysicalCableEnd { + key: key.into(), + name: format!("Cable end {key}"), + basis: "Operator-labelled physical cable".into(), + connector: Some("LC/UPC duplex".into()), + ..Default::default() + }), + expected: None, + } +} + +// Changed connector evidence blocks ordinary completion while preserving truthful actual-state reports. +#[test] +fn wiring_completion_rechecks_changed_port_mating() -> TestResult { + for changed_role in [Some(api::PhysicalMatingRole::SideA), None] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let cable = create(&store, &actor, K::Cable, "Reserved cable")?; + let port = create(&store, &actor, K::Port, "Changed connector")?; + let mut end = edit(&cable, "A"); + end.end.as_mut().ok_or(Error::Corrupt)?.mating = + Some(mating(api::PhysicalMatingRole::SideA)); + let saved = physical(&store, &actor, api::physical::Action::CableEnd(end))? + .cable_ends + .pop() + .ok_or(Error::Missing)?; + let mut definition = api::PhysicalDefinition { + target: Some(port.clone()), + basis: "Measured connector interface".into(), + mating: Some(mating(api::PhysicalMatingRole::SideB)), + ..Default::default() + }; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition.clone()), + }), + )?; + let plan = wiring_plan( + &store, + &actor, + &cable, + saved.revision, + api::physical_wiring::After::Port(port.clone()), + api::PhysicalWiringIntent::Preserve, + )?; + let reserved = reserve(&store, &actor, plan)?.plan.ok_or(Error::Missing)?; + definition.mating = changed_role.map(mating); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition), + }), + )?; + let before = store.usage()?; + let result = report(&store, &actor, &reserved, false); + if changed_role.is_some() { + assert!(matches!(result, Err(Error::Conflict))); + } else { + assert!(matches!(result, Err(Error::Incomplete))); + } + assert_eq!(store.usage()?, before); + let actual = report(&store, &actor, &reserved, true)?; + assert_eq!(actual.state, api::PhysicalPlanState::Completed as i32); + let current = ends(&store, &actor, &cable, None)? + .cable_ends + .pop() + .ok_or(Error::Missing)?; + assert!( + matches!(current.attachment, Some(api::physical_cable_end::Attachment::Connected(ref value)) if value.port.as_ref() == Some(&port)) + ); + } + Ok(()) +} + +// A breakout remains one adopted assembly with explicit internal paths, not cloned cables. +#[test] +fn breakout_channels_have_two_ends_and_one_assembly_identity() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let cable = create(&store, &actor, K::Cable, "DAC breakout assembly")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + target: Some(cable.clone()), + basis: "Vendor-declared inseparable copper assembly".into(), + assembly_kind: api::PhysicalAssemblyKind::Dac as i32, + ..Default::default() + }), + }), + )?; + for (key, channels) in [ + ("trunk", vec![1, 2, 3, 4]), + ("leg-1", vec![1]), + ("leg-2", vec![2]), + ("leg-3", vec![3]), + ("leg-4", vec![4]), + ] { + let mut value = edit(&cable, key); + value.end.as_mut().ok_or("end")?.channels = channels; + physical(&store, &actor, api::physical::Action::CableEnd(value))?; + } + let before = store.usage()?; + let mut invalid = edit(&cable, "duplicate-leg"); + invalid.end.as_mut().ok_or("end")?.channels = vec![1]; + assert!(matches!( + physical(&store, &actor, api::physical::Action::CableEnd(invalid)), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + let current = ends(&store, &actor, &cable, None)?.cable_ends; + assert_eq!(current.len(), 5); + assert!( + current.iter().all(|e| e.attachment.is_none()), + "Channel declarations do not invent actual wiring" + ); + let mut identical = edit(&cable, "trunk"); + let trunk = current.iter().find(|e| e.key == "trunk").ok_or("trunk")?; + identical.expected = Some(trunk.revision); + identical.end.as_mut().ok_or("end")?.channels = vec![4, 3, 2, 1]; + let saved = physical(&store, &actor, api::physical::Action::CableEnd(identical))?; + assert_eq!(saved.cable_ends[0].revision, trunk.revision); + assert_eq!( + store.usage()?, + before, + "Reordering the same channel set is not a change" + ); + drop(store); + let store = Store::open(dir.path(), policy())?; + let current = ends(&store, &actor, &cable, None)?.cable_ends; + assert_eq!(current.iter().map(|e| e.channels.len()).sum::(), 8); + let profile = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(cable), + occupants: false, + after: None, + }), + )?; + assert_eq!( + profile.definitions[0].assembly_kind, + api::PhysicalAssemblyKind::Dac as i32 + ); + Ok(()) +} +fn ends( + store: &Store, + actor: &Context, + cable: &wire::AssetRef, + after: Option, +) -> Result { + physical( + store, + actor, + api::physical::Action::CableEnds(api::PhysicalCableEnds { + cable: Some(cable.clone()), + after, + }), + ) +} + +fn mating(role: api::PhysicalMatingRole) -> api::PhysicalMating { + api::PhysicalMating { + profile: Some(wire::EquipmentReference { + issuer: "private:bench".into(), + key: "lc-duplex".into(), + basis: "Measured connector pair revision 1".into(), + }), + role: role as i32, + } +} +fn wiring_plan( + store: &Store, + actor: &Context, + cable: &wire::AssetRef, + revision: u64, + after: api::physical_wiring::After, + intent: api::PhysicalWiringIntent, +) -> Result { + physical( + store, + actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(api::PhysicalPlan { + name: "Cable work".into(), + steps: vec![api::PhysicalStep { + action: Some(api::physical_step::Action::Wiring(api::PhysicalWiring { + cable: Some(cable.clone()), + end_key: "A".into(), + basis: Some(api::physical_wiring::Basis::ExpectedEndRevision(revision)), + after: Some(after), + intent: intent as i32, + ..Default::default() + })), + ..Default::default() + }], + ..Default::default() + }), + }), + )? + .plan + .ok_or(Error::Corrupt) +} +fn report( + store: &Store, + actor: &Context, + plan: &api::PhysicalPlan, + actual: bool, +) -> Result { + physical( + store, + actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: vec![0], + actual_state: actual, + discrepancy: actual.then(|| "Externally reported attachment uncertainty".into()), + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt) +} + +// Reported unplugging preserves intended wiring; unknown attachment cannot release its possible port. +#[test] +fn cable_attachment_intent_and_unknown_capacity_remain_independent() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let cable = create(&store, &actor, K::Cable, "Fiber")?; + let port = create(&store, &actor, K::Port, "Optic connector")?; + let mut end = edit(&cable, "A"); + end.end.as_mut().ok_or(Error::Corrupt)?.mating = Some(mating(api::PhysicalMatingRole::SideA)); + let saved = physical(&store, &actor, api::physical::Action::CableEnd(end))? + .cable_ends + .pop() + .ok_or(Error::Corrupt)?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + target: Some(port.clone()), + basis: "Measured socket".into(), + mating: Some(mating(api::PhysicalMatingRole::SideB)), + ..Default::default() + }), + }), + )?; + let draft = wiring_plan( + &store, + &actor, + &cable, + saved.revision, + api::physical_wiring::After::Port(port.clone()), + api::PhysicalWiringIntent::AdoptDestination, + )?; + let reserved = reserve(&store, &actor, draft)?.plan.ok_or(Error::Corrupt)?; + let done = report(&store, &actor, &reserved, false)?; + assert_eq!(done.state, api::PhysicalPlanState::Completed as i32); + let attached = ends(&store, &actor, &cable, None)?.cable_ends.remove(0); + assert_eq!(attached.intended_port, Some(port.clone())); + assert!( + matches!(&attached.attachment,Some(api::physical_cable_end::Attachment::Connected(a)) if a.port.as_ref()==Some(&port)) + ); + let usage = store.usage()?; + assert!(matches!( + report(&store, &actor, &reserved, false), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, usage); + let unknown = wiring_plan( + &store, + &actor, + &cable, + attached.revision, + api::physical_wiring::After::Unknown(api::Empty {}), + api::PhysicalWiringIntent::Preserve, + )?; + report(&store, &actor, &unknown, true)?; + let uncertain = ends(&store, &actor, &cable, None)?.cable_ends.remove(0); + assert!(uncertain.attachment.is_none()); + assert_eq!(uncertain.intended_port, Some(port.clone())); + assert_eq!( + store + .relations(AssetRef::try_from(&cable).map_err(|_| Error::Invalid)?, 32)? + .len(), + 1 + ); + // Unknown keeps the established socket occupied against another cable's reservation. + let competitor = create(&store, &actor, K::Cable, "Competing cable")?; + let mut competing_end = edit(&competitor, "A"); + competing_end.end.as_mut().ok_or(Error::Corrupt)?.mating = + Some(mating(api::PhysicalMatingRole::SideA)); + let competing_end = physical( + &store, + &actor, + api::physical::Action::CableEnd(competing_end), + )? + .cable_ends + .remove(0); + let competing = wiring_plan( + &store, + &actor, + &competitor, + competing_end.revision, + api::physical_wiring::After::Port(port.clone()), + api::PhysicalWiringIntent::Preserve, + )?; + let usage = store.usage()?; + assert!(matches!( + reserve(&store, &actor, competing), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, usage); + let loose = wiring_plan( + &store, + &actor, + &cable, + uncertain.revision, + api::physical_wiring::After::Loose(api::Empty {}), + api::PhysicalWiringIntent::Preserve, + )?; + let reserved = reserve(&store, &actor, loose)?.plan.ok_or(Error::Corrupt)?; + report(&store, &actor, &reserved, false)?; + let loose = ends(&store, &actor, &cable, None)?.cable_ends.remove(0); + assert!(matches!( + loose.attachment, + Some(api::physical_cable_end::Attachment::Loose(_)) + )); + assert_eq!(loose.intended_port, Some(port)); + assert!( + store + .relations(AssetRef::try_from(&cable).map_err(|_| Error::Invalid)?, 32)? + .is_empty() + ); + let usage = store.usage()?; + ends(&store, &actor, &cable, None)?; + assert_eq!(store.usage()?, usage); + drop(store); + let store = Store::open(dir.path(), policy())?; + assert_eq!(ends(&store, &actor, &cable, None)?.cable_ends[0], loose); + Ok(()) +} + +// One saved plan cannot promise the same unoccupied socket to two cable ends. +#[test] +fn cable_plan_rejects_parallel_double_booking() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let port = create(&store, &actor, K::Port, "One socket")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + target: Some(port.clone()), + basis: "Measured socket".into(), + mating: Some(mating(api::PhysicalMatingRole::SideB)), + ..Default::default() + }), + }), + )?; + let mut steps = Vec::new(); + for name in ["Cable A", "Cable B"] { + let cable = create(&store, &actor, K::Cable, name)?; + let mut edit = edit(&cable, "A"); + edit.end.as_mut().ok_or(Error::Corrupt)?.mating = + Some(mating(api::PhysicalMatingRole::SideA)); + let end = physical(&store, &actor, api::physical::Action::CableEnd(edit))? + .cable_ends + .remove(0); + let plan = wiring_plan( + &store, + &actor, + &cable, + end.revision, + api::physical_wiring::After::Port(port.clone()), + api::PhysicalWiringIntent::Preserve, + )?; + steps.extend(plan.steps); + } + let plan = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(api::PhysicalPlan { + name: "Conflicting connections".into(), + steps, + ..Default::default() + }), + }), + )? + .plan + .ok_or(Error::Corrupt)?; + let usage = store.usage()?; + assert!(matches!( + reserve(&store, &actor, plan), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, usage); + Ok(()) +} + +// A reported disconnect releases its socket for another cable only along the explicit dependency. +#[test] +fn ordered_socket_handoff_admits_two_cables_without_parallel_booking() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let port = create(&store, &actor, K::Port, "Handoff socket")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + target: Some(port.clone()), + basis: "Measured socket".into(), + mating: Some(mating(api::PhysicalMatingRole::SideB)), + ..Default::default() + }), + }), + )?; + let mut steps = Vec::new(); + for name in ["First cable", "Next cable"] { + let cable = create(&store, &actor, K::Cable, name)?; + let mut end = edit(&cable, "A"); + end.end.as_mut().ok_or(Error::Corrupt)?.mating = + Some(mating(api::PhysicalMatingRole::SideA)); + let end = physical(&store, &actor, api::physical::Action::CableEnd(end))? + .cable_ends + .remove(0); + let mut plan = wiring_plan( + &store, + &actor, + &cable, + end.revision, + api::physical_wiring::After::Port(port.clone()), + api::PhysicalWiringIntent::Preserve, + )?; + if steps.is_empty() { + let mut disconnect = plan.steps[0].clone(); + let Some(api::physical_step::Action::Wiring(w)) = &mut disconnect.action else { + return Err(Error::Corrupt.into()); + }; + w.basis = Some(api::physical_wiring::Basis::AfterStep(0)); + w.after = Some(api::physical_wiring::After::Loose(api::Empty {})); + steps.append(&mut plan.steps); + steps.push(disconnect); + } else { + plan.steps[0].control = Some(api::PlanControl { + after: vec![api::PlanDependency { + step: 1, + when: api::PlanTrigger::Reported as i32, + }], + ..Default::default() + }); + steps.append(&mut plan.steps); + } + } + let plan = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(api::PhysicalPlan { + name: "Ordered socket handoff".into(), + steps, + ..Default::default() + }), + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!( + reserve(&store, &actor, plan)? + .plan + .ok_or(Error::Corrupt)? + .state, + api::PhysicalPlanState::Reserved as i32 + ); + Ok(()) +} + +// An AfterStep basis belongs to one exact cable end, not a neighboring end or cable. +#[test] +fn cable_chain_rejects_cross_end_and_cross_cable_bases_during_save() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let cable = create(&store, &actor, K::Cable, "First cable")?; + let other = create(&store, &actor, K::Cable, "Other cable")?; + for (cable, key) in [(&cable, "A"), (&cable, "B"), (&other, "A")] { + physical( + &store, + &actor, + api::physical::Action::CableEnd(edit(cable, key)), + )?; + } + let end = physical( + &store, + &actor, + api::physical::Action::CableEnds(api::PhysicalCableEnds { + cable: Some(cable.clone()), + ..Default::default() + }), + )? + .cable_ends + .into_iter() + .find(|e| e.key == "A") + .ok_or(Error::Missing)?; + let base = wiring_plan( + &store, + &actor, + &cable, + end.revision, + api::physical_wiring::After::Loose(api::Empty {}), + api::PhysicalWiringIntent::Preserve, + )?; + for (cable, key) in [(cable, "B"), (other, "A")] { + let mut plan = base.clone(); + let mut second = plan.steps[0].clone(); + let Some(api::physical_step::Action::Wiring(wire)) = &mut second.action else { + return Err(Error::Corrupt.into()); + }; + wire.cable = Some(cable); + wire.end_key = key.into(); + wire.basis = Some(api::physical_wiring::Basis::AfterStep(0)); + plan.steps.push(second); + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }) + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + } + Ok(()) +} + +// Abandoning an unstarted dependent step needs no fictional output from its predecessor. +#[test] +fn cable_chain_cancellation_needs_no_effect_result() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let cable = create(&store, &actor, K::Cable, "Unstarted cable")?; + let end = physical( + &store, + &actor, + api::physical::Action::CableEnd(edit(&cable, "A")), + )? + .cable_ends + .remove(0); + let mut plan = wiring_plan( + &store, + &actor, + &cable, + end.revision, + api::physical_wiring::After::Loose(api::Empty {}), + api::PhysicalWiringIntent::Preserve, + )?; + let mut second = plan.steps[0].clone(); + let Some(api::physical_step::Action::Wiring(w)) = &mut second.action else { + return Err(Error::Corrupt.into()); + }; + w.basis = Some(api::physical_wiring::Basis::AfterStep(0)); + plan.steps.push(second); + let plan = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let usage = store.usage()?; + let cancelled = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + not_performed_steps: vec![0, 1], + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!(cancelled.state, api::PhysicalPlanState::Cancelled as i32); + assert_eq!( + store.usage()?.rows[omloom_store::Class::Pending as usize], + 0 + ); + assert_eq!(ends(&store, &actor, &cable, None)?.cable_ends[0], end); + assert!(store.usage()?.revision > usage.revision); + Ok(()) +} + +// Defining ends never invents a live attachment; repeats and reads do not write history. +#[test] +fn cable_end_definitions_are_scoped_bounded_and_durable() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::Cable, "Cable A").expect("Create cable A fixture"); + let b = create(&store, &actor, K::Cable, "Cable B").expect("Create cable B fixture"); + let mut value = edit(&a, "A"); + let saved = physical( + &store, + &actor, + api::physical::Action::CableEnd(value.clone()), + ) + .expect("Save named cable end") + .cable_ends + .pop() + .ok_or(Error::Corrupt)?; + let usage = store.usage()?; + value.expected = Some(saved.revision); + physical(&store, &actor, api::physical::Action::CableEnd(value))?; + assert_eq!( + ends(&store, &actor, &a, None)?.cable_ends, + vec![saved.clone()] + ); + assert!(ends(&store, &actor, &b, None)?.cable_ends.is_empty()); + assert_eq!(store.usage()?, usage); + assert!( + store + .relations(AssetRef::try_from(&a).map_err(|_| Error::Invalid)?, 32)? + .is_empty() + ); + for i in 1..64 { + physical( + &store, + &actor, + api::physical::Action::CableEnd(edit(&a, &format!("end{i:02}"))), + )?; + } + let usage = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::CableEnd(edit(&a, "overflow")) + ), + Err(Error::Limit) + )); + assert_eq!(store.usage()?, usage); + let first = ends(&store, &actor, &a, None)?; + assert_eq!(first.cable_ends.len(), 25); + let second = ends(&store, &actor, &a, first.next)?; + assert_eq!(second.cable_ends.len(), 25); + let last = ends(&store, &actor, &a, second.next)?; + assert_eq!(last.cable_ends.len(), 14); + assert!(last.next.is_none()); + assert_eq!(store.usage()?, usage); + drop(store); + let reopened = Store::open(dir.path(), policy())?; + assert_eq!(ends(&reopened, &actor, &a, None)?.cable_ends[0], saved); + Ok(()) +} + +// Scoped rights protect end definitions too; deleting an unlinked cable reclaims its own ends. +#[test] +fn cable_ends_follow_the_cables_rights_and_removal_lifecycle() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let cable = create(&store, &actor, K::Cable, "Unlinked cable")?; + physical( + &store, + &actor, + api::physical::Action::CableEnd(edit(&cable, "A")), + )?; + let usage = store.usage()?; + let mut restricted = actor.clone(); + restricted.grants[0].read = Fields::Selected(vec!["name".into()]); + assert!(matches!( + ends(&store, &restricted, &cable, None), + Err(Error::Denied) + )); + assert!(matches!( + physical( + &store, + &restricted, + api::physical::Action::CableEnd(edit(&cable, "B")) + ), + Err(Error::Denied) + )); + assert_eq!(store.usage()?, usage); + call( + &store, + &actor, + Command::Bulk(api::Bulk { + targets: vec![cable], + expected: usage.revision, + remove: true, + ..Default::default() + }), + )?; + assert_eq!( + store.usage()?.rows[omloom_store::Class::Inventory as usize], + usage.rows[omloom_store::Class::Inventory as usize] - 3 + ); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/clearance.rs b/crates/omloom-store/tests/workspace/physical/clearance.rs new file mode 100644 index 0000000..1794847 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/clearance.rs @@ -0,0 +1,104 @@ +use super::*; + +// A free body-sized region is not usable when it consumes another device's declared connector/service space. +#[test] +fn current_and_reserved_clearance_cannot_be_consumed_by_another_body() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Clearance rack")?; + let mut rack_facts = define(rack.clone()); + rack_facts + .volume + .as_mut() + .ok_or(Error::Corrupt)? + .x + .as_mut() + .ok_or(Error::Corrupt)? + .length_um = 1_500_000; + rack_facts.occupancy_coverage = rack_facts.volume.map(Box::new); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(rack_facts), + }), + )?; + let first = create(&store, &actor, K::Device, "Server with side access")?; + let second = create(&store, &actor, K::Device, "Another server")?; + for target in [&first, &second] { + equipment_dimensions(&store, &actor, target)?; + } + let facts = api::PhysicalDefinition { + target: Some(first.clone()), + basis: "Measured required side connector access".into(), + clearance: Some(Box::new(api::PhysicalClearance { + after_x_um: 500_000, + ..Default::default() + })), + ..Default::default() + }; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(facts.clone()), + }), + )?; + let read = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(first.clone()), + occupants: false, + after: None, + }), + )?; + assert_eq!(read.definitions[0].clearance, facts.clearance); + let first_plan = draft(&store, &actor, first, rack.clone())?; + let first_plan = reserve(&store, &actor, first_plan)? + .plan + .ok_or(Error::Corrupt)?; + let mut second_plan = draft(&store, &actor, second, rack)?; + let Some(api::physical_step::Action::Placement(p)) = &mut second_plan.steps[0].action else { + return Err(Error::Corrupt.into()); + }; + p.volume + .as_mut() + .ok_or(Error::Corrupt)? + .x + .as_mut() + .ok_or(Error::Corrupt)? + .start_um = 500_000; + let second_plan = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(second_plan), + }), + )? + .plan + .ok_or(Error::Corrupt)?; + let usage = store.usage()?; + for recorded in [false, true] { + if recorded { + report_steps(&store, &actor, &first_plan, vec![0])?; + } + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(second_plan.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Failed as i32); + assert!(matches!( + reserve(&store, &actor, second_plan.clone()), + Err(Error::Conflict) + )); + if !recorded { + assert_eq!(store.usage()?, usage); + } + } + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/endpoint_mappings.rs b/crates/omloom-store/tests/workspace/physical/endpoint_mappings.rs new file mode 100644 index 0000000..94f79d4 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/endpoint_mappings.rs @@ -0,0 +1,362 @@ +use super::*; +#[cfg(feature = "diagnostics")] +static PORT_BATCHES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +fn port( + store: &Store, + actor: &Context, + owner: &wire::AssetRef, + name: &str, +) -> Result { + let port = create(store, actor, K::Port, name)?; + call( + store, + actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(owner.clone()), + to: Some(port.clone()), + kind: wire::RelationKind::HasPort as i32, + ..Default::default() + }), + remove: false, + }), + )?; + Ok(port) +} + +// Endpoint judgments are independent from a replacement event and never reparent ports. +#[test] +fn endpoint_mapping_is_bounded_persistent_and_does_not_rewire() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let chassis = create(&store, &actor, K::Device, "Chassis")?; + let position = position_fixture( + &store, + &actor, + chassis, + "card/1", + api::PhysicalComponentKind::LineCard, + )?; + let old = create(&store, &actor, K::PhysicalComponent, "Old line card")?; + let new = create( + &store, + &actor, + K::PhysicalComponent, + "Different incoming model", + )?; + let a = port(&store, &actor, &old, "Old port 1")?; + let b = port(&store, &actor, &old, "Old port 2")?; + let c = port(&store, &actor, &new, "New port 1")?; + install_component(&store, &actor, position.clone(), old.clone())?; + let previous = binding(&store, &old)?; + let check = api::EvaluateRequirements { + anchor: Some(old.clone()), + nodes: vec![api::RequirementNode { + expression: Some(api::requirement_node::Expression::Binding( + api::BindingRequirement { + relation_id: previous.relation_id.clone(), + revision: previous.revision, + from: previous.from.clone(), + to: previous.to.clone(), + kind: previous.kind, + }, + )), + }], + root: 0, + }; + let evaluate = || -> Result { + match call(&store, &actor, Command::EvaluateRequirements(check.clone()))? { + Reply::RequirementEvaluation(value) => Ok(value), + _ => Err(Error::Corrupt), + } + }; + assert_eq!(evaluate()?.state, api::RequirementState::Satisfied as i32); + let replacement = api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(old.clone()), + replacement: Some(new.clone()), + previous_binding: Some(previous.relation_id), + previous_revision: Some(previous.revision), + endpoints: vec![ + api::PhysicalEndpointMapping { + previous: Some(a.clone()), + replacement: Some(c.clone()), + decision: api::PhysicalMappingDecision::Accepted as i32, + previous_channel: None, + replacement_channel: None, + }, + api::PhysicalEndpointMapping { + previous: Some(b.clone()), + replacement: None, + decision: api::PhysicalMappingDecision::Unresolved as i32, + previous_channel: None, + replacement_channel: None, + }, + ], + ..Default::default() + }; + let plan = replacement_plan(&store, &actor, vec![replacement.clone()])?; + let before = store.usage()?; + let same = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(plan.clone()), + }), + )? + .plan + .ok_or("plan")?; + assert_eq!(same.revision, plan.revision); + assert_eq!( + store.usage()?, + before, + "Unchanged endpoint review has no durable write" + ); + let mut duplicate = replacement.clone(); + duplicate.endpoints[1].replacement = Some(c.clone()); + duplicate.endpoints[1].decision = api::PhysicalMappingDecision::Accepted as i32; + assert!(matches!( + replacement_plan(&store, &actor, vec![duplicate]), + Err(Error::Conflict) + )); + let mut foreign = replacement; + foreign.endpoints[0].replacement = Some(b.clone()); + assert!(matches!( + replacement_plan(&store, &actor, vec![foreign]), + Err(Error::Invalid) + )); + let done = report_steps(&store, &actor, &plan, vec![0])?; + assert_eq!(done.state, api::PhysicalPlanState::Completed as i32); + assert_eq!(binding(&store, &new)?.to, Some(position)); + let usage = store.usage()?; + assert_eq!( + evaluate()?.state, + api::RequirementState::Failed as i32, + "The shared evaluator rejects a stale physical realization after replacement" + ); + assert_eq!( + store.usage()?, + usage, + "Checking changed path evidence is read-only" + ); + for (owner, child) in [(&old, &a), (&old, &b), (&new, &c)] { + assert!( + store + .relations(AssetRef::try_from(owner)?, 32)? + .iter() + .any(|r| r.kind == wire::RelationKind::HasPort as i32 + && r.to.as_ref() == Some(child)) + ); + } + let key = api::PhysicalPlanKey { + id: done.id.ok_or("id")?, + revision: done.revision, + }; + drop(store); + let store = Store::open(dir.path(), policy())?; + let loaded = physical(&store, &actor, api::physical::Action::Load(key))? + .plan + .ok_or("reopened plan")?; + let Some(api::physical_step::Action::Replacement(value)) = &loaded.steps[0].action else { + return Err("replacement".into()); + }; + assert_eq!(value.endpoints.len(), 2); + assert_eq!( + value.endpoints[1].decision, + api::PhysicalMappingDecision::Unresolved as i32 + ); + let mut denied = actor; + denied.grants = vec![Grant { + scope: Scope::All, + read: Fields::Selected(vec!["name".into(), "relations".into()]), + write: Fields::All, + writable: true, + }]; + assert!(matches!( + physical( + &store, + &denied, + api::physical::Action::Load(api::PhysicalPlanKey { + id: loaded.id.ok_or("id")?, + revision: 0 + }) + ), + Err(Error::Denied) + )); + Ok(()) +} + +// A smaller replacement keeps excess endpoints explicit and paginates direct hardware reads. +#[test] +fn cross_model_mapping_preserves_unmapped_ports_and_requires_declared_breakout_lanes() -> TestResult +{ + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + #[cfg(feature = "diagnostics")] + let mut store = store; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Modular owner")?; + let position = position_fixture( + &store, + &actor, + owner, + "switch/1", + api::PhysicalComponentKind::Chassis, + )?; + let old = create(&store, &actor, K::Device, "48-port switch")?; + let new = create(&store, &actor, K::Device, "24-port replacement")?; + let mut old_ports = Vec::new(); + let mut new_ports = Vec::new(); + for index in 0..48 { + old_ports.push(port(&store, &actor, &old, &format!("Old {index:02}"))?); + } + for index in 0..24 { + new_ports.push(port(&store, &actor, &new, &format!("New {index:02}"))?); + } + let query = api::Query { + roots: vec![old.clone()], + kinds: vec![K::Port as i32], + view: api::View::Hardware as i32, + level_only: true, + limit: 25, + columns: vec!["name".into()], + ..Default::default() + }; + let Reply::Page(first) = call(&store, &actor, Command::Query(query.clone()))? else { + return Err("page".into()); + }; + assert_eq!(first.total, 48); + assert_eq!(first.items.len(), 25); + let Reply::Page(second) = call( + &store, + &actor, + Command::Query(api::Query { + cursor: first.next, + ..query + }), + )? + else { + return Err("page".into()); + }; + assert_eq!(second.items.len(), 23); + let mut replacement = api::PhysicalReplacement { + position: Some(position), + previous: Some(old), + replacement: Some(new), + endpoints: old_ports + .iter() + .enumerate() + .map(|(index, old)| api::PhysicalEndpointMapping { + previous: Some(old.clone()), + replacement: new_ports.get(index).cloned(), + decision: if index < 24 { + api::PhysicalMappingDecision::Accepted as i32 + } else { + api::PhysicalMappingDecision::Unresolved as i32 + }, + ..Default::default() + }) + .collect(), + ..Default::default() + }; + #[cfg(feature = "diagnostics")] + { + PORT_BATCHES.store(0, std::sync::atomic::Ordering::Relaxed); + store.set_diagnostic_query_check(|db, statement| { + if statement.starts_with("UNWIND $ports AS port") { + PORT_BATCHES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let plan = db + .explain_plan(statement, None) + .expect("endpoint batch plan") + .explain(); + assert!( + plan.contains("IndexScan") && !plan.contains("Traverse"), + "Endpoint membership must use indexed probes, not a topology walk: {plan}" + ); + } + }); + } + let plan = replacement_plan(&store, &actor, vec![replacement.clone()])?; + #[cfg(feature = "diagnostics")] + assert_eq!( + PORT_BATCHES.load(std::sync::atomic::Ordering::Relaxed), + 2, + "Two supplier batches, independent of the 48-port map size" + ); + let Some(api::physical_step::Action::Replacement(saved)) = &plan.steps[0].action else { + return Err("replacement".into()); + }; + assert_eq!( + saved + .endpoints + .iter() + .filter(|p| p.replacement.is_none()) + .count(), + 24 + ); + replacement.endpoints = vec![ + api::PhysicalEndpointMapping { + previous: Some(old_ports[0].clone()), + replacement: Some(new_ports[0].clone()), + previous_channel: Some(1), + decision: api::PhysicalMappingDecision::Accepted as i32, + ..Default::default() + }, + api::PhysicalEndpointMapping { + previous: Some(old_ports[0].clone()), + replacement: Some(new_ports[1].clone()), + previous_channel: Some(2), + decision: api::PhysicalMappingDecision::Accepted as i32, + ..Default::default() + }, + ]; + assert!( + matches!( + replacement_plan(&store, &actor, vec![replacement.clone()]), + Err(Error::Incomplete) + ), + "A lane number cannot invent hardware support" + ); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + target: Some(old_ports[0].clone()), + basis: "Vendor-qualified breakout port channels".into(), + channels: vec![1, 2, 3, 4], + ..Default::default() + }), + }), + )?; + assert!(replacement_plan(&store, &actor, vec![replacement.clone()]).is_ok()); + replacement.endpoints[0].replacement_channel = Some(3); + assert!(matches!( + replacement_plan(&store, &actor, vec![replacement.clone()]), + Err(Error::Incomplete) + )); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(api::PhysicalDefinition { + target: Some(new_ports[0].clone()), + basis: "Vendor-qualified destination breakout channels".into(), + channels: vec![3], + ..Default::default() + }), + }), + )?; + assert!(replacement_plan(&store, &actor, vec![replacement.clone()]).is_ok()); + replacement.endpoints[1].previous_channel = None; + assert!( + matches!( + replacement_plan(&store, &actor, vec![replacement]), + Err(Error::Conflict) + ), + "A whole-port allocation overlaps all of its lanes" + ); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/materials.rs b/crates/omloom-store/tests/workspace/physical/materials.rs new file mode 100644 index 0000000..5cf0b97 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/materials.rs @@ -0,0 +1,863 @@ +use super::*; + +fn demand() -> api::PhysicalMaterial { + api::PhysicalMaterial { + name: "Required compute nodes".into(), + quantity: u32::MAX, + kind: K::Device as i32, + specifications: vec![equipment_quantity("width", 500_000, "mm", 3)], + physical: Some(Box::new(api::PhysicalDefinition { + basis: "Supplier mounting specification".into(), + mounting: Some("19-inch rails".into()), + ..Default::default() + })), + ..Default::default() + } +} +fn save_material_plan( + store: &Store, + actor: &Context, + plan: api::PhysicalPlan, +) -> Result { + physical( + store, + actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Missing) +} + +// Unreceived units reserve real geometry without fabricated assets; later receipt supplies identity to the same step. +#[test] +fn future_unit_placement_reserves_before_receipt_and_records_only_real_inventory() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Future unit rack")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let mut material = demand(); + material.physical.as_mut().ok_or(Error::Missing)?.mounting = None; + material.specifications = vec![ + equipment_quantity("width", 500_000, "mm", 3), + equipment_quantity("height", 2_000_000, "mm", 3), + equipment_quantity("depth", 1_000_000, "mm", 3), + ]; + let plan = save_material_plan( + &store, + &actor, + api::PhysicalPlan { + name: "Receive after layout reservation".into(), + materials: vec![material], + steps: vec![api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: None, + enclosure: Some(rack.clone()), + volume: Some(volume()), + material: Some(api::PhysicalMaterialUnit { + material: 0, + unit: u32::MAX - 1, + }), + }, + )), + ..Default::default() + }], + ..Default::default() + }, + )?; + let reserved = reserve(&store, &actor, plan)?.plan.ok_or(Error::Missing)?; + let before = store.usage()?; + let key = api::PhysicalPlanKey { + id: reserved.id.clone().ok_or(Error::Missing)?, + revision: reserved.revision, + }; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(key.clone()), + completed_steps: vec![0], + ..Default::default() + }) + ), + Err(Error::Incomplete) + )); + assert_eq!(store.usage()?, before); + let actual = create(&store, &actor, K::Device, "Received actual unit")?; + equipment_dimensions(&store, &actor, &actual)?; + let occupied = draft(&store, &actor, actual.clone(), rack.clone())?; + assert!(matches!( + reserve(&store, &actor, occupied), + Err(Error::Conflict) + )); + let received = physical( + &store, + &actor, + api::physical::Action::Receive(api::PhysicalMaterialReceipt { + plan: Some(key), + material: 0, + allocation: Some(api::PhysicalMaterialAllocation { + unit: u32::MAX - 1, + item: Some(actual.clone()), + basis: "Received the planned unit".into(), + }), + }), + )? + .plan + .ok_or(Error::Missing)?; + let metadata = store.asset(AssetRef::try_from(&actual).map_err(|_| Error::Invalid)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(actual.clone()), + kind: K::Device as i32, + expected: Some(metadata.revision), + changes: vec![equipment_quantity("height", 2_000_001, "mm", 3)], + ..Default::default() + }), + )?; + let before = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: received.id.clone().ok_or(Error::Missing)?, + revision: received.revision + }), + completed_steps: vec![0], + ..Default::default() + }) + ), + Err(Error::Conflict) + )); + assert_eq!( + store.usage()?, + before, + "An oversized receipt cannot consume its reservation through normal completion" + ); + let metadata = store.asset(AssetRef::try_from(&actual).map_err(|_| Error::Invalid)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(actual.clone()), + kind: K::Device as i32, + expected: Some(metadata.revision), + changes: vec![equipment_quantity("height", 2_000_000, "mm", 3)], + ..Default::default() + }), + )?; + let done = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: received.id.ok_or(Error::Missing)?, + revision: received.revision, + }), + completed_steps: vec![0], + ..Default::default() + }), + )? + .plan + .ok_or(Error::Missing)?; + assert_eq!(done.state, api::PhysicalPlanState::Completed as i32); + let view = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }), + )?; + assert_eq!(view.occupancy.len(), 1); + assert_eq!( + view.occupancy[0] + .placement + .as_ref() + .ok_or(Error::Missing)? + .item, + Some(actual) + ); + assert_eq!( + store.usage()?.rows[omloom_store::Class::Pending as usize], + 0 + ); + Ok(()) +} + +// Another plan's unreceived unit consumes its declared load as well as geometry, without creating an inventory identity. +#[test] +fn future_reservations_account_for_other_plans_material_demands() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Future demand capacity")?; + let mut enclosure = define(rack.clone()); + enclosure.max_mass_g = Some(15); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(enclosure), + }), + )?; + let mut material = demand(); + material.quantity = 1; + material.physical = None; + material.specifications = vec![ + equipment_quantity("width", 500_000, "mm", 3), + equipment_quantity("height", 1_000_000, "mm", 3), + equipment_quantity("depth", 1_000_000, "mm", 3), + equipment_quantity("weight", 10, "g", 0), + ]; + let mut lower = volume(); + lower.y.as_mut().ok_or(Error::Missing)?.length_um = 1_000_000; + let mut plan = api::PhysicalPlan { + name: "First future load".into(), + materials: vec![material], + steps: vec![api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + item: None, + enclosure: Some(rack.clone()), + volume: Some(lower), + material: Some(api::PhysicalMaterialUnit { + material: 0, + unit: 0, + }), + }, + )), + ..Default::default() + }], + ..Default::default() + }; + let first = save_material_plan(&store, &actor, plan.clone())?; + reserve(&store, &actor, first)?; + // A disjoint offered region cannot hide the load retained elsewhere in the enclosure. + let mut reduced = define(rack.clone()); + reduced.max_mass_g = Some(5); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(reduced), + }), + )?; + let mut offered_region = volume(); + offered_region.y.as_mut().ok_or(Error::Missing)?.start_um = 1_000_000; + offered_region.y.as_mut().ok_or(Error::Missing)?.length_um = 1_000_000; + let offered = physical( + &store, + &actor, + api::physical::Action::Offer(api::PhysicalOfferEdit { + offer: Some(api::PhysicalOffer { + enclosure: Some(rack.clone()), + region: Some(offered_region), + recipient: "capacity-reader".into(), + name: "Disjoint remaining region".into(), + mass: true, + ..Default::default() + }), + }), + )? + .offers + .remove(0); + let before = store.usage()?; + let availability = physical( + &store, + &actor, + api::physical::Action::Availability(api::PhysicalOfferRead { + id: offered.id.ok_or(Error::Missing)?, + }), + )?; + assert_eq!(availability.state, api::RequirementState::Failed as i32); + assert_eq!(store.usage()?, before); + let mut restored = define(rack.clone()); + restored.max_mass_g = Some(15); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(restored), + }), + )?; + plan.name = "Second future load".into(); + let Some(api::physical_step::Action::Placement(placement)) = &mut plan.steps[0].action else { + return Err(Error::Corrupt.into()); + }; + placement + .volume + .as_mut() + .ok_or(Error::Missing)? + .y + .as_mut() + .ok_or(Error::Missing)? + .start_um = 1_000_000; + let overweight = save_material_plan(&store, &actor, plan.clone())?; + let before = store.usage()?; + assert!(matches!( + reserve(&store, &actor, overweight), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + plan.materials[0] + .specifications + .retain(|f| f.name != "weight"); + plan.materials[0] + .specifications + .push(equipment_quantity("weight", 5, "g", 0)); + let exact = save_material_plan(&store, &actor, plan)?; + assert_eq!( + reserve(&store, &actor, exact)? + .plan + .ok_or(Error::Missing)? + .state, + api::PhysicalPlanState::Reserved as i32 + ); + Ok(()) +} + +// Receiving real hardware replaces the hypothetical load used by other plans' admission. +#[test] +fn received_units_replace_declared_loads_in_other_reservations() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Received load rack")?; + let mut facts = define(rack.clone()); + facts.max_mass_g = Some(15); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(facts), + }), + )?; + let material = api::PhysicalMaterial { + name: "Ten gram requested unit".into(), + quantity: 1, + kind: K::Device as i32, + specifications: vec![ + equipment_quantity("width", 500_000, "mm", 3), + equipment_quantity("height", 1_000_000, "mm", 3), + equipment_quantity("depth", 1_000_000, "mm", 3), + equipment_quantity("weight", 10, "g", 0), + ], + ..Default::default() + }; + let mut region = volume(); + region.y.as_mut().ok_or(Error::Missing)?.length_um = 1_000_000; + let mut proposal = api::PhysicalPlan { + name: "Reserved requested unit".into(), + materials: vec![material], + steps: vec![api::PhysicalStep { + action: Some(api::physical_step::Action::Placement( + api::PhysicalPlacement { + enclosure: Some(rack.clone()), + volume: Some(region), + material: Some(api::PhysicalMaterialUnit { + material: 0, + unit: 0, + }), + item: None, + }, + )), + ..Default::default() + }], + ..Default::default() + }; + let reserved = reserve( + &store, + &actor, + save_material_plan(&store, &actor, proposal.clone())?, + )? + .plan + .ok_or(Error::Missing)?; + let actual = create(&store, &actor, K::Device, "Twenty gram received unit")?; + let asset = store.asset(AssetRef::try_from(&actual).map_err(|_| Error::Invalid)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(actual.clone()), + kind: K::Device as i32, + expected: Some(asset.revision), + changes: vec![ + equipment_quantity("width", 500_000, "mm", 3), + equipment_quantity("height", 1_000_000, "mm", 3), + equipment_quantity("depth", 1_000_000, "mm", 3), + equipment_quantity("weight", 20, "g", 0), + ], + ..Default::default() + }), + )?; + let received = physical( + &store, + &actor, + api::physical::Action::Receive(api::PhysicalMaterialReceipt { + plan: Some(api::PhysicalPlanKey { + id: reserved.id.ok_or(Error::Missing)?, + revision: reserved.revision, + }), + material: 0, + allocation: Some(api::PhysicalMaterialAllocation { + unit: 0, + item: Some(actual), + basis: "Actual receipt, heavier than requested".into(), + }), + }), + )? + .plan + .ok_or(Error::Missing)?; + proposal.materials[0] + .specifications + .retain(|f| f.name != "weight"); + proposal.materials[0] + .specifications + .push(equipment_quantity("weight", 5, "g", 0)); + let Some(api::physical_step::Action::Placement(placement)) = &mut proposal.steps[0].action + else { + return Err(Error::Corrupt.into()); + }; + placement + .volume + .as_mut() + .ok_or(Error::Missing)? + .y + .as_mut() + .ok_or(Error::Missing)? + .start_um = 1_000_000; + let second = save_material_plan(&store, &actor, proposal)?; + let before = store.usage()?; + assert!(matches!( + reserve(&store, &actor, second), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + let completion = api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: received.id.clone().ok_or(Error::Missing)?, + revision: received.revision, + }), + completed_steps: vec![0], + ..Default::default() + }; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(completion.clone()) + ), + Err(Error::Conflict) + )); + assert_eq!( + store.usage()?, + before, + "Overload rejection must preserve the receipt and reservation atomically" + ); + let actual = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + actual_state: true, + discrepancy: Some( + "Installed actual heavier unit; capacity requires reconciliation".into(), + ), + ..completion + }), + )? + .plan + .ok_or(Error::Missing)?; + assert_eq!(actual.state, api::PhysicalPlanState::Completed as i32); + Ok(()) +} + +// Late partial receipt uses the same serialized locks; cancellation and expiry do not release unresolved work. +#[test] +fn received_materials_share_reservation_and_resolution_lifecycle() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Receiving rack")?; + let item = create(&store, &actor, K::Device, "Planned node")?; + let spare = create(&store, &actor, K::Device, "Late received node")?; + equipment_dimensions(&store, &actor, &item)?; + equipment_dimensions(&store, &actor, &spare)?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let mut plan = draft(&store, &actor, item.clone(), rack.clone())?; + let mut line = demand(); + line.quantity = 3; + line.allocations.push(api::PhysicalMaterialAllocation { + unit: 0, + item: Some(item.clone()), + basis: "Selected node for the installation step".into(), + }); + plan.materials.push(line); + let plan = save_material_plan(&store, &actor, plan)?; + let baseline = store.usage()?.rows[omloom_store::Class::Pending as usize]; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Missing)?; + let request = api::PhysicalMaterialReceipt { + plan: Some(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Missing)?, + revision: plan.revision, + }), + material: 0, + allocation: Some(api::PhysicalMaterialAllocation { + unit: 2, + item: Some(spare.clone()), + basis: "Late physical receipt".into(), + }), + }; + let received = physical( + &store, + &actor, + api::physical::Action::Receive(request.clone()), + )? + .plan + .ok_or(Error::Missing)?; + assert_eq!(received.state, api::PhysicalPlanState::Reserved as i32); + assert_eq!(received.materials[0].allocations.len(), 2); + let usage = store.usage()?; + let mut repeated = request; + repeated.plan.as_mut().ok_or(Error::Missing)?.revision = received.revision; + assert_eq!( + physical(&store, &actor, api::physical::Action::Receive(repeated))?.plan, + Some(received.clone()) + ); + assert_eq!(store.usage()?, usage); + let substitution = api::PhysicalMaterialReceipt { + plan: Some(api::PhysicalPlanKey { + id: received.id.clone().ok_or(Error::Missing)?, + revision: received.revision, + }), + material: 0, + allocation: Some(api::PhysicalMaterialAllocation { + unit: 0, + item: Some(spare.clone()), + basis: "Different resource would change the accepted step".into(), + }), + }; + assert!(matches!( + physical(&store, &actor, api::physical::Action::Receive(substitution)), + Err(Error::UnsafeTransition) + )); + assert_eq!(store.usage()?, usage); + let other = draft(&store, &actor, spare, rack)?; + assert!(matches!( + reserve(&store, &actor, other), + Err(Error::Conflict) + )); + let cancelled = physical( + &store, + &actor, + api::physical::Action::Cancel(api::PhysicalPlanKey { + id: received.id.clone().ok_or(Error::Missing)?, + revision: received.revision, + }), + )? + .plan + .ok_or(Error::Missing)?; + // Only the unresolved installation's item and geometry remain held after cancellation. + assert_eq!( + store.usage()?.rows[omloom_store::Class::Pending as usize], + baseline + 2 + ); + let done = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: cancelled.id.ok_or(Error::Missing)?, + revision: cancelled.revision, + }), + not_performed_steps: vec![0], + ..Default::default() + }), + )? + .plan + .ok_or(Error::Missing)?; + assert_eq!(done.materials[0].allocations.len(), 2); + assert_eq!( + store.usage()?.rows[omloom_store::Class::Pending as usize], + baseline + ); + Ok(()) +} + +// Unreceived quantities are scalar intent, not stock rows or adopted inventory. Reloads and equal saves do not append history. +#[test] +fn future_materials_are_sparse_plan_local_and_durable() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let original = api::PhysicalPlan { + name: "Receiving before installation".into(), + materials: vec![demand()], + ..Default::default() + }; + let plan = save_material_plan(&store, &actor, original)?; + assert!(plan.steps.is_empty()); + assert_eq!(plan.materials[0].quantity, u32::MAX); + assert!(plan.materials[0].allocations.is_empty()); + let usage = store.usage()?; + assert_eq!( + physical( + &store, + &actor, + api::physical::Action::Load(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Missing)?, + revision: plan.revision + }) + )? + .plan, + Some(plan.clone()) + ); + let saved = save_material_plan(&store, &actor, plan.clone())?; + assert_eq!(saved, plan); + assert_eq!(store.usage()?, usage); + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Unknown as i32); + assert_eq!(store.usage()?, usage); + let key = api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Missing)?, + revision: plan.revision, + }; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Reserve(api::PhysicalReserve { + plan: Some(key.clone()), + until_ms: 100_000 + }) + ), + Err(Error::Incomplete) + )); + drop(store); + let store = Store::open(dir.path(), policy())?; + assert_eq!( + physical(&store, &actor, api::physical::Action::Load(key))?.plan, + Some(plan) + ); + assert_eq!(store.usage()?, usage); + Ok(()) +} + +// Receiving links an explicitly adopted unit without installing it; replacing a sparse allocation does not retain its previous payload. +#[test] +fn material_receiving_tracks_only_real_partial_allocations() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let spare = create(&store, &actor, K::Device, "Received serialized node")?; + let mut line = demand(); + line.allocations.push(api::PhysicalMaterialAllocation { + unit: u32::MAX - 1, + item: Some(spare.clone()), + basis: "Received and inspected unit".into(), + }); + let mut plan = save_material_plan( + &store, + &actor, + api::PhysicalPlan { + name: "Partial receiving".into(), + materials: vec![line], + ..Default::default() + }, + )?; + assert_eq!(plan.materials[0].allocations.len(), 1); + assert!( + store + .relations(AssetRef::try_from(&spare).map_err(|_| Error::Invalid)?, 32)? + .is_empty() + ); + let usage = store.usage()?; + let mut invalid = plan.clone(); + let duplicate = invalid.materials[0].allocations[0].clone(); + invalid.materials[0].allocations.push(duplicate); + assert!(matches!( + save_material_plan(&store, &actor, invalid), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, usage); + plan.materials[0].allocations.clear(); + let plan = save_material_plan(&store, &actor, plan)?; + assert!(plan.materials[0].allocations.is_empty()); + assert_eq!( + store + .asset(AssetRef::try_from(&spare).map_err(|_| Error::Invalid)?)? + .adoption, + wire::Adoption::Adopted as i32 + ); + Ok(()) +} + +// Future declarations cannot claim present occupancy, complete observed sources, arbitrary serial payloads or invalid supply quantities. +#[test] +fn material_claims_validate_before_persistence() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let usage = store.usage()?; + for variant in 0..4 { + let mut line = demand(); + match variant { + 0 => line.quantity = 0, + 1 => line.specifications = vec![text_field("serial", "Unreceived serial")], + 2 => { + line.physical + .as_mut() + .ok_or(Error::Missing)? + .power_sources_complete = true + } + 3 => { + line.physical + .as_mut() + .ok_or(Error::Missing)? + .occupancy_coverage = Some(Box::new(volume())) + } + _ => unreachable!(), + } + assert!(matches!( + save_material_plan( + &store, + &actor, + api::PhysicalPlan { + name: "Invalid future assertion".into(), + materials: vec![line], + ..Default::default() + } + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, usage); + } + Ok(()) +} + +// Terminal BOM detail shares the existing finite cleanup lifecycle instead of leaving unbounded material rows. +#[test] +fn resolved_material_details_are_reclaimed_by_common_cleanup() -> TestResult { + let dir = tempfile::tempdir()?; + let policy = policy(); + let expiry = 101 + policy.detail_age_ms; + let store = Store::open(dir.path(), policy)?; + let actor = operator(); + let baseline = store.usage()?.rows[omloom_store::Class::Inventory as usize]; + let plan = save_material_plan( + &store, + &actor, + api::PhysicalPlan { + name: "Finished receiving study".into(), + materials: vec![demand()], + ..Default::default() + }, + )?; + let key = api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Missing)?, + revision: plan.revision, + }; + physical(&store, &actor, api::physical::Action::Cancel(key.clone()))?; + assert_eq!( + store.usage()?.rows[omloom_store::Class::Inventory as usize], + baseline + ); + let mut cursor = None; + let mut completed = false; + for _ in 0..32 { + let progress = store.cleanup(expiry, cursor.as_deref())?; + cursor = progress.cursor; + if cursor.is_none() { + completed = true; + break; + } + } + assert!(completed); + assert_eq!(store.usage()?.rows[omloom_store::Class::Detail as usize], 0); + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Load(api::PhysicalPlanKey { revision: 0, ..key }) + ), + Err(Error::Missing) + )); + Ok(()) +} + +// Plan material inspection uses current serial facts and never discloses a denied field or writes a snapshot. +#[test] +fn plan_material_serials_follow_current_field_authority() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Materials rack")?; + let item = create(&store, &actor, K::Device, "Selected serialized server")?; + let current = store.asset(AssetRef::try_from(&item).map_err(|_| Error::Invalid)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(item.clone()), + expected: Some(current.revision), + kind: current.kind, + changes: vec![text_field("serial", "manufacturer-serial-1")], + ..Default::default() + }), + )?; + let plan = draft(&store, &actor, item.clone(), rack)?; + let load = api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }; + let usage = store.usage()?; + let view = physical(&store, &actor, api::physical::Action::Load(load.clone()))?; + let material = view + .items + .iter() + .find(|i| i.target.as_ref() == Some(&item)) + .ok_or(Error::Missing)?; + assert!( + material + .fields + .contains(&text_field("serial", "manufacturer-serial-1")) + ); + assert_eq!(store.usage()?, usage); + let mut restricted = actor; + for grant in &mut restricted.grants { + grant.read = Fields::Selected(vec!["name".into(), "physical".into(), "relations".into()]); + } + let view = physical(&store, &restricted, api::physical::Action::Load(load))?; + assert!( + view.items + .iter() + .all(|i| i.fields.iter().all(|f| f.name != "serial")) + ); + assert_eq!(store.usage()?, usage); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/mounting.rs b/crates/omloom-store/tests/workspace/physical/mounting.rs new file mode 100644 index 0000000..1df1ae3 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/mounting.rs @@ -0,0 +1,460 @@ +use super::*; + +// Changed slot budgets must reject ordinary completion atomically, while explicit facts survive. +#[test] +fn component_completion_rechecks_current_load_limits() -> TestResult { + for selected in 0..3 { + for actual_state in [false, true] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Load owner")?; + let position = position_fixture( + &store, + &actor, + owner, + "disk/1", + api::PhysicalComponentKind::Disk, + )?; + let old = create(&store, &actor, K::PhysicalComponent, "Previous drive")?; + let incoming = create(&store, &actor, K::PhysicalComponent, "Incoming drive")?; + install_component(&store, &actor, position.clone(), old.clone())?; + equipment_dimensions(&store, &actor, &incoming)?; + let item = api::PhysicalDefinition { + target: Some(incoming.clone()), + basis: "Accepted drive requirements".into(), + component_kind: api::PhysicalComponentKind::Disk as i32, + mounting: Some("drive-carrier-v1".into()), + demand_mw: Some(20), + cooling_mw: Some(20), + ..Default::default() + }; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(item.clone()), + }), + )?; + let asset = store.asset(AssetRef::try_from(&incoming)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(incoming.clone()), + kind: K::PhysicalComponent as i32, + expected: Some(asset.revision), + changes: vec![equipment_quantity("weight", 20, "g", 0)], + ..Default::default() + }), + )?; + let mut slot = define(position.clone()); + slot.mounting = Some("drive-carrier-v1".into()); + slot.max_mass_g = (selected == 0).then_some(20); + slot.capacity_mw = (selected == 1).then_some(20); + slot.cooling_capacity_mw = (selected == 2).then_some(20); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(slot.clone()), + }), + )?; + let previous = binding(&store, &old)?; + let plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(old.clone()), + replacement: Some(incoming.clone()), + previous_binding: Some(previous.relation_id.clone()), + previous_revision: Some(previous.revision), + ..Default::default() + }], + )?; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let completion = api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: vec![0], + ..Default::default() + }; + let mut changed = slot.clone(); + changed.max_mass_g = (selected == 0).then_some(10); + changed.capacity_mw = (selected == 1).then_some(10); + changed.cooling_capacity_mw = (selected == 2).then_some(10); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(changed), + }), + )?; + let before = store.usage()?; + assert!( + matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(completion.clone()) + ), + Err(Error::Conflict) + ), + "Changed budget {selected}" + ); + assert_eq!(store.usage()?, before); + assert_eq!(binding(&store, &old)?, previous); + + let mut unknown = item.clone(); + if selected == 1 { + unknown.demand_mw = None; + } + if selected == 2 { + unknown.cooling_mw = None; + } + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(unknown), + }), + )?; + if selected == 0 { + let asset = store.asset(AssetRef::try_from(&incoming)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(incoming.clone()), + kind: K::PhysicalComponent as i32, + expected: Some(asset.revision), + changes: vec![wire::Field { + name: "weight".into(), + value: Some(wire::field::Value::Clear( + wire::NullValue::NullValue as i32, + )), + }], + ..Default::default() + }), + )?; + } + let before = store.usage()?; + assert!( + matches!( + physical( + &store, + &actor, + api::physical::Action::Complete(completion.clone()) + ), + Err(Error::Incomplete) + ), + "Unknown demand {selected}" + ); + assert_eq!(store.usage()?, before); + if !actual_state { + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(slot), + }), + )?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(item), + }), + )?; + if selected == 0 { + let asset = store.asset(AssetRef::try_from(&incoming)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(incoming.clone()), + kind: K::PhysicalComponent as i32, + expected: Some(asset.revision), + changes: vec![equipment_quantity("weight", 20, "g", 0)], + ..Default::default() + }), + )?; + } + } + let recorded = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + actual_state, + discrepancy: actual_state + .then(|| "Reported real work despite unresolved admission".into()), + ..completion + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!( + recorded.steps[0].state, + api::PhysicalStepState::Recorded as i32 + ); + assert_eq!(binding(&store, &incoming)?.to, Some(position)); + assert!( + !store + .relations(AssetRef::try_from(&old)?, 32)? + .iter() + .any(|r| r.kind == wire::RelationKind::InstalledIn as i32) + ); + } + } + Ok(()) +} + +// Slot-only replacements enforce each declared load budget even without a placement volume. +#[test] +fn component_replacement_checks_mass_power_and_cooling_limits() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Capacity owner")?; + let position = position_fixture( + &store, + &actor, + owner, + "disk/1", + api::PhysicalComponentKind::Disk, + )?; + let old = create(&store, &actor, K::PhysicalComponent, "Previous drive")?; + let incoming = create( + &store, + &actor, + K::PhysicalComponent, + "Oversized drive demand", + )?; + install_component(&store, &actor, position.clone(), old.clone())?; + equipment_dimensions(&store, &actor, &incoming)?; + let old_binding = binding(&store, &old)?; + let plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(old), + replacement: Some(incoming.clone()), + previous_binding: Some(old_binding.relation_id), + previous_revision: Some(old_binding.revision), + ..Default::default() + }], + )?; + for selected in 0..3 { + let mut slot = define(position.clone()); + slot.mounting = Some("drive-carrier-v1".into()); + slot.max_mass_g = (selected == 0).then_some(10); + slot.capacity_mw = (selected == 1).then_some(10); + slot.cooling_capacity_mw = (selected == 2).then_some(10); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(slot), + }), + )?; + let actual = api::PhysicalDefinition { + target: Some(incoming.clone()), + basis: "Accepted drive requirements".into(), + component_kind: api::PhysicalComponentKind::Disk as i32, + mounting: Some("drive-carrier-v1".into()), + demand_mw: Some(20), + cooling_mw: Some(20), + ..Default::default() + }; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(actual.clone()), + }), + )?; + let asset = store.asset(AssetRef::try_from(&incoming)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(incoming.clone()), + kind: K::PhysicalComponent as i32, + expected: Some(asset.revision), + changes: vec![equipment_quantity("weight", 20, "g", 0)], + ..Default::default() + }), + )?; + let before = store.usage()?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!( + preview.state, + api::RequirementState::Failed as i32, + "Selected budget {selected}" + ); + assert!(matches!( + reserve(&store, &actor, plan.clone()), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + let mut unknown = actual; + unknown.demand_mw = None; + unknown.cooling_mw = None; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(unknown.clone()), + }), + )?; + let asset = store.asset(AssetRef::try_from(&incoming)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(incoming.clone()), + kind: K::PhysicalComponent as i32, + expected: Some(asset.revision), + changes: vec![wire::Field { + name: "weight".into(), + value: Some(wire::field::Value::Clear(wire::NullValue::NullValue as i32)), + }], + ..Default::default() + }), + )?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Unknown as i32); + unknown.demand_mw = Some(10); + unknown.cooling_mw = Some(10); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(unknown), + }), + )?; + let asset = store.asset(AssetRef::try_from(&incoming)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(incoming.clone()), + kind: K::PhysicalComponent as i32, + expected: Some(asset.revision), + changes: vec![equipment_quantity("weight", 10, "g", 0)], + ..Default::default() + }), + )?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Satisfied as i32); + } + Ok(()) +} + +// Geometry alone cannot satisfy a declared rail requirement; preview and reservation share admission. +#[test] +fn rack_mounting_requirements_gate_preview_and_reservation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Mounting rack")?; + let item = create(&store, &actor, K::Device, "Rail-mounted server")?; + equipment_dimensions(&store, &actor, &item)?; + let mut rack_facts = define(rack.clone()); + rack_facts.mounting = Some("qualified-rail-A".into()); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(rack_facts), + }), + )?; + let mut item_facts = api::PhysicalDefinition { + target: Some(item.clone()), + basis: "Manufacturer rail specification".into(), + mounting: Some("qualified-rail-B".into()), + ..Default::default() + }; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(item_facts.clone()), + }), + )?; + let plan = draft(&store, &actor, item, rack)?; + let before = store.usage()?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Failed as i32); + assert!(matches!( + reserve(&store, &actor, plan.clone()), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + item_facts.mounting = None; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(item_facts.clone()), + }), + )?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Unknown as i32); + assert!(matches!( + reserve(&store, &actor, plan.clone()), + Err(Error::Incomplete) + )); + item_facts.mounting = Some("qualified-rail-A".into()); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(item_facts), + }), + )?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(plan.clone()), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Satisfied as i32); + reserve(&store, &actor, plan)?; + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/occupancy.rs b/crates/omloom-store/tests/workspace/physical/occupancy.rs new file mode 100644 index 0000000..2bdc032 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/occupancy.rs @@ -0,0 +1,241 @@ +use super::*; + +// A resolved step's released lock may be reused without blocking the original plan's remaining work. +#[test] +fn resolved_resource_reuse_does_not_block_completion_recheck_or_cancellation() -> TestResult { + for cancel in [false, true] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::Rack, "First enclosure")?; + let b = create(&store, &actor, K::Rack, "Second enclosure")?; + for rack in [&a, &b] { + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + } + let first = create(&store, &actor, K::Device, "First installation")?; + let second = create(&store, &actor, K::Device, "Independent installation")?; + for item in [&first, &second] { + equipment_dimensions(&store, &actor, item)?; + } + let mut plan = draft(&store, &actor, first.clone(), a.clone())?; + let mut step = plan.steps[0].clone(); + let Some(api::physical_step::Action::Placement(p)) = &mut step.action else { + return Err(Error::Corrupt.into()); + }; + p.item = Some(second); + p.enclosure = Some(b); + plan.steps.push(step); + let plan = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + let plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + let mut plan = report_steps(&store, &actor, &plan, vec![0])?; + let old = binding(&store, &first)?; + let removal = api::PhysicalReplacement { + position: Some(a), + previous: Some(first.clone()), + previous_binding: Some(old.relation_id), + previous_revision: Some(old.revision), + ..Default::default() + }; + let next = replacement_plan(&store, &actor, vec![removal.clone()])?; + let next = reserve(&store, &actor, next)?.plan.ok_or(Error::Corrupt)?; + let rechecked = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + ..Default::default() + }), + ); + assert!( + rechecked.is_ok(), + "a reused lock must not block a postcondition recheck" + ); + plan = rechecked?.plan.ok_or(Error::Corrupt)?; + if cancel { + plan = physical( + &store, + &actor, + api::physical::Action::Cancel(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + )? + .plan + .ok_or(Error::Corrupt)?; + } + let resolved = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + completed_steps: if cancel { vec![] } else { vec![1] }, + not_performed_steps: if cancel { vec![1] } else { vec![] }, + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!( + resolved.steps[0].state, + api::PhysicalStepState::Recorded as i32 + ); + assert_eq!( + resolved.steps[1].state, + if cancel { + api::PhysicalStepState::NotPerformed + } else { + api::PhysicalStepState::Recorded + } as i32 + ); + let competitor = replacement_plan(&store, &actor, vec![removal])?; + let before = store.usage()?; + assert!(matches!( + reserve(&store, &actor, competitor), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + assert_eq!(next.state, api::PhysicalPlanState::Reserved as i32); + } + Ok(()) +} + +// Large enclosures remain readable through bounded pages rather than failing the entire projection. +#[test] +fn occupancy_beyond_128_parts_returns_a_bounded_first_page() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Large enclosure")?; + for index in 0..129 { + let item = create( + &store, + &actor, + K::Device, + &format!("Installed unit {index}"), + )?; + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(item), + to: Some(rack.clone()), + kind: wire::RelationKind::InstalledIn as i32, + ..Default::default() + }), + ..Default::default() + }), + )?; + } + let before = store.usage()?; + let page = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: true, + after: None, + }), + )?; + assert_eq!(page.occupancy.len(), 25); + assert!(page.next.is_some()); + let mut seen: BTreeSet<_> = page + .occupancy + .iter() + .filter_map(|v| v.binding.as_ref().map(|v| v.relation_id.clone())) + .collect(); + let first_cursor = page.next.clone(); + let mut after = page.next; + let mut sizes = vec![page.occupancy.len()]; + while let Some(cursor) = after { + let page = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: true, + after: Some(cursor), + }), + )?; + sizes.push(page.occupancy.len()); + for value in page.occupancy { + assert!(seen.insert(value.binding.ok_or(Error::Corrupt)?.relation_id)); + } + after = page.next; + } + assert_eq!(sizes, vec![25, 25, 25, 25, 25, 4]); + assert_eq!(seen.len(), 129); + let mut restricted = actor.clone(); + restricted.grants[0].read = Fields::Selected(vec!["physical".into(), "name".into()]); + let private_page = physical( + &store, + &restricted, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: true, + after: None, + }), + )?; + assert_eq!( + private_page + .next + .as_deref() + .and_then(|v| v.rsplit(':').next()) + .and_then(|v| v.parse::().ok()), + Some(25), + "Continuation must not disclose a withheld binding ID" + ); + assert!( + private_page + .occupancy + .iter() + .all(|v| v.binding.is_none() && v.revision == 0) + ); + assert_eq!(store.usage()?, before); + let mut changed = actor.clone(); + changed.authority_revision += 1; + assert!(matches!( + physical( + &store, + &changed, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack.clone()), + occupants: true, + after: first_cursor.clone() + }) + ), + Err(Error::Expired) + )); + create(&store, &actor, K::Device, "Revision change")?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: first_cursor + }) + ), + Err(Error::Expired) + )); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/power.rs b/crates/omloom-store/tests/workspace/physical/power.rs new file mode 100644 index 0000000..51f77ae --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/power.rs @@ -0,0 +1,382 @@ +use super::*; + +fn survey(store: &Store, actor: &Context, resource: &wire::AssetRef) -> TestResult { + let definition = api::PhysicalDefinition { + target: Some(resource.clone()), + basis: "Reviewed current upstream supply connections".into(), + power_sources_complete: true, + ..Default::default() + }; + let view = physical( + store, + actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition.clone()), + }), + )?; + assert_eq!(view.definitions, vec![definition]); + Ok(()) +} +fn supply( + store: &Store, + actor: &Context, + from: &wire::AssetRef, + to: &wire::AssetRef, +) -> TestResult { + call( + store, + actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(from.clone()), + to: Some(to.clone()), + kind: wire::RelationKind::PoweredBy as i32, + ..Default::default() + }), + remove: false, + }), + )?; + Ok(()) +} +fn evaluate( + store: &Store, + actor: &Context, + sources: &[wire::AssetRef], + boundaries: &[wire::AssetRef], +) -> Result { + let result = call( + store, + actor, + Command::EvaluateRequirements(api::EvaluateRequirements { + anchor: sources.first().cloned(), + nodes: vec![api::RequirementNode { + expression: Some(api::requirement_node::Expression::PowerPaths( + api::PowerPathRequirement { + sources: sources.into(), + boundaries: boundaries.into(), + }, + )), + }], + root: 0, + }), + )?; + match result { + Reply::RequirementEvaluation(v) => Ok(v), + _ => Err(Error::Corrupt), + } +} + +// Feed names and absent edges do not qualify independent paths; only complete bounded paths do. +#[test] +fn power_path_checks_use_current_scoped_topology_without_snapshots() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::PowerFeed, "Feed A")?; + let b = create(&store, &actor, K::PowerFeed, "Feed B")?; + let root_a = create(&store, &actor, K::PowerFeed, "Terminal A")?; + let root_b = create(&store, &actor, K::PowerFeed, "Terminal B")?; + let sources = [a.clone(), b.clone()]; + assert_eq!( + evaluate(&store, &actor, &sources, &sources)?.reason, + api::RequirementReason::Missing as i32 + ); + for resource in [&a, &b] { + survey(&store, &actor, resource)?; + } + assert_eq!( + evaluate(&store, &actor, &sources, &sources)?.state, + api::RequirementState::Satisfied as i32 + ); + assert_eq!( + evaluate( + &store, + &actor, + &[a.clone(), a.clone()], + std::slice::from_ref(&a) + )? + .state, + api::RequirementState::Failed as i32 + ); + supply(&store, &actor, &a, &root_a)?; + supply(&store, &actor, &b, &root_b)?; + assert_eq!( + evaluate(&store, &actor, &sources, &sources)?.reason, + api::RequirementReason::ScopeMismatch as i32 + ); + let terminals = [root_a.clone(), root_b.clone()]; + assert_eq!( + evaluate(&store, &actor, &sources, &terminals)?.reason, + api::RequirementReason::Missing as i32 + ); + for resource in &terminals { + survey(&store, &actor, resource)?; + } + let before = store.usage()?; + survey(&store, &actor, &root_b)?; + assert_eq!( + store.usage()?, + before, + "Equal source assertions add no rows or audit detail" + ); + assert_eq!( + evaluate(&store, &actor, &sources, &terminals)?.state, + api::RequirementState::Satisfied as i32 + ); + assert_eq!(store.usage()?, before); + supply(&store, &actor, &b, &root_a)?; + let before = store.usage()?; + assert_eq!( + evaluate(&store, &actor, &sources, &terminals)?.state, + api::RequirementState::Failed as i32 + ); + let mut limited = actor.clone(); + limited.grants[0].scope = Scope::Targets(vec![ + AssetRef::try_from(&a)?, + AssetRef::try_from(&b)?, + AssetRef::try_from(&root_b)?, + ]); + let denied = evaluate(&store, &limited, &sources, &terminals)?; + assert_eq!(denied.reason, api::RequirementReason::Restricted as i32); + assert!( + denied + .results + .iter() + .all(|r| r.target.is_none() && r.evidence.is_empty()) + ); + limited = actor.clone(); + limited.grants[0].read = Fields::Selected(vec!["name".into(), "relations".into()]); + assert_eq!( + evaluate(&store, &limited, &sources, &terminals)?.reason, + api::RequirementReason::Restricted as i32 + ); + assert_eq!(store.usage()?, before); + drop(store); + let store = Store::open(dir.path(), policy())?; + assert_eq!( + evaluate(&store, &actor, &sources, &terminals)?.state, + api::RequirementState::Failed as i32 + ); + assert_eq!(store.usage()?, before); + Ok(()) +} + +// A shared physical UPS is a real dependency, not another duplicated power-feed inventory. +#[test] +fn feed_paths_follow_existing_hardware_and_input_ports() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::PowerFeed, "Rack feed A")?; + let b = create(&store, &actor, K::PowerFeed, "Rack feed B")?; + let ups = create(&store, &actor, K::Device, "One physical UPS")?; + let inlet = create(&store, &actor, K::Port, "UPS input")?; + let utility = create(&store, &actor, K::PowerFeed, "Selected utility boundary")?; + for resource in [&a, &b, &ups, &inlet, &utility] { + survey(&store, &actor, resource)?; + } + for (from, to) in [(&a, &ups), (&b, &ups), (&ups, &inlet), (&inlet, &utility)] { + supply(&store, &actor, from, to)?; + } + let sources = [a.clone(), b.clone()]; + let result = evaluate(&store, &actor, &sources, std::slice::from_ref(&utility))?; + assert_eq!(result.state, api::RequirementState::Failed as i32); + assert_eq!(result.results[0].target, Some(ups.clone())); + let mut limited = actor; + limited.grants[0].scope = Scope::Targets(vec![ + AssetRef::try_from(&a)?, + AssetRef::try_from(&b)?, + AssetRef::try_from(&ups)?, + ]); + let result = evaluate(&store, &limited, &sources, &[utility])?; + assert_eq!( + result.state, + api::RequirementState::Failed as i32, + "A visible shared UPS is a complete counterexample without reading its private upstream network" + ); + assert_eq!(result.results[0].target, Some(ups)); + let partial = evaluate(&store, &limited, &sources, &[])?; + assert_eq!( + partial.state, + api::RequirementState::Failed as i32, + "A known shared UPS needs no invented terminal source to establish the counterexample" + ); + Ok(()) +} + +// Two references to one asset with different incarnations cannot alias one metadata row. +#[test] +fn power_paths_validate_every_requested_incarnation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::PowerFeed, "Live feed")?; + let b = create(&store, &actor, K::PowerFeed, "Different incarnation")?; + survey(&store, &actor, &a)?; + let stale = wire::AssetRef { + asset_id: a.asset_id.clone(), + incarnation: b.incarnation, + }; + let inputs = [a, stale]; + let result = evaluate(&store, &actor, &inputs, &inputs)?; + assert_eq!(result.reason, api::RequirementReason::Restricted as i32); + assert!(result.results.iter().all(|r| r.target.is_none())); + Ok(()) +} + +// Recording manual work evaluates the persisted power postcondition through the common evaluator. +#[test] +fn recorded_step_evaluates_its_power_postcondition() -> TestResult { + assert_recorded_power_postcondition(true) +} + +// Healthy recorded paths must remain satisfied, not inherit a failure-only completion result. +#[test] +fn recorded_step_preserves_a_satisfied_power_postcondition() -> TestResult { + assert_recorded_power_postcondition(false) +} + +fn assert_recorded_power_postcondition(shared_supply: bool) -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Planning rack")?; + let item = create(&store, &actor, K::Device, "Installed server")?; + let feeds = [ + create(&store, &actor, K::PowerFeed, "Terminal feed A")?, + create(&store, &actor, K::PowerFeed, "Terminal feed B")?, + ]; + let terminals = [ + create(&store, &actor, K::PowerFeed, "Independent terminal A")?, + create(&store, &actor, K::PowerFeed, "Independent terminal B")?, + ]; + for feed in feeds.iter().chain(&terminals) { + survey(&store, &actor, feed)?; + } + supply(&store, &actor, &feeds[0], &terminals[0])?; + supply(&store, &actor, &feeds[1], &terminals[1])?; + assert_eq!( + evaluate(&store, &actor, &feeds, &terminals)?.state, + api::RequirementState::Satisfied as i32 + ); + let mut plan = draft(&store, &actor, item, rack.clone())?; + plan.steps[0].control = Some(api::PlanControl { + check: Some(api::EvaluateRequirements { + anchor: Some(rack), + nodes: vec![api::RequirementNode { + expression: Some(api::requirement_node::Expression::PowerPaths( + api::PowerPathRequirement { + sources: feeds.to_vec(), + boundaries: terminals.to_vec(), + }, + )), + }], + root: 0, + }), + ..Default::default() + }); + let saved = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + if shared_supply { + supply(&store, &actor, &feeds[1], &terminals[0])?; + } + let expected = evaluate(&store, &actor, &feeds, &terminals)?; + assert_eq!( + expected.state, + if shared_supply { + api::RequirementState::Failed + } else { + api::RequirementState::Satisfied + } as i32 + ); + let recorded = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: saved.id.ok_or(Error::Corrupt)?, + revision: saved.revision, + }), + completed_steps: vec![0], + actual_state: true, + discrepancy: Some("Recorded manually completed installation".into()), + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!( + recorded.steps[0].state, + api::PhysicalStepState::Recorded as i32 + ); + assert_eq!(recorded.steps[0].checked_state, Some(expected.state)); + assert_eq!(recorded.steps[0].checked_reason, Some(expected.reason)); + Ok(()) +} + +// The typed power predicate survives the existing plan codec without an early source/geometry gate. +#[test] +fn power_postcondition_uses_the_common_persisted_plan() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Unmeasured planning rack")?; + let item = create(&store, &actor, K::Device, "Partly specified server")?; + let a = create(&store, &actor, K::PowerFeed, "Unqualified feed A")?; + let b = create(&store, &actor, K::PowerFeed, "Unqualified feed B")?; + let mut plan = draft(&store, &actor, item, rack.clone())?; + let check = api::EvaluateRequirements { + anchor: Some(rack), + nodes: vec![api::RequirementNode { + expression: Some(api::requirement_node::Expression::PowerPaths( + api::PowerPathRequirement { + sources: vec![a, b], + boundaries: vec![], + }, + )), + }], + root: 0, + }; + plan.steps[0].control = Some(api::PlanControl { + check: Some(check.clone()), + ..Default::default() + }); + let saved = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!( + saved.steps[0] + .control + .as_ref() + .and_then(|c| c.check.as_ref()), + Some(&check) + ); + let key = api::PhysicalPlanKey { + id: saved.id.ok_or(Error::Corrupt)?, + revision: saved.revision, + }; + drop(store); + let store = Store::open(dir.path(), policy())?; + let before = store.usage()?; + let loaded = physical(&store, &actor, api::physical::Action::Load(key))? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!( + loaded.steps[0] + .control + .as_ref() + .and_then(|c| c.check.as_ref()), + Some(&check) + ); + assert_eq!(store.usage()?, before); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/rack_grid.rs b/crates/omloom-store/tests/workspace/physical/rack_grid.rs new file mode 100644 index 0000000..1579b9a --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/rack_grid.rs @@ -0,0 +1,122 @@ +use super::*; + +// A cabinet's height cannot silently supply its rail offset, count or numbering. +#[test] +fn rack_grid_survives_reopen_without_per_unit_records_or_repeat_writes() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Offset rail rack")?; + let mut facts = define(rack.clone()); + facts.rack_grid = Some(Box::new(api::PhysicalRackGrid { + top_um: 30_000, + unit_count: 42, + first_unit: 10, + direction: api::PhysicalRackDirection::BottomUp as i32, + })); + let save = api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(facts.clone()), + }); + physical(&store, &actor, save.clone())?; + let usage = store.usage()?; + physical(&store, &actor, save)?; + assert_eq!(store.usage()?, usage); + drop(store); + let store = Store::open(dir.path(), policy())?; + let read = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: false, + after: None, + }), + )?; + assert_eq!(read.definitions[0], facts); + assert_eq!(store.usage()?, usage); + Ok(()) +} + +// Invalid source grids are refused before replacing the current physical facts. +#[test] +fn rack_grid_rejects_invalid_numbering_overflow_and_outside_known_envelope() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Validated rail rack")?; + let mut facts = define(rack); + let valid = api::PhysicalRackGrid { + top_um: 0, + unit_count: 42, + first_unit: 1, + direction: api::PhysicalRackDirection::TopDown as i32, + }; + for grid in [ + api::PhysicalRackGrid { + unit_count: 0, + ..valid + }, + api::PhysicalRackGrid { + first_unit: 0, + ..valid + }, + api::PhysicalRackGrid { + direction: 0, + ..valid + }, + api::PhysicalRackGrid { + direction: 99, + ..valid + }, + api::PhysicalRackGrid { + first_unit: u32::MAX, + ..valid + }, + api::PhysicalRackGrid { + top_um: i64::MAX, + ..valid + }, + api::PhysicalRackGrid { + top_um: -1, + ..valid + }, + api::PhysicalRackGrid { + unit_count: 46, + ..valid + }, + ] { + facts.rack_grid = Some(Box::new(grid)); + let usage = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(facts.clone()) + }) + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, usage); + } + facts.target = Some(create( + &store, + &actor, + K::Device, + "A chassis is not a rack rail grid", + )?); + facts.rack_grid = Some(Box::new(valid)); + let usage = store.usage()?; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(facts), + }) + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, usage); + Ok(()) +} diff --git a/crates/omloom-store/tests/workspace/physical/reviews.rs b/crates/omloom-store/tests/workspace/physical/reviews.rs new file mode 100644 index 0000000..95281f4 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/reviews.rs @@ -0,0 +1,466 @@ +use super::*; +use prost::Message; +use sha2::{Digest, Sha256}; + +// Reordered or repeated roots describe the same disclosure scope and one current contribution. +#[test] +fn replacement_feedback_scope_canonicalizes_root_identity_sets() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Enclosure")?; + let extra = create(&store, &actor, K::Device, "Additional selected root")?; + let (slot, old) = mountable_component_fixture(&store, &actor, owner.clone(), "disk-bay")?; + let incoming = create( + &store, + &actor, + K::PhysicalComponent, + "Unqualified replacement", + )?; + let previous = binding(&store, &old)?; + let plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(slot.clone()), + previous: Some(old.clone()), + replacement: Some(incoming.clone()), + previous_binding: Some(previous.relation_id), + previous_revision: Some(previous.revision), + ..Default::default() + }], + )?; + let scoped = |actor: &Context, + roots: Vec, + action: api::physical::Action| + -> Result { + let revision = physical( + &store, + actor, + api::physical::Action::List(api::PhysicalPlanList::default()), + )? + .revision; + let reply = store.workspace( + actor, + api::Request { + command: Some(Command::Physical(api::Physical { + action: Some(action), + })), + working: Some(api::WorkingContext { + roots, + expected: Some(revision), + ..Default::default() + }), + }, + 100, + )?; + match reply.result { + Some(Reply::Physical(view)) => Ok(view), + _ => Err(Error::Corrupt), + } + }; + let roots = vec![owner.clone(), slot, old, incoming, extra.clone()]; + let inspect = |plan: &api::PhysicalPlan| api::PhysicalReplacementReview { + plan: Some(api::PhysicalPlanKey { + id: plan.id.clone().expect("saved plan"), + revision: plan.revision, + }), + step: 0, + ..Default::default() + }; + let explanation = scoped( + &actor, + roots.clone(), + api::physical::Action::ReviewReplacement(inspect(&plan)), + )?; + let judgment = api::PhysicalReplacementReview { + decision: Some(api::PhysicalReplacementDecision::Confirmed as i32), + basis: Some( + explanation + .replacement_hypothesis + .ok_or(Error::Corrupt)? + .basis, + ), + ..inspect(&plan) + }; + let accepted = scoped( + &actor, + roots.clone(), + api::physical::Action::ReviewReplacement(judgment), + )? + .plan + .ok_or(Error::Corrupt)?; + // The optimized hash must retain the canonical protobuf representation of each identity. + let mut reference = Sha256::new(); + reference.update(actor.authority_revision.to_le_bytes()); + reference.update(Sha256::digest( + format!("{:?}/{:?}", actor.grants[0].scope, actor.grants[0].read).as_bytes(), + )); + let mut encoded: Vec<_> = roots.iter().map(Message::encode_to_vec).collect(); + encoded.sort_unstable(); + encoded.dedup(); + for root in encoded { + reference.update(root); + } + let Some(api::physical_step::Action::Replacement(replacement)) = &accepted.steps[0].action + else { + return Err(Error::Corrupt.into()); + }; + assert_eq!( + replacement.feedback.as_ref().ok_or(Error::Corrupt)?.scope, + format!("{:x}", reference.finalize()) + ); + let mut reversed = roots.clone(); + reversed.reverse(); + let quality_action = || { + api::physical::Action::ReplacementQuality(api::PhysicalReplacementQualityQuery::default()) + }; + let before = store.usage()?; + let counts = scoped(&actor, reversed.clone(), quality_action())?; + assert_eq!( + counts + .replacement_quality + .iter() + .map(|v| v.count) + .sum::(), + 1 + ); + assert_eq!(store.usage()?, before); + let explanation = scoped( + &actor, + reversed.clone(), + api::physical::Action::ReviewReplacement(inspect(&accepted)), + )?; + let corrected = api::PhysicalReplacementReview { + decision: Some(api::PhysicalReplacementDecision::Rejected as i32), + basis: Some( + explanation + .replacement_hypothesis + .ok_or(Error::Corrupt)? + .basis, + ), + ..inspect(&accepted) + }; + scoped( + &actor, + reversed.clone(), + api::physical::Action::ReviewReplacement(corrected), + )?; + reversed.extend(roots.clone()); + let counts = scoped(&actor, reversed, quality_action())?.replacement_quality; + assert_eq!(counts.iter().map(|v| v.count).sum::(), 1); + assert!( + counts + .iter() + .all(|v| v.decision == api::PhysicalReplacementDecision::Rejected as i32) + ); + assert!( + scoped(&actor, vec![owner.clone()], quality_action())? + .replacement_quality + .is_empty() + ); + let mut changed = actor.clone(); + changed.authority_revision += 1; + assert!( + scoped(&changed, roots.clone(), quality_action())? + .replacement_quality + .is_empty() + ); + let mut widened = actor.clone(); + let mut second = widened.grants[0].clone(); + second.read = Fields::Selected(vec!["name".into()]); + widened.grants.push(second); + assert!( + scoped(&widened, roots.clone(), quality_action())? + .replacement_quality + .is_empty() + ); + assert!(matches!( + scoped(&actor, vec![owner; MAX_BULK + 1], quality_action()), + Err(Error::Limit) + )); + // Archival changes current visibility without changing the caller's grants or their hash. + let mut retired = store.asset(AssetRef::try_from(&extra)?)?; + let revision = retired.revision; + retired.revision += 1; + retired.adoption = wire::Adoption::Archived as i32; + store.save_asset( + &admit(wire::record::Body::Asset(retired)), + Some(revision), + "fixture", + 100, + )?; + let before = store.usage()?; + assert!(matches!( + scoped(&actor, roots, quality_action()), + Err(Error::Missing) + )); + assert_eq!(store.usage()?, before); + Ok(()) +} + +fn review( + store: &Store, + actor: &Context, + plan: &api::PhysicalPlan, + decision: Option, + basis: Option, +) -> Result { + physical( + store, + actor, + api::physical::Action::ReviewReplacement(api::PhysicalReplacementReview { + plan: Some(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + step: 0, + decision: decision.map(|v| v as i32), + basis, + ..Default::default() + }), + ) +} +fn quality(store: &Store, actor: &Context) -> Result, Error> { + Ok(physical( + store, + actor, + api::physical::Action::ReplacementQuality(api::PhysicalReplacementQualityQuery::default()), + )? + .replacement_quality) +} + +// One current contribution survives retry and correction, without treating deferral as rejection. +#[test] +fn replacement_feedback_is_atomic_scoped_and_expires_without_relabelling() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let owner = create(&store, &actor, K::Device, "Chassis")?; + let position = position_fixture( + &store, + &actor, + owner, + "slot/1", + api::PhysicalComponentKind::Disk, + )?; + let old = create(&store, &actor, K::PhysicalComponent, "Previous disk")?; + let incoming = create( + &store, + &actor, + K::PhysicalComponent, + "Unqualified incoming disk", + )?; + install_component(&store, &actor, position.clone(), old.clone())?; + let predecessor_binding = binding(&store, &old)?; + let plan = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: Some(position.clone()), + previous: Some(old), + replacement: Some(incoming.clone()), + previous_binding: Some(predecessor_binding.relation_id), + previous_revision: Some(predecessor_binding.revision), + ..Default::default() + }], + )?; + let usage = store.usage()?; + let explanation = review(&store, &actor, &plan, None, None)?; + assert_eq!( + store.usage()?, + usage, + "Explaining a hypothesis does not write" + ); + let hypothesis = explanation.replacement_hypothesis.ok_or("hypothesis")?; + assert_eq!( + hypothesis.strength, + api::PhysicalReplacementStrength::Contextual as i32 + ); + let mut accepted = review( + &store, + &actor, + &plan, + Some(api::PhysicalReplacementDecision::Confirmed), + Some(hypothesis.basis.clone()), + )? + .plan + .ok_or("plan")?; + assert_eq!( + quality(&store, &actor)? + .iter() + .map(|v| v.count) + .sum::(), + 1 + ); + let usage = store.usage()?; + let mut auditor = actor.clone(); + auditor.actor = "independent-read-only-auditor".into(); + auditor.fixture = "audit".into(); + auditor.grants[0].writable = false; + auditor.grants[0].write = Fields::Selected(Vec::new()); + assert_eq!( + quality(&store, &auditor)? + .iter() + .map(|v| v.count) + .sum::(), + 1, + "A reader authorized for the complete contributing scope can inspect its aggregate without being its author" + ); + let repeated = review( + &store, + &actor, + &accepted, + Some(api::PhysicalReplacementDecision::Confirmed), + Some(hypothesis.basis.clone()), + )? + .plan + .ok_or("plan")?; + assert_eq!(repeated.revision, accepted.revision); + assert_eq!(store.usage()?, usage); + // Removing and reintroducing a draft event replaces its contribution, never accumulates it. + let original_steps = accepted.steps.clone(); + accepted.steps.clear(); + accepted.materials.push(api::PhysicalMaterial { + name: "Remaining unplaced demand".into(), + quantity: 1, + kind: K::Device as i32, + ..Default::default() + }); + let removed = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(accepted), + }), + )? + .plan + .ok_or("plan")?; + assert!(quality(&store, &actor)?.is_empty()); + let mut restored_event = removed; + restored_event.steps = original_steps; + let Some(api::physical_step::Action::Replacement(r)) = &mut restored_event.steps[0].action + else { + return Err("replacement".into()); + }; + r.feedback = None; + let restored_event = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { + plan: Some(restored_event), + }), + )? + .plan + .ok_or("plan")?; + accepted = review( + &store, + &actor, + &restored_event, + Some(api::PhysicalReplacementDecision::Confirmed), + Some(hypothesis.basis.clone()), + )? + .plan + .ok_or("plan")?; + assert_eq!( + quality(&store, &actor)? + .iter() + .map(|v| v.count) + .sum::(), + 1 + ); + let mut image = Vec::new(); + store.export(&mut image)?; + let restored_dir = tempfile::tempdir()?; + let restored = Store::restore(restored_dir.path(), &image, policy(), image.len() as u64)?; + assert!( + matches!(quality(&restored, &actor), Err(Error::RecoveryRequired)), + "A restored aggregate cannot claim current disclosure or correction state" + ); + assert!( + matches!(binding(&store, &incoming), Err(Error::Corrupt)), + "A decision never installs the incoming part" + ); + let corrected = review( + &store, + &actor, + &accepted, + Some(api::PhysicalReplacementDecision::Deferred), + Some(hypothesis.basis.clone()), + )? + .plan + .ok_or("plan")?; + let counts = quality(&store, &actor)?; + assert_eq!(counts.len(), 1); + assert_eq!( + counts[0].decision, + api::PhysicalReplacementDecision::Deferred as i32 + ); + assert_eq!(counts[0].count, 1); + let mut revoked = actor.clone(); + revoked.authority_revision += 1; + assert!(quality(&store, &revoked)?.is_empty()); + let mut restricted = actor.clone(); + restricted.grants[0].read = Fields::Selected(vec!["name".into()]); + assert!(quality(&store, &restricted)?.is_empty()); + assert!(matches!( + review( + &store, + &actor, + &accepted, + Some(api::PhysicalReplacementDecision::Rejected), + Some(hypothesis.basis.clone()) + ), + Err(Error::Conflict) + )); + let until = counts[0].until_ms; + let request = api::Request { + command: Some(Command::Physical(api::Physical { + action: Some(api::physical::Action::ReplacementQuality( + api::PhysicalReplacementQualityQuery::default(), + )), + })), + working: Some(api::WorkingContext::default()), + }; + let Some(Reply::Physical(expired)) = store.workspace(&actor, request, until)?.result else { + return Err("quality".into()); + }; + assert!( + expired.replacement_quality.is_empty(), + "Expired buckets are hidden before cleanup" + ); + let mut cursor = None; + loop { + let pass = store.cleanup(until, cursor.as_deref())?; + cursor = pass.cursor; + if cursor.is_none() { + break; + } + } + assert!( + quality(&store, &actor)?.is_empty(), + "Cleanup actually removes the bucket" + ); + let late = api::Request { + command: Some(Command::Physical(api::Physical { + action: Some(api::physical::Action::ReviewReplacement( + api::PhysicalReplacementReview { + plan: Some(api::PhysicalPlanKey { + id: corrected.id.ok_or("id")?, + revision: corrected.revision, + }), + step: 0, + decision: Some(api::PhysicalReplacementDecision::Rejected as i32), + basis: Some(hypothesis.basis), + ..Default::default() + }, + )), + })), + working: Some(api::WorkingContext::default()), + }; + assert!( + matches!(store.workspace(&actor, late, until), Err(Error::Expired)), + "Expiry cannot silently count the same event again" + ); + Ok(()) +} diff --git a/docs/inventory.md b/docs/inventory.md index f879679..054d024 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -749,6 +749,153 @@ facets bound to the old placement; those become source-unavailable after its revision changes. Review their administrative source separately in the existing responsibility editor. The new host never silently becomes the owner. +## Physical plans and cable ends + +Physical plans use the same accepted inventory, revision checks and requirement +evaluation as ordinary resource work. Saving a draft does not reserve capacity +or report installation. Reservation and attributed manual completion are separate +commands. Completed effects survive cancellation of remaining work. A required +postcondition can remain unknown after work is reported; an old observation is +not automatically evidence of the new result. + +Recorded power-path separation is another condition in that same evaluator. +Select two feeds and, when known, their terminal boundaries. A visible shared +UPS is a counterexample without needing to read its private upstream supplies. +A positive result requires explicitly complete source lists through the selected +boundaries. Missing boundaries allow counterexamples only; an early boundary +cannot hide known dependencies. Record current source-list coverage in physical +facts and use ordinary Powered by relationships for the actual topology. +These checks do not certify live power, electrical safety or surviving capacity. +The same condition can be saved as a physical step's postcondition. + +Rack elevations project the recorded enclosure frame. Rear viewing mirrors the +horizontal axis and orders reported bodies from far to near; proposed placements +remain explicit overlays. Switching faces changes no inventory coordinates. +The reported coordinate table keeps exact values available, including geometry +that cannot be drawn, in pages of 25 without loading neighboring infrastructure. +Installed parts are fetched one server page at a time. Continuations are bound +to the enclosure, working scope, permissions and inventory revision. A page is +not a claim of complete occupancy or free capacity; admission checks all relevant +current occupants and reservations independently of the displayed page. +An optional sourced rail grid records its top Y coordinate, unit count, lowest +printed number and numbering direction on the current rack profile. Cabinet +height does not supply these facts. Printed U selection follows this declaration +and rounds allocation height outward to half-U increments without changing the +equipment dimensions. Without the declaration, the geometric offset editor and +explicit coordinate drafts remain available and do not claim printed rack units. +Space reuse after a removal requires the corresponding prerequisite in the common +plan. A future removal does not free current occupancy for an independent step. +Uncertain overlap between old and new loads keeps capacity unqualified. Manual +reports record work already performed, including discrepancies, and do not +certify physical fit or dispatch another operation. +An enclosure replacement can include an explicitly reviewed incoming allocation +on that same step. Reporting it commits the new binding and its reported pose +atomically. The previous allocation can be selected as a proposal for review; +it does not supply the incoming dimensions, cable attachment or physical proof. + +Named ends belong to a retained cable. Their definition, accepted destination +intent and reported attachment are distinct. A confirmed loose end releases its +physical binding while preserving intent unless explicitly cleared. An unknown +attachment retains possible occupancy and cannot make that socket available to +another cable. Replacing a component never transfers its cable attachment or +service evidence to the replacement by position or name. + +Cable-end transitions are ordinary steps in a physical plan. Repeated work on +the same end references the earlier step's recorded result. Conflicting socket +claims require a preceding release through the shared dependency graph; a plan +cannot promise a free socket to two ends at once. Retries and reads do not append +connection histories. Endpoint visibility and relationship rights are checked +before displaying or changing the attachment. + +One DAC or AOC is one adopted cable assembly, including inseparable end modules. +Record only its real named ends and known internal channel numbers. Breakout +legs share that assembly identity; each declared channel has at most two ends. +Missing channel data is unknown. These declarations do not certify polarity, +speed, signal continuity or service health. + +Replacement plans retain explicit endpoint judgments separately from the +component event. Unmapped ports remain visible when a smaller replacement has +fewer ports. Whole-port and individual-channel allocations cannot overlap; +accepted breakout lanes require a current sourced port-channel declaration. +Correspondence does not transfer configuration, grants, credentials or wiring. +An exact-binding postcondition uses the common requirement evaluator and fails +when its reviewed relation is removed, recreated or changed. + +The replacement review explains its current evidence using versioned rules and +strength labels, without invented probabilities. Confirm, reject and defer are +attributed judgments, not installation reports. Current decisions contribute +once to fixed-window local quality counts; correcting a decision replaces its +contribution atomically. Deferral is unlabelled, not a negative example. Counts +are available under the complete contributing read-grant and working scope, +including read-only auditors. Changing authority or field access invalidates +that projection. +The configured detail-retention period bounds the correction/count window; +expired counts use ordinary bounded cleanup. Minimal current disposition stays +with active plan work to prevent recounting unchanged evidence, and terminal +plan detail follows its existing lifecycle. No raw training corpus or automatic +retraining is created. Acceptance counts do not establish accuracy or recall. + +An optional sourced mating contract identifies one exact declared interface and +its complementary sides. Matching connector labels alone cannot establish fit; +different contract revisions remain unqualified. The declaration checks only +that interface, not signal compatibility, power safety or working service. +Normal reserved cable completion rechecks the current endpoint declarations. +Explicit actual-state reporting can retain an incompatible connection without +asserting physical fit or service health. + +Equipment may also declare six service/connector margins in its enclosing +coordinate frame. A missing declaration is unqualified, while explicit zero is +a supplied measurement. Admission checks material bodies against neighboring +declared service spaces and the candidate's service space against other bodies. +Empty service spaces may overlap each other. An interior envelope does not +certify an outside maintenance aisle or a cable route. + +A plan can retain up to 64 material requirements, each with a scalar positive +quantity and optional sourced equipment claims or physical specifications. These +are plan-local intentions, not assets, orders or promised stock. Even a large +quantity creates no rows per unreceived unit. Current occupancy and power-source +completeness cannot be asserted for proposed equipment. + +A selected requested unit can be placed and reserve space before receipt, using +its declared dimensions and demand. The placement references its line and unit, +not a fabricated inventory ID. Other plans account for that reserved geometry, +clearance and load. Receipt supplies the real adopted incarnation to the same +step; installation cannot be recorded while that unit remains unreceived. + +Explicit receipt or reuse links an adopted resource to one requested unit. +Receipt may be partial and can continue after reservation. It neither installs +the resource nor proves compatibility or health. Real allocations use the same +serialized-part locks as plan steps. +Normal completion rechecks a received unit's actual dimensions and demands through +the common physical admission mechanism, excluding only the hold being consumed. +An explicit actual-state report remains available for work already performed; +it does not establish compatibility or healthy service. +Cancellation releases unused materials but preserves holds for unresolved effects. +An allocation already used by a step +cannot be silently replaced. Terminal BOM detail follows the existing finite +cleanup lifecycle. Equal input, including equivalent quantity spellings, writes +no additional revision or history. + +Selected serialized materials combine step targets and real BOM allocations. +Moving and placing the same retained component counts it once. The list separates related +reported work from outstanding reservations and includes a serial only when +that current field is permitted. Export reloads the authorized plan first; it +does not order equipment, copy credentials or retain an inventory snapshot. + +An explicit availability profile selects an enclosure region, recipient and +permitted capacity constraints. Its recipient also needs current availability +permission; knowing a profile ID is insufficient. The response contains that +region's result without occupant names, counts or silhouettes. Revocation stops +new disclosure, and restored data cannot certify fresh availability. This is +advisory information, separate from atomic reservation. + +Usable dimensions do not prove empty space. Record the bounded region whose +current occupants are completely represented before qualifying its availability. +Outside that region remains unknown. Whole-enclosure mass, power and cooling +checks need coverage of their contributing area. Withdrawing this statement +releases neither installed occupancy nor unresolved reservations. Drafts and +attributed reports of actual work remain available without a complete survey. + ## Verification ```sh diff --git a/docs/storage.md b/docs/storage.md index 9e89825..0cdeadd 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -22,6 +22,50 @@ the host must supply finite settings suitable for all of its namespaces. ## Layout and write contract +Physical availability profiles store only a selected enclosure/incarnation, +region, recipient, explicit constraint flags and current revision/revocation. +At most 64 profiles belong to one enclosure, listed in pages of 25. Existing +profiles may be edited; removal of the enclosure reclaims them. These are +current disclosure configuration, independent of ordinary bounded audit detail. +Availability is derived from authoritative bindings and reservation references; +reads do not save occupancy snapshots or maintain a second capacity inventory. +Declared physical clearance retains six current integer margins in the existing +typed array column. Expanded service regions are derived in bounded batches +for admission and selected availability, without another geometry inventory. +Format 16 records an explicitly surveyed occupancy region as six current +coordinates in the existing typed array layout. Older data acquires no assumed +empty-space evidence during migration. This independent current assertion can +cover less than the usable enclosure; increasing dimensions cannot extend it. +Older hosts reject the new format instead of ignoring its admission semantics. +Format 17 adds one current sourced assertion per physical profile that its +upstream power dependencies are completely recorded. Missing remains unqualified. +The existing typed relationship graph owns those dependencies; no second topology, +path cache or evaluation history is stored. Requirement evaluation reads selected +paths in one snapshot, within a shared 128-node/256-edge request allowance. +Boundary selection cannot hide a known upstream dependency. Path disjointness +does not qualify surviving capacity, live power or electrical safety. +Serialized part lists and exports derive from current steps and permitted +inventory fields, with no per-open export copy. Explicit unreceived material +requirements instead belong to the saved plan: one row per selected line, bounded +canonical specification fields, and sparse allocations for real received units. +Quantity never creates inventory assets or rows per unreceived unit. Placement +steps refer to a line and selected unit until its real allocation is known. +Held geometry reads the owning immutable step and specifications, not a copied +inventory or capacity ledger. Terminal material detail uses ordinary cleanup; +actual occupancy and unresolved recovery work retain their independent lifetime. + +Replacement endpoint correspondence is bounded plan intent, not connectivity. +One review consumes its small mapping payload as a whole; source and destination +membership and declared channels are read in indexed batches. Current cable-end +channel sets and assembly construction preserve one actual assembly identity. +Current event dispositions remain on their owning step. Quality counters contain +only scope hashes, bounded rule/class/decision categories and counts. Corrections +replace the old contribution atomically. Fixed windows use the existing finite +detail-retention policy and cleanup; expired detail never becomes a negative +label. Changed authority partitions invalidate disclosure, and restored counters +require reconciliation before being presented as current. No raw sample corpus, +per-query persistence or second history store is introduced. + The schema stores directly indexed current rows. Asset identity and accepted revision are separate from intended fields, provider mappings, current source claims and freshness. Source fields use scoped keys; omitted partial fields @@ -124,8 +168,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 14. Supported versions 2 -through 13 upgrade in bounded restartable batches, including asset name/sort +This unreleased storage format uses schema version 22. Supported versions 2 +through 21 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/package.json b/package.json index 44e680f..0d19996 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "generate": "protoc --proto_path=proto --plugin=protoc-gen-ts=node_modules/.bin/protoc-gen-ts --ts_out=web/src/generated --ts_opt=long_type_bigint,force_optimize_code_size,force_client_none proto/omloom/v1/contracts.proto proto/omloom/v1/workspace.proto", "typecheck": "tsc --noEmit", "build": "tsc && esbuild web/src/browser-corpus.ts --bundle --format=iife --outfile=dist/browser-corpus.js", - "test:contracts": "node --test web/tests/contracts.test.mjs web/tests/presentation.test.mjs web/tests/field-host.test.mjs web/tests/host-report.test.mjs web/tests/docker-template.test.mjs", + "test:contracts": "node --test web/tests/contracts.test.mjs web/tests/presentation.test.mjs web/tests/field-host.test.mjs web/tests/host-report.test.mjs web/tests/docker-template.test.mjs web/tests/physical-units.test.mjs web/tests/physical-elevation.test.mjs web/tests/rack-grid.test.mjs", "test:browser": "playwright test", "clean": "node scripts/clean.mjs", "build:app": "node scripts/check-vue.cjs --noEmit -p web/app/tsconfig.json && vite build --config web/app/vite.config.ts" diff --git a/proto/omloom/v1/contracts.proto b/proto/omloom/v1/contracts.proto index 2b1d8ce..abfc5e9 100644 --- a/proto/omloom/v1/contracts.proto +++ b/proto/omloom/v1/contracts.proto @@ -49,6 +49,10 @@ enum AssetKind { ASSET_KIND_WORKLOAD = 27; // Concrete container lifetime; a name, image or published port is not its identity. ASSET_KIND_CONTAINER = 28; + // Adopted movable physical part, independent of its present bay, label or serial evidence. + ASSET_KIND_PHYSICAL_COMPONENT = 29; + // Instantiated structural position scoped to its actual owning component incarnation. + ASSET_KIND_PHYSICAL_POSITION = 30; } // Scoped provider identity is matching evidence, never authority. diff --git a/proto/omloom/v1/workspace.proto b/proto/omloom/v1/workspace.proto index f283502..6d1cd9b 100644 --- a/proto/omloom/v1/workspace.proto +++ b/proto/omloom/v1/workspace.proto @@ -1415,13 +1415,41 @@ message RequirementGroup { // Direct field subjects can vote; nested logical groups have unknown voter identity. uint32 minimum = 3; } +message PowerPathRequirement { + // Caller-selected input sources/ports whose accepted dependency paths must be + // disjoint, from 2 to 8 references. Repeated identity proves a shared source. + repeated omloom.v1.AssetRef sources = 1; + // Caller-selected terminal boundaries, at most 8 references. Every recorded + // path must reach one to qualify a pass; a boundary cannot hide known upstream + // dependencies. Omission allows counterexamples, never a positive result. + // Completeness and authority come from current server records, not this list. + repeated omloom.v1.AssetRef boundaries = 2; +} message RequirementNode { // One bounded predicate or group. Groups are a flat indexed graph, not recursive wire messages. oneof expression { FieldRequirement field = 1; RequirementGroup group = 2; + // Accepted dependency-path disjointness within explicit boundaries. This + // does not certify live power, surviving capacity or electrical safety. + PowerPathRequirement power_paths = 3; + // Exact accepted binding basis. Combines with other predicates in the + // shared graph; it never certifies observed signal or service availability. + BindingRequirement binding = 4; } } +message BindingRequirement { + // User-selected relation UUID and revision from an authorized current read. + string relation_id = 1; + // Exact reviewed positive revision. A removed/recreated edge cannot satisfy it. + uint64 revision = 2; + // Both reviewed endpoints, required to check rights even if the edge is absent. + omloom.v1.AssetRef from = 3; + // Reviewed destination incarnation, never inferred from a label or position. + omloom.v1.AssetRef to = 4; + // Reviewed relationship meaning. A matching UUID cannot change its meaning. + omloom.v1.RelationKind kind = 5; +} message EvaluateRequirements { // Resource whose selected requirements are being inspected in the working context. omloom.v1.AssetRef anchor = 1; @@ -1505,6 +1533,758 @@ message RequirementEvaluation { repeated RequirementResult results = 5; } +// Half-open physical extent in integer micrometres in its owner's coordinates. +message PhysicalExtent { + // User-supplied signed origin, allowing documented external service clearance. + sint64 start_um = 1; + // Positive physical length; zero is invalid, not an unknown measurement. + uint64 length_um = 2; +} +message PhysicalVolume { + // Horizontal extent in the owning enclosure's coordinate system. + PhysicalExtent x = 1; + // Vertical extent; one rack unit is exactly 44450 micrometres. + PhysicalExtent y = 2; + // Front-to-rear extent; opposite faces share the same coordinates. + PhysicalExtent z = 3; +} +// Current explicit private/unmatched physical facts, not a second model catalogue. +message PhysicalDefinition { + // Existing resource incarnation whose current physical facts are being refined. + omloom.v1.AssetRef target = 1; + // Usable enclosure volume, absent if unknown. Device/component dimensions + // belong to their canonical width/height/depth equipment claims instead. + optional PhysicalVolume volume = 2; + // Source/instruction reference supplied by the operator, max 512 bytes; no secrets. + string basis = 3; + reserved 4; + // Optional permitted supported mass in grams, independently of actual equipment mass. + optional uint64 max_mass_g = 5; + // Optional design power requirement in milliwatts, not a measured load. + optional uint64 demand_mw = 6; + // Optional design power budget in milliwatts, not proof of independent feeds. + optional uint64 capacity_mw = 7; + // Optional design cooling demand in milliwatts, independent of power measurements. + optional uint64 cooling_mw = 8; + // Optional qualified cooling capacity in milliwatts; missing is unknown. + optional uint64 cooling_capacity_mw = 9; + // Explicit rail/attachment standard, max 128 bytes; missing means unqualified fit. + optional string mounting = 10; + // Sourced device/component class; unspecified preserves unknown classification. + // Structural position classes belong to PhysicalPositionEdit, not this profile. + // Labels, serials and position alone never establish the part's identity/class. + PhysicalComponentKind component_kind = 11; + // Optional operator/source-declared mating contract for this actual connector. + // It proves only the named mechanical interface, not signal/power/service health. + optional PhysicalMating mating = 12; + // Optional operator/source-declared equipment service/connector margins in + // its enclosing coordinate frame. Missing is unqualified, not measured zero. + // Admission checks declared margins without saving expanded occupancy copies. + optional PhysicalClearance clearance = 13; + // Operator/source-asserted region whose current actual occupancy is completely + // represented, in this enclosure's coordinate frame. Missing is unqualified, + // never empty. A broader usable envelope does not extend this evidence. + // This grants no access and reserves no space; its source is the basis above. + optional PhysicalVolume occupancy_coverage = 14; + // Operator/source assertion that all current upstream PoweredBy dependencies + // of this resource are recorded. False/absent is unqualified, not an empty + // supply list. The basis above attributes this assertion. It grants no rights + // and does not assert live power or independence beyond the selected boundary. + bool power_sources_complete = 15; + // Optional sourced rack rail grid, independent of cabinet dimensions or occupancy. + // Missing permits coordinate-based drafts but supplies no rack unit numbering. + optional PhysicalRackGrid rack_grid = 16; + // Sourced assembly construction, only on a Cable. DAC/AOC end modules belong + // to this one cable identity; no independently replaceable optics are implied. + PhysicalAssemblyKind assembly_kind = 17; + // Explicit sourced channel numbers supplied by this physical Port, at most + // 64 distinct values from 1 to 4096. Missing is unknown, never all lanes. + repeated uint32 channels = 18; +} +message PhysicalChannelSet { + // Bounded canonical channel set consumed as one physical definition. + repeated uint32 channels = 1; +} +enum PhysicalAssemblyKind { + PHYSICAL_ASSEMBLY_KIND_UNSPECIFIED = 0; + PHYSICAL_ASSEMBLY_KIND_PASSIVE = 1; + PHYSICAL_ASSEMBLY_KIND_DAC = 2; + PHYSICAL_ASSEMBLY_KIND_AOC = 3; +} +message PhysicalRackGrid { + // User/source-measured top of the rail grid in enclosure Y micrometres. + // Increasing Y runs downward for this rack frame; this is not the cabinet height. + sint64 top_um = 1; + // User/source-declared positive number of rack units; each is exactly 44450 um. + uint32 unit_count = 2; + // User/source-declared lowest printed unit number, positive. Together with count + // it must fit uint32. Labels do not create inventory objects for every unit. + uint32 first_unit = 3; + // User/source-declared numbering direction. UNSPECIFIED is invalid when present. + PhysicalRackDirection direction = 4; +} +enum PhysicalRackDirection { + PHYSICAL_RACK_DIRECTION_UNSPECIFIED = 0; + PHYSICAL_RACK_DIRECTION_TOP_DOWN = 1; + PHYSICAL_RACK_DIRECTION_BOTTOM_UP = 2; +} +message PhysicalClearance { + // User/source-required space before the equipment's X face, in micrometres. + uint64 before_x_um = 1; + // User/source-required space after the equipment's X face, in micrometres. + uint64 after_x_um = 2; + // User/source-required space before the equipment's Y face, in micrometres. + uint64 before_y_um = 3; + // User/source-required space after the equipment's Y face, in micrometres. + uint64 after_y_um = 4; + // User/source-required space before the equipment's Z face, in micrometres. + uint64 before_z_um = 5; + // User/source-required space after the equipment's Z face, in micrometres. + uint64 after_z_um = 6; +} +// Complete compatibility declaration for a selected connector interface. Matching +// exact contracts with complementary roles qualifies that declared interface only; +// designation text and different contract revisions never imply compatibility. +message PhysicalMating { + // User/source-supplied immutable contract reference. No acquisition, issuer + // authentication or manufacturer approval is inferred from these identifiers. + omloom.v1.EquipmentReference profile = 1; + // Declared side within this contract; UNSPECIFIED is invalid when present. + PhysicalMatingRole role = 2; +} +enum PhysicalMatingRole { + PHYSICAL_MATING_ROLE_UNSPECIFIED = 0; + PHYSICAL_MATING_ROLE_SIDE_A = 1; + PHYSICAL_MATING_ROLE_SIDE_B = 2; + PHYSICAL_MATING_ROLE_SELF_MATING = 3; +} +message PhysicalDefinitionEdit { + // User-supplied current definition, validated under resource/field permissions. + PhysicalDefinition definition = 1; +} +enum PhysicalComponentKind { + PHYSICAL_COMPONENT_KIND_UNSPECIFIED = 0; + PHYSICAL_COMPONENT_KIND_CHASSIS = 1; + PHYSICAL_COMPONENT_KIND_TRANSCEIVER = 2; + PHYSICAL_COMPONENT_KIND_DISK = 3; + PHYSICAL_COMPONENT_KIND_POWER_SUPPLY = 4; + PHYSICAL_COMPONENT_KIND_NETWORK_ADAPTER = 5; + PHYSICAL_COMPONENT_KIND_LINE_CARD = 6; + PHYSICAL_COMPONENT_KIND_DOCK = 7; + PHYSICAL_COMPONENT_KIND_ASSEMBLY = 8; +} +message PhysicalPositionEdit { + // Selected actual owner; replacing that owner creates new structural positions. + omloom.v1.AssetRef owner = 1; + // User/source-qualified immutable structural token, 1-128 ASCII token bytes. + // Labels, serials and enumeration indices alone do not establish this key. + string structural_key = 2; + // User-visible bounded label, independent of structural identity. + string name = 3; + // Kind of removable part admitted by this position, unspecified means unknown. + PhysicalComponentKind component_kind = 4; + // Exact current position revision for an edit; absent means explicit creation. + optional uint64 expected = 5; +} +message PhysicalPosition { + // Server-generated stable position identity, scoped structurally by its owner. + omloom.v1.AssetRef target = 1; + // Current actual owning incarnation, not the presently removable occupant. + omloom.v1.AssetRef owner = 2; + // Immutable structural identity within the actual owning incarnation. + string structural_key = 3; + // Current declared part class; it does not prove electrical/media compatibility. + PhysicalComponentKind component_kind = 4; + // Accepted position revision for a reviewed mutation. + uint64 revision = 5; +} +message PhysicalPositionQuery { + // Explicit actual owner whose permitted direct positions are requested. + omloom.v1.AssetRef owner = 1; + // Previous keyset continuation, absent on the first page; never an authority grant. + optional string after = 2; + // Requested page size, zero selects 25 and values above 25 are rejected. + uint32 limit = 3; +} +message PhysicalPlacement { + // Existing adopted part/device incarnation. Exactly one of item or material + // is supplied; a future material never acquires a fabricated asset identity. + omloom.v1.AssetRef item = 1; + // Existing receiving rack/enclosure incarnation; parent authority is checked separately. + omloom.v1.AssetRef enclosure = 2; + // Proposed position/envelope in receiving enclosure coordinates. Admission + // checks actual current dimensions or an unreceived unit's declared specification; + // it cannot shrink the body to make it fit. Unknown dimensions stay unvalidated. + PhysicalVolume volume = 3; + // User-selected requested unit in this plan's materials. Its allocation, when + // received, supplies the real item; callers never send both derived identities. + PhysicalMaterialUnit material = 4; +} +message PhysicalMaterialUnit { + // Zero-based requirement line in the same saved or transient plan. + uint32 material = 1; + // Zero-based unit below that line's quantity. Selecting one unit creates + // one proposed placement, never placements for the line's entire quantity. + uint32 unit = 2; +} +message PhysicalReplacement { + // Selected surviving position or actual enclosure for a chassis replacement. + omloom.v1.AssetRef position = 1; + // Previously established part; absence leaves the predecessor unresolved, + // never implicitly asserts that the position is empty. + optional omloom.v1.AssetRef previous = 2; + // Explicit new adopted part; absence records removal. Equal previous/replacement + // records a same-part reseat, preserving the part's identity. + optional omloom.v1.AssetRef replacement = 3; + // Exact ordinary InstalledIn relation reviewed for previous, required with previous. + optional string previous_binding = 4; + // Revision of that binding, never a serial/name/time-based identity guess. + optional uint64 previous_revision = 5; + // Explicit old position when moving an established part or staging a chassis + // elsewhere; omission selects the same position as the destination. + optional omloom.v1.AssetRef source_position = 6; + // Optional explicitly reviewed incoming placement/envelope in position's frame. + // A rack chassis replacement requires this for reservation. It is proposal + // geometry until reported; no old pose or physical proof is copied implicitly. + // Report commits this pose with the new binding in the same transaction. + optional PhysicalVolume volume = 7; + // Explicit reviewed endpoint correspondence, at most 64 pairs across a plan. + // It preserves intent only: no cable attachment, runtime identity, configuration + // or grants are copied. Unmapped and rejected ports remain explicit. + repeated PhysicalEndpointMapping endpoints = 8; + // Server-owned current review disposition. Echo unchanged on draft saves; + // only the review command may correct it. It never proves a physical effect. + optional PhysicalReplacementFeedback feedback = 9; +} +enum PhysicalReplacementDecision { + PHYSICAL_REPLACEMENT_DECISION_UNSPECIFIED = 0; + PHYSICAL_REPLACEMENT_DECISION_CONFIRMED = 1; + PHYSICAL_REPLACEMENT_DECISION_REJECTED = 2; + PHYSICAL_REPLACEMENT_DECISION_DEFERRED = 3; +} +enum PhysicalReplacementStrength { + PHYSICAL_REPLACEMENT_STRENGTH_UNSPECIFIED = 0; + PHYSICAL_REPLACEMENT_STRENGTH_LIMITED = 1; + PHYSICAL_REPLACEMENT_STRENGTH_CONTEXTUAL = 2; + PHYSICAL_REPLACEMENT_STRENGTH_ESTABLISHED_IDENTITY = 3; +} +enum PhysicalReplacementReason { + PHYSICAL_REPLACEMENT_REASON_UNSPECIFIED = 0; + PHYSICAL_REPLACEMENT_REASON_MOVED = 1; + PHYSICAL_REPLACEMENT_REASON_ADDITIONAL = 2; + PHYSICAL_REPLACEMENT_REASON_RESEATED = 3; + PHYSICAL_REPLACEMENT_REASON_SOURCE_ERROR = 4; + PHYSICAL_REPLACEMENT_REASON_WRONG_PREDECESSOR = 5; +} +message PhysicalReplacementFeedback { + // Operator judgment, independent of endpoint correspondence and installation. + PhysicalReplacementDecision decision = 1; + // Optional bounded correction category; no free-text sample corpus. + PhysicalReplacementReason reason = 2; + // Server fingerprint of the reviewed current evidence, not an identity proof. + string basis = 3; + // Server-owned correction-window deadline. Expiry never creates a new alert. + uint64 until_ms = 4; + // Versioned explainable rule, currently 1. No calibrated probability is claimed. + uint32 rule = 5; + // Server evidence class: 1 limited, 2 contextual, 3 established same-part identity. + PhysicalReplacementStrength strength = 6; + // Server component class used for the bounded quality bucket, not model inference. + PhysicalComponentKind component_kind = 7; + // Server opaque disclosure partition. Neither its possession nor a matching + // actor name grants access; current complete scope is checked on each request. + string scope = 8; +} +message PhysicalReplacementReview { + // Existing plan and exact revision; no review is implicitly an installation. + PhysicalPlanKey plan = 1; + // Zero-based replacement step, not an arbitrary resource query. + uint32 step = 2; + // Absent means read-only explanation. Present explicitly records/corrects + // one judgment, requiring the returned basis and current plan revision. + optional PhysicalReplacementDecision decision = 3; + // Optional bounded reason; supplied only with a decision. + PhysicalReplacementReason reason = 4; + // Server-returned evidence fingerprint echoed for a conditional decision. + optional string basis = 5; +} +message PhysicalReplacementHypothesis { + // Current evidence fingerprint for an explicit conditional review command. + string basis = 1; + // Version of the deterministic explanation rules, not a learned predictor. + uint32 rule = 2; + // Evidence-strength class, independent of compatibility and service health. + PhysicalReplacementStrength strength = 3; + // Server explanation of the evidence and plausible alternatives. + string explanation = 4; +} +message PhysicalReplacementQuality { + // Component class, rule and strength partition of actual reviewed judgments. + PhysicalComponentKind component_kind = 1; + // Versioned deterministic rule; counts do not establish predictor accuracy. + uint32 rule = 2; + // Evidence class, not a calibrated probability. + PhysicalReplacementStrength strength = 3; + // Current judgment class; deferred is unlabelled, never a negative example. + PhysicalReplacementDecision decision = 4; + // Bounded reason category, without identities, topology or raw evidence. + PhysicalReplacementReason reason = 5; + // Number of current contributions, replaced atomically on correction. + uint64 count = 6; + // End of this finite correction/aggregate window, not a permanent dataset. + uint64 until_ms = 7; +} +message PhysicalReplacementQualityQuery { + // Previous server-returned bucket key. It supplies no disclosure permission. + optional string after = 1; +} +message PhysicalEndpointMapping { + // Existing physical port directly supplied by the previous component. + omloom.v1.AssetRef previous = 1; + // User-selected port directly supplied by the incoming component. Absent + // means unresolved; names, numbering and likelihood never supply this value. + optional omloom.v1.AssetRef replacement = 2; + // Operator's mapping judgment, independent of accepting the replacement event. + PhysicalMappingDecision decision = 3; + // Explicit source lane for a breakout; absent selects the entire port. + // It must exist in the current sourced port definition before acceptance. + optional uint32 previous_channel = 4; + // Explicit destination lane, absent selects the entire destination port. + // A whole-port allocation and its lane allocations cannot coexist. + optional uint32 replacement_channel = 5; +} +enum PhysicalMappingDecision { + PHYSICAL_MAPPING_DECISION_UNSPECIFIED = 0; + PHYSICAL_MAPPING_DECISION_UNRESOLVED = 1; + PHYSICAL_MAPPING_DECISION_ACCEPTED = 2; + PHYSICAL_MAPPING_DECISION_REJECTED = 3; +} +message PhysicalEndpointMappings { + // Bounded mappings consumed together by one replacement review, never an + // alternative authoritative connectivity graph or an execution receipt. + repeated PhysicalEndpointMapping pairs = 1; +} +enum PhysicalStepState { + PHYSICAL_STEP_STATE_UNSPECIFIED = 0; + PHYSICAL_STEP_STATE_PLANNED = 1; + PHYSICAL_STEP_STATE_RESERVED = 2; + PHYSICAL_STEP_STATE_RECORDED = 3; + PHYSICAL_STEP_STATE_NOT_PERFORMED = 4; +} +enum PlanTrigger { + PLAN_TRIGGER_UNSPECIFIED = 0; + PLAN_TRIGGER_REPORTED = 1; + PLAN_TRIGGER_SATISFIED = 2; + PLAN_TRIGGER_FAILED = 3; +} +message PlanDependency { + // Zero-based stable step index within this admitted plan revision. + uint32 step = 1; + // Required predecessor outcome. Unknown evidence never selects a branch. + PlanTrigger when = 2; +} +message PlanControl { + // Explicit serial/branch prerequisites; at most 256 edges across the plan. + repeated PlanDependency after = 1; + // Optional postcondition using the common requirement graph. Machine evidence + // must qualify for this post-effect phase, not merely be an old current sample. + optional EvaluateRequirements check = 2; +} +enum PlanReadiness { + PLAN_READINESS_UNSPECIFIED = 0; + PLAN_READINESS_READY = 1; + PLAN_READINESS_WAITING = 2; + PLAN_READINESS_UNSELECTED = 3; + PLAN_READINESS_COMPLETE = 4; +} +message PhysicalStep { + // Server-derived progress. New drafts may omit it or specify PLANNED only. + // RECORDED is an attributed report, never verified connectivity or health. + PhysicalStepState state = 1; + // Explicit serial/branch prerequisites, at most 256 edges across the plan. + // Empty means independent work, not an implicit dependency on list order. + optional PlanControl control = 4; + // Server-derived current checkpoint verdict; absent before work is reported. + // Reports cannot set this value, and manual work is not machine verification. + optional RequirementState checked_state = 5; + // Current machine-readable checkpoint reason, never a caller-supplied verdict. + optional RequirementReason checked_reason = 6; + // Current graph result, recomputed under server permissions; never authority. + PlanReadiness readiness = 7; + // Exactly one transition in the shared plan and reservation lifecycle. + oneof action { + // Proposed geometry for a selected part in a selected enclosure. + PhysicalPlacement placement = 2; + // Reviewed transition preserving unaffected identities and intent. + PhysicalReplacement replacement = 3; + // Manual wiring transition at one named cable end, sharing plan progress, + // prerequisite checks and reservation/reconciliation ownership. + PhysicalWiring wiring = 8; + } +} +enum PhysicalPlanState { + PHYSICAL_PLAN_STATE_UNSPECIFIED = 0; + PHYSICAL_PLAN_STATE_DRAFT = 1; + PHYSICAL_PLAN_STATE_RESERVED = 2; + PHYSICAL_PLAN_STATE_PARTIAL = 3; + PHYSICAL_PLAN_STATE_COMPLETED = 4; + PHYSICAL_PLAN_STATE_CANCELLED = 5; +} +message PhysicalPlan { + // Server-assigned identity on explicit save; omitted for a new transient preview. + optional string id = 1; + // User label, max 160 UTF-8 bytes. It does not name an executable command. + string name = 2; + // Ordered physical transitions, at most 64; unsaved editing reserves nothing. + repeated PhysicalStep steps = 3; + // Server-derived current lifecycle; callers cannot assert accepted completion. + PhysicalPlanState state = 4; + // Server-derived saved revision, used for conditional plan changes. + uint64 revision = 5; + // Server-admitted reservation deadline in Unix milliseconds, absent for drafts. + optional uint64 reserved_until_ms = 6; + // User's plan-local material requirements, at most 64. These are not adopted + // assets or stock promises. Quantities never allocate one record per unit. + repeated PhysicalMaterial materials = 7; +} +message PhysicalMaterial { + // User-visible current BOM label, 1-160 UTF-8 bytes, not a global identity. + string name = 1; + // Requested count of discrete units, positive uint32. Large counts do not + // instantiate assets, positions or allocations. Receipt may be partial. + uint32 quantity = 2; + // Requested physical resource kind: device, physical component, cable or rack. + omloom.v1.AssetKind kind = 3; + // Optional selected canonical equipment claims, without serials or credentials. + // Each field has its own source basis; at most the 12 equipment claim names. + repeated omloom.v1.Field specifications = 4; + // Optional plan-local physical specification using the common fact vocabulary. + // Target must be absent. Current occupancy/power completeness assertions are + // forbidden: proposed equipment cannot assert actual inventory or live sources. + optional PhysicalDefinition physical = 5; + // Explicitly received/reused adopted resources, at most 64 across one plan. + // Each retains its real identity and current serial; no template serial is copied. + repeated PhysicalMaterialAllocation allocations = 6; +} +message PhysicalMaterialAllocation { + // User-selected zero-based unit within this BOM line, less than quantity. + // Sparse receipt can name any requested unit without allocating preceding ones. + uint32 unit = 1; + // Actual adopted resource explicitly received or selected for reuse. This + // reference grants nothing and does not assert installation or verified health. + omloom.v1.AssetRef item = 2; + // User's bounded receipt/reuse source, 1-512 UTF-8 bytes; no secrets. Manual + // attribution is not provider verification, procurement or a financial action. + string basis = 3; +} +message PhysicalMaterialReceipt { + // Caller-selected saved plan and last reviewed revision, under current rights. + PhysicalPlanKey plan = 1; + // Zero-based material line in that saved plan, not a global stock identifier. + uint32 material = 2; + // Explicit actual unit and receipt/reuse source. New units may arrive partially + // after reservation. Replacing an allocation already used by a step is forbidden. + PhysicalMaterialAllocation allocation = 3; +} +message PhysicalPreview { + // User-supplied proposed plan; state, revision and deadline are not authority. + PhysicalPlan plan = 1; +} +message PhysicalSave { + // Proposed current draft; existing plans must include their last reviewed revision. + PhysicalPlan plan = 1; +} +message PhysicalRead { + // Existing enclosure whose permitted physical projection is requested. + omloom.v1.AssetRef enclosure = 1; + // Explicit next-level installed-component projection. False reads only the + // selected resource's facts, without traversing its children. + bool occupants = 2; + // Server-returned continuation for the same selected enclosure, working scope, + // authority and inventory revision. Absent starts the first page of 25 parts. + // At most 256 UTF-8 bytes; requires occupants=true and grants no additional access. + optional string after = 3; +} +message PhysicalPlanKey { + // Exact saved plan identity returned by its save command, not a capability. + string id = 1; + // Last reviewed plan revision; changes require a new review. + uint64 revision = 2; +} +message PhysicalPlanList { + // Opaque last plan key from the previous page, not an access capability. + optional string after = 1; + // Requested summary count, at most 25. Omission uses the fixed first-page size. + uint32 limit = 2; +} +message PhysicalPlanSummary { + // Server-assigned saved plan UUID; names never identify a plan. + string id = 1; + // Owner-visible current plan label; resource access is independently rechecked. + string name = 2; + // Current conditional edit basis, not execution or installation evidence. + uint64 revision = 3; + // Derived current plan lifecycle, independent of actual installed occupancy. + PhysicalPlanState state = 4; +} +message PhysicalReserve { + // Existing saved draft and revision to admit against current capacity. + PhysicalPlanKey plan = 1; + // Requested finite deadline, Unix milliseconds, bounded by the host policy. + uint64 until_ms = 2; +} +message PhysicalCompletion { + // Admitted plan and revision under which the operator performed manual work. + PhysicalPlanKey plan = 1; + // Selected zero-based step indexes actually completed, at most 64. Unselected + // work remains unresolved; deadline and missing observation never complete it. + repeated uint32 completed_steps = 2; + // Operator's concise discrepancy report, max 2048 bytes; never machine verification. + optional string discrepancy = 3; + // Explicitly confirmed steps not performed. Only their future reservation is + // released; installed occupancy and unresolved effects are preserved. + repeated uint32 not_performed_steps = 4; + // User explicitly records externally performed actual work, even if a planned + // safe installation could not be admitted. This grants no executor authority + // and never marks compatibility, cable connectivity or service health verified. + bool actual_state = 5; +} +message PhysicalOccupancy { + // Current installed placement, independent of the reservation's expiry. + PhysicalPlacement placement = 1; + // Attribution of the manual installation, not proof of live service or wiring. + optional string reported_by = 2; + // Server-returned accepted physical binding revision, used to invalidate old + // evidence. Zero when binding is withheld by either endpoint's relationship grant. + uint64 revision = 3; + // Exact current installed binding, disclosed only with relationship access on + // both endpoints. Absence supplies no permission to guess its identity/revision. + optional omloom.v1.Relation binding = 4; + // Geometry report revision, absent when this binding has no reported placement envelope. + optional uint64 pose_revision = 5; + // Geometry reporter, independent of the installed binding's source/actor. + optional string pose_reported_by = 6; +} +message Physical { + // Exactly one physical query or command; no caller-supplied effective grants. + oneof action { + // Inspect one selected enclosure without writes or reservation admission. + PhysicalRead read = 1; + // Conditionally refine current physical facts, not installed component state. + PhysicalDefinitionEdit define = 2; + // Check a transient proposal against current authoritative constraints. + PhysicalPreview preview = 3; + // Explicitly save a bounded current plan without reserving or installing it. + PhysicalSave save = 4; + // Load the saved plan under current rights, without creating a snapshot. + PhysicalPlanKey load = 5; + // Atomically reserve current physical capacity, without claiming installation. + PhysicalReserve reserve = 6; + // Record attributed actual installation and preserve unresolved allocations. + PhysicalCompletion complete = 7; + // Cancel future work while preserving installed occupancy and unresolved effects. + PhysicalPlanKey cancel = 8; + // Retrieve only the caller's saved plan summaries with bounded stable pagination. + PhysicalPlanList list = 9; + // Create/refine a structural position with its ordinary inventory ownership atomically. + PhysicalPositionEdit position = 10; + // Retrieve bounded direct positions without loading occupants or descendants. + PhysicalPositionQuery positions = 11; + // Create/refine one of at most 64 named cable ends; no attachment or unplugging. + PhysicalCableEndEdit cable_end = 12; + // Read only the selected cable's named ends, without tracing its neighbors. + PhysicalCableEnds cable_ends = 13; + // Save or explicitly revoke a selected-region disclosure offer. This never + // grants resource, placement or external-operation authority. + PhysicalOfferEdit offer = 14; + // Read only the selected offer's permitted availability, not its occupants. + PhysicalOfferRead availability = 15; + // List the current actor's offers for one already selected enclosure. + PhysicalOffers offers = 16; + // Record one received/reused unit without installing it or completing a step. + PhysicalMaterialReceipt receive = 17; + // Explain or explicitly judge a replacement using current scoped evidence. + PhysicalReplacementReview review_replacement = 18; + // Bounded quality buckets for the exact current complete disclosure scope. + // No topology is traversed and no narrower/global aggregate is inferred. + PhysicalReplacementQualityQuery replacement_quality = 19; + } +} +message PhysicalCableEnd { + // Stable operator/source-qualified token within the exact cable incarnation, + // 1-64 ASCII token bytes. Labels or connector types do not establish identity. + string key = 1; + // Current operator label, 1-128 UTF-8 bytes, independent of structural identity. + string name = 2; + // Operator-supplied source/measurement reference, 1-512 UTF-8 bytes; no secrets. + string basis = 3; + // Optional qualified connector designation, 1-128 UTF-8 bytes. This label + // alone cannot establish medium, polarity, lane mapping or compatibility. + optional string connector = 4; + // Server-owned current end-record revision, not connectivity evidence. + uint64 revision = 5; + // Server-derived current attachment knowledge. Absence means unknown or + // unavailable, never unplugged; mutation receipts may omit this projection. + oneof attachment { + // Exact accepted binding, disclosed only with both endpoints' current rights. + PhysicalCableAttachment connected = 6; + // Explicit attributed report of a loose end; not inferred from a missing scan. + Empty loose = 7; + } + // Independently accepted expected destination, retained while unplugged or + // uncertain. Omitted unless the caller can read this relationship and its port. + optional omloom.v1.AssetRef intended_port = 8; + // Optional declared connector mating contract; a display label is insufficient. + optional PhysicalMating mating = 9; + // Explicit operator/source-declared internal channel numbers, at most 64 + // distinct positive values up to 4096 in this cable's namespace. Equal numbers + // at two ends describe one passive path; absent numbers leave it unknown. + // Breakout uses several numbered paths in one assembly, never cloned cables. + repeated uint32 channels = 10; +} +message PhysicalCableAttachment { + // Actual port incarnation supplied by the accepted inventory binding. + omloom.v1.AssetRef port = 1; + // Server-generated current ConnectedTo relation UUID; not an end identity. + string binding_id = 2; + // Exact current attachment revision, independent of the cable's lifetime. + uint64 binding_revision = 3; +} +message PhysicalWiring { + // Selected retained cable incarnation, never inferred from its label or path. + omloom.v1.AssetRef cable = 1; + // Existing named end within this cable, 1-64 ASCII token bytes. + string end_key = 2; + // Exact current-state basis, or the output of an earlier wiring step at the + // same end. A step reference contributes a dependency to the common DAG. + oneof basis { + // User's reviewed current end revision; zero is invalid. + uint64 expected_end_revision = 3; + // User-selected earlier step index; its recorded result must exist first. + uint32 after_step = 10; + } + // Explicit disposition of accepted destination intent, independent of what + // physically happened. UNSPECIFIED is rejected; no automatic intent adoption. + PhysicalWiringIntent intent = 7; + // Server-captured previous accepted binding for this exact admitted step. + // It may be only possible occupancy, never proof of current signal/health. + optional PhysicalCableAttachment previous = 8; + // Server-captured previous intent needed for reviewed correction/recovery. + // These output fields do not let a caller overwrite the source basis. + optional omloom.v1.AssetRef previous_intended_port = 9; + // Server-recorded end revision produced by this effect. It permits dependent + // steps to continue after restart without replaying the physical work. + optional uint64 result_revision = 11; + // Explicit proposed/reported attachment. No state can be inferred from omission. + oneof after { + // Actual destination physical port; normal scope and endpoint rights apply. + omloom.v1.AssetRef port = 4; + // Operator confirms that this end is physically loose. + Empty loose = 5; + // Operator cannot establish attachment; preserves uncertainty, not free capacity. + Empty unknown = 6; + } +} +enum PhysicalWiringIntent { + PHYSICAL_WIRING_INTENT_UNSPECIFIED = 0; + PHYSICAL_WIRING_INTENT_PRESERVE = 1; + PHYSICAL_WIRING_INTENT_ADOPT_DESTINATION = 2; + PHYSICAL_WIRING_INTENT_CLEAR_DESTINATION = 3; +} +message PhysicalCableEndEdit { + // Selected adopted cable incarnation; no device/port identity is inferred. + omloom.v1.AssetRef cable = 1; + // User's proposed end definition. Its output revision grants no authority. + PhysicalCableEnd end = 2; + // Last reviewed definition revision; absent means explicit creation. + optional uint64 expected = 3; +} +message PhysicalCableEnds { + // Exact selected cable, checked under the existing working context and rights. + omloom.v1.AssetRef cable = 1; + // Last returned structural token within this cable, absent for the first page. + optional string after = 2; +} +message PhysicalOffer { + // Server-generated UUID, absent only when explicitly creating an offer. + optional string id = 1; + // Actual enclosure incarnation supplying this offer, not a neighbor or model. + omloom.v1.AssetRef enclosure = 2; + // User-reviewed region in this enclosure's coordinates. It is the complete + // disclosed query granularity; recipients cannot probe smaller hidden regions. + PhysicalVolume region = 3; + // Existing recipient identity selected by the publisher, max 128 UTF-8 bytes. + // The authenticated context must match this identity; possession of an ID + // or link alone supplies no authority. No SID identity is created here. + string recipient = 4; + // Publisher-supplied label, max 160 UTF-8 bytes, disclosed to this recipient. + string name = 5; + // Exact offer revision required for changing its region, recipient or state. + uint64 revision = 6; + // Explicit current revocation; revocation stops new disclosure, not copies + // already obtained. Old restored state never qualifies current availability. + bool revoked = 7; + // Explicit additional permission to disclose the whole enclosure's mass + // admission result. Neither the limit nor individual loads are disclosed. + bool mass = 8; + // Explicit additional permission to disclose the enclosure power result. + // This does not certify independent supplies or electrical safety. + bool power = 9; + // Explicit additional permission to disclose the enclosure cooling result. + // It describes declared capacity, not sensor readings or thermal certification. + bool cooling = 10; +} +message PhysicalOfferEdit { + // Publisher's selected current profile. Current resource/field rights and + // exact revisions are rechecked before an atomic change. + PhysicalOffer offer = 1; +} +message PhysicalOfferRead { + // Server-generated offer UUID. It identifies a disclosure profile, not a grant. + string id = 1; +} +message PhysicalOffers { + // Existing selected enclosure for a publisher's own bounded offer list. + omloom.v1.AssetRef enclosure = 1; + // Last returned offer UUID, absent on the first page. + optional string after = 2; +} +message PhysicalView { + // Explicitly requested current replacement hypothesis, without durable writes. + optional PhysicalReplacementHypothesis replacement_hypothesis = 14; + // At most 25 bounded quality buckets under the exact contributing scope. + repeated PhysicalReplacementQuality replacement_quality = 15; + // Current application revision under which the projection was produced. + uint64 revision = 1; + // Permitted current definitions for this bounded selection only. + repeated PhysicalDefinition definitions = 2; + // Explicitly selected current plan or transient proposal, absent for enclosure reads. + optional PhysicalPlan plan = 3; + // Shared constraint result, never a grant, execution receipt or live health. + RequirementState state = 4; + // Machine-readable missing/conflicting basis without hidden occupant identities. + RequirementReason reason = 5; + // One page of permitted current occupants of the selected enclosure, maximum 25. + repeated PhysicalOccupancy occupancy = 6; + // At most 25 owner-visible plan summaries, without child or occupancy hydration. + repeated PhysicalPlanSummary plans = 7; + // Continuation for the same selected query. Occupant pages bind enclosure, + // working scope, authority and revision. It supplies no resource or plan authority. + optional string next = 8; + // Currently permitted direct structural positions, at most 25 per query. + repeated PhysicalPosition positions = 9; + // Minimal permitted ordinary inventory labels for this selected response only. + // Selected plan materials also include current permitted serial fields; + // neither a material snapshot nor denied neighboring inventory is returned. + repeated Item items = 10; + // At most 25 named ends belonging to the selected cable; no attached devices + // or neighboring cable paths are implicitly loaded or disclosed. + repeated PhysicalCableEnd cable_ends = 11; + // Selected permitted region profiles, at most 25. A recipient availability + // response returns exactly its selected profile and no occupant projection. + repeated PhysicalOffer offers = 12; +} + message Request { // Explicit working selector for resource details, prepared actions and every // inventory mutation/import. Query/export use Query roots/focus instead. @@ -1576,6 +2356,8 @@ message Request { PrepareVmPlacement prepare_vm_placement = 32; // Evaluate desired conditions against permitted current facts without writes or probes. EvaluateRequirements evaluate_requirements = 33; + // Query or explicitly refine physical planning through the common store boundary. + Physical physical = 34; } } message Reply { @@ -1625,5 +2407,7 @@ message Reply { VmPlacementReview vm_placement_review = 21; // Shared read-only prerequisite explanations, not operation admission. RequirementEvaluation requirement_evaluation = 22; + // Bounded physical facts and plan validation over the same inventory revision. + PhysicalView physical = 23; } } diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/web/app/App.vue b/web/app/App.vue index 1678793..ecca81a 100644 --- a/web/app/App.vue +++ b/web/app/App.vue @@ -9,9 +9,16 @@ import VmPlacement from './VmPlacement.vue'; import ResponsibilityPanel from './ResponsibilityPanel.vue'; import AccountPanel from './AccountPanel.vue'; import RequirementsPanel from './RequirementsPanel.vue'; +import PhysicalFacts from './PhysicalFacts.vue'; +import PhysicalPositions from './PhysicalPositions.vue'; +import CableEnds from './CableEnds.vue'; +import AvailabilityAccess from './AvailabilityAccess.vue'; +import PhysicalOffers from './PhysicalOffers.vue'; import { softwareFields } from './software-fields'; import { equipmentFields } from './equipment-fields'; -import { ref, computed, watch, onMounted, nextTick } from 'vue'; +import { ref, computed, watch, onMounted, nextTick, defineAsyncComponent } from 'vue'; +const PowerPathsPanel=defineAsyncComponent(()=>import('./PowerPathsPanel.vue')); +const PhysicalPlanner=defineAsyncComponent(()=>import('./PhysicalPlanner.vue')); import { useRoute, useRouter } from 'vue-router'; import type { LocationQuery } from 'vue-router'; import AssetEditor from './AssetEditor.vue'; import SchemaEditor from './SchemaEditor.vue'; import ImportEditor from './ImportEditor.vue'; import ViewEditor from './ViewEditor.vue'; import NetworkEditor from './NetworkEditor.vue'; @@ -378,7 +385,14 @@ onMounted(async () => { try { await metadata(); await load(); } catch (e) { erro + + + + + + +

Showing one bounded evidence page. Observation counts and filters apply to this page.

@@ -386,7 +400,7 @@ onMounted(async () => { try { await metadata(); await load(); } catch (e) { erro

Typed relationships

No relationships visible in this scope. This does not establish absence outside it.

- + diff --git a/web/app/AssetEditor.vue b/web/app/AssetEditor.vue index a643c97..6b9b1c8 100644 --- a/web/app/AssetEditor.vue +++ b/web/app/AssetEditor.vue @@ -23,7 +23,7 @@ const equipmentChanges = 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)) ?? [] : []); +const equipmentFields = computed(()=>(kind.value===AssetKind.DEVICE||kind.value===AssetKind.PHYSICAL_COMPONENT) ? (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); diff --git a/web/app/AvailabilityAccess.vue b/web/app/AvailabilityAccess.vue new file mode 100644 index 0000000..43909e2 --- /dev/null +++ b/web/app/AvailabilityAccess.vue @@ -0,0 +1,20 @@ + + + diff --git a/web/app/BindingCheck.vue b/web/app/BindingCheck.vue new file mode 100644 index 0000000..b1337b3 --- /dev/null +++ b/web/app/BindingCheck.vue @@ -0,0 +1,22 @@ + + diff --git a/web/app/CableEnds.vue b/web/app/CableEnds.vue new file mode 100644 index 0000000..205bb6c --- /dev/null +++ b/web/app/CableEnds.vue @@ -0,0 +1,53 @@ + + + diff --git a/web/app/EndpointMappings.vue b/web/app/EndpointMappings.vue new file mode 100644 index 0000000..220fb16 --- /dev/null +++ b/web/app/EndpointMappings.vue @@ -0,0 +1,64 @@ + + + diff --git a/web/app/EquipmentField.vue b/web/app/EquipmentField.vue index f5851cf..d3da6b3 100644 --- a/web/app/EquipmentField.vue +++ b/web/app/EquipmentField.vue @@ -1,7 +1,7 @@ + + diff --git a/web/app/PhysicalFacts.vue b/web/app/PhysicalFacts.vue new file mode 100644 index 0000000..b4d7c0a --- /dev/null +++ b/web/app/PhysicalFacts.vue @@ -0,0 +1,180 @@ + + + diff --git a/web/app/PhysicalMaterials.vue b/web/app/PhysicalMaterials.vue new file mode 100644 index 0000000..3a0cd23 --- /dev/null +++ b/web/app/PhysicalMaterials.vue @@ -0,0 +1,123 @@ + + + diff --git a/web/app/PhysicalOffers.vue b/web/app/PhysicalOffers.vue new file mode 100644 index 0000000..d55c2cd --- /dev/null +++ b/web/app/PhysicalOffers.vue @@ -0,0 +1,46 @@ + + + diff --git a/web/app/PhysicalPlanner.vue b/web/app/PhysicalPlanner.vue new file mode 100644 index 0000000..46f91d7 --- /dev/null +++ b/web/app/PhysicalPlanner.vue @@ -0,0 +1,490 @@ + +