diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cabc45a..adf9ef7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} permissions: contents: read +env: + CARGO_INCREMENTAL: '0' + CARGO_PROFILE_DEV_DEBUG: line-tables-only + CARGO_PROFILE_TEST_DEBUG: line-tables-only jobs: contracts: name: Contracts, storage and inventory (Rust, Vue, Chromium) @@ -20,14 +24,21 @@ jobs: uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2 with: args: --offline --include-fragments --no-progress README.md CONTRIBUTING.md SECURITY.md docs/contracts.md docs/storage.md docs/inventory.md + - run: corepack enable - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 with: node-version: 24 + cache: yarn + cache-dependency-path: yarn.lock - uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable with: toolchain: stable components: rustfmt, clippy targets: thumbv7em-none-eabihf + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + with: + cache-bin: 'false' + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 with: version: "36.2" @@ -36,10 +47,14 @@ jobs: with: tool: nextest fallback: none - - run: corepack enable - run: yarn install --immutable - run: yarn playwright install --with-deps chromium - run: sh scripts/check-contracts.sh + - name: Report disk usage + if: always() + run: | + df -h . + if [ -d target ]; then du -sh target; fi native-observation: name: Native observation (${{ matrix.os }}) strategy: @@ -55,6 +70,10 @@ jobs: with: toolchain: stable components: rustfmt, clippy + - uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2 + with: + cache-bin: 'false' + save-if: ${{ github.ref == 'refs/heads/main' }} - uses: arduino/setup-protoc@c65c819552d16ad3c9b72d9dfd5ba5237b9c906b # v3.0.0 with: version: "36.2" @@ -66,4 +85,4 @@ jobs: - run: cargo build --locked -p omloom-observe - run: cargo clippy --locked -p omloom-observe --all-targets -- -D warnings - run: cargo nextest run --locked -p omloom-observe - - run: cargo clean + - run: cargo clean -p omloom-observe diff --git a/README.md b/README.md index 0b77ba4..7752e5f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ as current inventory, independently of discovery or execution adapters. Devices can retain [partial equipment knowledge](docs/inventory.md#partly-identified-equipment), including bounded quantities, model alternatives and supported exclusions, then be refined without changing their identity. +The workspace can [check recorded requirements](docs/inventory.md#recorded-requirements) +against permitted current facts, preserving missing, stale and conflicting evidence. ## License diff --git a/crates/omloom-contracts/build.rs b/crates/omloom-contracts/build.rs index 9f29d1e..986183d 100644 --- a/crates/omloom-contracts/build.rs +++ b/crates/omloom-contracts/build.rs @@ -65,6 +65,10 @@ fn main() -> Result<(), Box> { && matches!(field.name(), "candidates" | "excluded") { 16 + } else if (message.name() == "EvaluateRequirements" && field.name() == "nodes") + || (message.name() == "RequirementGroup" && field.name() == "members") + { + 64 } else { 0 } diff --git a/crates/omloom-contracts/src/preflight.rs b/crates/omloom-contracts/src/preflight.rs index 58502e5..01a6afa 100644 --- a/crates/omloom-contracts/src/preflight.rs +++ b/crates/omloom-contracts/src/preflight.rs @@ -141,7 +141,9 @@ impl Work { .find(|f| f.tag == tag) .ok_or(Error::UnknownField)?; let mask = 1u64.checked_shl(tag).ok_or(Error::Wire)?; - if shape.max_count != 0 { + if shape.max_count != 0 + && !(shape.repeated && matches!(shape.kind, Kind::Varint(_)) && key & 7 == 2) + { let count = &mut counts[tag as usize]; if *count == shape.max_count { return Err(Error::Limit); @@ -184,6 +186,13 @@ impl Work { let mut packed = body; while !packed.is_empty() { self.field()?; + if shape.max_count != 0 { + let count = &mut counts[tag as usize]; + if *count == shape.max_count { + return Err(Error::Limit); + } + *count += 1; + } if varint(&mut packed)? > max { return Err(Error::Wire); } diff --git a/crates/omloom-contracts/tests/workspace.rs b/crates/omloom-contracts/tests/workspace.rs index 96e11a8..6736eee 100644 --- a/crates/omloom-contracts/tests/workspace.rs +++ b/crates/omloom-contracts/tests/workspace.rs @@ -83,3 +83,68 @@ fn repeated_empty_messages_cannot_amplify_small_frames() { )); assert_eq!(budget.used(), 0); } + +// Packed scalar references count individually before allocation, not once per buffer. +#[test] +fn requirements_bound_nodes_and_packed_members_before_decoding() { + let budget = MemoryBudget::new(16 * 1_048_576); + for (nodes, members) in [(65, 0), (1, 65)] { + let node = api::RequirementNode { + expression: Some(api::requirement_node::Expression::Group( + api::RequirementGroup { + kind: api::RequirementGroupKind::All as i32, + members: vec![0; members], + minimum: 0, + }, + )), + }; + let bytes = api::Request { + command: Some(api::request::Command::EvaluateRequirements( + api::EvaluateRequirements { + nodes: vec![node; nodes], + ..Default::default() + }, + )), + working: None, + } + .encode_to_vec(); + assert!(matches!( + decode_workspace(&bytes, Limits::default(), &budget), + Err(Error::Limit) + )); + assert_eq!(budget.used(), 0); + } +} + +#[test] +fn requirements_share_member_limits_across_packed_and_unpacked_segments() { + // Splitting one repeated field across wire encodings cannot reset its allocation bound. + fn message(tag: u32, body: &[u8]) -> Vec { + let mut out = Vec::new(); + prost::encoding::encode_key(tag, prost::encoding::WireType::LengthDelimited, &mut out); + prost::encoding::encode_varint(body.len() as u64, &mut out); + out.extend_from_slice(body); + out + } + let budget = MemoryBudget::new(16 * 1_048_576); + for (unpacked, extra) in [(true, 32), (true, 33), (false, 33)] { + let mut group = vec![0x08, 1]; + group.extend(message(2, &[0; 32])); + if unpacked { + for _ in 0..extra { + group.extend([0x10, 0]); + } + } else { + group.extend(message(2, &vec![0; extra])); + } + let bytes = message(33, &message(2, &message(2, &group))); + let result = decode_workspace(&bytes, Limits::default(), &budget); + if extra == 32 { + assert!(result.is_ok()); + } else { + assert!(matches!(result, Err(Error::Limit))); + } + drop(result); + assert_eq!(budget.used(), 0); + } +} diff --git a/crates/omloom-store/src/disk/workspace.rs b/crates/omloom-store/src/disk/workspace.rs index 6785a70..23d4039 100644 --- a/crates/omloom-store/src/disk/workspace.rs +++ b/crates/omloom-store/src/disk/workspace.rs @@ -18,6 +18,7 @@ mod interface_facts; mod network; pub(super) mod network_facts; mod projection; +mod requirements; mod responsibility; mod software; mod source_layout; @@ -337,6 +338,12 @@ impl Store { value, working.as_ref().ok_or(Error::Invalid)?, ), + C::EvaluateRequirements(value) => self.workspace_requirements( + context, + value, + working.as_ref().ok_or(Error::Invalid)?, + now, + ), C::Export(export) => self.workspace_export(context, export), C::Network(network) => self.workspace_network( context, diff --git a/crates/omloom-store/src/disk/workspace/requirements.rs b/crates/omloom-store/src/disk/workspace/requirements.rs new file mode 100644 index 0000000..aac26af --- /dev/null +++ b/crates/omloom-store/src/disk/workspace/requirements.rs @@ -0,0 +1,692 @@ +//! Current selected facts enter the common evaluator through this restricted query. +use super::*; +use crate::requirements::{self as core, knowledge as k}; + +impl k::Alternatives for wire::EquipmentAlternatives { + fn candidates(&self) -> usize { + self.candidates.len() + } + fn candidate(&self, index: usize) -> k::Atom<'_> { + let value = &self.candidates[index]; + value + .reference + .as_ref() + .map_or(k::Atom::Text(&value.value), reference) + } + fn exclusions(&self) -> usize { + self.excluded.len() + } + fn exclusion(&self, index: usize) -> k::Atom<'_> { + let value = &self.excluded[index]; + value + .reference + .as_ref() + .map_or(k::Atom::Text(&value.value), reference) + } + fn exhaustive(&self) -> bool { + self.exhaustive + } +} +fn reference(value: &wire::EquipmentReference) -> k::Atom<'_> { + k::Atom::Reference(k::Reference { + issuer: &value.issuer, + key: &value.key, + basis: &value.basis, + }) +} +fn claim(field: &wire::Field) -> k::Claim<'_, wire::EquipmentAlternatives> { + match field.value.as_ref() { + Some(field::Value::Text(value)) => k::Claim::Known(k::Atom::Text(value)), + Some(field::Value::Integer(value)) => k::Claim::Known(k::Atom::Integer(*value)), + Some(field::Value::Flag(value)) => k::Claim::Known(k::Atom::Flag(*value)), + Some(field::Value::Equipment(value)) => match value.assertion.as_ref() { + Some(wire::equipment_claim::Assertion::Model(value)) => { + k::Claim::Known(reference(value)) + } + Some(wire::equipment_claim::Assertion::Alternatives(value)) => { + k::Claim::Alternatives(value) + } + Some(wire::equipment_claim::Assertion::Quantity(value)) => { + k::Claim::Quantity(k::Quantity { + lower: value.lower, + upper: value.upper, + unit: &value.unit, + scale: value.scale, + }) + } + None => k::Claim::Missing, + }, + _ => k::Claim::Missing, + } +} +fn compare( + actual: &wire::Field, + expected: &wire::Field, + comparison: i32, +) -> Result { + use api::RequirementComparison as C; + let comparison = C::try_from(comparison).map_err(|_| Error::Invalid)?; + let expected = claim(expected); + let mut atoms = [k::Atom::Text(""); 16]; + let predicate = match (comparison, expected) { + (C::Equal, k::Claim::Known(atom)) => k::Predicate::Is(atom), + (C::Equal, k::Claim::Alternatives(values)) + if values.exhaustive + && !values.candidates.is_empty() + && values.candidates.len() <= 16 + && values.excluded.is_empty() + && values.calibration_basis.is_none() + && values + .candidates + .iter() + .all(|c| c.probability_ppm.is_none() && c.score_ppm.is_none()) => + { + for (index, atom) in atoms[..values.candidates.len()].iter_mut().enumerate() { + *atom = k::Alternatives::candidate(values, index); + } + k::Predicate::OneOf(&atoms[..values.candidates.len()]) + } + (C::Minimum | C::Maximum, k::Claim::Known(k::Atom::Integer(value))) => { + if comparison == C::Minimum { + k::Predicate::Minimum { + value, + unit: "count", + scale: 0, + } + } else { + k::Predicate::Maximum { + value, + unit: "count", + scale: 0, + } + } + } + (C::Minimum | C::Maximum, k::Claim::Quantity(q)) + if q.lower.is_some() && q.lower == q.upper && q.scale <= 6 => + { + let value = q.lower.ok_or(Error::Invalid)?; + if comparison == C::Minimum { + k::Predicate::Minimum { + value, + unit: q.unit, + scale: q.scale, + } + } else { + k::Predicate::Maximum { + value, + unit: q.unit, + scale: q.scale, + } + } + } + _ => return Err(Error::Invalid), + }; + Ok(k::compare(claim(actual), predicate)) +} +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 { + State::Satisfied => S::Satisfied, + State::Failed => S::Failed, + State::Unknown => S::Unknown, + }; + let reason = match decision.reason { + C::Match => R::Match, + C::Mismatch => R::Mismatch, + C::Missing => R::Missing, + C::Stale => R::Stale, + C::Conflict => R::Conflict, + C::Restricted => R::Restricted, + C::Unsupported => R::Unsupported, + C::Unconfigured => R::Unconfigured, + C::AuthorityUnavailable => R::AuthorityUnavailable, + C::OwnerActionRequired => R::OwnerActionRequired, + C::Incomplete => R::Incomplete, + C::ScopeMismatch => R::ScopeMismatch, + C::VantageMismatch => R::VantageMismatch, + C::Unqualified => R::Unqualified, + C::WrongEvidence => R::WrongEvidence, + C::Cycle => R::Cycle, + C::NotSelected => R::Unspecified, + }; + (state as i32, reason as i32) +} +fn subject(target: &wire::AssetRef) -> core::Subject<'_> { + core::Subject { + namespace: "inventory", + resource: &target.asset_id, + incarnation: &target.incarnation, + network: core::NetworkScope::NotApplicable, + } +} + +fn reference_identity(value: &wire::EquipmentReference) -> (&str, &str, &str) { + (&value.issuer, &value.key, &value.basis) +} +fn group_mode(group: &api::RequirementGroup) -> Result { + match api::RequirementGroupKind::try_from(group.kind).map_err(|_| Error::Invalid)? { + api::RequirementGroupKind::All if group.minimum == 0 => Ok(core::Group::All), + api::RequirementGroupKind::Any if group.minimum == 0 => Ok(core::Group::Any), + api::RequirementGroupKind::AtLeast + if group.minimum != 0 && (group.minimum as usize) <= group.members.len() => + { + Ok(core::Group::AtLeast(group.minimum as usize)) + } + _ => Err(Error::Invalid), + } +} +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::Field(condition)) = &node.expression { + let mut condition = condition.clone(); + if let Some(wire::Field { + value: Some(field::Value::Equipment(claim)), + .. + }) = &mut condition.expected + { + // Narrative and candidate ordering are not part of a predicate's identity. + claim.basis.clear(); + if let Some(wire::equipment_claim::Assertion::Alternatives(set)) = + &mut claim.assertion + { + set.domain.clear(); + for candidate in &mut set.candidates { + if candidate.reference.is_some() { + candidate.value.clear(); + } + } + set.candidates.sort_unstable_by(|a, b| { + a.value.cmp(&b.value).then_with(|| { + a.reference + .as_ref() + .map(reference_identity) + .cmp(&b.reference.as_ref().map(reference_identity)) + }) + }); + } + if let Some(wire::equipment_claim::Assertion::Quantity(q)) = &mut claim.assertion { + if q.scale > 6 { + return Err(Error::Invalid); + } + while q.scale != 0 + && q.lower.is_some_and(|n| n % 10 == 0) + && q.upper.is_some_and(|n| n % 10 == 0) + { + q.lower = q.lower.map(|n| n / 10); + q.upper = q.upper.map(|n| n / 10); + q.scale -= 1; + } + } + } + digests[index] = Some(<[u8; 32]>::from(Sha256::digest(condition.encode_to_vec()))); + } + } + // At most 64 bounded passes, without recursion or keys dependent on node numbering. + for _ in 0..nodes.len() { + let mut changed = false; + for (index, node) in nodes.iter().enumerate() { + if digests[index].is_some() { + continue; + } + let Some(api::requirement_node::Expression::Group(group)) = &node.expression else { + return Err(Error::Invalid); + }; + let mut children = [[0u8; 32]; core::MAX_NODES]; + let mut ready = true; + for (position, &child) in group.members.iter().enumerate() { + let Some(child) = digests + .get(child as usize) + .filter(|_| (child as usize) < nodes.len()) + else { + return Err(Error::Invalid); + }; + if let Some(child) = child { + children[position] = *child; + } else { + ready = false; + break; + } + } + if !ready { + continue; + } + children[..group.members.len()].sort_unstable(); + let mut hash = Sha256::new(); + hash.update(b"group"); + hash.update(group.kind.to_le_bytes()); + hash.update(group.minimum.to_le_bytes()); + for child in &children[..group.members.len()] { + hash.update(child); + } + digests[index] = Some(hash.finalize().into()); + changed = true; + } + if !changed { + break; + } + } + Ok(nodes + .iter() + .enumerate() + .map(|(i, node)| { + // Invalid cyclic graphs remain explainable but never acquire a successful result. + let digest = digests[i].unwrap_or_else(|| Sha256::digest(node.encode_to_vec()).into()); + format!("{:x}", sha2::digest::Output::::from(digest)) + }) + .collect()) +} + +impl Store { + pub(super) fn workspace_requirements( + &self, + context: &Context, + request: api::EvaluateRequirements, + working: &api::WorkingContext, + now: u64, + ) -> Result { + if request.nodes.is_empty() + || request.nodes.len() > core::MAX_NODES + || request.root as usize >= request.nodes.len() + { + return Err(Error::Invalid); + } + let mut total_edges = 0; + for node in &request.nodes { + match &node.expression { + Some(api::requirement_node::Expression::Field(condition)) => { + if condition.encoded_len() > 8192 { + return Err(Error::Limit); + } + } + Some(api::requirement_node::Expression::Group(group)) => { + if group.members.len() > core::MAX_NODES + || group.members.len() > core::MAX_EDGES - total_edges + { + return Err(Error::Limit); + } + total_edges += group.members.len(); + } + 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)?; + // 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()); + let mut members = Vec::with_capacity(request.nodes.len()); + let mut edges = 0; + for ((index, node), semantic_key) in request.nodes.iter().enumerate().zip(semantic_keys) { + let response = api::RequirementResult { + node: index as u32, + semantic_key, + ..Default::default() + }; + let decision = match node.expression.as_ref().ok_or(Error::Invalid)? { + api::requirement_node::Expression::Field(_) => { + core::Decision::unknown(core::Reason::Missing) + } + api::requirement_node::Expression::Group(group) => { + if group.members.len() > core::MAX_EDGES - edges { + return Err(Error::Limit); + } + edges += group.members.len(); + core::Decision::unknown(core::Reason::NotSelected) + } + }; + decisions.push(decision); + responses.push(response); + members.push(match &node.expression { + Some(api::requirement_node::Expression::Group(group)) => group + .members + .iter() + .map(|&i| i as usize) + .collect::>(), + _ => Vec::new(), + }); + } + let mut nodes = Vec::with_capacity(request.nodes.len()); + let keys: Vec<_> = responses + .iter_mut() + .map(|r| std::mem::take(&mut r.semantic_key)) + .collect(); + for (index, node) in request.nodes.iter().enumerate() { + let (subject, expression) = match node.expression.as_ref().ok_or(Error::Invalid)? { + api::requirement_node::Expression::Field(condition) => ( + subject(condition.target.as_ref().ok_or(Error::Invalid)?), + core::Expression::Fact(decisions[index]), + ), + api::requirement_node::Expression::Group(_) => { + ( + core::Subject { + namespace: "requirement", + resource: &keys[index], + incarnation: "", + network: core::NetworkScope::NotApplicable, + }, + core::Expression::Group { + // The first pass discovers reachability without choosing a method. + mode: core::Group::All, + members: &members[index], + membership: core::Membership { + complete: true, + consensus: false, + qualified_profile: None, + revision: None, + }, + }, + ) + } + }; + nodes.push(core::Node { + subject, + role: core::Role::Requirement, + // A client-built logical group is not a qualified independent voter. + member_role: if matches!( + node.expression, + Some(api::requirement_node::Expression::Field(_)) + ) { + core::MemberRole::Eligible + } else { + core::MemberRole::Unknown + }, + expression, + }); + } + let mut results = [core::Decision::unknown(core::Reason::NotSelected); core::MAX_NODES]; + let map_error = |e| match e { + core::Error::Invalid => Error::Invalid, + core::Error::Limit => Error::Limit, + }; + let shape = + 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. + for (index, node) in nodes.iter_mut().enumerate() { + if results[index].reason == core::Reason::NotSelected { + continue; + } + if let Some(api::requirement_node::Expression::Group(group)) = + &request.nodes[index].expression + && let core::Expression::Group { mode, .. } = &mut node.expression + { + *mode = group_mode(group)?; + } + } + for (index, node) in nodes.iter_mut().enumerate() { + if results[index].reason == core::Reason::NotSelected { + continue; + } + 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 overall = + core::evaluate(&nodes, request.root as usize, &mut results).map_err(map_error)?; + drop(nodes); + let mut output = Vec::with_capacity(request.nodes.len()); + for ((index, mut response), semantic_key) in responses.into_iter().enumerate().zip(keys) { + if results[index].reason == core::Reason::NotSelected { + continue; + } + response.semantic_key = semantic_key; + if results[index].state == core::State::Satisfied { + response.resolutions.clear(); + } + (response.state, response.reason) = wire_decision(results[index]); + output.push(response); + } + let (state, reason) = wire_decision(overall); + let evaluation = api::RequirementEvaluation { + revision: number(&tx.root, "revision")?, + evaluated_at_ms: now, + state, + reason, + results: output, + }; + tx.commit()?; + Ok(reply(api::reply::Result::RequirementEvaluation(evaluation))) + } +} + +impl Tx<'_> { + fn requirement_field( + &self, + context: &Context, + condition: &api::FieldRequirement, + response: &mut api::RequirementResult, + now: u64, + ) -> 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)?; + let row = match self.asset(target) { + Ok(row) => row, + // A stale incarnation must not reveal that a protected asset ID exists. + Err(Error::Missing | Error::Conflict) => { + return Ok(core::Decision::unknown(core::Reason::Restricted)); + } + Err(error) => return Err(error), + }; + let kind = number(&row, "kind")? as i32; + let asset_revision = number(&row, "revision")?; + if !context.readable(target, kind) + || !context.field(target, kind, &expected.name, false) + || number(&row, "adoption")? != wire::Adoption::Adopted as u64 + { + return Ok(core::Decision::unknown(core::Reason::Restricted)); + } + response.target = condition.target.clone(); + response.field = Some(expected.name.clone()); + let layer = + match api::RequirementLayer::try_from(condition.layer).map_err(|_| Error::Invalid)? { + api::RequirementLayer::Accepted => "intent", + api::RequirementLayer::Observed => "observed", + _ => return Err(Error::Invalid), + }; + if layer == "intent" && context.field(target, kind, &expected.name, true) { + response + .resolutions + .push(api::RequirementResolution::RecordFact as i32); + } + if kind == wire::AssetKind::ProviderAccount as i32 && expected.name == "delegation" { + if layer != "intent" { + return Ok(core::Decision::unknown(core::Reason::Unconfigured)); + } + let Some(metadata) = self.get(&format!("{}/account", asset_key(target)))? else { + return Ok(core::Decision::unknown(core::Reason::Missing)); + }; + let delegation = + api::AccountDelegation::try_from(number(&metadata, "delegation")? as i32) + .map_err(|_| Error::Corrupt)?; + if delegation == api::AccountDelegation::Unspecified { + return Ok(core::Decision::unknown(core::Reason::Missing)); + } + let actual = wire::Field { + name: "delegation".into(), + value: Some(field::Value::Text(delegation.as_str_name().into())), + }; + let decision = compare(&actual, expected, condition.comparison)?; + response.evidence.push(api::RequirementEvidence { + source: target.asset.to_string(), + revision: Some(asset_revision.to_string()), + ..Default::default() + }); + if delegation == api::AccountDelegation::OwnerOnly + && decision.state == core::State::Failed + { + if context.field(target, kind, "account.request", true) { + response + .resolutions + .push(api::RequirementResolution::OwnerAction as i32); + } + return Ok(core::Decision { + state: core::State::Failed, + reason: core::Reason::OwnerActionRequired, + }); + } + return Ok(decision); + } + let rows = if layer == "intent" { + self.get(&format!("{}/intent/{}", asset_key(target), expected.name))? + .into_iter() + .collect::>() + } else { + self.query("MATCH (n:OmRow {parent:$parent}) WHERE n.layer='observed' AND n.field=$field RETURN properties(n) AS p LIMIT 33", params([("parent", text(asset_key(target))), ("field", text(&expected.name))]))? + }; + if rows.len() > 32 { + return Err(Error::Limit); + } + let freshness = if layer == "observed" && !rows.is_empty() { + let keys = rows + .iter() + .map(|row| row.get("key").cloned().ok_or(Error::Corrupt)) + .collect::>>()?; + self.query("MATCH (n:OmRow {parent:$parent}) WHERE n.layer='freshness' AND n.claim IN $keys RETURN properties(n) AS p LIMIT 33", params([("parent", text(asset_key(target))), ("keys", Value::Array(keys))]))? + } else { + Vec::new() + }; + if freshness.len() > 32 { + return Err(Error::Limit); + } + let freshness: BTreeMap<_, _> = freshness + .iter() + .map(|row| { + Ok(( + row.get("claim") + .and_then(Value::as_str) + .ok_or(Error::Corrupt)?, + row, + )) + }) + .collect::>()?; + let mut result = core::Decision::unknown(core::Reason::Missing); + let mut known = None; + let mut current_fields = Vec::with_capacity(rows.len()); + for row in &rows { + let (source, revision, at, until, scan_id) = if layer == "intent" { + ( + string(row, "authority")?, + Some(asset_revision.to_string()), + None, + None, + None, + ) + } else { + let key = row + .get("key") + .and_then(Value::as_str) + .ok_or(Error::Corrupt)?; + let fresh = freshness.get(key).ok_or(Error::Corrupt)?; + ( + string(row, "source")?, + fresh + .get("source_revision") + .and_then(Value::as_str) + .map(str::to_owned), + Some(number(fresh, "observed")?), + Some(number(fresh, "valid")?), + fresh.get("scan").and_then(Value::as_str).map(str::to_owned), + ) + }; + response.evidence.push(api::RequirementEvidence { + source, + revision, + observed_at_ms: at, + valid_until_ms: until, + scan_id, + }); + let required = core::Requirement { + subject: subject(condition.target.as_ref().ok_or(Error::Invalid)?), + semantic: &expected.name, + vantage: "recorded-source-claim", + phase: core::Phase::Current, + kind: if layer == "intent" { + core::EvidenceKind::Configuration + } else { + core::EvidenceKind::Observation + }, + interval: None, + }; + // A recorded source claim is attributed evidence, never an independent probe. + let validity = core::fact( + required, + core::Evidence { + requirement: required, + origin: if layer == "intent" { + core::Origin::Accepted + } else { + core::Origin::PeerAssertion + }, + observed_at: at, + valid_until: until, + coverage: None, + qualified: true, + decision: core::Decision::PASS, + }, + now, + ); + if validity.state != core::State::Satisfied { + if known.is_none() { + result = validity; + } + continue; + } + let actual = inventory::read_field(row)?; + let current = compare(&actual, expected, condition.comparison)?; + current_fields.push(actual); + if current.state != core::State::Unknown { + if known.is_some_and(|old| old != current.state) { + return Ok(core::Decision::unknown(core::Reason::Conflict)); + } + known = Some(current.state); + result = current; + } else if known.is_none() { + result = current; + } + if current.reason == core::Reason::Conflict { + return Ok(current); + } + } + let claims: Vec<_> = current_fields.iter().map(claim).collect(); + let consistency = k::consistent(&claims); + if consistency.state != core::State::Satisfied { + return Ok(consistency); + } + if result.state != core::State::Satisfied { + response + .resolutions + .push(api::RequirementResolution::ReviewSource as i32); + } + Ok(result) + } +} diff --git a/crates/omloom-store/src/lib.rs b/crates/omloom-store/src/lib.rs index 631ff8b..17e9c1f 100644 --- a/crates/omloom-store/src/lib.rs +++ b/crates/omloom-store/src/lib.rs @@ -4,9 +4,10 @@ #![cfg_attr(not(feature = "std"), no_std)] #![deny(unsafe_code, clippy::unwrap_used)] -#[cfg(feature = "std")] extern crate alloc; +pub mod requirements; + /// Independently bounded data classes. Recovery checkpoints never expire with detail. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(usize)] diff --git a/crates/omloom-store/src/requirements.rs b/crates/omloom-store/src/requirements.rs new file mode 100644 index 0000000..f4e6050 --- /dev/null +++ b/crates/omloom-store/src/requirements.rs @@ -0,0 +1,472 @@ +//! Shared prerequisite semantics. Inputs belong to a trusted fact producer; +//! a result never supplies authorization or evidence that an effect occurred. +pub mod knowledge; + +pub const MAX_NODES: usize = 64; +pub const MAX_EDGES: usize = 256; +pub const MAX_DEPTH: usize = 32; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Satisfied, + Failed, + Unknown, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Reason { + Match, + Mismatch, + Missing, + Stale, + Conflict, + Restricted, + Unsupported, + Unconfigured, + AuthorityUnavailable, + OwnerActionRequired, + Incomplete, + ScopeMismatch, + VantageMismatch, + Unqualified, + WrongEvidence, + Cycle, + NotSelected, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Decision { + pub state: State, + pub reason: Reason, +} +impl Decision { + pub const PASS: Self = Self { + state: State::Satisfied, + reason: Reason::Match, + }; + pub const FAIL: Self = Self { + state: State::Failed, + reason: Reason::Mismatch, + }; + pub const fn unknown(reason: Reason) -> Self { + Self { + state: State::Unknown, + reason, + } + } +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum NetworkScope<'a> { + NotApplicable, + Unknown, + Bound(&'a str), +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Subject<'a> { + pub namespace: &'a str, + pub resource: &'a str, + pub incarnation: &'a str, + pub network: NetworkScope<'a>, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase { + Current, + Predicted, + ConcurrentIntent, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Origin { + Accepted, + DirectObservation, + PeerAssertion, + AuthorityDecision, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EvidenceKind { + Configuration, + Observation, + IndependentObservation, + Authority, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Window { + pub start: u64, + pub end: u64, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Requirement<'a> { + pub subject: Subject<'a>, + /// Includes semantic action and direction, not merely a transport port. + pub semantic: &'a str, + pub vantage: &'a str, + pub phase: Phase, + pub kind: EvidenceKind, + pub interval: Option, +} +#[derive(Clone, Copy, Debug)] +pub struct Evidence<'a> { + pub requirement: Requirement<'a>, + pub origin: Origin, + pub observed_at: Option, + pub valid_until: Option, + pub coverage: Option, + /// Attribution and qualification are independent of the measured value. + pub qualified: bool, + pub decision: Decision, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Profile<'a> { + pub method: &'a str, + pub adapter_artifact: &'a str, + pub provider_version: &'a str, +} +/// Qualification binds the actual method/artifact/version. Applicability facts +/// remain separate from missing authority and from the operation's outcome. +pub fn applicability( + selected: Profile<'_>, + qualified: Option>, + conditions: Decision, +) -> Decision { + for value in [ + selected.method, + selected.adapter_artifact, + selected.provider_version, + ] { + if value.is_empty() || value.len() > 256 { + return Decision::unknown(Reason::Unqualified); + } + } + if qualified != Some(selected) { + return Decision::unknown(Reason::Unqualified); + } + conditions +} + +/// Validate provenance before interpreting the predicate's value. An adapter +/// cannot turn a host observation into a guest observation by changing its label. +pub fn fact(wanted: Requirement<'_>, evidence: Evidence<'_>, now: u64) -> Decision { + if wanted.subject != evidence.requirement.subject + || matches!( + wanted.subject.network, + NetworkScope::Unknown | NetworkScope::Bound("") + ) + { + return Decision::unknown(Reason::ScopeMismatch); + } + if wanted.semantic != evidence.requirement.semantic + || wanted.phase != evidence.requirement.phase + { + return Decision::unknown(Reason::WrongEvidence); + } + if wanted.vantage != evidence.requirement.vantage { + return Decision::unknown(Reason::VantageMismatch); + } + if matches!( + evidence.decision.reason, + Reason::Unsupported + | Reason::Unconfigured + | Reason::AuthorityUnavailable + | Reason::Restricted + ) { + return Decision::unknown(evidence.decision.reason); + } + if !evidence.qualified { + return Decision::unknown(Reason::Unqualified); + } + if wanted.kind == EvidenceKind::Configuration + && (evidence.requirement.kind != EvidenceKind::Configuration + || evidence.origin != Origin::Accepted) + { + return Decision::unknown(Reason::WrongEvidence); + } + if wanted.kind == EvidenceKind::Authority + && (evidence.requirement.kind != EvidenceKind::Authority + || evidence.origin != Origin::AuthorityDecision) + { + return Decision::unknown(Reason::AuthorityUnavailable); + } + if matches!( + wanted.kind, + EvidenceKind::Observation | EvidenceKind::IndependentObservation + ) && (!matches!( + evidence.origin, + Origin::DirectObservation | Origin::PeerAssertion + ) || !matches!( + evidence.requirement.kind, + EvidenceKind::Observation | EvidenceKind::IndependentObservation + )) { + return Decision::unknown(Reason::WrongEvidence); + } + if wanted.kind == EvidenceKind::IndependentObservation + && evidence.origin != Origin::DirectObservation + { + return Decision::unknown(Reason::WrongEvidence); + } + if matches!( + wanted.kind, + EvidenceKind::Observation | EvidenceKind::IndependentObservation | EvidenceKind::Authority + ) && (evidence.observed_at.is_none() || evidence.valid_until.is_none()) + { + return Decision::unknown(Reason::Missing); + } + if evidence.observed_at.is_some_and(|at| at > now) + || evidence.valid_until.is_some_and(|until| until <= now) + { + return Decision::unknown(Reason::Stale); + } + if let Some(interval) = wanted.interval { + if interval.start >= interval.end || interval.end > now { + return Decision::unknown(Reason::Incomplete); + } + match evidence.coverage { + Some(coverage) if coverage.start <= interval.start && coverage.end >= interval.end => {} + _ => return Decision::unknown(Reason::Incomplete), + } + } + evidence.decision +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Role { + Requirement, + Authority, + ConditionalBlock, + Preference, + Fact, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MemberRole { + Eligible, + Learner, + Unknown, +} +#[derive(Clone, Copy, Debug)] +pub enum Group { + All, + Any, + AtLeast(usize), + Majority, +} +#[derive(Clone, Copy, Debug)] +pub struct Membership<'a> { + pub complete: bool, + /// Consensus needs a provider-qualified membership revision, not ping counts. + pub consensus: bool, + pub qualified_profile: Option<&'a str>, + pub revision: Option<&'a str>, +} +#[derive(Clone, Copy, Debug)] +pub enum Expression<'a> { + Fact(Decision), + Group { + mode: Group, + members: &'a [usize], + membership: Membership<'a>, + }, +} +#[derive(Clone, Copy, Debug)] +pub struct Node<'a> { + pub subject: Subject<'a>, + pub role: Role, + pub member_role: MemberRole, + pub expression: Expression<'a>, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Error { + Limit, + Invalid, +} +#[derive(Clone, Copy, Default)] +struct Frame { + node: usize, + child: usize, +} + +/// Evaluate only the selected branch, once per node, with caller-owned output. +/// No allocation, recursion, clock read, storage access or hidden graph expansion. +pub fn evaluate( + nodes: &[Node<'_>], + root: usize, + output: &mut [Decision], +) -> Result { + if nodes.len() > MAX_NODES || output.len() < nodes.len() { + return Err(Error::Limit); + } + if root >= nodes.len() { + return Err(Error::Invalid); + } + let mut edges = 0; + for node in nodes { + if let Expression::Group { members, .. } = node.expression { + if members.len() > MAX_EDGES - edges { + return Err(Error::Limit); + } + edges += members.len(); + if members.iter().any(|&child| child >= nodes.len()) { + return Err(Error::Invalid); + } + } + } + output[..nodes.len()].fill(Decision::unknown(Reason::NotSelected)); + let mut colors = [0u8; MAX_NODES]; + let mut stack = [Frame::default(); MAX_DEPTH]; + stack[0].node = root; + colors[root] = 1; + let mut depth = 1; + while depth != 0 { + let frame = &mut stack[depth - 1]; + let node = nodes[frame.node]; + // Global representation bounds remain checked above; unfinished alternatives + // need semantic validation only when they become selected prerequisites. + if frame.child == 0 + && let Expression::Group { + mode, + members, + membership, + } = node.expression + { + if members.is_empty() { + return Err(Error::Invalid); + } + if let Group::AtLeast(k) = mode + && (k == 0 || (membership.complete && k > members.len())) + { + return Err(Error::Invalid); + } + } + if let Expression::Group { members, .. } = node.expression + && frame.child < members.len() + { + let next = members[frame.child]; + frame.child += 1; + if matches!(nodes[next].role, Role::Preference | Role::Fact) { + continue; + } + if colors[next] == 1 { + return Ok(Decision::unknown(Reason::Cycle)); + } + if colors[next] == 0 { + if depth == MAX_DEPTH { + return Err(Error::Limit); + } + stack[depth] = Frame { + node: next, + child: 0, + }; + colors[next] = 1; + depth += 1; + } + continue; + } + let mut result = match node.expression { + Expression::Fact(decision) => decision, + Expression::Group { + mode, + members, + membership, + } => aggregate(nodes, output, mode, members, membership)?, + }; + if node.role == Role::ConditionalBlock { + result = match result.state { + State::Satisfied => Decision::FAIL, + State::Failed => Decision::PASS, + State::Unknown => result, + }; + } + output[frame.node] = result; + colors[frame.node] = 2; + depth -= 1; + } + Ok(output[root]) +} + +fn aggregate( + nodes: &[Node<'_>], + results: &[Decision], + mode: Group, + members: &[usize], + membership: Membership<'_>, +) -> Result { + let threshold = matches!(mode, Group::AtLeast(_) | Group::Majority); + if (membership.consensus || matches!(mode, Group::Majority)) + && (!membership.consensus + || !membership.complete + || membership.qualified_profile.is_none_or(str::is_empty) + || membership.revision.is_none_or(str::is_empty)) + { + return Ok(Decision::unknown(Reason::Unqualified)); + } + let mut pass = 0; + let mut fail = 0; + let mut unknown = 0; + let mut reason = Reason::Incomplete; + for (position, &index) in members.iter().enumerate() { + let node = nodes[index]; + if matches!(node.role, Role::Preference | Role::Fact) { + continue; + } + if threshold { + if node.member_role == MemberRole::Learner { + continue; + } + let mut duplicate = false; + for &previous in &members[..position] { + if matches!(nodes[previous].role, Role::Preference | Role::Fact) + || nodes[previous].member_role == MemberRole::Learner + { + continue; + } + if nodes[previous].subject == node.subject { + if results[previous] != results[index] + || nodes[previous].member_role != node.member_role + { + return Ok(Decision::unknown(Reason::Conflict)); + } + duplicate = true; + break; + } + } + if duplicate { + continue; + } + } + let decision = if threshold && node.member_role == MemberRole::Unknown { + Decision::unknown(Reason::Incomplete) + } else { + results[index] + }; + match decision.state { + State::Satisfied => pass += 1, + State::Failed => fail += 1, + State::Unknown => { + unknown += 1; + reason = decision.reason; + } + } + } + let count = pass + fail + unknown; + if count == 0 { + return Err(Error::Invalid); + } + match mode { + Group::All if fail != 0 => Ok(Decision::FAIL), + Group::All if unknown == 0 && membership.complete => Ok(Decision::PASS), + Group::Any if pass != 0 => Ok(Decision::PASS), + Group::Any if unknown == 0 && membership.complete => Ok(Decision::FAIL), + Group::AtLeast(k) if pass >= k => Ok(Decision::PASS), + Group::AtLeast(k) if membership.complete && pass + unknown < k => Ok(Decision::FAIL), + Group::Majority + if unknown != 0 + && members + .iter() + .any(|&i| nodes[i].member_role == MemberRole::Unknown) => + { + Ok(Decision::unknown(Reason::Incomplete)) + } + Group::Majority if pass > count / 2 => Ok(Decision::PASS), + Group::Majority if pass + unknown <= count / 2 => Ok(Decision::FAIL), + _ => Ok(Decision::unknown(reason)), + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/omloom-store/src/requirements/knowledge.rs b/crates/omloom-store/src/requirements/knowledge.rs new file mode 100644 index 0000000..7002685 --- /dev/null +++ b/crates/omloom-store/src/requirements/knowledge.rs @@ -0,0 +1,329 @@ +//! Predicate comparison over current claims, independently of their producer. +use super::{Decision, Reason}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Reference<'a> { + pub issuer: &'a str, + pub key: &'a str, + pub basis: &'a str, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Atom<'a> { + Text(&'a str), + Integer(i64), + Flag(bool), + Reference(Reference<'a>), +} +#[derive(Clone, Copy, Debug)] +pub struct Quantity<'a> { + pub lower: Option, + pub upper: Option, + pub unit: &'a str, + pub scale: u32, +} +#[derive(Clone, Copy, Debug)] +pub enum Predicate<'a> { + Is(Atom<'a>), + OneOf(&'a [Atom<'a>]), + Minimum { + value: i64, + unit: &'a str, + scale: u32, + }, + Maximum { + value: i64, + unit: &'a str, + scale: u32, + }, +} + +/// A borrowed, bounded view avoids copying a wire producer's candidate arrays. +/// Probabilities and scores deliberately supply no operational truth value. +pub trait Alternatives { + fn candidates(&self) -> usize; + fn candidate(&self, index: usize) -> Atom<'_>; + fn exclusions(&self) -> usize; + fn exclusion(&self, index: usize) -> Atom<'_>; + fn exhaustive(&self) -> bool; +} +#[derive(Clone, Copy)] +pub enum Claim<'a, A> { + Known(Atom<'a>), + Quantity(Quantity<'a>), + Alternatives(&'a A), + Missing, +} +fn equal(a: Atom<'_>, b: Atom<'_>) -> Option { + match (a, b) { + (Atom::Text(a), Atom::Text(b)) => Some(a == b), + (Atom::Integer(a), Atom::Integer(b)) => Some(a == b), + (Atom::Flag(a), Atom::Flag(b)) => Some(a == b), + (Atom::Reference(a), Atom::Reference(b)) => Some(a == b), + _ => None, + } +} +fn numeric(value: i64, scale: u32) -> i128 { + // At most six places: every scaled i64 fits in i128, including MIN. + debug_assert!(scale <= 6); + i128::from(value) * 10i128.pow(6 - scale) +} +fn atom(value: Atom<'_>, predicate: Predicate<'_>) -> Decision { + match predicate { + Predicate::Is(expected) => match equal(value, expected) { + Some(true) => Decision::PASS, + Some(false) => Decision::FAIL, + None => Decision::unknown(Reason::WrongEvidence), + }, + Predicate::OneOf(values) => { + if values.is_empty() || values.len() > 16 { + return Decision::unknown(Reason::Incomplete); + } + let mut compatible_type = false; + for &expected in values { + match equal(value, expected) { + Some(true) => return Decision::PASS, + Some(false) => compatible_type = true, + None => {} + } + } + if compatible_type { + Decision::FAIL + } else { + Decision::unknown(Reason::WrongEvidence) + } + } + Predicate::Minimum { + value: expected, + unit: "count", + scale: 0, + } => match value { + Atom::Integer(value) if value >= expected => Decision::PASS, + Atom::Integer(_) => Decision::FAIL, + _ => Decision::unknown(Reason::WrongEvidence), + }, + Predicate::Maximum { + value: expected, + unit: "count", + scale: 0, + } => match value { + Atom::Integer(value) if value <= expected => Decision::PASS, + Atom::Integer(_) => Decision::FAIL, + _ => Decision::unknown(Reason::WrongEvidence), + }, + _ => Decision::unknown(Reason::WrongEvidence), + } +} + +pub fn compare(claim: Claim<'_, A>, predicate: Predicate<'_>) -> Decision { + match claim { + Claim::Missing => Decision::unknown(Reason::Missing), + Claim::Known(value) => atom(value, predicate), + Claim::Quantity(quantity) => { + if quantity.scale > 6 + || matches!((quantity.lower, quantity.upper), (Some(a), Some(b)) if a > b) + { + return Decision::unknown(Reason::Conflict); + } + let (bound, unit, scale, minimum) = match predicate { + Predicate::Minimum { value, unit, scale } => (value, unit, scale, true), + Predicate::Maximum { value, unit, scale } => (value, unit, scale, false), + Predicate::Is(Atom::Integer(value)) if quantity.unit == "count" => { + (value, "count", 0, true) + } + _ => return Decision::unknown(Reason::WrongEvidence), + }; + if unit != quantity.unit || scale > 6 { + return Decision::unknown(Reason::WrongEvidence); + } + let bound = numeric(bound, scale); + let lower = quantity.lower.map(|n| numeric(n, quantity.scale)); + let upper = quantity.upper.map(|n| numeric(n, quantity.scale)); + if matches!(predicate, Predicate::Is(_)) { + if lower == Some(bound) && upper == Some(bound) { + return Decision::PASS; + } + if lower.is_some_and(|n| n > bound) || upper.is_some_and(|n| n < bound) { + return Decision::FAIL; + } + } else if minimum { + if lower.is_some_and(|n| n >= bound) { + return Decision::PASS; + } + if upper.is_some_and(|n| n < bound) { + return Decision::FAIL; + } + } else { + if upper.is_some_and(|n| n <= bound) { + return Decision::PASS; + } + if lower.is_some_and(|n| n > bound) { + return Decision::FAIL; + } + } + Decision::unknown(Reason::Incomplete) + } + Claim::Alternatives(values) => { + let count = values.candidates(); + let exclusions = values.exclusions(); + if count > 16 || exclusions > 16 { + return Decision::unknown(Reason::Incomplete); + } + for c in 0..count { + for e in 0..exclusions { + if equal(values.candidate(c), values.exclusion(e)) == Some(true) { + return Decision::unknown(Reason::Conflict); + } + } + } + // A supported exclusion can disprove an exact desired value even + // when the candidate list is incomplete; a low score cannot. + if let Predicate::Is(expected) = predicate { + for e in 0..exclusions { + if equal(expected, values.exclusion(e)) == Some(true) { + return Decision::FAIL; + } + } + } + if !values.exhaustive() || count == 0 { + return Decision::unknown(Reason::Incomplete); + } + let mut pass = 0; + let mut fail = 0; + for i in 0..count { + match atom(values.candidate(i), predicate).state { + super::State::Satisfied => pass += 1, + super::State::Failed => fail += 1, + super::State::Unknown => return Decision::unknown(Reason::WrongEvidence), + } + } + if pass == count { + Decision::PASS + } else if fail == count { + Decision::FAIL + } else { + Decision::unknown(Reason::Incomplete) + } + } + } +} + +fn supports(claim: &Claim<'_, A>, value: Atom<'_>) -> Option { + match claim { + Claim::Missing => Some(true), + Claim::Known(known) => equal(*known, value), + Claim::Quantity(q) => match value { + Atom::Integer(n) if q.unit == "count" && q.scale <= 6 => { + let n = numeric(n, 0); + Some( + q.lower.is_none_or(|low| numeric(low, q.scale) <= n) + && q.upper.is_none_or(|high| numeric(high, q.scale) >= n), + ) + } + _ => None, + }, + Claim::Alternatives(set) => { + if set.candidates() > 16 || set.exclusions() > 16 { + return None; + } + for i in 0..set.exclusions() { + if equal(set.exclusion(i), value) == Some(true) { + return Some(false); + } + } + if !set.exhaustive() { + return Some(true); + } + let mut comparable = false; + for i in 0..set.candidates() { + match equal(set.candidate(i), value) { + Some(true) => return Some(true), + Some(false) => comparable = true, + None => {} + } + } + if comparable { Some(false) } else { None } + } + } +} + +/// Intersect current source assertions before testing the desired predicate. +/// Two incompatible models cannot pass merely because both are allowed models. +pub fn consistent(claims: &[Claim<'_, A>]) -> Decision { + if claims.len() > 32 { + return Decision::unknown(Reason::Incomplete); + } + let mut finite = None; + for claim in claims { + match claim { + Claim::Known(_) => { + finite = Some(claim); + break; + } + Claim::Alternatives(set) if set.exhaustive() => finite = Some(claim), + _ => {} + } + } + if let Some(base) = finite { + let count = match base { + Claim::Known(_) => 1, + Claim::Alternatives(set) => set.candidates(), + _ => 0, + }; + if count > 16 { + return Decision::unknown(Reason::Incomplete); + } + let mut uncertain = false; + for index in 0..count { + let value = match base { + Claim::Known(value) => *value, + Claim::Alternatives(set) => set.candidate(index), + _ => return Decision::unknown(Reason::WrongEvidence), + }; + let mut possible = true; + for claim in claims { + match supports(claim, value) { + Some(true) => {} + Some(false) => { + possible = false; + break; + } + None => { + uncertain = true; + possible = false; + break; + } + } + } + if possible { + return Decision::PASS; + } + } + return Decision::unknown(if uncertain { + Reason::WrongEvidence + } else { + Reason::Conflict + }); + } + let mut unit = None; + let mut low = i128::MIN; + let mut high = i128::MAX; + for claim in claims { + if let Claim::Quantity(q) = claim { + if q.scale > 6 || unit.is_some_and(|unit| unit != q.unit) { + return Decision::unknown(Reason::WrongEvidence); + } + unit = Some(q.unit); + if let Some(n) = q.lower { + low = low.max(numeric(n, q.scale)); + } + if let Some(n) = q.upper { + high = high.min(numeric(n, q.scale)); + } + } + } + if low > high { + Decision::unknown(Reason::Conflict) + } else { + Decision::PASS + } +} diff --git a/crates/omloom-store/src/requirements/tests.rs b/crates/omloom-store/src/requirements/tests.rs new file mode 100644 index 0000000..2d9a837 --- /dev/null +++ b/crates/omloom-store/src/requirements/tests.rs @@ -0,0 +1,483 @@ +use super::*; +use knowledge::{Alternatives, Atom, Claim, Predicate, Quantity}; + +fn subject(id: &'static str) -> Subject<'static> { + Subject { + namespace: "local", + resource: id, + incarnation: "first", + network: NetworkScope::Bound("lan"), + } +} +fn leaf(id: &'static str, decision: Decision) -> Node<'static> { + Node { + subject: subject(id), + role: Role::Requirement, + member_role: MemberRole::Eligible, + expression: Expression::Fact(decision), + } +} +fn membership(complete: bool) -> Membership<'static> { + Membership { + complete, + consensus: false, + qualified_profile: None, + revision: None, + } +} +fn group<'a>(mode: Group, members: &'a [usize], membership: Membership<'a>) -> Node<'a> { + Node { + subject: subject("group"), + role: Role::Requirement, + member_role: MemberRole::Eligible, + expression: Expression::Group { + mode, + members, + membership, + }, + } +} +fn run(nodes: &[Node<'_>]) -> Decision { + let mut results = [Decision::unknown(Reason::NotSelected); MAX_NODES]; + evaluate(nodes, nodes.len() - 1, &mut results).expect("valid test graph") +} + +#[test] +fn partial_all_any_and_fixed_threshold_preserve_unknown() { + // Missing alternatives cannot establish failure; incomplete mandatory sets cannot pass. + for complete in [false, true] { + for mode in [Group::All, Group::Any, Group::AtLeast(2)] { + let nodes = [ + leaf("a", Decision::PASS), + leaf("b", Decision::unknown(Reason::Stale)), + group(mode, &[0, 1], membership(complete)), + ]; + let result = run(&nodes); + assert_eq!( + result.state, + if matches!(mode, Group::Any) { + State::Satisfied + } else { + State::Unknown + } + ); + } + assert_eq!( + run(&[ + leaf("a", Decision::FAIL), + group(Group::All, &[0], membership(complete)) + ]), + Decision::FAIL + ); + assert_eq!( + run(&[ + leaf("a", Decision::FAIL), + group(Group::Any, &[0], membership(complete)) + ]) + .state, + if complete { + State::Failed + } else { + State::Unknown + } + ); + } +} + +#[test] +fn duplicate_voters_learners_and_unqualified_majority_never_inflate_success() { + // Three rows are not three votes when two describe the same incarnation. + let nodes = [ + leaf("a", Decision::PASS), + leaf("a", Decision::PASS), + leaf("b", Decision::FAIL), + group(Group::AtLeast(2), &[0, 1, 2], membership(true)), + ]; + assert_eq!(run(&nodes), Decision::FAIL); + let mut nodes = nodes; + nodes[1].expression = Expression::Fact(Decision::FAIL); + assert_eq!(run(&nodes).reason, Reason::Conflict); + nodes[1].subject = subject("learner"); + nodes[1].member_role = MemberRole::Learner; + nodes[3] = group(Group::Majority, &[0, 1, 2], membership(true)); + assert_eq!(run(&nodes).reason, Reason::Unqualified); + let qualified = Membership { + complete: true, + consensus: true, + qualified_profile: Some("provider/v1/voting"), + revision: Some("membership/42"), + }; + nodes[3] = group(Group::Majority, &[0, 1, 2], qualified); + assert_eq!(run(&nodes), Decision::FAIL); + nodes[2].expression = Expression::Fact(Decision::unknown(Reason::Restricted)); + assert_eq!(run(&nodes).state, State::Unknown); + nodes[2].member_role = MemberRole::Unknown; + assert_eq!(run(&nodes).reason, Reason::Incomplete); +} + +#[test] +fn unselected_method_preferences_and_prose_cannot_change_mandatory_result() { + // The selected action does not depend on a missing alternative method. + let mut nodes = [ + leaf("selected", Decision::FAIL), + leaf("other-method", Decision::unknown(Reason::Missing)), + leaf("preference", Decision::PASS), + group(Group::All, &[0, 2], membership(true)), + ]; + nodes[2].role = Role::Preference; + assert_eq!(run(&nodes), Decision::FAIL); + nodes[0].expression = Expression::Fact(Decision::PASS); + let mut results = [Decision::unknown(Reason::NotSelected); MAX_NODES]; + assert_eq!(evaluate(&nodes, 3, &mut results), Ok(Decision::PASS)); + assert_eq!(results[1].reason, Reason::NotSelected); + nodes[0].role = Role::ConditionalBlock; + assert_eq!(run(&nodes), Decision::FAIL); +} + +#[test] +fn cycle_depth_empty_and_work_limits_are_enforced_without_recursion() { + // A cycle or truncation cannot turn into a vacuously satisfied prerequisite. + let nodes = [ + group(Group::All, &[1], membership(true)), + group(Group::All, &[0], membership(true)), + ]; + assert_eq!(run(&nodes).reason, Reason::Cycle); + let mut output = [Decision::PASS; MAX_NODES]; + assert_eq!( + evaluate(&[group(Group::All, &[], membership(true))], 0, &mut output), + Err(Error::Invalid) + ); + let indices: alloc::vec::Vec<_> = (1..=MAX_DEPTH).map(|i| [i]).collect(); + let mut nodes: alloc::vec::Vec<_> = indices + .iter() + .map(|m| group(Group::All, m, membership(true))) + .collect(); + nodes.push(leaf("end", Decision::PASS)); + assert_eq!(evaluate(&nodes, 0, &mut output), Err(Error::Limit)); + let wide = [0usize; MAX_EDGES + 1]; + assert_eq!( + evaluate( + &[ + leaf("a", Decision::PASS), + group(Group::All, &wide, membership(true)) + ], + 1, + &mut output + ), + Err(Error::Limit) + ); +} + +fn requirement(kind: EvidenceKind) -> Requirement<'static> { + Requirement { + subject: subject("guest"), + semantic: "graceful-stop", + vantage: "guest-network", + phase: Phase::Current, + kind, + interval: None, + } +} +fn evidence(wanted: Requirement<'static>) -> Evidence<'static> { + Evidence { + requirement: wanted, + origin: Origin::DirectObservation, + observed_at: Some(90), + valid_until: Some(110), + coverage: Some(Window { + start: 80, + end: 100, + }), + qualified: true, + decision: Decision::PASS, + } +} + +#[test] +fn scope_vantage_phase_action_and_source_are_part_of_evidence_identity() { + // Equal IP strings, provider success and forced power-off do not prove the requested guest action. + let wanted = requirement(EvidenceKind::IndependentObservation); + let mut source = evidence(wanted); + source.requirement.subject.network = NetworkScope::Bound("other-lan"); + assert_eq!(fact(wanted, source, 100).reason, Reason::ScopeMismatch); + source = evidence(wanted); + source.requirement.vantage = "executor"; + assert_eq!(fact(wanted, source, 100).reason, Reason::VantageMismatch); + source = evidence(wanted); + source.origin = Origin::PeerAssertion; + assert_eq!(fact(wanted, source, 100).reason, Reason::WrongEvidence); + source = evidence(wanted); + source.requirement.semantic = "force-off"; + assert_eq!(fact(wanted, source, 100).reason, Reason::WrongEvidence); + source = evidence(wanted); + source.requirement.phase = Phase::Predicted; + assert_eq!(fact(wanted, source, 100).reason, Reason::WrongEvidence); + source = evidence(wanted); + source.origin = Origin::Accepted; + assert_eq!(fact(wanted, source, 100).reason, Reason::WrongEvidence); + source = evidence(wanted); + source.qualified = false; + assert_eq!(fact(wanted, source, 100).reason, Reason::Unqualified); +} + +#[test] +fn interval_gaps_expiry_and_authority_remain_distinct() { + // Missing coverage is not zero activity; inventory ownership is not a grant. + let mut wanted = requirement(EvidenceKind::Observation); + wanted.interval = Some(Window { + start: 70, + end: 100, + }); + let source = evidence(wanted); + assert_eq!(fact(wanted, source, 100).reason, Reason::Incomplete); + assert_eq!(fact(wanted, source, 110).reason, Reason::Stale); + let authority = Requirement { + kind: EvidenceKind::Authority, + ..wanted + }; + assert_eq!( + fact(authority, source, 100).reason, + Reason::AuthorityUnavailable + ); + let mut source = evidence(wanted); + source.coverage = Some(Window { + start: 60, + end: 100, + }); + assert_eq!(fact(wanted, source, 100), Decision::PASS); +} + +struct Set { + candidates: &'static [&'static str], + excluded: &'static [&'static str], + complete: bool, +} +impl Alternatives for Set { + fn candidates(&self) -> usize { + self.candidates.len() + } + fn candidate(&self, index: usize) -> Atom<'_> { + Atom::Text(self.candidates[index]) + } + fn exclusions(&self) -> usize { + self.excluded.len() + } + fn exclusion(&self, index: usize) -> Atom<'_> { + Atom::Text(self.excluded[index]) + } + fn exhaustive(&self) -> bool { + self.complete + } +} +#[test] +fn partial_models_exclusions_and_independent_measurements_do_not_invent_fit() { + // Neither a top-ranked model nor a score can certify rails; measured bounds can. + let partial = Set { + candidates: &["DL160"], + excluded: &["DL120"], + complete: false, + }; + assert_eq!( + knowledge::compare( + Claim::Alternatives(&partial), + Predicate::Is(Atom::Text("DL160")) + ) + .state, + State::Unknown + ); + assert_eq!( + knowledge::compare( + Claim::Alternatives(&partial), + Predicate::Is(Atom::Text("DL120")) + ), + Decision::FAIL + ); + let complete = Set { + candidates: &["DL160", "DL120"], + excluded: &[], + complete: true, + }; + assert_eq!( + knowledge::compare( + Claim::Alternatives(&complete), + Predicate::OneOf(&[Atom::Text("DL160"), Atom::Text("DL120")]) + ), + Decision::PASS + ); + let conflict = Set { + candidates: &["DL160"], + excluded: &["DL160"], + complete: true, + }; + assert_eq!( + knowledge::compare( + Claim::Alternatives(&conflict), + Predicate::Is(Atom::Text("DL160")) + ) + .reason, + Reason::Conflict + ); + for (width, expected) in [(480, State::Failed), (440, State::Satisfied)] { + let claim: Claim<'_, Set> = Claim::Quantity(Quantity { + lower: Some(width), + upper: Some(width), + unit: "mm", + scale: 0, + }); + assert_eq!( + knowledge::compare( + claim, + Predicate::Maximum { + value: 4500, + unit: "mm", + scale: 1 + } + ) + .state, + expected + ); + } +} + +#[test] +fn console_viewing_does_not_prove_boot_disk_or_protection_control() { + // These are conditional technical-control facts, never grants or compromise findings. + for kind in ["vm", "device", "service", "container"] { + let mut nodes = [ + leaf(kind, Decision::PASS), + leaf("boot", Decision::unknown(Reason::Missing)), + leaf("disk-writable", Decision::PASS), + leaf("unlocked", Decision::PASS), + group(Group::All, &[1, 2, 3], membership(true)), + ]; + assert_eq!(run(&nodes).state, State::Unknown); + nodes[1].expression = Expression::Fact(Decision::PASS); + assert_eq!(run(&nodes), Decision::PASS); + nodes[3].expression = Expression::Fact(Decision::unknown(Reason::Missing)); + assert_eq!(run(&nodes).state, State::Unknown); + } +} + +#[test] +fn requirements_unavailable_capability_is_not_missing_measurement() { + // Unsupported/configuration/authority failures stay actionable without fabricated observations. + let wanted = requirement(EvidenceKind::Observation); + for reason in [ + Reason::Unsupported, + Reason::Unconfigured, + Reason::AuthorityUnavailable, + ] { + let mut source = evidence(wanted); + source.qualified = false; + source.observed_at = None; + source.valid_until = None; + source.decision = Decision::unknown(reason); + assert_eq!(fact(wanted, source, 100), Decision::unknown(reason)); + } +} + +#[test] +fn requirements_preferences_cannot_consume_a_real_voters_identity() { + // Ignored annotations with the same subject cannot erase an eligible vote. + let mut nodes = [ + leaf("a", Decision::PASS), + leaf("a", Decision::PASS), + group(Group::AtLeast(1), &[0, 1], membership(true)), + ]; + nodes[0].role = Role::Preference; + assert_eq!(run(&nodes), Decision::PASS); +} + +#[test] +fn requirements_profile_match_never_supplies_missing_authority_or_facts() { + // A tested artifact/version is not transitive to another provider release or method. + let selected = Profile { + method: "graceful-stop/v1", + adapter_artifact: "qualified-artifact", + provider_version: "supported-release", + }; + assert_eq!( + applicability(selected, Some(selected), Decision::PASS), + Decision::PASS + ); + for reason in [Reason::Missing, Reason::AuthorityUnavailable] { + assert_eq!( + applicability(selected, Some(selected), Decision::unknown(reason)), + Decision::unknown(reason) + ); + } + let other = Profile { + provider_version: "unqualified-release", + ..selected + }; + assert_eq!( + applicability(other, Some(selected), Decision::PASS).reason, + Reason::Unqualified + ); + let other = Profile { + method: "force-off/v1", + ..selected + }; + assert_eq!( + applicability(other, Some(selected), Decision::PASS).reason, + Reason::Unqualified + ); +} + +#[test] +fn requirements_authority_evidence_cannot_prove_observed_service_state() { + // Permission to reach a service is not an actual observation of that service. + let wanted = requirement(EvidenceKind::Observation); + let mut source = evidence(wanted); + source.requirement.kind = EvidenceKind::Authority; + source.origin = Origin::AuthorityDecision; + assert_eq!(fact(wanted, source, 100).reason, Reason::WrongEvidence); +} + +#[test] +fn requirements_unknown_network_binding_is_not_a_shared_network() { + // Two missing network identifiers cannot establish a path in the same network. + let mut wanted = requirement(EvidenceKind::Observation); + wanted.subject.network = NetworkScope::Unknown; + assert_eq!( + fact(wanted, evidence(wanted), 100).reason, + Reason::ScopeMismatch + ); +} + +#[test] +fn requirements_do_not_expand_nonbinding_preferences() { + // A cyclic advisory branch is outside the mandatory prerequisite graph. + let mut preference = group(Group::All, &[1], membership(true)); + preference.role = Role::Preference; + let nodes = [ + leaf("required", Decision::PASS), + preference, + group(Group::All, &[0, 1], membership(true)), + ]; + assert_eq!(run(&nodes), Decision::PASS); +} + +#[test] +fn requirements_observations_cannot_manufacture_accepted_intent() { + // A discovered value does not mean an operator accepted that configuration. + let wanted = requirement(EvidenceKind::Configuration); + let mut source = evidence(wanted); + source.requirement.kind = EvidenceKind::Observation; + assert_eq!(fact(wanted, source, 100).reason, Reason::WrongEvidence); +} + +#[test] +fn requirements_incomplete_unselected_groups_do_not_block_selected_facts() { + // An unfinished alternative is not a prerequisite of the chosen method. + for other in [ + group(Group::All, &[], membership(true)), + group(Group::AtLeast(2), &[0], membership(true)), + ] { + let nodes = [leaf("chosen", Decision::PASS), other]; + let mut results = [Decision::unknown(Reason::NotSelected); MAX_NODES]; + assert_eq!(evaluate(&nodes, 0, &mut results), Ok(Decision::PASS)); + assert_eq!(results[1].reason, Reason::NotSelected); + assert_eq!(evaluate(&nodes, 1, &mut results), Err(Error::Invalid)); + } +} diff --git a/crates/omloom-store/tests/workspace.rs b/crates/omloom-store/tests/workspace.rs index 1685766..a844a82 100644 --- a/crates/omloom-store/tests/workspace.rs +++ b/crates/omloom-store/tests/workspace.rs @@ -26,6 +26,8 @@ mod host_import; mod host_network; #[path = "workspace/projections.rs"] mod projections; +#[path = "workspace/requirements.rs"] +mod requirements; #[path = "workspace/responsibility.rs"] mod responsibility; #[path = "workspace/software.rs"] diff --git a/crates/omloom-store/tests/workspace/requirements.rs b/crates/omloom-store/tests/workspace/requirements.rs new file mode 100644 index 0000000..fc61350 --- /dev/null +++ b/crates/omloom-store/tests/workspace/requirements.rs @@ -0,0 +1,524 @@ +use super::*; +use api::{reply::Result as Reply, request::Command}; + +fn condition( + target: &wire::AssetRef, + field: &str, + value: wire::field::Value, +) -> api::RequirementNode { + api::RequirementNode { + expression: Some(api::requirement_node::Expression::Field( + api::FieldRequirement { + target: Some(target.clone()), + expected: Some(wire::Field { + name: field.into(), + value: Some(value), + }), + comparison: api::RequirementComparison::Equal as i32, + layer: api::RequirementLayer::Accepted as i32, + }, + )), + } +} +fn evaluate( + store: &Store, + actor: &Context, + anchor: &wire::AssetRef, + nodes: Vec, + root: u32, +) -> Result { + match call( + store, + actor, + Command::EvaluateRequirements(api::EvaluateRequirements { + anchor: Some(anchor.clone()), + nodes, + root, + }), + )? { + Reply::RequirementEvaluation(value) => Ok(value), + _ => Err(Error::Corrupt), + } +} + +#[test] +fn requirements_use_real_current_facts_without_writes_or_unselected_expansion() -> TestResult { + // A corrected current fact changes only its result, never the semantic question identity. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Measured machine")?; + let predicate = condition( + &target, + "name", + wire::field::Value::Text("Measured machine".into()), + ); + let mut unused = predicate.clone(); + let Some(api::requirement_node::Expression::Field(field)) = &mut unused.expression else { + panic!("field") + }; + field.comparison = api::RequirementComparison::Unspecified as i32; + let before = store.usage()?; + let original = evaluate(&store, &actor, &target, vec![predicate.clone(), unused], 0)?; + assert_eq!(original.state, api::RequirementState::Satisfied as i32); + assert_eq!(original.results.len(), 1); + assert!(original.results[0].resolutions.is_empty()); + assert_eq!(store.usage()?, before); + let old = store.asset(AssetRef::try_from(&target)?)?; + call( + &store, + &actor, + Command::Write(api::Write { + target: Some(target.clone()), + kind: K::Device as i32, + expected: Some(old.revision), + changes: vec![text_field("name", "Corrected machine")], + ..Default::default() + }), + )?; + let before = store.usage()?; + let changed = evaluate(&store, &actor, &target, vec![predicate], 0)?; + assert_eq!(changed.state, api::RequirementState::Failed as i32); + assert_eq!( + changed.results[0].semantic_key, + original.results[0].semantic_key + ); + assert_eq!(store.usage()?, before); + Ok(()) +} + +#[test] +fn requirements_hide_denied_and_missing_dependencies_without_false_success() -> TestResult { + // A narrow resource screen cannot turn a denied upstream fact into an empty safe set. + use omloom_contracts::ids::{AssetId, IncarnationId}; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let anchor = create(&store, &actor, K::VirtualMachine, "Visible VM")?; + let hidden = create(&store, &actor, K::Device, "Private hypervisor")?; + let mut limited = actor; + limited.grants[0].scope = Scope::Targets(vec![AssetRef::try_from(&anchor)?]); + let before = store.usage()?; + let missing = wire::AssetRef { + asset_id: AssetId::generate().to_string(), + incarnation: IncarnationId::generate().to_string(), + }; + let mismatch = wire::AssetRef { + asset_id: hidden.asset_id.clone(), + incarnation: IncarnationId::generate().to_string(), + }; + // Missing, protected and mismatched incarnations must have the same public result. + for dependency in [hidden, missing, mismatch] { + let result = evaluate( + &store, + &limited, + &anchor, + vec![condition( + &dependency, + "name", + wire::field::Value::Text("Private hypervisor".into()), + )], + 0, + )?; + assert_eq!(result.state, api::RequirementState::Unknown as i32); + assert_eq!(result.reason, api::RequirementReason::Restricted as i32); + assert!(result.results[0].target.is_none()); + assert!(result.results[0].field.is_none()); + assert!(result.results[0].evidence.is_empty()); + assert!(result.results[0].resolutions.is_empty()); + } + assert_eq!(store.usage()?, before); + Ok(()) +} + +#[test] +fn requirements_conflicting_sources_cannot_pass_the_same_allowed_set() -> TestResult { + // Distinct exact model claims conflict even if both individually belong to the allowed set. + use omloom_contracts::ids::AuthorityId; + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Source disagreement")?; + let mut asset = store.asset(AssetRef::try_from(&target)?)?; + let previous = asset.revision; + asset.observed = vec![ + observation( + &AuthorityId::generate().to_string(), + 50, + vec![text_field("model", "A")], + ), + observation( + &AuthorityId::generate().to_string(), + 50, + vec![text_field("model", "B")], + ), + ]; + store.refresh_asset( + &admit(wire::record::Body::Asset(asset)), + previous, + store.usage()?.revision, + "fixture", + 60, + )?; + let mut node = condition( + &target, + "model", + wire::field::Value::Equipment(wire::EquipmentClaim { + basis: "Either model is acceptable".into(), + assertion: Some(wire::equipment_claim::Assertion::Alternatives( + wire::EquipmentAlternatives { + exhaustive: true, + candidates: ["A", "B"] + .into_iter() + .map(|value| wire::EquipmentAlternative { + value: value.into(), + ..Default::default() + }) + .collect(), + ..Default::default() + }, + )), + }), + ); + let Some(api::requirement_node::Expression::Field(field)) = &mut node.expression else { + panic!("field") + }; + field.layer = api::RequirementLayer::Observed as i32; + let before = store.usage()?; + let result = evaluate(&store, &actor, &target, vec![node.clone()], 0)?; + assert_eq!(result.reason, api::RequirementReason::Conflict as i32); + let Some(api::requirement_node::Expression::Field(field)) = &mut node.expression else { + panic!("field") + }; + let Some(wire::field::Value::Equipment(claim)) = + &mut field.expected.as_mut().ok_or(Error::Invalid)?.value + else { + panic!("claim") + }; + claim.basis = "Same predicate with another explanation".into(); + let Some(wire::equipment_claim::Assertion::Alternatives(values)) = &mut claim.assertion else { + panic!("alternatives") + }; + values.candidates.reverse(); + let reordered = evaluate(&store, &actor, &target, vec![node], 0)?; + assert_eq!( + result.results[0].semantic_key, + reordered.results[0].semantic_key + ); + assert_eq!(store.usage()?, before); + Ok(()) +} + +#[test] +fn requirements_owner_only_account_explains_a_request_without_granting_access() -> TestResult { + // Company/resource rights cannot manufacture provider delegation or secret release. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let account = create(&store, &actor, K::ProviderAccount, "Owner-held registrar")?; + store.workspace( + &actor, + api::Request { + working: Some(api::WorkingContext { + expected: Some(store.usage()?.revision), + ..Default::default() + }), + command: Some(Command::ExternalAccount(api::ExternalAccount { + target: Some(account.clone()), + expected: Some(store.asset(AssetRef::try_from(&account)?)?.revision), + metadata: Some(api::AccountMetadata { + issuer: "registrar.example".into(), + subject: "selected-account".into(), + kind: api::ExternalAccountKind::Administration as i32, + delegation: api::AccountDelegation::OwnerOnly as i32, + ..Default::default() + }), + ..Default::default() + })), + }, + 100, + )?; + let node = condition( + &account, + "delegation", + wire::field::Value::Text(api::AccountDelegation::Scoped.as_str_name().into()), + ); + let before = store.usage()?; + let result = evaluate(&store, &actor, &account, vec![node.clone()], 0)?; + assert_eq!( + result.reason, + api::RequirementReason::OwnerActionRequired as i32 + ); + assert!( + result.results[0] + .resolutions + .contains(&(api::RequirementResolution::OwnerAction as i32)) + ); + let mut limited = actor; + limited.grants[0].writable = false; + let result = evaluate(&store, &limited, &account, vec![node], 0)?; + assert_eq!( + result.reason, + api::RequirementReason::OwnerActionRequired as i32 + ); + assert!(result.results[0].resolutions.is_empty()); + assert_eq!(store.usage()?, before); + Ok(()) +} + +#[test] +fn requirements_observation_expiry_and_field_rights_preserve_unknown() -> TestResult { + // A retained but expired claim is not current evidence; field denial reveals no provenance. + use omloom_contracts::ids::AuthorityId; + let dir = tempfile::tempdir()?; + let mut store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Dated measurement")?; + store.set_diagnostic_query_check(|db, statement| { + if statement.contains("n.layer='observed'") || statement.contains("n.layer='freshness'") { + let plan = db + .explain_plan(statement, None) + .expect("selected evidence plan") + .explain(); + assert!( + plan.contains("IndexScan"), + "selected evidence must use its parent index: {plan}" + ); + } + }); + let mut asset = store.asset(AssetRef::try_from(&target)?)?; + let revision = asset.revision; + let mut observed = observation( + &AuthorityId::generate().to_string(), + 50, + vec![text_field("model", "A")], + ); + let scan = observed.scan_id.clone(); + observed + .evidence + .as_mut() + .ok_or(Error::Invalid)? + .valid_until_ms = 99; + asset.observed = vec![observed]; + store.refresh_asset( + &admit(wire::record::Body::Asset(asset)), + revision, + store.usage()?.revision, + "fixture", + 60, + )?; + let mut node = condition(&target, "model", wire::field::Value::Text("A".into())); + let Some(api::requirement_node::Expression::Field(field)) = &mut node.expression else { + panic!("field") + }; + field.layer = api::RequirementLayer::Observed as i32; + let before = store.usage()?; + let result = evaluate(&store, &actor, &target, vec![node.clone()], 0)?; + assert_eq!(result.reason, api::RequirementReason::Stale as i32); + assert!( + !result.results[0] + .resolutions + .contains(&(api::RequirementResolution::RecordFact as i32)) + ); + assert_eq!(result.results[0].evidence[0].scan_id, scan); + assert_eq!(result.results[0].evidence[0].valid_until_ms, Some(99)); + let mut limited = actor; + limited.grants[0].read = Fields::Selected(vec!["name".into()]); + limited.grants[0].write = Fields::Selected(vec!["name".into()]); + let result = evaluate(&store, &limited, &target, vec![node], 0)?; + assert_eq!(result.reason, api::RequirementReason::Restricted as i32); + assert!(result.results[0].target.is_none()); + assert!(result.results[0].field.is_none()); + assert!(result.results[0].evidence.is_empty()); + assert!(result.results[0].resolutions.is_empty()); + assert_eq!(store.usage()?, before); + Ok(()) +} + +#[test] +fn requirements_api_combines_distinct_subjects_and_rejects_cycles() -> TestResult { + // A threshold uses distinct scoped resources; graph cycles never admit an action. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let a = create(&store, &actor, K::Device, "A")?; + let b = create(&store, &actor, K::Device, "B")?; + let first = condition(&a, "name", wire::field::Value::Text("A".into())); + let second = condition(&b, "name", wire::field::Value::Text("B".into())); + let group = |members, minimum| api::RequirementNode { + expression: Some(api::requirement_node::Expression::Group( + api::RequirementGroup { + kind: api::RequirementGroupKind::AtLeast as i32, + members, + minimum, + }, + )), + }; + let before = store.usage()?; + let result = evaluate( + &store, + &actor, + &a, + vec![first.clone(), second.clone(), group(vec![0, 1], 2)], + 2, + )?; + assert_eq!(result.state, api::RequirementState::Satisfied as i32); + let reordered = evaluate( + &store, + &actor, + &a, + vec![group(vec![2, 1], 2), second, first.clone()], + 0, + )?; + assert_eq!( + result + .results + .iter() + .find(|r| r.node == 2) + .ok_or(Error::Missing)? + .semantic_key, + reordered + .results + .iter() + .find(|r| r.node == 0) + .ok_or(Error::Missing)? + .semantic_key + ); + let result = evaluate( + &store, + &actor, + &a, + vec![first.clone(), group(vec![0, 0], 2)], + 1, + )?; + assert_eq!(result.state, api::RequirementState::Failed as i32); + let result = evaluate(&store, &actor, &a, vec![first, group(vec![1], 1)], 1)?; + assert_eq!(result.reason, api::RequirementReason::Cycle as i32); + assert_eq!(store.usage()?, before); + Ok(()) +} + +#[test] +fn requirements_many_sources_use_two_bounded_evidence_queries() -> TestResult { + // Freshness is fetched as one selected batch, not one query per source. + use omloom_contracts::ids::AuthorityId; + use std::sync::atomic::{AtomicUsize, Ordering}; + static QUERIES: AtomicUsize = AtomicUsize::new(0); + let dir = tempfile::tempdir()?; + let mut store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Thirty-two sources")?; + let mut asset = store.asset(AssetRef::try_from(&target)?)?; + let revision = asset.revision; + asset.observed = (0..32) + .map(|_| { + observation( + &AuthorityId::generate().to_string(), + 50, + vec![text_field("model", "A")], + ) + }) + .collect(); + store.refresh_asset( + &admit(wire::record::Body::Asset(asset)), + revision, + store.usage()?.revision, + "fixture", + 60, + )?; + store.set_diagnostic_query_check(|_, statement| { + if statement.contains("n.layer='observed'") || statement.contains("n.layer='freshness'") { + QUERIES.fetch_add(1, Ordering::Relaxed); + } + }); + let mut node = condition(&target, "model", wire::field::Value::Text("A".into())); + let Some(api::requirement_node::Expression::Field(field)) = &mut node.expression else { + panic!("field") + }; + field.layer = api::RequirementLayer::Observed as i32; + let before = store.usage()?; + let result = evaluate(&store, &actor, &target, vec![node], 0)?; + assert_eq!(result.state, api::RequirementState::Satisfied as i32); + assert_eq!(result.results[0].evidence.len(), 32); + assert_eq!(QUERIES.load(Ordering::Relaxed), 2); + assert_eq!(store.usage()?, before); + Ok(()) +} + +#[test] +fn requirements_wrapping_one_resource_cannot_manufacture_two_voters() -> TestResult { + // Logical grouping is not an independently qualified voter identity. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "One voter")?; + let group = |kind, members, minimum| api::RequirementNode { + expression: Some(api::requirement_node::Expression::Group( + api::RequirementGroup { + kind: kind as i32, + members, + minimum, + }, + )), + }; + let result = evaluate( + &store, + &actor, + &target, + vec![ + condition( + &target, + "name", + wire::field::Value::Text("One voter".into()), + ), + group(api::RequirementGroupKind::All, vec![0], 0), + group(api::RequirementGroupKind::Any, vec![0], 0), + group(api::RequirementGroupKind::AtLeast, vec![1, 2], 2), + ], + 3, + )?; + assert_eq!(result.state, api::RequirementState::Unknown as i32); + assert_eq!(result.reason, api::RequirementReason::Incomplete as i32); + Ok(()) +} + +#[test] +fn requirements_api_defers_unselected_group_semantics() -> TestResult { + // Both the wire adapter and common core must ignore unfinished alternative methods. + let dir = tempfile::tempdir()?; + let store = Store::open(dir.path(), policy())?; + let actor = operator(); + let target = create(&store, &actor, K::Device, "Selected method")?; + let selected = condition( + &target, + "name", + wire::field::Value::Text("Selected method".into()), + ); + let before = store.usage()?; + for (kind, members, minimum) in [ + (api::RequirementGroupKind::All, vec![], 0), + (api::RequirementGroupKind::AtLeast, vec![0], 2), + (api::RequirementGroupKind::Any, vec![0], 1), + (api::RequirementGroupKind::Unspecified, vec![0], 0), + ] { + let other = api::RequirementNode { + expression: Some(api::requirement_node::Expression::Group( + api::RequirementGroup { + kind: kind as i32, + members, + minimum, + }, + )), + }; + let nodes = vec![selected.clone(), other]; + let result = evaluate(&store, &actor, &target, nodes.clone(), 0)?; + assert_eq!(result.state, api::RequirementState::Satisfied as i32); + assert_eq!(result.results.len(), 1); + assert!(matches!( + evaluate(&store, &actor, &target, nodes, 1), + Err(Error::Invalid) + )); + } + assert_eq!(store.usage()?, before); + Ok(()) +} diff --git a/docs/inventory.md b/docs/inventory.md index a0baf36..f879679 100644 --- a/docs/inventory.md +++ b/docs/inventory.md @@ -94,6 +94,38 @@ supplied value. Ordinary text and bay counts can also be entered directly in an import. Preview and ordinary field validation apply before accepting imported claims. +### Recorded requirements + +Open a device's **Check recorded requirements** section to compare a selected +equipment fact with a required value or measurement bound. Choose accepted +configuration or dated observations explicitly. A missing observation is unknown, +not a failed device. Incomplete model alternatives and recognition confidence +cannot certify compatibility. Independent measured bounds can answer their own +specific condition without identifying a model. + +Account cards can inspect the recorded delegation capability. An owner-only +account explains the need for owner intervention and, when permitted, points to +the existing owner-action request. Checking does not submit that request, reveal +a secret or grant account access. + +The `evaluate_requirements` workspace query supports a selected root in a bounded +flat graph of field predicates and `all`, `any` or fixed-threshold groups. It +reads exact resource incarnations under the current storage session. Thresholds +count distinct scoped field subjects. A nested logical group has unknown voter +identity and cannot manufacture another vote. This inventory query does not certify +a provider's consensus membership. Limits are 64 nodes, 256 edges and 32 levels, with at most +32 current source claims for a field. Oversized work fails explicitly instead +of returning a partially successful check. Unselected branches do not read facts +or require completed group semantics. Global wire, reference and work bounds still apply. + +Results include stable condition keys, current inventory revision, permitted +source revisions/scans and validity windows. Protected and unavailable subjects +share the same non-disclosing response. Reads do not create plans, requests, +snapshots, history or access timestamps. A satisfied condition is neither an +authorization grant nor an operation admission, live reachability test or +physical-fit certificate. Production authentication and provider qualification +remain separate from this fixture-based workspace. + ### Ownership and responsibility Open a resource's **Ownership and responsibility** card to inspect or edit its diff --git a/proto/omloom/v1/workspace.proto b/proto/omloom/v1/workspace.proto index 1f9146b..f283502 100644 --- a/proto/omloom/v1/workspace.proto +++ b/proto/omloom/v1/workspace.proto @@ -1377,6 +1377,134 @@ message Mutation { uint64 affected = 3; } message Empty {} +enum RequirementComparison { + REQUIREMENT_COMPARISON_UNSPECIFIED = 0; + REQUIREMENT_COMPARISON_EQUAL = 1; + REQUIREMENT_COMPARISON_MINIMUM = 2; + REQUIREMENT_COMPARISON_MAXIMUM = 3; +} +enum RequirementLayer { + REQUIREMENT_LAYER_UNSPECIFIED = 0; + REQUIREMENT_LAYER_ACCEPTED = 1; + REQUIREMENT_LAYER_OBSERVED = 2; +} +enum RequirementGroupKind { + REQUIREMENT_GROUP_KIND_UNSPECIFIED = 0; + REQUIREMENT_GROUP_KIND_ALL = 1; + REQUIREMENT_GROUP_KIND_ANY = 2; + REQUIREMENT_GROUP_KIND_AT_LEAST = 3; +} +message FieldRequirement { + // Caller-selected exact resource whose recorded fact is needed, not a grant. + omloom.v1.AssetRef target = 1; + // User's desired value and field name; clear and hypotheses are not expectations. + // Numeric measurements require equal lower/upper bounds and an explicit unit. + omloom.v1.Field expected = 2; + // Typed comparison selected by the caller, never executable expression text. + RequirementComparison comparison = 3; + // Whether to inspect accepted configuration or actual dated source claims. + // Accepted configuration never certifies observed availability. + RequirementLayer layer = 4; +} +message RequirementGroup { + // User-selected logical combination. This query does not certify consensus. + RequirementGroupKind kind = 1; + // Indices into EvaluateRequirements.nodes, at most 64 references per group. + repeated uint32 members = 2; + // Required only for AT_LEAST; count distinct scoped subjects, not source rows. + // Direct field subjects can vote; nested logical groups have unknown voter identity. + uint32 minimum = 3; +} +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; + } +} +message EvaluateRequirements { + // Resource whose selected requirements are being inspected in the working context. + omloom.v1.AssetRef anchor = 1; + // Caller-supplied desired conditions, at most 64 nodes and 256 total edges. + // Actual evidence, authority and completeness are always loaded by the server. + repeated RequirementNode nodes = 2; + // Selected method/root index. Unselected alternatives do not affect this result. + uint32 root = 3; +} +enum RequirementState { + REQUIREMENT_STATE_UNSPECIFIED = 0; + REQUIREMENT_STATE_SATISFIED = 1; + REQUIREMENT_STATE_FAILED = 2; + REQUIREMENT_STATE_UNKNOWN = 3; +} +enum RequirementReason { + REQUIREMENT_REASON_UNSPECIFIED = 0; + REQUIREMENT_REASON_MATCH = 1; + REQUIREMENT_REASON_MISMATCH = 2; + REQUIREMENT_REASON_MISSING = 3; + REQUIREMENT_REASON_STALE = 4; + REQUIREMENT_REASON_CONFLICT = 5; + REQUIREMENT_REASON_RESTRICTED = 6; + REQUIREMENT_REASON_UNSUPPORTED = 7; + REQUIREMENT_REASON_UNCONFIGURED = 8; + REQUIREMENT_REASON_AUTHORITY_UNAVAILABLE = 9; + REQUIREMENT_REASON_OWNER_ACTION_REQUIRED = 10; + REQUIREMENT_REASON_INCOMPLETE = 11; + REQUIREMENT_REASON_SCOPE_MISMATCH = 12; + REQUIREMENT_REASON_VANTAGE_MISMATCH = 13; + REQUIREMENT_REASON_UNQUALIFIED = 14; + REQUIREMENT_REASON_WRONG_EVIDENCE = 15; + REQUIREMENT_REASON_CYCLE = 16; +} +enum RequirementResolution { + REQUIREMENT_RESOLUTION_UNSPECIFIED = 0; + REQUIREMENT_RESOLUTION_RECORD_FACT = 1; + REQUIREMENT_RESOLUTION_REVIEW_SOURCE = 2; + REQUIREMENT_RESOLUTION_OWNER_ACTION = 3; +} +message RequirementEvidence { + // Permitted source or authority reference, never a credential or release token. + string source = 1; + // Source/content revision; absent means it was not supplied by that source. + optional string revision = 2; + // Original observation time in Unix milliseconds, absent for accepted intent. + optional uint64 observed_at_ms = 3; + // Original expiry in Unix milliseconds, absent for current accepted intent. + optional uint64 valid_until_ms = 4; + // Original source scan identity when no comparable source revision is supplied. + optional string scan_id = 5; +} +message RequirementResult { + // Original requested node index; no hidden dependency identities are added. + uint32 node = 1; + // Server-derived semantic key over the condition and scoped subject, independent of current values. + string semantic_key = 2; + // Predicate evidence result, never an authorization decision or effect receipt. + RequirementState state = 3; + // Stable reason; protected/missing subjects have the same restricted response. + RequirementReason reason = 4; + // Disclosed subject only when current object and requested field rights allow it. + optional omloom.v1.AssetRef target = 5; + // Disclosed field name; absent for groups and restricted requirements. + optional string field = 6; + // Only currently permitted next steps; reading never submits a request. + repeated RequirementResolution resolutions = 7; + // At most 32 permitted current source bases, without raw source payloads. + repeated RequirementEvidence evidence = 8; +} +message RequirementEvaluation { + // Current consistent inventory basis. Evaluations are transient and are not saved. + uint64 revision = 1; + // Observation of time for evaluating the supplied validity windows, Unix milliseconds. + uint64 evaluated_at_ms = 2; + // Overall selected-condition result, never safe-to-execute or an authority grant. + RequirementState state = 3; + // Overall reason; a cycle cannot become a partially successful graph. + RequirementReason reason = 4; + // Only nodes reached from the selected root; no facts for unselected methods. + repeated RequirementResult results = 5; +} + message Request { // Explicit working selector for resource details, prepared actions and every // inventory mutation/import. Query/export use Query roots/focus instead. @@ -1446,6 +1574,8 @@ message Request { ResolveIdentity resolve_identity = 31; // Review current/proposed VM placement without invoking an external effect. PrepareVmPlacement prepare_vm_placement = 32; + // Evaluate desired conditions against permitted current facts without writes or probes. + EvaluateRequirements evaluate_requirements = 33; } } message Reply { @@ -1493,5 +1623,7 @@ message Reply { ResolvedIdentity resolved_identity = 20; // Current authorized placement and the independently reviewed destination. VmPlacementReview vm_placement_review = 21; + // Shared read-only prerequisite explanations, not operation admission. + RequirementEvaluation requirement_evaluation = 22; } } diff --git a/scripts/check-contracts.sh b/scripts/check-contracts.sh index e6b5e66..b59380e 100644 --- a/scripts/check-contracts.sh +++ b/scripts/check-contracts.sh @@ -13,10 +13,20 @@ cargo clippy --locked --workspace --all-targets --all-features -- -D warnings cargo nextest run --locked --workspace --all-features cargo test --locked --workspace --all-features --doc cargo check --locked -p omloom-store --no-default-features --target thumbv7em-none-eabihf -cargo run --locked -p omloom-store --example smoke -cargo run --locked --example corpus -- dist/corpus +# Keep the default-feature runtime checks, using the same workspace selection +# so Cargo reuses dependency features instead of building package-specific graphs. +cargo build --locked --workspace --all-targets +du -sh target +df -h . +target/debug/examples/smoke +target/debug/examples/corpus dist/corpus yarn test:contracts -cargo run --locked --example verify -- dist/corpus +target/debug/examples/verify dist/corpus yarn test:browser -cargo clean +if [ "${CI:-}" = true ]; then + # Keep dependency artifacts for the cache action; remove this workspace's outputs. + cargo clean -p omloom-contracts -p omloom-store -p omloom-app -p omloom-observe +else + cargo clean +fi yarn clean diff --git a/web/app/App.vue b/web/app/App.vue index d46f95a..1678793 100644 --- a/web/app/App.vue +++ b/web/app/App.vue @@ -8,6 +8,7 @@ import HostIdentityCompare from './HostIdentityCompare.vue'; import VmPlacement from './VmPlacement.vue'; import ResponsibilityPanel from './ResponsibilityPanel.vue'; import AccountPanel from './AccountPanel.vue'; +import RequirementsPanel from './RequirementsPanel.vue'; import { softwareFields } from './software-fields'; import { equipmentFields } from './equipment-fields'; import { ref, computed, watch, onMounted, nextTick } from 'vue'; @@ -376,6 +377,7 @@ onMounted(async () => { try { await metadata(); await load(); } catch (e) { erro

