From c7e153261e677fc6c94fff48ce5d23ccb559e155 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:22:34 +0100 Subject: [PATCH 01/16] Bind requests carrying a run credential to mission-run/RUN --- crates/fractal-api-core/src/api.rs | 65 +++++++++++++- crates/fractal-api-core/src/api/tests.rs | 84 +++++++++++++++++++ .../src/api/write_principal.rs | 11 ++- crates/fractal-drivers/src/contracts.rs | 2 +- crates/fractal-store/src/store.rs | 3 + .../src/store/run_credentials.rs | 80 ++++++++++++++++++ .../src/store/run_credentials_tests.rs | 48 +++++++++++ crates/fractal/src/main.rs | 30 +++++-- crates/fractal/src/main_tests.rs | 11 +++ docs/st3/large-value-sites.json | 1 + docs/st3/mission-graph-runtime.md | 8 +- .../run-credential-binds-mission-run.json | 11 +++ 12 files changed, 339 insertions(+), 15 deletions(-) create mode 100644 crates/fractal-store/src/store/run_credentials.rs create mode 100644 crates/fractal-store/src/store/run_credentials_tests.rs create mode 100644 release-notes/run-credential-binds-mission-run.json diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 95db44c6e2f..00407ac0f54 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -7477,6 +7477,9 @@ async fn serve_reserved_unix_with_lookup_ready( if let Some(agent) = ancestry.agent() { request.extensions_mut().insert(BoundAgent(agent.to_owned())); } + if let HarnessAncestry::Run(credential) = &ancestry { + request.extensions_mut().insert(credential.clone()); + } if matches!(ancestry, HarnessAncestry::Unavailable) { request.extensions_mut().insert(HarnessAncestry::Unavailable); } @@ -7520,11 +7523,42 @@ mod gateway_listener_tests; #[derive(Clone, Debug, Default, PartialEq, Eq)] pub enum HarnessAncestry { Bound(String), + /// A program effect: an ancestor carries the run credential its attempt was started with + /// (docs/st3/program-engine.md §3). The guard resolves it to `mission-run/RUN`. + Run(RunCredential), Person, #[default] Unavailable, } +/// A run credential read from a caller's exec-time environment. Its `Debug` never shows the +/// secret, so ancestry diagnostics cannot leak it. +#[derive(Clone, PartialEq, Eq)] +pub struct RunCredential(String); + +impl RunCredential { + pub fn new(secret: impl Into) -> Self { + Self(secret.into()) + } + + pub fn secret(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Debug for RunCredential { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("RunCredential(..)") + } +} + +/// The run a request's credential binds, set by the serving guard and never by a client. +#[derive(Clone, Debug)] +pub struct BoundRun(pub crate::store::run_credentials::RunBinding); + +/// The environment variable a program attempt's effects carry their run credential in. +pub const RUN_CREDENTIAL_ENV: &str = "ST_RUN_CREDENTIAL"; + impl HarnessAncestry { fn agent(&self) -> Option<&str> { match self { Self::Bound(agent) => Some(agent), _ => None } @@ -7547,6 +7581,15 @@ impl HarnessAncestry { fn of_environment<'a>(lookup: &impl Fn(&str) -> Option<&'a str>) -> Self { let primary = lookup("ST_AGENT"); let legacy = lookup("FRACTAL_AGENT"); + // A program effect acts as its run, never as an agent too: one environment naming both + // cannot be bound to either. + if let Some(credential) = lookup(RUN_CREDENTIAL_ENV) { + return if credential.is_empty() || primary.is_some() || legacy.is_some() { + Self::Unavailable + } else { + Self::Run(RunCredential::new(credential)) + }; + } if primary.is_none() && legacy.is_none() { return Self::Person; } @@ -8006,6 +8049,16 @@ async fn guard_bound_actor( request.extensions_mut().remove::(); let ancestry_diagnostic = ancestry_diagnostics::RequestDiagnostic::capture(&request); let bound_agent = request.extensions().get::().map(|agent| agent.0.clone()); + // A run credential binds only while its attempt runs; one that no longer binds leaves the + // caller unverifiable, so it writes nothing. + request.extensions_mut().remove::(); + if let Some(credential) = request.extensions().get::().cloned() { + let lookup = store.clone(); + match tokio::task::spawn_blocking(move || lookup.run_binding(credential.secret())).await { + Ok(Ok(binding)) => { request.extensions_mut().insert(BoundRun(binding)); } + _ => { request.extensions_mut().insert(HarnessAncestry::Unavailable); } + } + } let client_request = request.uri().path().starts_with("/v1/client/"); // This guard is outside all router layers. Authenticate here, before ancestry admission, // independently of the ingress observe/enforce rollout. @@ -8063,7 +8116,9 @@ pub async fn guard_bound_request( bound_agent: Option<&str>, store: &Store, ) -> Result, ApiError> { - let Some(bound_agent) = bound_agent else { + // A bound run follows the rules of a bound agent, as the actor `mission-run/RUN`. + let bound_run = request.extensions().get::().map(|run| run.0.run.clone()); + let Some(bound_agent) = bound_agent.or(bound_run.as_deref()) else { return if matches!(request.extensions().get::(), Some(HarnessAncestry::Unavailable)) { guard_unverifiable_request(request).await } else { @@ -8088,9 +8143,11 @@ pub async fn guard_bound_request( && !client_v0::administrative_get(&request) { return Ok(request); } - let bound_identity = public_identity( - store, crate::store::identities::IdentityKind::Agent, bound_agent, - )?.identity; + let bound_identity = if bound_run.as_deref() == Some(bound_agent) { + bound_agent.to_owned() + } else { + public_identity(store, crate::store::identities::IdentityKind::Agent, bound_agent)?.identity + }; // Free mode lets an agent do what its person may do, but always as itself: a harness never // names a person, or another agent, as the local client-v0 actor. for person in [client_v0::LOCAL_PERSON_HEADER, wire_names::LOCAL_PERSON_HEADER_ALIAS] diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index f2052840cb8..58dd5731b05 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -27,6 +27,90 @@ fn incomplete_linux_environment_is_unavailable() { } } +#[cfg(target_os = "linux")] +#[test] +fn a_run_credential_in_the_environment_binds_a_run_and_never_an_agent_too() { + for (case, environment, expected) in [ + ("credential", b"ST_RUN_CREDENTIAL=secret\0".as_slice(), HarnessAncestry::Run(RunCredential::new("secret"))), + ("empty credential", b"ST_RUN_CREDENTIAL=\0".as_slice(), HarnessAncestry::Unavailable), + ("credential and agent", b"ST_RUN_CREDENTIAL=secret\0ST_AGENT=agent/fixture/worker\0".as_slice(), + HarnessAncestry::Unavailable), + ("credential and legacy agent", b"FRACTAL_AGENT=agent/fixture/worker\0ST_RUN_CREDENTIAL=secret\0".as_slice(), + HarnessAncestry::Unavailable), + ] { + assert_eq!(HarnessAncestry::of_linux_environment(environment), expected, "{case}"); + } + assert_eq!(format!("{:?}", RunCredential::new("secret")), "RunCredential(..)"); +} + +/// An effect's descendant that clears its own environment is still bound: the walk reads each +/// ancestor's exec-time environment and finds the credential the effect was started with. +#[cfg(target_os = "linux")] +#[test] +fn a_program_effects_descendant_binds_its_run() { + use std::io::BufRead; + let mut effect = std::process::Command::new("sh") + .args(["-c", "env -i MARKER=1 sleep 10 & echo $!; wait"]) + .env("ST_RUN_CREDENTIAL", "secret") + .env_remove("ST_AGENT") + .env_remove("FRACTAL_AGENT") + .stdout(std::process::Stdio::piped()) + .spawn() + .unwrap(); + let mut line = String::new(); + std::io::BufReader::new(effect.stdout.as_mut().unwrap()).read_line(&mut line).unwrap(); + let pid: u32 = line.trim().parse().unwrap(); + let peer = fractal_runtime::PeerProcessIdentity::capture_pid(pid).unwrap(); + let ancestry = harness_ancestor(pid, Some(&peer)); + effect.kill().unwrap(); + let _ = std::process::Command::new("kill").arg(pid.to_string()).status(); + effect.wait().unwrap(); + assert_eq!(ancestry, HarnessAncestry::Run(RunCredential::new("secret"))); +} + +/// A request whose ancestry carries a live run credential is attributed to `mission-run/RUN`, +/// may not name another actor, and writes nothing once its attempt has ended. +#[tokio::test] +async fn a_run_credential_binds_requests_to_its_run() { + let root = tempfile::tempdir().unwrap(); + let state = state(root.path()); + let step = state.store.start_program_step_for_tests(); + let secret = state.store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + let app = router(state.clone()).layer(from_fn_with_state((state.store.clone(), None), guard_bound_actor)); + let post = |subject: &str, actor: Option<&str>| { + let mut body = json!({"subject": subject, "kind": "custom.example.recorded", "fields": {}}); + if let Some(actor) = actor { body["actor"] = json!(actor); } + app.clone().oneshot(Request::builder().method("POST").uri("/v1/claims") + .header("content-type", "application/json") + .extension(RunCredential::new(secret.clone())) + .body(Body::from(body.to_string())).unwrap()) + }; + let response = post("custom/example/run-own", None).await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let claims = state.store.claims_for("custom/example/run-own", Some("custom.example.recorded")).unwrap(); + assert_eq!(claims.len(), 1); + assert_eq!(claims[0].actor.as_deref(), Some(step.run.as_str())); + assert!(step.run.starts_with("mission-run/")); + + let named = post("custom/example/run-named", Some(&step.run)).await.unwrap(); + assert_eq!(named.status(), StatusCode::OK); + for actor in ["person/operator", "agent/example/worker"] { + let before = state.store.index().unwrap(); + let refused = post("custom/example/run-foreign", Some(actor)).await.unwrap(); + assert_eq!(refused.status(), StatusCode::UNPROCESSABLE_ENTITY, "{actor}"); + let body = to_bytes(refused.into_body(), usize::MAX).await.unwrap(); + assert_eq!(serde_json::from_slice::(&body).unwrap()["code"], "foreign-agent-actor"); + assert_eq!(state.store.index().unwrap(), before); + } + + // The attempt ends: the credential no longer binds, and the caller writes nothing. + state.store.set_step_run_state_for_tests(&step.subject, "completed", 1); + let before = state.store.index().unwrap(); + let ended = post("custom/example/run-ended", None).await.unwrap(); + assert_eq!(ended.status(), StatusCode::UNPROCESSABLE_ENTITY); + assert_eq!(state.store.index().unwrap(), before); +} + #[cfg(target_os = "linux")] #[test] fn empty_process_environment_is_unavailable() { diff --git a/crates/fractal-api-core/src/api/write_principal.rs b/crates/fractal-api-core/src/api/write_principal.rs index 982d128826b..c710258a228 100644 --- a/crates/fractal-api-core/src/api/write_principal.rs +++ b/crates/fractal-api-core/src/api/write_principal.rs @@ -15,10 +15,14 @@ impl WritePrincipal { } let agent = request.extensions().get::().map(|agent| agent.0.as_str()) .or_else(|| request.extensions().get::().map(|peer| peer.agent.as_str())); - if let Some(agent) = agent { - let actor = public_identity( + // A program effect writes as its run, `mission-run/RUN`. + let actor = match request.extensions().get::() { + Some(run) => Some(run.0.run.clone()), + None => agent.map(|agent| public_identity( &state.store, crate::store::identities::IdentityKind::Agent, agent, - )?.identity; + ).map(|identity| identity.identity)).transpose()?, + }; + if let Some(actor) = actor { if let Some(session) = request.extensions().get::() && (session.pairing_grant.is_some() || client_v0::acting_party(session)) && !is_bound_actor(&state.store, &session.authority_actor, &actor)? @@ -134,6 +138,7 @@ pub(super) async fn bind_request( .is_some_and(|session| session.pairing_grant.is_some()); if !paired && request.extensions().get::().is_none() + && request.extensions().get::().is_none() && request.extensions().get::().is_none() && !matches!(request.extensions().get::(), Some(HarnessAncestry::Unavailable)) { diff --git a/crates/fractal-drivers/src/contracts.rs b/crates/fractal-drivers/src/contracts.rs index bf9fce52851..dd0ab934880 100644 --- a/crates/fractal-drivers/src/contracts.rs +++ b/crates/fractal-drivers/src/contracts.rs @@ -23,7 +23,7 @@ fn identity_env(name: &str) -> bool { matches!( name, "ST_AGENT" | "ST_SUPERVISOR" | "ST3_SUBJECT" | "ST3_INCARNATION" - | "ST_GATE_CAPABILITY" | "ST3_TERMINAL_CAPABILITY" + | "ST_GATE_CAPABILITY" | "ST3_TERMINAL_CAPABILITY" | "ST_RUN_CREDENTIAL" ) || name.starts_with("ST_DRIVER_") || ((name.starts_with("ST_CLAUDE_") || name.starts_with("ST_PI_CHANNEL_") diff --git a/crates/fractal-store/src/store.rs b/crates/fractal-store/src/store.rs index 5c06094825e..0584fb1f516 100644 --- a/crates/fractal-store/src/store.rs +++ b/crates/fractal-store/src/store.rs @@ -66,6 +66,7 @@ pub mod schedule_active_run_tests; pub mod seat_queue_plan_tests; pub mod schedule_standby; pub mod mission_capacity; +pub mod run_credentials; pub mod marks_switch; pub mod glasses; pub mod mailbox_wakes; @@ -344,6 +345,8 @@ mod status_summary_tests; #[cfg(test)] mod run_eta_tests; #[cfg(test)] +mod run_credentials_tests; +#[cfg(test)] mod run_eta_review_tests; pub mod client_summary; pub mod unread_mail; diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs new file mode 100644 index 00000000000..813578dece3 --- /dev/null +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -0,0 +1,80 @@ +//! Run credentials (docs/st3/program-engine.md §3): the engine mints one per program attempt and +//! passes it to the attempt's effects as `ST_RUN_CREDENTIAL`; the daemon binds a request carrying +//! it to `mission-run/RUN`. It is a local, hashed store capability whose subject is the step run +//! and whose incarnation is the attempt. It needs no revocation: it binds only while that attempt +//! is the step run's current one and the step is still live, so it dies with its attempt. +use super::*; + +/// The capability kind of a run credential. +pub const RUN_CREDENTIAL_KIND: &str = "program-run"; + +/// The step statuses in which a program attempt runs. +const LIVE_STEP_STATES: [&str; 3] = ["ready", "working", "blocked"]; + +/// The actor a run credential binds: `run` is `mission-run/RUN`. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RunBinding { + pub run: String, + pub step_run: String, + pub attempt: u32, +} + +fn ended(reason: &str) -> FractalError { + FractalError::new("run-credential-ended", format!("the run credential no longer binds: {reason}")) +} + +impl Store { + /// Mint the credential one program attempt's effects carry, valid for at most `lifetime_ms`. + pub fn issue_run_credential(&self, step_run: &str, attempt: u32, lifetime_ms: u64) -> Result { + self.issue_capability(RUN_CREDENTIAL_KIND, step_run, Some(&attempt.to_string()), lifetime_ms) + .map(|(secret, _)| secret) + } + + /// The run `secret` acts as, while its attempt is its step run's current, live attempt. + pub fn run_binding(&self, secret: &str) -> Result { + let capability = self.capability(secret, RUN_CREDENTIAL_KIND)?; + let attempt = capability.incarnation_id.as_deref().and_then(|attempt| attempt.parse().ok()) + .ok_or_else(|| FractalError::new("invalid-capability", "the run credential names no attempt"))?; + let step = self.step_run(&capability.subject).map_err(internal)? + .ok_or_else(|| ended("its step run does not exist"))?; + if step.attempt != attempt { + return Err(ended("its attempt is no longer the step run's current attempt")); + } + if !LIVE_STEP_STATES.contains(&step.status.as_str()) { + return Err(ended("its step run is no longer running")); + } + Ok(RunBinding { run: step.run, step_run: step.subject, attempt }) + } +} + +#[cfg(any(test, feature = "test-support"))] +impl Store { + /// Put a step run at `status` and `attempt`, as a dispatch, retry or end would. + pub fn set_step_run_state_for_tests(&self, subject: &str, status: &str, attempt: u32) { + self.connection.write().execute( + /* sqlite-large-value: allow-for-now lv-h2ec0013f005e owner=fractal_store::store::run_credentials TODO=bound-or-reference-values-and-reclamation */ "UPDATE step_runs SET status=?2, attempt=?3 WHERE subject=?1", + params![subject, status, attempt], + ).unwrap(); + } + + /// Publish a one-step mission, start a run of it and put its step at attempt 1, working. + pub fn start_program_step_for_tests(&self) -> StepRunView { + crate::store::tests_shared_missions::publish_carry(self, r#"version 2 +mission "land" state="ready" { + goal "Land one change." + step "program" { agentless } +}"#, "declare-land"); + let run = self.create_mission_run_inner(&crate::model::MissionRunRequest { + mission: "land".into(), + revision: None, + workspace: "/tmp".into(), + requester: Some("person/operator".into()), + mode: None, + inputs: Default::default(), + idempotency_key: "run-land".into(), + }, None, None, None).unwrap(); + let step = run.steps.into_iter().find(|step| step.step == "program").unwrap(); + self.set_step_run_state_for_tests(&step.subject, "working", 1); + step + } +} diff --git a/crates/fractal-store/src/store/run_credentials_tests.rs b/crates/fractal-store/src/store/run_credentials_tests.rs new file mode 100644 index 00000000000..aa8107f174c --- /dev/null +++ b/crates/fractal-store/src/store/run_credentials_tests.rs @@ -0,0 +1,48 @@ +use super::run_credentials::{RUN_CREDENTIAL_KIND, RunBinding}; +use super::*; + +fn running_step() -> (Store, StepRunView) { + let store = Store::open_memory("node").unwrap(); + let step = store.start_program_step_for_tests(); + (store, step) +} + +fn set_step(store: &Store, subject: &str, status: &str, attempt: u32) { + store.set_step_run_state_for_tests(subject, status, attempt); +} + +#[test] +fn a_run_credential_binds_its_run_only_while_its_attempt_runs() { + let (store, step) = running_step(); + let secret = store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + // Scoped to the step run and attempt. + let capability = store.capability(&secret, RUN_CREDENTIAL_KIND).unwrap(); + assert_eq!((capability.subject.as_str(), capability.incarnation_id.as_deref()), (step.subject.as_str(), Some("1"))); + assert_eq!(store.run_binding(&secret).unwrap(), RunBinding { + run: step.run.clone(), + step_run: step.subject.clone(), + attempt: 1, + }); + assert!(step.run.starts_with("mission-run/")); + + // A retry is a new attempt: the old credential dies, the new one binds. + set_step(&store, &step.subject, "ready", 2); + assert_eq!(store.run_binding(&secret).unwrap_err().code, "run-credential-ended"); + let retried = store.issue_run_credential(&step.subject, 2, 60_000).unwrap(); + assert_eq!(store.run_binding(&retried).unwrap().attempt, 2); + + // The attempt's end ends its credential. + set_step(&store, &step.subject, "completed", 2); + assert_eq!(store.run_binding(&retried).unwrap_err().code, "run-credential-ended"); +} + +#[test] +fn a_run_credential_is_refused_when_unknown_expired_or_of_another_kind() { + let (store, step) = running_step(); + assert_eq!(store.run_binding("not-a-credential").unwrap_err().code, "invalid-capability"); + let (gate, _) = store.issue_capability("gate-result", &step.subject, Some("1"), 60_000).unwrap(); + assert_eq!(store.run_binding(&gate).unwrap_err().code, "invalid-capability"); + let expired = store.issue_run_credential(&step.subject, 1, 0).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(5)); + assert_eq!(store.run_binding(&expired).unwrap_err().code, "expired-capability"); +} diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index 3419819b32c..c092e3d5196 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -10146,8 +10146,9 @@ fn execution_client(endpoint: &Endpoint, actor: String) -> Result { .with_outage_wait(DAEMON_WAIT.get().copied().unwrap_or_default(), true)) } -/// The actor of a terminal or client-v0 command: an explicit person or agent, else the harness's -/// own seat, else the configured person. Free mode lets an agent act, always as itself. +/// The actor of a terminal or client-v0 command: an explicit person, agent or run, else the +/// harness's own seat, else the run a program effect acts as, else the configured person. Free +/// mode lets an agent act, always as itself. fn configured_actor( explicit: Option<&str>, configured: Option<&str>, @@ -10161,9 +10162,23 @@ fn configured_actor( { return Ok(own); } + if let Some(run) = run_actor( + fractal_drivers::contracts::env("ST_RUN_CREDENTIAL").as_deref(), + fractal_drivers::contracts::env("ST_MISSION_RUN").as_deref(), + ) { + return Ok(run); + } configured_human(None, configured, command) } +/// A program effect carries its attempt's run credential and acts as its run +/// (docs/st3/program-engine.md §3); the daemon binds the credential to the same run. +fn run_actor(credential: Option<&str>, run: Option<&str>) -> Option { + credential.filter(|credential| !credential.is_empty())?; + let run = run.map(|run| run.trim_start_matches("mission-run/")).filter(|run| !run.is_empty())?; + Some(format!("mission-run/{run}")) +} + fn configured_human( explicit: Option<&str>, configured: Option<&str>, @@ -16167,11 +16182,14 @@ fn planning_session_intent( publication_document(session) } -/// A complete `person/NAME` or an agent. Free mode lets an agent do what its person may do, and -/// the daemon still records the agent itself as the actor. +/// A complete `person/NAME`, an agent, or a `mission-run/RUN` a program effect acts as. Free mode +/// lets an agent do what its person may do, and the daemon still records the agent itself as the +/// actor; a run is only what its credential binds. fn parse_actor_subject(actor: &str) -> std::result::Result { - if actor.starts_with("agent/") && actor.len() > "agent/".len() { - return Ok(actor.to_owned()); + for family in ["agent/", "mission-run/"] { + if actor.starts_with(family) && actor.len() > family.len() { + return Ok(actor.to_owned()); + } } parse_person_subject(actor) } diff --git a/crates/fractal/src/main_tests.rs b/crates/fractal/src/main_tests.rs index ea9929b0bf7..99257ee58cc 100644 --- a/crates/fractal/src/main_tests.rs +++ b/crates/fractal/src/main_tests.rs @@ -1799,6 +1799,17 @@ fn a_harness_cannot_act_as_another_agent() { ); } +#[test] +fn a_program_effect_acts_as_its_run() { + assert_eq!(run_actor(Some("secret"), Some("abc")).as_deref(), Some("mission-run/abc")); + assert_eq!(run_actor(Some("secret"), Some("mission-run/abc")).as_deref(), Some("mission-run/abc")); + assert_eq!(run_actor(None, Some("abc")), None); + assert_eq!(run_actor(Some(""), Some("abc")), None); + assert_eq!(run_actor(Some("secret"), Some("")), None); + assert_eq!(parse_actor_subject("mission-run/abc").as_deref(), Ok("mission-run/abc")); + assert!(parse_actor_subject("mission-run/").is_err()); +} + #[test] fn execution_actor_options_parse_and_fail_closed() { for (path, tail) in [ diff --git a/docs/st3/large-value-sites.json b/docs/st3/large-value-sites.json index df875d31391..bb869603df6 100644 --- a/docs/st3/large-value-sites.json +++ b/docs/st3/large-value-sites.json @@ -1576,6 +1576,7 @@ {"id": "lv-h2e8ef1166b77", "path": "crates/fractal-store/src/store/message_point_qualification_shared_delivery.rs", "kind": "unresolved-sql-sink", "fingerprint": "62e9f69e82a20355d1df3e3832188e2faeca99b9ca03eecd7d53916fd4b3c6b0", "owner": "fractal_store::store::message_point_qualification_shared_delivery", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 401, "excerpt": "sql , params ! [ normalized , bare , subject ] , | row | { Ok ( ( row . get ( 0 ) ? , row . get ( 1 ) ? , row . get ( 2 ) ? ) ) }"}, {"id": "lv-h2eace97aaa95", "path": "crates/fractal-delivery/src/store/message_point_qualification/tests.rs", "kind": "sql-template", "fingerprint": "63588b9cdab6795d7e7ccfe87a9cb74c1ba477e9978064bbc5bc90bb521215f5", "owner": "fractal_delivery::store::message_point_qualification::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 598, "excerpt": "INSERT INTO desired(subject,kind,revision,claim_id,body) VALUES('message/selected','message','r','sent',?1)"}, {"id": "lv-h2eb3b0d71dfa", "path": "crates/smallclaims/src/store/checkpoint.rs", "kind": "unresolved-sql-sink", "fingerprint": "d4a2e52a9158cd5573935d2222690a70c9ecc8d502b20cf5593f4635fbd86519", "owner": "smallclaims::store::checkpoint", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 415, "excerpt": "[ & claim . id ]"}, + {"id": "lv-h2ec0013f005e", "path": "crates/fractal-store/src/store/run_credentials.rs", "kind": "sql-template", "fingerprint": "2aeb47d036569eb578a251a37dd39dd6a61bf061d06b53ee0fa291cc3b24f873", "owner": "fractal_store::store::run_credentials", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 55, "excerpt": "UPDATE step_runs SET status=?2, attempt=?3 WHERE subject=?1"}, {"id": "lv-h2eccd4ddae86", "path": "crates/fractal-store/src/store/tests.rs", "kind": "sql-template", "fingerprint": "94dedd5db98c1e80254ce79345f6724cee48d90b3acfe79f248d3adebe5f661a", "owner": "fractal_store::store::tests", "todo": "bound-or-reference-values-and-reclamation", "disposition": "allow-for-now", "line_hint": 1367, "excerpt": "UPDATE projection_health SET status='stale' WHERE aggregate='graph'"}, {"id": "lv-h2ecd69fcc8f6", "path": "crates/fractal-store/src/store/completion_state_tests.rs", "kind": "unresolved-sql-sink", "fingerprint": "dc372daedd324b47f1261987900c1a08eff3ccdf253bfcf666d0ebecf17142ad", "owner": "fractal_store::store::completion_state_tests", "todo": "trace-statement-and-value-representation", "disposition": "allow-for-now", "line_hint": 241, "excerpt": "& store , | _ | ( )"}, {"id": "lv-h2ed47d41170d", "path": "crates/fractal-store/src/store/message_canonical_sort_tests.rs", "kind": "sql-template", "fingerprint": "43a749b59671cc07d56fb696fafe0ca0baf98feb73d187d0cfa440c237cf56ef", "owner": "fractal_store::store::message_canonical_sort_tests", "todo": "prepare-body-and-predecessor-values", "disposition": "allow-for-now", "line_hint": 329, "excerpt": "UPDATE claims SET accepted_at_unix_ms=x'00' WHERE id='healthy'"}, diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index 9b4a4c3d5ab..5e1895aaaf4 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -245,7 +245,13 @@ 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. An older member would quarantine a +the daemon does not enforce it yet. 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 +current, live attempt; one that no longer binds leaves the caller unverifiable, and an +environment that carries both a credential and `ST_AGENT` binds neither. The CLI defaults its +actor to `mission-run/$ST_MISSION_RUN` when the credential is present. 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` diff --git a/release-notes/run-credential-binds-mission-run.json b/release-notes/run-credential-binds-mission-run.json new file mode 100644 index 00000000000..b434d07fb82 --- /dev/null +++ b/release-notes/run-credential-binds-mission-run.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "A request whose caller's ancestry carries a live run credential (`ST_RUN_CREDENTIAL`) is bound to `mission-run/RUN`: it acts only as that run, its writes are attributed to it, and the credential dies with its program attempt", + "replay": {"status": "none", "detail": "No claim kind or projection change. Claims written by a run-bound request name `mission-run/RUN` as their actor, a subject the store already accepts as an actor."}, + "schema": {"status": "none", "detail": "No table or migration change. Run credentials are rows of the existing local `capabilities` table (kind `program-run`, subject the step run, incarnation the attempt; the secret is stored hashed)."}, + "rules": {"status": "changed", "detail": "New bound identity. The ancestry walk reads `ST_RUN_CREDENTIAL` beside `ST_AGENT`; the serving guard resolves it to its run while the attempt is the step run's current, live attempt, and otherwise leaves the caller unverifiable (writes refused). A run-bound request follows the bound-agent rules: no internal routes, no person header, and `actor`/`requester` must be the run (filled in when absent). An environment carrying both a credential and `ST_AGENT` binds neither. The CLI defaults its actor to `mission-run/$ST_MISSION_RUN` when the credential is present and accepts `--as mission-run/RUN`. Nothing mints credentials yet; the program engine does once it dispatches attempts."}, + "compatibility": {"status": "changed", "detail": "Processes without `ST_RUN_CREDENTIAL` are bound exactly as before. `ST_RUN_CREDENTIAL` joins the identity variables whose conflicting `FRACTAL_` spelling is ignored."}, + "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": "A caller refused for an ended credential runs again under its next attempt's credential."} +} From 727f22931e5e086c99b891677fc25b0d7c7c35cf Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 15:46:03 +0100 Subject: [PATCH 02/16] Publish the run credential fixture mission by its own name --- crates/fractal-store/src/store/run_credentials.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index 813578dece3..a48708fcb48 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -59,7 +59,7 @@ impl Store { /// Publish a one-step mission, start a run of it and put its step at attempt 1, working. pub fn start_program_step_for_tests(&self) -> StepRunView { - crate::store::tests_shared_missions::publish_carry(self, r#"version 2 + crate::store::tests_shared_seats::publish_mission(self, r#"version 2 mission "land" state="ready" { goal "Land one change." step "program" { agentless } From c311c6f199c1ea48bf9a1946ae1bad25fab86acd Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:28:13 +0100 Subject: [PATCH 03/16] Name the run credential variable in the store; a program waiting on a person stays bound --- crates/fractal-api-core/src/api.rs | 3 +-- crates/fractal-store/src/store/run_credentials.rs | 6 ++++-- crates/fractal-store/src/store/run_credentials_tests.rs | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index 00407ac0f54..b0cbd1e68e4 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -7556,8 +7556,7 @@ impl std::fmt::Debug for RunCredential { #[derive(Clone, Debug)] pub struct BoundRun(pub crate::store::run_credentials::RunBinding); -/// The environment variable a program attempt's effects carry their run credential in. -pub const RUN_CREDENTIAL_ENV: &str = "ST_RUN_CREDENTIAL"; +pub use crate::store::run_credentials::RUN_CREDENTIAL_ENV; impl HarnessAncestry { fn agent(&self) -> Option<&str> { diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index a48708fcb48..5b0e0fb99b7 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -7,9 +7,11 @@ use super::*; /// The capability kind of a run credential. pub const RUN_CREDENTIAL_KIND: &str = "program-run"; +/// The environment variable a program attempt's effects carry their run credential in. +pub const RUN_CREDENTIAL_ENV: &str = "ST_RUN_CREDENTIAL"; -/// The step statuses in which a program attempt runs. -const LIVE_STEP_STATES: [&str; 3] = ["ready", "working", "blocked"]; +/// The step statuses in which a program attempt runs, including one waiting on a person's answer. +const LIVE_STEP_STATES: [&str; 4] = ["ready", "working", "blocked", "waiting-person"]; /// The actor a run credential binds: `run` is `mission-run/RUN`. #[derive(Clone, Debug, Eq, PartialEq)] diff --git a/crates/fractal-store/src/store/run_credentials_tests.rs b/crates/fractal-store/src/store/run_credentials_tests.rs index aa8107f174c..17d180e07bc 100644 --- a/crates/fractal-store/src/store/run_credentials_tests.rs +++ b/crates/fractal-store/src/store/run_credentials_tests.rs @@ -30,6 +30,9 @@ fn a_run_credential_binds_its_run_only_while_its_attempt_runs() { assert_eq!(store.run_binding(&secret).unwrap_err().code, "run-credential-ended"); let retried = store.issue_run_credential(&step.subject, 2, 60_000).unwrap(); assert_eq!(store.run_binding(&retried).unwrap().attempt, 2); + // A program waiting on a person's answer still runs. + set_step(&store, &step.subject, "waiting-person", 2); + assert_eq!(store.run_binding(&retried).unwrap().attempt, 2); // The attempt's end ends its credential. set_step(&store, &step.subject, "completed", 2); From eab3ba912cfd72741de0beb8a2d8b8ce37d6ccab Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:29:08 +0100 Subject: [PATCH 04/16] Start each program attempt with its run credential and journal as the run --- .../fractal-reconcile/src/reconcile/program.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/fractal-reconcile/src/reconcile/program.rs b/crates/fractal-reconcile/src/reconcile/program.rs index 815817283fe..1ee0b6eefda 100644 --- a/crates/fractal-reconcile/src/reconcile/program.rs +++ b/crates/fractal-reconcile/src/reconcile/program.rs @@ -39,6 +39,9 @@ pub const RESULT_KEY: &str = "$result"; /// The step states in which the engine runs a program step's current attempt. A step waiting on /// a person keeps its program loaded (§11). pub const RUNNING_STATES: [&str; 4] = ["ready", "working", "blocked", "waiting-person"]; +/// How long a run credential stays valid at most. It binds only while its attempt runs, so this +/// only bounds a credential whose attempt the store never ends. +const RUN_CREDENTIAL_LIFETIME_MS: u64 = 7 * 24 * 60 * 60 * 1000; const HOST_SOURCE: &str = /* sqlite-large-value: allow-for-now lv-h344eb4debbcf owner=fractal_reconcile::reconcile::program TODO=trace-statement-and-value-representation */ include_str!("program_host.mjs"); /// How long a crashed host waits before its replacement starts, so a host that cannot start does /// not spin. @@ -148,6 +151,14 @@ impl ProgramEngine { Err(reason) => return Ok(Dispatched::Refused(reason)), }; let journal = self.journal(&dispatch.step, dispatch.attempt)?; + // The attempt's effects act as its run (docs/st3/program-engine.md §3). The credential + // binds only while this attempt is the step's live attempt, so its lifetime is a backstop; + // each start or resume mints a new one, and it never enters the journal or a log. + let mut env = dispatch.env.clone(); + env.insert( + crate::store::run_credentials::RUN_CREDENTIAL_ENV.to_owned(), + self.store.issue_run_credential(&dispatch.step, dispatch.attempt, RUN_CREDENTIAL_LIFETIME_MS)?, + ); let mut inner = self.lock(); if inner.running.get(&dispatch.step).is_some_and(|running| { running.dispatch.attempt == dispatch.attempt && inner.host.as_ref().is_some_and(|host| host.generation == running.generation) @@ -177,7 +188,7 @@ impl ProgramEngine { "module": module, "sha256": dispatch.program.bundle_hash, "payload": dispatch.payload, - "env": dispatch.env, + "env": env, "cwd": dispatch.cwd, "journal": journal, }); @@ -483,10 +494,12 @@ impl ProgramEngine { if let Some(child) = child { fields.insert("child".to_owned(), Value::from(child)); } + // The run writes its own journal, as the actor its effects act as. + let run = self.store.step_run(step).map_err(smallclaims::error::typed)?.map(|view| view.run); self.store.append_claim(&ClaimInput { subject: step.to_owned(), kind: JOURNAL_KIND.to_owned(), - actor: None, + actor: run, fields, evidence: Vec::new(), expected_subject: None, From be059d815969809e360e854491d5a79bd9b2bcd3 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:29:53 +0100 Subject: [PATCH 05/16] Test that a program's effects and journal act as its run --- .../src/reconcile/tests/program_engine.rs | 24 +++++++++++++++++++ .../program-attempts-act-as-their-run.json | 11 +++++++++ 2 files changed, 35 insertions(+) create mode 100644 release-notes/program-attempts-act-as-their-run.json diff --git a/crates/fractal-reconcile/src/reconcile/tests/program_engine.rs b/crates/fractal-reconcile/src/reconcile/tests/program_engine.rs index f45d5b266fa..3cfc134b2f6 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/program_engine.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/program_engine.rs @@ -21,6 +21,9 @@ const step = async (ctx, key, effect) => { export default { [Symbol.for('fractal.program.v1')]: async (ctx) => { const first = await step(ctx, 'first', async () => ({ attempt: ctx.attempt })) + if (ctx.payload.mode === 'credential') { + await step(ctx, 'who', async () => (await ctx.spawn(['sh', '-c', 'printf %s "$ST_RUN_CREDENTIAL"'])).stdout) + } if (ctx.payload.mode === 'busy') for (;;) {} const waited = await step(ctx, 'wait', async () => { const script = 'sleep 1000 & echo $$ $! > "$PIDS.tmp"; mv "$PIDS.tmp" "$PIDS"; while [ ! -e "$GATE" ]; do sleep 0.05; done; echo opened' @@ -221,3 +224,24 @@ fn an_unresponsive_worker_fails_only_its_own_step() { // The host itself stayed up: only the busy worker was terminated. assert_eq!(fixture.engine.host_pid(), host); } + +/// An effect carries a credential that binds its run while the attempt runs and no longer once +/// it ends, and the journal is written as the run (docs/st3/program-engine.md §3). +#[test] +fn a_programs_effects_and_journal_act_as_its_run() { + let fixture = fixture(Liveness::default()); + let started = fixture.start("credential", "credential"); + fixture.until("the effect reports its credential", || pids(&started.pids).is_some()); + let who = fixture.store.claims_for(&started.step, Some(JOURNAL_KIND)).unwrap().into_iter() + .find(|claim| claim.body["fields"]["key"] == "who" && claim.body["fields"]["status"] == "completed") + .unwrap(); + let credential = who.body["fields"]["value"].as_str().unwrap().to_owned(); + let binding = fixture.store.run_binding(&credential).unwrap(); + assert_eq!((binding.run.as_str(), binding.step_run.as_str(), binding.attempt), (started.run.as_str(), started.step.as_str(), 1)); + std::fs::write(&started.gate, "").unwrap(); + fixture.until("the program completes", || fixture.status(&started.step) == "completed"); + assert_eq!(fixture.store.run_binding(&credential).unwrap_err().code, "run-credential-ended"); + let claims = fixture.store.claims_for(&started.step, Some(JOURNAL_KIND)).unwrap(); + assert!(!claims.is_empty()); + assert!(claims.iter().all(|claim| claim.actor.as_deref() == Some(started.run.as_str())), "{claims:#?}"); +} diff --git a/release-notes/program-attempts-act-as-their-run.json b/release-notes/program-attempts-act-as-their-run.json new file mode 100644 index 00000000000..27cedfd30dd --- /dev/null +++ b/release-notes/program-attempts-act-as-their-run.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "summary": "Each program attempt starts with a run credential in its effects' environment (`ST_RUN_CREDENTIAL`), so their requests act as `mission-run/RUN`, and the program journal (`program.step`) is written as that run", + "replay": {"status": "changed", "detail": "`program.step` claims carry actor `mission-run/RUN` instead of no actor. Their fields and idempotency keys are unchanged."}, + "schema": {"status": "none", "detail": "No table or migration change. Each start or resume of an attempt adds one row to the local `capabilities` table (hashed secret)."}, + "rules": {"status": "changed", "detail": "Program effects no longer act as an unbound local person: the daemon binds them to their run while the attempt runs, and refuses their writes once it has ended."}, + "compatibility": {"status": "none", "detail": "Program steps are new in this milestone; no earlier journal exists."}, + "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": "A resumed attempt is started with a fresh credential; an effect from an ended attempt cannot write."} +} From 524d0e31f3f7ec820c66918d2c6fe3a6bfb1d907 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 16:51:25 +0100 Subject: [PATCH 06/16] Let the run credential fixture's mission write what its tests write --- crates/fractal-store/src/store/run_credentials.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index 5b0e0fb99b7..62f34cc17c2 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -59,11 +59,13 @@ impl Store { ).unwrap(); } - /// Publish a one-step mission, start a run of it and put its step at attempt 1, working. + /// Publish a one-step mission that may write `custom/example/*`, start a run of it and put its + /// step at attempt 1, working. pub fn start_program_step_for_tests(&self) -> StepRunView { crate::store::tests_shared_seats::publish_mission(self, r#"version 2 mission "land" state="ready" { goal "Land one change." + authority { write "custom/example/*"; } step "program" { agentless } }"#, "declare-land"); let run = self.create_mission_run_inner(&crate::model::MissionRunRequest { From e7fc120af51d629b40f1e474615028b1f7d03958 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:24:17 +0100 Subject: [PATCH 07/16] Carry a bound run through the trusted Unix client and through handlers that hold a bound caller to itself --- crates/fractal-api-core/src/api.rs | 32 ++++++++++ crates/fractal-api-core/src/api/client_v0.rs | 33 ++++++++-- crates/fractal-api-core/src/api/launches.rs | 4 +- crates/fractal-api-core/src/api/operations.rs | 6 +- crates/fractal-api-core/src/api/records.rs | 12 ++-- crates/fractal-api-core/src/api/tests.rs | 64 +++++++++++++++++++ crates/fractal-secrets/src/client.rs | 24 +++++++ crates/fractal/src/main.rs | 2 + docs/st3/mission-graph-runtime.md | 6 +- docs/st3/program-engine.md | 8 ++- .../run-credential-binds-mission-run.json | 2 +- 11 files changed, 172 insertions(+), 21 deletions(-) diff --git a/crates/fractal-api-core/src/api.rs b/crates/fractal-api-core/src/api.rs index ed769606af5..ebb7942e31e 100644 --- a/crates/fractal-api-core/src/api.rs +++ b/crates/fractal-api-core/src/api.rs @@ -8039,6 +8039,38 @@ pub fn authorize_worker(peer: Option<&LocalPeer>, observe: &dyn Fn() -> anyhow:: #[derive(Clone, Debug)] pub struct BoundAgent(pub String); +/// The identity a local request is bound to by its caller's ancestry: an agent harness, or the +/// mission run a program effect acts as. Handlers that hold a bound caller to its own identity +/// read this one extractor, so a bound run is never mistaken for an unbound person. +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum BoundActor { + Agent(String), + Run(String), +} + +impl BoundActor { + pub fn of(extensions: &axum::http::Extensions) -> Option { + extensions.get::().map(|agent| Self::Agent(agent.0.clone())) + .or_else(|| extensions.get::().map(|run| Self::Run(run.0.run.clone()))) + } + + /// The bound identity: the agent as the harness names it, or `mission-run/RUN`. + pub fn actor(&self) -> &str { + match self { Self::Agent(actor) | Self::Run(actor) => actor } + } +} + +impl axum::extract::OptionalFromRequestParts for BoundActor { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + _state: &S, + ) -> Result, Self::Rejection> { + Ok(Self::of(&parts.extensions)) + } +} + // The serving boundary supplies the immutable ancestry binding and its identity store before // any route or admission effect runs, including for directly constructed routers. async fn guard_bound_actor( diff --git a/crates/fractal-api-core/src/api/client_v0.rs b/crates/fractal-api-core/src/api/client_v0.rs index de5d5e5b6e3..47f358ac40c 100644 --- a/crates/fractal-api-core/src/api/client_v0.rs +++ b/crates/fractal-api-core/src/api/client_v0.rs @@ -556,9 +556,25 @@ impl ClientSession { }; // Free mode: an agent's local session holds every scope a person's does. Its actions // still record the agent as their actor. - Ok(Self { - actor: person.into(), - authority_actor: person.into(), + Ok(Self::acting(person)) + } + + /// A program effect's local session, acting as `run` (docs/st3/program-engine.md §3). Only a + /// request its run credential binds to that same run may name it; run authority then decides + /// each write. + pub fn local_run(run: &str, bound_run: Option<&str>) -> Result { + if bound_run != Some(run) { + return Err(forbidden( + "a trusted Unix client acts as a mission run only when its run credential binds that run", + )); + } + Ok(Self::acting(run)) + } + + fn acting(actor: &str) -> Self { + Self { + actor: actor.into(), + authority_actor: actor.into(), pairing_grant: None, pairing_issued_here: false, grant_expires_at: None, @@ -566,14 +582,14 @@ impl ClientSession { transport: "unix", local_peer: false, peer_process: None, - custom_forms, + custom_forms: false, conversation_blocks: false, conversation_delivery_links: false, conversation_message_participants: false, assistant_content_handles: false, messages_paging: false, scopes: ALL_SCOPES.iter().map(|scope| (*scope).to_owned()).collect(), - }) + } } pub fn pairing() -> Self { @@ -775,10 +791,15 @@ pub fn authenticate( .headers() .get(LOCAL_PERSON_HEADER) .and_then(|value| value.to_str().ok()); - let mut session = ClientSession::local(person)?; + let bound_run = request.extensions().get::().map(|bound| bound.0.run.as_str()); + let mut session = match person.filter(|person| person.starts_with("mission-run/")) { + Some(run) => ClientSession::local_run(run, bound_run)?, + None => ClientSession::local(person)?, + }; session.peer_process = request.extensions().get::() .and_then(|peer| peer.peer_process.clone()); session.local_peer = request.extensions().get::().is_some() + || bound_run.is_some() || request.extensions().get::() .is_some_and(|peer| peer.ancestor.is_some()) || request.extensions().get::().is_some(); diff --git a/crates/fractal-api-core/src/api/launches.rs b/crates/fractal-api-core/src/api/launches.rs index bb88b6fcb0d..207ebf0afb6 100644 --- a/crates/fractal-api-core/src/api/launches.rs +++ b/crates/fractal-api-core/src/api/launches.rs @@ -1439,10 +1439,10 @@ fn ignored_authority_warnings( pub async fn apply_with_bound( State(state): State, - bound: Option>, + bound: Option, Json(request): Json, ) -> Result, ApiError> { - apply_with_authority(state, request, bound.as_ref().map(|bound| bound.0.0.as_str())).await + apply_with_authority(state, request, bound.as_ref().map(BoundActor::actor)).await } pub async fn get_mission( diff --git a/crates/fractal-api-core/src/api/operations.rs b/crates/fractal-api-core/src/api/operations.rs index b9c69f4a966..8f1ec388db5 100644 --- a/crates/fractal-api-core/src/api/operations.rs +++ b/crates/fractal-api-core/src/api/operations.rs @@ -233,7 +233,7 @@ pub async fn operational_repair_plan( pub async fn apply_operational_repair( State(state): State, - bound: Option>, + bound: Option, Json(request): Json, ) -> Result, ApiError> { if bound.is_some() || request.actor.is_some() { @@ -249,14 +249,14 @@ pub async fn apply_operational_repair( /// before mutation, even if it would silently ignore an unknown actor field. pub async fn apply_attributed_operational_repair( State(state): State, - bound: Option>, + bound: Option, Json(request): Json, ) -> Result, ApiError> { let bound = bound.ok_or_else(|| ApiError::bad(FractalError::new( "foreign-agent-actor", "attributed repair apply requires an authenticated harness binding", )))?; - apply_operational_repair_with_actor(state, request, Some(bound.0.0)).await + apply_operational_repair_with_actor(state, request, Some(bound.actor().to_owned())).await } async fn apply_operational_repair_with_actor( diff --git a/crates/fractal-api-core/src/api/records.rs b/crates/fractal-api-core/src/api/records.rs index fe8fc0150cc..06cfb90f1ec 100644 --- a/crates/fractal-api-core/src/api/records.rs +++ b/crates/fractal-api-core/src/api/records.rs @@ -45,17 +45,19 @@ pub(super) fn generic_claims_page( pub async fn put_document( State(state): State, - bound: Option>, + bound: Option, Json(request): Json, ) -> Result, ApiError> { + // A seat writes only its own seat documents; a run, which has none, writes none. if let Some(bound) = bound.as_ref() - && !crate::handoff::may_write(&bound.0.0, &request.name) + && !crate::handoff::may_write(bound.actor(), &request.name) { return Err(ApiError::bad(FractalError::new( "document-not-yours", format!( - "`{}` belongs to another seat; a seat writes only doc/agents/… for itself", - request.name + "`{}` belongs to a seat; a seat writes only doc/agents/… for itself, and `{}` is not that seat", + request.name, + bound.actor(), ), ))); } @@ -66,7 +68,7 @@ pub async fn put_document( &request.bytes, &request.expected_document, &request.idempotency_key, - bound.as_ref().map(|bound| bound.0.0.as_str()), + bound.as_ref().map(BoundActor::actor), ) .map_err(ApiError::bad)?; signal_changed(&state); diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index 88f5922a56f..ce2008ba9f3 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -113,6 +113,70 @@ async fn a_run_credential_binds_requests_to_its_run() { assert_eq!(state.store.index().unwrap(), before); } +/// A program effect's CLI names its run on the trusted Unix client; only a request its run +/// credential binds to that same run may. +#[tokio::test] +async fn a_run_bound_unix_client_acts_as_its_run_and_no_other() { + let root = tempfile::tempdir().unwrap(); + let state = state(root.path()); + let step = state.store.start_program_step_for_tests(); + let secret = state.store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + let binding = state.store.run_binding(&secret).unwrap(); + let request = |person: &str, bound: bool| { + let mut request = Request::builder().method("POST").uri("/v1/client/actions") + .header(client_v0::LOCAL_PERSON_HEADER, person); + if bound { request = request.extension(BoundRun(binding.clone())); } + request.body(Body::empty()).unwrap() + }; + let session = client_v0::authenticate(&state, &request(&step.run, true), "unix").unwrap(); + assert_eq!((session.actor.as_str(), session.authority_actor.as_str()), (step.run.as_str(), step.run.as_str())); + assert!(session.local_peer); + for (person, bound) in [(step.run.as_str(), false), ("mission-run/another", true)] { + let refused = client_v0::authenticate(&state, &request(person, bound), "unix").unwrap_err(); + assert_eq!(refused.status, StatusCode::FORBIDDEN, "{person} bound={bound}"); + } +} + +/// Handlers that hold a bound caller to itself read one bound identity, so a bound run is never +/// an unbound person: it writes no seat's document, and it is the writer the rules decide. +#[tokio::test] +async fn a_bound_run_is_held_to_its_identity_by_document_writes() { + let root = tempfile::tempdir().unwrap(); + let state = state(root.path()); + let step = state.store.start_program_step_for_tests(); + let secret = state.store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + state.store.set_rule("runs-keep-out", &smallclaims::rules::Rule { + mode: smallclaims::rules::Mode::Enforce, + actors: vec!["mission-run/*".into()], + kinds: vec!["doc.bound".into()], + subjects: vec!["doc/locked/**".into()], + ..Default::default() + }, "person/owner").unwrap(); + let app = router(state.clone()).layer(from_fn_with_state((state.store.clone(), None), guard_bound_actor)); + let write = |name: &str, key: &str| { + let body = json!({"name": name, "bytes": b"notes".to_vec(), "expected_document": null, "idempotency_key": key}); + let request = Request::builder().method("POST").uri("/v1/documents") + .header("content-type", "application/json") + .extension(RunCredential::new(secret.clone())) + .body(Body::from(body.to_string())).unwrap(); + let app = app.clone(); + async move { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice::(&body).unwrap()) + } + }; + let (status, refused) = write("doc/agents/garden/ash/handoff", "seat").await; + assert_eq!((status, refused["code"].as_str()), (StatusCode::UNPROCESSABLE_ENTITY, Some("document-not-yours")), "{refused}"); + let (status, refused) = write("doc/locked/notes", "locked").await; + assert_eq!(refused["code"], "rule-denied", "{status} {refused}"); + let (status, written) = write("doc/runs/notes", "notes").await; + assert_eq!(status, StatusCode::OK, "{written}"); + assert_eq!(BoundActor::of(&Request::builder().extension(BoundAgent("agent/a".into())).body(()).unwrap().extensions()), + Some(BoundActor::Agent("agent/a".into()))); +} + #[cfg(target_os = "linux")] #[test] fn empty_process_environment_is_unavailable() { diff --git a/crates/fractal-secrets/src/client.rs b/crates/fractal-secrets/src/client.rs index 345f66aaa39..fef69e37fee 100644 --- a/crates/fractal-secrets/src/client.rs +++ b/crates/fractal-secrets/src/client.rs @@ -562,6 +562,21 @@ impl Client { Ok(client) } + /// Name the mission run a program effect acts as over the trusted Unix boundary. The daemon + /// accepts it only when the caller's run credential binds that run. + pub fn unix_run(path: impl Into, run: impl Into) -> Result { + let run = run.into(); + anyhow::ensure!( + run.strip_prefix("mission-run/").is_some_and(|id| { + !id.is_empty() && id.bytes().all(|b| b.is_ascii_alphanumeric() || b"._-".contains(&b)) + }), + "Unix run authority must be one concrete `mission-run/` subject" + ); + let mut client = Self::unix(path); + client.person = Some(run); + Ok(client) + } + pub async fn get(&self, path: &str) -> Result { let first_page = path.starts_with("/v1/client/") && !request_has_page_cursor(path); for attempt in 0..3 { @@ -1786,6 +1801,15 @@ fn decode_api_response(bytes: &[u8]) -> Result { mod tests { use super::*; + #[test] + fn a_unix_run_client_names_exactly_one_mission_run() { + let client = Client::unix_run("/tmp/socket", "mission-run/0123abcd").unwrap(); + assert_eq!(client.person.as_deref(), Some("mission-run/0123abcd")); + for run in ["mission-run/", "mission-run/a/b", "person/operator", "mission-run/a b"] { + assert!(Client::unix_run("/tmp/socket", run).is_err(), "{run}"); + } + } + #[tokio::test] async fn bind_422_warns_and_keeps_retrying() { use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index f8b3f8792b0..10267850cbd 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -10153,6 +10153,8 @@ fn execution_client(endpoint: &Endpoint, actor: String) -> Result { }; let client = if actor.starts_with("agent/") { Client::unix_agent(socket.as_ref(), actor)? + } else if actor.starts_with("mission-run/") { + Client::unix_run(socket.as_ref(), actor)? } else { Client::unix_as(socket.as_ref(), actor)? }; diff --git a/docs/st3/mission-graph-runtime.md b/docs/st3/mission-graph-runtime.md index 5e1895aaaf4..a8e6408588e 100644 --- a/docs/st3/mission-graph-runtime.md +++ b/docs/st3/mission-graph-runtime.md @@ -250,8 +250,10 @@ the daemon does not enforce it yet. A request whose caller's ancestry carries a 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 current, live attempt; one that no longer binds leaves the caller unverifiable, and an -environment that carries both a credential and `ST_AGENT` binds neither. The CLI defaults its -actor to `mission-run/$ST_MISSION_RUN` when the credential is present. An older member would quarantine a +environment that carries both a credential and `ST_AGENT` binds neither. A bound run writes no +seat's `doc/agents/…` document. The CLI defaults its actor to `mission-run/$ST_MISSION_RUN` when the +credential is present; the daemon accepts that actor from the trusted Unix client only when the +request's credential binds the same run. 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` diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md index 1a1a7856ebf..5fabe56c608 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -219,8 +219,12 @@ beside `ST_AGENT`, nearest ancestor first; one environment carrying both binds n 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. +routes, no person headers other than its own run, and `actor`/`requester` must be the run. Handlers +that hold a bound caller to itself read one bound identity (`BoundActor`) for agents and runs +alike, so a run writes no seat's `doc/agents/…` document and is the writer its document writes +name. The CLI defaults its actor to the run when the credential is present and names it on the +trusted Unix client, which the daemon accepts only from a request its credential binds to that +same run. 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. diff --git a/release-notes/run-credential-binds-mission-run.json b/release-notes/run-credential-binds-mission-run.json index b434d07fb82..804c17710c0 100644 --- a/release-notes/run-credential-binds-mission-run.json +++ b/release-notes/run-credential-binds-mission-run.json @@ -3,7 +3,7 @@ "summary": "A request whose caller's ancestry carries a live run credential (`ST_RUN_CREDENTIAL`) is bound to `mission-run/RUN`: it acts only as that run, its writes are attributed to it, and the credential dies with its program attempt", "replay": {"status": "none", "detail": "No claim kind or projection change. Claims written by a run-bound request name `mission-run/RUN` as their actor, a subject the store already accepts as an actor."}, "schema": {"status": "none", "detail": "No table or migration change. Run credentials are rows of the existing local `capabilities` table (kind `program-run`, subject the step run, incarnation the attempt; the secret is stored hashed)."}, - "rules": {"status": "changed", "detail": "New bound identity. The ancestry walk reads `ST_RUN_CREDENTIAL` beside `ST_AGENT`; the serving guard resolves it to its run while the attempt is the step run's current, live attempt, and otherwise leaves the caller unverifiable (writes refused). A run-bound request follows the bound-agent rules: no internal routes, no person header, and `actor`/`requester` must be the run (filled in when absent). An environment carrying both a credential and `ST_AGENT` binds neither. The CLI defaults its actor to `mission-run/$ST_MISSION_RUN` when the credential is present and accepts `--as mission-run/RUN`. Nothing mints credentials yet; the program engine does once it dispatches attempts."}, + "rules": {"status": "changed", "detail": "New bound identity. The ancestry walk reads `ST_RUN_CREDENTIAL` beside `ST_AGENT`; the serving guard resolves it to its run while the attempt is the step run's current, live attempt, and otherwise leaves the caller unverifiable (writes refused). A run-bound request follows the bound-agent rules: no internal routes, no person header other than its own run, and `actor`/`requester` must be the run (filled in when absent). An environment carrying both a credential and `ST_AGENT` binds neither. Handlers that hold a bound caller to itself (documents, publication, attributed repair) read one bound identity for agents and runs, so a run writes no seat's `doc/agents/…` document and is the writer the rules decide. The CLI defaults its actor to `mission-run/$ST_MISSION_RUN` when the credential is present and accepts `--as mission-run/RUN`; the trusted Unix client carries it, and the daemon accepts it only from a request its credential binds to that run. Nothing mints credentials yet; the program engine does once it dispatches attempts."}, "compatibility": {"status": "changed", "detail": "Processes without `ST_RUN_CREDENTIAL` are bound exactly as before. `ST_RUN_CREDENTIAL` joins the identity variables whose conflicting `FRACTAL_` spelling is ignored."}, "downtime": {"status": "none", "detail": "No restart or migration beyond a normal deploy."}, "manual": {"status": "none", "detail": "No operator action is required."}, From 08bbc16b9214e7e4746c82efc1d9e5b04b4162c2 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:27:35 +0100 Subject: [PATCH 08/16] 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. From b64fd1c5130e8d34ae3632bcb2fe5125d990b73b Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:31:31 +0100 Subject: [PATCH 09/16] Let the run credential fixture's mission bind its test documents --- crates/fractal-store/src/store/run_credentials.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index 62f34cc17c2..4c22ded9eb2 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -59,13 +59,13 @@ impl Store { ).unwrap(); } - /// Publish a one-step mission that may write `custom/example/*`, start a run of it and put its - /// step at attempt 1, working. + /// Publish a one-step mission that may write `custom/example/*` and `doc/runs/*`, start a run + /// of it and put its step at attempt 1, working. pub fn start_program_step_for_tests(&self) -> StepRunView { crate::store::tests_shared_seats::publish_mission(self, r#"version 2 mission "land" state="ready" { goal "Land one change." - authority { write "custom/example/*"; } + authority { write "custom/example/*"; write "doc/runs/*"; } step "program" { agentless } }"#, "declare-land"); let run = self.create_mission_run_inner(&crate::model::MissionRunRequest { From 3dde080735808201796a9ef6498515c4594d114e Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:33:54 +0100 Subject: [PATCH 10/16] Count a step run's minted run credentials in tests --- crates/fractal-store/src/store/run_credentials.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/fractal-store/src/store/run_credentials.rs b/crates/fractal-store/src/store/run_credentials.rs index 4c22ded9eb2..8dcec08315a 100644 --- a/crates/fractal-store/src/store/run_credentials.rs +++ b/crates/fractal-store/src/store/run_credentials.rs @@ -59,6 +59,15 @@ impl Store { ).unwrap(); } + /// How many run credentials were ever minted for `step_run`. + pub fn run_credentials_issued_for_tests(&self, step_run: &str) -> usize { + self.readers.get().query_row( + "SELECT COUNT(*) FROM capabilities WHERE kind=?1 AND subject=?2", + params![RUN_CREDENTIAL_KIND, step_run], + |row| row.get(0), + ).unwrap() + } + /// Publish a one-step mission that may write `custom/example/*` and `doc/runs/*`, start a run /// of it and put its step at attempt 1, working. pub fn start_program_step_for_tests(&self) -> StepRunView { From 170dce908b36eb7d2473716eb061d88eb7fab754 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:34:48 +0100 Subject: [PATCH 11/16] Mint a run credential only when an attempt starts, with no expiry of its own --- .../src/reconcile/program.rs | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/crates/fractal-reconcile/src/reconcile/program.rs b/crates/fractal-reconcile/src/reconcile/program.rs index 1ee0b6eefda..c9bb9eb9ec5 100644 --- a/crates/fractal-reconcile/src/reconcile/program.rs +++ b/crates/fractal-reconcile/src/reconcile/program.rs @@ -39,9 +39,9 @@ pub const RESULT_KEY: &str = "$result"; /// The step states in which the engine runs a program step's current attempt. A step waiting on /// a person keeps its program loaded (§11). pub const RUNNING_STATES: [&str; 4] = ["ready", "working", "blocked", "waiting-person"]; -/// How long a run credential stays valid at most. It binds only while its attempt runs, so this -/// only bounds a credential whose attempt the store never ends. -const RUN_CREDENTIAL_LIFETIME_MS: u64 = 7 * 24 * 60 * 60 * 1000; +/// A run credential has no expiry of its own: it binds only while its attempt is its step run's +/// current, live attempt, however long that attempt waits on an ask or a child run. +const RUN_CREDENTIAL_LIFETIME_MS: u64 = u64::MAX; const HOST_SOURCE: &str = /* sqlite-large-value: allow-for-now lv-h344eb4debbcf owner=fractal_reconcile::reconcile::program TODO=trace-statement-and-value-representation */ include_str!("program_host.mjs"); /// How long a crashed host waits before its replacement starts, so a host that cannot start does /// not spin. @@ -151,14 +151,6 @@ impl ProgramEngine { Err(reason) => return Ok(Dispatched::Refused(reason)), }; let journal = self.journal(&dispatch.step, dispatch.attempt)?; - // The attempt's effects act as its run (docs/st3/program-engine.md §3). The credential - // binds only while this attempt is the step's live attempt, so its lifetime is a backstop; - // each start or resume mints a new one, and it never enters the journal or a log. - let mut env = dispatch.env.clone(); - env.insert( - crate::store::run_credentials::RUN_CREDENTIAL_ENV.to_owned(), - self.store.issue_run_credential(&dispatch.step, dispatch.attempt, RUN_CREDENTIAL_LIFETIME_MS)?, - ); let mut inner = self.lock(); if inner.running.get(&dispatch.step).is_some_and(|running| { running.dispatch.attempt == dispatch.attempt && inner.host.as_ref().is_some_and(|host| host.generation == running.generation) @@ -174,6 +166,15 @@ impl ProgramEngine { } }; inner.faults.remove(&dispatch.step); + // The attempt's effects act as its run (docs/st3/program-engine.md §3). The credential is + // minted only here, once the host runs and this attempt is about to start, so a refused, + // already running or host-down dispatch leaves no row; each start or resume mints one, and + // it never enters the journal or a log. + let mut env = dispatch.env.clone(); + env.insert( + crate::store::run_credentials::RUN_CREDENTIAL_ENV.to_owned(), + self.store.issue_run_credential(&dispatch.step, dispatch.attempt, RUN_CREDENTIAL_LIFETIME_MS)?, + ); eprintln!( "fractal: program {} attempt {} started with {} journaled steps", dispatch.step, From 86431ba5ac8ae47a32ed6ee285603abe4aaee7fc Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:35:34 +0100 Subject: [PATCH 12/16] Test that a running attempt mints one credential without expiry --- .../src/reconcile/tests/program_engine.rs | 8 +++++ .../program-attempts-act-as-their-run.json | 35 +++++++++++++++---- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/crates/fractal-reconcile/src/reconcile/tests/program_engine.rs b/crates/fractal-reconcile/src/reconcile/tests/program_engine.rs index 3cfc134b2f6..87738650679 100644 --- a/crates/fractal-reconcile/src/reconcile/tests/program_engine.rs +++ b/crates/fractal-reconcile/src/reconcile/tests/program_engine.rs @@ -238,6 +238,14 @@ fn a_programs_effects_and_journal_act_as_its_run() { let credential = who.body["fields"]["value"].as_str().unwrap().to_owned(); let binding = fixture.store.run_binding(&credential).unwrap(); assert_eq!((binding.run.as_str(), binding.step_run.as_str(), binding.attempt), (started.run.as_str(), started.step.as_str(), 1)); + // Passes over a running attempt mint nothing more, and the credential has no expiry of its own. + for _ in 0..5 { + fixture.reconciler.reconcile_once().unwrap(); + } + assert_eq!(fixture.store.run_credentials_issued_for_tests(&started.step), 1); + let capability = fixture.store.capability(&credential, crate::store::run_credentials::RUN_CREDENTIAL_KIND).unwrap(); + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis(); + assert!(capability.expires_at_unix_ms > now + 100 * 365 * 24 * 60 * 60 * 1000); std::fs::write(&started.gate, "").unwrap(); fixture.until("the program completes", || fixture.status(&started.step) == "completed"); assert_eq!(fixture.store.run_binding(&credential).unwrap_err().code, "run-credential-ended"); diff --git a/release-notes/program-attempts-act-as-their-run.json b/release-notes/program-attempts-act-as-their-run.json index 27cedfd30dd..f1535a8b5e7 100644 --- a/release-notes/program-attempts-act-as-their-run.json +++ b/release-notes/program-attempts-act-as-their-run.json @@ -1,11 +1,32 @@ { "version": 1, "summary": "Each program attempt starts with a run credential in its effects' environment (`ST_RUN_CREDENTIAL`), so their requests act as `mission-run/RUN`, and the program journal (`program.step`) is written as that run", - "replay": {"status": "changed", "detail": "`program.step` claims carry actor `mission-run/RUN` instead of no actor. Their fields and idempotency keys are unchanged."}, - "schema": {"status": "none", "detail": "No table or migration change. Each start or resume of an attempt adds one row to the local `capabilities` table (hashed secret)."}, - "rules": {"status": "changed", "detail": "Program effects no longer act as an unbound local person: the daemon binds them to their run while the attempt runs, and refuses their writes once it has ended."}, - "compatibility": {"status": "none", "detail": "Program steps are new in this milestone; no earlier journal exists."}, - "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": "A resumed attempt is started with a fresh credential; an effect from an ended attempt cannot write."} + "replay": { + "status": "changed", + "detail": "`program.step` claims carry actor `mission-run/RUN` instead of no actor. Their fields and idempotency keys are unchanged." + }, + "schema": { + "status": "none", + "detail": "No table or migration change. Each start or resume of an attempt adds one row to the local `capabilities` table (hashed secret)." + }, + "rules": { + "status": "changed", + "detail": "Program effects no longer act as an unbound local person: the engine mints one credential when an attempt starts or resumes (never on a refused, already running or host-down dispatch), with no expiry of its own, and the daemon binds its effects to their run while that attempt is the step's live attempt, however long it waits, and refuses their writes once it has ended." + }, + "compatibility": { + "status": "none", + "detail": "Program steps are new in this milestone; no earlier journal exists." + }, + "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": "A resumed attempt is started with a fresh credential; an effect from an ended attempt cannot write." + } } From 95a7c082e3db8dff844cc12676c0f7d9e4d44fc4 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 17:36:01 +0100 Subject: [PATCH 13/16] Keep the release note's one-line fields --- .../program-attempts-act-as-their-run.json | 35 ++++--------------- 1 file changed, 7 insertions(+), 28 deletions(-) diff --git a/release-notes/program-attempts-act-as-their-run.json b/release-notes/program-attempts-act-as-their-run.json index f1535a8b5e7..6e70e9e0465 100644 --- a/release-notes/program-attempts-act-as-their-run.json +++ b/release-notes/program-attempts-act-as-their-run.json @@ -1,32 +1,11 @@ { "version": 1, "summary": "Each program attempt starts with a run credential in its effects' environment (`ST_RUN_CREDENTIAL`), so their requests act as `mission-run/RUN`, and the program journal (`program.step`) is written as that run", - "replay": { - "status": "changed", - "detail": "`program.step` claims carry actor `mission-run/RUN` instead of no actor. Their fields and idempotency keys are unchanged." - }, - "schema": { - "status": "none", - "detail": "No table or migration change. Each start or resume of an attempt adds one row to the local `capabilities` table (hashed secret)." - }, - "rules": { - "status": "changed", - "detail": "Program effects no longer act as an unbound local person: the engine mints one credential when an attempt starts or resumes (never on a refused, already running or host-down dispatch), with no expiry of its own, and the daemon binds its effects to their run while that attempt is the step's live attempt, however long it waits, and refuses their writes once it has ended." - }, - "compatibility": { - "status": "none", - "detail": "Program steps are new in this milestone; no earlier journal exists." - }, - "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": "A resumed attempt is started with a fresh credential; an effect from an ended attempt cannot write." - } + "replay": {"status": "changed", "detail": "`program.step` claims carry actor `mission-run/RUN` instead of no actor. Their fields and idempotency keys are unchanged."}, + "schema": {"status": "none", "detail": "No table or migration change. Each start or resume of an attempt adds one row to the local `capabilities` table (hashed secret)."}, + "rules": {"status": "changed", "detail": "Program effects no longer act as an unbound local person: the engine mints one credential when an attempt starts or resumes (never on a refused, already running or host-down dispatch), with no expiry of its own, and the daemon binds its effects to their run while that attempt is the step's live attempt, however long it waits, and refuses their writes once it has ended."}, + "compatibility": {"status": "none", "detail": "Program steps are new in this milestone; no earlier journal exists."}, + "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": "A resumed attempt is started with a fresh credential; an effect from an ended attempt cannot write."} } From d2c69c9d1a446b9c74c49133ee4845d212ac6871 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 18:08:11 +0100 Subject: [PATCH 14/16] Read a mission's authority patterns as patterns, not document references --- crates/fractal-missions/src/graph.rs | 5 ++++- crates/fractal-store/src/graph.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/fractal-missions/src/graph.rs b/crates/fractal-missions/src/graph.rs index 9cb975f1e2d..66f69bcd500 100644 --- a/crates/fractal-missions/src/graph.rs +++ b/crates/fractal-missions/src/graph.rs @@ -97,7 +97,10 @@ fn resolve_node_documents(node: &mut KdlNode, bindings: &BTreeMap) -> Resul let gate = node.name().value() == "gate"; let mission = node.name().value() == "mission"; for child in children.nodes() { - if !(gate && is_document_gate(child) || mission && child.name().value() == "provenance") { + // A mission's `authority` names subject patterns such as `doc/team/*`, not documents. + if !(gate && is_document_gate(child) + || mission && matches!(child.name().value(), "provenance" | "authority")) + { collect_document_refs(child, output)?; } } From 065dff639ad6839bf34444fa551c7a96aee7e74d Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 18:23:19 +0100 Subject: [PATCH 15/16] Drop a needless borrow in the bound identity test --- crates/fractal-api-core/src/api/tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index ce2008ba9f3..769b22d4ef4 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -173,7 +173,7 @@ async fn a_bound_run_is_held_to_its_identity_by_document_writes() { assert_eq!(refused["code"], "rule-denied", "{status} {refused}"); let (status, written) = write("doc/runs/notes", "notes").await; assert_eq!(status, StatusCode::OK, "{written}"); - assert_eq!(BoundActor::of(&Request::builder().extension(BoundAgent("agent/a".into())).body(()).unwrap().extensions()), + assert_eq!(BoundActor::of(Request::builder().extension(BoundAgent("agent/a".into())).body(()).unwrap().extensions()), Some(BoundActor::Agent("agent/a".into()))); } From 3d6cf4aa511be0cf8d6afa9acb96e3d276d6c469 Mon Sep 17 00:00:00 2001 From: Johannes Schickling Date: Tue, 10 Nov 2026 22:06:43 +0100 Subject: [PATCH 16/16] Let a run publish with fractal apply as mission-run/RUN --- crates/fractal-api-core/src/api/tests.rs | 34 ++++++++++++ crates/fractal/src/main.rs | 52 +++++++++++++++---- crates/fractal/src/main_tests.rs | 37 ++++++++++++- docs/st3/operational-state/cli-commands.json | 2 +- docs/st3/program-engine.md | 6 +++ .../program-attempts-act-as-their-run.json | 2 +- 6 files changed, 121 insertions(+), 12 deletions(-) diff --git a/crates/fractal-api-core/src/api/tests.rs b/crates/fractal-api-core/src/api/tests.rs index b3796bba012..83c2a27aff2 100644 --- a/crates/fractal-api-core/src/api/tests.rs +++ b/crates/fractal-api-core/src/api/tests.rs @@ -113,6 +113,40 @@ async fn a_run_credential_binds_requests_to_its_run() { assert_eq!(state.store.index().unwrap(), before); } +/// A program effect's `fractal apply` publishes a mission as its run, and as no other actor. +#[tokio::test] +async fn a_run_bound_request_publishes_as_its_run() { + let root = tempfile::tempdir().unwrap(); + let state = state(root.path()); + let step = state.store.start_program_step_for_tests(); + let secret = state.store.issue_run_credential(&step.subject, 1, 60_000).unwrap(); + let app = router(state.clone()).layer(from_fn_with_state((state.store.clone(), None), guard_bound_actor)); + let source = "version 2\nmission \"x/notes\" state=\"ready\" {\n goal \"Take notes.\"\n step \"work\" { }\n}\n"; + let apply = |actor: &str| { + let body = serde_json::to_string(&apply_request(&state, source, actor, actor)).unwrap(); + let request = Request::builder().method("POST").uri("/v1/intent/apply") + .header("content-type", "application/json") + .extension(RunCredential::new(secret.clone())) + .body(Body::from(body)).unwrap(); + let app = app.clone(); + async move { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + (status, serde_json::from_slice::(&body).unwrap()) + } + }; + for actor in ["person/operator", "agent/example/worker"] { + let (status, refused) = apply(actor).await; + assert_eq!((status, refused["code"].as_str()), (StatusCode::UNPROCESSABLE_ENTITY, Some("foreign-agent-actor")), "{actor} {refused}"); + } + assert!(state.store.latest_claim("mission/x/notes", Some("mission.published")).unwrap().is_none()); + let (status, published) = apply(&step.run).await; + assert_eq!(status, StatusCode::OK, "{published}"); + let claim = state.store.latest_claim("mission/x/notes", Some("mission.published")).unwrap().unwrap(); + assert_eq!(claim.actor.as_deref(), Some(step.run.as_str())); +} + /// A program effect's CLI names its run on the trusted Unix client; only a request its run /// credential binds to that same run may. #[tokio::test] diff --git a/crates/fractal/src/main.rs b/crates/fractal/src/main.rs index c9fd526a5be..5341d9b75f0 100644 --- a/crates/fractal/src/main.rs +++ b/crates/fractal/src/main.rs @@ -1399,9 +1399,9 @@ struct MissionPublishArgs { /// Preview against this exact store index. #[arg(long, visible_alias = "at")] at_index: Option, - /// Complete person or agent subject authoring the publication. + /// Complete person, agent or run subject authoring the publication. #[arg(add = ArgValueCompleter::new(Complete(Entity::Actor)))] - #[arg(long = "as", value_parser = parse_publication_actor)] + #[arg(long = "as", value_parser = parse_mission_publication_actor)] actor: String, /// The workspace a run would use, where publish checks mission and step exec gates. #[arg(long, default_value = ".")] @@ -3389,10 +3389,11 @@ struct ApplyArgs { allow_empty: bool, #[arg(long, requires = "set")] confirm_retire: Option, - /// Complete person or agent subject authoring the publication. + /// Complete person, agent or run subject authoring the publication; defaults to the + /// harness's own seat, else to the run a program effect acts as. #[arg(add = ArgValueCompleter::new(Complete(Entity::Actor)))] - #[arg(long = "as", env = "ST_AGENT", value_parser = parse_publication_actor)] - actor: String, + #[arg(long = "as", env = "ST_AGENT", value_parser = parse_mission_publication_actor)] + actor: Option, } #[derive(Subcommand)] @@ -3452,9 +3453,10 @@ fn read_apply_files(files: &[PathBuf], allow_empty: bool) -> Result } async fn run_apply(client: &Client, args: ApplyArgs, json_output: bool) -> Result<()> { + let actor = own_publication_actor(args.actor.clone(), "apply")?; let intent = read_apply_files(&args.files, args.allow_empty)?; if args.set.is_some() { - return run_owned_set_apply(client, intent, args, json_output).await; + return run_owned_set_apply(client, intent, args, actor, json_output).await; } publish_mission_intent( client, @@ -3463,7 +3465,7 @@ async fn run_apply(client: &Client, args: ApplyArgs, json_output: bool) -> Resul file: PathBuf::new(), dry_run: args.dry_run, at_index: args.at_index, - actor: args.actor, + actor, workspace: args.workspace, no_gate_check: args.no_gate_check, check: args.check, @@ -3478,6 +3480,7 @@ async fn run_owned_set_apply( client: &Client, intent: IntentInput, args: ApplyArgs, + actor: String, json_output: bool, ) -> Result<()> { use fractal_daemon::store::owned_sets::{Options, Preview, Request, Source}; @@ -3510,7 +3513,7 @@ async fn run_owned_set_apply( let mut request = Request { intent, options, - actor: args.actor, + actor, idempotency_key: uuid::Uuid::now_v7().to_string(), program_bundles: BTreeMap::new(), }; @@ -6322,7 +6325,7 @@ fn guard_mutating_cli_actor( anyhow::ensure!(actor == own, "actor flag must equal ST_AGENT literally ({own})"); } let actor = match command { - Command::Apply(args) => Some(args.actor.as_str()), + Command::Apply(args) => args.actor.as_deref(), Command::Changes(change_cli::ChangesArgs { command: change_cli::ChangesCommand::Sync { actor, .. }, }) | Command::Gate { @@ -10198,6 +10201,21 @@ fn configured_actor( configured_human(None, configured, command) } +/// The actor of a publication whose `--as` defaults to the caller's own identity: the explicit or +/// `ST_AGENT` subject, else the run a program effect acts as (docs/st3/program-engine.md §3). +fn own_publication_actor(explicit: Option, command: &str) -> Result { + explicit + .or_else(|| { + run_actor( + fractal_drivers::contracts::env("ST_RUN_CREDENTIAL").as_deref(), + fractal_drivers::contracts::env("ST_MISSION_RUN").as_deref(), + ) + }) + .with_context(|| { + format!("fractal {command} needs `--as person/NAME`, `--as agent/PATH` or `--as mission-run/RUN`") + }) +} + /// A program effect carries its attempt's run credential and acts as its run /// (docs/st3/program-engine.md §3); the daemon binds the credential to the same run. fn run_actor(credential: Option<&str>, run: Option<&str>) -> Option { @@ -16316,6 +16334,22 @@ fn parse_publication_actor(actor: &str) -> std::result::Result { Ok(actor.to_owned()) } +/// The author of a mission publication: a person, an agent, or a run, which publishes what its +/// `authority` block's `publish` rules grant (docs/st3/program-engine.md §3). Starting, revising +/// and cancelling runs stay with the program API and the person or agent commands. +fn parse_mission_publication_actor(actor: &str) -> std::result::Result { + if actor + .strip_prefix("mission-run/") + .is_some_and(|run| !run.is_empty() && !run.ends_with('/')) + { + return Ok(actor.to_owned()); + } + parse_publication_actor(actor).map_err(|_| { + "mission publication authority must be an explicit `person/NAME`, `agent/PATH` or `mission-run/RUN` subject" + .into() + }) +} + #[cfg(test)] mod native_seed_driver_tests; diff --git a/crates/fractal/src/main_tests.rs b/crates/fractal/src/main_tests.rs index 99257ee58cc..0befc68f3d4 100644 --- a/crates/fractal/src/main_tests.rs +++ b/crates/fractal/src/main_tests.rs @@ -1810,6 +1810,41 @@ fn a_program_effect_acts_as_its_run() { assert!(parse_actor_subject("mission-run/").is_err()); } +/// A run publishes missions as itself: `apply` and `missions publish` take `--as mission-run/RUN`, +/// and `apply` defaults to it when it holds a run credential. The commands a run's authority cannot +/// grant still take only a person or an agent. +#[test] +fn a_run_is_a_mission_publication_actor() { + for actor in ["person/operator", "agent/fleet/worker", "mission-run/abc"] { + assert_eq!(parse_mission_publication_actor(actor).as_deref(), Ok(actor)); + } + for actor in ["mission-run/", "mission-run/abc/", "mission/abc", "person/", "abc"] { + assert!(parse_mission_publication_actor(actor).is_err(), "{actor}"); + } + assert!(parse_publication_actor("mission-run/abc").is_err()); + for command in [ + vec!["st", "apply", "notes.kdl", "--as", "mission-run/abc"], + vec!["st", "missions", "publish", "notes.kdl", "--as", "mission-run/abc"], + ] { + assert!(Cli::try_parse_from(&command).is_ok(), "{command:?}"); + } + for command in [ + vec!["st", "missions", "retire", "x/notes", "--as", "mission-run/abc"], + vec!["st", "agents", "rollout", "fleet/worker", "--as", "mission-run/abc"], + ] { + assert!(Cli::try_parse_from(&command).is_err(), "{command:?}"); + } + let Command::Apply(args) = Cli::try_parse_from(["st", "apply", "notes.kdl", "--as", "mission-run/abc"]) + .unwrap() + .command + else { + panic!("expected apply") + }; + assert_eq!(own_publication_actor(args.actor, "apply").unwrap(), "mission-run/abc"); + // Without `--as`, apply resolves its actor from its environment when it runs. + assert!(Cli::try_parse_from(["st", "apply", "notes.kdl"]).is_ok()); +} + #[test] fn execution_actor_options_parse_and_fail_closed() { for (path, tail) in [ @@ -8609,7 +8644,7 @@ async fn publish_test_mission(client: &Client, root: &Path, goal: &str) { confirm_retire: None, dry_run: false, at_index: None, - actor: "person/test".into(), + actor: Some("person/test".into()), workspace: root.to_owned(), no_gate_check: false, check: false, diff --git a/docs/st3/operational-state/cli-commands.json b/docs/st3/operational-state/cli-commands.json index d337b7da94f..80023b07008 100644 --- a/docs/st3/operational-state/cli-commands.json +++ b/docs/st3/operational-state/cli-commands.json @@ -44,7 +44,7 @@ "changes": {"job": "Read Git change state and explicitly synchronize retained metadata", "why": "Expose source, ref, index and evidence freshness while retaining the PR path", "actor": "person or agent", "mode": "read-or-controlled-sync", "model": "Git change observation and local derived receipts", "inputs": "configured repository name, full Change-Id, offline flag and bounded list cursor", "defaults": "feature disabled; list limit 50; one designated reader per repository", "output": "bounded list, exact change detail or typed sync admission", "success": "indexed state with explicit freshness or unavailable PR fallback", "actions": "list show sync", "authority": "read.projections for reads; control.work for sync; operator-owned transport", "recovery": "use the named reader or the PR path; configure transport before activation", "human_example": "st changes list --repository fixture/garden --offline", "json_example": "st changes list --repository fixture/garden --offline --json", "workflow": "product/git-changes"}, "ui": {"job": "Open the terminal interface", "why": "Spaces collect conversations, Home, missions and agent terminals", "actor": "person", "mode": "interactive", "model": "client projections", "inputs": "space, demo, local or client options", "defaults": "last space; paired device when available, otherwise local daemon", "output": "interactive terminal; demo dump supports text snapshots", "success": "terminal restored when the UI exits", "actions": "existing typed model actions", "authority": "configured person or paired session", "recovery": "reconnect or pair with st devices complete", "human_example": "st ui --space harbor", "json_example": "st ui --help --json", "workflow": "product/ui"}, "demo": {"job": "Play a scripted demo of st in the terminal interface", "why": "Screencasts need a seeded fleet whose agents act the same way every time and cost no tokens", "actor": "person", "mode": "interactive", "model": "demo KDL file played into its own instance", "inputs": "demo file and --instance", "defaults": "advance key F8; beats without after wait for it", "output": "interactive terminal; failed beats listed after quitting", "success": "every beat ran and Ctrl+Q stopped the demo's daemon and agents", "actions": "play check", "authority": "local person process in an instance; unavailable in agent seats", "recovery": "read state/st3-demo/play.log, fix the file and play again", "human_example": "st --instance demo demo play examples/demos/release-day.kdl", "json_example": "st demo --help --json", "workflow": "product/demos"}, - "apply": {"job": "Validate and publish authored KDL", "why": "Seats, missions and schedules share one preview and atomic publication", "actor": "person or agent", "mode": "read-or-fenced-mutation", "model": "publication-preview or owned-set", "inputs": "version-2 KDL files; optional owned-set source and revision flags", "defaults": "plain upsert; dry runs execute no commands unless --check", "output": "resolved preview or publication receipt; gate answers on stderr", "success": "valid preview or atomic publication under captured subject heads", "actions": "dry-run check apply", "authority": "explicit publication actor or ST_AGENT", "recovery": "correct blockers or refresh stale fences", "human_example": "st apply seats.kdl missions.kdl schedules.kdl --as person/operator --dry-run", "json_example": "st apply missions/demo.kdl --as person/operator --dry-run --json", "workflow": "product/apply"}, + "apply": {"job": "Validate and publish authored KDL", "why": "Seats, missions and schedules share one preview and atomic publication", "actor": "person, agent or run", "mode": "read-or-fenced-mutation", "model": "publication-preview or owned-set", "inputs": "version-2 KDL files; optional owned-set source and revision flags", "defaults": "plain upsert; dry runs execute no commands unless --check", "output": "resolved preview or publication receipt; gate answers on stderr", "success": "valid preview or atomic publication under captured subject heads", "actions": "dry-run check apply", "authority": "explicit publication actor, ST_AGENT, or the run of a held run credential", "recovery": "correct blockers or refresh stale fences", "human_example": "st apply seats.kdl missions.kdl schedules.kdl --as person/operator --dry-run", "json_example": "st apply missions/demo.kdl --as person/operator --dry-run --json", "workflow": "product/apply"}, "validate": {"job": "Validate authored KDL offline", "why": "Catch declaration errors and inspect mission revisions without a daemon", "actor": "person or agent", "mode": "read", "model": "offline declarations", "inputs": "one or more version-2 KDL files parsed independently", "defaults": "literal local host; no configuration or live graph lookup", "output": "subject kinds and IDs plus mission IDs and revisions", "success": "every file parses and passes pure declaration validation", "actions": "none", "authority": "local file access only; not an authorization or deployment preview", "recovery": "correct the named file and validate again", "human_example": "st validate seats.kdl missions.kdl", "json_example": "st validate missions.kdl --json", "workflow": "operator/authoring"}, "owned-sets": {"job": "Publish and inspect complete owned membership", "why": "Omission retires managed declarations under source and graph fences", "actor": "person or agent", "mode": "read-or-fenced-mutation", "model": "owned-set", "inputs": "complete version-2 KDL bundle, repository, ref, SHA, source sequence and expected set revision", "defaults": "no adoption or retirement confirmation", "output": "preview, publication receipt or set status", "success": "atomic selected membership or exact commit rollout state", "actions": "apply ls show status", "authority": "explicit publication actor or read scope", "recovery": "refresh fences and preview retirement before retrying", "human_example": "st sets show garden", "json_example": "st sets status garden --sha 0123456789abcdef0123456789abcdef01234567 --json", "workflow": "product/owned-sets"}, "daemon": { "job": "Run the local control daemon", "why": "Serve local projections and reconciliation", "actor": "operator", "mode": "mutation", "model": "daemon.v0", "inputs": "config and local listeners", "defaults": "Unix socket only", "output": "lifecycle diagnostics", "success": "daemon is ready", "actions": "stop via service manager", "authority": "local operator", "recovery": "doctor then restart", "human_example": "st up", "json_example": "st up --json", "workflow": "operator" }, diff --git a/docs/st3/program-engine.md b/docs/st3/program-engine.md index a89a644e1cd..bfef0751199 100644 --- a/docs/st3/program-engine.md +++ b/docs/st3/program-engine.md @@ -240,6 +240,12 @@ name. The CLI defaults its actor to the run when the credential is present and n trusted Unix client, which the daemon accepts only from a request its credential binds to that same run. The same-UID boundary is cooperative, as for agents. +**Publication.** A run publishes with `fractal apply` like any other author: `apply` and +`fractal missions publish` take `--as mission-run/RUN`, and `apply` without `--as` publishes as +the harness's own seat, else as the run whose credential it holds. Its `publish` rules decide +which missions it may publish. The commands no run authority grants (agent lifecycle, owned sets, +mission retirement, run outcomes) still take only a person or an agent. + **Journal.** The engine writes `program.step` claims as `mission-run/RUN`; a run may always journal on its own step runs, whatever it declares. diff --git a/release-notes/program-attempts-act-as-their-run.json b/release-notes/program-attempts-act-as-their-run.json index 6e70e9e0465..3709c3d2754 100644 --- a/release-notes/program-attempts-act-as-their-run.json +++ b/release-notes/program-attempts-act-as-their-run.json @@ -1,6 +1,6 @@ { "version": 1, - "summary": "Each program attempt starts with a run credential in its effects' environment (`ST_RUN_CREDENTIAL`), so their requests act as `mission-run/RUN`, and the program journal (`program.step`) is written as that run", + "summary": "Each program attempt starts with a run credential in its effects' environment (`ST_RUN_CREDENTIAL`), so their requests act as `mission-run/RUN`, and the program journal (`program.step`) is written as that run. An effect publishes with `fractal apply` as its run: `apply` and `missions publish` accept `--as mission-run/RUN`, and `apply` without `--as` defaults to the run whose credential it holds", "replay": {"status": "changed", "detail": "`program.step` claims carry actor `mission-run/RUN` instead of no actor. Their fields and idempotency keys are unchanged."}, "schema": {"status": "none", "detail": "No table or migration change. Each start or resume of an attempt adds one row to the local `capabilities` table (hashed secret)."}, "rules": {"status": "changed", "detail": "Program effects no longer act as an unbound local person: the engine mints one credential when an attempt starts or resumes (never on a refused, already running or host-down dispatch), with no expiry of its own, and the daemon binds its effects to their run while that attempt is the step's live attempt, however long it waits, and refuses their writes once it has ended."},