Skip to main content

web4_core/
role_extension.rs

1//! Role-extension entities — the Rust representation of the canonical
2//! `web4-standard/ontology/role-extension.ttl` schema (Phase-0 concord, #486),
3//! plus role-LCT issuance and a registry. This is the web4-core/schema half of
4//! the Hestia role-orchestration PRD: it turns an orchestration role from a bare
5//! string into a first-class `EntityType::Role` LCT entity carrying its law
6//! extension (affordances / responsibilities / scope), so a role born from a
7//! migrated launcher and a role defined via the hub UI are the *same* entity.
8//!
9//! Serialization mirrors the ontology property names, so the Rust `RoleExtension`
10//! round-trips against the ttl (the check HUB asked for in Phase 1).
11//!
12//! **Monotone restriction** is NOT enforced here (it is an eval-time property of
13//! the *fold*, `hestia::policy::fold_strictest` — strictest-wins, deployed): an
14//! extension can only tighten inherited law, never grant. What this module owns
15//! is the §2.3 correction — an eval-time base-deny on a role-permissive action is
16//! attributable to a *cause* only via the authoring-validity witness persisted
17//! here (`authored_under` + `lint_verdict`); the fold alone cannot tell the causes
18//! apart. See [`RoleExtension::drift_mark`].
19
20use serde::{Deserialize, Serialize};
21use uuid::Uuid;
22
23use crate::crypto::KeyPair;
24use crate::lct::{EntityType, Lct, LctBuilder};
25
26/// A concrete affordance grant — what the role's occupant MAY do. One variant per
27/// `role:Affordance` subclass in the ontology; the payload is `role:permits` (the
28/// grant token). Absence is fail-closed: the launcher MUST refuse what the role
29/// does not afford (`--dangerously-skip-permissions` is exactly a `CliFlag`).
30///
31/// NOTE: `role:EnvAffordance` (env-at-invocation, e.g. `PATH=…`) is deliberately
32/// absent — flagged to HUB in the Phase-1 round-trip as a pending concord item;
33/// added here only once the ontology adds it, to keep the round-trip exact.
34#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(tag = "kind", content = "permits", rename_all = "snake_case")]
36pub enum Affordance {
37    /// `role:ToolAffordance` — a tool or tool-category (hestia `PolicyMatch.tools`/`.categories`).
38    Tool(String),
39    /// `role:ChannelAffordance` — a mesh/hub channel id.
40    Channel(String),
41    /// `role:RepoAffordance` — a repository the role may act in.
42    Repo(String),
43    /// `role:WriteClassAffordance` — a class of write the role may perform.
44    WriteClass(String),
45    /// `role:CliFlagAffordance` — a specific launcher flag.
46    CliFlag(String),
47}
48
49/// `role:Responsibility` — what the occupant MUST do, and on what cadence.
50#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
51pub struct Responsibility {
52    pub description: String,
53    /// `role:cadence` — cron-like or event trigger for how often the duty is due.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub cadence: Option<String>,
56    /// `role:reportsTo` — where the duty's discharge is reported.
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub reports_to: Option<String>,
59}
60
61/// `role:Scope` — the MRH horizon the role reads and acts within.
62/// `role:atpBudget` — the ATP ceiling a role may spend per period, enforced by the
63/// Treasurer at act time. Unbounded MUST be an explicit choice ON the record, never
64/// an omission: absence deserializes to `Limited(0.0)` (no budget = fail-closed),
65/// so an attributed-but-unbounded role is a deliberate `Unbounded`, not a silent gap.
66#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "snake_case")]
68pub enum AtpBudget {
69    /// A finite per-period ceiling.
70    Limited(f64),
71    /// Explicitly unbounded — a choice on the record, subject to audit.
72    Unbounded,
73}
74
75impl Default for AtpBudget {
76    /// Fail-closed: an unspecified budget grants nothing, not everything.
77    fn default() -> Self {
78        AtpBudget::Limited(0.0)
79    }
80}
81
82#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
83pub struct Scope {
84    /// `role:rangesOver` — resources (repos, machines, channels, data classes) in
85    /// the MRH. Empty = no MRH (fail-closed: the window is the epistemic horizon).
86    #[serde(default)]
87    pub ranges_over: Vec<String>,
88    /// `role:atpBudget` — fail-closed to `Limited(0.0)` when absent.
89    #[serde(default)]
90    pub atp_budget: AtpBudget,
91}
92
93/// The extension's no-match verdict (`role:defaultVerdict`). Monotone restriction
94/// constrains the *direction of change* — an extension may only tighten inherited
95/// law — NOT the default; `Deny` is a legal (maximal) tightening, `Allow` is the
96/// no-op, and monotone permits both. So the default is `Deny` (fail-closed, per the
97/// ttl "Fail-closed roles default to deny"): a well-formed permissive role sets
98/// `Allow` explicitly; an omitted field must never mint a permissive extension.
99#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(rename_all = "lowercase")]
101pub enum ExtensionVerdict {
102    Allow,
103    Warn,
104    #[default]
105    Deny,
106}
107
108/// `role:lintVerdict` — the write-time linter's recorded verdict against
109/// `authored_under`. Persisted, not throwaway: it is the attribution anchor.
110#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
111#[serde(rename_all = "lowercase")]
112pub enum LintVerdict {
113    /// Extension passed the linter against its authoring-time parent law.
114    Pass,
115    /// Extension exceeded parent even at authoring time (author error).
116    Fail,
117}
118
119/// `role:driftMark` — the *cause* of an eval-time base-deny on a role-permissive
120/// action, DERIVED from the authoring witness (never re-derived from the fold
121/// alone; the fold cannot tell these apart — all three present identically as
122/// overlay-permissive/base-denies). The load-bearing §2.3 correction.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
124#[serde(rename_all = "kebab-case")]
125pub enum DriftMark {
126    /// `lint_verdict = Fail` — extension always exceeded parent. Author error.
127    #[serde(rename = "author:violation")]
128    AuthorViolation,
129    /// `lint_verdict = Pass` — once-valid extension, parent later tightened. Silent
130    /// role-rot, NOT the author's fault.
131    #[serde(rename = "drift:parent-tightened")]
132    DriftParentTightened,
133    /// No witness (offline / linter skipped / pre-migration string-role). Denied,
134    /// cause unknown — fail-closed on the deny, honest on the cause.
135    #[serde(rename = "drift:unattributed")]
136    DriftUnattributed,
137}
138
139/// The `role:Extension` — a role's own law layer, composed under inherited society
140/// ∧ constellation law via strictest-wins fold (eval-time, in hestia). Affordances,
141/// responsibilities, scope, plus the authoring-validity witness (§2.3).
142#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
143pub struct RoleExtension {
144    /// `role:boundToRoleLct` — the Role LCT this extension is the law of.
145    pub bound_to_role_lct: Uuid,
146    /// `role:hasAffordance`.
147    #[serde(default)]
148    pub affordances: Vec<Affordance>,
149    /// `role:hasResponsibility`.
150    #[serde(default)]
151    pub responsibilities: Vec<Responsibility>,
152    /// `role:hasScope`.
153    #[serde(default)]
154    pub scope: Scope,
155    /// `role:defaultVerdict` — REQUIRED. Absence is a deserialize error, never a
156    /// silent permissive default: "the field was omitted" must not be
157    /// indistinguishable from "the author chose Allow" (F1, CBP 2026-07-08).
158    pub default_verdict: ExtensionVerdict,
159    /// `role:foldsUnder` — the parent law level(s) this composes under. REQUIRED
160    /// and must be non-empty: an extension folding under NO parent has nothing to
161    /// be strictest against, so its overlay becomes the only law — the fail-open
162    /// worst case. Absence is a deserialize error; emptiness is rejected at use.
163    pub folds_under: Vec<String>,
164    /// `role:authoredUnder` — the parent-law snapshot the write-time linter checked
165    /// this extension against. `None` = no witness → `drift:unattributed` on deny.
166    #[serde(default, skip_serializing_if = "Option::is_none")]
167    pub authored_under: Option<String>,
168    /// `role:lintVerdict` — the linter's persisted verdict. Paired with
169    /// `authored_under` it records whether the extension was EVER valid.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub lint_verdict: Option<LintVerdict>,
172}
173
174impl RoleExtension {
175    /// The §2.3 attribution: given an eval-time base-deny on a role-permissive
176    /// action, the cause is read from the authoring witness — never guessed from
177    /// the fold. `Fail` ⇒ author violated parent at authoring; `Pass` ⇒ parent
178    /// tightened under a once-valid extension; no witness ⇒ unattributed.
179    pub fn drift_mark(&self) -> DriftMark {
180        match self.lint_verdict {
181            Some(LintVerdict::Fail) => DriftMark::AuthorViolation,
182            Some(LintVerdict::Pass) => DriftMark::DriftParentTightened,
183            None => DriftMark::DriftUnattributed,
184        }
185    }
186
187    /// Does this extension grant exactly this affordance? Fail-closed helper for
188    /// the launcher: a grant not present is refused. **Kind-aware** — the five
189    /// `role:Affordance` subclasses are distinct namespaces, so a `Repo("x")` does
190    /// NOT satisfy a query about a `Channel("x")`. (F3, CBP 2026-07-08: the old
191    /// token-only check unioned the namespaces — fail-open across kinds.)
192    pub fn affords(&self, wanted: &Affordance) -> bool {
193        self.affordances.contains(wanted)
194    }
195
196    /// Validate the fail-closed invariants that the type system can't express.
197    /// Callers MUST call this before evaluating an extension: making a field
198    /// REQUIRED stops it being *absent*, but an explicitly-empty `folds_under`
199    /// (`[]`) still deserializes and would leave the overlay as the only law with
200    /// nothing to be strictest against — the fail-open worst case CBP flagged (F1,
201    /// "emptiness is rejected at use"). Rejected here.
202    pub fn validate(&self) -> Result<(), &'static str> {
203        if self.folds_under.is_empty() {
204            return Err("role extension has empty folds_under: no parent law to compose under (fail-open)");
205        }
206        Ok(())
207    }
208}
209
210/// A first-class orchestration role: a `EntityType::Role` LCT + its canonical
211/// label (`role:constellation:*`) + its law extension.
212#[derive(Clone, Debug)]
213pub struct RoleEntity {
214    pub lct: Lct,
215    /// The canonical constellation-role label (the string the fleet already uses).
216    pub label: String,
217    pub extension: RoleExtension,
218}
219
220impl RoleEntity {
221    /// Issue a new role entity: mint a `Role` LCT under the sovereign and bind the
222    /// extension to it. The role gets its own keypair (roles have presence). The
223    /// extension's `bound_to_role_lct` is overwritten with the freshly-minted id so
224    /// the binding is authoritative, not caller-asserted.
225    pub fn issue(label: impl Into<String>, sovereign_lct: Uuid, mut extension: RoleExtension) -> (Self, KeyPair) {
226        let (lct, keypair) = LctBuilder::new(EntityType::Role)
227            .created_by(sovereign_lct)
228            .build();
229        extension.bound_to_role_lct = lct.id;
230        (
231            Self { lct, label: label.into(), extension },
232            keypair,
233        )
234    }
235}
236
237/// A registry of orchestration role entities, keyed by canonical label. The
238/// audit-first mirror populates this from the existing launchers (Phase 1); the
239/// hub UI adds to it (Phase 2+). Read/act split lives at the caller — this is the
240/// in-memory index.
241#[derive(Clone, Debug, Default)]
242pub struct RoleRegistry {
243    roles: std::collections::HashMap<String, RoleEntity>,
244}
245
246impl RoleRegistry {
247    pub fn new() -> Self {
248        Self::default()
249    }
250
251    /// Register (or replace by label) a role entity. Idempotent by label.
252    pub fn register(&mut self, role: RoleEntity) {
253        self.roles.insert(role.label.clone(), role);
254    }
255
256    pub fn get(&self, label: &str) -> Option<&RoleEntity> {
257        self.roles.get(label)
258    }
259
260    /// Canonical labels currently registered, sorted for stable enumeration.
261    pub fn labels(&self) -> Vec<&str> {
262        let mut v: Vec<&str> = self.roles.keys().map(String::as_str).collect();
263        v.sort_unstable();
264        v
265    }
266
267    pub fn len(&self) -> usize {
268        self.roles.len()
269    }
270
271    pub fn is_empty(&self) -> bool {
272        self.roles.is_empty()
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    fn ext(lint: Option<LintVerdict>) -> RoleExtension {
281        RoleExtension {
282            bound_to_role_lct: Uuid::nil(),
283            affordances: vec![
284                Affordance::CliFlag("--dangerously-skip-permissions".into()),
285                Affordance::Tool("Bash".into()),
286            ],
287            responsibilities: vec![Responsibility {
288                description: "drain mailbox".into(),
289                cadence: Some("event".into()),
290                reports_to: None,
291            }],
292            scope: Scope { ranges_over: vec!["repo:web4".into()], atp_budget: AtpBudget::Limited(100.0) },
293            default_verdict: ExtensionVerdict::Allow,
294            folds_under: vec!["law:constellation".into()],
295            authored_under: lint.map(|_| "lawsnap:2026-07-08".into()),
296            lint_verdict: lint,
297        }
298    }
299
300    #[test]
301    fn issue_mints_a_role_lct_and_binds_the_extension() {
302        let sovereign = Uuid::new_v4();
303        let (role, _kp) = RoleEntity::issue("role:constellation:mesh-worker", sovereign, ext(Some(LintVerdict::Pass)));
304        assert_eq!(role.lct.entity_type, EntityType::Role);
305        assert_eq!(role.lct.created_by, Some(sovereign));
306        // the binding is authoritative — extension points at the freshly-minted LCT
307        assert_eq!(role.extension.bound_to_role_lct, role.lct.id);
308        assert_ne!(role.lct.id, Uuid::nil());
309        assert_eq!(role.label, "role:constellation:mesh-worker");
310    }
311
312    #[test]
313    fn drift_mark_derives_cause_from_the_witness_not_the_fold() {
314        // The §2.3 correction: the three causes are distinguished ONLY by the
315        // persisted authoring witness — the fold cannot tell them apart.
316        assert_eq!(ext(Some(LintVerdict::Fail)).drift_mark(), DriftMark::AuthorViolation);
317        assert_eq!(ext(Some(LintVerdict::Pass)).drift_mark(), DriftMark::DriftParentTightened);
318        assert_eq!(ext(None).drift_mark(), DriftMark::DriftUnattributed);
319    }
320
321    #[test]
322    fn affords_is_fail_closed_and_kind_aware() {
323        let e = ext(Some(LintVerdict::Pass));
324        assert!(e.affords(&Affordance::CliFlag("--dangerously-skip-permissions".into())));
325        assert!(e.affords(&Affordance::Tool("Bash".into())));
326        assert!(!e.affords(&Affordance::CliFlag("--some-flag-not-granted".into())));
327        assert!(!e.affords(&Affordance::Tool("Write".into())));
328        // F3: the same token under a DIFFERENT kind is NOT afforded (distinct namespaces).
329        assert!(!e.affords(&Affordance::Repo("Bash".into())));
330        assert!(!e.affords(&Affordance::Channel("--dangerously-skip-permissions".into())));
331    }
332
333    #[test]
334    fn defaults_are_fail_closed() {
335        // F1: an extension with the wire fields OMITTED must not mint a permissive
336        // record. default_verdict + folds_under are REQUIRED (deserialize error);
337        // ExtensionVerdict::default() and AtpBudget::default() are the closed pole.
338        assert_eq!(ExtensionVerdict::default(), ExtensionVerdict::Deny);
339        assert_eq!(AtpBudget::default(), AtpBudget::Limited(0.0));
340        // omitting default_verdict is a hard error, not silent Allow
341        let missing = r#"{"bound_to_role_lct":"00000000-0000-0000-0000-000000000000","folds_under":["law:x"]}"#;
342        assert!(serde_json::from_str::<RoleExtension>(missing).is_err(), "absent default_verdict must fail");
343        // omitting folds_under is a hard error, not silent no-parent
344        let no_parent = r#"{"bound_to_role_lct":"00000000-0000-0000-0000-000000000000","default_verdict":"deny"}"#;
345        assert!(serde_json::from_str::<RoleExtension>(no_parent).is_err(), "absent folds_under must fail");
346    }
347
348    #[test]
349    fn validate_rejects_empty_folds_under() {
350        // REQUIRED stops absent; validate() stops explicitly-empty (F1 "at use").
351        let mut e = ext(Some(LintVerdict::Pass));
352        assert!(e.validate().is_ok());
353        e.folds_under.clear();
354        assert!(e.validate().is_err(), "empty folds_under = no parent law = fail-open, must reject");
355    }
356
357    #[test]
358    fn registry_registers_and_looks_up_by_label() {
359        let mut reg = RoleRegistry::new();
360        let s = Uuid::new_v4();
361        for label in ["role:constellation:mesh-worker", "role:constellation:reviewer"] {
362            let (r, _) = RoleEntity::issue(label, s, ext(Some(LintVerdict::Pass)));
363            reg.register(r);
364        }
365        assert_eq!(reg.len(), 2);
366        assert_eq!(reg.labels(), vec!["role:constellation:mesh-worker", "role:constellation:reviewer"]);
367        assert!(reg.get("role:constellation:mesh-worker").is_some());
368        assert!(reg.get("role:constellation:nope").is_none());
369    }
370
371    #[test]
372    fn extension_serde_round_trips() {
373        let e = ext(Some(LintVerdict::Pass));
374        let json = serde_json::to_string(&e).unwrap();
375        let back: RoleExtension = serde_json::from_str(&json).unwrap();
376        assert_eq!(e, back);
377    }
378
379    /// F2: bind the Rust `driftMark` enum to its SOURCE OF TRUTH — the merged ttl —
380    /// so a rename in the ontology breaks THIS build, not just a same-file literal.
381    /// All THREE markers pinned (the provisional enum is designed to move).
382    #[test]
383    fn drift_mark_strings_match_the_merged_ontology() {
384        let ttl = include_str!("../../web4-standard/ontology/role-extension.ttl");
385        for marker in ["author:violation", "drift:parent-tightened", "drift:unattributed"] {
386            assert!(ttl.contains(marker), "ontology missing driftMark marker '{marker}'");
387        }
388        // and the Rust serialization emits exactly those tokens
389        assert_eq!(serde_json::to_string(&DriftMark::AuthorViolation).unwrap(), "\"author:violation\"");
390        assert_eq!(serde_json::to_string(&DriftMark::DriftParentTightened).unwrap(), "\"drift:parent-tightened\"");
391        assert_eq!(serde_json::to_string(&DriftMark::DriftUnattributed).unwrap(), "\"drift:unattributed\"");
392    }
393}