From ef4801f73127170c8bdd7040394fa02088ba1d37 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:54:21 +0100 Subject: [PATCH 1/5] Decide every write by a mission run against its authority, audited by default --- crates/fractal-daemon/src/service.rs | 1 + crates/fractal-delivery/src/store/delivery.rs | 2 +- crates/fractal-kernel/src/config.rs | 26 +++ crates/fractal-missions/src/store/missions.rs | 54 +++++- crates/fractal-store/src/store.rs | 9 + .../mission_start_production_copy_tests.rs | 1 + crates/fractal-store/src/store/missions.rs | 7 +- .../fractal-store/src/store/run_authority.rs | 174 ++++++++++++++++++ .../src/store/run_authority_tests.rs | 160 ++++++++++++++++ crates/fractal/src/main.rs | 1 + docs/site/reference/configuration.md | 13 ++ docs/st3/mission-graph-runtime.md | 18 +- .../mission-run-authority-audit.json | 11 ++ 13 files changed, 462 insertions(+), 15 deletions(-) create mode 100644 crates/fractal-store/src/store/run_authority.rs create mode 100644 crates/fractal-store/src/store/run_authority_tests.rs create mode 100644 release-notes/mission-run-authority-audit.json diff --git a/crates/fractal-daemon/src/service.rs b/crates/fractal-daemon/src/service.rs index 0c174bec143..f826a2e7ada 100644 --- a/crates/fractal-daemon/src/service.rs +++ b/crates/fractal-daemon/src/service.rs @@ -1739,6 +1739,7 @@ mod tests { limits: crate::config::LimitsConfig::default(), logging: crate::config::LoggingConfig::default(), reconcile: crate::config::ReconcileConfig::default(), + programs: crate::config::ProgramsConfig::default(), asks: crate::config::AsksConfig::default(), codex_work_without_asking: None, wait_for_peers_after_start: None, diff --git a/crates/fractal-delivery/src/store/delivery.rs b/crates/fractal-delivery/src/store/delivery.rs index 672e27e928b..f6d53e9ac5c 100644 --- a/crates/fractal-delivery/src/store/delivery.rs +++ b/crates/fractal-delivery/src/store/delivery.rs @@ -299,7 +299,7 @@ impl DeliveryStore for Store { let child = parent.map(|parent| ChildMissionContext { root_revision: parent.root_revision.clone(), root_run_id: parent.root_mission_run.trim_start_matches("mission-run/").into(), - parent_step_run: normalize_step_run(subscription), default_selector: None, + parent_step_run: normalize_step_run(subscription), default_selector: None, started_by: None, }); self.create_mission_run_inner(request, child, None, Some((resource, discovery))) } diff --git a/crates/fractal-kernel/src/config.rs b/crates/fractal-kernel/src/config.rs index 85ff65a7fa4..4ad04741175 100644 --- a/crates/fractal-kernel/src/config.rs +++ b/crates/fractal-kernel/src/config.rs @@ -151,6 +151,10 @@ pub struct Config { /// Background reconciliation start cap, reloaded with daemon policies. #[serde(skip_serializing_if = "ReconcileConfig::is_default")] pub reconcile: ReconcileConfig, + /// What a write outside a mission run's authority does. Written only when it differs from + /// the default. + #[serde(skip_serializing_if = "ProgramsConfig::is_default")] + pub programs: ProgramsConfig, /// How long a person has to answer an ask that names no deadline. Written only when it /// differs from the default. #[serde(skip_serializing_if = "AsksConfig::is_default")] @@ -460,6 +464,27 @@ impl LimitsConfig { } } +/// `[programs]`: how the daemon holds a program's run to its authority +/// (docs/st3/program-engine.md §3). +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(default, deny_unknown_fields)] +pub struct ProgramsConfig { + /// `audit` lets a write outside a run's authority proceed and records it; `enforce` refuses it. + pub authority: RunAuthorityMode, +} + +impl ProgramsConfig { + pub fn is_default(&self) -> bool { self == &Self::default() } +} + +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum RunAuthorityMode { + #[default] + Audit, + Enforce, +} + /// The reconcile pass cap when `[reconcile] max_passes_per_minute` is not set. pub const DEFAULT_MAX_PASSES_PER_MINUTE: u32 = 30; @@ -789,6 +814,7 @@ impl Default for Config { limits: LimitsConfig::default(), logging: LoggingConfig::default(), reconcile: ReconcileConfig::default(), + programs: ProgramsConfig::default(), asks: AsksConfig::default(), codex_work_without_asking: None, wait_for_peers_after_start: None, diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index c5c84bfbdc6..41fca4cb613 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -166,6 +166,25 @@ mod identifier_query_tests { } } +fn child_context( + parent: &MissionRunView, + parent_step_run: &str, + default_selector: Option, + started_by: Option, +) -> ChildMissionContext { + ChildMissionContext { + root_revision: parent.root_revision.clone(), + root_run_id: parent + .root_mission_run + .strip_prefix("mission-run/") + .unwrap_or(&parent.root_mission_run) + .to_owned(), + parent_step_run: normalize_step_run(parent_step_run), + default_selector, + started_by, + } +} + /// Render a copy; the signed revision and execution spec remain physical. fn public_mission_spec(connection: &Connection, body: &str) -> Result { let mut mission: MissionSpec = serde_json::from_str(body)?; @@ -1539,6 +1558,15 @@ pub trait MissionsStore { parent_step_run: &str, default_selector: Option<&WorkSelector>, ) -> Result; + /// A child run a program step starts: the parent run starts it, so its creation is written + /// as `mission-run/PARENT` and it holds at most its parent's authority + /// (docs/st3/program-engine.md §3). The requester stays the parent's. + fn create_program_child_mission_run( + &self, + request: &MissionRunRequest, + parent: &MissionRunView, + parent_step_run: &str, + ) -> Result; /// An occurrence belongs to its schedule, regardless of the requesting member or revision. fn scheduled_mission_run_subject(schedule: &str, occurrence: u64) -> String; fn create_scheduled_mission_run( @@ -2865,16 +2893,21 @@ impl MissionsStore for Store { ) -> Result { self.create_mission_run_inner( request, - Some(ChildMissionContext { - root_revision: parent.root_revision.clone(), - root_run_id: parent - .root_mission_run - .strip_prefix("mission-run/") - .unwrap_or(&parent.root_mission_run) - .to_owned(), - parent_step_run: normalize_step_run(parent_step_run), - default_selector: default_selector.cloned(), - }), + Some(child_context(parent, parent_step_run, default_selector.cloned(), None)), + None, + None, + ) + } + + fn create_program_child_mission_run( + &self, + request: &MissionRunRequest, + parent: &MissionRunView, + parent_step_run: &str, + ) -> Result { + self.create_mission_run_inner( + request, + Some(child_context(parent, parent_step_run, None, Some(parent.subject.clone()))), None, None, ) @@ -2910,6 +2943,7 @@ impl MissionsStore for Store { .into(), parent_step_run: normalize_step_run(schedule), default_selector: None, + started_by: None, }); self.create_mission_run_inner(request, child, Some(&subject), None) } diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 0584fb1f516..8631a2b04ad 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -67,6 +67,7 @@ pub mod seat_queue_plan_tests; pub mod schedule_standby; pub mod mission_capacity; pub mod run_credentials; +pub mod run_authority; pub mod marks_switch; pub mod glasses; pub mod mailbox_wakes; @@ -347,6 +348,8 @@ mod run_eta_tests; #[cfg(test)] mod run_credentials_tests; #[cfg(test)] +mod run_authority_tests; +#[cfg(test)] mod run_eta_review_tests; pub mod client_summary; pub mod unread_mail; @@ -2411,6 +2414,10 @@ pub struct ChildMissionContext { pub root_run_id: String, pub parent_step_run: String, pub default_selector: Option, + /// Who starts the child when it is not its requester: `mission-run/PARENT` for a child a + /// program starts. Its creation claims are written as this actor, and run authority takes it + /// as the child's starter (docs/st3/program-engine.md §3). + pub started_by: Option, } fn stale_ref_request_tx(connection: &Connection, resource: &str, discovery: &str) -> Result> { @@ -11525,6 +11532,8 @@ pub fn append_claim_tx_with_assistant_answer( owned_sets::validate_receipt(subject, body).map_err(anyhow::Error::new)?; } let fields = schema_fields_for_body(kind, body)?; + run_authority::admit_tx(transaction, origin, actor, kind, subject, &fields) + .map_err(anyhow::Error::new)?; human_review_reassignment::admit_reassignment(transaction, kind, &fields) .map_err(anyhow::Error::new)?; if kind == "person.delegation-set" diff --git a/crates/fractal-store/src/store/mission_start_production_copy_tests.rs b/crates/fractal-store/src/store/mission_start_production_copy_tests.rs index 2c0dab6cb00..f5168e58022 100644 --- a/crates/fractal-store/src/store/mission_start_production_copy_tests.rs +++ b/crates/fractal-store/src/store/mission_start_production_copy_tests.rs @@ -122,6 +122,7 @@ fn scheduled_mission_start_writer_hold_on_production_copy() { root_run_id: root_run_id.clone(), parent_step_run: parent_step_run.clone(), default_selector: None, + started_by: None, }); let result = store.create_mission_run_inner(&request, context, Some(&subject), None); let mut receipt = lend_receipt(started.elapsed()); diff --git a/crates/fractal-store/src/store/missions.rs b/crates/fractal-store/src/store/missions.rs index e121db31e25..0ebefa7a274 100644 --- a/crates/fractal-store/src/store/missions.rs +++ b/crates/fractal-store/src/store/missions.rs @@ -396,6 +396,9 @@ impl Store { request.requester.as_deref().unwrap_or("person/requester"), "person", ); + // The starter writes the run's creation: its requester, or the run that started it. + let starter = child.as_ref().and_then(|child| child.started_by.clone()) + .unwrap_or_else(|| requester.clone()); let mode = request.mode.as_deref().unwrap_or("run"); if !matches!(mode, "run" | "eval") { return Err(FractalError::new( @@ -514,7 +517,7 @@ impl Store { &self.origin, &subject, "mission-run.created", - Some(&requester), + Some(&starter), &body, &[], None, @@ -526,7 +529,7 @@ impl Store { &self.origin, &generation_subject, "run-generation.created", - Some(&requester), + Some(&starter), &json!({"fields": { "run": subject, "revision": mission.revision, diff --git a/crates/fractal-store/src/store/run_authority.rs b/crates/fractal-store/src/store/run_authority.rs new file mode 100644 index 00000000000..bc90e14a1a7 --- /dev/null +++ b/crates/fractal-store/src/store/run_authority.rs @@ -0,0 +1,174 @@ +//! Run authority (docs/st3/program-engine.md §3): what `mission-run/RUN` may write. One decision +//! at the store's write admission, beside the rules gate, for every claim a run writes. +//! +//! A run holds the `authority` of its current generation's revision, intersected with its +//! starter's authority. The starter is the actor of the run's creation: its requester, or the +//! run whose program started it, whose own effective authority is then intersected in turn. A +//! person, agent or daemon starter holds everything in free mode except what the rules restrict, +//! so each write is also decided by the rules for that starter. +//! +//! A write outside the authority is audited (`rule.audited` on `rule/run-authority`) or refused +//! (`run-authority-denied`), as the daemon's `[programs] authority` says. +use super::*; +use crate::config::RunAuthorityMode; +use crate::model::{AuthorityGrant, AuthorityVerb}; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// The audit records' rule name: `rule.audited` on `rule/run-authority`. +pub const AUDIT_RULE: &str = "run-authority"; + +/// Run starters nest at most this deep; a deeper chain grants nothing. +const MAX_STARTERS: usize = 32; + +static ENFORCE: AtomicBool = AtomicBool::new(false); + +/// Set what a write outside a run's authority does, from the daemon's `[programs] authority`. +pub fn set_run_authority_mode(mode: RunAuthorityMode) { + ENFORCE.store(mode == RunAuthorityMode::Enforce, Ordering::Relaxed); +} + +pub fn run_authority_mode() -> RunAuthorityMode { + if ENFORCE.load(Ordering::Relaxed) { RunAuthorityMode::Enforce } else { RunAuthorityMode::Audit } +} + +/// Whether `pattern` covers `target`, itself exact or a terminal `/*` namespace. +pub fn covers(pattern: &str, target: &str) -> bool { + pattern == target + || pattern.strip_suffix("/*").is_some_and(|namespace| { + target.strip_prefix(namespace).is_some_and(|rest| rest.starts_with('/')) + }) +} + +/// What both rule sets grant. With exact-or-namespace patterns this is exact: for each pair with +/// one verb, the narrower pattern when one covers the other. +pub fn intersect(left: &[AuthorityGrant], right: &[AuthorityGrant]) -> Vec { + let mut both = BTreeSet::new(); + for one in left { + for other in right.iter().filter(|other| other.verb == one.verb) { + if covers(&one.pattern, &other.pattern) { + both.insert(other.clone()); + } else if covers(&other.pattern, &one.pattern) { + both.insert(one.clone()); + } + } + } + both.into_iter().collect() +} + +/// The grant one claim needs, or none for the run's own journal. +fn needed_tx( + transaction: &Transaction<'_>, + run: &str, + kind: &str, + subject: &str, + fields: &BTreeMap, +) -> Result, FractalError> { + let mission_of = |run: &str| -> Result { + transaction.query_row( + "SELECT mission_id FROM mission_runs WHERE id=?1", + [run.trim_start_matches("mission-run/")], + |row| row.get::<_, String>(0), + ).optional().map_err(internal)?.ok_or_else(|| FractalError::new( + "run-authority-denied", format!("{run} is not a mission run this host holds"), + )) + }; + let field = |name: &str| fields.get(name).and_then(Value::as_str).unwrap_or_default(); + let grant = |verb, pattern: String| Ok(Some(AuthorityGrant { verb, pattern })); + match kind { + // A run always journals its own program steps. + "program.step" => { + let generation = subject.strip_prefix("step-run/").and_then(|rest| rest.split('/').next()); + let owner: Option = match generation { + Some(generation) => transaction.query_row( + "SELECT run_id FROM run_generations WHERE id=?1", [generation], |row| row.get(0), + ).optional().map_err(internal)?, + None => None, + }; + if owner.as_deref() == Some(run) { Ok(None) } else { grant(AuthorityVerb::Write, subject.into()) } + } + "mission.published" => grant(AuthorityVerb::Publish, subject.trim_start_matches("mission/").into()), + "mission-run.created" => grant(AuthorityVerb::Start, field("mission").trim_start_matches("mission/").into()), + "run-generation.created" => { + let verb = if field("reason") == "initial mission run" { AuthorityVerb::Start } else { AuthorityVerb::Revise }; + grant(verb, mission_of(field("run"))?) + } + _ => grant(AuthorityVerb::Write, subject.into()), + } +} + +/// A run's effective authority, and the first starter in its chain that is not a run. +fn effective_tx( + transaction: &Transaction<'_>, + run: &str, +) -> Result<(Vec, Option), FractalError> { + let mut granted: Option> = None; + let mut current = run.to_owned(); + for _ in 0..MAX_STARTERS { + let Some((mission, revision, requester)) = transaction.query_row( + "SELECT r.mission_id, g.revision, r.requester FROM mission_runs r + JOIN run_generations g ON g.id=r.current_generation_id WHERE r.id=?1", + [¤t], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)), + ).optional().map_err(internal)? else { + return Ok((Vec::new(), None)); + }; + let declared = super::missions::mission_revision_spec_tx(transaction, &mission, &revision)? + .map(|mission| mission.authority) + .unwrap_or_default(); + granted = Some(match granted { + Some(granted) => intersect(&granted, &declared), + None => declared, + }); + let starter: Option = transaction.query_row( + "SELECT actor FROM claims WHERE subject=?1 AND kind='mission-run.created' LIMIT 1", + [format!("mission-run/{current}")], + |row| row.get(0), + ).optional().map_err(internal)?.flatten(); + let starter = starter.unwrap_or(requester); + match starter.strip_prefix("mission-run/") { + Some(parent) => current = parent.to_owned(), + None => return Ok((granted.unwrap_or_default(), Some(starter))), + } + } + Ok((Vec::new(), None)) +} + +/// Admit one claim written as `actor`: a claim a run writes outside its effective authority is +/// audited or refused. Every other writer passes untouched. +pub(super) fn admit_tx( + transaction: &Transaction<'_>, + origin: &str, + actor: Option<&str>, + kind: &str, + subject: &str, + fields: &BTreeMap, +) -> Result<(), FractalError> { + let Some(actor) = actor.filter(|actor| actor.starts_with("mission-run/")) else { return Ok(()) }; + let run = actor.trim_start_matches("mission-run/"); + let Some(needed) = needed_tx(transaction, run, kind, subject, fields)? else { return Ok(()) }; + let (granted, starter) = effective_tx(transaction, run)?; + if let Some(starter) = &starter { + smallclaims::store::principals::rules_gate_tx(transaction, origin, starter, kind, subject) + .map_err(smallclaims::error::typed)?; + } + if granted.iter().any(|grant| grant.verb == needed.verb && covers(&grant.pattern, &needed.pattern)) { + return Ok(()); + } + match run_authority_mode() { + RunAuthorityMode::Enforce => Err(FractalError::new( + "run-authority-denied", + format!( + "{actor} may not {} `{}` ({kind} on {subject}): its mission's authority, intersected with its starter's, does not grant it", + needed.verb.as_str(), needed.pattern, + ), + )), + RunAuthorityMode::Audit => { + append_claim_tx( + transaction, origin, &format!("rule/{AUDIT_RULE}"), smallclaims::rules::RULE_AUDITED, None, + &json!({"fields": {"rule": AUDIT_RULE, "actor": actor, "action": kind, "target": subject}, "evidence": []}), + &[], None, + ).map_err(internal)?; + Ok(()) + } + } +} diff --git a/crates/fractal-store/src/store/run_authority_tests.rs b/crates/fractal-store/src/store/run_authority_tests.rs new file mode 100644 index 00000000000..3150ef37b0d --- /dev/null +++ b/crates/fractal-store/src/store/run_authority_tests.rs @@ -0,0 +1,160 @@ +use super::run_authority::{covers, intersect, run_authority_mode, set_run_authority_mode}; +use super::tests_shared_seats::publish_mission; +use super::*; +use crate::config::RunAuthorityMode; +use crate::model::{AuthorityGrant, AuthorityVerb, MissionRunRequest}; + +/// The mode is the daemon's, process-wide: tests that depend on it take turns. +static MODE: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +fn grant(verb: AuthorityVerb, pattern: &str) -> AuthorityGrant { + AuthorityGrant { verb, pattern: pattern.into() } +} + +#[test] +fn covering_and_intersection_are_exact_for_namespaces() { + assert!(covers("custom/factory/*", "custom/factory/landing")); + assert!(covers("custom/factory/*", "custom/factory/deep/*")); + assert!(covers("custom/factory/landing", "custom/factory/landing")); + assert!(!covers("custom/factory/*", "custom/factory")); + assert!(!covers("custom/factory/*", "custom/factoryx/landing")); + assert!(!covers("custom/factory/landing", "custom/factory/*")); + let declared = [grant(AuthorityVerb::Write, "custom/*"), grant(AuthorityVerb::Start, "check")]; + let starter = [ + grant(AuthorityVerb::Write, "custom/factory/*"), + grant(AuthorityVerb::Start, "other"), + grant(AuthorityVerb::Publish, "check"), + ]; + assert_eq!(intersect(&declared, &starter), [grant(AuthorityVerb::Write, "custom/factory/*")]); + assert_eq!(intersect(&starter, &declared), [grant(AuthorityVerb::Write, "custom/factory/*")]); +} + +fn start(store: &Store, mission: &str, key: &str, started_by: Option<&MissionRunView>) -> Result { + let child = started_by.map(|parent| ChildMissionContext { + root_revision: parent.root_revision.clone(), + root_run_id: parent.root_mission_run.trim_start_matches("mission-run/").into(), + parent_step_run: parent.steps[0].subject.clone(), + default_selector: None, + started_by: Some(parent.subject.clone()), + }); + store.create_mission_run_inner(&MissionRunRequest { + mission: mission.into(), + revision: None, + workspace: "/tmp".into(), + requester: Some("person/operator".into()), + mode: None, + inputs: Default::default(), + idempotency_key: key.into(), + }, child, None, None) +} + +fn write(store: &Store, actor: &str, subject: &str) -> Result { + store.append_claim(&ClaimInput { + subject: subject.into(), + kind: "custom.test.observed".into(), + actor: Some(actor.into()), + fields: BTreeMap::from([("value".into(), json!(1))]), + evidence: Vec::new(), + expected_subject: None, + idempotency_key: None, + }) +} + +fn code(result: Result) -> String { + result.unwrap_err().code.to_string() +} + +fn missions() -> Store { + let store = Store::open_memory("node").unwrap(); + publish_mission(&store, r#"version 2 +mission "land" state="ready" { + goal "Land one change." + authority { write "custom/factory/*"; start "check"; } + step "program" { agentless } +}"#, "land"); + publish_mission(&store, r#"version 2 +mission "check" state="ready" { + goal "Check it." + authority { write "custom/*"; } + step "program" { agentless } +}"#, "check"); + publish_mission(&store, r#"version 2 +mission "other" state="ready" { + goal "Something else." + step "work" { agentless } +}"#, "other"); + store +} + +#[test] +fn a_run_writes_only_within_its_authority_intersected_with_its_starters() { + let _mode = MODE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + set_run_authority_mode(RunAuthorityMode::Enforce); + let store = missions(); + let land = start(&store, "land", "land-run", None).unwrap(); + + // Inside the declared authority: written as the run. + let inside = write(&store, &land.subject, "custom/factory/landing").unwrap(); + assert_eq!(inside.actor.as_deref(), Some(land.subject.as_str())); + // Outside it: refused, and nothing is written. + let before = store.index().unwrap(); + assert_eq!(code(write(&store, &land.subject, "custom/other/landing")), "run-authority-denied"); + assert_eq!(store.index().unwrap(), before); + + // A program starts a child it may start; the child's creation is written as the parent run. + let check = start(&store, "check", "check-run", Some(&land)).unwrap(); + let created = store.claims_for(&check.subject, Some("mission-run.created")).unwrap(); + assert_eq!(created[0].actor.as_deref(), Some(land.subject.as_str())); + assert_eq!(check.requester, "person/operator"); + // The child declares `custom/*`, but its starter holds only `custom/factory/*`. + write(&store, &check.subject, "custom/factory/check").unwrap(); + assert_eq!(code(write(&store, &check.subject, "custom/other/check")), "run-authority-denied"); + // A mission it may not start does not start. + assert_eq!(code(start(&store, "other", "other-run", Some(&land))), "run-authority-denied"); + // A run whose mission declares nothing writes nothing. + let other = start(&store, "other", "other-direct", None).unwrap(); + assert_eq!(code(write(&store, &other.subject, "custom/factory/other")), "run-authority-denied"); + + // The person who started the run is still held to the rules that restrict them. + store.set_rule("locked", &smallclaims::rules::Rule { + mode: smallclaims::rules::Mode::Enforce, + actors: vec!["person/operator".into()], + kinds: vec!["custom.test.observed".into()], + subjects: vec!["custom/factory/locked".into()], + ..Default::default() + }, "person/owner").unwrap(); + assert_eq!(code(write(&store, &land.subject, "custom/factory/locked")), "rule-denied"); + assert_eq!(code(write(&store, &check.subject, "custom/factory/locked")), "rule-denied"); + + // In audit mode the write proceeds and is recorded under `rule/run-authority`. + set_run_authority_mode(RunAuthorityMode::Audit); + let audited = write(&store, &land.subject, "custom/other/audited").unwrap(); + assert_eq!(audited.actor.as_deref(), Some(land.subject.as_str())); + let audits = store.rule_audits(Some("run-authority"), 10).unwrap(); + assert_eq!(audits.len(), 1); + assert_eq!(audits[0].body["fields"]["actor"], land.subject); + assert_eq!(audits[0].body["fields"]["action"], "custom.test.observed"); + assert_eq!(audits[0].body["fields"]["target"], "custom/other/audited"); + // A write inside the authority leaves no audit. + write(&store, &land.subject, "custom/factory/again").unwrap(); + assert_eq!(store.rule_audits(Some("run-authority"), 10).unwrap().len(), 1); + assert_eq!(run_authority_mode(), RunAuthorityMode::Audit); +} + +#[test] +fn writers_other_than_runs_are_untouched() { + let _mode = MODE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + set_run_authority_mode(RunAuthorityMode::Enforce); + let store = missions(); + write(&store, "person/operator", "custom/other/person").unwrap(); + store.append_claim(&ClaimInput { + subject: "custom/other/node".into(), + kind: "custom.test.observed".into(), + actor: None, + fields: BTreeMap::from([("value".into(), json!(1))]), + evidence: Vec::new(), + expected_subject: None, + idempotency_key: None, + }).unwrap(); + set_run_authority_mode(RunAuthorityMode::Audit); +} diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index c092e3d5196..945af7578ee 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -7585,6 +7585,7 @@ async fn run_up(args: UpArgs) -> Result<()> { reconciler.set_idle_nudge(config.reconcile.idle_nudge()?); store.set_default_ask_deadline_ms(config.asks.default_deadline_ms()?)?; store.set_ask_deadlines(config.asks.deadlines); + fractal_store::store::run_authority::set_run_authority_mode(config.programs.authority); startup_phases.reconciler_ms = phase_elapsed_ms(); startup.phase("start-services"); startup_telemetry.phase("start-services"); diff --git a/docs/site/reference/configuration.md b/docs/site/reference/configuration.md index 36bf5557902..23ec12b65e3 100644 --- a/docs/site/reference/configuration.md +++ b/docs/site/reference/configuration.md @@ -188,6 +188,19 @@ Mission-owned runtime declarations follow their owner lifetime; independent top- Within a fleet, an agent may publish, start, and revise missions and move agent queues as itself without a separate grant. This is broad authority, not isolation: review declarations before publication and give agents only access you intend them to use. A bound harness must retain its own identity; free mode does not allow it to impersonate a person or bypass operation-specific fences, human review, or a claimed producing step. +A mission run is the exception. A program step acts as `mission-run/RUN` and holds only what its +mission's `authority` block grants, intersected with the authority of whoever started the run. The +daemon's `[programs]` table says what a write outside it does: + +```toml +[programs] +authority = "audit" # the default: the write proceeds and `rule.audited` records it on `rule/run-authority` +# authority = "enforce" # the write is refused with `run-authority-denied` +``` + +Read the audit log with `fractal rules audit run-authority` before you enforce. The setting is read +when the daemon starts. + ## Definitions do not start work Publication saves a definition; it does not create a run. Start it explicitly with `fractal missions start`. To make a new run wait for another run to complete, use `--after RUN`; a failed or cancelled prerequisite is not successful completion. See [Missions in practice](../guides/missions-in-practice.md#queue-follow-on-work) for the runnable sequence. diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index 5e1895aaaf4..82290c6332b 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -244,8 +244,22 @@ a mission ID without `mission/`; `write` takes a subject, or a registered family `/*`. A pattern is exact or ends in `/*`, which covers everything under it. Parsing refuses an empty block, a repeated block or rule, an unknown verb, any other `*`, and the block in a nested or loop mission. Rules are stored in canonical order, so reordering them is not a revision; any other change -is. A mission without the block grants its runs nothing. The block is stored with the revision; -the daemon does not enforce it yet. A request whose caller's ancestry carries a live run credential +is. A mission without the block grants its runs nothing. + +Each claim written as `mission-run/RUN` is decided at write admission. The run holds the +`authority` of its current generation's revision, intersected with its starter's: the actor of +its `mission-run.created`, which is its requester, or `mission-run/PARENT` for a child a program +starts (whose own effective authority is intersected in turn). A person, agent or daemon starter +contributes everything free mode gives it, and the rules that restrict it decide each of the run's +writes as well. The claim names the grant it needs: `mission.published` on `mission/ID` needs +`publish ID`; a run's creation and its initial generation need `start ID`; a later generation +needs `revise ID`; any other claim needs `write SUBJECT`. A run always journals `program.step` on +its own step runs. Setting another run's outcome, which is how a run is cancelled, takes a person or +agent actor, so a run cannot cancel one whatever it declares. A write outside the effective authority is +recorded as `rule.audited` on `rule/run-authority` and proceeds, or is refused with +`run-authority-denied`, as the daemon's `[programs] authority` says (`audit` by default). + +A request whose caller's ancestry carries a live run credential (`ST_RUN_CREDENTIAL`, one per program attempt) is bound to `mission-run/RUN`: like a bound agent, it may not call internal routes, send a person header or name another `actor` or `requester`, and its writes are attributed to the run. The credential binds only while its attempt is the step run's diff --git a/release-notes/mission-run-authority-audit.json b/release-notes/mission-run-authority-audit.json new file mode 100644 index 00000000000..7e3eef46d84 --- /dev/null +++ b/release-notes/mission-run-authority-audit.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Every claim written as `mission-run/RUN` is decided against the run's declared `authority` intersected with its starter's; the new daemon setting `[programs] authority` (default `audit`) records a write outside it as `rule.audited` on `rule/run-authority`, or refuses it with `run-authority-denied` when set to `enforce`", + "replay": {"status": "none", "detail": "No claim kind or projection change. Audit records reuse `rule.audited` with `rule` set to `run-authority`. A child run a program starts writes its `mission-run.created` and initial `run-generation.created` with actor `mission-run/PARENT`; its `requester` stays the parent's."}, + "schema": {"status": "none", "detail": "No table, index or migration change."}, + "rules": {"status": "changed", "detail": "First enforced declared authority. Writes by `mission-run/*` actors are checked at write admission: `mission.published` needs `publish`, a run's creation and initial generation need `start`, a later generation needs `revise`, any other claim needs `write` on its subject; a run always journals `program.step` on its own step runs. The effective authority is the current revision's block intersected with the starter's (recursively through program-started parents); a person, agent or daemon starter's enforce-mode rules also decide each write. Writers that are not runs are untouched. Audit is the default."}, + "compatibility": {"status": "changed", "detail": "New optional config table `[programs]` with `authority = \"audit\" | \"enforce\"`; an older build refuses a config that sets it (unknown field), so set it only on builds that read it. Nothing writes as a run until the program engine dispatches with run credentials."}, + "downtime": {"status": "none", "detail": "The mode is read when the daemon starts; changing it needs a restart."}, + "manual": {"status": "none", "detail": "No operator action is required. Read `fractal rules audit run-authority` before setting `enforce`."}, + "recovery": {"status": "none", "detail": "Set `[programs] authority = \"audit\"` (or remove the table) and restart to let refused writes proceed again."} +} From a8a4de48986530292a824dc1a96f10259d6f841e Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:04:53 +0100 Subject: [PATCH 2/5] Keep a typed refusal's code when a run's creation is refused --- crates/fractal-store/src/store/missions.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/fractal-store/src/store/missions.rs b/crates/fractal-store/src/store/missions.rs index 0ebefa7a274..ba8f24fa622 100644 --- a/crates/fractal-store/src/store/missions.rs +++ b/crates/fractal-store/src/store/missions.rs @@ -523,7 +523,9 @@ impl Store { None, &request.idempotency_key, ) - .map_err(internal)?; + // A typed refusal, such as a starter without the authority to start this mission, keeps + // its code. + .map_err(smallclaims::error::typed)?; append_claim_tx( &transaction, &self.origin, @@ -539,7 +541,7 @@ impl Store { &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; stages.enter("view"); let view = created_mission_run_view_tx(&transaction, &run_id).map_err(internal)?; stages.enter("idempotency-record"); From 6afe95fe17a2a805d2af22217b2342b1e7061283 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:39:22 +0100 Subject: [PATCH 3/5] Record the run authority gate in the large-value flow inventory --- docs/st3/large-value-sites.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index cf1defbdf2f..14f43f8eacf 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -804,7 +804,7 @@ ], "flows": [ {"id": "flow-h0f24faa60a9f", "path": "crates/fractal-store/src/backup.rs", "function": "restore", "owner": "fractal_store::backup", "fingerprint": "367616d58ec64e13d447f49aa64db2731708beadc4a192eae7860c648e752808", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Offline backup restore is a historical producer/open path; write-preserving recovery and opener quiescence require a separate plan."}, - {"id": "flow-h1f241b496964", "path": "crates/fractal-store/src/store.rs", "function": "append_admitted_claim_tx", "owner": "fractal_store::store", "fingerprint": "5a85f47271c2c12786f62278c14e6686dbda7ab6f5f0c744e326482a56962d8c", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Application admission passes actor/body/dependency values to Core and projection; guards cover raw legacy paths too."}, + {"id": "flow-h1f241b496964", "path": "crates/fractal-store/src/store.rs", "function": "append_admitted_claim_tx", "owner": "fractal_store::store", "fingerprint": "0f90aec8e253911fe3860d326e69241918efbd9428a4594c48edae4a4f20582d", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Application admission passes actor/body/dependency values to Core and projection; guards cover raw legacy paths too."}, {"id": "flow-hae49bfcdc951", "path": "crates/fractal-store/src/store.rs", "function": "insert_event", "owner": "fractal_store::store", "fingerprint": "9cdeca113f2b75bcf56424758796dc68d0158e63ba3c7520de898551700bbf4e", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "description": "Event positions/publication must retain canonical pointer/body and ordering semantics."}, {"id": "flow-hcc1764594fd2", "path": "crates/fractal-store/src/store/backup.rs", "function": "receive_backup_page", "owner": "fractal_store::store::backup", "fingerprint": "35b1ec2cf1835ef24f9f29107f60813ca41eb4197e8a2be9a98b5771cbccaed7", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Backup import permits valid historical records under its own record/page limits, not client extractor limits."}, {"id": "flow-he7b2f39a2da2", "path": "crates/smallclaims/src/store.rs", "function": "append_claim_record_tx", "owner": "smallclaims::store", "fingerprint": "fd48ee537bf54b5b208b4e3da218640c9239c63fd3b76ab3e836cf6ef81b97b8", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Local append serializes the logical body and predecessor identities; no route-limit inference."}, From fb10e8f2c46c2889939363c83d0cc3866c20c5f5 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:30:17 +0100 Subject: [PATCH 4/5] Decide run sends, documents and revision companions by run authority; pick start or revise by predecessor; read only the authority member --- crates/fractal-missions/src/store/missions.rs | 10 +-- crates/fractal-store/src/store.rs | 14 ++-- .../src/store/message_routing.rs | 6 ++ .../src/store/message_routing_tests.rs | 5 ++ .../fractal-store/src/store/run_authority.rs | 78 ++++++++++++------- .../src/store/run_authority_tests.rs | 55 ++++++++++++- crates/fractal-store/src/store/runtime.rs | 11 +++ crates/smallclaims/src/store.rs | 1 + crates/smallclaims/src/store/runtime.rs | 13 ++++ docs/st3/large-value-sites.json | 2 +- docs/st3/mission-graph-runtime.md | 27 ++++--- docs/st3/program-engine.md | 22 ++++-- .../mission-run-authority-audit.json | 10 +-- 13 files changed, 189 insertions(+), 65 deletions(-) diff --git a/crates/fractal-missions/src/store/missions.rs b/crates/fractal-missions/src/store/missions.rs index 41fca4cb613..efe0d927949 100644 --- a/crates/fractal-missions/src/store/missions.rs +++ b/crates/fractal-missions/src/store/missions.rs @@ -4634,7 +4634,7 @@ impl MissionsStore for Store { &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; } } revision_seats::carry_completed_agents_tx( @@ -4677,7 +4677,7 @@ impl MissionsStore for Store { None, idempotency_key, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; terminalize_generation_steps_tx( &transaction, &self.origin, @@ -4704,7 +4704,7 @@ impl MissionsStore for Store { &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; if reopening { // The run's own state history says who reopened it, when, and why. append_claim_tx( @@ -4722,7 +4722,7 @@ impl MissionsStore for Store { &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; } if let Some(proposal) = proposal { let changed = transaction @@ -4753,7 +4753,7 @@ impl MissionsStore for Store { &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; } let view = mission_run_view_tx(&transaction, run_id).map_err(internal)?; transaction diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 775e38acff0..82e018fd3b7 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -7939,7 +7939,7 @@ fn adopt_declared_mission_revision_tx( &[], Some(batch_id), ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; if status == "draining" { transaction .execute( @@ -8034,7 +8034,7 @@ fn adopt_declared_mission_revision_tx( &[], Some(batch_id), ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(claim.id); } } @@ -8081,7 +8081,7 @@ fn adopt_declared_mission_revision_tx( &[], Some(batch_id), ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(superseded.id); claim_ids.extend(terminalize_generation_steps_tx( transaction, @@ -8109,7 +8109,7 @@ fn adopt_declared_mission_revision_tx( &[], Some(batch_id), ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(created.id); Ok((claim_ids, false)) } @@ -21638,7 +21638,7 @@ pub fn terminalize_generation_steps_tx( &[], batch_id, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(claim.id); } Ok(claim_ids) @@ -22108,7 +22108,7 @@ pub fn cancel_descendant_mission_runs_tx( &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(run_claim.id); let generation_claim = append_claim_tx( transaction, @@ -22120,7 +22120,7 @@ pub fn cancel_descendant_mission_runs_tx( &[], None, ) - .map_err(internal)?; + .map_err(smallclaims::error::typed)?; claim_ids.push(generation_claim.id); } Ok(claim_ids) diff --git a/crates/fractal-store/src/store/message_routing.rs b/crates/fractal-store/src/store/message_routing.rs index 904ddf7774d..1b06914bd5e 100644 --- a/crates/fractal-store/src/store/message_routing.rs +++ b/crates/fractal-store/src/store/message_routing.rs @@ -22,6 +22,12 @@ pub fn legacy_reason(store: &Store, input: &ClaimInput) -> Result Vec, subject: &str) -> Result, FractalError> { + if let Some(run) = subject.strip_prefix("mission-run/") { + return Ok(Some(run.to_owned())); + } + let generation = subject.strip_prefix("run-generation/") + .or_else(|| subject.strip_prefix("step-run/").and_then(|rest| rest.split('/').next())); + let Some(generation) = generation else { return Ok(None) }; + transaction.query_row("SELECT run_id FROM run_generations WHERE id=?1", [generation], |row| row.get(0)) + .optional().map_err(internal) +} + +/// The grant one claim needs, or none for the run's own journal. A start or a revision writes +/// several claims as one operation; each names the operation's grant on the mission of the run +/// it changes, never a `write`, so the operation's verb alone admits it. +pub(super) fn needed_tx( transaction: &Transaction<'_>, run: &str, kind: &str, @@ -72,31 +86,42 @@ fn needed_tx( "run-authority-denied", format!("{run} is not a mission run this host holds"), )) }; - let field = |name: &str| fields.get(name).and_then(Value::as_str).unwrap_or_default(); + let field = |name: &str| fields.get(name).and_then(Value::as_str); let grant = |verb, pattern: String| Ok(Some(AuthorityGrant { verb, pattern })); + let revise = |owner: Option| match owner { + Some(owner) => grant(AuthorityVerb::Revise, mission_of(&owner)?), + None => grant(AuthorityVerb::Write, subject.into()), + }; match kind { // A run always journals its own program steps. "program.step" => { - let generation = subject.strip_prefix("step-run/").and_then(|rest| rest.split('/').next()); - let owner: Option = match generation { - Some(generation) => transaction.query_row( - "SELECT run_id FROM run_generations WHERE id=?1", [generation], |row| row.get(0), - ).optional().map_err(internal)?, - None => None, - }; - if owner.as_deref() == Some(run) { Ok(None) } else { grant(AuthorityVerb::Write, subject.into()) } + if owning_run_tx(transaction, subject)?.as_deref() == Some(run) { + Ok(None) + } else { + grant(AuthorityVerb::Write, subject.into()) + } } "mission.published" => grant(AuthorityVerb::Publish, subject.trim_start_matches("mission/").into()), - "mission-run.created" => grant(AuthorityVerb::Start, field("mission").trim_start_matches("mission/").into()), + "mission-run.created" => grant(AuthorityVerb::Start, field("mission").unwrap_or_default().trim_start_matches("mission/").into()), + // Only a revision's generation has a predecessor; its free-text reason decides nothing. "run-generation.created" => { - let verb = if field("reason") == "initial mission run" { AuthorityVerb::Start } else { AuthorityVerb::Revise }; - grant(verb, mission_of(field("run"))?) + let verb = if field("predecessor").is_some() { AuthorityVerb::Revise } else { AuthorityVerb::Start }; + grant(verb, mission_of(field("run").unwrap_or_default())?) + } + // What a revision writes on the runs it changes: its proposal, carried and terminalized + // steps, the superseded generation, the run's and its descendants' states. + "step-run.carried" | "step-run.state" | "run-generation.superseded" | "run-generation.state" + | "mission-run.state" => revise(owning_run_tx(transaction, subject)?), + "revision-proposal.created" => revise(owning_run_tx(transaction, field("run").unwrap_or_default())?), + "revision-proposal.applied" => { + revise(owning_run_tx(transaction, field("successor_generation").unwrap_or_default())?) } _ => grant(AuthorityVerb::Write, subject.into()), } } -/// A run's effective authority, and the first starter in its chain that is not a run. +/// A run's effective authority, and the first starter in its chain that is not a run. Each level +/// reads only the revision's `authority` member, not the whole mission spec. fn effective_tx( transaction: &Transaction<'_>, run: &str, @@ -104,26 +129,27 @@ fn effective_tx( let mut granted: Option> = None; let mut current = run.to_owned(); for _ in 0..MAX_STARTERS { - let Some((mission, revision, requester)) = transaction.query_row( - "SELECT r.mission_id, g.revision, r.requester FROM mission_runs r - JOIN run_generations g ON g.id=r.current_generation_id WHERE r.id=?1", + let Some((declared, requester, starter)) = transaction.query_row( + "SELECT json_extract(m.body, '$.authority'), r.requester, + (SELECT actor FROM claims WHERE subject='mission-run/'||r.id AND kind='mission-run.created' LIMIT 1) + FROM mission_runs r + JOIN run_generations g ON g.id=r.current_generation_id + LEFT JOIN mission_revisions m ON m.mission_id=r.mission_id AND m.revision=g.revision + WHERE r.id=?1", [¤t], - |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)), + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?, row.get::<_, Option>(2)?)), ).optional().map_err(internal)? else { return Ok((Vec::new(), None)); }; - let declared = super::missions::mission_revision_spec_tx(transaction, &mission, &revision)? - .map(|mission| mission.authority) + let declared: Vec = declared + .map(|declared| serde_json::from_str(&declared)) + .transpose() + .map_err(internal)? .unwrap_or_default(); granted = Some(match granted { Some(granted) => intersect(&granted, &declared), None => declared, }); - let starter: Option = transaction.query_row( - "SELECT actor FROM claims WHERE subject=?1 AND kind='mission-run.created' LIMIT 1", - [format!("mission-run/{current}")], - |row| row.get(0), - ).optional().map_err(internal)?.flatten(); let starter = starter.unwrap_or(requester); match starter.strip_prefix("mission-run/") { Some(parent) => current = parent.to_owned(), diff --git a/crates/fractal-store/src/store/run_authority_tests.rs b/crates/fractal-store/src/store/run_authority_tests.rs index 3150ef37b0d..600076e4218 100644 --- a/crates/fractal-store/src/store/run_authority_tests.rs +++ b/crates/fractal-store/src/store/run_authority_tests.rs @@ -69,7 +69,7 @@ fn missions() -> Store { publish_mission(&store, r#"version 2 mission "land" state="ready" { goal "Land one change." - authority { write "custom/factory/*"; start "check"; } + authority { write "custom/factory/*"; write "doc/factory/*"; start "check"; revise "check"; } step "program" { agentless } }"#, "land"); publish_mission(&store, r#"version 2 @@ -86,6 +86,59 @@ mission "other" state="ready" { store } +/// The grant a claim needs follows the operation it belongs to, decided by fields its writer +/// cannot choose. +#[test] +fn a_claims_needed_grant_follows_its_operation() { + use super::run_authority::needed_tx; + let store = missions(); + let land = start(&store, "land", "land-needs", None).unwrap(); + let check = start(&store, "check", "check-needs", None).unwrap(); + let other = start(&store, "other", "other-needs", None).unwrap(); + let run = land.subject.trim_start_matches("mission-run/"); + let generation = |view: &MissionRunView| view.steps[0].subject.split('/').nth(1).unwrap().to_owned(); + let mut connection = store.connection.write(); + let transaction = connection.transaction().unwrap(); + let needs = |kind: &str, subject: &str, fields: Value| { + let fields = fields.as_object().unwrap().clone().into_iter().collect::>(); + needed_tx(&transaction, run, kind, subject, &fields).unwrap() + .map(|grant| format!("{} {}", grant.verb.as_str(), grant.pattern)) + }; + let next = "run-generation/next"; + // A revision's generation names its predecessor, whatever its reason says. + assert_eq!(needs("run-generation.created", next, + json!({"run": check.subject, "predecessor": format!("run-generation/{}", generation(&check)), "reason": "initial mission run"})), + Some("revise check".into())); + assert_eq!(needs("run-generation.created", next, json!({"run": check.subject, "reason": "anything"})), Some("start check".into())); + // What a revision writes on the runs it changes needs `revise` on their missions. + assert_eq!(needs("step-run.carried", &check.steps[0].subject, json!({})), Some("revise check".into())); + assert_eq!(needs("run-generation.superseded", &format!("run-generation/{}", generation(&check)), json!({})), Some("revise check".into())); + assert_eq!(needs("step-run.state", &other.steps[0].subject, json!({})), Some("revise other".into())); + assert_eq!(needs("mission-run.state", &other.subject, json!({})), Some("revise other".into())); + assert_eq!(needs("revision-proposal.created", "revision-proposal/one", json!({"run": check.subject})), Some("revise check".into())); + // A run journals its own steps freely; another run's journal is a write like any other. + assert_eq!(needs("program.step", &land.steps[0].subject, json!({})), None); + assert_eq!(needs("program.step", &check.steps[0].subject, json!({})), Some(format!("write {}", check.steps[0].subject))); + assert_eq!(needs("custom.test.observed", "custom/factory/x", json!({})), Some("write custom/factory/x".into())); +} + +/// A document a run binds is decided by its run authority, as its claims are. +#[test] +fn a_runs_document_writes_are_decided_by_its_authority() { + let _mode = MODE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + set_run_authority_mode(RunAuthorityMode::Enforce); + let store = missions(); + let land = start(&store, "land", "land-docs", None).unwrap(); + let put = |name: &str| store.put_document_as(name, b"notes", &None, name, Some(&land.subject)); + put("doc/factory/notes").unwrap(); + assert_eq!(code(put("doc/elsewhere/notes")), "run-authority-denied"); + set_run_authority_mode(RunAuthorityMode::Audit); + put("doc/elsewhere/audited").unwrap(); + let audits = store.rule_audits(Some("run-authority"), 10).unwrap(); + assert_eq!(audits[0].body["fields"]["target"], "doc/elsewhere/audited"); + assert_eq!(audits[0].body["fields"]["action"], "doc.bound"); +} + #[test] fn a_run_writes_only_within_its_authority_intersected_with_its_starters() { let _mode = MODE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); diff --git a/crates/fractal-store/src/store/runtime.rs b/crates/fractal-store/src/store/runtime.rs index 1268c9d1e80..4fdd3f6783f 100644 --- a/crates/fractal-store/src/store/runtime.rs +++ b/crates/fractal-store/src/store/runtime.rs @@ -268,6 +268,17 @@ impl Runtime for FractalRuntime { fn writer_observer(&self) -> Option> { Some(self.observer_actuals.clone()) } + fn admit_writer_tx( + &self, + transaction: &Transaction<'_>, + origin: &str, + writer: &str, + kind: &str, + subject: &str, + ) -> Result<(), FractalError> { + // A document a run binds is decided by its run authority, as its claims are. + super::run_authority::admit_tx(transaction, origin, Some(writer), kind, subject, &BTreeMap::new()) + } fn idempotency_response_in_use(&self, connection: &Connection, response: &str) -> Result { // Some local entries contain a digest, not JSON. They have no work lifetime to pin. let Ok(value) = serde_json::from_str::(response) else { diff --git a/crates/smallclaims/src/store.rs b/crates/smallclaims/src/store.rs index f2829fa1561..fc21ee19e7e 100644 --- a/crates/smallclaims/src/store.rs +++ b/crates/smallclaims/src/store.rs @@ -6664,6 +6664,7 @@ impl Store { if let Some(writer) = writer { principals::rules_gate_tx(transaction, &self.origin, writer, "doc.bound", name) .map_err(crate::error::typed)?; + self.runtime.admit_writer_tx(transaction, &self.origin, writer, "doc.bound", name)?; } let mut body = json!({ "name": name, "hash": hash, "size": bytes.len() }); idempotency::attach(&mut body, idempotency_key).map_err(internal)?; diff --git a/crates/smallclaims/src/store/runtime.rs b/crates/smallclaims/src/store/runtime.rs index 908c533b447..1d66d0e19e3 100644 --- a/crates/smallclaims/src/store/runtime.rs +++ b/crates/smallclaims/src/store/runtime.rs @@ -109,6 +109,19 @@ pub trait Runtime: Send + Sync { false } + /// Admit a write whose claim names no actor but whose writer is known, such as a document + /// binding: the runtime's actor-keyed decisions see the writer as they see an actor. + fn admit_writer_tx( + &self, + _transaction: &Transaction<'_>, + _origin: &str, + _writer: &str, + _kind: &str, + _subject: &str, + ) -> Result<(), crate::error::Error> { + Ok(()) + } + /// Check a replicated claim's kind and fields once the graph has verified its hash and batch. fn classify_replicated_claim( &self, diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 14f43f8eacf..b5eba6592ae 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -811,7 +811,7 @@ {"id": "flow-h8ff255a3c271", "path": "crates/smallclaims/src/store.rs", "function": "insert_claim", "owner": "smallclaims::store", "fingerprint": "c1f2beebb8654792635657bffc14ee4fa7e2f3303e803360b91feb922790e708", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Direct claim insert: body, predecessors and identifiers; bound parameters enter claims."}, {"id": "flow-h9a1973bf379a", "path": "crates/smallclaims/src/store.rs", "function": "promote_claim_blobs", "owner": "smallclaims::store", "fingerprint": "3a7eace9c6a74cbe44a5fbe23b9321f47c241c2662186a405ce7e863170442d5", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "description": "Blob promotion copies staged bytes to durable graph storage without changing logical identity."}, {"id": "flow-hd4562283f4d7", "path": "crates/smallclaims/src/store.rs", "function": "put_blob", "owner": "smallclaims::store", "fingerprint": "46f2545418004329783b4f1be7c2e0c5d5bd0a271694c3168a91ad3264c354ec", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "description": "Immutable blob bytes are independently variable values; retain digest/length/promotion semantics."}, - {"id": "flow-h5298e6bf0818", "path": "crates/smallclaims/src/store.rs", "function": "put_document_as", "owner": "smallclaims::store", "fingerprint": "1e506aa4b6b4fbea69af79a2ce98c3220bb8b41f127de139e327fd6fedc6a7c9", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Document API/local producer body, identity and predecessors reach the common claim writer."}, + {"id": "flow-h5298e6bf0818", "path": "crates/smallclaims/src/store.rs", "function": "put_document_as", "owner": "smallclaims::store", "fingerprint": "d4b8d676c6eef326bc2fc38e9da42ba86b602aab489539e83a0eec6310543298", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "description": "Document API/local producer body, identity and predecessors reach the common claim writer."}, {"id": "flow-h7818827323a6", "path": "crates/smallclaims/src/store.rs", "function": "receive_replication_exchange_via", "owner": "smallclaims::store", "fingerprint": "7fac5944c99444ba5e7e6024033ae70f75fe4e71784443e6a0ea77de8be7b8f4", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Exchange importer delegates admission and projection; wire/decompression bounds are not a universal stored-cell cap."}, {"id": "flow-he3fba1ec9f02", "path": "crates/smallclaims/src/store.rs", "function": "seed_replica_envelopes_signed_tx", "owner": "smallclaims::store", "fingerprint": "e8bca83e997622c80dbc8208225b55e51bf69a8d329a524a1aa61a2a2fa95180", "todo": "chunk-or-reference-exact-wire-values", "disposition": "allow-for-now", "description": "Local sealing serializes admitted history into exact signed raw envelopes; body/wire representations both matter."}, {"id": "flow-hc220cedadab9", "path": "crates/smallclaims/src/store.rs", "function": "separate_staged_blobs", "owner": "smallclaims::store", "fingerprint": "691fbc18013b213398f54c34218b5e72735c4b2300534ff1ed68822f17d8b4e7", "todo": "chunk-or-reference-variable-columns", "disposition": "allow-for-now", "description": "Legacy staged blob migration/copy may contain valid historical large cells."}, diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index c56c1040b97..fecd78688dc 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -246,18 +246,21 @@ block, a repeated block or rule, an unknown verb, any other `*`, and the block i mission. Rules are stored in canonical order, so reordering them is not a revision; any other change is. A mission without the block grants its runs nothing. -Each claim written as `mission-run/RUN` is decided at write admission. The run holds the -`authority` of its current generation's revision, intersected with its starter's: the actor of -its `mission-run.created`, which is its requester, or `mission-run/PARENT` for a child a program -starts (whose own effective authority is intersected in turn). A person, agent or daemon starter -contributes everything free mode gives it, and the rules that restrict it decide each of the run's -writes as well. The claim names the grant it needs: `mission.published` on `mission/ID` needs -`publish ID`; a run's creation and its initial generation need `start ID`; a later generation -needs `revise ID`; any other claim needs `write SUBJECT`. A run always journals `program.step` on -its own step runs. Setting another run's outcome, which is how a run is cancelled, takes a person or -agent actor, so a run cannot cancel one whatever it declares. A write outside the effective authority is -recorded as `rule.audited` on `rule/run-authority` and proceeds, or is refused with -`run-authority-denied`, as the daemon's `[programs] authority` says (`audit` by default). +Each claim written as `mission-run/RUN`, and each document a run binds, is decided at write +admission; a run's message send stays on the graph append so the decision applies to it. The run +holds the `authority` of its current generation's revision, intersected with its starter's: the +actor of its `mission-run.created`, which is its requester, or `mission-run/PARENT` for a child a +program starts (whose own effective authority is intersected in turn). A person, agent or daemon +starter contributes everything free mode gives it, and the rules that restrict it decide each of +the run's writes as well. The claim names the grant it needs: `mission.published` on `mission/ID` +needs `publish ID`; a run's creation and a generation without a predecessor need `start ID`; a +generation with a predecessor needs `revise ID`, as does everything a revision writes on the runs +it changes, each on that run's mission; any other claim needs `write SUBJECT`. A run always +journals `program.step` on its own step runs. `cancel` is reserved and grants nothing yet: setting +another run's outcome, which is how a run is cancelled, takes a person or agent actor. A write +outside the effective authority is recorded as `rule.audited` on `rule/run-authority` and +proceeds, or is refused with `run-authority-denied`, as the daemon's `[programs] authority` says +(`audit` by default). A request whose caller's ancestry carries a live run credential (`ST_RUN_CREDENTIAL`, one per program attempt) is bound to `mission-run/RUN`: like a bound agent, it diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md index 5fabe56c608..67fc784cb24 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -195,7 +195,8 @@ repeated block or rule, an empty block, an unknown verb and any other `*`. There form: a mission without the block grants its runs nothing. The block is a field of the mission spec, kept in canonical order and absent when undeclared, so existing revisions hash unchanged and changing it is a mission revision. Publication holds a revision that declares it until every -member advertises `mission_authority=1`, as for `capacity`. +member advertises `mission_authority=1`, as for `capacity`. `cancel` is reserved: it parses and +grants nothing yet, because setting another run's outcome takes a person or agent actor. **Starter.** A run's starter is the actor of its `mission-run.created`: its requester, or, for a child a program starts (§6), the parent run, `mission-run/PARENT`. The requester stays the human. @@ -230,13 +231,18 @@ same run. The same-UID boundary is cooperative, as for agents. on its own step runs, whatever it declares. **Enforcement** is one decision at the store's write admission, beside the rules gate, for every -claim whose actor is a run. The claim names the grant it needs: `mission.published` on `mission/ID` -needs `publish ID`; a run's creation and its initial generation need `start ID`; a later generation -needs `revise ID`; any other claim needs `write SUBJECT`. Setting another run's outcome takes a -person or agent actor, so a run cannot cancel one. The daemon's `[programs] authority` says what -a write outside the authority does: `enforce` refuses it with `run-authority-denied`; `audit` -lets it through and records `rule.audited` on `rule/run-authority`, so a new program can discover -the grants it needs. +claim whose actor is a run and every document a run binds. A run's message send stays on the graph +append, where the decision applies, rather than the messages stream. The claim names the grant it +needs, from fields its writer cannot choose: `mission.published` on `mission/ID` needs +`publish ID`; a run's creation and a generation without a predecessor need `start ID`; a +generation with a predecessor needs `revise ID`, and so does everything a revision writes on the +runs it changes (its proposal, carried and terminalized steps, the superseded generation, the +run's and its descendants' states), each on the mission of the run it changes; any other claim +needs `write SUBJECT`. A refusal reaches its caller as `run-authority-denied`. The daemon's +`[programs] authority` says what a write outside the authority does: `enforce` refuses it; +`audit` lets it through and records `rule.audited` on `rule/run-authority`, so a new program can +discover the grants it needs. Each decision reads only each revision's `authority` member along +the starter chain. ### 4. Versioning diff --git a/release-notes/mission-run-authority-audit.json b/release-notes/mission-run-authority-audit.json index 7e3eef46d84..8f59509ba66 100644 --- a/release-notes/mission-run-authority-audit.json +++ b/release-notes/mission-run-authority-audit.json @@ -1,11 +1,11 @@ { "version": 1, - "summary": "Every claim written as `mission-run/RUN` is decided against the run's declared `authority` intersected with its starter's; the new daemon setting `[programs] authority` (default `audit`) records a write outside it as `rule.audited` on `rule/run-authority`, or refuses it with `run-authority-denied` when set to `enforce`", + "summary": "Every claim and document binding written as `mission-run/RUN` is decided against the run's declared `authority` intersected with its starter's; the new daemon setting `[programs] authority` records a write outside it as `rule.audited` on `rule/run-authority` (`audit`) or refuses it with `run-authority-denied` (`enforce`); this change ships `audit` as the default, and the next change in the stack makes `enforce` the default", "replay": {"status": "none", "detail": "No claim kind or projection change. Audit records reuse `rule.audited` with `rule` set to `run-authority`. A child run a program starts writes its `mission-run.created` and initial `run-generation.created` with actor `mission-run/PARENT`; its `requester` stays the parent's."}, "schema": {"status": "none", "detail": "No table, index or migration change."}, - "rules": {"status": "changed", "detail": "First enforced declared authority. Writes by `mission-run/*` actors are checked at write admission: `mission.published` needs `publish`, a run's creation and initial generation need `start`, a later generation needs `revise`, any other claim needs `write` on its subject; a run always journals `program.step` on its own step runs. The effective authority is the current revision's block intersected with the starter's (recursively through program-started parents); a person, agent or daemon starter's enforce-mode rules also decide each write. Writers that are not runs are untouched. Audit is the default."}, - "compatibility": {"status": "changed", "detail": "New optional config table `[programs]` with `authority = \"audit\" | \"enforce\"`; an older build refuses a config that sets it (unknown field), so set it only on builds that read it. Nothing writes as a run until the program engine dispatches with run credentials."}, + "rules": {"status": "changed", "detail": "First enforced declared authority. Writes by `mission-run/*` actors, and documents a run binds, are decided at write admission. `mission.published` needs `publish`; a run's creation and a generation without a predecessor need `start`; a generation with a predecessor needs `revise`, and so do the claims a revision writes on the runs it changes (its proposal, carried and terminalized steps, the superseded generation, run and descendant states); any other claim needs `write` on its subject. A run always journals `program.step` on its own step runs. A message a run sends stays on the graph append, where this decision applies, rather than the messages stream. The effective authority is the current revision's block intersected with the starter's (recursively through program-started parents); a person, agent or daemon starter's enforce-mode rules also decide each write. Writers that are not runs are untouched. `cancel` is reserved: it parses and grants nothing yet. Refusals reach the caller as `run-authority-denied`."}, + "compatibility": {"status": "changed", "detail": "New optional config table `[programs]` with `authority = \"audit\" | \"enforce\"`; an older build refuses a config that sets it (unknown field), so set it only on builds that read it. Without the table this change audits; once the next change lands, removing the table enforces. Nothing writes as a run until the program engine dispatches with run credentials."}, "downtime": {"status": "none", "detail": "The mode is read when the daemon starts; changing it needs a restart."}, - "manual": {"status": "none", "detail": "No operator action is required. Read `fractal rules audit run-authority` before setting `enforce`."}, - "recovery": {"status": "none", "detail": "Set `[programs] authority = \"audit\"` (or remove the table) and restart to let refused writes proceed again."} + "manual": {"status": "none", "detail": "No operator action is required. Read `fractal rules audit run-authority` before enforcing."}, + "recovery": {"status": "none", "detail": "Set `[programs] authority = \"audit\"` and restart to let refused writes proceed again while their grants are declared."} } From 88c4b7d97ee1b20fc4693d7ccfe56610b10cf7d6 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:31:21 +0100 Subject: [PATCH 5/5] Give back the run authority mode each test found --- .../src/store/run_authority_tests.rs | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/crates/fractal-store/src/store/run_authority_tests.rs b/crates/fractal-store/src/store/run_authority_tests.rs index 600076e4218..fa616485878 100644 --- a/crates/fractal-store/src/store/run_authority_tests.rs +++ b/crates/fractal-store/src/store/run_authority_tests.rs @@ -4,9 +4,26 @@ use super::*; use crate::config::RunAuthorityMode; use crate::model::{AuthorityGrant, AuthorityVerb, MissionRunRequest}; -/// The mode is the daemon's, process-wide: tests that depend on it take turns. +/// The mode is the daemon's, process-wide: tests that depend on it take turns, and each gives +/// back the mode it found. static MODE: std::sync::Mutex<()> = std::sync::Mutex::new(()); +struct ModeGuard { + before: RunAuthorityMode, + _turn: std::sync::MutexGuard<'static, ()>, +} + +impl Drop for ModeGuard { + fn drop(&mut self) { + set_run_authority_mode(self.before); + } +} + +fn hold_mode() -> ModeGuard { + let turn = MODE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + ModeGuard { before: run_authority_mode(), _turn: turn } +} + fn grant(verb: AuthorityVerb, pattern: &str) -> AuthorityGrant { AuthorityGrant { verb, pattern: pattern.into() } } @@ -125,7 +142,7 @@ fn a_claims_needed_grant_follows_its_operation() { /// A document a run binds is decided by its run authority, as its claims are. #[test] fn a_runs_document_writes_are_decided_by_its_authority() { - let _mode = MODE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _mode = hold_mode(); set_run_authority_mode(RunAuthorityMode::Enforce); let store = missions(); let land = start(&store, "land", "land-docs", None).unwrap(); @@ -141,7 +158,7 @@ fn a_runs_document_writes_are_decided_by_its_authority() { #[test] fn a_run_writes_only_within_its_authority_intersected_with_its_starters() { - let _mode = MODE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _mode = hold_mode(); set_run_authority_mode(RunAuthorityMode::Enforce); let store = missions(); let land = start(&store, "land", "land-run", None).unwrap(); @@ -196,7 +213,7 @@ fn a_run_writes_only_within_its_authority_intersected_with_its_starters() { #[test] fn writers_other_than_runs_are_untouched() { - let _mode = MODE.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + let _mode = hold_mode(); set_run_authority_mode(RunAuthorityMode::Enforce); let store = missions(); write(&store, "person/operator", "custom/other/person").unwrap();