diff --git a/.machine_readable/6a2/STATE.a2ml b/.machine_readable/6a2/STATE.a2ml index 06c56ff..f3cf531 100644 --- a/.machine_readable/6a2/STATE.a2ml +++ b/.machine_readable/6a2/STATE.a2ml @@ -3,7 +3,7 @@ [metadata] project = "palimpsest-plasma" -version = "0.4.0" +version = "0.5.0" last-updated = "2026-07-02" status = "active" diff --git a/CHANGELOG.md b/CHANGELOG.md index f5c530e..cdbb05a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,19 @@ this project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.5.0] - 2026-07-16 + +### Added + +- feat(engine): overlay `override-rules` semantics — an overlay removes base + rules by id from the effective set, and may pair an override with an + `add-rules` reusing the same id to *replace* a rule (e.g. relax a severity + or exempt a zone). `override-rules` targeting an unknown base id is a load + error; `modify-rules` remains reserved. The evaluator and the action + planner share one `effective_rules` set, so a finding never resolves to an + overridden rule (`plasma-engine/src/{eval,schema,action}.rs`) +- docs: overlay-algebra section in `docs/engine-v0-design.adoc` + ## [0.4.0] - 2026-07-16 ### Added diff --git a/CITATION.cff b/CITATION.cff index 195e4d0..04e8cb0 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -7,7 +7,7 @@ authors: family-names: "Jewell" email: "j.d.a.jewell@open.ac.uk" affiliation: "hyperpolymath" -version: 0.4.0 +version: 0.5.0 date-released: 2026-07-02 license: "MPL-2.0" repository-code: "https://github.com/hyperpolymath/palimpsest-plasma" diff --git a/Cargo.lock b/Cargo.lock index f0791c4..e5a631b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,7 +258,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "palimpsest-plasma" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "clap", @@ -273,7 +273,7 @@ dependencies = [ [[package]] name = "plasma-engine" -version = "0.4.0" +version = "0.5.0" dependencies = [ "glob", "plasma-parser", @@ -287,7 +287,7 @@ dependencies = [ [[package]] name = "plasma-parser" -version = "0.4.0" +version = "0.5.0" dependencies = [ "glob", "serde", diff --git a/Cargo.toml b/Cargo.toml index e108f56..8add1ed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = [".", "plasma-parser", "plasma-engine"] [package] name = "palimpsest-plasma" -version = "0.4.0" +version = "0.5.0" edition = "2021" rust-version = "1.74" authors = ["Jonathan D.A. Jewell "] diff --git a/Justfile b/Justfile index 1999953..e02b856 100644 --- a/Justfile +++ b/Justfile @@ -12,7 +12,7 @@ set positional-arguments := true import? "contractile.just" project := "palimpsest-plasma" -version := "0.4.0" +version := "0.5.0" tier := "infrastructure" state := ".machine_readable/6a2/STATE.a2ml" diff --git a/ROADMAP.adoc b/ROADMAP.adoc index 50cf9d4..5e04bbb 100644 --- a/ROADMAP.adoc +++ b/ROADMAP.adoc @@ -42,7 +42,8 @@ future project; the earlier union-policy-parser sub-project was removed * [x] Findings → planned actions mapping (pure `plan`; IO `apply`) * [x] First action type: `AddSpdxHeader` (+ manual partition with reasons) * [ ] More action types (CreateFile, UpdateField, …) -* [ ] Overlay `modify-rules` / `override-rules` semantics +* [x] Overlay `override-rules` semantics (v0.5) — remove/replace base rules + per context; `modify-rules` remains reserved * [ ] Content facts + `file-matches-pattern` condition semantics === v0.4.0 — Interchange Contracts & Integration Surfaces [IN PROGRESS] diff --git a/codemeta.json b/codemeta.json index fd0cc68..68de2dd 100644 --- a/codemeta.json +++ b/codemeta.json @@ -30,7 +30,7 @@ "programmingLanguage": [ "Rust" ], - "version": "0.4.0", + "version": "0.5.0", "url": "https://github.com/hyperpolymath/palimpsest-plasma", "readme": "https://github.com/hyperpolymath/palimpsest-plasma/blob/main/README.adoc", "issueTracker": "https://github.com/hyperpolymath/palimpsest-plasma/issues", diff --git a/docs/engine-v0-design.adoc b/docs/engine-v0-design.adoc index 933d3b4..e697de1 100644 --- a/docs/engine-v0-design.adoc +++ b/docs/engine-v0-design.adoc @@ -84,13 +84,33 @@ Every construct the v0 evaluator cannot give exact semantics to is rejected at **load**, never mid-evaluation (`plasma-engine/src/schema.rs`): * `schema_version` ≠ 0.1 → `UnsupportedSchemaVersion` -* duplicate rule ids (across base rules and overlay-added rules) +* duplicate rule ids — collision-free across the *effective* base ids + (base minus overridden) plus every overlay-added id, so a same-id + replacement of an overridden rule is legal +* `override-rules` targeting an id that names no base rule → + `OverrideUnknownRule` * reserved constructs: subject `release`, resource `release`, action `consistent-with`, condition `file-matches-pattern` (v0 facts carry no - file contents), overlay effects `modify-rules` / `override-rules` + file contents), overlay effect `modify-rules` (diff semantics + underspecified) Consequence: **any policy that loads, evaluates** — `evaluate` is total. +=== Overlay algebra + +Overlays extend a base policy per context. Two effects are evaluable: + +* **`add-rules`** — append rules to the effective set; their findings carry + `source = "overlay:"` when the rule declares no source of its own. +* **`override-rules { ids }`** — remove base rules with those ids from the + effective set. Each id must name a real base rule (checked at load). An + overlay may pair an override with an `add-rules` reusing the same id to + *replace* a base rule (e.g. relax a severity, or exempt a zone by + overriding without a replacement). + +`modify-rules` remains reserved. Overridden base rules are still fully +validated at load — evaluation skips them, but they must stay well-formed. + == Fact collection `plasma-engine/src/facts.rs` is the only impure module. All repository @@ -118,9 +138,11 @@ reproduce identical facts: `evaluate(policy, facts)` (`plasma-engine/src/eval.rs`): -1. Effective rules = base rules ++ overlay `add-rules` (in document order). - Findings from overlay rules carry `source = "overlay:"` when the - rule declares no source of its own. +1. Effective rules = (base rules minus any overridden by `override-rules`) + ++ overlay `add-rules` (in document order). Findings from overlay rules + carry `source = "overlay:"` when the rule declares no source of its + own. The action planner shares this exact effective set, so a finding + never resolves to an overridden rule. 2. Each rule's subject expands to concrete instances: `repo` → itself; `file` → that path; `file-pattern` → every collected file matching any pattern; `metadata` → that key. diff --git a/plasma-engine/Cargo.toml b/plasma-engine/Cargo.toml index a18b707..f2e751c 100644 --- a/plasma-engine/Cargo.toml +++ b/plasma-engine/Cargo.toml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: MPL-2.0 [package] name = "plasma-engine" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = ["Jonathan D.A. Jewell "] description = "Deterministic, typed policy evaluation — policy AST, fact collection, and a pure evaluator producing findings" diff --git a/plasma-engine/src/action.rs b/plasma-engine/src/action.rs index 4b73349..e308e5d 100644 --- a/plasma-engine/src/action.rs +++ b/plasma-engine/src/action.rs @@ -8,7 +8,7 @@ // mechanical Action or recorded as a ManualItem with a reason — nothing is // silently dropped. Applying the plan (IO) lives in `apply`. -use crate::ast::{ActionKind, Modality, OverlayEffect, Policy, Resource, Rule}; +use crate::ast::{ActionKind, Modality, Policy, Resource, Rule}; use crate::finding::{Evaluation, FindingStatus}; use serde::{Deserialize, Serialize}; use std::collections::BTreeMap; @@ -149,22 +149,14 @@ fn manual_reason(rule: &Rule) -> String { } } -/// Index every effective rule (base + overlay-added) by id. +/// Index the effective rule set (base minus overridden, plus overlay-added) +/// by id — the same set the evaluator saw, so a finding never resolves to a +/// rule that was overridden out of evaluation. fn rule_index(policy: &Policy) -> BTreeMap<&str, &Rule> { - let mut map = BTreeMap::new(); - for rule in &policy.rules { - map.insert(rule.id.as_str(), rule); - } - for overlay in &policy.overlays { - for effect in &overlay.effects { - if let OverlayEffect::AddRules { rules } = effect { - for rule in rules { - map.insert(rule.id.as_str(), rule); - } - } - } - } - map + crate::eval::effective_rules(policy) + .into_iter() + .map(|(rule, _origin)| (rule.id.as_str(), rule)) + .collect() } #[cfg(test)] diff --git a/plasma-engine/src/eval.rs b/plasma-engine/src/eval.rs index 0778213..5bdbfc9 100644 --- a/plasma-engine/src/eval.rs +++ b/plasma-engine/src/eval.rs @@ -67,10 +67,29 @@ pub fn evaluate(policy: &Policy, facts: &FactSet) -> Evaluation { } } -/// Base rules followed by overlay-added rules, each with its origin -/// (None = base policy, Some(id) = overlay). -fn effective_rules(policy: &Policy) -> Vec<(&Rule, Option<&str>)> { - let mut rules: Vec<(&Rule, Option<&str>)> = policy.rules.iter().map(|r| (r, None)).collect(); +/// Base rules (minus any overridden by an overlay) followed by +/// overlay-added rules, each with its origin (None = base policy, +/// Some(id) = overlay). Shared with the action planner so both see the +/// same effective set. +pub(crate) fn effective_rules(policy: &Policy) -> Vec<(&Rule, Option<&str>)> { + // Ids removed from the base set by an `override-rules` effect. + let overridden: std::collections::BTreeSet<&str> = policy + .overlays + .iter() + .flat_map(|o| &o.effects) + .filter_map(|e| match e { + OverlayEffect::OverrideRules { ids } => Some(ids.iter().map(String::as_str)), + _ => None, + }) + .flatten() + .collect(); + + let mut rules: Vec<(&Rule, Option<&str>)> = policy + .rules + .iter() + .filter(|r| !overridden.contains(r.id.as_str())) + .map(|r| (r, None)) + .collect(); for overlay in &policy.overlays { for effect in &overlay.effects { if let OverlayEffect::AddRules { rules: added } = effect { @@ -538,6 +557,81 @@ action = { type = "present" } assert_eq!(eval.summary.passes, 1); } + #[test] + fn test_override_disables_base_rule() { + // Base requires a LICENSE; overlay overrides that rule away. + let p = policy( + r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "license-present" +modality = "obligation" +subject = { type = "repo" } +resource = { type = "file", path = "LICENSE*" } +action = { type = "present" } + +[[overlays]] +id = "vendored-exemption" +applies_to = [] + +[[overlays.effects]] +type = "override-rules" +ids = ["license-present"] +"#, + ); + // No LICENSE, but the rule is overridden away → no findings at all. + let eval = evaluate(&p, &facts_with(&["README.md"], &[])); + assert_eq!(eval.findings.len(), 0); + } + + #[test] + fn test_override_with_replacement() { + // Base marks missing LICENSE an error; overlay overrides it and adds + // a same-id replacement at warning severity. + let p = policy( + r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "license-present" +modality = "obligation" +severity = "error" +subject = { type = "repo" } +resource = { type = "file", path = "LICENSE*" } +action = { type = "present" } + +[[overlays]] +id = "relax" +applies_to = [] + +[[overlays.effects]] +type = "override-rules" +ids = ["license-present"] + +[[overlays.effects]] +type = "add-rules" + +[[overlays.effects.rules]] +id = "license-present" +modality = "obligation" +severity = "warning" +subject = { type = "repo" } +resource = { type = "file", path = "LICENSE*" } +action = { type = "present" } +"#, + ); + let eval = evaluate(&p, &facts_with(&["README.md"], &[])); + // Exactly one finding, at the replacement's warning severity. + assert_eq!(eval.summary.errors, 0); + assert_eq!(eval.summary.warnings, 1); + assert_eq!(eval.findings[0].source.as_deref(), Some("overlay:relax")); + } + #[test] fn test_overlay_rules_carry_origin() { let p = policy( diff --git a/plasma-engine/src/schema.rs b/plasma-engine/src/schema.rs index 405dae6..baf5014 100644 --- a/plasma-engine/src/schema.rs +++ b/plasma-engine/src/schema.rs @@ -49,6 +49,8 @@ pub enum SchemaError { }, #[error("duplicate rule id: {0}")] DuplicateRuleId(String), + #[error("overlay {overlay_id}: override-rules targets unknown base rule id {rule_id}")] + OverrideUnknownRule { overlay_id: String, rule_id: String }, #[error("unknown policy file extension {0:?}: use .toml or .json")] UnknownExtension(String), } @@ -93,36 +95,76 @@ fn validate(policy: &Policy) -> Result<(), SchemaError> { }); } - let mut seen_ids = std::collections::BTreeSet::new(); - - let overlay_rules = policy.overlays.iter().flat_map(|o| { - o.effects.iter().filter_map(|e| match e { - OverlayEffect::AddRules { rules } => Some(rules.iter()), - _ => None, - }) - }); + // `modify-rules` is still schema-reserved (its diff semantics are + // underspecified); `override-rules` is supported as of schema 0.1. + for overlay in &policy.overlays { + for effect in &overlay.effects { + if let OverlayEffect::ModifyRules { .. } = effect { + return Err(SchemaError::ReservedOverlayEffect { + overlay_id: overlay.id.clone(), + construct: "modify-rules".to_string(), + }); + } + } + } - for rule in policy.rules.iter().chain(overlay_rules.flatten()) { - if !seen_ids.insert(rule.id.clone()) { + // Base rule ids must be unique among themselves. + let mut base_ids = std::collections::BTreeSet::new(); + for rule in &policy.rules { + if !base_ids.insert(rule.id.clone()) { return Err(SchemaError::DuplicateRuleId(rule.id.clone())); } - validate_rule(rule)?; } + // Every overridden id must name a real base rule. Collect the set so + // an added rule may legally reuse an overridden id (a replacement). + let mut overridden = std::collections::BTreeSet::new(); for overlay in &policy.overlays { for effect in &overlay.effects { - let construct = match effect { - OverlayEffect::AddRules { .. } => continue, - OverlayEffect::ModifyRules { .. } => "modify-rules", - OverlayEffect::OverrideRules { .. } => "override-rules", - }; - return Err(SchemaError::ReservedOverlayEffect { - overlay_id: overlay.id.clone(), - construct: construct.to_string(), - }); + if let OverlayEffect::OverrideRules { ids } = effect { + for id in ids { + if !base_ids.contains(id) { + return Err(SchemaError::OverrideUnknownRule { + overlay_id: overlay.id.clone(), + rule_id: id.clone(), + }); + } + overridden.insert(id.clone()); + } + } + } + } + + // Effective base ids (minus overridden) plus every overlay-added id + // must be collision-free. A replacement — an added rule whose id was + // overridden — is allowed precisely because the base rule is gone. + let mut seen_ids: std::collections::BTreeSet = + base_ids.difference(&overridden).cloned().collect(); + for overlay in &policy.overlays { + for effect in &overlay.effects { + if let OverlayEffect::AddRules { rules } = effect { + for rule in rules { + if !seen_ids.insert(rule.id.clone()) { + return Err(SchemaError::DuplicateRuleId(rule.id.clone())); + } + } + } } } + // Validate every rule (base + overlay-added), including overridden base + // rules — they must still be well-formed even though evaluation skips + // them, so a later override removal doesn't resurrect a bad rule. + let added = policy.overlays.iter().flat_map(|o| { + o.effects.iter().filter_map(|e| match e { + OverlayEffect::AddRules { rules } => Some(rules.iter()), + _ => None, + }) + }); + for rule in policy.rules.iter().chain(added.flatten()) { + validate_rule(rule)?; + } + Ok(()) } @@ -231,6 +273,88 @@ action = { type = "present" } assert!(matches!(err, SchemaError::DuplicateRuleId(_))); } + #[test] + fn test_override_unknown_rule_rejected() { + let doc = r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "real" +modality = "obligation" +subject = { type = "repo" } +resource = { type = "file", path = "LICENSE" } +action = { type = "present" } + +[[overlays]] +id = "o" +applies_to = [] + +[[overlays.effects]] +type = "override-rules" +ids = ["does-not-exist"] +"#; + let err = load_policy_str(doc, PolicyFormat::Toml).unwrap_err(); + assert!(matches!(err, SchemaError::OverrideUnknownRule { .. })); + } + + #[test] + fn test_override_allows_same_id_replacement() { + let doc = r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } + +[[rules]] +id = "r" +modality = "obligation" +subject = { type = "repo" } +resource = { type = "file", path = "LICENSE" } +action = { type = "present" } + +[[overlays]] +id = "o" +applies_to = [] + +[[overlays.effects]] +type = "override-rules" +ids = ["r"] + +[[overlays.effects]] +type = "add-rules" + +[[overlays.effects.rules]] +id = "r" +modality = "obligation" +subject = { type = "repo" } +resource = { type = "file", path = "README" } +action = { type = "present" } +"#; + // Replacing an overridden id is not a duplicate. + assert!(load_policy_str(doc, PolicyFormat::Toml).is_ok()); + } + + #[test] + fn test_modify_rules_still_reserved() { + let doc = r#" +schema_version = { major = 0, minor = 1 } +id = "t" +version = { major = 1, minor = 0 } +rules = [] + +[[overlays]] +id = "o" +applies_to = [] + +[[overlays.effects]] +type = "modify-rules" +ids = ["whatever"] +"#; + let err = load_policy_str(doc, PolicyFormat::Toml).unwrap_err(); + assert!(matches!(err, SchemaError::ReservedOverlayEffect { .. })); + } + #[test] fn test_json_round_trip() { let policy = builtin_repo_hygiene(); diff --git a/plasma-parser/Cargo.toml b/plasma-parser/Cargo.toml index 6e25587..ae10f4f 100644 --- a/plasma-parser/Cargo.toml +++ b/plasma-parser/Cargo.toml @@ -1,7 +1,7 @@ # SPDX-License-Identifier: MPL-2.0 [package] name = "plasma-parser" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = ["Jonathan D.A. Jewell "] description = "License/SPDX parsing library — SPDX expressions (including Palimpsest-family identifiers), zone auditing, compatibility checking"