From 86a47d8815458785aace3b2aed3a1786e6807219 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:10:12 +0100 Subject: [PATCH 1/6] Parse, render and store a mission's run authority block --- crates/fractal-kernel/src/model.rs | 37 ++++ crates/fractal-missions-base/src/mission.rs | 162 +++++++++++++++++- .../src/mission_render.rs | 22 ++- docs/st3/mission-graph-runtime.md | 12 ++ .../mission-run-authority-block.json | 11 ++ 5 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 release-notes/mission-run-authority-block.json diff --git a/crates/fractal-kernel/src/model.rs b/crates/fractal-kernel/src/model.rs index 9ba505849f9..1fb3089a4d9 100644 --- a/crates/fractal-kernel/src/model.rs +++ b/crates/fractal-kernel/src/model.rs @@ -899,6 +899,39 @@ pub struct DeclaredResourceReference { pub reason: Option, } +/// One rule of a mission's `authority` block: what its runs may do as `mission-run/RUN` +/// (docs/st3/program-engine.md §3). Mission verbs take a mission ID pattern, `write` a subject +/// pattern; either is exact or a terminal `/*` namespace. +#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +pub struct AuthorityGrant { + pub verb: AuthorityVerb, + pub pattern: String, +} + +#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum AuthorityVerb { + Publish, + Start, + Revise, + Cancel, + Write, +} + +impl AuthorityVerb { + pub const ALL: [Self; 5] = [Self::Publish, Self::Start, Self::Revise, Self::Cancel, Self::Write]; + + pub fn as_str(self) -> &'static str { + match self { + Self::Publish => "publish", + Self::Start => "start", + Self::Revise => "revise", + Self::Cancel => "cancel", + Self::Write => "write", + } + } +} + #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct MissionSpec { pub id: String, @@ -911,6 +944,10 @@ pub struct MissionSpec { pub system: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub resources: Vec, + /// What runs of this mission may do as `mission-run/RUN`, in canonical order; empty grants + /// nothing. Omitted when empty, so an undeclared mission hashes as it did before the field. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub authority: Vec, #[serde(default)] pub inputs: BTreeMap, #[serde(default = "default_mission_run_limit")] diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index d8f53486697..0c2884286f0 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -7,10 +7,10 @@ use sha2::{Digest as _, Sha256}; use crate::declaration_syntax::*; use crate::model::{ - BaselineSpec, CompletionSpec, DependencySpec, GateSpec, LoopAttentionSpec, LoopExhaustionSpec, - LoopSpec, LoopStopSpec, MetricSource, MetricSpec, MissionInputKind, MissionInputSpec, - MissionSpec, MissionState, ProductSpec, RetrySpec, RevisionCutover, FractalError, StepSpec, - UsedMissionSpec, WorkSelector, + AuthorityGrant, AuthorityVerb, BaselineSpec, CompletionSpec, DependencySpec, GateSpec, + LoopAttentionSpec, LoopExhaustionSpec, LoopSpec, LoopStopSpec, MetricSource, MetricSpec, + MissionInputKind, MissionInputSpec, MissionSpec, MissionState, ProductSpec, RetrySpec, + RevisionCutover, FractalError, StepSpec, UsedMissionSpec, WorkSelector, }; pub const LOOP_ROUND_INPUT: &str = "__st3_loop_round"; @@ -308,6 +308,7 @@ fn parse_mission( let mut revision_owners = Vec::new(); let mut system = false; let mut capacity = None; + let mut authority = None; for child in children.nodes() { match child.name().value() { "declared-priority" | "declared-lane" | "declared-budgets" => {} @@ -353,6 +354,21 @@ fn parse_mission( } resources.push(reference); } + "authority" if top_level => { + if authority.is_some() { + return Err(FractalError::new( + "duplicate-mission-field", + format!("mission `{id}` repeats `authority`"), + )); + } + authority = Some(parse_authority(child, &id)?); + } + "authority" => { + return Err(FractalError::new( + "nested-mission-authority", + format!("mission `{id}` is nested; declare authority on the top-level mission"), + )); + } "concurrent-runs" => { if concurrent_runs_seen { return Err(FractalError::new( @@ -565,6 +581,7 @@ fn parse_mission( declared_lane: admission.lane, declared_budgets: admission.budgets, resources, + authority: authority.unwrap_or_default(), inputs, max_active_runs, timeout_ms, @@ -597,6 +614,86 @@ fn parse_mission( Ok(mission) } +/// `authority { VERB "PATTERN" … }`: what runs of a top-level mission may do as +/// `mission-run/RUN` (docs/st3/program-engine.md §3). The shape of an agent's `*-authority` +/// block; `publish`, `start`, `revise` and `cancel` take mission IDs as `mission-authority` does, +/// `write` takes a subject. The rules are kept in canonical order, so reordering is no revision. +fn parse_authority(node: &KdlNode, id: &str) -> Result, FractalError> { + ensure_bare(node)?; + let empty = || { + FractalError::new( + "empty-authority", + format!("mission `{id}` authority needs at least one rule"), + ) + }; + let body = node.children().ok_or_else(empty)?; + let mut grants = BTreeSet::new(); + for rule in body.nodes() { + let name = rule.name().value(); + let verb = AuthorityVerb::ALL + .into_iter() + .find(|verb| verb.as_str() == name) + .ok_or_else(|| { + FractalError::new( + "invalid-authority", + format!( + "mission `{id}` authority cannot contain `{name}`; its rules are publish, start, revise, cancel and write" + ), + ) + })?; + ensure_no_properties(rule)?; + ensure_no_children(rule)?; + let pattern = one_string(rule)?; + validate_authority_pattern(verb, &pattern)?; + if !grants.insert(AuthorityGrant { verb, pattern: pattern.clone() }) { + return Err(FractalError::new( + "duplicate-authority", + format!("mission `{id}` repeats authority `{name} {pattern}`"), + )); + } + } + if grants.is_empty() { + return Err(empty()); + } + Ok(grants.into_iter().collect()) +} + +/// Exact, or a terminal `/*` namespace covering everything under it: a mission ID without +/// `mission/` for the mission verbs, a subject (or a bare registered family before `/*`) for +/// `write`. +fn validate_authority_pattern(verb: AuthorityVerb, pattern: &str) -> Result<(), FractalError> { + let invalid = || { + FractalError::new( + "invalid-authority-pattern", + format!( + "authority `{} {pattern}` needs {} or a terminal `/*` namespace", + verb.as_str(), + if verb == AuthorityVerb::Write { "an exact subject" } else { "an exact mission ID without `mission/`" }, + ), + ) + }; + let namespace = pattern.strip_suffix("/*"); + let exact = namespace.unwrap_or(pattern); + if exact.is_empty() || exact.contains('*') { + return Err(invalid()); + } + if verb != AuthorityVerb::Write { + if exact.starts_with("mission/") { + return Err(invalid()); + } + return validate_mission_id(exact).map_err(|_| invalid()); + } + // A namespace is a registered family, alone or followed by non-empty path segments. + let registry = fractal_schema::registry(); + let Some(prefix) = namespace else { + return registry.validate_subject(exact).map(|_| ()).map_err(|_| invalid()); + }; + let well_formed = registry.subject(&format!("{prefix}/_")).is_some() + && !prefix.split('/').any(str::is_empty) + && !prefix.chars().any(|character| character.is_whitespace() || character.is_control()); + well_formed.then_some(()).ok_or_else(invalid) +} + fn parse_loop_group( node: &KdlNode, parent_mission: &str, @@ -3230,4 +3327,61 @@ mod tests { .unwrap(); assert_eq!(command, format!("printf '%s' '{message}'")); } + + fn authority_error(body: &str) -> String { + let source = format!(r#"mission "land" state="ready" {{ goal "Land."; step "work" {{ agentless; }}; {body} }}"#); + super::parse_missions(&source.parse().unwrap(), "node").unwrap_err().code + } + + #[test] + fn mission_authority_parses_in_canonical_order_and_enters_the_revision() { + use crate::model::{AuthorityGrant, AuthorityVerb}; + let parse = |authority: &str| { + let source = format!(r#"mission "land" state="ready" {{ goal "Land."; step "work" {{ agentless; }}; {authority} }}"#); + super::parse_missions(&source.parse().unwrap(), "node").unwrap().remove("land").unwrap() + }; + let mission = parse(r#"authority { write "resource/factory/*"; publish "scratch/*"; start "factory/publish"; cancel "factory/*"; revise "factory/*"; write "custom/factory/landing"; write "resource/*" }"#); + let grant = |verb, pattern: &str| AuthorityGrant { verb, pattern: pattern.into() }; + assert_eq!(mission.authority, [ + grant(AuthorityVerb::Publish, "scratch/*"), + grant(AuthorityVerb::Start, "factory/publish"), + grant(AuthorityVerb::Revise, "factory/*"), + grant(AuthorityVerb::Cancel, "factory/*"), + grant(AuthorityVerb::Write, "custom/factory/landing"), + grant(AuthorityVerb::Write, "resource/*"), + grant(AuthorityVerb::Write, "resource/factory/*"), + ]); + let reordered = parse(r#"authority { revise "factory/*"; cancel "factory/*"; start "factory/publish"; publish "scratch/*"; write "resource/*"; write "resource/factory/*"; write "custom/factory/landing" }"#); + assert_eq!(reordered.revision, mission.revision); + let narrower = parse(r#"authority { revise "factory/*"; cancel "factory/*"; start "factory/publish"; publish "scratch/*"; write "resource/factory/*"; write "custom/factory/landing" }"#); + assert_ne!(narrower.revision, mission.revision); + let undeclared = parse(""); + assert!(undeclared.authority.is_empty()); + assert!(serde_json::to_value(&undeclared).unwrap().get("authority").is_none()); + } + + #[test] + fn mission_authority_refusals() { + for (body, code) in [ + (r#"authority"#, "empty-authority"), + (r#"authority {}"#, "empty-authority"), + (r#"authority "none""#, "unexpected-value"), + (r#"authority { write "resource/a"; }; authority { write "resource/b"; }"#, "duplicate-mission-field"), + (r#"authority { write "resource/a"; write "resource/a"; }"#, "duplicate-authority"), + (r#"authority { move "agent/a"; }"#, "invalid-authority"), + (r#"authority { write "resource/a" mode="audit"; }"#, "unknown-property"), + (r#"authority { write "resource/*/x"; }"#, "invalid-authority-pattern"), + (r#"authority { write "resource/a*"; }"#, "invalid-authority-pattern"), + (r#"authority { write "*"; }"#, "invalid-authority-pattern"), + (r#"authority { write "unregistered/a"; }"#, "invalid-authority-pattern"), + (r#"authority { write "unregistered/*"; }"#, "invalid-authority-pattern"), + (r#"authority { write "resource"; }"#, "invalid-authority-pattern"), + (r#"authority { start "mission/factory"; }"#, "invalid-authority-pattern"), + (r#"authority { start "factory/*/land"; }"#, "invalid-authority-pattern"), + (r#"authority { publish "/*"; }"#, "invalid-authority-pattern"), + (r#"step "outer" { mission "inner" { goal "Inner."; authority { write "resource/a"; }; step "x" { agentless; }; }; }"#, "nested-mission-authority"), + ] { + assert_eq!(authority_error(body), code, "{body}"); + } + } } diff --git a/crates/fractal-missions-base/src/mission_render.rs b/crates/fractal-missions-base/src/mission_render.rs index 5e1fb6b1ea5..38575cd9784 100644 --- a/crates/fractal-missions-base/src/mission_render.rs +++ b/crates/fractal-missions-base/src/mission_render.rs @@ -35,7 +35,7 @@ fn mission_node(mission: &MissionSpec) -> Result { fn mission_body(mission: &MissionSpec, parent: Option<&MissionSpec>) -> Result, FractalError> { // Exhaustive destructuring makes a new MissionSpec field a renderer coverage decision. - let MissionSpec { id: _, subject: _, state: _, revision: _, system, resources, inputs, + let MissionSpec { id: _, subject: _, state: _, revision: _, system, resources, authority, inputs, max_active_runs, timeout_ms: _, report_to: _, stalled_after_ms: _, report_stalls: _, report_completed: _, revision_owners: _, revisions_human_only: _, revision_reviewer: _, revision_cutover: _, declarations_kdl: _, capacity, work_selector, completion, goals, @@ -71,6 +71,10 @@ fn mission_body(mission: &MissionSpec, parent: Option<&MissionSpec>) -> Result>(); + assert_eq!(rules, ["publish scratch/*", "start factory/publish", "write resource/factory/*"]); + } + #[test] fn canonical_declared_admission_retains_typed_metadata() { let mut spec = parse(r#"mission "test" state="ready" { goal "Keep declaration metadata."; }"#); diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index a060aa19457..a8009f56104 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -194,6 +194,7 @@ mission "MISSION_ID" assigned-to "agent/${ST_MISSION_RUN}/owner" // Or repeat available-to. A mission cannot declare agentless. capacity cores=2 memory="4G" // the lane of each agent here that declares none + authority { start "factory/publish"; write "resource/factory/*" } // top-level only completion { when "all-steps-exhausted" } // Or: completion { depends-on { step "publish" completed } } @@ -235,6 +236,17 @@ An exact idempotent retry returns its existing run before the capacity check. A The reviewer defaults to the mission run requester. `revision-cutover` is `restart-active` by default or `when-idle` when declared. The value in the current generation controls how its successor starts; a candidate cannot select its own cutover. +`authority` is optional and belongs on a top-level mission only. It says what the mission's runs +may do as the actor `mission-run/RUN` when a program step acts as the run +([program engine §3](program-engine.md#3-declaration-and-authority)): one `VERB "PATTERN"` rule per +line, in the shape of an agent's `*-authority` blocks. `publish`, `start`, `revise` and `cancel` take +a mission ID without `mission/`; `write` takes a subject, or a registered family alone, before +`/*`. 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 yet bind a run actor or enforce it. + A mission can contain direct declarations. Direct agents in the mission can revise the complete mission. A mission can contain zero steps. Missions default to `completion { when "all-steps-exhausted" }`, diff --git a/release-notes/mission-run-authority-block.json b/release-notes/mission-run-authority-block.json new file mode 100644 index 00000000000..bdf822d6d4f --- /dev/null +++ b/release-notes/mission-run-authority-block.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "A top-level mission can declare `authority { VERB \"PATTERN\" }`: what its runs may do as `mission-run/RUN` (publish, start, revise and cancel missions by ID, write subjects); it parses, renders and is stored with the revision, and is not enforced yet", + "replay": {"status": "none", "detail": "No claim kind or projection change. `mission.published` bodies of missions that declare the block carry a new optional `authority` array in the mission spec; absent otherwise."}, + "schema": {"status": "none", "detail": "No table, index or migration change."}, + "rules": {"status": "changed", "detail": "New declared authority grammar for mission runs. Nothing binds a run actor or enforces it in this change; later changes bind program steps to `mission-run/RUN` and enforce the block intersected with the starter's authority."}, + "compatibility": {"status": "changed", "detail": "An undeclared mission's revision hash is unchanged (the field is omitted when empty; pinned by an existing hash test). A member on an older build refuses a mission revision that declares `authority` as an unknown mission child, so publish the block only once every active member runs this build, as for program steps."}, + "downtime": {"status": "none", "detail": "No restart or migration beyond a normal deploy."}, + "manual": {"status": "none", "detail": "No operator action is required."}, + "recovery": {"status": "none", "detail": "Remove the `authority` block and publish again to return to an undeclared revision."} +} From 77b91d78c425a6463d8ec67a5571ef2b4fcc50d2 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:15:21 +0100 Subject: [PATCH 2/6] Hold run authority publication until every member reads it --- .../src/store/mission_capacity.rs | 125 ++++++++++++++---- crates/fractal/src/main.rs | 2 +- docs/st3/large-value-sites.json | 2 +- docs/st3/mission-graph-runtime.md | 6 +- .../mission-run-authority-block.json | 2 +- 5 files changed, 104 insertions(+), 33 deletions(-) diff --git a/crates/fractal-store/src/store/mission_capacity.rs b/crates/fractal-store/src/store/mission_capacity.rs index e59e8f3aa00..3a9eb7cbde2 100644 --- a/crates/fractal-store/src/store/mission_capacity.rs +++ b/crates/fractal-store/src/store/mission_capacity.rs @@ -1,12 +1,39 @@ -//! A mission revision that declares `capacity`, on the mission or on a step, is published only -//! once every current member reads the field. A member whose build does not checks a replicated -//! revision by hashing the mission as it reads it, without the field, and quarantines the claim -//! (docs/st3/seat-lanes.md, "Mixed versions"). Each member advertises the field on its own +//! A mission revision that declares a field an older build cannot read (`capacity` on the +//! mission or a step, or the mission's run `authority`) is published only once every current +//! member reads the field. A member whose build does not checks a replicated revision by hashing +//! the mission as it reads it, without the field, and quarantines the claim +//! (docs/st3/seat-lanes.md, "Mixed versions"). Each member advertises each field on its own //! `daemon.started`, as the standby barrier reads `schedule_standby`. use super::*; /// The `daemon.started` feature of a build that reads mission and step capacity. pub const FEATURE: &str = "mission_capacity"; +/// The `daemon.started` feature of a build that reads a mission's run `authority`. +pub const AUTHORITY_FEATURE: &str = "mission_authority"; + +/// One mission field behind a fleet barrier: the feature that says a member reads it, the +/// refusal code, and what to do once every member is upgraded. +struct Barrier { + feature: &'static str, + code: &'static str, + declares: fn(&MissionSpec) -> bool, + later: &'static str, +} + +const BARRIERS: [Barrier; 2] = [ + Barrier { + feature: FEATURE, + code: "mission-capacity-unavailable", + declares: declares_capacity, + later: "declare capacity on a mission or step", + }, + Barrier { + feature: AUTHORITY_FEATURE, + code: "mission-authority-unavailable", + declares: |mission| !mission.authority.is_empty(), + later: "declare authority on a mission", + }, +]; const WRITER_TABLES: [(&str, &str); 3] = [ ("replica_envelopes", "writer"), ("batches", "origin"), ("checkpoint_envelopes", "writer"), @@ -15,10 +42,6 @@ fn writer_seek(table: &str, column: &str) -> String { format!("SELECT {column} FROM {table} WHERE {column}>?1 ORDER BY {column} LIMIT 1") } -fn refused(reason: impl Into) -> FractalError { - FractalError::new("mission-capacity-unavailable", reason) -} - fn declares_capacity(mission: &MissionSpec) -> bool { mission.capacity.is_some() || mission.steps.values().any(|step| { @@ -26,15 +49,17 @@ fn declares_capacity(mission: &MissionSpec) -> bool { }) } -/// Refuses each new mission revision in `intent` that declares capacity while a current member -/// has not advertised [`FEATURE`]. A revision already published passes, and so does a host with -/// no other member. +/// Refuses each new mission revision in `intent` that declares a field behind a barrier while a +/// current member has not advertised its feature. A revision already published passes, and so +/// does a host with no other member. pub(super) fn validate_publication( connection: &Connection, origin: &str, intent: &NormalizedIntent, ) -> Result<(), FractalError> { - for mission in intent.missions.values().filter(|mission| declares_capacity(mission)) { + for mission in intent.missions.values() + .filter(|mission| BARRIERS.iter().any(|barrier| (barrier.declares)(mission))) + { let published: Option = connection .query_row( "SELECT revision FROM mission_definitions WHERE mission_id=?1", @@ -55,9 +80,15 @@ pub(super) fn validate_mission( origin: &str, mission: &MissionSpec, ) -> Result<(), FractalError> { - if !declares_capacity(mission) { - return Ok(()); + for barrier in BARRIERS.iter().filter(|barrier| (barrier.declares)(mission)) { + validate_barrier(connection, origin, barrier)?; } + Ok(()) +} + +fn validate_barrier(connection: &Connection, origin: &str, barrier: &Barrier) -> Result<(), FractalError> { + let Barrier { feature, code, later, .. } = barrier; + let refused = |reason: String| FractalError::new(*code, reason); let membership = fleet_membership_tx(connection).map_err(internal)?; for member in membership.incarnations().filter(|member| member.end.is_none()) { // This host runs this build. @@ -66,14 +97,14 @@ pub(super) fn validate_mission( } if !matches!(membership.state(&member.name), crate::fleet::MemberState::Current(_)) { return Err(refused(format!( - "the fleet membership of host/{} is ambiguous, so its build cannot be checked; declare capacity on a mission or step once it is settled", + "the fleet membership of host/{} is ambiguous, so its build cannot be checked; {later} once it is settled", member.name ))); } - let advertised: Option> = connection + let features: Option> = connection .query_row( - /* sqlite-large-value: allow-for-now lv-h20cb3d2e4d9e owner=fractal_store::store::mission_capacity TODO=trace-statement-and-value-representation */ &canonical_sql( - "SELECT json_extract(claims.body,'$.fields.features.mission_capacity')=1 + /* sqlite-large-value: allow-for-now lv-h88ff6fefeb9f owner=fractal_store::store::mission_capacity TODO=trace-statement-and-value-representation */ &canonical_sql( + "SELECT json_extract(claims.body,'$.fields.features') FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.started' AND claims.origin=?2 AND batches.replica_sequence>=?3 ORDER BY CANONICAL_DESC(claims) LIMIT 1", @@ -83,9 +114,12 @@ pub(super) fn validate_mission( ) .optional() .map_err(internal)?; - if advertised.flatten() != Some(true) { + let advertised = features.flatten() + .and_then(|features| serde_json::from_str::(&features).ok()) + .is_some_and(|features| features.get(*feature) == Some(&json!(1))); + if !advertised { return Err(refused(format!( - "host/{} runs an older build that would refuse this revision (it has not advertised {FEATURE}=1); declare capacity on a mission or step once every member is upgraded", + "host/{} runs an older build that would refuse this revision (it has not advertised {feature}=1); {later} once every member is upgraded", member.name ))); } @@ -102,7 +136,7 @@ pub(super) fn validate_mission( { if writer != origin && !membership.is_keyed_writer(&writer) && !fenced.contains(&writer) { return Err(refused(format!( - "`{writer}` replicates to this host outside the fleet membership, so its build cannot be checked; declare capacity on a mission or step once it is a member or fenced" + "`{writer}` replicates to this host outside the fleet membership, so its build cannot be checked; {later} once it is a member or fenced" ))); } previous = writer; @@ -150,14 +184,8 @@ mission "plain" state="ready" { json!({"status": "running", "features": {FEATURE: u8::from(reads_capacity)}})); } - #[test] - fn a_revision_that_declares_capacity_waits_until_every_member_reads_it() { - // A host alone publishes at once, with or without a fleet. - publish(&Store::open_memory("alder").unwrap(), LANED, "alone").unwrap(); - let anchored = Store::open_memory("alder").unwrap(); - enable(&anchored); - publish(&anchored, LANED, "anchored-alone").unwrap(); - + /// `alder` with `birch` admitted, synced and started on a build that reads no barrier field. + fn two_members() -> (Store, Store) { let store = Store::open_memory("alder").unwrap(); let anchor = enable(&store); let peer_key = Arc::new(MemberKey::generate().unwrap().0); @@ -171,6 +199,18 @@ mission "plain" state="ready" { sync(&store, &peer); started(&peer, false); sync(&peer, &store); + (store, peer) + } + + #[test] + fn a_revision_that_declares_capacity_waits_until_every_member_reads_it() { + // A host alone publishes at once, with or without a fleet. + publish(&Store::open_memory("alder").unwrap(), LANED, "alone").unwrap(); + let anchored = Store::open_memory("alder").unwrap(); + enable(&anchored); + publish(&anchored, LANED, "anchored-alone").unwrap(); + + let (store, peer) = two_members(); // A member on an older build would quarantine the revision: the plan shows why and the // publication is refused. A mission without capacity is unaffected. @@ -202,4 +242,31 @@ mission "plain" state="ready" { assert!(publish(&store, LANED, "upgraded").unwrap().is_empty()); assert_eq!(store.mission_spec("laned", None).unwrap().unwrap().capacity, mission.capacity); } + + #[test] + fn a_revision_that_declares_run_authority_waits_until_every_member_reads_it() { + const AUTHORIZED: &str = r#"version 2 +mission "authorized" state="ready" { + goal "Work." + authority { write "resource/example/*"; } + step "work" { agentless; } +} +"#; + publish(&Store::open_memory("alder").unwrap(), AUTHORIZED, "alone").unwrap(); + let (store, peer) = two_members(); + // Reading capacity is not reading authority. + claim(&peer, "daemon/birch", "daemon.started", None, + json!({"status": "running", "features": {FEATURE: 1}})); + sync(&peer, &store); + let refusal = publish(&store, AUTHORIZED, "older").unwrap_err(); + assert_eq!(refusal.code, "mission-authority-unavailable"); + assert!(refusal.message.contains("has not advertised mission_authority=1; declare authority on a mission once every member is upgraded"), + "{}", refusal.message); + assert!(store.mission_spec("authorized", None).unwrap().is_none()); + claim(&peer, "daemon/birch", "daemon.started", None, + json!({"status": "running", "features": {FEATURE: 1, AUTHORITY_FEATURE: 1}})); + sync(&peer, &store); + assert!(publish(&store, AUTHORIZED, "upgraded").unwrap().is_empty()); + assert_eq!(store.mission_spec("authorized", None).unwrap().unwrap().authority.len(), 1); + } } diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index c74d4983c76..3419819b32c 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -7467,7 +7467,7 @@ async fn run_up(args: UpArgs) -> Result<()> { fields: BTreeMap::from([ ( "features".into(), - serde_json::json!({"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"agent_charter":1,"schedule_standby":1,"github_plugin":2,"seat_freshness":1,"seat_freshness_reads":1,"message_offsets":1,"message_view_reads":1,"message_staged_reads":1,"mission_capacity":1, + serde_json::json!({"owned_sets":1,"seat_rollout":1,"seat_rollout_manual":1,"person_directive_note":1,"declared_admission":1,"declared_attention":1,"agent_charter":1,"schedule_standby":1,"github_plugin":2,"seat_freshness":1,"seat_freshness_reads":1,"message_offsets":1,"message_view_reads":1,"message_staged_reads":1,"mission_capacity":1,"mission_authority":1, "harness_access_auto": u8::from(config.active_access_mode() == fractal_drivers::access::AccessMode::Auto), "messages_stream": u8::from(messages_shard.is_ok()), // Counts its stream mail in its idle, unread, held and usage indexes. It diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index 7e0db80d782..df875d31391 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -1336,7 +1336,6 @@ {"id": "lv-h20b4ce3791b8", "path": "crates/fractal-store/src/store/published_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "00e16953f852b16396fd7199a772beaa8544e3ecb26b3e18f6e8461c55971338", "owner": "fractal_store::store::published_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 435, "excerpt": "CHILD_RUNS"}, {"id": "lv-h20b935346aaf", "path": "crates/fractal-store/src/store/checkpoint_tests.rs", "kind": "sql-template", "fingerprint": "a466c1bb6842b49c1d34a00a24fdcb27190d6711850fee20c555c3c6c8bfc672", "owner": "fractal_store::store::checkpoint_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 493, "excerpt": "UPDATE claims SET accepted_at_unix_ms=?1 WHERE id=?2"}, {"id": "lv-h20bffcbd0763", "path": "crates/smallclaims/src/store/checkpoint_staging.rs", "kind": "sql-template", "fingerprint": "f742addfcfcc6af8ad5172ff290f31172a85780c82d523ed4e99b3b71e76f2e2", "owner": "smallclaims::store::checkpoint_staging", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 455, "excerpt": "INSERT INTO meta(id, identity, phase, cursor) VALUES (1, ?1, ?2, 0)"}, - {"id": "lv-h20cb3d2e4d9e", "path": "crates/fractal-store/src/store/mission_capacity.rs", "kind": "unresolved-sql-sink", "fingerprint": "e318d0369b4e7bf334d1427a20489fefe9c9d84d83b7c4eb72f6a2ca2749c235", "owner": "fractal_store::store::mission_capacity", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 75, "excerpt": "& canonical_sql ( \"SELECT json_extract(claims.body,'$.fields.features.mission_capacity')=1 FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.starte"}, {"id": "lv-h20ede67dacaa", "path": "crates/fractal-store/src/store.rs", "kind": "unresolved-sql-sink", "fingerprint": "6d5b306c813337790dcc59d3ea0244058138d994c293b1d1b62b4d21f61d94ca", "owner": "fractal_store::store", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 1608, "excerpt": "params ! [ subject , store_index ] , | row | row . get : : < _ , u64 > ( 0 )"}, {"id": "lv-h20ff92cef130", "path": "crates/fractal-store/src/store/resources_preparation_tests.rs", "kind": "sql-template", "fingerprint": "da375a6ef935682d924483cbd6c26b9c32a0a576b639560d3ec18290cfd5125b", "owner": "fractal_store::store::resources_preparation_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 1352, "excerpt": "DELETE FROM claims WHERE id=?1"}, {"id": "lv-h21121b909ab8", "path": "crates/fractal-missions/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "2beeceba08cc58c8d4101eebee3d7bdc5ff8260a291b89568ff5ea3680d6d8c7", "owner": "fractal_missions::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 6497, "excerpt": "& format ! ( \"SELECT json_extract(claims.body, '$.fields.state'), claims.accepted_at_unix_ms FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='harness.obs"}, @@ -3128,6 +3127,7 @@ {"id": "lv-h88d82d562560", "path": "crates/fractal-missions/src/store/eta_tests.rs", "kind": "sql-template", "fingerprint": "7648f29922e944fcaaf997a490ebfc96315a26ef7da5badc340a90e8a2ecba8b", "owner": "fractal_missions::store::eta_tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 158, "excerpt": "INSERT INTO local_run_eta_samples SELECT 'extra',run_id,definition_hash,step_path,2,duration FROM local_run_eta_samples WHERE subject='sample-0'"}, {"id": "lv-h88ea458f2e12", "path": "crates/fractal-attention/src/store/attention_list.rs", "kind": "unresolved-sql-sink", "fingerprint": "a42ff4c289a7e734672cb0b0042e0fbce20df6f7f3fdbdb1fae520e39ff319ee", "owner": "fractal_attention::store::attention_list", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 425, "excerpt": "params ! [ after , through , i64 : : try_from ( now ) . unwrap_or ( i64 : : MAX ) ] , | row | row . get ( 0 )"}, {"id": "lv-h88ebca0a4792", "path": "crates/fractal-seats/src/store/usage_period.rs", "kind": "unresolved-sql-sink", "fingerprint": "6c2ed65f6add7630a3428cd5a407227fa4efd13a56a9d5bfcc0e68db8bb931da", "owner": "fractal_seats::store::usage_period", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 22, "excerpt": "& format ! ( \"SELECT id,subject,store_index,body FROM claims INDEXED BY {INDEX} WHERE {PREDICATE} AND {series}=?1 AND {observed}=?2 LIMIT 2\" )"}, + {"id": "lv-h88ff6fefeb9f", "path": "crates/fractal-store/src/store/mission_capacity.rs", "kind": "unresolved-sql-sink", "fingerprint": "54829f8306fffea3dfe5d84e805a161bde817b5ae41888c4f253d3879ed5b995", "owner": "fractal_store::store::mission_capacity", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 106, "excerpt": "& canonical_sql ( \"SELECT json_extract(claims.body,'$.fields.features') FROM claims JOIN batches ON batches.id=claims.batch_id WHERE claims.subject=?1 AND claims.kind='daemon.started' AND claims.origi"}, {"id": "lv-h8911c1538a9c", "path": "crates/fractal-store/src/store/tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "8edb630b01a1f78d4abc5ee08b714ce7a8578b35d2269aec45684bf19ac1e4fc", "owner": "fractal_store::store::tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 447, "excerpt": "& query"}, {"id": "lv-h89145e643a90", "path": "crates/smallclaims/src/store/saved_inventory_tests.rs", "kind": "sql-template", "fingerprint": "6972dcbe1981439c50004941304392991f4b835e7c273105cb3287dede71d3a4", "owner": "smallclaims::store::saved_inventory_tests", "todo": "stage-generation-and-keyed-reclamation", "disposition": "allow-for-now", "line_hint": 471, "excerpt": "UPDATE checkpoint_envelopes SET envelope_hash=?1 WHERE sequence>=2000000"}, {"id": "lv-h8928d49246cd", "path": "crates/fractal-store/src/store/backup_ledgers.rs", "kind": "sql-template", "fingerprint": "0783930aa84e56d8b6563d8387b70ad63b424bdc19a08715e60f9f7ebe73fdb5", "owner": "fractal_store::store::backup_ledgers", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 145, "excerpt": "INSERT OR IGNORE INTO local_message_marks(message, kind, consumer, recorded_at_unix_ms, receipt) \\ VALUES ({0}, {1}, {2}, {3}, {4})"}, diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index a8009f56104..9b4a4c3d5ab 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -245,7 +245,11 @@ a mission ID without `mission/`; `write` takes a subject, or a registered family 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 yet bind a run actor or enforce it. +the daemon does not yet bind a run actor or enforce it. An older member would quarantine a +revision that declares it (it hashes the mission without the field), so publication holds such a +revision until every member advertises `mission_authority=1` on its `daemon.started` +(`mission-authority-unavailable`, also an `apply --dry-run` blocker), as for `capacity` +([seat lanes](seat-lanes.md)). A mission can contain direct declarations. Direct agents in the mission can revise the complete mission. diff --git a/release-notes/mission-run-authority-block.json b/release-notes/mission-run-authority-block.json index bdf822d6d4f..91c1df15a69 100644 --- a/release-notes/mission-run-authority-block.json +++ b/release-notes/mission-run-authority-block.json @@ -4,7 +4,7 @@ "replay": {"status": "none", "detail": "No claim kind or projection change. `mission.published` bodies of missions that declare the block carry a new optional `authority` array in the mission spec; absent otherwise."}, "schema": {"status": "none", "detail": "No table, index or migration change."}, "rules": {"status": "changed", "detail": "New declared authority grammar for mission runs. Nothing binds a run actor or enforces it in this change; later changes bind program steps to `mission-run/RUN` and enforce the block intersected with the starter's authority."}, - "compatibility": {"status": "changed", "detail": "An undeclared mission's revision hash is unchanged (the field is omitted when empty; pinned by an existing hash test). A member on an older build refuses a mission revision that declares `authority` as an unknown mission child, so publish the block only once every active member runs this build, as for program steps."}, + "compatibility": {"status": "changed", "detail": "An undeclared mission's revision hash is unchanged (the field is omitted when empty; pinned by an existing hash test). Each daemon advertises `mission_authority=1` on `daemon.started`; a new revision that declares `authority` is refused with `mission-authority-unavailable` (and shown as an `apply --dry-run` blocker) while any current member has not advertised it, the barrier `capacity` already uses, now one table of mission-field barriers. An older CLI refuses a file that declares `authority` at apply as an unknown mission child."}, "downtime": {"status": "none", "detail": "No restart or migration beyond a normal deploy."}, "manual": {"status": "none", "detail": "No operator action is required."}, "recovery": {"status": "none", "detail": "Remove the `authority` block and publish again to return to an undeclared revision."} From 31de4e6a07ae748a584d251913be1b67a36dfec2 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:40:58 +0100 Subject: [PATCH 3/6] Compare authority refusal codes as strings --- crates/fractal-missions-base/src/mission.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index 0c2884286f0..7670d6dad34 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -3330,7 +3330,7 @@ mod tests { fn authority_error(body: &str) -> String { let source = format!(r#"mission "land" state="ready" {{ goal "Land."; step "work" {{ agentless; }}; {body} }}"#); - super::parse_missions(&source.parse().unwrap(), "node").unwrap_err().code + super::parse_missions(&source.parse().unwrap(), "node").unwrap_err().code.to_string() } #[test] From 49684298c50d7f5070188468553f68574428ec33 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:45:50 +0100 Subject: [PATCH 4/6] Report every authority refusal mismatch; match the barrier message --- crates/fractal-missions-base/src/mission.rs | 7 +++++-- crates/fractal-store/src/store/mission_capacity.rs | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/fractal-missions-base/src/mission.rs b/crates/fractal-missions-base/src/mission.rs index 7670d6dad34..33a1fc3b433 100644 --- a/crates/fractal-missions-base/src/mission.rs +++ b/crates/fractal-missions-base/src/mission.rs @@ -3362,10 +3362,11 @@ mod tests { #[test] fn mission_authority_refusals() { + let mut mismatches = Vec::new(); for (body, code) in [ (r#"authority"#, "empty-authority"), (r#"authority {}"#, "empty-authority"), - (r#"authority "none""#, "unexpected-value"), + (r#"authority "none""#, "invalid-node"), (r#"authority { write "resource/a"; }; authority { write "resource/b"; }"#, "duplicate-mission-field"), (r#"authority { write "resource/a"; write "resource/a"; }"#, "duplicate-authority"), (r#"authority { move "agent/a"; }"#, "invalid-authority"), @@ -3381,7 +3382,9 @@ mod tests { (r#"authority { publish "/*"; }"#, "invalid-authority-pattern"), (r#"step "outer" { mission "inner" { goal "Inner."; authority { write "resource/a"; }; step "x" { agentless; }; }; }"#, "nested-mission-authority"), ] { - assert_eq!(authority_error(body), code, "{body}"); + let actual = authority_error(body); + if actual != code { mismatches.push(format!("{body}: {actual}, expected {code}")); } } + assert!(mismatches.is_empty(), "{mismatches:#?}"); } } diff --git a/crates/fractal-store/src/store/mission_capacity.rs b/crates/fractal-store/src/store/mission_capacity.rs index 3a9eb7cbde2..92a68550095 100644 --- a/crates/fractal-store/src/store/mission_capacity.rs +++ b/crates/fractal-store/src/store/mission_capacity.rs @@ -260,7 +260,7 @@ mission "authorized" state="ready" { sync(&peer, &store); let refusal = publish(&store, AUTHORIZED, "older").unwrap_err(); assert_eq!(refusal.code, "mission-authority-unavailable"); - assert!(refusal.message.contains("has not advertised mission_authority=1; declare authority on a mission once every member is upgraded"), + assert!(refusal.message.contains("has not advertised mission_authority=1); declare authority on a mission once every member is upgraded"), "{}", refusal.message); assert!(store.mission_spec("authorized", None).unwrap().is_none()); claim(&peer, "daemon/birch", "daemon.started", None, From 76665ee920aac22e85c966c35a20dbb895f44b46 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:36:30 +0100 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20run=20authority=20in=20program=20en?= =?UTF-8?q?gine=20=C2=A73?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/st3/program-engine.md | 50 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md index a7e2526b377..1a1a7856ebf 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -184,6 +184,56 @@ the authority of whoever started the run, and nothing else. The daemon writes jo that actor. Changing `authority` is a mission revision, so factory classifies it as an authority-class change. +#### Run authority + +**Grammar.** A top-level mission may declare one `authority` block in the shape of the agent +`*-authority` blocks, one `VERB "PATTERN"` rule per line. `publish`, `start`, `revise` and `cancel` +take mission IDs exactly as `mission-authority` does (no `mission/` prefix); `write` takes a +subject, or a registered family alone, before `/*`. A pattern is exact or a terminal `/*` +namespace covering everything under it. Parsing refuses the block in a nested or loop body, a +repeated block or rule, an empty block, an unknown verb and any other `*`. There is no `"none"` +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`. + +**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. +The starter's authority is evaluated when the run writes, not copied at start, so a starter that +loses authority takes it from its running runs. + +**Effective authority** of `mission-run/RUN` is the `authority` of its current generation's +revision intersected with its starter's. A run starter contributes its own effective authority, +recursively, so a child never exceeds its parent. A person, agent or daemon starter contributes all +authority in free mode, and the rules that restrict it (`rule.set` in enforce mode) decide each of +the run's writes as well. With exact-or-namespace patterns the intersection is exact: for each +pair with one verb, the narrower pattern if one covers the other, nothing otherwise. + +**Credential.** For each start or resume of an attempt the engine mints a store capability of kind +`program-run` whose subject is the step run and whose incarnation is the attempt (the secret is +hashed at rest and never journaled), and passes it as `ST_RUN_CREDENTIAL` in the `start` +message's `env`. The host spreads the run env last, so an effect cannot replace it. + +**Binding.** The ancestry walk reads `ST_RUN_CREDENTIAL` from each ancestor's exec-time environment +beside `ST_AGENT`, nearest ancestor first; one environment carrying both binds neither. The daemon +binds a request to `mission-run/RUN` only while the credential's attempt is its step run's current, +live attempt, so the credential dies with its attempt and needs no revocation; one that no longer +binds leaves the caller unverifiable. A run-bound request follows the bound-agent rules: no internal +routes, no person headers, and `actor`/`requester` must be the run. The CLI defaults its actor to +the run when the credential is present. The same-UID boundary is cooperative, as for agents. + +**Journal.** The engine writes `program.step` claims as `mission-run/RUN`; a run may always journal +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. + ### 4. Versioning A program's revision is its bundle hash: `sha256` is part of the step definition, so it enters the From 08bbc16b9214e7e4746c82efc1d9e5b04b4162c2 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:27:35 +0100 Subject: [PATCH 6/6] store: drop an explicit deref clippy flags in the mission field barrier --- crates/fractal-store/src/store/mission_capacity.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fractal-store/src/store/mission_capacity.rs b/crates/fractal-store/src/store/mission_capacity.rs index 92a68550095..9b20f0a39ae 100644 --- a/crates/fractal-store/src/store/mission_capacity.rs +++ b/crates/fractal-store/src/store/mission_capacity.rs @@ -88,7 +88,7 @@ pub(super) fn validate_mission( fn validate_barrier(connection: &Connection, origin: &str, barrier: &Barrier) -> Result<(), FractalError> { let Barrier { feature, code, later, .. } = barrier; - let refused = |reason: String| FractalError::new(*code, reason); + let refused = |reason: String| FractalError::new(code, reason); let membership = fleet_membership_tx(connection).map_err(internal)?; for member in membership.incarnations().filter(|member| member.end.is_none()) { // This host runs this build.