Checking indexed source claims…

+

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

diff --git a/web/app/RequirementsPanel.vue b/web/app/RequirementsPanel.vue new file mode 100644 index 0000000..b219d77 --- /dev/null +++ b/web/app/RequirementsPanel.vue @@ -0,0 +1,70 @@ + + + diff --git a/web/browser/inventory.spec.mjs b/web/browser/inventory.spec.mjs index 74d52fa..f99b019 100644 --- a/web/browser/inventory.spec.mjs +++ b/web/browser/inventory.spec.mjs @@ -42,13 +42,91 @@ async function workspaceClient(url) { const built = buildSync({ stdin: { contents: 'export * from "./web/src/generated/omloom/v1/workspace"; export {AssetKind, RelationKind, EffectOutcome, OperationState} from "./web/src/generated/omloom/v1/contracts";', resolveDir: process.cwd() }, bundle: true, write: false, format: 'esm', platform: 'node' }); const m = await import('data:text/javascript;base64,' + Buffer.from(built.outputFiles[0].contents).toString('base64')); const transport = new GrpcWebFetchTransport({ baseUrl: url, format: 'binary' }); - async function rpc(command, focus) { + async function revision() { const q = await transport.unary(m.Workspace.methods[0], m.Request.create({ command: { oneofKind: 'query', query: m.Query.create({ limit: 1 }) } }), transport.mergeOptions()); - return (await transport.unary(m.Workspace.methods[0], m.Request.create({ command, working: { expected: q.response.result.page.revision, focus } }), transport.mergeOptions())).response.result; + return q.response.result.page.revision; + } + async function rpc(command, focus) { + return (await transport.unary(m.Workspace.methods[0], m.Request.create({ command, working: { expected: await revision(), focus } }), transport.mergeOptions())).response.result; } - return { m, rpc }; + return { m, rpc, revision }; } +test('requirements explain current and incomplete equipment on desktop and phone',async({browser})=>{ + // The real UI queries the disk-backed common evaluator; checks never create a plan or effect. + const dir=await mkdtemp(join(tmpdir(),'omloom-requirements-'));const server=await host(dir); + try{ + for(const width of [1440,390]){ + const page=await browser.newPage({viewport:{width,height:900},isMobile:width<600,hasTouch:width<600}); + try{ + await page.goto(server.url); + const created=await create(page,`Requirements ${width}`); + const target={assetId:created.asset_id,incarnation:created.incarnation}; + const {m,rpc,revision}=await workspaceClient(server.url); + const detail=(await rpc({oneofKind:'read',read:m.Read.create({target})})).detail; + await rpc({oneofKind:'write',write:m.Write.create({target,kind:m.AssetKind.DEVICE,expected:detail.item.revision,changes:[ + {name:'drive_bays',value:{oneofKind:'integer',integer:12n}}, + {name:'width',value:{oneofKind:'equipment',equipment:{basis:'Measured enclosure',assertion:{oneofKind:'quantity',quantity:{lower:440n,upper:440n,unit:'mm',scale:0}}}}}, + {name:'model',value:{oneofKind:'equipment',equipment:{basis:'Visible shape, exact model unconfirmed',assertion:{oneofKind:'alternatives',alternatives:{candidates:[{value:'Model A',scorePpm:950000}],exhaustive:false,domain:'Rack models'}}}}} + ]})}); + const beforeRequirementRevision=await revision(); + await page.reload(); + await page.getByText('Check recorded requirements',{exact:true}).click(); + await page.getByRole('combobox',{name:'Required fact',exact:true}).selectOption('drive_bays',{timeout:5000}); + await page.getByRole('combobox',{name:'Required comparison',exact:true}).selectOption(String(m.RequirementComparison.MINIMUM),{timeout:5000}); + await page.getByRole('textbox',{name:'Required value (count)',exact:true}).fill('12',{timeout:5000}); + await page.getByRole('button',{name:'Check requirement',exact:true}).click(); + const result=page.getByRole('region',{name:'Requirement result'}); + await expect(result.getByRole('heading',{name:'Recorded evidence matches',exact:true})).toBeVisible(); + await page.getByRole('textbox',{name:'Required value (count)',exact:true}).fill('13',{timeout:5000}); + await expect(result).toBeHidden(); + await page.getByRole('button',{name:'Check requirement',exact:true}).click(); + await expect(result.getByRole('heading',{name:'Requirement not satisfied',exact:true})).toBeVisible(); + await page.getByRole('combobox',{name:'Evidence layer',exact:true}).selectOption(String(m.RequirementLayer.OBSERVED),{timeout:5000}); + await page.getByRole('button',{name:'Check requirement',exact:true}).click(); + await expect(result.getByRole('heading',{name:'Requirement not established',exact:true})).toBeVisible(); + await page.getByRole('combobox',{name:'Evidence layer',exact:true}).selectOption(String(m.RequirementLayer.ACCEPTED)); + await page.getByRole('combobox',{name:'Required fact',exact:true}).selectOption('model'); + await page.getByRole('textbox',{name:'Required value',exact:true}).fill('Model A'); + await page.getByRole('button',{name:'Check requirement',exact:true}).click(); + await expect(result.getByRole('heading',{name:'Requirement not established',exact:true})).toBeVisible(); + await expect(result.getByText('incomplete',{exact:true})).toBeVisible(); + await page.getByRole('combobox',{name:'Required fact',exact:true}).selectOption('width'); + await expect(page.getByRole('button',{name:'Check requirement',exact:true})).toBeDisabled(); + await page.getByRole('combobox',{name:'Required comparison',exact:true}).selectOption(String(m.RequirementComparison.MAXIMUM)); + await page.getByRole('textbox',{name:'Required value (mm)',exact:true}).fill('450'); + await page.getByRole('button',{name:'Check requirement',exact:true}).click(); + await expect(result.getByRole('heading',{name:'Recorded evidence matches',exact:true})).toBeVisible(); + if(width===390)await page.screenshot({path:'test-results/requirements-phone.png',fullPage:true}); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + expect(await revision()).toBe(beforeRequirementRevision); + expect((await rpc({oneofKind:'read',read:m.Read.create({target})})).detail.item.revision).toBe(detail.item.revision+1n); + await page.goto(server.url); + await create(page,`Requirement account ${width}`,'PROVIDER_ACCOUNT'); + await page.getByRole('button',{name:'Edit account metadata',exact:true}).click(); + const dialog=page.getByRole('dialog'); + await dialog.getByLabel('Provider namespace',{exact:true}).fill('registrar.example'); + await dialog.getByLabel('Provider account identity',{exact:true}).fill(`requirement-${width}`); + await dialog.getByLabel('Account kind',{exact:true}).selectOption(String(m.ExternalAccountKind.ADMINISTRATION)); + await dialog.getByLabel('Provider delegation',{exact:true}).selectOption(String(m.AccountDelegation.OWNER_ONLY)); + await dialog.getByRole('button',{name:'Save account metadata',exact:true}).click(); + await expect(dialog).toBeHidden(); + const beforeAccountRevision=await revision(); + await page.getByText('Check recorded requirements',{exact:true}).click(); + await page.getByRole('combobox',{name:'Required fact',exact:true}).selectOption('delegation'); + await page.getByRole('combobox',{name:'Required value',exact:true}).selectOption('ACCOUNT_DELEGATION_SCOPED'); + await page.getByRole('button',{name:'Check requirement',exact:true}).click(); + await expect(result.getByRole('heading',{name:'Requirement not satisfied',exact:true})).toBeVisible(); + await expect(result.getByText('owner action required',{exact:true})).toBeVisible(); + await expect(page.getByRole('button',{name:'Request owner action',exact:true})).toHaveCount(0); + await expect(result.getByText(/It requires a visible managed resource\./)).toBeVisible(); + expect(await revision()).toBe(beforeAccountRevision); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + }finally{await page.close();} + } + }finally{await server.stop();await rm(dir,{recursive:true,force:true});} +}); + test('partial equipment is created and refined on desktop and phone without changing identity', async ({browser})=>{ // Real disk/API/UI: uncertain models and visible quantities remain current // attributed claims; refinement creates neither disks nor a second machine. diff --git a/web/src/generated/omloom/v1/workspace.ts b/web/src/generated/omloom/v1/workspace.ts index 01b4e39..bc1975e 100644 --- a/web/src/generated/omloom/v1/workspace.ts +++ b/web/src/generated/omloom/v1/workspace.ts @@ -3305,6 +3305,233 @@ export interface Mutation { */ export interface Empty { } +/** + * @generated from protobuf message omloom.workspace.v1.FieldRequirement + */ +export interface FieldRequirement { + /** + * Caller-selected exact resource whose recorded fact is needed, not a grant. + * + * @generated from protobuf field: omloom.v1.AssetRef target = 1 + */ + target?: AssetRef; + /** + * User's desired value and field name; clear and hypotheses are not expectations. + * Numeric measurements require equal lower/upper bounds and an explicit unit. + * + * @generated from protobuf field: omloom.v1.Field expected = 2 + */ + expected?: Field; + /** + * Typed comparison selected by the caller, never executable expression text. + * + * @generated from protobuf field: omloom.workspace.v1.RequirementComparison comparison = 3 + */ + comparison: RequirementComparison; + /** + * Whether to inspect accepted configuration or actual dated source claims. + * Accepted configuration never certifies observed availability. + * + * @generated from protobuf field: omloom.workspace.v1.RequirementLayer layer = 4 + */ + layer: RequirementLayer; +} +/** + * @generated from protobuf message omloom.workspace.v1.RequirementGroup + */ +export interface RequirementGroup { + /** + * User-selected logical combination. This query does not certify consensus. + * + * @generated from protobuf field: omloom.workspace.v1.RequirementGroupKind kind = 1 + */ + kind: RequirementGroupKind; + /** + * Indices into EvaluateRequirements.nodes, at most 64 references per group. + * + * @generated from protobuf field: repeated uint32 members = 2 + */ + members: number[]; + /** + * Required only for AT_LEAST; count distinct scoped subjects, not source rows. + * Direct field subjects can vote; nested logical groups have unknown voter identity. + * + * @generated from protobuf field: uint32 minimum = 3 + */ + minimum: number; +} +/** + * @generated from protobuf message omloom.workspace.v1.RequirementNode + */ +export interface RequirementNode { + /** + * One bounded predicate or group. Groups are a flat indexed graph, not recursive wire messages. + * + * @generated from protobuf oneof: expression + */ + expression: { + oneofKind: "field"; + /** + * @generated from protobuf field: omloom.workspace.v1.FieldRequirement field = 1 + */ + field: FieldRequirement; + } | { + oneofKind: "group"; + /** + * @generated from protobuf field: omloom.workspace.v1.RequirementGroup group = 2 + */ + group: RequirementGroup; + } | { + oneofKind: undefined; + }; +} +/** + * @generated from protobuf message omloom.workspace.v1.EvaluateRequirements + */ +export interface EvaluateRequirements { + /** + * Resource whose selected requirements are being inspected in the working context. + * + * @generated from protobuf field: omloom.v1.AssetRef anchor = 1 + */ + anchor?: AssetRef; + /** + * Caller-supplied desired conditions, at most 64 nodes and 256 total edges. + * Actual evidence, authority and completeness are always loaded by the server. + * + * @generated from protobuf field: repeated omloom.workspace.v1.RequirementNode nodes = 2 + */ + nodes: RequirementNode[]; + /** + * Selected method/root index. Unselected alternatives do not affect this result. + * + * @generated from protobuf field: uint32 root = 3 + */ + root: number; +} +/** + * @generated from protobuf message omloom.workspace.v1.RequirementEvidence + */ +export interface RequirementEvidence { + /** + * Permitted source or authority reference, never a credential or release token. + * + * @generated from protobuf field: string source = 1 + */ + source: string; + /** + * Source/content revision; absent means it was not supplied by that source. + * + * @generated from protobuf field: optional string revision = 2 + */ + revision?: string; + /** + * Original observation time in Unix milliseconds, absent for accepted intent. + * + * @generated from protobuf field: optional uint64 observed_at_ms = 3 + */ + observedAtMs?: bigint; + /** + * Original expiry in Unix milliseconds, absent for current accepted intent. + * + * @generated from protobuf field: optional uint64 valid_until_ms = 4 + */ + validUntilMs?: bigint; + /** + * Original source scan identity when no comparable source revision is supplied. + * + * @generated from protobuf field: optional string scan_id = 5 + */ + scanId?: string; +} +/** + * @generated from protobuf message omloom.workspace.v1.RequirementResult + */ +export interface RequirementResult { + /** + * Original requested node index; no hidden dependency identities are added. + * + * @generated from protobuf field: uint32 node = 1 + */ + node: number; + /** + * Server-derived semantic key over the condition and scoped subject, independent of current values. + * + * @generated from protobuf field: string semantic_key = 2 + */ + semanticKey: string; + /** + * Predicate evidence result, never an authorization decision or effect receipt. + * + * @generated from protobuf field: omloom.workspace.v1.RequirementState state = 3 + */ + state: RequirementState; + /** + * Stable reason; protected/missing subjects have the same restricted response. + * + * @generated from protobuf field: omloom.workspace.v1.RequirementReason reason = 4 + */ + reason: RequirementReason; + /** + * Disclosed subject only when current object and requested field rights allow it. + * + * @generated from protobuf field: optional omloom.v1.AssetRef target = 5 + */ + target?: AssetRef; + /** + * Disclosed field name; absent for groups and restricted requirements. + * + * @generated from protobuf field: optional string field = 6 + */ + field?: string; + /** + * Only currently permitted next steps; reading never submits a request. + * + * @generated from protobuf field: repeated omloom.workspace.v1.RequirementResolution resolutions = 7 + */ + resolutions: RequirementResolution[]; + /** + * At most 32 permitted current source bases, without raw source payloads. + * + * @generated from protobuf field: repeated omloom.workspace.v1.RequirementEvidence evidence = 8 + */ + evidence: RequirementEvidence[]; +} +/** + * @generated from protobuf message omloom.workspace.v1.RequirementEvaluation + */ +export interface RequirementEvaluation { + /** + * Current consistent inventory basis. Evaluations are transient and are not saved. + * + * @generated from protobuf field: uint64 revision = 1 + */ + revision: bigint; + /** + * Observation of time for evaluating the supplied validity windows, Unix milliseconds. + * + * @generated from protobuf field: uint64 evaluated_at_ms = 2 + */ + evaluatedAtMs: bigint; + /** + * Overall selected-condition result, never safe-to-execute or an authority grant. + * + * @generated from protobuf field: omloom.workspace.v1.RequirementState state = 3 + */ + state: RequirementState; + /** + * Overall reason; a cycle cannot become a partially successful graph. + * + * @generated from protobuf field: omloom.workspace.v1.RequirementReason reason = 4 + */ + reason: RequirementReason; + /** + * Only nodes reached from the selected root; no facts for unselected methods. + * + * @generated from protobuf field: repeated omloom.workspace.v1.RequirementResult results = 5 + */ + results: RequirementResult[]; +} /** * @generated from protobuf message omloom.workspace.v1.Request */ @@ -3570,6 +3797,14 @@ export interface Request { * @generated from protobuf field: omloom.workspace.v1.PrepareVmPlacement prepare_vm_placement = 32 */ prepareVmPlacement: PrepareVmPlacement; + } | { + oneofKind: "evaluateRequirements"; + /** + * Evaluate desired conditions against permitted current facts without writes or probes. + * + * @generated from protobuf field: omloom.workspace.v1.EvaluateRequirements evaluate_requirements = 33 + */ + evaluateRequirements: EvaluateRequirements; } | { oneofKind: undefined; }; @@ -3751,6 +3986,14 @@ export interface Reply { * @generated from protobuf field: omloom.workspace.v1.VmPlacementReview vm_placement_review = 21 */ vmPlacementReview: VmPlacementReview; + } | { + oneofKind: "requirementEvaluation"; + /** + * Shared read-only prerequisite explanations, not operation admission. + * + * @generated from protobuf field: omloom.workspace.v1.RequirementEvaluation requirement_evaluation = 22 + */ + requirementEvaluation: RequirementEvaluation; } | { oneofKind: undefined; }; @@ -4289,6 +4532,180 @@ export enum AddressMeaning { */ DESTINATION = 2 } +/** + * @generated from protobuf enum omloom.workspace.v1.RequirementComparison + */ +export enum RequirementComparison { + /** + * @generated from protobuf enum value: REQUIREMENT_COMPARISON_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: REQUIREMENT_COMPARISON_EQUAL = 1; + */ + EQUAL = 1, + /** + * @generated from protobuf enum value: REQUIREMENT_COMPARISON_MINIMUM = 2; + */ + MINIMUM = 2, + /** + * @generated from protobuf enum value: REQUIREMENT_COMPARISON_MAXIMUM = 3; + */ + MAXIMUM = 3 +} +/** + * @generated from protobuf enum omloom.workspace.v1.RequirementLayer + */ +export enum RequirementLayer { + /** + * @generated from protobuf enum value: REQUIREMENT_LAYER_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: REQUIREMENT_LAYER_ACCEPTED = 1; + */ + ACCEPTED = 1, + /** + * @generated from protobuf enum value: REQUIREMENT_LAYER_OBSERVED = 2; + */ + OBSERVED = 2 +} +/** + * @generated from protobuf enum omloom.workspace.v1.RequirementGroupKind + */ +export enum RequirementGroupKind { + /** + * @generated from protobuf enum value: REQUIREMENT_GROUP_KIND_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: REQUIREMENT_GROUP_KIND_ALL = 1; + */ + ALL = 1, + /** + * @generated from protobuf enum value: REQUIREMENT_GROUP_KIND_ANY = 2; + */ + ANY = 2, + /** + * @generated from protobuf enum value: REQUIREMENT_GROUP_KIND_AT_LEAST = 3; + */ + AT_LEAST = 3 +} +/** + * @generated from protobuf enum omloom.workspace.v1.RequirementState + */ +export enum RequirementState { + /** + * @generated from protobuf enum value: REQUIREMENT_STATE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: REQUIREMENT_STATE_SATISFIED = 1; + */ + SATISFIED = 1, + /** + * @generated from protobuf enum value: REQUIREMENT_STATE_FAILED = 2; + */ + FAILED = 2, + /** + * @generated from protobuf enum value: REQUIREMENT_STATE_UNKNOWN = 3; + */ + UNKNOWN = 3 +} +/** + * @generated from protobuf enum omloom.workspace.v1.RequirementReason + */ +export enum RequirementReason { + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_MATCH = 1; + */ + MATCH = 1, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_MISMATCH = 2; + */ + MISMATCH = 2, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_MISSING = 3; + */ + MISSING = 3, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_STALE = 4; + */ + STALE = 4, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_CONFLICT = 5; + */ + CONFLICT = 5, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_RESTRICTED = 6; + */ + RESTRICTED = 6, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_UNSUPPORTED = 7; + */ + UNSUPPORTED = 7, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_UNCONFIGURED = 8; + */ + UNCONFIGURED = 8, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_AUTHORITY_UNAVAILABLE = 9; + */ + AUTHORITY_UNAVAILABLE = 9, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_OWNER_ACTION_REQUIRED = 10; + */ + OWNER_ACTION_REQUIRED = 10, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_INCOMPLETE = 11; + */ + INCOMPLETE = 11, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_SCOPE_MISMATCH = 12; + */ + SCOPE_MISMATCH = 12, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_VANTAGE_MISMATCH = 13; + */ + VANTAGE_MISMATCH = 13, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_UNQUALIFIED = 14; + */ + UNQUALIFIED = 14, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_WRONG_EVIDENCE = 15; + */ + WRONG_EVIDENCE = 15, + /** + * @generated from protobuf enum value: REQUIREMENT_REASON_CYCLE = 16; + */ + CYCLE = 16 +} +/** + * @generated from protobuf enum omloom.workspace.v1.RequirementResolution + */ +export enum RequirementResolution { + /** + * @generated from protobuf enum value: REQUIREMENT_RESOLUTION_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + /** + * @generated from protobuf enum value: REQUIREMENT_RESOLUTION_RECORD_FACT = 1; + */ + RECORD_FACT = 1, + /** + * @generated from protobuf enum value: REQUIREMENT_RESOLUTION_REVIEW_SOURCE = 2; + */ + REVIEW_SOURCE = 2, + /** + * @generated from protobuf enum value: REQUIREMENT_RESOLUTION_OWNER_ACTION = 3; + */ + OWNER_ACTION = 3 +} // @generated message type with reflection information, may provide speed optimized methods class Predicate$Type extends MessageType { constructor() { @@ -5767,6 +6184,113 @@ class Empty$Type extends MessageType { */ export const Empty = new Empty$Type(); // @generated message type with reflection information, may provide speed optimized methods +class FieldRequirement$Type extends MessageType { + constructor() { + super("omloom.workspace.v1.FieldRequirement", [ + { no: 1, name: "target", kind: "message", T: () => AssetRef }, + { no: 2, name: "expected", kind: "message", T: () => Field }, + { no: 3, name: "comparison", kind: "enum", T: () => ["omloom.workspace.v1.RequirementComparison", RequirementComparison, "REQUIREMENT_COMPARISON_"] }, + { no: 4, name: "layer", kind: "enum", T: () => ["omloom.workspace.v1.RequirementLayer", RequirementLayer, "REQUIREMENT_LAYER_"] } + ]); + } +} +/** + * @generated MessageType for protobuf message omloom.workspace.v1.FieldRequirement + */ +export const FieldRequirement = new FieldRequirement$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class RequirementGroup$Type extends MessageType { + constructor() { + super("omloom.workspace.v1.RequirementGroup", [ + { no: 1, name: "kind", kind: "enum", T: () => ["omloom.workspace.v1.RequirementGroupKind", RequirementGroupKind, "REQUIREMENT_GROUP_KIND_"] }, + { no: 2, name: "members", kind: "scalar", repeat: 1 /*RepeatType.PACKED*/, T: 13 /*ScalarType.UINT32*/ }, + { no: 3, name: "minimum", kind: "scalar", T: 13 /*ScalarType.UINT32*/ } + ]); + } +} +/** + * @generated MessageType for protobuf message omloom.workspace.v1.RequirementGroup + */ +export const RequirementGroup = new RequirementGroup$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class RequirementNode$Type extends MessageType { + constructor() { + super("omloom.workspace.v1.RequirementNode", [ + { no: 1, name: "field", kind: "message", oneof: "expression", T: () => FieldRequirement }, + { no: 2, name: "group", kind: "message", oneof: "expression", T: () => RequirementGroup } + ]); + } +} +/** + * @generated MessageType for protobuf message omloom.workspace.v1.RequirementNode + */ +export const RequirementNode = new RequirementNode$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class EvaluateRequirements$Type extends MessageType { + constructor() { + super("omloom.workspace.v1.EvaluateRequirements", [ + { no: 1, name: "anchor", kind: "message", T: () => AssetRef }, + { no: 2, name: "nodes", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => RequirementNode }, + { no: 3, name: "root", kind: "scalar", T: 13 /*ScalarType.UINT32*/ } + ]); + } +} +/** + * @generated MessageType for protobuf message omloom.workspace.v1.EvaluateRequirements + */ +export const EvaluateRequirements = new EvaluateRequirements$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class RequirementEvidence$Type extends MessageType { + constructor() { + super("omloom.workspace.v1.RequirementEvidence", [ + { no: 1, name: "source", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 2, name: "revision", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "observed_at_ms", kind: "scalar", opt: true, T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 4, name: "valid_until_ms", kind: "scalar", opt: true, T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 5, name: "scan_id", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ } + ]); + } +} +/** + * @generated MessageType for protobuf message omloom.workspace.v1.RequirementEvidence + */ +export const RequirementEvidence = new RequirementEvidence$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class RequirementResult$Type extends MessageType { + constructor() { + super("omloom.workspace.v1.RequirementResult", [ + { no: 1, name: "node", kind: "scalar", T: 13 /*ScalarType.UINT32*/ }, + { no: 2, name: "semantic_key", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, + { no: 3, name: "state", kind: "enum", T: () => ["omloom.workspace.v1.RequirementState", RequirementState, "REQUIREMENT_STATE_"] }, + { no: 4, name: "reason", kind: "enum", T: () => ["omloom.workspace.v1.RequirementReason", RequirementReason, "REQUIREMENT_REASON_"] }, + { no: 5, name: "target", kind: "message", T: () => AssetRef }, + { no: 6, name: "field", kind: "scalar", opt: true, T: 9 /*ScalarType.STRING*/ }, + { no: 7, name: "resolutions", kind: "enum", repeat: 1 /*RepeatType.PACKED*/, T: () => ["omloom.workspace.v1.RequirementResolution", RequirementResolution, "REQUIREMENT_RESOLUTION_"] }, + { no: 8, name: "evidence", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => RequirementEvidence } + ]); + } +} +/** + * @generated MessageType for protobuf message omloom.workspace.v1.RequirementResult + */ +export const RequirementResult = new RequirementResult$Type(); +// @generated message type with reflection information, may provide speed optimized methods +class RequirementEvaluation$Type extends MessageType { + constructor() { + super("omloom.workspace.v1.RequirementEvaluation", [ + { no: 1, name: "revision", kind: "scalar", T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 2, name: "evaluated_at_ms", kind: "scalar", T: 4 /*ScalarType.UINT64*/, L: 0 /*LongType.BIGINT*/ }, + { no: 3, name: "state", kind: "enum", T: () => ["omloom.workspace.v1.RequirementState", RequirementState, "REQUIREMENT_STATE_"] }, + { no: 4, name: "reason", kind: "enum", T: () => ["omloom.workspace.v1.RequirementReason", RequirementReason, "REQUIREMENT_REASON_"] }, + { no: 5, name: "results", kind: "message", repeat: 2 /*RepeatType.UNPACKED*/, T: () => RequirementResult } + ]); + } +} +/** + * @generated MessageType for protobuf message omloom.workspace.v1.RequirementEvaluation + */ +export const RequirementEvaluation = new RequirementEvaluation$Type(); +// @generated message type with reflection information, may provide speed optimized methods class Request$Type extends MessageType { constructor() { super("omloom.workspace.v1.Request", [ @@ -5801,7 +6325,8 @@ class Request$Type extends MessageType { { no: 29, name: "correct_identity", kind: "message", oneof: "command", T: () => IdentityCorrection }, { no: 30, name: "find_identity_candidates", kind: "message", oneof: "command", T: () => FindIdentityCandidates }, { no: 31, name: "resolve_identity", kind: "message", oneof: "command", T: () => ResolveIdentity }, - { no: 32, name: "prepare_vm_placement", kind: "message", oneof: "command", T: () => PrepareVmPlacement } + { no: 32, name: "prepare_vm_placement", kind: "message", oneof: "command", T: () => PrepareVmPlacement }, + { no: 33, name: "evaluate_requirements", kind: "message", oneof: "command", T: () => EvaluateRequirements } ]); } } @@ -5833,7 +6358,8 @@ class Reply$Type extends MessageType { { no: 18, name: "identity_corrected", kind: "message", oneof: "result", T: () => IdentityCorrectionApplied }, { no: 19, name: "identity_candidates", kind: "message", oneof: "result", T: () => IdentityCandidates }, { no: 20, name: "resolved_identity", kind: "message", oneof: "result", T: () => ResolvedIdentity }, - { no: 21, name: "vm_placement_review", kind: "message", oneof: "result", T: () => VmPlacementReview } + { no: 21, name: "vm_placement_review", kind: "message", oneof: "result", T: () => VmPlacementReview }, + { no: 22, name: "requirement_evaluation", kind: "message", oneof: "result", T: () => RequirementEvaluation } ]); } }