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..205b4bd 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())?; @@ -65,10 +71,36 @@ fn main() -> Result<(), Box> { && matches!(field.name(), "candidates" | "excluded") { 16 + } else if message.name() == "PowerPathRequirement" + && matches!(field.name(), "sources" | "boundaries") + { + 8 } 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..8934dee 100644 --- a/crates/omloom-contracts/tests/workspace.rs +++ b/crates/omloom-contracts/tests/workspace.rs @@ -1,6 +1,91 @@ use omloom_contracts::{Error, Limits, MemoryBudget, decode_workspace, workspace as api}; use prost::Message; +// The evaluator's eight-reference bound must reject oversized frames before decoded allocation. +#[test] +fn power_path_reference_counts_are_bounded_before_decode() { + for sources in [true, false] { + for count in [8, 9] { + let refs = vec![Default::default(); count]; + let mut paths = api::PowerPathRequirement::default(); + if sources { + paths.sources = refs; + } else { + paths.boundaries = refs; + } + let bytes = api::Request { + command: Some(api::request::Command::EvaluateRequirements( + api::EvaluateRequirements { + nodes: vec![api::RequirementNode { + expression: Some(api::requirement_node::Expression::PowerPaths(paths)), + }], + ..Default::default() + }, + )), + ..Default::default() + } + .encode_to_vec(); + let budget = MemoryBudget::new(16 * 1_048_576); + let decoded = decode_workspace(&bytes, Limits::default(), &budget); + if count == 9 { + assert!(matches!(decoded, Err(Error::Limit))); + } else { + assert!(decoded.is_ok()); + } + drop(decoded); + assert_eq!(budget.used(), 0); + } + } +} + +// 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-observe/src/command.rs b/crates/omloom-observe/src/command.rs index 4abb606..a183008 100644 --- a/crates/omloom-observe/src/command.rs +++ b/crates/omloom-observe/src/command.rs @@ -101,7 +101,7 @@ impl Runner { }; let result = match timeout_at(deadline, work).await { Ok(result) => result, - Err(_) => Err("Command deadline exceeded".into()), + Err(_) => Err(format!("Command deadline exceeded ({executable})")), }; if result.is_err() && child.id().is_some() { child diff --git a/crates/omloom-observe/src/command/tests.rs b/crates/omloom-observe/src/command/tests.rs index 3d300f5..399257e 100644 --- a/crates/omloom-observe/src/command/tests.rs +++ b/crates/omloom-observe/src/command/tests.rs @@ -119,9 +119,11 @@ async fn deadline_terminates_the_owned_child() { .await; #[cfg(not(windows))] let result = runner.run("/bin/sleep", &["5"]).await; - assert_eq!( - result.err().expect("deadline failure"), - "Command deadline exceeded" - ); + let message = result.err().expect("deadline failure"); + assert!(message.starts_with("Command deadline exceeded")); + #[cfg(windows)] + assert!(message.contains("cmd.exe")); + #[cfg(not(windows))] + assert!(message.contains("/bin/sleep")); assert_eq!(runner.remaining, initial); } diff --git a/crates/omloom-observe/src/main.rs b/crates/omloom-observe/src/main.rs index 56c793b..804c604 100644 --- a/crates/omloom-observe/src/main.rs +++ b/crates/omloom-observe/src/main.rs @@ -76,7 +76,9 @@ fn add(report: &mut api::HostReport, value: api::HostFinding) -> Result<()> { async fn collect() -> Result { let mut runner = command::Runner::new(); - let identity = native::identity(&mut runner).await?; + let identity = native::identity(&mut runner) + .await + .map_err(|error| format!("Initial host identity unavailable: {error}"))?; let native::Identity { platform, source, @@ -128,7 +130,9 @@ async fn collect() -> Result { }); native::collect(&mut runner, &mut report).await?; runtime::collect(&mut runner, &mut report).await?; - let after = native::identity(&mut runner).await?; + let after = native::identity(&mut runner) + .await + .map_err(|error| format!("Final host identity unavailable: {error}"))?; check_identity(&source, &boot, &after.source, &after.boot)?; if report.findings.first().and_then(|v| v.identity.first()) != after.hardware.as_ref() { return Err("Hardware evidence changed during collection; output not written".into()); @@ -203,6 +207,20 @@ fn save(path: &Path, bytes: &[u8]) -> Result<()> { .map_err(|e| format!("Report directory sync failed: {e}"))?; Ok(()) } +fn finish_collection(report: Result, path: &Path) -> Result<()> { + let report = match report { + Ok(report) => report, + Err(reason) => { + println!("Collection unavailable: {reason}. No report was written."); + return Ok(()); + } + }; + let bytes = encode(report)?; + save(path, &bytes)?; + println!("Report written. Import it in Omloom to review selected current facts."); + Ok(()) +} + #[tokio::main(flavor = "current_thread")] async fn main() -> Result<()> { let args: Vec<_> = std::env::args_os().skip(1).collect(); @@ -245,9 +263,5 @@ async fn main() -> Result<()> { } return Ok(()); } - let report = collect().await?; - let bytes = encode(report)?; - save(Path::new(&args[1]), &bytes)?; - println!("Report written. Import it in Omloom to review selected current facts."); - Ok(()) + finish_collection(collect().await, Path::new(&args[1])) } diff --git a/crates/omloom-observe/src/native.rs b/crates/omloom-observe/src/native.rs index 53f71d7..ae6eee9 100644 --- a/crates/omloom-observe/src/native.rs +++ b/crates/omloom-observe/src/native.rs @@ -100,7 +100,7 @@ fn known_address( } as i32, })) } -async fn ps(runner: &mut Runner, script: &str) -> Result { +async fn ps(runner: &mut Runner, source: &str, script: &str) -> Result { runner .run( "powershell.exe", @@ -114,6 +114,7 @@ async fn ps(runner: &mut Runner, script: &str) -> Result { ) .await .map(|v| v.text) + .map_err(|error| format!("{error}; source: {source}")) } pub(super) struct Identity { pub platform: &'static str, @@ -124,6 +125,31 @@ pub(super) struct Identity { pub installation: api::HostIdentityClaim, } +const WINDOWS_IDENTITY_SOURCE: &str = + "Windows identity (Win32_OperatingSystem, Win32_ComputerSystemProduct)"; +fn windows_identity(input: &str) -> Result { + (|| { + let value = json(input)?; + let source = string(&value, "source")?; + Ok(Identity { + platform: "windows", + source: source.into(), + boot: string(&value, "boot")?.into(), + name: string(&value, "name")?.into(), + hardware: value + .get("hardware") + .and_then(Value::as_str) + .and_then(|v| hardware_claim("smbios/system-uuid", v)), + installation: identity_claim( + api::HostIdentityKind::InstallationId, + "windows/machine-guid", + source, + )?, + }) + })() + .map_err(|error: String| format!("{error}; source: {WINDOWS_IDENTITY_SOURCE}")) +} + fn identity_claim( kind: api::HostIdentityKind, namespace: &str, @@ -220,23 +246,8 @@ pub(super) async fn identity(runner: &mut Runner) -> Result { }) } "windows" => { - let result = ps(runner, "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $os=Get-CimInstance Win32_OperatingSystem -Property LastBootUpTime; $cs=Get-CimInstance Win32_ComputerSystemProduct -Property Name,UUID; @{source=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid; boot=$os.LastBootUpTime.ToUniversalTime().ToString('o'); name=$cs.Name; hardware=$cs.UUID} | ConvertTo-Json -Compress").await?; - let value = json(&result)?; - Ok(Identity { - platform: "windows", - source: string(&value, "source")?.into(), - boot: string(&value, "boot")?.into(), - name: string(&value, "name")?.into(), - hardware: value - .get("hardware") - .and_then(Value::as_str) - .and_then(|v| hardware_claim("smbios/system-uuid", v)), - installation: identity_claim( - api::HostIdentityKind::InstallationId, - "windows/machine-guid", - string(&value, "source")?, - )?, - }) + let result = ps(runner, WINDOWS_IDENTITY_SOURCE, "$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $os=Get-CimInstance Win32_OperatingSystem -Property LastBootUpTime; $cs=Get-CimInstance Win32_ComputerSystemProduct -Property Name,UUID; @{source=(Get-ItemProperty 'HKLM:\\SOFTWARE\\Microsoft\\Cryptography').MachineGuid; boot=$os.LastBootUpTime.ToUniversalTime().ToString('o'); name=$cs.Name; hardware=$cs.UUID} | ConvertTo-Json -Compress").await?; + windows_identity(&result) } _ => Err("Native platform is not supported".into()), } @@ -248,9 +259,11 @@ fn result(report: &mut api::HostReport, module: &str, outcome: Result<()>, note: } } pub(super) fn failure_status(error: &str) -> &'static str { - if error == "Permission denied" { + if error.starts_with("Permission denied") { "blocked" - } else if error.starts_with("Command unavailable (") { + } else if error.starts_with("Command unavailable (") + || error.starts_with("Command deadline exceeded (") + { "unavailable" } else { "failed" @@ -527,7 +540,7 @@ async fn windows_interfaces( report: &mut api::HostReport, excluded: &mut std::collections::BTreeSet, ) -> Result<()> { - let output = ps(runner,"$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); ConvertTo-Json -InputObject @(Get-NetIPAddress | Select-Object InterfaceIndex,InterfaceAlias,IPAddress,PrefixLength,@{n='State';e={$_.AddressState.ToString()}},@{n='Origin';e={$_.SuffixOrigin.ToString()}}) -Depth 4 -Compress").await?; + let output = ps(runner,"Windows interfaces (Get-NetIPAddress)","$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); ConvertTo-Json -InputObject @(Get-NetIPAddress | Select-Object InterfaceIndex,InterfaceAlias,IPAddress,PrefixLength,@{n='State';e={$_.AddressState.ToString()}},@{n='Origin';e={$_.SuffixOrigin.ToString()}}) -Depth 4 -Compress").await?; let value = json(&output)?; let mut rows = std::collections::BTreeMap::::new(); for item in array(&value)? { @@ -664,40 +677,43 @@ async fn sockets(runner: &mut Runner, report: &mut api::HostReport) -> Result<() network.sockets_complete = complete; } "windows" => { - let output=ps(runner,"$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $rows=@(foreach($s in Get-NetTCPConnection -State Listen){@{protocol='tcp';bind=$s.LocalAddress;port=$s.LocalPort;pid=$s.OwningProcess}}; foreach($s in Get-NetUDPEndpoint){@{protocol='udp';bind=$s.LocalAddress;port=$s.LocalPort;pid=$s.OwningProcess}}); ConvertTo-Json -InputObject @($rows) -Depth 4 -Compress").await?; - let values = json(&output)?; - for row in array(&values)? { - let ip = string(row, "bind")?; - let pid = row - .get("pid") - .and_then(Value::as_u64) - .ok_or("Missing socket owner")?; - let port = row - .get("port") - .and_then(Value::as_u64) - .ok_or("Missing socket port")? - .try_into() - .map_err(|_| "Invalid port")?; - bind( - report, - pid, - &format!("Process {pid}"), - string(row, "protocol")?, - if ip.contains(':') { "IPv6" } else { "IPv4" }, - ip, - port, - )?; - } - let network = report.network.as_mut().ok_or("Missing network")?; - network.socket_coverage = - "Windows kernel endpoint tables; process names and executable attribution unknown" - .into(); - network.sockets_complete = true; + let output=ps(runner,"Windows sockets (Get-NetTCPConnection, Get-NetUDPEndpoint)","$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); $rows=@(foreach($s in Get-NetTCPConnection -State Listen){@{protocol='tcp';bind=$s.LocalAddress;port=$s.LocalPort;pid=$s.OwningProcess}}; foreach($s in Get-NetUDPEndpoint){@{protocol='udp';bind=$s.LocalAddress;port=$s.LocalPort;pid=$s.OwningProcess}}); ConvertTo-Json -InputObject @($rows) -Depth 4 -Compress").await?; + windows_sockets(&output, report)?; } _ => return Err("Unsupported socket platform".into()), } Ok(()) } +fn windows_sockets(input: &str, report: &mut api::HostReport) -> Result<()> { + let values = json(input)?; + for row in array(&values)? { + let ip = string(row, "bind")?; + let pid = row + .get("pid") + .and_then(Value::as_u64) + .ok_or("Missing socket owner")?; + let port = row + .get("port") + .and_then(Value::as_u64) + .ok_or("Missing socket port")? + .try_into() + .map_err(|_| "Invalid port")?; + bind( + report, + pid, + &format!("Process {pid}"), + string(row, "protocol")?, + if ip.contains(':') { "IPv6" } else { "IPv4" }, + ip, + port, + )?; + } + let network = report.network.as_mut().ok_or("Missing network")?; + network.socket_coverage = + "Windows kernel endpoint tables; process names and executable attribution unknown".into(); + network.sockets_complete = true; + Ok(()) +} fn bind( report: &mut api::HostReport, pid: u64, @@ -993,29 +1009,8 @@ async fn routes(runner: &mut Runner, report: &mut api::HostReport) -> Result<()> } } "windows" => { - let output=ps(runner,"$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); ConvertTo-Json -InputObject @(Get-NetRoute -IncludeAllCompartments | Select-Object DestinationPrefix,NextHop,InterfaceIndex,CompartmentId,@{n='Family';e={$_.AddressFamily.ToString()}},@{n='Protocol';e={$_.Protocol.ToString()}}) -Depth 4 -Compress").await?; - let values = json(&output)?; - for value in array(&values)? { - rows.push(api::LocalRoute { - destination: string(value, "DestinationPrefix")?.into(), - gateway: string(value, "NextHop")?.into(), - interface: value - .get("InterfaceIndex") - .and_then(Value::as_u64) - .ok_or("Route interface missing")? - .to_string(), - family: string(value, "Family")?.into(), - flags: format!( - "protocol={};compartment={}", - string(value, "Protocol")?, - value - .get("CompartmentId") - .and_then(Value::as_u64) - .ok_or("Route compartment missing")? - ), - link_layer: false, - }); - } + let output=ps(runner,"Windows routes (Get-NetRoute)","$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); ConvertTo-Json -InputObject @(Get-NetRoute -IncludeAllCompartments | Select-Object DestinationPrefix,NextHop,InterfaceIndex,CompartmentId,@{n='Family';e={$_.AddressFamily.ToString()}},@{n='Protocol';e={$_.Protocol.ToString()}}) -Depth 4 -Compress").await?; + rows = windows_routes(&output)?; } _ => return Err("Unsupported route platform".into()), } @@ -1027,6 +1022,33 @@ async fn routes(runner: &mut Runner, report: &mut api::HostReport) -> Result<()> Ok(()) } +fn windows_routes(input: &str) -> Result> { + let values = json(input)?; + let values = array(&values)?; + let mut rows = Vec::with_capacity(values.len()); + for value in values { + rows.push(api::LocalRoute { + destination: string(value, "DestinationPrefix")?.into(), + gateway: string(value, "NextHop")?.into(), + interface: value + .get("InterfaceIndex") + .and_then(Value::as_u64) + .ok_or("Route interface missing")? + .to_string(), + family: string(value, "Family")?.into(), + flags: format!( + "protocol={};compartment={}", + string(value, "Protocol")?, + value + .get("CompartmentId") + .and_then(Value::as_u64) + .ok_or("Route compartment missing")? + ), + link_layer: false, + }); + } + Ok(rows) +} fn route_qualifiers(value: &Value) -> Result { let mut flags = Vec::new(); for name in ["table", "type", "protocol", "scope", "metric"] { @@ -1183,7 +1205,7 @@ async fn firewall(runner: &mut Runner, report: &mut api::HostReport) -> Result<( } "windows" => { let outcome: Result = async { - let output=ps(runner,"$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); ConvertTo-Json -InputObject @(Get-NetFirewallProfile -PolicyStore ActiveStore | Select-Object Name,@{n='Enabled';e={$_.Enabled.ToString()}},@{n='Inbound';e={$_.DefaultInboundAction.ToString()}},@{n='Outbound';e={$_.DefaultOutboundAction.ToString()}}) -Depth 4 -Compress").await?; + let output=ps(runner,"Windows firewall (Get-NetFirewallProfile)","$ErrorActionPreference='Stop'; [Console]::OutputEncoding=[Text.UTF8Encoding]::new($false); ConvertTo-Json -InputObject @(Get-NetFirewallProfile -PolicyStore ActiveStore | Select-Object Name,@{n='Enabled';e={$_.Enabled.ToString()}},@{n='Inbound';e={$_.DefaultInboundAction.ToString()}},@{n='Outbound';e={$_.DefaultOutboundAction.ToString()}}) -Depth 4 -Compress").await?; let value = json(&output)?; let mut profiles = Vec::new(); for row in array(&value)? { diff --git a/crates/omloom-observe/src/native/tests.rs b/crates/omloom-observe/src/native/tests.rs index 39107bb..2b85d07 100644 --- a/crates/omloom-observe/src/native/tests.rs +++ b/crates/omloom-observe/src/native/tests.rs @@ -1,16 +1,106 @@ use super::*; +// Readable Windows tables must produce verified coverage and preserve their endpoint/route values. +#[test] +fn readable_windows_tables_have_verified_coverage() -> Result<()> { + let mut report = api::HostReport { + boot_basis: "fixture-boot".into(), + network: Some(api::HostNetwork::default()), + ..Default::default() + }; + let sockets = windows_sockets( + r#"[{"protocol":"tcp","bind":"0.0.0.0","port":443,"pid":17}]"#, + &mut report, + ); + assert!(sockets.is_ok()); + result( + &mut report, + "sockets", + sockets, + "Readable Windows endpoint fixture", + ); + let routes = windows_routes( + r#"[{"DestinationPrefix":"192.0.2.0/24","NextHop":"192.0.2.1","InterfaceIndex":5,"Family":"IPv4","Protocol":"Local","CompartmentId":1}]"#, + )?; + assert_eq!(routes.len(), 1); + assert_eq!(routes[0].destination, "192.0.2.0/24"); + assert_eq!(routes[0].interface, "5"); + result( + &mut report, + "routes", + Ok(()), + "Readable Windows route fixture", + ); + assert_eq!(report.binds.len(), 1); + assert_eq!(report.binds[0].value.as_ref().expect("socket").port, 443); + assert!(report.network.as_ref().expect("network").sockets_complete); + assert_eq!( + report + .coverage + .iter() + .map(|v| (v.module.as_str(), v.status.as_str())) + .collect::>(), + [("sockets", "verified"), ("routes", "verified")] + ); + assert!(windows_routes(r#"[{"DestinationPrefix":"192.0.2.0/24"}]"#).is_err()); + assert!(windows_sockets(r#"[{"protocol":"tcp"}]"#, &mut report).is_err()); + Ok(()) +} + +// Parser failures identify the same native query as command failures without exposing source data. +#[test] +fn windows_identity_parser_errors_name_the_source() { + for input in [ + "untrusted-private-output", + "{}", + r#"{"source":"installation"}"#, + r#"{"source":"installation","boot":"session"}"#, + ] { + let error = windows_identity(input).err().expect("incomplete source"); + assert!(error.contains(WINDOWS_IDENTITY_SOURCE), "{error}"); + assert!(!error.contains("untrusted-private-output")); + } +} + +// Command timeouts leave source coverage unavailable, never verified or a fatal collection status. +#[test] +fn timed_out_sources_are_unavailable_with_safe_command_context() { + assert_eq!( + failure_status( + "Command deadline exceeded (powershell.exe); source: Windows routes (Get-NetRoute)" + ), + "unavailable" + ); + assert_eq!( + failure_status("Permission denied; source: Windows firewall (Get-NetFirewallProfile)"), + "blocked" + ); +} + // Exercise the native OS source on each qualified host, without assuming that // an unreadable or unset hardware UUID exists or inventing one from an address. #[tokio::test] async fn native_identity_observation_keeps_layers_separate() -> Result<()> { - let value = identity(&mut Runner::new()).await?; let namespace = match std::env::consts::OS { "macos" => "macos/apfs-volume", "linux" => "linux/machine-id", "windows" => "windows/machine-guid", _ => return Err("Unsupported native identity platform".into()), }; + let value = match identity(&mut Runner::new()).await { + Ok(value) => value, + Err(error) => { + let dir = tempfile::tempdir().expect("temporary directory"); + let path = dir.path().join("unavailable.omloom"); + assert!( + crate::tests::source_unavailable(&error), + "Unexpected identity failure: {error}" + ); + crate::finish_collection(Err(error), &path)?; + assert!(!path.exists()); + return Ok(()); + } + }; assert!(!value.source.is_empty()); assert!(!value.boot.is_empty()); assert_eq!(value.installation.namespace, namespace); diff --git a/crates/omloom-observe/src/tests.rs b/crates/omloom-observe/src/tests.rs index 50d87bc..155d614 100644 --- a/crates/omloom-observe/src/tests.rs +++ b/crates/omloom-observe/src/tests.rs @@ -242,15 +242,113 @@ fn temporary_literals_are_removed_from_routes_and_binds() { assert!(value.network.expect("network").routes.is_empty()); } -// This runs on the actual native host. Fixtures cannot substitute for working -// OS reads, coherent boot evidence or a file accepted by the common decoder. +// An unavailable source is a collection outcome, not a crash or an empty inventory. +#[test] +fn unavailable_collection_does_not_publish_or_fail_the_command() { + let dir = tempfile::tempdir().expect("temporary directory"); + let path = dir.path().join("unavailable.omloom"); + assert_eq!( + finish_collection( + Err("Windows identity: command deadline exceeded (powershell.exe)".into()), + &path + ), + Ok(()) + ); + assert!(!path.exists()); + std::fs::write(&path, b"existing report").expect("existing output"); + assert_eq!( + finish_collection(Err("Interfaces unavailable".into()), &path), + Ok(()) + ); + assert_eq!( + std::fs::read(&path).expect("preserved file"), + b"existing report" + ); +} + +// Source unavailability cannot turn storage/publication failures into success. +#[test] +fn collection_publication_failures_remain_errors() { + let dir = tempfile::tempdir().expect("temporary directory"); + let path = dir.path().join("existing.omloom"); + std::fs::write(&path, b"original report").expect("existing output"); + let error = finish_collection(Ok(report()), &path).expect_err("no replacement"); + assert!(error.contains("Cannot publish report"), "{error}"); + assert_eq!( + std::fs::read(path).expect("preserved output"), + b"original report" + ); +} + +pub(super) fn source_unavailable(reason: &str) -> bool { + reason.contains("Command unavailable (") + || reason.contains("Command deadline exceeded (") + || reason.contains("Permission denied") + || reason.contains("Collection execution budget exhausted") + || reason.contains("Collection byte budget exhausted") + || reason.contains("Hardware evidence changed during collection") + || reason.contains("Installation or boot changed during collection") +} + +// Unavailable commands are normal, but parsing and execution defects must not skip native assertions. +#[test] +fn native_availability_does_not_hide_implementation_errors() { + for reason in [ + "Invalid structured output; source: Windows identity", + "Missing source field boot", + "Invalid native identity claim", + "Command wait failed", + "Cannot terminate failed command", + ] { + assert!(!source_unavailable(reason), "{reason}"); + } + for reason in [ + "Initial host identity: Command unavailable (powershell.exe): NotFound", + "Required interface table unavailable: Permission denied", + "Command deadline exceeded (powershell.exe)", + "Collection execution budget exhausted", + "Installation or boot changed during collection; output not written", + ] { + assert!(source_unavailable(reason), "{reason}"); + } +} + +fn source_coverage_valid(status: &str) -> bool { + ["verified", "partial", "blocked", "unavailable"].contains(&status) +} + +// Qualification accepts availability limits, but never an unexplained failure of a readable table. +#[test] +fn failed_native_sources_do_not_qualify_readable_tables() { + assert!(!source_coverage_valid("failed")); + assert!(!source_coverage_valid("unknown")); + for status in ["verified", "partial", "blocked", "unavailable"] { + assert!(source_coverage_valid(status)); + } +} + +// Native availability is reported honestly. A failed source cannot publish an +// empty inventory; available sources retain coherent boot and decoder checks. #[tokio::test] -async fn native_host_collection_has_a_decodable_core() { - let report = collect().await.expect("native collection"); +async fn native_host_collection_reports_verified_or_unavailable_sources() { + let report = match collect().await { + Ok(report) => report, + Err(reason) => { + assert!( + source_unavailable(&reason), + "Unexpected native failure: {reason}" + ); + let dir = tempfile::tempdir().expect("temporary directory"); + let path = dir.path().join("unavailable.omloom"); + finish_collection(Err(reason), &path).expect("normal unavailable outcome"); + assert!(!path.exists()); + return; + } + }; for coverage in &report.coverage { println!("native module {}: {}", coverage.module, coverage.status); - if coverage.module == "docker" && coverage.status != "verified" { - println!("native Docker diagnostic: {}", coverage.detail); + if coverage.status != "verified" { + println!("native source {}: {}", coverage.module, coverage.detail); } } println!( @@ -272,7 +370,12 @@ async fn native_host_collection_has_a_decodable_core() { .iter() .find(|v| v.module == module) .expect("attempted native module"); - assert_eq!(coverage.status, "verified", "{module}: {}", coverage.detail); + assert!( + source_coverage_valid(&coverage.status), + "{module}: {}", + coverage.status + ); + assert!(!coverage.detail.is_empty()); } assert!( report 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/correction.rs b/crates/omloom-store/src/disk/workspace/correction.rs index ebe28db..704bb58 100644 --- a/crates/omloom-store/src/disk/workspace/correction.rs +++ b/crates/omloom-store/src/disk/workspace/correction.rs @@ -226,6 +226,9 @@ impl Tx<'_> { let final_to = *mappings.get(&to).unwrap_or(&to); if !destinations.insert((final_from, final_to, before.kind)) { redundant_ids.insert(before.relation_id.clone()); + if before.kind == wire::RelationKind::InstalledIn as i32 { + self.remove_physical_pose(&before.relation_id)?; + } self.delete(&format!("relation/{}", before.relation_id))?; } } @@ -263,6 +266,9 @@ impl Tx<'_> { .find(|r| r.from == after.from && r.to == after.to && r.kind == after.kind); if let Some(retained) = redundant { self.rebind_correction_responsibility(context, &before, &retained)?; + if before.kind == wire::RelationKind::InstalledIn as i32 { + self.remove_physical_pose(&before.relation_id)?; + } self.delete(&format!("relation/{}", before.relation_id))?; impacts.push(api::IdentityImpact { before: Some(before), diff --git a/crates/omloom-store/src/disk/workspace/correction/undo.rs b/crates/omloom-store/src/disk/workspace/correction/undo.rs index cf34df3..aa51e94 100644 --- a/crates/omloom-store/src/disk/workspace/correction/undo.rs +++ b/crates/omloom-store/src/disk/workspace/correction/undo.rs @@ -1,5 +1,33 @@ use super::*; +impl Tx<'_> { + fn restore_correction_row(&mut self, key: &str, before: &Option) -> Result<()> { + match before { + None => self.delete(key)?, + Some(before) => { + let parent = string(before, "parent")?; + let class = match number(before, "class")? { + 0 => Class::Inventory, + 5 => Class::Checkpoint, + _ => return Err(Error::Corrupt), + }; + let mut data = strip(before.clone()); + if parent == "assets" { + let prior = number(before, "revision")?; + let revision = match self.get(key)? { + Some(current) => number(¤t, "revision")?.max(prior).checked_add(1), + None => prior.checked_add(2), + } + .ok_or(Error::Limit)?; + data.insert("revision".into(), int(revision)?); + } + self.put(key, &parent, class, data)?; + } + } + Ok(()) + } +} + impl Store { pub(super) fn undo_identity_correction( &self, @@ -122,33 +150,10 @@ impl Store { tx.unlink_correction_relation(item)?; } for (key, before) in &inverse.rows { - if key.starts_with("relation/") { + if key.starts_with("relation/") || key.starts_with("physical-pose/") { continue; } - match before { - None => tx.delete(key)?, - Some(before) => { - let parent = string(before, "parent")?; - let class = match number(before, "class")? { - 0 => Class::Inventory, - 5 => Class::Checkpoint, - _ => return Err(Error::Corrupt), - }; - let mut data = strip(before.clone()); - if parent == "assets" { - let prior = number(before, "revision")?; - let revision = match tx.get(key)? { - Some(current) => { - number(¤t, "revision")?.max(prior).checked_add(1) - } - None => prior.checked_add(2), - } - .ok_or(Error::Limit)?; - data.insert("revision".into(), int(revision)?); - } - tx.put(key, &parent, class, data)?; - } - } + tx.restore_correction_row(key, before)?; } for (key, before) in &inverse.rows { if !key.starts_with("relation/") { @@ -171,6 +176,12 @@ impl Store { tx.save_rebound_relation(&restored, expected, &context.actor, now)?; tx.rebind_correction_responsibility(context, &original, &restored)?; } + // Rebinding invalidates geometry, so restore dependent poses after their exact bindings. + for (key, before) in &inverse.rows { + if key.starts_with("physical-pose/") { + tx.restore_correction_row(key, before)?; + } + } // Validate the fully restored topology, including retired roots, before // committing. A partial restoration is never exposed to another reader. let selection = tx.selection(context, working, false)?; 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..aa4139e --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical.rs @@ -0,0 +1,838 @@ +//! 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_allocation(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() || value.replacement.is_some() => + { + Some(Placement { + item: &value.replacement, + material: None, + enclosure: &value.position, + volume: &value.volume, + }) + } + _ => None, + } +} +fn allocation_steps(plan: &api::PhysicalPlan) -> impl Iterator)> { + plan.steps + .iter() + .enumerate() + .filter_map(|(index, step)| step_allocation(step).map(|p| (index, p))) +} +fn step_placement(step: &api::PhysicalStep) -> Option> { + let allocation = step_allocation(step)?; + if matches!( + step.action, + Some(api::physical_step::Action::Replacement(_)) + ) && allocation.volume.is_none() + { + return None; + } + Some(allocation) +} +fn shared_enclosure(kind: u64) -> bool { + kind == wire::AssetKind::Site as u64 || kind == wire::AssetKind::Location as u64 +} +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.trim().is_empty() + || value.basis.len() > 512 + || value.basis.chars().any(char::is_control) + || value.mounting.as_ref().is_some_and(|s| { + s.trim().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..92fa69f --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/admission.rs @@ -0,0 +1,577 @@ +//! 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, true)?; + 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, + )?); + } + } + } + // Site/location membership consumes shared budgets even without a geometric pose. + for (_, allocation) in allocation_steps(plan) { + if allocation.volume.is_some() { + continue; + } + let enclosure = parse_target(allocation.enclosure.as_ref())?; + if !shared_enclosure(number(&self.asset(enclosure)?, "kind")?) { + continue; + } + if let std::collections::btree_map::Entry::Vacant(entry) = enclosures.entry(enclosure) { + let definition = self + .physical_definition(enclosure)? + .ok_or(Error::Incomplete)?; + if definition.max_mass_g.is_none() + && definition.capacity_mw.is_none() + && definition.cooling_capacity_mw.is_none() + { + continue; + } + let occupied = + self.physical_use(enclosure, plan, &graph.ancestors, 2048 - rows_read, false)?; + rows_read += occupied.inspected; + entry.insert(occupied); + } + enclosures + .get_mut(&enclosure) + .ok_or(Error::Corrupt)? + .items + .insert( + allocation + .actual(&plan.materials)? + .ok_or(Error::Incomplete)?, + ); + } + // 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 allocation_steps(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())?; + // Explicit allocations are checked at their real coordinates by placement admission. + if replacement.volume.is_none() { + 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..021ba1b --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/availability.rs @@ -0,0 +1,283 @@ +//! 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, + true, + )?; + 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..3b53c5e --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/completion.rs @@ -0,0 +1,408 @@ +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 { + // Unreported and declined work must not acquire resources belonging to other plans. + for (&index, &done) in &selected { + if !done { + continue; + } + let step = &plan.steps[index as usize]; + 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)) + }); + // Complete also refreshes postconditions; an unstarted plan must not become partial. + let reported = plan.steps.iter().any(|step| { + matches!( + api::PhysicalStepState::try_from(step.state), + Ok(api::PhysicalStepState::Recorded | api::PhysicalStepState::NotPerformed) + ) + }); + let next = if !reported { + state + } else 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..82718d0 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/current.rs @@ -0,0 +1,448 @@ +//! 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, + geometry: bool, + ) -> 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 allocation_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 = + Vec::with_capacity(pending.len() + if geometry { bindings.len() } else { 0 }); + if geometry { + for (id, _, _) in &bindings { + keys.push(text(pose_key(id))); + } + } + 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 geometric_capacity = if geometry { + bindings.len() + pending.len() + } else { + 0 + }; + let mut output = PhysicalUse { + definition, + volumes: Vec::with_capacity(geometric_capacity), + conditional_removals: Vec::with_capacity(removed.len().min(bindings.len())), + items: BTreeSet::new(), + future_loads: Vec::new(), + inspected, + }; + let mut bodies = Vec::with_capacity(geometric_capacity); + let mut removals = Vec::with_capacity(geometric_capacity); + for (id, item, removal) in bindings { + output.items.insert(item); + if !geometry { + if let Some(removal) = removal { + output.conditional_removals.push((removal, None)); + } + continue; + } + 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_allocation(&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())?; + output.items.insert(item); + if !geometry { + continue; + } + // A capacity-only reservation cannot certify geometric availability. + let body = placement.volume.as_ref().ok_or(Error::Incomplete)?; + bodies.push((item, volume(body)?)); + 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); + } + if geometry { + 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); + if geometry { + 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, + )?); + } + } + } + if !geometry { + return Ok(output); + } + 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..99d0a30 --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/physical/lifecycle.rs @@ -0,0 +1,232 @@ +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 allocation_steps(&plan) { + let enclosure = parse_target(p.enclosure.as_ref())?; + if p.volume.is_none() && !shared_enclosure(number(&self.asset(enclosure)?, "kind")?) { + continue; + } + 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..82a6305 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical.rs @@ -0,0 +1,2283 @@ +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; +#[path = "physical/shared_loads.rs"] +mod shared_loads; + +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), + } +} + +// Occupancy and power-completeness evidence require meaningful attribution before any write. +#[test] +fn blank_physical_definition_basis_is_rejected_without_writes() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let device = create(&store, &actor, K::Device, "Measured power consumer")?; + let before = store.usage()?; + for basis in [" ", "\u{2003}", "\u{a0}"] { + let mut definition = define(device.clone()); + definition.basis = basis.into(); + definition.power_sources_complete = true; + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition) + }) + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + } + Ok(()) +} + +// Blank mounting labels cannot become matching rack/item qualifications or produce durable rows. +#[test] +fn blank_physical_mounting_is_rejected_without_writes() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Mounting enclosure")?; + let item = create(&store, &actor, K::Device, "Mounting item")?; + let before = store.usage()?; + for target in [rack, item] { + for mounting in [" ", "\u{2003}", "\u{a0}"] { + let mut definition = define(target.clone()); + definition.mounting = Some(mounting.into()); + assert!(matches!( + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(definition) + }) + ), + Err(Error::Invalid) + )); + assert_eq!(store.usage()?, before); + } + } + Ok(()) +} + +// 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..256d864 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/admission.rs @@ -0,0 +1,1102 @@ +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() + }) +} + +// Explicit allocations can leave front/side service space before the incoming body. +#[test] +fn replacement_clearance_uses_the_proposed_allocation() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let mut replacement = rack_replacement_fixture(&store, &actor)?; + let incoming = replacement.replacement.clone().ok_or(Error::Corrupt)?; + let mut enclosure = define(replacement.position.clone().ok_or(Error::Corrupt)?); + enclosure.mounting = Some("rack-19".into()); + enclosure.capacity_mw = Some(20); + enclosure.cooling_capacity_mw = Some(20); + enclosure + .volume + .as_mut() + .ok_or(Error::Corrupt)? + .x + .as_mut() + .ok_or(Error::Corrupt)? + .length_um += 1; + enclosure.occupancy_coverage = enclosure.volume.map(Box::new); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(enclosure), + }), + )?; + let mut item = define(incoming.clone()); + item.mounting = Some("rack-19".into()); + item.demand_mw = Some(20); + item.cooling_mw = Some(20); + item.clearance = Some(Box::new(api::PhysicalClearance { + before_x_um: 1, + ..Default::default() + })); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(item), + }), + )?; + replacement + .volume + .as_mut() + .ok_or(Error::Corrupt)? + .x + .as_mut() + .ok_or(Error::Corrupt)? + .start_um = 1; + 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::Satisfied as i32); + assert_eq!(store.usage()?, before); + let reserved = reserve(&store, &actor, plan)?.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], + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!(completed.state, api::PhysicalPlanState::Completed as i32); + Ok(()) +} + +// 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..ef4500c --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/cables.rs @@ -0,0 +1,757 @@ +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)?; + let mut plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + assert_eq!(plan.state, api::PhysicalPlanState::Reserved as i32); + for index in 0..3 { + plan = 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![index], + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + } + assert_eq!(plan.state, api::PhysicalPlanState::Completed as i32); + let Some(api::physical_step::Action::Wiring(last)) = &plan.steps[2].action else { + return Err(Error::Corrupt.into()); + }; + let view = ends( + &store, + &actor, + last.cable.as_ref().ok_or(Error::Corrupt)?, + None, + )?; + let end = view + .cable_ends + .iter() + .find(|e| e.key == last.end_key) + .ok_or(Error::Corrupt)?; + let Some(api::physical_cable_end::Attachment::Connected(binding)) = &end.attachment else { + return Err(Error::Corrupt.into()); + }; + assert_eq!(binding.port, Some(port)); + 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..4a6a6af --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/endpoint_mappings.rs @@ -0,0 +1,388 @@ +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")?; + let far_owner = create(&store, &actor, K::Device, "Far chassis")?; + let far = port(&store, &actor, &far_owner, "Far port")?; + let cable = create(&store, &actor, K::Cable, "Retained cable route")?; + for endpoint in [&a, &far] { + call( + &store, + &actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(cable.clone()), + to: Some(endpoint.clone()), + kind: wire::RelationKind::ConnectedTo as i32, + ..Default::default() + }), + remove: false, + }), + )?; + } + let mut cable_bindings = store.relations(AssetRef::try_from(&cable)?, 32)?; + cable_bindings.sort_by(|a, b| a.relation_id.cmp(&b.relation_id)); + 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); + let mut after_cable = store.relations(AssetRef::try_from(&cable)?, 32)?; + after_cable.sort_by(|a, b| a.relation_id.cmp(&b.relation_id)); + assert_eq!( + after_cable, cable_bindings, + "Mapping neither reparents the near cable attachment nor alters its far end" + ); + 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..5727254 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/occupancy.rs @@ -0,0 +1,501 @@ +use super::*; + +// Reporting performed work must not acquire unreported or declined assets held by another plan. +#[test] +fn draft_actual_report_does_not_lock_other_steps() -> TestResult { + for decline in [false, true] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::Rack, "Reported enclosure")?; + let b = create(&store, &actor, K::Rack, "Other 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, "Performed installation")?; + let second = create(&store, &actor, K::Device, "Separately reserved item")?; + 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.clone()); + p.enclosure = Some(b.clone()); + plan.steps.push(step); + let plan = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + let other = draft(&store, &actor, second.clone(), b.clone())?; + reserve(&store, &actor, other)?; + let reported = 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], + not_performed_steps: if decline { vec![1] } else { vec![] }, + actual_state: true, + discrepancy: Some("Only the first installation was performed".into()), + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!( + reported.steps[0].state, + api::PhysicalStepState::Recorded as i32 + ); + assert_eq!( + reported.steps[1].state, + if decline { + api::PhysicalStepState::NotPerformed + } else { + api::PhysicalStepState::Planned + } as i32 + ); + assert_eq!(binding(&store, &first)?.to, Some(a)); + let third = draft(&store, &actor, second, b)?; + let before = store.usage()?; + assert!(matches!( + reserve(&store, &actor, third), + Err(Error::Conflict) + )); + assert_eq!( + store.usage()?, + before, + "The other reservation remains authoritative" + ); + } + Ok(()) +} + +// Empty refreshes do not claim work started, bump revisions or write audit/history rows. +#[test] +fn empty_completion_keeps_unstarted_plans_unchanged() -> TestResult { + for reserved in [true, false] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Unstarted enclosure")?; + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(define(rack.clone())), + }), + )?; + let item = create(&store, &actor, K::Device, "Unstarted installation")?; + equipment_dimensions(&store, &actor, &item)?; + let mut plan = draft(&store, &actor, item, rack)?; + if reserved { + plan = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + } + for actual_state in [true, false] { + if !reserved && !actual_state { + continue; + } + let before = store.usage()?; + let refreshed = physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: plan.id.clone().ok_or(Error::Corrupt)?, + revision: plan.revision, + }), + actual_state, + ..Default::default() + }), + )? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!(refreshed.state, plan.state); + assert_eq!(refreshed.revision, plan.revision); + assert_eq!(refreshed.steps[0].state, plan.steps[0].state); + assert_eq!(store.usage()?, before); + } + } + Ok(()) +} + +// 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(()) +} + +// Deduplication removes orphan poses; only an unchanged binding keeps geometry, and undo restores both. +#[test] +fn identity_correction_reclaims_removed_binding_poses_and_undo_restores_them() -> TestResult { + for retained_installed in [true, false] { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let rack = create(&store, &actor, K::Rack, "Shared physical enclosure")?; + let retained = create(&store, &actor, K::Device, "Retained identity")?; + let first = create(&store, &actor, K::Device, "First duplicate identity")?; + let second = if retained_installed { + retained.clone() + } else { + create(&store, &actor, K::Device, "Second duplicate identity")? + }; + for target in [&first, &second] { + let plan = draft(&store, &actor, target.clone(), rack.clone()).expect("physical draft"); + report_steps(&store, &actor, &plan, vec![0]).expect("physical actual report"); + } + let poses = || { + store.diagnostic_database().execute_cypher_shared( + "MATCH (n:OmRow {parent:'physical-poses'}) RETURN n.key AS key, n.x_length AS x, n.y_length AS y, n.z_length AS z, n.actor AS actor ORDER BY n.key LIMIT 8", None, None, None, None + ) + }; + let before = poses()?.rows; + assert_eq!(before.len(), 2); + let correct = |request: api::IdentityCorrection| -> Result { + store + .workspace( + &actor, + api::Request { + working: Some(api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::CorrectIdentity(request)), + }, + 100, + )? + .result + .ok_or(Error::Corrupt) + }; + let duplicates = if retained_installed { + vec![first] + } else { + vec![first, second] + }; + let mut mappings = Vec::new(); + for duplicate in duplicates { + mappings.push(api::IdentityMapping { + attachments: vec![binding(&store, &duplicate)?.relation_id], + duplicate: Some(duplicate), + retained: Some(retained.clone()), + fields: vec![api::IdentityFieldChoice { + name: "name".into(), + use_duplicate: false, + }], + ..Default::default() + }); + } + let Reply::IdentityCorrection(preview) = correct(api::IdentityCorrection { + decision: api::IdentityDecision::DuplicateInstallation as i32, + mappings, + ..Default::default() + }) + .expect("identity correction preview") else { + return Err(Error::Corrupt.into()); + }; + let Reply::IdentityCorrected(applied) = correct(api::IdentityCorrection { + preview: Some(preview.id), + ..Default::default() + }) + .expect("identity correction apply") else { + return Err(Error::Corrupt.into()); + }; + let current = poses()?.rows; + assert_eq!( + current.len(), + usize::from(retained_installed), + "Only unchanged retained bindings keep their measured geometry" + ); + let survivor = binding(&store, &retained)?; + if retained_installed { + assert_eq!( + current[0].get("key").and_then(|v| v.as_str()), + Some(format!("physical-pose/{}", survivor.relation_id).as_str()) + ); + assert!(before.contains(¤t[0])); + } + let projected = physical( + &store, + &actor, + api::physical::Action::Read(api::PhysicalRead { + enclosure: Some(rack), + occupants: true, + after: None, + }), + )?; + assert_eq!(projected.occupancy.len(), 1); + let placement = projected.occupancy[0] + .placement + .as_ref() + .ok_or(Error::Corrupt)?; + assert_eq!(placement.item.as_ref(), Some(&retained)); + assert_eq!( + placement.volume.is_some(), + retained_installed, + "A rewritten binding requires fresh geometry rather than transferring an old basis" + ); + correct(api::IdentityCorrection { + undo: Some(applied.id), + ..Default::default() + })?; + let restored = poses()?.rows; + assert_eq!( + restored.len(), + 2, + "Undo restores geometry after rebinding both original identities" + ); + assert_eq!( + restored, before, + "Undo restores both poses and their original reporters" + ); + } + 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/crates/omloom-store/tests/workspace/physical/shared_loads.rs b/crates/omloom-store/tests/workspace/physical/shared_loads.rs new file mode 100644 index 0000000..ad803e0 --- /dev/null +++ b/crates/omloom-store/tests/workspace/physical/shared_loads.rs @@ -0,0 +1,282 @@ +use super::*; + +fn loads(store: &Store, actor: &Context, item: &wire::AssetRef, value: u64) -> TestResult { + let mut profile = define(item.clone()); + profile.mounting = Some("floor-standing".into()); + profile.demand_mw = Some(value); + profile.cooling_mw = Some(value); + physical( + store, + actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(profile), + }), + )?; + let asset = store.asset(AssetRef::try_from(item)?)?; + call( + store, + actor, + Command::Write(api::Write { + target: Some(item.clone()), + expected: Some(asset.revision), + kind: asset.kind, + changes: vec![equipment_quantity("weight", i64::try_from(value)?, "g", 0)], + ..Default::default() + }), + )?; + Ok(()) +} + +fn fixture( + store: &Store, + actor: &Context, + kind: K, + axis: usize, +) -> Result<(api::PhysicalReplacement, wire::AssetRef), Box> { + let enclosure = create(store, actor, kind, "Shared enclosure")?; + let old = create(store, actor, K::Device, "Previous machine")?; + let retained = create(store, actor, K::Device, "Retained machine")?; + let incoming = create(store, actor, K::Device, "Incoming machine")?; + let mut profile = define(enclosure.clone()); + profile.mounting = Some("floor-standing".into()); + match axis { + 0 => profile.max_mass_g = Some(100), + 1 => profile.capacity_mw = Some(100), + _ => profile.cooling_capacity_mw = Some(100), + } + physical( + store, + actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(profile), + }), + )?; + for item in [&old, &retained, &incoming] { + equipment_dimensions(store, actor, item)?; + loads(store, actor, item, if item == &old { 0 } else { 60 })?; + } + for item in [&old, &retained] { + call( + store, + actor, + Command::Relation(api::Relationship { + relation: Some(wire::Relation { + from: Some(item.clone()), + to: Some(enclosure.clone()), + kind: wire::RelationKind::InstalledIn as i32, + ..Default::default() + }), + remove: false, + }), + )?; + } + let prior = binding(store, &old)?; + Ok(( + api::PhysicalReplacement { + position: Some(enclosure), + previous: Some(old), + replacement: Some(incoming), + previous_binding: Some(prior.relation_id), + previous_revision: Some(prior.revision), + ..Default::default() + }, + retained, + )) +} + +// Unpositioned devices still consume shared weight, power and cooling budgets. +#[test] +fn unpositioned_replacements_include_retained_loads() -> TestResult { + for kind in [K::Site, K::Location] { + for axis in 0..3 { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let (replacement, retained) = fixture(&store, &actor, kind, axis)?; + 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, + "{kind:?}, axis {axis}" + ); + assert!(matches!( + reserve(&store, &actor, plan.clone()), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + loads(&store, &actor, &retained, 40)?; + let reserved = reserve(&store, &actor, plan)?.plan.ok_or(Error::Corrupt)?; + loads(&store, &actor, &retained, 41)?; + let completion = api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: reserved.id.ok_or(Error::Corrupt)?, + revision: reserved.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); + loads(&store, &actor, &retained, 40)?; + let completed = physical(&store, &actor, api::physical::Action::Complete(completion))? + .plan + .ok_or(Error::Corrupt)?; + assert_eq!(completed.state, api::PhysicalPlanState::Completed as i32); + } + } + Ok(()) +} + +// An accepted unpositioned replacement holds capacity across reopen until recorded or stopped. +#[test] +fn unpositioned_replacements_include_other_plan_reservations() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let (replacement, retained) = fixture(&store, &actor, K::Site, 1)?; + loads(&store, &actor, &retained, 0)?; + let other = create(&store, &actor, K::Device, "Another incoming machine")?; + equipment_dimensions(&store, &actor, &other)?; + loads(&store, &actor, &other, 60)?; + let prior = binding(&store, &retained)?; + let second = replacement_plan( + &store, + &actor, + vec![api::PhysicalReplacement { + position: replacement.position.clone(), + previous: Some(retained), + replacement: Some(other), + previous_binding: Some(prior.relation_id), + previous_revision: Some(prior.revision), + ..Default::default() + }], + )?; + let first = replacement_plan(&store, &actor, vec![replacement])?; + let first = reserve(&store, &actor, first)?.plan.ok_or(Error::Corrupt)?; + drop(store); + let store = Store::open(dir.path(), policy())?; + let before = store.usage()?; + assert!(matches!( + reserve(&store, &actor, second.clone()), + Err(Error::Conflict) + )); + assert_eq!(store.usage()?, before); + physical( + &store, + &actor, + api::physical::Action::Complete(api::PhysicalCompletion { + plan: Some(api::PhysicalPlanKey { + id: first.id.ok_or(Error::Corrupt)?, + revision: first.revision, + }), + completed_steps: vec![0], + ..Default::default() + }), + )?; + assert!(matches!( + reserve(&store, &actor, second), + Err(Error::Conflict) + )); + Ok(()) +} + +// A real capacity-only hold has unknown geometry, not a corrupt placement record. +#[test] +fn unpositioned_hold_keeps_geometric_admission_unknown() -> TestResult { + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let (replacement, retained) = fixture(&store, &actor, K::Site, 1)?; + loads(&store, &actor, &retained, 0)?; + let site = replacement.position.clone().ok_or(Error::Corrupt)?; + let mut profile = define(site.clone()); + profile.mounting = Some("floor-standing".into()); + profile.capacity_mw = Some(100); + profile + .volume + .as_mut() + .ok_or(Error::Corrupt)? + .x + .as_mut() + .ok_or(Error::Corrupt)? + .length_um = 1_500_000; + profile.occupancy_coverage = profile.volume.map(Box::new); + physical( + &store, + &actor, + api::physical::Action::Define(api::PhysicalDefinitionEdit { + definition: Some(profile), + }), + )?; + for (index, item) in [ + replacement.previous.clone().ok_or(Error::Corrupt)?, + retained, + ] + .into_iter() + .enumerate() + { + let mut plan = draft(&store, &actor, item, site.clone())?; + let Some(api::physical_step::Action::Placement(p)) = &mut 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 = index as i64 * 500_000; + let plan = physical( + &store, + &actor, + api::physical::Action::Save(api::PhysicalSave { plan: Some(plan) }), + )? + .plan + .ok_or(Error::Corrupt)?; + report_steps(&store, &actor, &plan, vec![0])?; + } + let pending = replacement_plan(&store, &actor, vec![replacement])?; + reserve(&store, &actor, pending)?; + let third = create(&store, &actor, K::Device, "Geometric candidate")?; + equipment_dimensions(&store, &actor, &third)?; + loads(&store, &actor, &third, 0)?; + let mut proposal = draft(&store, &actor, third, site)?; + let Some(api::physical_step::Action::Placement(p)) = &mut proposal.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 = 1_000_000; + let before = store.usage()?; + let preview = physical( + &store, + &actor, + api::physical::Action::Preview(api::PhysicalPreview { + plan: Some(proposal), + }), + )?; + assert_eq!(preview.state, api::RequirementState::Unknown as i32); + assert_eq!(preview.reason, api::RequirementReason::Missing as i32); + assert_eq!(store.usage()?, before); + Ok(()) +} diff --git a/docs/inventory.md b/docs/inventory.md index f879679..dadfed3 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -599,6 +599,16 @@ atomic no-replace operation; an interrupted collection cannot publish a partial frame. Reports contain private host facts. Keep them outside version control. The check command prints coverage and counts, without inventory names or IPs. +External commands have a ten-second deadline within the ninety-second collection +budget. A timed-out child is terminated and reaped; diagnostics identify the +executable and, for Windows PowerShell sources, the module and native cmdlets, +without dumping scripts or their output. Timeouts leave coverage unavailable. +Optional source failures remain in the report's coverage. If required identity +or interface evidence cannot be collected, the command reports collection +unavailable and exits successfully without writing a report or replacing an +existing file. This is not a verified inventory or an empty scan. Invalid +arguments, malformed report frames and file publication errors remain errors. + In the workspace, choose **Import host report**, supply a source name and select the resources to adopt. Select required parents together with their components. Match an existing resource only after checking its identity and incarnation. @@ -749,6 +759,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..e5b681f 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,760 @@ 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 { + // Saved plan and reviewed revision for manual outcomes or a postcondition + // refresh. With no outcomes, checks are refreshed without repeating work; + // unchanged progress is not written, but newly satisfied goals can finish. + 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 +2358,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 +2409,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..f61edff 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..d826a9d --- /dev/null +++ b/web/app/CableEnds.vue @@ -0,0 +1,54 @@ + + + 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..4ecdae4 --- /dev/null +++ b/web/app/PhysicalPlanner.vue @@ -0,0 +1,502 @@ + +