Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .machine_readable/6a2/STATE.a2ml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

[metadata]
project = "palimpsest-plasma"
version = "0.4.0"
version = "0.5.0"
last-updated = "2026-07-02"
status = "active"

Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 <j.d.a.jewell@open.ac.uk>"]
Expand Down
2 changes: 1 addition & 1 deletion Justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion codemeta.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
32 changes: 27 additions & 5 deletions docs/engine-v0-design.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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:<id>"` 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
Expand Down Expand Up @@ -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:<id>"` 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:<id>"` 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.
Expand Down
2 changes: 1 addition & 1 deletion plasma-engine/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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 <j.d.a.jewell@open.ac.uk>"]
description = "Deterministic, typed policy evaluation — policy AST, fact collection, and a pure evaluator producing findings"
Expand Down
24 changes: 8 additions & 16 deletions plasma-engine/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)]
Expand Down
102 changes: 98 additions & 4 deletions plasma-engine/src/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading